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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4478d51aae
commit
c623de9959
@@ -11,6 +11,7 @@
|
|||||||
"db:migrate": "drizzle-kit migrate"
|
"db:migrate": "drizzle-kit migrate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@elysiajs/cors": "1.4.2",
|
||||||
"@tensorflow/tfjs": "4.22.0",
|
"@tensorflow/tfjs": "4.22.0",
|
||||||
"@zeavis/shared": "workspace:*",
|
"@zeavis/shared": "workspace:*",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const googleOAuthEnabled = Boolean(
|
|||||||
Bun.env.GOOGLE_CLIENT_ID && Bun.env.GOOGLE_CLIENT_SECRET && Bun.env.GOOGLE_REDIRECT_URI,
|
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 = {
|
export const env = {
|
||||||
port: Number(Bun.env.API_PORT ?? 3000),
|
port: Number(Bun.env.API_PORT ?? 3000),
|
||||||
databaseUrl: Bun.env.DATABASE_URL,
|
databaseUrl: Bun.env.DATABASE_URL,
|
||||||
@@ -18,7 +21,8 @@ export const env = {
|
|||||||
googleClientId: Bun.env.GOOGLE_CLIENT_ID,
|
googleClientId: Bun.env.GOOGLE_CLIENT_ID,
|
||||||
googleClientSecret: Bun.env.GOOGLE_CLIENT_SECRET,
|
googleClientSecret: Bun.env.GOOGLE_CLIENT_SECRET,
|
||||||
googleRedirectUri: Bun.env.GOOGLE_REDIRECT_URI,
|
googleRedirectUri: Bun.env.GOOGLE_REDIRECT_URI,
|
||||||
webAppUrl: Bun.env.WEB_APP_URL ?? 'http://localhost:5173',
|
webAppUrl,
|
||||||
|
secureCookies,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function assertRequiredEnv() {
|
export function assertRequiredEnv() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Elysia } from 'elysia';
|
import { Elysia } from 'elysia';
|
||||||
|
import cors from '@elysiajs/cors';
|
||||||
import { env, assertRequiredEnv } from './config/env';
|
import { env, assertRequiredEnv } from './config/env';
|
||||||
import { healthRoutes } from './routes/health';
|
import { healthRoutes } from './routes/health';
|
||||||
import { statusRoutes } from './routes/status';
|
import { statusRoutes } from './routes/status';
|
||||||
@@ -12,6 +13,10 @@ import { expertRoutes } from './routes/expert';
|
|||||||
assertRequiredEnv();
|
assertRequiredEnv();
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
|
.use(cors({
|
||||||
|
origin: env.webAppUrl,
|
||||||
|
credentials: true,
|
||||||
|
}))
|
||||||
.use(healthRoutes)
|
.use(healthRoutes)
|
||||||
.use(statusRoutes)
|
.use(statusRoutes)
|
||||||
.use(authRoutes)
|
.use(authRoutes)
|
||||||
|
|||||||
@@ -30,11 +30,13 @@ function hashToken(token: string) {
|
|||||||
|
|
||||||
export function createSessionCookie(token: string) {
|
export function createSessionCookie(token: string) {
|
||||||
const maxAge = 60 * 60 * 24 * 30;
|
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() {
|
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) {
|
export function readSessionToken(cookieHeader: string | null | undefined) {
|
||||||
|
|||||||
@@ -65,34 +65,44 @@ export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' })
|
|||||||
const diagnosis = existing[0];
|
const diagnosis = existing[0];
|
||||||
if (!diagnosis) return notFound('Diagnosis not found');
|
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
|
// Wrap update and insert in a transaction for atomicity
|
||||||
const updated = await db
|
const result = await db.transaction(async (tx) => {
|
||||||
.update(diagnoses)
|
// Perform conditional update first to determine if this review should proceed
|
||||||
.set({
|
const updated = await tx
|
||||||
status: req.verdict === 'corrected' ? 'expert_corrected' : 'expert_verified',
|
.update(diagnoses)
|
||||||
predictedDiseaseSlug: req.verdict === 'corrected' ? correctedSlug : diagnosis.predictedDiseaseSlug,
|
.set({
|
||||||
updatedAt: new Date(),
|
status: verdict === 'corrected' ? 'expert_corrected' : 'expert_verified',
|
||||||
})
|
predictedDiseaseSlug: verdict === 'corrected' ? correctedSlug : diagnosis.predictedDiseaseSlug,
|
||||||
.where(and(eq(diagnoses.id, diagnosis.id), eq(diagnoses.status, 'needs_review')))
|
updatedAt: new Date(),
|
||||||
.returning();
|
})
|
||||||
|
.where(and(eq(diagnoses.id, diagnosis.id), eq(diagnoses.status, 'needs_review')))
|
||||||
|
.returning();
|
||||||
|
|
||||||
if (updated.length === 0) {
|
if (updated.length === 0) {
|
||||||
return badRequest('Diagnosis is not pending review');
|
throw new Error('DIAGNOSIS_NOT_PENDING_REVIEW');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only insert expert review after successful update
|
// Only insert expert review after successful update
|
||||||
await db.insert(expertReviews).values({
|
const reviewValues = {
|
||||||
diagnosisId: diagnosis.id,
|
diagnosisId: diagnosis.id,
|
||||||
expertId: user.id,
|
expertId: user.id,
|
||||||
verdict: req.verdict,
|
verdict,
|
||||||
correctedDiseaseSlug: correctedSlug,
|
correctedDiseaseSlug: correctedSlug,
|
||||||
notes,
|
notes,
|
||||||
|
};
|
||||||
|
await tx.insert(expertReviews).values(reviewValues);
|
||||||
|
|
||||||
|
return updated[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
return await loadDiagnosisRecord(diagnosis.id, null, true);
|
return await loadDiagnosisRecord(diagnosis.id, null, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'DIAGNOSIS_NOT_PENDING_REVIEW') {
|
||||||
|
return badRequest('Diagnosis is not pending review');
|
||||||
|
}
|
||||||
return serviceUnavailable('Database unavailable');
|
return serviceUnavailable('Database unavailable');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"name": "@zeavis/api",
|
"name": "@zeavis/api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@elysiajs/cors": "1.4.2",
|
||||||
"@tensorflow/tfjs": "4.22.0",
|
"@tensorflow/tfjs": "4.22.0",
|
||||||
"@zeavis/shared": "packages/shared",
|
"@zeavis/shared": "packages/shared",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -107,6 +108,8 @@
|
|||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
"@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/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=="],
|
"@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=="],
|
"@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" } }],
|
"@zeavis/shared": ["@zeavis/shared@workspace:packages/shared", { "devDependencies": { "typescript": "^5.6.3" } }],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user