initial wcag-ada

This commit is contained in:
Boki 2025-06-28 11:11:34 -04:00
parent 042b8cb83a
commit d52cfe7de2
112 changed files with 9069 additions and 0 deletions

View file

@ -0,0 +1,69 @@
import { Context, Next } from 'hono';
import { verify } from 'hono/jwt';
import { prisma } from '../utils/prisma';
import { config } from '../config';
export const authenticate = async (c: Context, next: Next) => {
try {
// Check for Bearer token
const authHeader = c.req.header('Authorization');
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
const payload = await verify(token, config.JWT_SECRET, 'HS256') as {
sub: string;
email: string;
role: string;
};
c.set('userId', payload.sub);
c.set('userRole', payload.role);
c.set('authType', 'jwt');
return next();
} catch (error) {
// Invalid JWT, continue to check API key
}
}
// Check for API key
const apiKey = c.req.header('X-API-Key');
if (apiKey) {
const user = await prisma.user.findUnique({
where: {
apiKey,
isActive: true,
},
select: {
id: true,
role: true,
},
});
if (user) {
c.set('userId', user.id);
c.set('userRole', user.role);
c.set('authType', 'apiKey');
return next();
}
}
return c.json({ error: 'Unauthorized' }, 401);
} catch (error) {
return c.json({ error: 'Authentication failed' }, 401);
}
};
export const requireRole = (role: string) => {
return async (c: Context, next: Next) => {
const userRole = c.get('userRole');
if (!userRole || userRole !== role) {
return c.json({ error: 'Insufficient permissions' }, 403);
}
return next();
};
};

View file

@ -0,0 +1,59 @@
import { Context } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { ZodError } from 'zod';
import { appConfig } from '../config';
import { logger } from '../utils/logger';
export const errorHandler = (err: Error, c: Context) => {
logger.error('Error:', err);
// Handle Hono HTTP exceptions
if (err instanceof HTTPException) {
return c.json(
{ error: err.message },
err.status
);
}
// Handle Zod validation errors
if (err instanceof ZodError) {
return c.json(
{
error: 'Validation error',
details: err.errors.map(e => ({
path: e.path.join('.'),
message: e.message,
})),
},
400
);
}
// Handle Prisma errors
if (err.constructor.name === 'PrismaClientKnownRequestError') {
const prismaError = err as any;
if (prismaError.code === 'P2002') {
return c.json(
{ error: 'Duplicate entry' },
400
);
}
if (prismaError.code === 'P2025') {
return c.json(
{ error: 'Record not found' },
404
);
}
}
// Default error response
return c.json(
{
error: 'Internal server error',
message: appConfig.env === 'development' ? err.message : undefined,
},
500
);
};

View file

@ -0,0 +1,75 @@
import { Context, Next } from 'hono';
import Redis from 'ioredis';
import { config } from '../config';
import { logger } from '../utils/logger';
import { getWorkerConfig } from '@wcag-ada/config';
const workerConfig = getWorkerConfig();
const redis = new Redis({
host: workerConfig.redis.host,
port: workerConfig.redis.port,
password: workerConfig.redis.password,
db: workerConfig.redis.db,
});
interface RateLimitOptions {
limit?: number;
window?: number; // in milliseconds
keyGenerator?: (c: Context) => string;
}
export const rateLimiter = (options: RateLimitOptions = {}) => {
const {
limit = config.API_RATE_LIMIT,
window = config.API_RATE_WINDOW,
keyGenerator = (c) => {
const userId = c.get('userId');
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown';
return userId ? `rate:user:${userId}` : `rate:ip:${ip}`;
},
} = options;
return async (c: Context, next: Next) => {
const key = keyGenerator(c);
try {
// Get current count
const current = await redis.get(key);
const count = current ? parseInt(current) : 0;
// Check if limit exceeded
if (count >= limit) {
const ttl = await redis.pttl(key);
c.header('X-RateLimit-Limit', limit.toString());
c.header('X-RateLimit-Remaining', '0');
c.header('X-RateLimit-Reset', new Date(Date.now() + ttl).toISOString());
return c.json(
{
error: 'Rate limit exceeded',
retryAfter: Math.ceil(ttl / 1000),
},
429
);
}
// Increment counter
const pipeline = redis.pipeline();
pipeline.incr(key);
pipeline.pexpire(key, window);
await pipeline.exec();
// Set headers
c.header('X-RateLimit-Limit', limit.toString());
c.header('X-RateLimit-Remaining', (limit - count - 1).toString());
c.header('X-RateLimit-Reset', new Date(Date.now() + window).toISOString());
return next();
} catch (error) {
logger.error('Rate limiter error:', error);
// Continue on error - don't block requests due to rate limiter failure
return next();
}
};
};