From c623de99591cb1e89ebfcb62844dbc3ce42e34e5 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 17:32:57 +0000 Subject: [PATCH] fix: harden production auth and review flow - Add environment-aware CORS and secure cookie support: * Add secureCookies config to env.ts based on SECURE_COOKIES env var or https detection * Integrate @elysiajs/cors with credentials and origin configuration * Update cookie helpers to use SameSite=None; Secure in production - Wrap expert review update and insert in database transaction for atomicity: * Ensures diagnosis status update and review insert succeed together * Rolls back both operations if either fails * Preserves behavior: only update if status is needs_review, return badRequest if no row updated Co-Authored-By: Claude Opus 4.7 --- apps/api/package.json | 1 + apps/api/src/config/env.ts | 6 +++- apps/api/src/index.ts | 5 ++++ apps/api/src/lib/auth.ts | 6 ++-- apps/api/src/routes/expert.ts | 52 +++++++++++++++++++++-------------- bun.lock | 5 +++- 6 files changed, 50 insertions(+), 25 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index bac60fe..ee15bb3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -11,6 +11,7 @@ "db:migrate": "drizzle-kit migrate" }, "dependencies": { + "@elysiajs/cors": "1.4.2", "@tensorflow/tfjs": "4.22.0", "@zeavis/shared": "workspace:*", "bcryptjs": "^2.4.3", diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index b440e1c..e9814a2 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -7,6 +7,9 @@ const googleOAuthEnabled = Boolean( Bun.env.GOOGLE_CLIENT_ID && Bun.env.GOOGLE_CLIENT_SECRET && Bun.env.GOOGLE_REDIRECT_URI, ); +const webAppUrl = Bun.env.WEB_APP_URL ?? 'http://localhost:5173'; +const secureCookies = Bun.env.SECURE_COOKIES === 'true' || webAppUrl.startsWith('https://'); + export const env = { port: Number(Bun.env.API_PORT ?? 3000), databaseUrl: Bun.env.DATABASE_URL, @@ -18,7 +21,8 @@ export const env = { googleClientId: Bun.env.GOOGLE_CLIENT_ID, googleClientSecret: Bun.env.GOOGLE_CLIENT_SECRET, googleRedirectUri: Bun.env.GOOGLE_REDIRECT_URI, - webAppUrl: Bun.env.WEB_APP_URL ?? 'http://localhost:5173', + webAppUrl, + secureCookies, }; export function assertRequiredEnv() { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c4ea953..2d80cc4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,4 +1,5 @@ import { Elysia } from 'elysia'; +import cors from '@elysiajs/cors'; import { env, assertRequiredEnv } from './config/env'; import { healthRoutes } from './routes/health'; import { statusRoutes } from './routes/status'; @@ -12,6 +13,10 @@ import { expertRoutes } from './routes/expert'; assertRequiredEnv(); const app = new Elysia() + .use(cors({ + origin: env.webAppUrl, + credentials: true, + })) .use(healthRoutes) .use(statusRoutes) .use(authRoutes) diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 543df19..8e90768 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -30,11 +30,13 @@ function hashToken(token: string) { export function createSessionCookie(token: string) { const maxAge = 60 * 60 * 24 * 30; - return `${sessionCookieName}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${maxAge}`; + const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax'; + return `${sessionCookieName}=${token}; HttpOnly; Path=/; ${sameSite}; Max-Age=${maxAge}`; } export function clearSessionCookie() { - return `${sessionCookieName}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`; + const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax'; + return `${sessionCookieName}=; HttpOnly; Path=/; ${sameSite}; Max-Age=0`; } export function readSessionToken(cookieHeader: string | null | undefined) { diff --git a/apps/api/src/routes/expert.ts b/apps/api/src/routes/expert.ts index c922885..632a7cd 100644 --- a/apps/api/src/routes/expert.ts +++ b/apps/api/src/routes/expert.ts @@ -65,34 +65,44 @@ export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' }) const diagnosis = existing[0]; if (!diagnosis) return notFound('Diagnosis not found'); - const correctedSlug = req.verdict === 'corrected' ? req.correctedDiseaseSlug : null; + const verdict = req.verdict as 'verified' | 'corrected'; + const correctedSlug = verdict === 'corrected' ? req.correctedDiseaseSlug : null; - // Perform conditional update first to determine if this review should proceed - const updated = await db - .update(diagnoses) - .set({ - status: req.verdict === 'corrected' ? 'expert_corrected' : 'expert_verified', - predictedDiseaseSlug: req.verdict === 'corrected' ? correctedSlug : diagnosis.predictedDiseaseSlug, - updatedAt: new Date(), - }) - .where(and(eq(diagnoses.id, diagnosis.id), eq(diagnoses.status, 'needs_review'))) - .returning(); + // Wrap update and insert in a transaction for atomicity + const result = await db.transaction(async (tx) => { + // Perform conditional update first to determine if this review should proceed + const updated = await tx + .update(diagnoses) + .set({ + status: verdict === 'corrected' ? 'expert_corrected' : 'expert_verified', + predictedDiseaseSlug: verdict === 'corrected' ? correctedSlug : diagnosis.predictedDiseaseSlug, + updatedAt: new Date(), + }) + .where(and(eq(diagnoses.id, diagnosis.id), eq(diagnoses.status, 'needs_review'))) + .returning(); - if (updated.length === 0) { - return badRequest('Diagnosis is not pending review'); - } + if (updated.length === 0) { + throw new Error('DIAGNOSIS_NOT_PENDING_REVIEW'); + } - // Only insert expert review after successful update - await db.insert(expertReviews).values({ - diagnosisId: diagnosis.id, - expertId: user.id, - verdict: req.verdict, - correctedDiseaseSlug: correctedSlug, - notes, + // Only insert expert review after successful update + const reviewValues = { + diagnosisId: diagnosis.id, + expertId: user.id, + verdict, + correctedDiseaseSlug: correctedSlug, + notes, + }; + await tx.insert(expertReviews).values(reviewValues); + + return updated[0]; }); return await loadDiagnosisRecord(diagnosis.id, null, true); } catch (error) { + if (error instanceof Error && error.message === 'DIAGNOSIS_NOT_PENDING_REVIEW') { + return badRequest('Diagnosis is not pending review'); + } return serviceUnavailable('Database unavailable'); } }); diff --git a/bun.lock b/bun.lock index 0382858..d8ce964 100755 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "name": "@zeavis/api", "version": "0.1.0", "dependencies": { + "@elysiajs/cors": "1.4.2", "@tensorflow/tfjs": "4.22.0", "@zeavis/shared": "packages/shared", "bcryptjs": "^2.4.3", @@ -107,6 +108,8 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@elysiajs/cors": ["@elysiajs/cors@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-FTCcbH35brTLigF1W7BYySRZomgI/dBEMK9BgK9RP9Nez7zmpGh4koL/Yr1BFv8nYz7CfhRvcM8d/c+XnwMaVQ=="], + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], @@ -311,7 +314,7 @@ "@webgpu/types": ["@webgpu/types@0.1.38", "", {}, "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA=="], - "@zeavis/api": ["@zeavis/api@workspace:apps/api", { "dependencies": { "@tensorflow/tfjs": "4.22.0", "@zeavis/shared": "packages/shared", "bcryptjs": "^2.4.3", "drizzle-orm": "^0.36.4", "elysia": "^1.1.25", "jpeg-js": "0.4.4", "pngjs": "7.0.0", "postgres": "^3.4.5" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/bun": "latest", "drizzle-kit": "^0.27.1", "typescript": "^5.6.3" } }], + "@zeavis/api": ["@zeavis/api@workspace:apps/api", { "dependencies": { "@elysiajs/cors": "1.4.2", "@tensorflow/tfjs": "4.22.0", "@zeavis/shared": "packages/shared", "bcryptjs": "^2.4.3", "drizzle-orm": "^0.36.4", "elysia": "^1.1.25", "jpeg-js": "0.4.4", "pngjs": "7.0.0", "postgres": "^3.4.5" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/bun": "latest", "drizzle-kit": "^0.27.1", "typescript": "^5.6.3" } }], "@zeavis/shared": ["@zeavis/shared@workspace:packages/shared", { "devDependencies": { "typescript": "^5.6.3" } }],