diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41c517a..cdd695d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,7 @@ jobs: env: JWT_SECRET: ci-only-do-not-use-in-prod-abcdef1234567890 DATABASE_URL: postgresql://postgres:postgres@localhost:5432/interviewlab_test + NEXT_PUBLIC_APP_URL: http://localhost:3000 # Auth/API rate limits are tuned tight for production; the live-server # integration suite below makes far more requests per minute than a # real user would, so we relax the limits for this job only. @@ -55,8 +56,8 @@ jobs: - name: ESLint run: bun run lint - - name: Push database schema - run: bun run db:push + - name: Apply database migrations + run: bun run db:deploy - name: Seed database run: bun run db:seed @@ -91,4 +92,4 @@ jobs: - name: Secret scan (gitleaks) uses: gitleaks/gitleaks-action@v2 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 7de0610..e7bae68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,7 @@ bun run test:api # API tests only bun run lint # ESLint bun run db:push # Schema push (dev) bun run db:migrate # Create migration +bun run db:deploy # Apply committed migrations bun run db:reset # Reset + seed ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e3a51b..0f4fbe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,17 @@ Record user-visible and operationally meaningful changes. Use semantic versionin - Standard documentation profile created: BUILD_LOG, BUILD_SPEC, PRD, ROADMAP, TESTING, SECURITY, DEPLOYMENT, ENGINEERING_DIARY, ERROR_LOG, LOOP_ENGINEERING. - CONTRIBUTING.md and CHANGELOG.md added. - PR template added to `.github/pull_request_template.md`. +- Revocation-aware session versions and `POST /api/auth/logout-all`. +- Prisma migration baseline and incremental session-version migration. ### Changed -- No released behavior has changed yet. +- CI now applies committed Prisma migrations instead of inferring schema changes with `db push`. +- Session restoration resolves the current database user and rejects revoked token versions. ### Fixed -- No fixes recorded yet. +- Admin question, guide, and download mutations now reject missing, opaque, or cross-origin browser + requests before database writes. +- Persistent login and registration rate limits no longer trust spoofable forwarding headers. +- Middleware rate limits use only the verified hosting proxy identity contract. diff --git a/CLAUDE.md b/CLAUDE.md index 6cfc213..59b4aed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,12 +27,13 @@ bun test -t "creates user with valid email" # run tests matching a name bun run db:push # push prisma/schema.prisma to Postgres (dev) bun run db:migrate # create + apply a migration +bun run db:deploy # apply committed migrations (CI/production) bun run db:reset # reset db and reseed bun run db:seed # tsx prisma/seed.ts bun run db:generate # regenerate Prisma client ``` -Bun is the package manager (`bun.lock` is committed — don't add a `package-lock.json`). CI (`.github/workflows/ci.yml`) also uses Bun; it runs `tsc --noEmit`, `lint`, `db:push`, `db:seed`, `bun run test`, `bun run build`, then boots the built server and runs a live-server integration subset (`__tests__/api/auth.test.ts`, `resources.test.ts`, `questions-interview-ai.test.ts`, `user-paths.test.ts`) against it before a gitleaks secret scan. Match that sequence when validating a change end-to-end. +Bun is the package manager (`bun.lock` is committed — don't add a `package-lock.json`). CI (`.github/workflows/ci.yml`) also uses Bun; it runs `tsc --noEmit`, `lint`, `db:deploy`, `db:seed`, `bun run test`, `bun run build`, then boots the built server and runs a live-server integration subset (`__tests__/api/auth.test.ts`, `resources.test.ts`, `questions-interview-ai.test.ts`, `user-paths.test.ts`) against it before a gitleaks secret scan. Match that sequence when validating a change end-to-end. Database is **PostgreSQL only** (`prisma/schema.prisma` `provider = "postgresql"`) — despite older docs mentioning SQLite for MVP/dev, do not reintroduce SQLite. diff --git a/__tests__/api/admin-content-mutations.test.ts b/__tests__/api/admin-content-mutations.test.ts new file mode 100644 index 0000000..5f59216 --- /dev/null +++ b/__tests__/api/admin-content-mutations.test.ts @@ -0,0 +1,76 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getUserFromRequest = vi.fn(); +const guideCreate = vi.fn(); +const guideUpdate = vi.fn(); +const downloadCreate = vi.fn(); + +vi.mock('@/lib/auth-helpers', () => ({ + getUserFromRequest: (...args: unknown[]) => getUserFromRequest(...args), +})); + +vi.mock('@/lib/db', () => ({ + db: { + guide: { + create: (...args: unknown[]) => guideCreate(...args), + update: (...args: unknown[]) => guideUpdate(...args), + }, + download: { + create: (...args: unknown[]) => downloadCreate(...args), + }, + }, +})); + +import { POST as createGuide } from '@/app/api/guides/route'; +import { PUT as updateGuide } from '@/app/api/guides/[id]/route'; +import { POST as createDownload } from '@/app/api/downloads/route'; + +function request(path: string, method: 'POST' | 'PUT', body: object, origin?: string): Request { + const headers = new Headers({ 'content-type': 'application/json' }); + if (origin) headers.set('origin', origin); + return new Request(`https://interview-lab.example${path}`, { + method, + headers, + body: JSON.stringify(body), + }); +} + +describe('admin content mutation origin protection', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.NODE_ENV = 'test'; + delete process.env.NEXT_PUBLIC_APP_URL; + getUserFromRequest.mockResolvedValue({ id: 'admin-1', isAdmin: true }); + }); + + it.each([ + ['guide creation', () => createGuide(request('/api/guides', 'POST', { + title: 'Guide', slug: 'guide', content: 'Content', + })), guideCreate], + ['guide update', () => updateGuide( + request('/api/guides/guide-1', 'PUT', { title: 'Updated' }), + { params: Promise.resolve({ id: 'guide-1' }) }, + ), guideUpdate], + ['download creation', () => createDownload(request('/api/downloads', 'POST', { + title: 'Template', + })), downloadCreate], + ] as const)('rejects %s without an Origin before writing', async (_label, invoke, write) => { + const response = await invoke(); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'Forbidden — untrusted request origin' }); + expect(write).not.toHaveBeenCalled(); + }); + + it('allows same-origin guide creation', async () => { + guideCreate.mockResolvedValue({ id: 'guide-1', title: 'Guide' }); + + const response = await createGuide(request('/api/guides', 'POST', { + title: 'Guide', slug: 'guide', content: 'Content', + }, 'https://interview-lab.example')); + + expect(response.status).toBe(201); + expect(guideCreate).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/api/admin-questions.test.ts b/__tests__/api/admin-questions.test.ts new file mode 100644 index 0000000..30e338b --- /dev/null +++ b/__tests__/api/admin-questions.test.ts @@ -0,0 +1,73 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getUserFromRequest = vi.fn(); +const questionCreate = vi.fn(); +const questionUpdate = vi.fn(); + +vi.mock('@/lib/auth-helpers', () => ({ + getUserFromRequest: (...args: unknown[]) => getUserFromRequest(...args), +})); + +vi.mock('@/lib/db', () => ({ + db: { + question: { + create: (...args: unknown[]) => questionCreate(...args), + update: (...args: unknown[]) => questionUpdate(...args), + }, + }, +})); + +import { POST, PUT } from '@/app/api/admin/questions/route'; + +function mutationRequest(method: 'POST' | 'PUT', body: object, origin?: string): Request { + const headers = new Headers({ 'content-type': 'application/json' }); + if (origin) { + headers.set('origin', origin); + headers.set('sec-fetch-site', 'same-origin'); + } + + return new Request('https://interview-lab.example/api/admin/questions', { + method, + headers, + body: JSON.stringify(body), + }); +} + +describe('admin question mutation origin protection', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.NODE_ENV = 'test'; + delete process.env.NEXT_PUBLIC_APP_URL; + getUserFromRequest.mockResolvedValue({ id: 'admin-1', isAdmin: true }); + }); + + it.each([ + ['POST', POST, { question: 'How do you optimize bids?' }, questionCreate], + ['PUT', PUT, { id: 'question-1', status: 'published' }, questionUpdate], + ] as const)('rejects %s requests without an Origin before writing', async ( + method, + handler, + body, + write, + ) => { + const response = await handler(mutationRequest(method, body)); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: 'Forbidden — untrusted request origin' }); + expect(write).not.toHaveBeenCalled(); + }); + + it('allows a same-origin admin to create a question', async () => { + questionCreate.mockResolvedValue({ id: 'question-1', question: 'How do you optimize bids?' }); + + const response = await POST(mutationRequest( + 'POST', + { question: 'How do you optimize bids?' }, + 'https://interview-lab.example', + )); + + expect(response.status).toBe(201); + expect(questionCreate).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/api/auth-login.test.ts b/__tests__/api/auth-login.test.ts index 3281aa0..3592121 100644 --- a/__tests__/api/auth-login.test.ts +++ b/__tests__/api/auth-login.test.ts @@ -64,11 +64,13 @@ const baseUser = { subscriptionTier: 'free', isAdmin: false, emailVerified: false, + sessionVersion: 4, profile: null, }; describe('POST /api/auth/login', () => { beforeEach(() => { + process.env.TRUSTED_CLIENT_IP_HEADER = 'x-interview-lab-test-client-ip'; findUnique.mockReset(); update.mockReset(); verifyPassword.mockReset(); @@ -149,14 +151,20 @@ describe('POST /api/auth/login', () => { expect(await res.json()).not.toHaveProperty('passwordHash'); }); - it('creates a session cookie whose payload matches the user (sub/email/tier/isAdmin)', async () => { + it('creates a session cookie whose payload includes the current session version', async () => { findUnique.mockResolvedValue({ ...baseUser, id: 'u3', email: 'admin@test.com', subscriptionTier: 'pro', isAdmin: true }); verifyPassword.mockResolvedValue(true); const res = await login(req({ email: 'admin@test.com', password: 'correct-password' })); const setCookie = res.headers.get('set-cookie')!; const token = setCookie.match(/interviewlab_session=([^;]+)/)![1]; const payload = await verifyToken(token); - expect(payload).toMatchObject({ sub: 'u3', email: 'admin@test.com', tier: 'pro', isAdmin: true }); + expect(payload).toMatchObject({ + sub: 'u3', + email: 'admin@test.com', + tier: 'pro', + isAdmin: true, + sessionVersion: 4, + }); }); it('sanitizes email: trims whitespace and lowercases before the db lookup', async () => { @@ -210,20 +218,19 @@ describe('POST /api/auth/login', () => { expect(findUnique).not.toHaveBeenCalled(); }); - it('keys the rate limiter by x-forwarded-for (first IP) when present', async () => { + it('keys the rate limiter by the trusted proxy header and ignores x-forwarded-for', async () => { checkRateLimit.mockResolvedValue({ allowed: true, remaining: 9 }); findUnique.mockResolvedValue(null); - await login(req({ email: 'x@x.com', password: 'p' }, { 'x-forwarded-for': '1.2.3.4, 5.6.7.8' })); - expect(checkRateLimit).toHaveBeenCalledWith('1.2.3.4', 'auth-login', expect.any(Number), expect.any(Number)); + await login(req({ email: 'x@x.com', password: 'p' }, { + 'x-interview-lab-test-client-ip': '203.0.113.10', + 'x-forwarded-for': '1.2.3.4, 5.6.7.8', + })); + expect(checkRateLimit).toHaveBeenCalledWith('203.0.113.10', 'auth-login', expect.any(Number), expect.any(Number)); }); - it('falls back to x-real-ip, then "unknown", for rate-limit keying', async () => { + it('falls back to "unknown" rather than trusting x-real-ip', async () => { findUnique.mockResolvedValue(null); await login(req({ email: 'x@x.com', password: 'p' }, { 'x-real-ip': '10.0.0.1' })); - expect(checkRateLimit).toHaveBeenCalledWith('10.0.0.1', 'auth-login', expect.any(Number), expect.any(Number)); - - checkRateLimit.mockClear(); - await login(req({ email: 'x@x.com', password: 'p' })); expect(checkRateLimit).toHaveBeenCalledWith('unknown', 'auth-login', expect.any(Number), expect.any(Number)); }); }); diff --git a/__tests__/api/auth-logout-all.test.ts b/__tests__/api/auth-logout-all.test.ts new file mode 100644 index 0000000..2fc49c5 --- /dev/null +++ b/__tests__/api/auth-logout-all.test.ts @@ -0,0 +1,57 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const getUserFromRequest = vi.fn(); +const revokeAllUserSessions = vi.fn(); + +vi.mock('@/lib/auth-helpers', () => ({ + getUserFromRequest: (...args: unknown[]) => getUserFromRequest(...args), +})); + +vi.mock('@/lib/session-revocation', () => ({ + revokeAllUserSessions: (...args: unknown[]) => revokeAllUserSessions(...args), +})); + +import { POST } from '@/app/api/auth/logout-all/route'; + +function request(): Request { + return new Request('http://localhost/api/auth/logout-all', { method: 'POST' }); +} + +describe('POST /api/auth/logout-all', () => { + beforeEach(() => { + vi.clearAllMocks(); + revokeAllUserSessions.mockResolvedValue(undefined); + }); + + it('rejects an unauthenticated request without changing session state', async () => { + getUserFromRequest.mockResolvedValue(null); + + const response = await POST(request()); + + expect(response.status).toBe(401); + expect(revokeAllUserSessions).not.toHaveBeenCalled(); + }); + + it('revokes every user session and clears the current cookie', async () => { + getUserFromRequest.mockResolvedValue({ id: 'user-123456' }); + + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(revokeAllUserSessions).toHaveBeenCalledWith('user-123456'); + expect(response.headers.get('set-cookie')).toContain('interviewlab_session='); + expect(response.headers.get('set-cookie')).toMatch(/Max-Age=0/i); + }); + + it('returns a generic error without clearing the cookie when revocation fails', async () => { + getUserFromRequest.mockResolvedValue({ id: 'user-123456' }); + revokeAllUserSessions.mockRejectedValue(new Error('database unavailable')); + + const response = await POST(request()); + + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ error: 'Failed to revoke sessions' }); + expect(response.headers.get('set-cookie')).toBeNull(); + }); +}); diff --git a/__tests__/api/auth-register.test.ts b/__tests__/api/auth-register.test.ts index 93f47d2..698fd3a 100644 --- a/__tests__/api/auth-register.test.ts +++ b/__tests__/api/auth-register.test.ts @@ -43,10 +43,10 @@ vi.mock('@/lib/rate-limit', () => ({ import { POST as register } from '@/app/api/auth/register/route'; -function req(body: unknown) { +function req(body: unknown, headers: Record = {}) { return new Request('http://localhost/api/auth/register', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }); } @@ -55,6 +55,7 @@ let nextId = 0; describe('POST /api/auth/register', () => { beforeEach(() => { + process.env.TRUSTED_CLIENT_IP_HEADER = 'x-interview-lab-test-client-ip'; delete process.env.MAX_USERS; nextId = 0; findUnique.mockReset(); @@ -78,6 +79,7 @@ describe('POST /api/auth/register', () => { subscriptionTier: 'free', isAdmin: false, emailVerified: false, + sessionVersion: 1, })); }); @@ -236,7 +238,12 @@ describe('POST /api/auth/register', () => { const res = await register(req({ email: 'newuser@example.com', password: 'password123' })); const token = res.headers.get('set-cookie')!.match(/interviewlab_session=([^;]+)/)![1]; const payload = await verifyToken(token); - expect(payload).toMatchObject({ email: 'newuser@example.com', tier: 'free', isAdmin: false }); + expect(payload).toMatchObject({ + email: 'newuser@example.com', + tier: 'free', + isAdmin: false, + sessionVersion: 1, + }); }); it('creates a profile row alongside the user', async () => { @@ -257,6 +264,24 @@ describe('POST /api/auth/register', () => { expect(create).not.toHaveBeenCalled(); }); + it('keys the limiter by the trusted proxy header and ignores spoofed forwarding headers', async () => { + await register(req( + { email: 'newuser@example.com', password: 'password123' }, + { + 'x-interview-lab-test-client-ip': '203.0.113.11', + 'x-forwarded-for': '1.2.3.4', + 'x-real-ip': '5.6.7.8', + }, + )); + + expect(checkRateLimit).toHaveBeenCalledWith( + '203.0.113.11', + 'auth-register', + expect.any(Number), + expect.any(Number), + ); + }); + it('returns 500 when the database throws unexpectedly', async () => { create.mockRejectedValue(new Error('db down')); const res = await register(req({ email: 'test@example.com', password: 'password123' })); diff --git a/__tests__/api/auth-session.test.ts b/__tests__/api/auth-session.test.ts new file mode 100644 index 0000000..a115ac6 --- /dev/null +++ b/__tests__/api/auth-session.test.ts @@ -0,0 +1,71 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const getUserFromRequest = vi.fn(); +const verifySession = vi.fn(); +const userFindUnique = vi.fn(); + +vi.mock('@/lib/auth-helpers', () => ({ + getUserFromRequest: (...args: unknown[]) => getUserFromRequest(...args), +})); + +vi.mock('@/lib/session', () => ({ + verifySession: (...args: unknown[]) => verifySession(...args), +})); + +vi.mock('@/lib/db', () => ({ + db: { + user: { + findUnique: (...args: unknown[]) => userFindUnique(...args), + }, + }, +})); + +import { GET } from '@/app/api/auth/session/route'; + +function request(): NextRequest { + return new NextRequest('http://localhost/api/auth/session', { + headers: { cookie: 'interviewlab_session=signed-token' }, + }); +} + +describe('GET /api/auth/session', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the current revocation-aware database user', async () => { + getUserFromRequest.mockResolvedValue({ + id: 'user-123456', + email: 'user@example.com', + name: 'Test User', + subscriptionTier: 'free', + isAdmin: true, + emailVerified: true, + }); + + const response = await GET(request()); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + user: { id: 'user-123456', isAdmin: true }, + }); + }); + + it('returns no user when the centralized auth boundary rejects a revoked token', async () => { + getUserFromRequest.mockResolvedValue(null); + verifySession.mockResolvedValue({ sub: 'user-123456', sessionVersion: 1 }); + userFindUnique.mockResolvedValue({ + id: 'user-123456', + email: 'user@example.com', + isAdmin: true, + sessionVersion: 2, + }); + + const response = await GET(request()); + + expect(await response.json()).toEqual({ user: null }); + expect(userFindUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/api/auth.test.ts b/__tests__/api/auth.test.ts index 6738d3e..3a23733 100644 --- a/__tests__/api/auth.test.ts +++ b/__tests__/api/auth.test.ts @@ -7,7 +7,7 @@ const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; describe('Auth API', () => { - const testIfServer = process.env.CI && !process.env.TEST_BASE_URL ? it.skip : it; + const testIfServer = process.env.TEST_BASE_URL ? it : it.skip; async function api(method: string, path: string, body?: unknown, headers?: Record) { const opts: RequestInit = { diff --git a/__tests__/api/questions-interview-ai.test.ts b/__tests__/api/questions-interview-ai.test.ts index 797b6e8..a52ed3b 100644 --- a/__tests__/api/questions-interview-ai.test.ts +++ b/__tests__/api/questions-interview-ai.test.ts @@ -8,8 +8,8 @@ const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; -// Skip integration tests in CI unless a live server is provided via TEST_BASE_URL -const testIfServer = process.env.CI && !process.env.TEST_BASE_URL ? it.skip : it; +// Live-server tests are opt-in so the default unit suite is deterministic. +const testIfServer = process.env.TEST_BASE_URL ? it : it.skip; function extractSessionCookie(setCookieHeader: string | null): string | undefined { if (!setCookieHeader) return undefined; diff --git a/__tests__/api/resources.test.ts b/__tests__/api/resources.test.ts index dfcf1ff..d37619a 100644 --- a/__tests__/api/resources.test.ts +++ b/__tests__/api/resources.test.ts @@ -8,8 +8,8 @@ const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; -// Skip integration tests in CI unless a live server is provided via TEST_BASE_URL -const testIfServer = process.env.CI && !process.env.TEST_BASE_URL ? it.skip : it; +// Live-server tests are opt-in so the default unit suite is deterministic. +const testIfServer = process.env.TEST_BASE_URL ? it : it.skip; function extractSessionCookie(setCookieHeader: string | null): string | undefined { if (!setCookieHeader) return undefined; @@ -19,7 +19,7 @@ function extractSessionCookie(setCookieHeader: string | null): string | undefine async function api(method: string, path: string, body?: unknown, headers?: Record) { const opts: RequestInit = { method, - headers: { 'Content-Type': 'application/json', ...headers }, + headers: { 'Content-Type': 'application/json', Origin: new URL(BASE_URL).origin, ...headers }, }; if (body) opts.body = JSON.stringify(body); const res = await fetch(`${BASE_URL}${path}`, opts); diff --git a/__tests__/api/user-paths.test.ts b/__tests__/api/user-paths.test.ts index ab9f01d..cd7bf14 100644 --- a/__tests__/api/user-paths.test.ts +++ b/__tests__/api/user-paths.test.ts @@ -9,8 +9,8 @@ const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000'; -// Skip integration tests in CI unless a live server is provided via TEST_BASE_URL -const testIfServer = process.env.CI && !process.env.TEST_BASE_URL ? it.skip : it; +// Live-server tests are opt-in so the default unit suite is deterministic. +const testIfServer = process.env.TEST_BASE_URL ? it : it.skip; function extractSessionCookie(setCookieHeader: string | null): string | undefined { if (!setCookieHeader) return undefined; @@ -20,7 +20,7 @@ function extractSessionCookie(setCookieHeader: string | null): string | undefine async function api(method: string, path: string, body?: unknown, headers?: Record) { const opts: RequestInit = { method, - headers: { 'Content-Type': 'application/json', ...headers }, + headers: { 'Content-Type': 'application/json', Origin: new URL(BASE_URL).origin, ...headers }, }; if (body) opts.body = JSON.stringify(body); const res = await fetch(`${BASE_URL}${path}`, opts); diff --git a/__tests__/lib/auth-helpers.test.ts b/__tests__/lib/auth-helpers.test.ts new file mode 100644 index 0000000..e394466 --- /dev/null +++ b/__tests__/lib/auth-helpers.test.ts @@ -0,0 +1,64 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const verifyToken = vi.fn(); +const userFindUnique = vi.fn(); + +vi.mock('@/lib/session', () => ({ + TOKEN_NAME: 'interviewlab_session', + verifyToken: (...args: unknown[]) => verifyToken(...args), +})); + +vi.mock('@/lib/db', () => ({ + db: { + user: { + findUnique: (...args: unknown[]) => userFindUnique(...args), + }, + }, +})); + +import { getUserFromRequest } from '@/lib/auth-helpers'; + +function requestWithSession(): NextRequest { + return new NextRequest('http://localhost/api/profile', { + headers: { cookie: 'interviewlab_session=signed-token' }, + }); +} + +const databaseUser = { + id: 'user-123456', + email: 'user@example.com', + name: 'Test User', + subscriptionTier: 'free', + isAdmin: false, + emailVerified: true, + sessionVersion: 2, +}; + +describe('getUserFromRequest session revocation', () => { + beforeEach(() => { + vi.clearAllMocks(); + verifyToken.mockResolvedValue({ + sub: databaseUser.id, + email: databaseUser.email, + tier: 'free', + isAdmin: false, + sessionVersion: 2, + }); + userFindUnique.mockResolvedValue(databaseUser); + }); + + it('returns the current database user when the session version matches', async () => { + await expect(getUserFromRequest(requestWithSession())).resolves.toMatchObject({ + id: databaseUser.id, + isAdmin: false, + }); + }); + + it('rejects a previously valid token after the database session version changes', async () => { + userFindUnique.mockResolvedValue({ ...databaseUser, sessionVersion: 3 }); + + await expect(getUserFromRequest(requestWithSession())).resolves.toBeNull(); + }); +}); diff --git a/__tests__/lib/client-ip.test.ts b/__tests__/lib/client-ip.test.ts new file mode 100644 index 0000000..2a509e5 --- /dev/null +++ b/__tests__/lib/client-ip.test.ts @@ -0,0 +1,69 @@ +/** @vitest-environment node */ +import { afterEach, describe, expect, it } from 'vitest'; +import { getTrustedClientIp } from '@/lib/client-ip'; + +const originalVercel = process.env.VERCEL; +const originalTrustedHeader = process.env.TRUSTED_CLIENT_IP_HEADER; + +function headers(values: Record): Headers { + return new Headers(values); +} + +afterEach(() => { + if (originalVercel === undefined) delete process.env.VERCEL; + else process.env.VERCEL = originalVercel; + if (originalTrustedHeader === undefined) delete process.env.TRUSTED_CLIENT_IP_HEADER; + else process.env.TRUSTED_CLIENT_IP_HEADER = originalTrustedHeader; +}); + +describe('getTrustedClientIp', () => { + it('ignores forwarding headers when no trusted proxy is configured', () => { + delete process.env.VERCEL; + delete process.env.TRUSTED_CLIENT_IP_HEADER; + + expect(getTrustedClientIp(headers({ + 'x-forwarded-for': '198.51.100.10', + 'x-real-ip': '198.51.100.11', + }))).toBe('unknown'); + }); + + it('uses the Vercel-managed client address on Vercel', () => { + process.env.VERCEL = '1'; + delete process.env.TRUSTED_CLIENT_IP_HEADER; + + expect(getTrustedClientIp(headers({ + 'x-vercel-forwarded-for': '2001:db8::1', + 'x-forwarded-for': '198.51.100.10', + }))).toBe('2001:db8::1'); + }); + + it('uses a custom header supplied by a trusted self-hosted proxy', () => { + process.env.TRUSTED_CLIENT_IP_HEADER = 'X-Interview-Lab-Connecting-IP'; + + expect(getTrustedClientIp(headers({ + 'x-interview-lab-connecting-ip': '203.0.113.7', + }))).toBe('203.0.113.7'); + }); + + it.each(['x-forwarded-for', 'x-real-ip'])( + 'refuses the commonly client-controlled %s header as custom configuration', + (unsafeHeader) => { + process.env.TRUSTED_CLIENT_IP_HEADER = unsafeHeader; + + expect(getTrustedClientIp(headers({ [unsafeHeader]: '198.51.100.10' }))).toBe('unknown'); + }, + ); + + it.each([ + '', + '198.51.100.1, 203.0.113.1', + 'not-an-ip', + '1'.repeat(65), + ])('rejects an invalid trusted-header value: %j', (value) => { + process.env.TRUSTED_CLIENT_IP_HEADER = 'x-interview-lab-connecting-ip'; + + expect(getTrustedClientIp(headers({ + 'x-interview-lab-connecting-ip': value, + }))).toBe('unknown'); + }); +}); diff --git a/__tests__/lib/middleware.test.ts b/__tests__/lib/middleware.test.ts index 12dd4d3..55faca8 100644 --- a/__tests__/lib/middleware.test.ts +++ b/__tests__/lib/middleware.test.ts @@ -5,13 +5,22 @@ * sits in front of every /api/* route. Previously untested (only the * separate DB-backed src/lib/rate-limit.ts had coverage). */ -import { describe, it, expect } from 'vitest'; +import { afterAll, describe, it, expect } from 'vitest'; import { NextRequest } from 'next/server'; import { middleware } from '@/middleware'; +const TRUSTED_TEST_HEADER = 'x-interview-lab-test-client-ip'; +const ORIGINAL_TRUSTED_CLIENT_IP_HEADER = process.env.TRUSTED_CLIENT_IP_HEADER; +process.env.TRUSTED_CLIENT_IP_HEADER = TRUSTED_TEST_HEADER; + +afterAll(() => { + if (ORIGINAL_TRUSTED_CLIENT_IP_HEADER === undefined) delete process.env.TRUSTED_CLIENT_IP_HEADER; + else process.env.TRUSTED_CLIENT_IP_HEADER = ORIGINAL_TRUSTED_CLIENT_IP_HEADER; +}); + function req(pathname: string, ip?: string) { const headers: Record = {}; - if (ip) headers['x-forwarded-for'] = ip; + if (ip) headers[TRUSTED_TEST_HEADER] = ip; return new NextRequest(`http://localhost${pathname}`, { headers }); } @@ -90,27 +99,59 @@ describe('middleware (Edge rate limiter)', () => { expect(res.status).toBe(200); }); - it('uses x-real-ip when x-forwarded-for is absent', () => { - const ip = freshIp(); - const request = new NextRequest('http://localhost/api/questions', { headers: { 'x-real-ip': ip } }); - const res = middleware(request); - expect(res.status).toBe(200); + it('ignores x-real-ip when a trusted proxy header is configured', () => { + const trustedIp = freshIp(); + let last; + for (let i = 0; i < GENERAL_MAX + 1; i++) { + last = middleware(new NextRequest('http://localhost/api/questions', { + headers: { + [TRUSTED_TEST_HEADER]: trustedIp, + 'x-real-ip': `203.0.113.${i}`, + }, + })); + } + expect(last!.status).toBe(429); }); - it('uses the first address in a multi-hop x-forwarded-for header', () => { - const ip = freshIp(); - const request = new NextRequest('http://localhost/api/questions', { - headers: { 'x-forwarded-for': `${ip}, 5.6.7.8` }, - }); + it('ignores x-forwarded-for when a trusted proxy header is configured', () => { + const trustedIp = freshIp(); let last; - for (let i = 0; i < GENERAL_MAX + 1; i++) last = middleware(request); + for (let i = 0; i < GENERAL_MAX + 1; i++) { + last = middleware(new NextRequest('http://localhost/api/questions', { + headers: { + [TRUSTED_TEST_HEADER]: trustedIp, + 'x-forwarded-for': `198.51.100.${i}, 5.6.7.8`, + }, + })); + } expect(last!.status).toBe(429); + }); - // A request that resolves to the same first-hop IP shares the counter. - const sameFirstHop = new NextRequest('http://localhost/api/questions', { - headers: { 'x-forwarded-for': `${ip}, 9.9.9.9` }, - }); - expect(middleware(sameFirstHop).status).toBe(429); + it('does not let a spoofed x-forwarded-for value bypass the Vercel client-IP limit', () => { + const originalVercel = process.env.VERCEL; + const originalTrustedHeader = process.env.TRUSTED_CLIENT_IP_HEADER; + process.env.VERCEL = '1'; + delete process.env.TRUSTED_CLIENT_IP_HEADER; + const trustedIp = freshIp(); + let last; + + try { + for (let i = 0; i < GENERAL_MAX + 1; i++) { + last = middleware(new NextRequest('http://localhost/api/questions', { + headers: { + 'x-vercel-forwarded-for': trustedIp, + 'x-forwarded-for': `198.51.100.${i}`, + }, + })); + } + } finally { + if (originalVercel === undefined) delete process.env.VERCEL; + else process.env.VERCEL = originalVercel; + if (originalTrustedHeader === undefined) delete process.env.TRUSTED_CLIENT_IP_HEADER; + else process.env.TRUSTED_CLIENT_IP_HEADER = originalTrustedHeader; + } + + expect(last!.status).toBe(429); }); it('falls back to a shared "unknown" bucket when no IP header is present', () => { diff --git a/__tests__/lib/request-origin.test.ts b/__tests__/lib/request-origin.test.ts new file mode 100644 index 0000000..1f04bbd --- /dev/null +++ b/__tests__/lib/request-origin.test.ts @@ -0,0 +1,61 @@ +/** @vitest-environment node */ +import { afterEach, describe, expect, it } from 'vitest'; +import { isTrustedMutationOrigin } from '@/lib/request-origin'; + +const originalNodeEnv = process.env.NODE_ENV; +const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL; + +function request(origin?: string, fetchSite?: string): Request { + const headers = new Headers(); + if (origin !== undefined) headers.set('origin', origin); + if (fetchSite !== undefined) headers.set('sec-fetch-site', fetchSite); + return new Request('https://interview-lab.example/api/admin/questions', { + method: 'POST', + headers, + }); +} + +afterEach(() => { + if (originalNodeEnv === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = originalNodeEnv; + if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL; + else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl; +}); + +describe('isTrustedMutationOrigin', () => { + it('accepts the configured production origin', () => { + process.env.NODE_ENV = 'production'; + process.env.NEXT_PUBLIC_APP_URL = 'https://interview-lab.example'; + + expect(isTrustedMutationOrigin(request( + 'https://interview-lab.example', + 'same-origin', + ))).toBe(true); + }); + + it.each([ + ['missing Origin', undefined, 'same-origin'], + ['opaque Origin', 'null', 'same-origin'], + ['cross-origin host', 'https://attacker.example', 'cross-site'], + ['same-site subdomain', 'https://evil.interview-lab.example', 'same-site'], + ])('rejects %s', (_label, origin, fetchSite) => { + process.env.NODE_ENV = 'production'; + process.env.NEXT_PUBLIC_APP_URL = 'https://interview-lab.example'; + + expect(isTrustedMutationOrigin(request(origin, fetchSite))).toBe(false); + }); + + it('fails closed in production when the configured app URL is invalid', () => { + process.env.NODE_ENV = 'production'; + process.env.NEXT_PUBLIC_APP_URL = 'not a URL'; + + expect(isTrustedMutationOrigin(request('https://interview-lab.example'))).toBe(false); + }); + + it('uses the request origin outside production when no app URL is configured', () => { + process.env.NODE_ENV = 'test'; + delete process.env.NEXT_PUBLIC_APP_URL; + + expect(isTrustedMutationOrigin(request('https://interview-lab.example'))).toBe(true); + }); +}); diff --git a/__tests__/lib/session-revocation.test.ts b/__tests__/lib/session-revocation.test.ts new file mode 100644 index 0000000..fda0fdc --- /dev/null +++ b/__tests__/lib/session-revocation.test.ts @@ -0,0 +1,31 @@ +/** @vitest-environment node */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const userUpdate = vi.fn(); + +vi.mock('@/lib/db', () => ({ + db: { + user: { + update: (...args: unknown[]) => userUpdate(...args), + }, + }, +})); + +import { revokeAllUserSessions } from '@/lib/session-revocation'; + +describe('revokeAllUserSessions', () => { + beforeEach(() => { + vi.clearAllMocks(); + userUpdate.mockResolvedValue({ id: 'user-123456', sessionVersion: 3 }); + }); + + it('atomically increments the user session version', async () => { + await revokeAllUserSessions('user-123456'); + + expect(userUpdate).toHaveBeenCalledWith({ + where: { id: 'user-123456' }, + data: { sessionVersion: { increment: 1 } }, + select: { id: true }, + }); + }); +}); diff --git a/__tests__/lib/session.test.ts b/__tests__/lib/session.test.ts index c50cffc..4f03ef7 100644 --- a/__tests__/lib/session.test.ts +++ b/__tests__/lib/session.test.ts @@ -13,7 +13,13 @@ import { TOKEN_MAX_AGE, } from '@/lib/session'; -const payload = { sub: 'user-1', email: 'demo@interviewlab.com', tier: 'free', isAdmin: false }; +const payload = { + sub: 'user-1', + email: 'demo@interviewlab.com', + tier: 'free', + isAdmin: false, + sessionVersion: 3, +}; function requestWithCookie(cookieValue?: string) { const headers = new Headers(); diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 87a1697..ac7b789 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -40,6 +40,13 @@ bun run dev | `JWT_SECRET` | JWT signing secret (min 256 bits) | `openssl rand -base64 32` | | `NEXT_PUBLIC_APP_URL` | Production URL | `https://interview-lab.vercel.app` | +Vercel deployments use the platform-managed `x-vercel-forwarded-for` header +for rate-limit identity. Self-hosted deployments behind a trusted reverse +proxy must additionally set `TRUSTED_CLIENT_IP_HEADER` to a custom header that +the proxy overwrites (for example, `x-interview-lab-connecting-ip`). Do not set +it to `x-forwarded-for` or `x-real-ip`, and do not expose the application +directly around that proxy. + ### Deployment Steps 1. Push to main branch triggers automatic deployment @@ -55,10 +62,29 @@ bun run build # Runs: prisma generate && next build ### Database Migrations +The repository now contains a Prisma baseline followed by incremental +migrations. For a new database, apply all migrations normally. For an existing +database that was previously maintained with `prisma db push`, take and verify +a backup, confirm its schema matches the baseline, mark only the baseline as +already applied, and then deploy the incremental migration: + +```bash +bunx prisma migrate resolve --applied 00000000000000_baseline +bunx prisma migrate deploy +``` + +Do not mark the `20260821192500_add_session_version` migration as applied before +its SQL has actually run. Rollback requires restoring the verified pre-migration +backup; dropping the column after sessions have been issued would invalidate the +revocation contract. + ```bash # Create migration bun run db:migrate +# Apply committed migrations (CI/production) +bun run db:deploy + # Push schema (dev only) bun run db:push diff --git a/docs/REMEDIATION_PLAN.md b/docs/REMEDIATION_PLAN.md new file mode 100644 index 0000000..db7c67a --- /dev/null +++ b/docs/REMEDIATION_PLAN.md @@ -0,0 +1,382 @@ +# Interview Lab Remediation and Modernization Plan + +**Status:** Implementation in progress on `feat/remediation-foundation` +**Risk:** High (authentication, secrets, AI providers, database migration, and broad deletion) +**Baseline:** `main` at `f1b4cce`; 38 test files and approximately 380 `it`/`test` declarations found by static inventory. The full suite, build, lint, and coverage baseline must still be established in a working Bun environment. + +### Implementation ledger + +| Date | Slice | Evidence | Status | Next | +| --- | --- | --- | --- | --- | +| 2026-08-21 | Reproducible default test suite | 426 passed, 85 opt-in live-server tests skipped without `TEST_BASE_URL` | Complete | Run live-server phase with PostgreSQL | +| 2026-08-21 | Trusted client-IP policy | Valid Red reproduced rotating `x-forwarded-for` bypass; 20 focused tests Green; new helper at 100% coverage | Complete | Current-database authorization | +| 2026-08-21 | Session revocation | JWT/database version matching, centralized session restore, additive migration, and logout-all endpoint | Complete in code | Rehearse migration and live-server flow on PostgreSQL | +| 2026-08-21 | Repository baseline | TypeScript pass; build pass; lint 0 errors/30 warnings; total coverage 38.81% statements, 39.71% branches, 38.48% functions, 40.11% lines | Partial | Warning cleanup and coverage closure by vertical slice | + +## 1. Outcomes + +This program will: + +1. Remove payment, subscription, pricing, tier, upgrade, and monetization behavior from the product and data model. +2. Repair the AI workflows and make their provider/model configuration manageable by administrators. +3. Support mainstream LLM providers and custom OpenAI-compatible endpoints without coupling product features to a vendor SDK. +4. Close the confirmed security, reliability, accessibility, content, and documentation gaps from the audit. +5. Replace temporary/placeholder visual assets and unsupported marketing claims with verified copy and purpose-built generated imagery. +6. Enforce test-driven development, SOLID boundaries, and non-bypassable quality gates. +7. Reach complete, explicitly measured automated coverage for owned application code, with critical user journeys also covered by integration and browser tests. +8. Replace Interview Lab's current Field Manual styling with a global React/Tailwind implementation of the `amazon-ph-simulators` **Amazon Pro** design system. + +## 2. Non-negotiable engineering protocol + +Every behavior change follows one observable Red-Green-Refactor loop. Production behavior must not be written before a test fails for the intended reason. Refactors require characterization tests first. Tests may not be deleted, skipped, weakened, or replaced by snapshots merely to make a gate pass. + +### Required loop record + +Each PR must record: + +- Behavior and acceptance example. +- Test level and command. +- Red failure and why it is valid. +- Smallest Green change and passing result. +- Refactor performed, if any. +- Related regression checks. +- Remaining risk. + +### SOLID enforcement + +The provider/model feature has these real change axes: + +| Change axis | Owner | Stable contract | Volatile detail | Required proof | +| --- | --- | --- | --- | --- | +| AI use case | Feature service | structured completion request/result | prompt and response schema | unit + schema tests | +| Provider transport | provider adapter | `LLMProvider` contract | vendor SDK/HTTP API | shared contract suite | +| Provider selection | resolver | enabled configuration resolves to adapter/model | database and defaults | integration tests | +| Secret storage | credential vault | write/decrypt/rotate without disclosure | encryption implementation | security tests | +| Admin management | admin use case/API | authenticated CRUD/test/activate contract | Next.js transport/UI | API + component + E2E tests | +| Observability | run recorder | redacted outcome/latency/token metadata | provider response formats | integration tests | + +Dependency direction must be `feature policy -> LLM contract <- provider adapters`. Next.js, Prisma, vendor SDKs, and environment access stay in infrastructure/composition code. Adding a provider must not require editing feature prompts or route handlers. All adapters must pass the same contract tests; unsupported capabilities must be represented as explicit capabilities, not no-op methods. + +### Completion gates + +A PR is not complete until all applicable checks pass: + +```bash +bun install --frozen-lockfile +bunx prisma validate +bunx prisma generate +bunx tsc --noEmit +bun run lint +bun run test:coverage +bun run build +``` + +Schema and critical-flow PRs must also run a real PostgreSQL migration test, seed test, live-server API suite, and browser E2E suite. CI must reject focused tests, `.only`, unexpected `.skip`/`.todo`, reduced coverage, leaked secrets, or a dirty generated-code diff. + +## 3. Target AI architecture + +### Domain contracts + +- `LLMCompletionRequest`: messages/system prompt, response format, temperature, token limit, timeout, abort signal, and feature identifier. +- `LLMCompletionResult`: content, normalized finish reason, provider/model IDs, usage when available, latency, and request correlation ID. +- `LLMProvider`: completion plus declared capabilities. +- `ProviderResolver`: resolves the active enabled configuration for a feature and returns a configured adapter. +- `CredentialVault`: encrypts/decrypts versioned credentials and supports rotation; plaintext never crosses the service boundary or appears in API responses/logs. +- `ModelConfigurationRepository`: persists provider metadata, model entries, per-feature defaults, status, and timestamps. + +The existing `src/lib/ai/client.ts` seam will be evolved rather than duplicated. The exported global `ai = new ZAIProvider()` singleton will be removed; handlers will receive a resolver/use-case dependency through the composition root. + +### Provider options + +First-party presets: + +- OpenAI +- Anthropic +- Google Gemini +- Azure OpenAI +- Amazon Bedrock +- Mistral +- Groq +- OpenRouter +- Existing Z AI integration, retained only if its runtime contract is verified + +Custom providers use an OpenAI-compatible endpoint with admin-supplied display name, base URL, API key, model ID, optional organization/project headers, timeout, and additional allow-listed headers. URLs must be HTTPS outside local development; private/link-local/metadata addresses and redirect escapes must be blocked to prevent SSRF. Arbitrary headers, query-string secrets, and browser-side credentials are prohibited. + +### Persistence + +Add normalized models (names are provisional until the migration PR): + +- `LlmProviderConfig`: ID, provider kind, display name, encrypted credential envelope, base URL, enabled state, validation status, timestamps, and optimistic-lock version. +- `LlmModelConfig`: provider relation, provider model ID, display name, capabilities, context/output limits when known, enabled state, and timestamps. +- `LlmFeatureRouting`: feature key, primary model, optional fallback model, timeout, and timestamps. + +Do not store secrets in the generic string-valued `AppSetting` table. Use authenticated encryption (AES-256-GCM or a managed KMS envelope), a versioned server-only master key, unique nonce per value, and rotation metadata. The admin read API returns only `hasCredential` and a safe suffix/fingerprint, never ciphertext or plaintext. + +### Admin page + +Add an **AI Providers** section to the authenticated admin area with: + +- Provider list, enabled/disabled and health states. +- Add/edit provider preset or custom endpoint. +- Write-only API key input with replace/remove semantics. +- Models list with manual model entry and optional provider discovery. +- Per-feature model assignment for coach, assessment scoring, resume review, and cover-letter generation. +- Test connection action using a minimal non-user prompt, explicit timeout, redacted error, and audit event. +- Activate/change confirmation, safe fallback selection, and warning before disabling an in-use model. +- Credential rotation and last-tested metadata. + +All mutations require a freshly resolved database admin identity, CSRF protection, strict schemas, audit logging, rate limiting, and generic client errors. Provider diagnostics must redact keys, authorization headers, prompt content, and raw vendor payloads. + +### AI reliability fixes + +- Validate provider output against feature-specific runtime schemas; TypeScript generics alone are insufficient. +- Distinguish timeout, cancellation, authentication, rate limit, unavailable model, malformed output, and provider outage errors. +- Use bounded retry with jitter only for safe transient failures; never retry validation/authentication failures blindly. +- Add optional, explicitly configured fallback routing with loop prevention and a total time budget. +- Preserve truthfulness and anti-fabrication guardrails for resumes, cover letters, and coaching. +- Record redacted `AgentRun` metadata for provider/model, latency, success/failure category, usage, and safety outcome. +- Provide a deterministic, honest user fallback when AI is unavailable; do not present canned content as model-generated. + +## 4. Payment and tier removal + +This is a removal migration, not a dormant-code cleanup. + +### Inventory to delete or simplify + +- Prisma: `Subscription`, `Payment`, `User.subscriptionTier`, their relations, Stripe fields, and tier-only indexes/data. +- Content: `Download.accessTier` becomes unnecessary; every valid resource is available based on normal authentication/content status. +- Code: subscription guards, entitlement implementation, subscription hook, pricing page, upgrade modal, subscription banner, tier types/constants, and all related callers. +- Seed/tests: pro/starter users, tiered downloads, entitlement/guard tests, and tier-based fixtures/assertions. +- Copy/docs: pricing, upgrade, subscription, payment history, Stripe, monetization phases, and nonexistent subscription API routes. +- CI/PR template: replace the subscription acceptance item with free-access and authorization checks. + +### Safe migration sequence + +1. Characterize current free-access behavior with API and UI tests. +2. Query production-like data counts for subscription/payment rows and export a rollback archive before destructive migration. +3. Remove runtime reads/writes and make all resources independent of tier fields. +4. Deploy compatibility code that tolerates old columns while no longer using them. +5. Apply a reviewed migration that drops foreign keys, tables, relations, and columns; verify on a restored production snapshot. +6. Remove compatibility code and stale tests/docs. + +Rollback requires a documented database backup/restore point. A down migration that recreates empty payment tables is not adequate if rows existed. + +### Acceptance behavior + +- No pricing/upgrade/payment/subscription UI, routes, imports, schema fields, seed values, docs, or product metadata remain. +- Every authenticated user can access every published interview, guide, download, resume, cover-letter, and practice-test capability subject only to legitimate safety/rate limits. +- Authorization remains user/admin based; removing tiers must not weaken ownership checks. + +## 5. Security and platform gaps + +### P0 — before feature expansion + +1. **Trusted client IP:** stop trusting the first unverified `x-forwarded-for` value. Parse only the platform-defined trusted header/proxy chain, use a safe fallback, and test spoofing and malformed lists. +2. **Fresh authorization:** stop authorizing from stale JWT `isAdmin`/tier claims. Resolve the current user record for protected actions. Remove tier claims as part of payment removal. +3. **Session revocation:** add a session version or server-side session record; increment/revoke after password change, account disablement, privilege changes, and security events. +4. **AI secrets:** encrypted storage, strict redaction, server-only access, rotation procedure, and secret scanning. +5. **Admin mutation protection:** current database authorization, CSRF/origin checks, validation, rate limiting, and audit trail. + +### P1 — reliability and correctness + +- Replace in-memory global API rate limiting with a shared production store or platform rate-limit service; preserve deterministic local/test adapters. +- Define runtime schemas for request bodies and serialized JSON fields at API boundaries. +- Review manually serialized JSON columns and migrate high-value structures to PostgreSQL `Json` only where the compatibility benefit justifies it. +- Add consistent correlation IDs and structured, privacy-safe errors/metrics. +- Test ownership on every `[id]` route and enumeration-resistant not-found responses. +- Add accessibility checks for keyboard navigation, focus, names, contrast, reduced motion, and mobile layouts. + +## 6. Visual content and claim integrity + +Create an asset manifest before generating anything: current path, consumer, purpose, dimensions/aspect ratio, alt text, status, and replacement prompt. Review every SVG under `public/images/illustrations`, the OG image, logos, empty states, and landing-page visuals. + +Replace only assets confirmed to be placeholders, low-quality, duplicated, misleading, or inconsistent with the Field Manual system. Generate cohesive, original illustrations depicting Filipino Amazon VA candidates in realistic remote-work/interview contexts. Do not generate UI screenshots, logos, certificates, employer marks, or factual diagrams. Export optimized WebP/AVIF with responsive sizes while retaining an accessible fallback where needed. + +Remove or substantiate marketing claims such as “70%,” “3x higher offer rate,” “hired within 2 weeks,” and testimonials. Unverified statistics/testimonials must be rewritten as non-quantified product benefits, not decorated with generated identities or implied evidence. + +Visual acceptance requires desktop and mobile screenshot comparison, no layout shift, meaningful alt text (or empty alt for decorative images), acceptable image weight, correct social-preview rendering, and no placeholder asset references. + +## 7. Global Amazon Pro theme migration + +The canonical visual reference is `projectamazonph/amazon-ph-simulators` at audited commit `4c0e5f33b09feedbac1df6f2e1ea19cfb84e882e`, specifically `assets/tokens.css`, `assets/shell.css`, `assets/responsive.css`, the component rules in `assets/skin.css`, and `tests/design-system-contract.test.cjs`. Reconfirm the source commit immediately before implementation so upstream theme changes are either deliberately included or pinned. + +### Target identity + +Interview Lab will use the same **Amazon Pro** visual language: + +- Brand/navy: `#131921` with the supporting navy scale. +- Primary accent: Amazon orange `#FF9900`, including hover/pressed/tint variants. +- Seller Central-style light surfaces: `#F7F8FA` page, white cards, subtle gray borders, restrained shadows. +- Text/link/semantic palettes matching the simulator tokens, including Amazon link blue and green/amber/red states. +- Typography: Archivo for display, PT Sans for body, IBM Plex Mono for technical data, and Barlow Condensed only for compact labels. +- 4px spacing system, 6px controls, 8px cards, 44px minimum interactive targets, fluid headings, and the simulator breakpoint semantics. +- Shared Project Amazon PH Academy chrome, confident training/operator voice, visible orange keyboard focus, reduced-motion support, and responsive mobile navigation. + +### Architecture decision + +Do **not** copy `skin.css` or `shell.js` into the Next.js application. Those files are compatibility layers for standalone legacy HTML pages and rely on broad selectors, `!important`, DOM injection, and hidden native chrome. Interview Lab will implement the same design semantics natively: + +- Map canonical simulator tokens into `src/app/globals.css` CSS variables and Tailwind v4 theme variables. +- Create or normalize semantic primitives for buttons, cards, badges/chips, fields, dialogs, tabs, tables, alerts, navigation, and page containers. +- Build one React application shell for public, authenticated, and admin contexts with explicit variants, shared header/footer behavior, mobile drawer, skip link, focus management, and safe-area handling. +- Keep product feature layouts distinct. The global theme owns tokens and primitive appearance; Resume Lab, Mock Interview, Question Bank, Practice Tests, Learning Paths, Download Center, dashboard, and admin retain task-specific information architecture. +- Remove legacy `glass-*`/`Field*` naming during migration when it obscures the Amazon Pro contract; use temporary compatibility exports only to keep each PR small. +- Ban new arbitrary brand hex values, page-level font imports, duplicate shells, and feature-local reimplementations of shared primitives. + +### Migration sequence + +1. Create a cross-repository token/component parity matrix and approve intentional differences required by Interview Lab accessibility or framework constraints. +2. Add characterization screenshots and interaction tests for every current route at phone, tablet, and desktop widths. +3. Introduce Amazon Pro tokens, locally bundled fonts, base/reset rules, focus/reduced-motion behavior, and theme contract tests without changing feature behavior. +4. Implement the shared shell and primitives in Storybook-equivalent fixture pages or test harnesses before migrating feature pages. +5. Migrate one vertical slice at a time: public/auth, dashboard/shell, interview tools, document tools, learning/resources, then admin/AI providers. +6. Remove Field Manual tokens, hard-coded colors, legacy component aliases, and duplicate chrome only after repository-wide caller migration is proven Green. +7. Regenerate product imagery and screenshots against the completed theme, then update README/metadata/docs. + +### Theme tests and acceptance + +- Static contract test ensures required canonical tokens exist and forbidden legacy/hard-coded brand values do not reappear. +- Shared component contract tests cover all variants and disabled/loading/error/focus states. +- Browser tests cover drawer focus trap, Escape/outside close, active navigation, skip link, keyboard-only operation, reduced motion, and 44px targets. +- Automated accessibility checks pass WCAG 2.2 AA for critical pages; semantic state colors are not the only signal. +- Visual regression baselines cover all routes at 320/375/768/1024/1440px and light mode. Dark mode is out of scope unless separately requested. +- No horizontal overflow, clipped dialogs, hidden content behind fixed chrome, duplicate headers/footers, cumulative layout shift from fonts/images, or route-specific theme drift. +- A reviewer opening Interview Lab beside SimGrid immediately recognizes the same Project Amazon PH Academy system while each Interview Lab tool remains functionally distinct. + +## 8. Complete test coverage program + +“Complete coverage” means both metric completeness and behavioral completeness. A 100% line number alone is not sufficient. + +### Coverage policy + +- CI thresholds: 100% statements, branches, functions, and lines for owned application code in the agreed coverage scope. +- Exclusions are limited to framework-only thin route/page composition, generated files, type-only declarations, and design-system primitives already covered by higher-value shared tests. Every exclusion requires an inline reason and review; exclusions may not hide business logic. +- Move logic out of excluded `page.tsx`/`layout.tsx` files when it contains behavior. +- Publish text and LCOV reports as CI artifacts; fail any threshold regression. + +### Required layers + +1. Pure unit tests for policies, parsing, validation, routing, retry, redaction, and encryption envelopes. +2. Shared provider contract tests for every adapter, including timeout, abort, malformed output, auth failure, rate limit, and capability reporting. +3. API integration tests with PostgreSQL for authentication, ownership, admin configuration, provider switching, payment-removal invariants, and migrations. +4. Component tests for admin provider/model workflows and every material error/empty/loading state. +5. Browser E2E tests for register/login/onboarding, full mock interview, resume review, cover letter, practice test, downloads, admin provider setup/test/activate, access denial, and logout/revocation. +6. Security tests for header spoofing, SSRF, CSRF, secret disclosure, stale privilege, IDOR, rate limiting, and malformed AI payloads. +7. Accessibility tests plus manual keyboard/screen-reader and responsive verification for critical routes. +8. Migration tests from the current schema with representative payment/tier data and rollback/restore rehearsal. + +Flaky tests are defects: control time, IDs, randomness, network, and shared state; use narrow fakes at architectural boundaries and real PostgreSQL for persistence contracts. + +## 9. Documentation truth pass + +Update documentation in the same PR as behavior; do not defer it to the end. Establish these authoritative documents: + +- `README.md`: current product, free-access policy, setup, configuration, verified commands, screenshots, and links. +- `AGENTS.md` / `CLAUDE.md`: identical current constraints for free access, provider architecture, secrets, TDD/SOLID gates, and supported commands. +- `docs/architecture.md`: actual routes, current schema, auth/session flow, AI boundaries, and diagrams. +- `docs/TESTING.md`: real directory inventory, test layers, coverage policy, Red-Green-Refactor evidence, and exact CI sequence. +- `docs/SECURITY.md`: threat model, trusted proxy assumptions, session revocation, credential encryption/rotation, SSRF/CSRF controls, and incident response. +- `docs/DEPLOYMENT.md`: required env vars, encryption-key rotation, migration/backup/restore, provider bootstrap, health checks, and rollback. +- `docs/ROADMAP.md`: delete monetization; replace with security, AI administration, content quality, and verified advanced features. +- `docs/decisions.md`: ADRs for free-only product, provider adapter architecture, encrypted credentials, runtime schema validation, and complete-coverage policy. +- Design-system documentation: canonical SimGrid commit, token parity matrix, shared component catalog, layout rules, imagery guidance, accessibility requirements, and visual-regression update procedure. + +Archive or rewrite speculative duplicate PRDs/build specs so they are clearly historical and cannot be mistaken for current behavior. Remove nonexistent subscription routes, SQLite/NextAuth claims, stale test counts, missing-document links, and unresolved “TBD” claims once verified. + +## 10. Delivery sequence + +### Milestone 0 — reproducible baseline + +- Repair local dependency/bootstrap instructions without changing locked dependencies. +- Run and record type, lint, tests, coverage, build, seed, and live-server results. +- Produce coverage map, route/ownership matrix, asset manifest, documentation drift matrix, and data-migration inventory. +- Exit: every later change can be compared to a trusted baseline; pre-existing failures are explicitly separated. + +### Milestone 1 — security foundation + +- Trusted IP handling, current-database authorization, session revocation, shared rate-limit design, admin mutation controls, logging/redaction. +- Exit: security regression suite passes and privilege changes take effect immediately. + +Implementation ledger: + +- [x] Trusted client-IP boundary and auth rate-limit identity. +- [x] Current-database authorization and revocable session versions. +- [x] Shared fail-closed origin policy across question, guide, and download admin mutations. +- [ ] Extend mutation enforcement to remaining cookie-authenticated user writes. +- [ ] Complete logging/redaction review and security regression gate. + +### Milestone 2 — payment removal + +- Free-access characterization, compatibility release, backup/data decision, schema migration, code/UI/test/doc deletion. +- Exit: repository-wide forbidden-term scan is clean except legitimate Amazon product-pricing training content and historical migration notes. + +### Milestone 3 — provider core and AI repair + +- Runtime schemas, provider contracts, resolver, vault, persistence, adapters, normalized errors, fallback policy, observability. +- Exit: all adapters pass the contract suite and existing AI features pass against deterministic fakes plus opt-in provider smoke tests. + +### Milestone 4 — admin provider/model page + +- Admin APIs, configuration UI, test connection, model/feature routing, activation safety, audit events. +- Exit: full admin E2E succeeds; secrets cannot be read back or leaked. + +### Milestone 5 — Amazon Pro global theme + +- Token parity matrix, native React/Tailwind primitives, shared shell, page-by-page migration, accessibility and visual-regression coverage. +- Exit: every route passes theme contracts and responsive visual review with no Field Manual or duplicate-shell residue. + +### Milestone 6 — content and visual integrity + +- Claim cleanup, generated asset replacements, accessibility/performance validation, updated screenshots/OG assets. +- Exit: asset manifest is complete and responsive visual review passes. + +### Milestone 7 — coverage closure and documentation + +- Close uncovered branches/behaviors one TDD loop at a time, enforce thresholds, reconcile all canonical docs and archive stale documents. +- Exit: all gates pass at required thresholds on a clean checkout and production-like database. + +## 11. PR strategy and checkpoints + +Use small, reversible PRs; never combine destructive schema removal with the new provider system. Recommended order: + +1. Baseline tooling/CI and documentation inventory. +2. Trusted IP and current-role authorization. +3. Session revocation and security tests. +4. Free-access characterization and UI/code removal. +5. Payment/tier database migration. +6. LLM contracts, schemas, and Z-AI characterization. +7. Credential vault and provider configuration persistence. +8. Provider adapters and contract suites. +9. Admin APIs and UI. +10. AI feature migration/reliability/fallback/observability. +11. Amazon Pro tokens, font assets, contract tests, and shared primitives. +12. Shared shell and public/auth/dashboard migration. +13. Feature and admin page theme migration. +14. Generated visual assets, refreshed screenshots, and claim cleanup. +15. Coverage closure, canonical docs, and final release gate. + +Each PR needs a rollback note, migration impact, actual Red/Green evidence, changed docs, final diff review, and a binary PASS/BLOCKED gate result. Destructive database work and encryption-key changes require an explicit deployment checkpoint after backup verification. + +## 12. Final acceptance checklist + +- [ ] Payment, subscription, pricing, tier, upgrade, Stripe, and monetization product behavior is removed end to end. +- [ ] Admins can securely add, test, enable, disable, and route supported or custom LLM providers/models. +- [ ] API keys are encrypted, write-only, redacted, rotatable, and never sent to the browser after submission. +- [ ] Every AI feature validates structured output, handles provider failure honestly, and records privacy-safe diagnostics. +- [ ] Confirmed security findings have permanent regression tests. +- [ ] All owned application code meets 100% statement/branch/function/line thresholds with reviewed exclusions. +- [ ] Critical user/admin journeys pass against a production build and real PostgreSQL. +- [ ] Accessibility, responsive layouts, image performance, and social previews pass review. +- [ ] Every route uses the native Amazon Pro global theme sourced from the audited simulator design tokens and shared component contracts. +- [ ] No stale Field Manual styling, duplicate chrome, arbitrary brand colors, page-specific fonts, or broad legacy CSS overrides remain. +- [ ] All claims are substantiated or rewritten; confirmed placeholders are replaced with approved generated assets. +- [ ] README, engineering instructions, architecture, security, testing, deployment, roadmap, and ADRs match the shipped system. +- [ ] Type, lint, Prisma, test, coverage, build, integration, E2E, migration, secret-scan, and diff-integrity gates all report PASS. + +## 13. Decisions required before destructive implementation + +1. Confirm whether any production `Subscription` or `Payment` records must be retained outside the live database for accounting/audit purposes and define the retention period. +2. Choose the credential root-of-trust for production: managed KMS/secret manager is preferred; otherwise provide a dedicated versioned encryption key separate from `JWT_SECRET`. +3. Confirm which provider presets should ship enabled in the first release. The architecture can support all listed providers without requiring every adapter in the first deploy. +4. Confirm whether AI routing is global only or may be overridden per feature; this plan recommends per-feature routing with one global default. +5. Approve replacement or removal of unverified statistics and testimonials; generated people must never be presented as real customers. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fb72036..c1c93ba 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -18,6 +18,16 @@ Interview Lab handles user credentials, resume content, and interview data. Secu - Admin routes require `isAdmin: true` in user record - Subscription tier checks enforced in API, not just UI - Rate limiting on auth endpoints (10 attempts per minute) +- Rate-limit identity comes only from Vercel's managed client-IP header or an + explicitly configured trusted reverse-proxy header. Client-supplied + `x-forwarded-for` and `x-real-ip` values are ignored. +- Every session carries the user's current `sessionVersion`. Incrementing that + value revokes every older token even when its signature and expiry remain + valid. `POST /api/auth/logout-all` performs this revocation and clears the + current session cookie. +- State-changing admin content requests require an exact `Origin` match with + `NEXT_PUBLIC_APP_URL` and reject cross-site Fetch Metadata. Production fails + closed when the application URL is missing or invalid. ## Data Protection @@ -37,6 +47,7 @@ Required secrets (never commit to git): DATABASE_URL # PostgreSQL connection string JWT_SECRET # min 256-bit random string NEXT_PUBLIC_APP_URL # Public app URL +TRUSTED_CLIENT_IP_HEADER # Self-hosted trusted proxy only; never x-forwarded-for ``` ## Security Checklist @@ -47,10 +58,12 @@ NEXT_PUBLIC_APP_URL # Public app URL - [x] Input validation on all API endpoints - [x] SQL injection prevented via Prisma - [x] XSS prevented via React's default escaping -- [ ] CSRF: SameSite cookies are a partial measure; add CSRF token for state-changing POSTs +- [ ] CSRF: admin content mutations have origin/Fetch Metadata enforcement; + extend the shared policy to every remaining cookie-authenticated mutation - [x] Rate limiting on auth endpoints - [x] Rate limiting on AI endpoints (15 req/min per user) - [x] Account deletion endpoint (DELETE /api/user/me) — requires password confirmation +- [x] All-session revocation endpoint (`POST /api/auth/logout-all`) - [x] Data export endpoint (GET /api/user/me/export) — GDPR data portability ## GDPR / Data Privacy diff --git a/package.json b/package.json index 8fbcff8..69d40bd 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "db:push": "prisma db push", "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", + "db:deploy": "prisma migrate deploy", "db:reset": "prisma migrate reset", "db:seed": "tsx prisma/seed.ts", "test": "vitest run", diff --git a/prisma/migrations/00000000000000_baseline/migration.sql b/prisma/migrations/00000000000000_baseline/migration.sql new file mode 100644 index 0000000..001f7bf --- /dev/null +++ b/prisma/migrations/00000000000000_baseline/migration.sql @@ -0,0 +1,321 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "name" TEXT, + "passwordHash" TEXT, + "subscriptionTier" TEXT NOT NULL DEFAULT 'free', + "isAdmin" BOOLEAN NOT NULL DEFAULT false, + "emailVerified" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AppSetting" ( + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AppSetting_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "UserProfile" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "targetRole" TEXT, + "experienceLevel" TEXT, + "toolsKnown" TEXT, + "weakAreas" TEXT, + "interviewDate" TEXT, + "confidenceLevel" TEXT, + "resumeStatus" TEXT, + "country" TEXT, + "onboardingDone" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserProfile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Question" ( + "id" TEXT NOT NULL, + "role" TEXT NOT NULL, + "difficulty" TEXT NOT NULL, + "type" TEXT NOT NULL, + "skillArea" TEXT NOT NULL, + "question" TEXT NOT NULL, + "whyEmployersAsk" TEXT, + "strongAnswerPoints" TEXT, + "weakAnswerWarnings" TEXT, + "sampleAnswer" TEXT, + "answerFormat" TEXT, + "timeLimit" INTEGER, + "status" TEXT NOT NULL DEFAULT 'published', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Question_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "InterviewSession" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "mode" TEXT NOT NULL, + "targetRole" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + "overallScore" DOUBLE PRECISION, + "transcript" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "InterviewSession_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "QuestionAttempt" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "questionId" TEXT NOT NULL, + "userAnswer" TEXT NOT NULL, + "aiFeedback" TEXT, + "score" DOUBLE PRECISION, + "rubricBreakdown" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "QuestionAttempt_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Resume" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "originalText" TEXT, + "targetRole" TEXT, + "score" DOUBLE PRECISION, + "improvedVersion" TEXT, + "truthFlags" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Resume_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CoverLetter" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "jobDescription" TEXT, + "tone" TEXT, + "generatedLetter" TEXT, + "truthFlags" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CoverLetter_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Assessment" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "role" TEXT NOT NULL, + "difficulty" TEXT NOT NULL, + "description" TEXT, + "datasetInfo" TEXT, + "answerKey" TEXT, + "rubric" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Assessment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Download" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "fileType" TEXT NOT NULL, + "role" TEXT NOT NULL, + "description" TEXT, + "fileName" TEXT NOT NULL, + "accessTier" TEXT NOT NULL DEFAULT 'free', + "category" TEXT NOT NULL DEFAULT 'Other', + "downloadCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Download_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AgentRun" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "agentType" TEXT NOT NULL, + "input" TEXT, + "output" TEXT, + "safetyFlags" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AgentRun_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Guide" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "level" TEXT NOT NULL, + "role" TEXT NOT NULL, + "content" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'published', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Guide_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "GuideProgress" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "guideId" TEXT NOT NULL, + "completed" BOOLEAN NOT NULL DEFAULT false, + "checklist" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "GuideProgress_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Subscription" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tier" TEXT NOT NULL DEFAULT 'free', + "status" TEXT NOT NULL DEFAULT 'active', + "stripeCustomerId" TEXT, + "stripePriceId" TEXT, + "stripeSubscriptionId" TEXT, + "currentPeriodStart" TIMESTAMP(3), + "currentPeriodEnd" TIMESTAMP(3), + "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Payment" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'php', + "status" TEXT NOT NULL DEFAULT 'pending', + "stripePaymentId" TEXT, + "stripeInvoiceId" TEXT, + "description" TEXT, + "metadata" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Payment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "email" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RateLimitEntry" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 1, + "resetTime" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RateLimitEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserProfile_userId_key" ON "UserProfile"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Guide_slug_key" ON "Guide"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "GuideProgress_userId_guideId_key" ON "GuideProgress"("userId", "guideId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_stripeCustomerId_key" ON "Subscription"("stripeCustomerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_stripeSubscriptionId_key" ON "Subscription"("stripeSubscriptionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Payment_stripePaymentId_key" ON "Payment"("stripePaymentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Payment_stripeInvoiceId_key" ON "Payment"("stripeInvoiceId"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "RateLimitEntry_key_key" ON "RateLimitEntry"("key"); + +-- AddForeignKey +ALTER TABLE "UserProfile" ADD CONSTRAINT "UserProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "InterviewSession" ADD CONSTRAINT "InterviewSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuestionAttempt" ADD CONSTRAINT "QuestionAttempt_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "InterviewSession"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "QuestionAttempt" ADD CONSTRAINT "QuestionAttempt_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "Question"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Resume" ADD CONSTRAINT "Resume_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CoverLetter" ADD CONSTRAINT "CoverLetter_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AgentRun" ADD CONSTRAINT "AgentRun_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GuideProgress" ADD CONSTRAINT "GuideProgress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GuideProgress" ADD CONSTRAINT "GuideProgress_guideId_fkey" FOREIGN KEY ("guideId") REFERENCES "Guide"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260821192500_add_session_version/migration.sql b/prisma/migrations/20260821192500_add_session_version/migration.sql new file mode 100644 index 0000000..85d2135 --- /dev/null +++ b/prisma/migrations/20260821192500_add_session_version/migration.sql @@ -0,0 +1,2 @@ +-- Add a monotonic version used to revoke all previously issued user sessions. +ALTER TABLE "User" ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 1; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..99e4f20 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c9f48b6..073016a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { subscriptionTier String @default("free") isAdmin Boolean @default(false) emailVerified Boolean @default(false) + sessionVersion Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/api/admin/questions/route.ts b/src/app/api/admin/questions/route.ts index d28ee6f..6d77013 100644 --- a/src/app/api/admin/questions/route.ts +++ b/src/app/api/admin/questions/route.ts @@ -1,8 +1,17 @@ import { db } from '@/lib/db'; import { getUserFromRequest } from '@/lib/auth-helpers'; import { sanitizeText } from '@/lib/sanitize'; +import { isTrustedMutationOrigin } from '@/lib/request-origin'; import { NextResponse } from 'next/server'; +function rejectUntrustedOrigin(request: Request): NextResponse | null { + if (isTrustedMutationOrigin(request)) return null; + return NextResponse.json( + { error: 'Forbidden — untrusted request origin' }, + { status: 403 }, + ); +} + export async function GET(request: Request) { try { const admin = await getUserFromRequest(request); @@ -42,6 +51,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized — admin access required' }, { status: 401 }); } + const originRejection = rejectUntrustedOrigin(request); + if (originRejection) return originRejection; + const data = await request.json(); // Validate required fields @@ -109,6 +121,9 @@ export async function PUT(request: Request) { return NextResponse.json({ error: 'Unauthorized — admin access required' }, { status: 401 }); } + const originRejection = rejectUntrustedOrigin(request); + if (originRejection) return originRejection; + const data = await request.json(); const { id, ...updateData } = data; diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 1c99ed5..ec1f7fd 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -3,12 +3,12 @@ import { NextResponse } from 'next/server'; import { verifyPassword, isLegacyHash, hashPassword } from '@/lib/password'; import { createSession } from '@/lib/session'; import { checkRateLimit } from '@/lib/rate-limit'; +import { getTrustedClientIp } from '@/lib/client-ip'; export async function POST(request: Request) { try { // Persistent rate limiting for login (survives server restarts) - const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() - || request.headers.get('x-real-ip') || 'unknown'; + const clientIp = getTrustedClientIp(request.headers); const loginRateLimitMax = Number(process.env.AUTH_LOGIN_RATE_LIMIT_MAX) || 10; const rl = await checkRateLimit(clientIp, 'auth-login', loginRateLimitMax, 15 * 60_000); if (!rl.allowed) { @@ -73,6 +73,7 @@ export async function POST(request: Request) { email: user.email, tier: user.subscriptionTier, isAdmin: user.isAdmin, + sessionVersion: user.sessionVersion, }, response); return response; diff --git a/src/app/api/auth/logout-all/route.ts b/src/app/api/auth/logout-all/route.ts new file mode 100644 index 0000000..7aaae7c --- /dev/null +++ b/src/app/api/auth/logout-all/route.ts @@ -0,0 +1,20 @@ +import { getUserFromRequest } from '@/lib/auth-helpers'; +import { clearSession } from '@/lib/session'; +import { revokeAllUserSessions } from '@/lib/session-revocation'; +import { NextResponse } from 'next/server'; + +export async function POST(request: Request): Promise { + const user = await getUserFromRequest(request); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await revokeAllUserSessions(user.id); + const response = NextResponse.json({ success: true }); + clearSession(response); + return response; + } catch { + return NextResponse.json({ error: 'Failed to revoke sessions' }, { status: 500 }); + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 3ee3268..2b7ad65 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -4,6 +4,7 @@ import { hashPassword } from '@/lib/password'; import { createVerificationToken } from '@/lib/email-verification'; import { createSession } from '@/lib/session'; import { checkRateLimit } from '@/lib/rate-limit'; +import { getTrustedClientIp } from '@/lib/client-ip'; // Configurable max users (0 = unlimited). Set via AppSetting "max_users" in DB, or env MAX_USERS. const DEFAULT_MAX_USERS = 0; @@ -40,8 +41,7 @@ function isBot(body: Record): boolean { export async function POST(request: Request) { try { // Persistent rate limiting (survives server restarts) - const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() - || request.headers.get('x-real-ip') || 'unknown'; + const clientIp = getTrustedClientIp(request.headers); const registerRateLimitMax = Number(process.env.AUTH_REGISTER_RATE_LIMIT_MAX) || 5; const rl = await checkRateLimit(clientIp, 'auth-register', registerRateLimitMax, 15 * 60_000); if (!rl.allowed) { @@ -148,6 +148,7 @@ export async function POST(request: Request) { email: user.email, tier: user.subscriptionTier, isAdmin: user.isAdmin, + sessionVersion: user.sessionVersion, }, response); return response; diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 819ecda..ed3b375 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -1,25 +1,9 @@ -import { db } from '@/lib/db'; -import { verifySession } from '@/lib/session'; +import { getUserFromRequest } from '@/lib/auth-helpers'; import { NextRequest, NextResponse } from 'next/server'; export async function GET(request: NextRequest) { try { - const payload = await verifySession(request); - if (!payload) { - return NextResponse.json({ user: null }); - } - - const user = await db.user.findUnique({ - where: { id: payload.sub }, - select: { - id: true, - email: true, - name: true, - subscriptionTier: true, - isAdmin: true, - emailVerified: true, - }, - }); + const user = await getUserFromRequest(request); if (!user) { return NextResponse.json({ user: null }); diff --git a/src/app/api/downloads/route.ts b/src/app/api/downloads/route.ts index 92b19e7..65ece8c 100644 --- a/src/app/api/downloads/route.ts +++ b/src/app/api/downloads/route.ts @@ -1,6 +1,7 @@ import { db } from '@/lib/db'; import { getUserFromRequest } from '@/lib/auth-helpers'; import { sanitizeText } from '@/lib/sanitize'; +import { isTrustedMutationOrigin } from '@/lib/request-origin'; import { NextResponse } from 'next/server'; export async function GET(request: Request) { @@ -34,6 +35,10 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized — admin access required' }, { status: 401 }); } + if (!isTrustedMutationOrigin(request)) { + return NextResponse.json({ error: 'Forbidden — untrusted request origin' }, { status: 403 }); + } + const data = await request.json(); // Sanitize text fields diff --git a/src/app/api/guides/[id]/route.ts b/src/app/api/guides/[id]/route.ts index ad84317..f1f3060 100644 --- a/src/app/api/guides/[id]/route.ts +++ b/src/app/api/guides/[id]/route.ts @@ -1,6 +1,7 @@ import { db } from '@/lib/db'; import { getUserFromRequest } from '@/lib/auth-helpers'; import { sanitizeText } from '@/lib/sanitize'; +import { isTrustedMutationOrigin } from '@/lib/request-origin'; import { NextResponse } from 'next/server'; export async function GET( @@ -33,6 +34,10 @@ export async function PUT( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } + if (!isTrustedMutationOrigin(request)) { + return NextResponse.json({ error: 'Forbidden — untrusted request origin' }, { status: 403 }); + } + const { id } = await params; const data = await request.json(); diff --git a/src/app/api/guides/route.ts b/src/app/api/guides/route.ts index 992a568..4de26d1 100644 --- a/src/app/api/guides/route.ts +++ b/src/app/api/guides/route.ts @@ -2,6 +2,7 @@ import { db } from '@/lib/db'; import { getUserFromRequest } from '@/lib/auth-helpers'; import { checkGuideAccess } from '@/lib/subscription-guard'; import { sanitizeText } from '@/lib/sanitize'; +import { isTrustedMutationOrigin } from '@/lib/request-origin'; import { NextResponse } from 'next/server'; export async function GET(request: Request) { @@ -62,6 +63,10 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized — admin access required' }, { status: 401 }); } + if (!isTrustedMutationOrigin(request)) { + return NextResponse.json({ error: 'Forbidden — untrusted request origin' }, { status: 403 }); + } + const data = await request.json(); // Validate required fields diff --git a/src/lib/auth-helpers.ts b/src/lib/auth-helpers.ts index 2a828cd..66b31df 100644 --- a/src/lib/auth-helpers.ts +++ b/src/lib/auth-helpers.ts @@ -74,9 +74,13 @@ export async function getUserFromRequest(request: NextRequest | Request): Promis subscriptionTier: true, isAdmin: true, emailVerified: true, + sessionVersion: true, }, }); - if (user) return user; + if (user && user.sessionVersion === payload.sessionVersion) { + const { sessionVersion: _sessionVersion, ...authUser } = user; + return authUser; + } } } } catch { diff --git a/src/lib/client-ip.ts b/src/lib/client-ip.ts new file mode 100644 index 0000000..b0dabab --- /dev/null +++ b/src/lib/client-ip.ts @@ -0,0 +1,24 @@ +type HeaderReader = Pick; + +const VERCEL_CLIENT_IP_HEADER = 'x-vercel-forwarded-for'; +const IP_ADDRESS_PATTERN = /^[0-9a-f:.]+$/i; +const UNSAFE_CUSTOM_HEADERS = new Set(['x-forwarded-for', 'x-real-ip']); + +/** + * Resolve the rate-limit identity only from a header supplied by a trusted + * deployment proxy. Client-controlled forwarding headers are deliberately + * ignored when the deployment has not declared a trust boundary. + */ +export function getTrustedClientIp(headers: HeaderReader): string { + const configuredHeader = process.env.TRUSTED_CLIENT_IP_HEADER?.trim().toLowerCase(); + if (configuredHeader && UNSAFE_CUSTOM_HEADERS.has(configuredHeader)) return 'unknown'; + const trustedHeader = configuredHeader || (process.env.VERCEL === '1' ? VERCEL_CLIENT_IP_HEADER : null); + if (!trustedHeader) return 'unknown'; + + const value = headers.get(trustedHeader)?.trim(); + if (!value || value.length > 64 || value.includes(',') || !IP_ADDRESS_PATTERN.test(value)) { + return 'unknown'; + } + + return value; +} diff --git a/src/lib/request-origin.ts b/src/lib/request-origin.ts new file mode 100644 index 0000000..dcb574f --- /dev/null +++ b/src/lib/request-origin.ts @@ -0,0 +1,33 @@ +function configuredOrigin(request: Request): string | null { + const configuredUrl = process.env.NEXT_PUBLIC_APP_URL; + if (configuredUrl) { + try { + return new URL(configuredUrl).origin; + } catch { + return null; + } + } + + if (process.env.NODE_ENV === 'production') return null; + + try { + return new URL(request.url).origin; + } catch { + return null; + } +} + +export function isTrustedMutationOrigin(request: Request): boolean { + const expectedOrigin = configuredOrigin(request); + const suppliedOrigin = request.headers.get('origin'); + if (!expectedOrigin || !suppliedOrigin || suppliedOrigin === 'null') return false; + + const fetchSite = request.headers.get('sec-fetch-site'); + if (fetchSite && fetchSite !== 'same-origin') return false; + + try { + return new URL(suppliedOrigin).origin === expectedOrigin; + } catch { + return false; + } +} diff --git a/src/lib/session-revocation.ts b/src/lib/session-revocation.ts new file mode 100644 index 0000000..3fc6b44 --- /dev/null +++ b/src/lib/session-revocation.ts @@ -0,0 +1,10 @@ +import { db } from '@/lib/db'; + +/** Revoke every issued session for one user. */ +export async function revokeAllUserSessions(userId: string): Promise { + await db.user.update({ + where: { id: userId }, + data: { sessionVersion: { increment: 1 } }, + select: { id: true }, + }); +} diff --git a/src/lib/session.ts b/src/lib/session.ts index ee9ef43..7a3ea76 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -30,6 +30,7 @@ export interface SessionPayload { email: string; tier: string; // subscriptionTier isAdmin: boolean; + sessionVersion: number; } /** @@ -78,6 +79,7 @@ export async function verifySession(request: NextRequest): Promise email: payload.email as string, tier: payload.tier as string, isAdmin: payload.isAdmin as boolean, + sessionVersion: payload.sessionVersion as number, }; } catch { return null; diff --git a/src/middleware.ts b/src/middleware.ts index c991dfd..2d7af3d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getTrustedClientIp } from '@/lib/client-ip'; // ─── In-memory rate limiter (Edge Runtime compatible) ───────────────────────── // Note: Edge Runtime does not support fs/path/process.cwd(), so we use in-memory @@ -12,12 +13,6 @@ const GENERAL_MAX = Number(process.env.API_RATE_LIMIT_MAX) || 60; // requests pe const AUTH_WINDOW = 15 * 60_000; // 15 minutes const AUTH_MAX = Number(process.env.AUTH_RATE_LIMIT_MAX) || 10; // auth attempts per 15 minutes per IP -function getClientIp(request: NextRequest): string { - const forwarded = request.headers.get('x-forwarded-for'); - const ip = forwarded ? forwarded.split(',')[0].trim() : request.headers.get('x-real-ip') || 'unknown'; - return ip; -} - function isRateLimited(key: string, max: number, window: number): boolean { const now = Date.now(); const entry = rateLimitMap.get(key); @@ -58,7 +53,7 @@ export function middleware(request: NextRequest) { cleanupRateLimits(); - const clientIp = getClientIp(request); + const clientIp = getTrustedClientIp(request.headers); // Stricter rate limit on auth endpoints (brute force protection) if (pathname === '/api/auth/login' || pathname === '/api/auth/register') {