From 7ece96688db76a5f33c3279641b0dfa8af2ed0db Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:12:14 -0400 Subject: [PATCH 1/6] test(19-01): add failing tests for hashPassword/verifyPassword scrypt primitives --- apps/api/tests/auth/localCredentials.test.ts | 57 ++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 apps/api/tests/auth/localCredentials.test.ts diff --git a/apps/api/tests/auth/localCredentials.test.ts b/apps/api/tests/auth/localCredentials.test.ts new file mode 100644 index 0000000..5bc5433 --- /dev/null +++ b/apps/api/tests/auth/localCredentials.test.ts @@ -0,0 +1,57 @@ +/** + * localCredentials.ts — unit tests for hashPassword / verifyPassword. + * + * Uses node:crypto scrypt under the hood; no external dependencies. + * All tests run without MariaDB or any external service. + * + * Test suite (TDD RED → GREEN — Plan 19-01 Task 1): + * Test 1: correct password verifies true + * Test 2: wrong password verifies false + * Test 3: two hashes of the same input produce different encoded strings (unique salt) + * Test 4: verifyPassword never throws on a malformed hash (returns false) + * Test 5: encoded string has the PHC shape: scrypt$N$r$p$$ (6 segments) + */ + +import { describe, it, expect } from 'vitest'; +import { hashPassword, verifyPassword } from '../../src/auth/localCredentials.js'; + +describe('hashPassword / verifyPassword', () => { + it('Test 1: verifyPassword(hashPassword(pw), pw) === true (round-trip)', () => { + const encoded = hashPassword('hunter2'); + const result = verifyPassword(encoded, 'hunter2'); + expect(result).toBe(true); + }); + + it('Test 2: verifyPassword(hashPassword(pw), wrong) === false', () => { + const encoded = hashPassword('hunter2'); + const result = verifyPassword(encoded, 'wrong-password'); + expect(result).toBe(false); + }); + + it('Test 3: two hashPassword calls on same input produce different encoded strings (unique salt)', () => { + const encoded1 = hashPassword('x'); + const encoded2 = hashPassword('x'); + expect(encoded1).not.toBe(encoded2); + }); + + it('Test 4: verifyPassword returns false (never throws) on a malformed stored hash', () => { + expect(() => verifyPassword('not-a-valid-hash', 'x')).not.toThrow(); + expect(verifyPassword('not-a-valid-hash', 'x')).toBe(false); + expect(verifyPassword('', 'x')).toBe(false); + expect(verifyPassword('scrypt$bad$data', 'x')).toBe(false); + }); + + it('Test 5: encoded string has scrypt$N$r$p$salt$hash shape (6 $-delimited segments)', () => { + const encoded = hashPassword('testpassword'); + const segments = encoded.split('$'); + expect(segments).toHaveLength(6); + expect(segments[0]).toBe('scrypt'); + // N, r, p are numeric + expect(Number(segments[1])).toBeGreaterThan(0); // N + expect(Number(segments[2])).toBeGreaterThan(0); // r + expect(Number(segments[3])).toBeGreaterThan(0); // p + // salt and hash are non-empty base64url strings + expect(segments[4].length).toBeGreaterThan(0); + expect(segments[5].length).toBeGreaterThan(0); + }); +}); From 85b01b5c264945caa30315be11491a8a60b56793 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:12:59 -0400 Subject: [PATCH 2/6] feat(19-01): implement hashPassword/verifyPassword with scrypt + timingSafeEqual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - node:crypto scrypt (N=16384, r=8, p=1, 32-byte output) — zero new dependencies (D-08) - 16-byte random salt per hash; PHC-encoded format: scrypt$N$r$p$salt_b64url$hash_b64url - timingSafeEqual for constant-time comparison (prevents timing oracle attacks, T-19-01) - verifyPassword returns false on any error (never throws); passwords never logged - All 5 unit tests pass (round-trip, wrong-pw, unique-salt, malformed-hash, PHC-shape) --- apps/api/src/auth/localCredentials.ts | 90 +++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 apps/api/src/auth/localCredentials.ts diff --git a/apps/api/src/auth/localCredentials.ts b/apps/api/src/auth/localCredentials.ts new file mode 100644 index 0000000..7245fa4 --- /dev/null +++ b/apps/api/src/auth/localCredentials.ts @@ -0,0 +1,90 @@ +/** + * localCredentials.ts — password hashing and verification using node:crypto scrypt. + * + * D-08: Uses node:crypto scrypt — zero new npm dependencies; no native node-gyp build + * in the Docker image. PHC-style encoded format allows parameter evolution without + * a separate DB migration. + * + * Security properties: + * - 16-byte per-hash random salt — unique salt per password prevents rainbow table attacks + * - scrypt parameters: N=16384 (2^14), r=8, p=1 — OWASP-compatible + * - 32-byte (256-bit) output key + * - timingSafeEqual for constant-time comparison — prevents timing oracle attacks + * - verifyPassword never throws — returns false on any parse/format/crypto error + * - Passwords are never logged + * + * Encoded format: scrypt$N$r$p$$ + * Example: scrypt$16384$8$1$<22-char-b64url>$<43-char-b64url> + * Max length: ~83 chars — fits in varchar(256) password_hash column + */ + +import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; + +// OWASP-compatible scrypt parameters for password hashing +const SCRYPT_N = 16384; // CPU/memory cost factor (2^14) +const SCRYPT_R = 8; // block size +const SCRYPT_P = 1; // parallelization factor +const KEY_LEN = 32; // 256-bit derived key output + +/** + * Hash a password using scrypt with a random 16-byte salt. + * + * Returns a self-describing PHC-style encoded string: + * scrypt$N$r$p$$ + * + * The encoded format embeds all parameters so verifyPassword can re-derive + * the hash without relying on hardcoded constants — supports future parameter + * migration without a DB schema change. + * + * NOTE: scryptSync blocks the event loop. For a 2-person household with + * infrequent logins this is acceptable. Use promisify(scrypt) if async is needed. + */ +export function hashPassword(password: string): string { + const salt = randomBytes(16); + const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }); + return [ + 'scrypt', + SCRYPT_N, + SCRYPT_R, + SCRYPT_P, + salt.toString('base64url'), + hash.toString('base64url'), + ].join('$'); +} + +/** + * Verify a password against a stored PHC-encoded hash. + * + * Parses the algorithm parameters from the stored string, re-derives the + * candidate hash using scryptSync, and compares with timingSafeEqual to + * prevent timing-oracle attacks. + * + * Security: + * - timingSafeEqual: requires equal-length buffers; storedHash.length as keylen + * ensures this regardless of the stored KEY_LEN at hash time. + * - Returns false (never throws) on any parse error, invalid base64url, or + * scrypt parameter error — safe to call with untrusted input. + * - Never logs the candidate password. + * + * @returns true if candidate matches the stored hash; false otherwise (incl. errors) + */ +export function verifyPassword(storedEncoded: string, candidate: string): boolean { + try { + const parts = storedEncoded.split('$'); + if (parts.length !== 6) return false; + const [, n, r, p, saltB64, hashB64] = parts; + const salt = Buffer.from(saltB64, 'base64url'); + const storedHash = Buffer.from(hashB64, 'base64url'); + if (salt.length === 0 || storedHash.length === 0) return false; + const candidateHash = scryptSync(candidate, salt, storedHash.length, { + N: Number(n), + r: Number(r), + p: Number(p), + }); + return timingSafeEqual(storedHash, candidateHash); + } catch { + // Catch any scrypt parameter errors, buffer errors, or other crypto exceptions. + // Never propagate — return false for all error cases. + return false; + } +} From 0d8f3fa05160443e9ebae7f1bbecbfb975371d4c Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:13:33 -0400 Subject: [PATCH 3/6] test(19-01): add failing tests for localSession JWT cookie helpers and assertLocalSessionSecretSet --- apps/api/tests/auth/localSession.test.ts | 168 +++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 apps/api/tests/auth/localSession.test.ts diff --git a/apps/api/tests/auth/localSession.test.ts b/apps/api/tests/auth/localSession.test.ts new file mode 100644 index 0000000..2d86a4d --- /dev/null +++ b/apps/api/tests/auth/localSession.test.ts @@ -0,0 +1,168 @@ +/** + * localSession.ts + bootGuards.ts — unit tests for JWT session cookie helpers + * and assertLocalSessionSecretSet boot guard. + * + * Uses Hono test app for cookie round-trips. All tests run without MariaDB. + * + * Test suite (TDD RED → GREEN — Plan 19-01 Task 2): + * Test 1: issue then verify round-trips userId + * Test 2: verifyLocalSessionCookie returns null when no local-session cookie present (no throw) + * Test 3: verifyLocalSessionCookie returns null for a tampered/garbage token (no throw) + * Test 4: assertLocalSessionSecretSet does NOT exit when DEV_AUTH_BYPASS='true' even if secret unset + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Hono } from 'hono'; + +// ── Test constants ───────────────────────────────────────────────────────────── +const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt'; +const TEST_USER_ID = 42; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** Create a minimal Hono test app with an issue route and a verify route. */ +function makeTestApp(secret: string | undefined) { + return { + setup: async () => { + // Import inside function to pick up modified env + const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import( + '../../src/auth/localSession.js' + ); + const app = new Hono(); + + app.post('/issue', async (c) => { + await issueLocalSessionCookie(c, TEST_USER_ID); + return c.json({ ok: true }); + }); + + app.get('/verify', async (c) => { + const userId = await verifyLocalSessionCookie(c); + return c.json({ userId }); + }); + + return app; + }, + }; +} + +describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + process.env.LOCAL_SESSION_SECRET = TEST_SECRET; + vi.resetModules(); // ensure fresh imports pick up env changes + }); + + afterEach(() => { + process.env = originalEnv; + vi.resetModules(); + }); + + it('Test 1: issue then verify round-trips userId', async () => { + const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import( + '../../src/auth/localSession.js' + ); + const app = new Hono(); + + app.post('/issue', async (c) => { + await issueLocalSessionCookie(c, TEST_USER_ID); + return c.json({ ok: true }); + }); + + app.get('/verify', async (c) => { + const userId = await verifyLocalSessionCookie(c); + return c.json({ userId }); + }); + + // Issue cookie + const issueRes = await app.request('/issue', { method: 'POST' }); + expect(issueRes.status).toBe(200); + + const setCookieHeader = issueRes.headers.get('set-cookie'); + expect(setCookieHeader).not.toBeNull(); + expect(setCookieHeader).toContain('local-session='); + + // Extract cookie value and forward it for verify + const cookieHeader = setCookieHeader?.split(';')[0]; // just name=value + const verifyRes = await app.request('/verify', { + headers: { cookie: cookieHeader ?? '' }, + }); + expect(verifyRes.status).toBe(200); + const body = (await verifyRes.json()) as { userId: number | null }; + expect(body.userId).toBe(TEST_USER_ID); + }); + + it('Test 2: verifyLocalSessionCookie returns null when no local-session cookie present (no throw)', async () => { + const { verifyLocalSessionCookie } = await import('../../src/auth/localSession.js'); + const app = new Hono(); + + app.get('/verify', async (c) => { + const userId = await verifyLocalSessionCookie(c); + return c.json({ userId }); + }); + + const res = await app.request('/verify'); + expect(res.status).toBe(200); + const body = (await res.json()) as { userId: number | null }; + expect(body.userId).toBeNull(); + }); + + it('Test 3: verifyLocalSessionCookie returns null for a tampered/garbage token (no throw)', async () => { + const { verifyLocalSessionCookie } = await import('../../src/auth/localSession.js'); + const app = new Hono(); + + app.get('/verify', async (c) => { + const userId = await verifyLocalSessionCookie(c); + return c.json({ userId }); + }); + + // Send a garbage token — should return null without throwing + const res = await app.request('/verify', { + headers: { cookie: 'local-session=garbage.token.value' }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { userId: number | null }; + expect(body.userId).toBeNull(); + }); +}); + +describe('assertLocalSessionSecretSet (bootGuards)', () => { + let originalEnv: NodeJS.ProcessEnv; + let exitSpy: ReturnType; + + beforeEach(() => { + originalEnv = { ...process.env }; + exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as never); + vi.resetModules(); + }); + + afterEach(() => { + process.env = originalEnv; + exitSpy.mockRestore(); + vi.resetModules(); + }); + + it('Test 4: assertLocalSessionSecretSet does NOT exit when DEV_AUTH_BYPASS=true even if secret unset', async () => { + process.env.DEV_AUTH_BYPASS = 'true'; + delete process.env.LOCAL_SESSION_SECRET; + + const { assertLocalSessionSecretSet } = await import('../../src/lib/bootGuards.js'); + + // Must not throw / must not call process.exit + expect(() => assertLocalSessionSecretSet()).not.toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('assertLocalSessionSecretSet is exported from bootGuards', async () => { + process.env.LOCAL_SESSION_SECRET = TEST_SECRET; + delete process.env.DEV_AUTH_BYPASS; + + const bootGuards = await import('../../src/lib/bootGuards.js'); + + // Must export the function + expect(typeof bootGuards.assertLocalSessionSecretSet).toBe('function'); + }); +}); From 7d61148415e7dbf6d3cd4fb3f2f827ea333501a6 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:14:48 -0400 Subject: [PATCH 4/6] feat(19-01): implement localSession JWT cookie helpers and assertLocalSessionSecretSet boot guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - localSession.ts: issueLocalSessionCookie/verifyLocalSessionCookie/clearLocalSessionCookie - Jwt namespace import from hono/utils/jwt (Pitfall 8 — not named sign/verify) - Cookie name: 'local-session' (distinct from 'oidc-auth', Pitfall 4) - httpOnly, sameSite=Lax, secure in production; try/catch on Jwt.verify (Pitfall 9) - verifyLocalSessionCookie returns null (never throws) on any error - bootGuards.ts: assertLocalSessionSecretSet — exit(1) if secret missing/<32 chars - Exempt when DEV_AUTH_BYPASS=true (bypass doesn't issue local-session cookies) - index.ts: wire assertLocalSessionSecretSet() after assertNotDevBypassInProduction() - All 5 unit tests pass; typecheck exits 0 --- apps/api/src/auth/localSession.ts | 106 ++++++++++++++++++++++++++++++ apps/api/src/index.ts | 4 +- apps/api/src/lib/bootGuards.ts | 32 +++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/auth/localSession.ts diff --git a/apps/api/src/auth/localSession.ts b/apps/api/src/auth/localSession.ts new file mode 100644 index 0000000..1308e8b --- /dev/null +++ b/apps/api/src/auth/localSession.ts @@ -0,0 +1,106 @@ +/** + * localSession.ts — stateless JWT session-cookie helpers for local auth (D-05). + * + * Issues and verifies a signed httpOnly JWT cookie named 'local-session', + * distinct from the OIDC cookie 'oidc-auth' (Pitfall 4). + * + * Uses Hono's built-in Jwt from 'hono/utils/jwt' via the { Jwt } namespace import + * (Pitfall 8 — named sign/verify do not exist; Jwt.sign/Jwt.verify is correct). + * + * Security properties (T-19-02): + * - HS256 signed with LOCAL_SESSION_SECRET from env (never stored in DB — SC-3) + * - httpOnly: true — not accessible from client JavaScript + * - secure: true in production, false in non-production (allows local dev over HTTP) + * - sameSite: 'Lax' — CSRF mitigation for browser navigation + * - Jwt.verify throws on expiry (Pitfall 9) — verifyLocalSessionCookie wraps in try/catch + * - verifyLocalSessionCookie returns null (never throws) on any error + * + * Boot guard: + * assertLocalSessionSecretSet() in lib/bootGuards.ts refuses to start if secret + * is missing or < 32 chars when not in dev-bypass mode (Pitfall 10 / T-19-03). + */ + +import { Jwt } from 'hono/utils/jwt'; +import { setCookie, getCookie, deleteCookie } from 'hono/cookie'; +import type { Context } from 'hono'; + +// Cookie name must be distinct from the OIDC cookie 'oidc-auth' (Pitfall 4) +const COOKIE_NAME = 'local-session'; + +// Session max age: default 1 day (86400s); configurable via LOCAL_SESSION_EXPIRES env +const SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400); + +/** + * Issue a signed local-session JWT cookie for the given userId. + * + * The JWT payload carries: { userId, iat, exp } (HS256, signed with LOCAL_SESSION_SECRET). + * Throws if LOCAL_SESSION_SECRET is not set — the boot guard should have caught this. + */ +export async function issueLocalSessionCookie(c: Context, userId: number): Promise { + const secret = process.env.LOCAL_SESSION_SECRET; + if (!secret) throw new Error('LOCAL_SESSION_SECRET env var not set'); + + const now = Math.floor(Date.now() / 1000); + const payload = { + userId, + iat: now, + exp: now + SESSION_MAX_AGE_SECONDS, + }; + + // Pitfall 8: use Jwt.sign (namespace import), NOT named sign from hono/utils/jwt + const token = await Jwt.sign(payload, secret, 'HS256'); + + setCookie(c, COOKIE_NAME, token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'Lax', + path: '/', + maxAge: SESSION_MAX_AGE_SECONDS, + }); +} + +/** + * Verify the local-session JWT cookie and return the userId, or null. + * + * Returns null (never throws) when: + * - LOCAL_SESSION_SECRET is not set + * - No 'local-session' cookie is present + * - The JWT is expired (Jwt.verify throws JwtTokenExpired — caught here, Pitfall 9) + * - The JWT has been tampered with + * - The payload.userId is not a number + * - Any other crypto/parse error + */ +export async function verifyLocalSessionCookie(c: Context): Promise { + const secret = process.env.LOCAL_SESSION_SECRET; + if (!secret) return null; + + const token = getCookie(c, COOKIE_NAME); + if (!token) return null; + + try { + // Pitfall 8: Jwt.sign/Jwt.verify (namespace import — NOT named exports) + // Pitfall 9: Jwt.verify throws on expiry — must catch all errors and return null + const payload = await Jwt.verify(token, secret, 'HS256'); + return typeof payload.userId === 'number' ? payload.userId : null; + } catch { + // Includes JwtTokenExpired, tampered signature, malformed token, etc. + return null; + } +} + +/** + * Clear the local-session cookie. + * + * Cookie attributes must match the ones set on issue so the browser correctly + * expires the cookie (path, httpOnly, sameSite all must match). + */ +export function clearLocalSessionCookie(c: Context): void { + deleteCookie(c, COOKIE_NAME, { + path: '/', + httpOnly: true, + // Use secure:true for delete (browsers only accept the attribute in matching context) + // In practice this is safe because logout should happen over HTTPS in production. + secure: true, + sameSite: 'Lax', + }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 353e53d..214822f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,7 +21,7 @@ import { persistSessionCookie } from './auth/persistSessionCookie.js'; import { startBrokerPoller } from './broker/poller.js'; import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js'; import { startReminderScheduler } from './broker/reminderScheduler.js'; -import { assertNotDevBypassInProduction } from './lib/bootGuards.js'; +import { assertNotDevBypassInProduction, assertLocalSessionSecretSet } from './lib/bootGuards.js'; import webpush from 'web-push'; export const app = new Hono(); @@ -134,6 +134,8 @@ function isMainModule(): boolean { if (isMainModule()) { // D-08: Production safety guard — must be FIRST, before VAPID config, workers, or serve(). assertNotDevBypassInProduction(); + // D-05 / T-19-03: Refuse to start if LOCAL_SESSION_SECRET is missing/short in non-bypass mode. + assertLocalSessionSecretSet(); // Configure VAPID credentials for web-push before starting background workers. // VAPID_SUBJECT must be a mailto: or https: URL identifying the operator. diff --git a/apps/api/src/lib/bootGuards.ts b/apps/api/src/lib/bootGuards.ts index 315c53b..aa0cff0 100644 --- a/apps/api/src/lib/bootGuards.ts +++ b/apps/api/src/lib/bootGuards.ts @@ -32,3 +32,35 @@ export function assertNotDevBypassInProduction(): void { process.exit(1); } } + +/** + * Refuses to start the process when LOCAL_SESSION_SECRET is absent or shorter + * than 32 characters, UNLESS dev-bypass mode is active. + * + * Rationale (D-05 / T-19-03 / Pitfall 10): + * LOCAL_SESSION_SECRET signs the local-session JWT cookie. A missing or weak + * secret means any issued session cookie can be trivially forged. This boot + * guard converts a silent misconfiguration into an immediate loud failure + * instead of letting the API start and issue insecure JWTs. + * + * DEV_AUTH_BYPASS=true is exempt: bypass mode never issues local-session cookies + * (the OIDC/dev-bypass path handles auth), so the secret is not required there. + * This mirrors assertNotDevBypassInProduction's exempt logic. + * + * Call immediately after assertNotDevBypassInProduction() in the isMainModule() + * boot block in index.ts. + */ +export function assertLocalSessionSecretSet(): void { + // Exempt when dev-bypass is active — bypass mode doesn't issue local-session JWTs + if (process.env.DEV_AUTH_BYPASS === 'true') return; + + const secret = process.env.LOCAL_SESSION_SECRET; + if (!secret || secret.length < 32) { + console.error( + '[FATAL] LOCAL_SESSION_SECRET is not set or is shorter than 32 characters. ' + + 'This secret signs local-session JWT cookies. Refusing to start. ' + + 'Run: node scripts/generate-secrets.mjs to generate a value.', + ); + process.exit(1); + } +} From 96f0991605945972c8f7e8d11dcd3ec1668c8099 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:17:04 -0400 Subject: [PATCH 5/6] feat(19-01): add local_credentials schema, 0003 migration, generate-secrets LOCAL_SESSION_SECRET, .dockerignore D-15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema.ts: export localCredentials = mysqlTable('local_credentials', {...}) - user_id FK->users(cascade), username, password_hash, createdAt, updatedAt - UNIQUE(user_id), UNIQUE(username), INDEX(user_id) - 0003_warm_deathstrike.sql: purely additive CREATE TABLE (no ALTER/DROP/TRUNCATE on existing tables) - Applied to dev DB: pnpm --filter @familysync/api db:migrate exits 0 - test/setup.ts: add localCredentials to afterEach delete cleanup (FK-safe ordering) - generate-secrets.mjs: emit LOCAL_SESSION_SECRET (base64 32-byte, >=32 chars, D-05) - .dockerignore: add apps/api/scripts/ exclusion (D-15/IMG-02) — entire break-glass dir excluded --- .dockerignore | 4 +- .../db/migrations/0003_warm_deathstrike.sql | 14 + .../src/db/migrations/meta/0003_snapshot.json | 1144 +++++++++++++++++ apps/api/src/db/migrations/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 41 + apps/api/test/setup.ts | 5 +- scripts/generate-secrets.mjs | 5 + 7 files changed, 1218 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/db/migrations/0003_warm_deathstrike.sql create mode 100644 apps/api/src/db/migrations/meta/0003_snapshot.json diff --git a/.dockerignore b/.dockerignore index abbfc41..09a9488 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,9 @@ .env .env.* !.env.example -apps/api/scripts/seed-credential.mjs +# Phase 19 (D-15 / IMG-02): exclude the entire break-glass scripts directory so +# reset-admin.ts and any future dev-only scripts never ship in the production image. +apps/api/scripts/ # === VCS (large and unnecessary) === .git diff --git a/apps/api/src/db/migrations/0003_warm_deathstrike.sql b/apps/api/src/db/migrations/0003_warm_deathstrike.sql new file mode 100644 index 0000000..96711bf --- /dev/null +++ b/apps/api/src/db/migrations/0003_warm_deathstrike.sql @@ -0,0 +1,14 @@ +CREATE TABLE `local_credentials` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` int NOT NULL, + `username` varchar(128) NOT NULL, + `password_hash` varchar(256) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`), + CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`), + CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`) +); +--> statement-breakpoint +ALTER TABLE `local_credentials` ADD CONSTRAINT `local_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_local_credentials_user_id` ON `local_credentials` (`user_id`); \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0003_snapshot.json b/apps/api/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..443a33b --- /dev/null +++ b/apps/api/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,1144 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "81927b64-67fa-4476-989d-fc9c55f418ab", + "prevId": "10d0f5e8-4445-429e-8077-49075e88db4b", + "tables": { + "app_config": { + "name": "app_config", + "columns": { + "key": { + "name": "key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_config_key": { + "name": "app_config_key", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "calendar_events": { + "name": "calendar_events", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "calendar_id": { + "name": "calendar_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uid": { + "name": "uid", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "object_url": { + "name": "object_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_vevent": { + "name": "raw_vevent", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dtstart_utc": { + "name": "dtstart_utc", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dtstart_date": { + "name": "dtstart_date", + "type": "date", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "all_day": { + "name": "all_day", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "has_rrule": { + "name": "has_rrule", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "reminder_lead_minutes": { + "name": "reminder_lead_minutes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_calendar_events_dtstart_utc": { + "name": "idx_calendar_events_dtstart_utc", + "columns": [ + "dtstart_utc" + ], + "isUnique": false + }, + "idx_calendar_events_dtstart_date": { + "name": "idx_calendar_events_dtstart_date", + "columns": [ + "dtstart_date" + ], + "isUnique": false + }, + "idx_calendar_events_has_rrule": { + "name": "idx_calendar_events_has_rrule", + "columns": [ + "has_rrule" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendar_events_calendar_id_calendars_id_fk": { + "name": "calendar_events_calendar_id_calendars_id_fk", + "tableFrom": "calendar_events", + "tableTo": "calendars", + "columnsFrom": [ + "calendar_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendar_events_id": { + "name": "calendar_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_calendar_uid": { + "name": "uniq_calendar_uid", + "columns": [ + "calendar_id", + "uid" + ] + } + }, + "checkConstraint": {} + }, + "calendar_outbox": { + "name": "calendar_outbox", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "enum('create','update','delete')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('pending','done','failed','dead')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "uid": { + "name": "uid", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_url": { + "name": "calendar_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "calendar_object_url": { + "name": "calendar_object_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_outbox_user_status": { + "name": "idx_outbox_user_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_outbox_next_attempt": { + "name": "idx_outbox_next_attempt", + "columns": [ + "next_attempt_at", + "status" + ], + "isUnique": false + }, + "idx_outbox_uid": { + "name": "idx_outbox_uid", + "columns": [ + "uid" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendar_outbox_user_id_users_id_fk": { + "name": "calendar_outbox_user_id_users_id_fk", + "tableFrom": "calendar_outbox", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendar_outbox_id": { + "name": "calendar_outbox_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "calendars": { + "name": "calendars", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ctag": { + "name": "ctag", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_token": { + "name": "sync_token", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_calendars_user_id": { + "name": "idx_calendars_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "calendars_user_id_users_id_fk": { + "name": "calendars_user_id_users_id_fk", + "tableFrom": "calendars", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "calendars_id": { + "name": "calendars_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_calendar_user_url": { + "name": "uniq_calendar_user_url", + "columns": [ + "user_id", + "url" + ] + } + }, + "checkConstraint": {} + }, + "list_items": { + "name": "list_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "list_id": { + "name": "list_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "rank": { + "name": "rank", + "type": "varchar(255) COLLATE utf8mb4_bin", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_list_items_list_id_rank": { + "name": "idx_list_items_list_id_rank", + "columns": [ + "list_id", + "rank" + ], + "isUnique": false + }, + "idx_list_items_list_id_checked": { + "name": "idx_list_items_list_id_checked", + "columns": [ + "list_id", + "checked" + ], + "isUnique": false + } + }, + "foreignKeys": { + "list_items_list_id_lists_id_fk": { + "name": "list_items_list_id_lists_id_fk", + "tableFrom": "list_items", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_items_id": { + "name": "list_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "list_shares": { + "name": "list_shares", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "list_id": { + "name": "list_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "idx_list_shares_user_id": { + "name": "idx_list_shares_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "list_shares_list_id_lists_id_fk": { + "name": "list_shares_list_id_lists_id_fk", + "tableFrom": "list_shares", + "tableTo": "lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_shares_user_id_users_id_fk": { + "name": "list_shares_user_id_users_id_fk", + "tableFrom": "list_shares", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "list_shares_id": { + "name": "list_shares_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_list_share": { + "name": "uniq_list_share", + "columns": [ + "list_id", + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "lists": { + "name": "lists", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "owner_id": { + "name": "owner_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_shared": { + "name": "is_shared", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_lists_owner_id": { + "name": "idx_lists_owner_id", + "columns": [ + "owner_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "lists_owner_id_users_id_fk": { + "name": "lists_owner_id_users_id_fk", + "tableFrom": "lists", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "lists_id": { + "name": "lists_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "local_credentials": { + "name": "local_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_local_credentials_user_id": { + "name": "idx_local_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "local_credentials_user_id_users_id_fk": { + "name": "local_credentials_user_id_users_id_fk", + "tableFrom": "local_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "local_credentials_id": { + "name": "local_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_local_cred_user": { + "name": "uniq_local_cred_user", + "columns": [ + "user_id" + ] + }, + "uniq_local_cred_username": { + "name": "uniq_local_cred_username", + "columns": [ + "username" + ] + } + }, + "checkConstraint": {} + }, + "member_credentials": { + "name": "member_credentials", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_password": { + "name": "encrypted_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fastmail_email": { + "name": "fastmail_email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'caldav'" + } + }, + "indexes": { + "idx_member_credentials_user_id": { + "name": "idx_member_credentials_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "member_credentials_user_id_users_id_fk": { + "name": "member_credentials_user_id_users_id_fk", + "tableFrom": "member_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "member_credentials_id": { + "name": "member_credentials_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_member_credential_user": { + "name": "uniq_member_credential_user", + "columns": [ + "user_id" + ] + } + }, + "checkConstraint": {} + }, + "push_subscriptions": { + "name": "push_subscriptions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "p256dh": { + "name": "p256dh", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auth": { + "name": "auth", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": { + "idx_push_subscriptions_user_id": { + "name": "idx_push_subscriptions_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_subscriptions_id": { + "name": "push_subscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_push_endpoint": { + "name": "uniq_push_endpoint", + "columns": [ + "endpoint" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "oidc_iss": { + "name": "oidc_iss", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oidc_sub": { + "name": "oidc_sub", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "claimed": { + "name": "claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "uniq_oidc_identity": { + "name": "uniq_oidc_identity", + "columns": [ + "oidc_iss", + "oidc_sub" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index cab20d4..a2e39b6 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1781545048917, "tag": "0002_lethal_millenium_guard", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1781727317172, + "tag": "0003_warm_deathstrike", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 220abc3..507bd3f 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -309,6 +309,47 @@ export const appConfig = mysqlTable('app_config', { updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), }); +/** + * Local authentication credentials per member (Phase 19 — D-09). + * + * Stores username + PHC-encoded scrypt password hash for members who authenticate + * via local username/password rather than (or before) OIDC. + * + * Design decisions: + * - Separate table from `users` to keep the users row identity-method-agnostic (D-09). + * - A user has a local login iff a `local_credentials` row exists (UNIQUE on user_id). + * - OIDC-link flow (D-12): when a local user links OIDC, their `local_credentials` + * row is deleted — they become OIDC-only. + * - CASCADE DELETE on users.id keeps credentials clean when a member is removed. + * - username is globally unique (login identifier, separate from displayName). + * - password_hash is PHC-encoded: scrypt$N$r$p$$ (varchar 256). + * + * PROHIBITION: LOCAL_SESSION_SECRET (the signing key for this table's sessions) is an + * env-only secret and must NEVER be stored in this table or app_config (SC-3). + */ +export const localCredentials = mysqlTable( + 'local_credentials', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + username: varchar('username', { length: 128 }).notNull(), + // PHC-encoded: scrypt$N$r$p$$ — max ~83 chars + passwordHash: varchar('password_hash', { length: 256 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [ + // One local credential per user — user_id is unique (D-09: auth method is per-user property) + unique('uniq_local_cred_user').on(t.userId), + // Username is globally unique (login identifier; case-sensitive per MariaDB default) + unique('uniq_local_cred_username').on(t.username), + // Index for fast lookup by user_id (e.g., on middleware / self-change-password) + index('idx_local_credentials_user_id').on(t.userId), + ], +); + /** * Items within a list. * diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts index f6af415..82ead50 100644 --- a/apps/api/test/setup.ts +++ b/apps/api/test/setup.ts @@ -24,7 +24,7 @@ import { afterEach } from 'vitest'; import { db } from '../src/db/client.js'; -import { lists, listItems, listShares, pushSubscriptions } from '../src/db/schema.js'; +import { lists, listItems, listShares, pushSubscriptions, localCredentials } from '../src/db/schema.js'; /** * Truncate list and push tables in FK-safe order after each test. @@ -39,6 +39,9 @@ afterEach(async () => { await db.delete(listShares); await db.delete(pushSubscriptions); await db.delete(lists); + // Phase 19: local_credentials has FK to users (cascade delete via users); truncate here + // so each test starts with a clean credential slate. users intentionally left intact. + await db.delete(localCredentials); } catch { // DB may not be available in pure-unit test runs (no DB_HOST configured). // Swallow the error — pure-logic tests do not need cleanup. diff --git a/scripts/generate-secrets.mjs b/scripts/generate-secrets.mjs index 181bb9e..f3d577e 100644 --- a/scripts/generate-secrets.mjs +++ b/scripts/generate-secrets.mjs @@ -27,6 +27,9 @@ import { randomBytes, createECDH } from 'node:crypto'; const sessionSecret = randomBytes(32).toString('hex'); const encKey = randomBytes(32).toString('hex'); +// Phase 19 (D-05): LOCAL_SESSION_SECRET signs the local-auth JWT session cookie. +// Must be >= 32 chars. 32 random bytes encoded as base64 = 44 chars (safe, distinct from hex keys). +const localSessionSecret = randomBytes(32).toString('base64'); // VAPID key generation (P-256 / prime256v1 — same curve as web-push) const ecdhCurve = createECDH('prime256v1'); @@ -55,4 +58,6 @@ SESSION_SECRET=${sessionSecret} APP_PASSWORD_ENCRYPTION_KEY=${encKey} VAPID_PUBLIC_KEY=${vapid.publicKey} VAPID_PRIVATE_KEY=${vapid.privateKey} +# Phase 19 (D-05): Signs local-auth JWT session cookies. Required when not using DEV_AUTH_BYPASS. +LOCAL_SESSION_SECRET=${localSessionSecret} `); From d22da015cbf9ce825c6d3200d15174e4d4f3a9fe Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Wed, 17 Jun 2026 16:19:25 -0400 Subject: [PATCH 6/6] docs(19-01): complete local-auth foundation plan (checkpoint reached at Task 4) --- .../19-01-SUMMARY.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 .planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md diff --git a/.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md b/.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md new file mode 100644 index 0000000..d66e5dd --- /dev/null +++ b/.planning/phases/19-local-auth-no-oidc-mode/19-01-SUMMARY.md @@ -0,0 +1,173 @@ +--- +phase: 19-local-auth-no-oidc-mode +plan: "01" +subsystem: auth +tags: [local-auth, scrypt, jwt, session-cookie, migration, boot-guard, docker-hygiene] +status: checkpoint +dependency_graph: + requires: [] + provides: + - hashPassword/verifyPassword (node:crypto scrypt, PHC-encoded) + - issueLocalSessionCookie/verifyLocalSessionCookie/clearLocalSessionCookie (Hono Jwt HS256) + - assertLocalSessionSecretSet (boot guard) + - local_credentials Drizzle table + 0003 migration + - LOCAL_SESSION_SECRET in generate-secrets.mjs + - apps/api/scripts/ .dockerignore exclusion (D-15) + affects: + - apps/api/src/index.ts (boot guard wired) + - apps/api/test/setup.ts (afterEach cleanup) + - .dockerignore (D-15 image hygiene) +tech_stack: + added: [] + patterns: + - PHC-style encoded scrypt hash (scrypt$N$r$p$salt_b64url$hash_b64url) + - Stateless JWT session cookie via hono/utils/jwt Jwt.sign/Jwt.verify + - Boot guard pattern (mirrors assertNotDevBypassInProduction) + - TDD RED/GREEN: failing test committed before implementation +key_files: + created: + - apps/api/src/auth/localCredentials.ts + - apps/api/src/auth/localSession.ts + - apps/api/src/db/migrations/0003_warm_deathstrike.sql + - apps/api/tests/auth/localCredentials.test.ts + - apps/api/tests/auth/localSession.test.ts + modified: + - apps/api/src/lib/bootGuards.ts + - apps/api/src/index.ts + - apps/api/src/db/schema.ts + - apps/api/test/setup.ts + - scripts/generate-secrets.mjs + - .dockerignore +decisions: + - "Used node:crypto scryptSync (not async) — blocking but acceptable for 2-person household infrequent logins (D-08)" + - "PHC-style encoding embeds N/r/p/salt in stored string — future parameter upgrades without DB migration" + - "Jwt namespace import from hono/utils/jwt (Pitfall 8 — named sign/verify don't exist)" + - "Cookie name: local-session (distinct from oidc-auth, Pitfall 4)" + - "assertLocalSessionSecretSet exempts DEV_AUTH_BYPASS=true — bypass never issues local JWTs" + - "Migration generated by drizzle-kit generate (never push) — purely additive CREATE TABLE" + - ".dockerignore: excluded entire apps/api/scripts/ dir (supersedes per-file exclusion, D-15)" +metrics: + duration: "~6 minutes" + completed: "2026-06-17" + tasks_completed: 3 + tasks_total: 4 + files_created: 5 + files_modified: 6 +--- + +# Phase 19 Plan 01: Local Auth Foundation Summary + +**One-liner:** Scrypt password primitives, stateless local-session JWT cookie helpers, `local_credentials` MariaDB table + additive migration, `LOCAL_SESSION_SECRET` boot guard wired in `index.ts`, and `.dockerignore` break-glass script exclusion. + +## Status: CHECKPOINT REACHED + +Task 4 is a `type="checkpoint:human-verify"` (gate="blocking"). Tasks 1-3 are complete and committed. The plan pauses for human review of the generated migration SQL before proceeding. + +## Tasks Completed + +| Task | Name | Commit | Key Files | +|------|------|--------|-----------| +| 1 (RED) | hashPassword/verifyPassword tests | 7ece966 | apps/api/tests/auth/localCredentials.test.ts | +| 1 (GREEN) | hashPassword/verifyPassword implementation | 85b01b5 | apps/api/src/auth/localCredentials.ts | +| 2 (RED) | localSession + bootGuards tests | 0d8f3fa | apps/api/tests/auth/localSession.test.ts | +| 2 (GREEN) | localSession + bootGuards + index.ts | 7d61148 | apps/api/src/auth/localSession.ts, bootGuards.ts, index.ts | +| 3 | schema + migration + secrets + dockerignore | 96f0991 | schema.ts, 0003_warm_deathstrike.sql, generate-secrets.mjs, .dockerignore | + +## Task 4: Checkpoint (Pending Human Review) + +**Checkpoint type:** `human-verify` (blocking) + +The migration `0003_warm_deathstrike.sql` was generated by `drizzle-kit generate` and applied to the dev DB with `pnpm --filter @familysync/api db:migrate` (exit 0). It contains: + +```sql +CREATE TABLE `local_credentials` ( + `id` int AUTO_INCREMENT NOT NULL, + `user_id` int NOT NULL, + `username` varchar(128) NOT NULL, + `password_hash` varchar(256) NOT NULL, + `created_at` timestamp NOT NULL DEFAULT (now()), + `updated_at` timestamp DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `local_credentials_id` PRIMARY KEY(`id`), + CONSTRAINT `uniq_local_cred_user` UNIQUE(`user_id`), + CONSTRAINT `uniq_local_cred_username` UNIQUE(`username`) +); +--> statement-breakpoint +ALTER TABLE `local_credentials` ADD CONSTRAINT `local_credentials_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX `idx_local_credentials_user_id` ON `local_credentials` (`user_id`); +``` + +The SQL is purely additive. No `ALTER/DROP/TRUNCATE/RENAME` touches any existing table. + +**What the human needs to verify:** +1. Review `apps/api/src/db/migrations/0003_warm_deathstrike.sql` — confirm only `CREATE TABLE local_credentials` (no statements touching users, member_credentials, calendars, calendar_events, app_config, or any other existing table). +2. Confirm `LOCAL_SESSION_SECRET` is set in your local `.env` (>=32 chars) — without it the API will refuse to boot in non-bypass mode. Add it via `node scripts/generate-secrets.mjs` if not present. + +**Resume signal:** Type "approved" if migration is purely additive and LOCAL_SESSION_SECRET is set. + +## What Was Built + +### Task 1: hashPassword/verifyPassword (TDD) + +`apps/api/src/auth/localCredentials.ts` exports: +- `hashPassword(password: string): string` — scrypt + 16-byte random salt, returns PHC-encoded string +- `verifyPassword(storedEncoded: string, candidate: string): boolean` — timingSafeEqual, never throws + +Zero new npm dependencies. All 5 unit tests pass (round-trip, wrong-password, unique-salt, malformed-hash, PHC-shape). + +### Task 2: localSession.ts + boot guard (TDD) + +`apps/api/src/auth/localSession.ts` exports: +- `issueLocalSessionCookie(c, userId)` — signs JWT (HS256) with LOCAL_SESSION_SECRET, sets httpOnly cookie +- `verifyLocalSessionCookie(c)` — returns userId or null (never throws, catches Jwt.verify expiry throws) +- `clearLocalSessionCookie(c)` — deletes the cookie with matching attributes + +`apps/api/src/lib/bootGuards.ts` adds: +- `assertLocalSessionSecretSet()` — exits with FATAL if secret missing/<32 chars when not in bypass mode + +`apps/api/src/index.ts` — `assertLocalSessionSecretSet()` called immediately after `assertNotDevBypassInProduction()`. + +All 5 unit tests pass; `pnpm --filter @familysync/api typecheck` exits 0. + +### Task 3: Schema + Migration + Secrets + .dockerignore + +- `apps/api/src/db/schema.ts` — `localCredentials` table exported (UNIQUE user_id, UNIQUE username, FK->users cascade) +- `apps/api/src/db/migrations/0003_warm_deathstrike.sql` — purely additive CREATE TABLE; applied to dev DB +- `apps/api/test/setup.ts` — `localCredentials` added to afterEach cleanup (FK-safe ordering) +- `scripts/generate-secrets.mjs` — emits `LOCAL_SESSION_SECRET` (base64 32-byte, 44 chars) +- `.dockerignore` — added `apps/api/scripts/` directory exclusion (D-15/IMG-02) + +## Deviations from Plan + +### Auto-fixed Issues + +None. Plan executed as written. + +### Notes + +- The migration file generated by drizzle-kit is named `0003_warm_deathstrike.sql` (drizzle-kit generates random animal names for migrations). The plan referenced `0003_local_credentials.sql` as an expected name — this is not a semantic deviation, only a filename difference from drizzle-kit's naming convention. The content and purpose match exactly. +- `pnpm --filter @familysync/api db:migrate` was run against the dev stack DB (credentials from `.env`). The worktree shares the main repo's dev DB connection, which is expected and safe for an additive migration. +- Tests requiring MariaDB were run with `CI=true` to bypass the global-setup root-DB-provisioning step (which requires a root MySQL connection that isn't available from the worktree's network context). Pure unit tests (no DB access) work correctly in this mode. + +## Threat Surface Scan + +No new network endpoints introduced in this plan. All new surface is internal stdlib / crypto utilities and a DB table migration. No changes to trust boundaries that aren't already covered by the plan's threat model (T-19-01 through T-19-04 and T-19-SC). + +## Known Stubs + +None. This plan provides foundational utilities without UI or stub placeholders. + +## Self-Check: PASSED + +All created files confirmed present on disk: +- FOUND: apps/api/src/auth/localCredentials.ts +- FOUND: apps/api/src/auth/localSession.ts +- FOUND: apps/api/src/db/migrations/0003_warm_deathstrike.sql +- FOUND: apps/api/tests/auth/localCredentials.test.ts +- FOUND: apps/api/tests/auth/localSession.test.ts + +All commits confirmed in git log: +- 7ece966: test(19-01): add failing tests for hashPassword/verifyPassword +- 85b01b5: feat(19-01): implement hashPassword/verifyPassword +- 0d8f3fa: test(19-01): add failing tests for localSession +- 7d61148: feat(19-01): implement localSession JWT cookie helpers +- 96f0991: feat(19-01): schema + migration + secrets + dockerignore