fix: resolve TypeScript errors for CI - add auth middleware plugin
All checks were successful
CI/CD / check (push) Successful in 51s
CI/CD / deploy (push) Successful in 1s

- Create shared authMiddleware plugin with scoped derive for proper type propagation
- Each route file now uses authMiddleware instead of relying on parent derive
- Fix error handler to use instanceof Error checks for message/stack access
- Fix null vs undefined type mismatch in hammer route auth validation
- Fix invite role type assertion for enum compatibility
- Fix test type assertions to avoid impossible comparisons
This commit is contained in:
2026-01-30 02:57:07 +00:00
parent 9316461d6e
commit c965fdd06f
10 changed files with 46 additions and 29 deletions

View File

@@ -35,7 +35,7 @@ describe('Invite System', () => {
const invite = { const invite = {
token: 'valid-token', token: 'valid-token',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
status: 'accepted' as const, status: 'accepted' as string,
}; };
const canAccept = invite.status === 'pending'; const canAccept = invite.status === 'pending';
@@ -76,13 +76,13 @@ describe('Invite System', () => {
describe('Admin Access', () => { describe('Admin Access', () => {
test('should allow admin to create invites', () => { test('should allow admin to create invites', () => {
const user = { role: 'admin' as const }; const user: { role: string } = { role: 'admin' };
const canInvite = user.role === 'admin'; const canInvite = user.role === 'admin';
expect(canInvite).toBe(true); expect(canInvite).toBe(true);
}); });
test('should deny regular users from creating invites', () => { test('should deny regular users from creating invites', () => {
const user = { role: 'user' as const }; const user: { role: string } = { role: 'user' };
const canInvite = user.role === 'admin'; const canInvite = user.role === 'admin';
expect(canInvite).toBe(false); expect(canInvite).toBe(false);
}); });

View File

@@ -8,8 +8,6 @@ import { taskRoutes } from './routes/tasks';
import { labelRoutes } from './routes/labels'; import { labelRoutes } from './routes/labels';
import { commentRoutes } from './routes/comments'; import { commentRoutes } from './routes/comments';
import { hammerRoutes } from './routes/hammer'; import { hammerRoutes } from './routes/hammer';
import type { User } from './lib/auth';
const app = new Elysia() const app = new Elysia()
// CORS // CORS
.use(cors({ .use(cors({
@@ -39,21 +37,7 @@ const app = new Elysia()
// Hammer API (uses separate API key auth) // Hammer API (uses separate API key auth)
.group('/api', app => app.use(hammerRoutes)) .group('/api', app => app.use(hammerRoutes))
// Protected routes - require auth // Authenticated API routes (auth middleware is in each route plugin)
.derive(async ({ request, set }): Promise<{ user: User }> => {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
set.status = 401;
throw new Error('Unauthorized');
}
return { user: session.user as User };
})
// Authenticated API routes
.group('/api', app => app .group('/api', app => app
.use(adminRoutes) .use(adminRoutes)
.use(projectRoutes) .use(projectRoutes)
@@ -64,30 +48,33 @@ const app = new Elysia()
// Error handler // Error handler
.onError(({ code, error, set, path }) => { .onError(({ code, error, set, path }) => {
const message = error instanceof Error ? error.message : String(error);
const stack = error instanceof Error ? error.stack : undefined;
console.error(`[${new Date().toISOString()}] ERROR on ${path}:`, { console.error(`[${new Date().toISOString()}] ERROR on ${path}:`, {
code, code,
message: error.message, message,
stack: process.env.NODE_ENV !== 'production' ? error.stack : undefined, stack: process.env.NODE_ENV !== 'production' ? stack : undefined,
}); });
if (code === 'VALIDATION') { if (code === 'VALIDATION') {
set.status = 400; set.status = 400;
return { error: 'Validation error', details: error.message }; return { error: 'Validation error', details: message };
} }
if (error.message === 'Unauthorized') { if (message === 'Unauthorized') {
set.status = 401; set.status = 401;
return { error: 'Unauthorized' }; return { error: 'Unauthorized' };
} }
if (error.message === 'Admin access required') { if (message === 'Admin access required') {
set.status = 403; set.status = 403;
return { error: 'Forbidden: Admin access required' }; return { error: 'Forbidden: Admin access required' };
} }
if (error.message.includes('not found')) { if (message.includes('not found')) {
set.status = 404; set.status = 404;
return { error: error.message }; return { error: message };
} }
set.status = 500; set.status = 500;

View File

@@ -0,0 +1,20 @@
import { Elysia } from 'elysia';
import { auth, type User } from '../lib/auth';
/**
* Auth middleware plugin - adds `user` to the Elysia context.
* Import and `.use(authMiddleware)` in route files that need authentication.
*/
export const authMiddleware = new Elysia({ name: 'auth-middleware' })
.derive({ as: 'scoped' }, async ({ request, set }): Promise<{ user: User }> => {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
set.status = 401;
throw new Error('Unauthorized');
}
return { user: session.user as User };
});

View File

@@ -6,8 +6,10 @@ import { sendInviteEmail } from '../lib/email';
import { auth } from '../lib/auth'; import { auth } from '../lib/auth';
import type { User } from '../lib/auth'; import type { User } from '../lib/auth';
import crypto from 'crypto'; import crypto from 'crypto';
import { authMiddleware } from '../middleware/auth';
export const adminRoutes = new Elysia({ prefix: '/admin' }) export const adminRoutes = new Elysia({ prefix: '/admin' })
.use(authMiddleware)
// Middleware: require admin role // Middleware: require admin role
.derive(({ user, set }) => { .derive(({ user, set }) => {
if ((user as User).role !== 'admin') { if ((user as User).role !== 'admin') {

View File

@@ -78,7 +78,7 @@ export const authRoutes = new Elysia({ prefix: '/auth' })
if (newUser) { if (newUser) {
// Set role from invite if specified // Set role from invite if specified
if (invite.role && invite.role !== 'user') { if (invite.role && invite.role !== 'user') {
await db.update(users).set({ role: invite.role }).where(eq(users.id, newUser.id)); await db.update(users).set({ role: invite.role as 'admin' | 'user' | 'service' }).where(eq(users.id, newUser.id));
} }
// Create default inbox project // Create default inbox project

View File

@@ -3,8 +3,10 @@ import { db } from '../db';
import { comments, tasks } from '../db/schema'; import { comments, tasks } from '../db/schema';
import { eq, and, asc } from 'drizzle-orm'; import { eq, and, asc } from 'drizzle-orm';
import type { User } from '../lib/auth'; import type { User } from '../lib/auth';
import { authMiddleware } from '../middleware/auth';
export const commentRoutes = new Elysia({ prefix: '/comments' }) export const commentRoutes = new Elysia({ prefix: '/comments' })
.use(authMiddleware)
// Get comments for a task // Get comments for a task
.get('/task/:taskId', async ({ params, user, set }) => { .get('/task/:taskId', async ({ params, user, set }) => {
// Verify task ownership // Verify task ownership

View File

@@ -8,7 +8,7 @@ import { auth } from '../lib/auth';
// This route uses bearer token auth for Hammer (service account) // This route uses bearer token auth for Hammer (service account)
// The token is set in HAMMER_API_KEY env var // The token is set in HAMMER_API_KEY env var
const validateHammerAuth = (authHeader: string | undefined): boolean => { const validateHammerAuth = (authHeader: string | null | undefined): boolean => {
if (!authHeader) return false; if (!authHeader) return false;
const token = authHeader.replace('Bearer ', ''); const token = authHeader.replace('Bearer ', '');
return token === process.env.HAMMER_API_KEY; return token === process.env.HAMMER_API_KEY;

View File

@@ -3,8 +3,10 @@ import { db } from '../db';
import { labels, taskLabels } from '../db/schema'; import { labels, taskLabels } from '../db/schema';
import { eq, asc, sql } from 'drizzle-orm'; import { eq, asc, sql } from 'drizzle-orm';
import type { User } from '../lib/auth'; import type { User } from '../lib/auth';
import { authMiddleware } from '../middleware/auth';
export const labelRoutes = new Elysia({ prefix: '/labels' }) export const labelRoutes = new Elysia({ prefix: '/labels' })
.use(authMiddleware)
// List all labels for user // List all labels for user
.get('/', async ({ user }) => { .get('/', async ({ user }) => {
const userLabels = await db.query.labels.findMany({ const userLabels = await db.query.labels.findMany({

View File

@@ -3,8 +3,10 @@ import { db } from '../db';
import { projects, sections } from '../db/schema'; import { projects, sections } from '../db/schema';
import { eq, and, asc, desc } from 'drizzle-orm'; import { eq, and, asc, desc } from 'drizzle-orm';
import type { User } from '../lib/auth'; import type { User } from '../lib/auth';
import { authMiddleware } from '../middleware/auth';
export const projectRoutes = new Elysia({ prefix: '/projects' }) export const projectRoutes = new Elysia({ prefix: '/projects' })
.use(authMiddleware)
// List all projects for user // List all projects for user
.get('/', async ({ user }) => { .get('/', async ({ user }) => {
const userProjects = await db.query.projects.findMany({ const userProjects = await db.query.projects.findMany({

View File

@@ -3,6 +3,7 @@ import { db } from '../db';
import { tasks, taskLabels, projects, activityLog, hammerWebhooks } from '../db/schema'; import { tasks, taskLabels, projects, activityLog, hammerWebhooks } from '../db/schema';
import { eq, and, or, asc, desc, isNull, gte, lte, sql } from 'drizzle-orm'; import { eq, and, or, asc, desc, isNull, gte, lte, sql } from 'drizzle-orm';
import type { User } from '../lib/auth'; import type { User } from '../lib/auth';
import { authMiddleware } from '../middleware/auth';
// Helper to trigger Hammer webhooks // Helper to trigger Hammer webhooks
async function triggerHammerWebhooks(event: string, payload: Record<string, unknown>) { async function triggerHammerWebhooks(event: string, payload: Record<string, unknown>) {
@@ -46,6 +47,7 @@ async function logActivity(params: {
} }
export const taskRoutes = new Elysia({ prefix: '/tasks' }) export const taskRoutes = new Elysia({ prefix: '/tasks' })
.use(authMiddleware)
// List tasks with filters // List tasks with filters
.get('/', async ({ user, query }) => { .get('/', async ({ user, query }) => {
const userId = (user as User).id; const userId = (user as User).id;