feat(19-01): add local_credentials schema, 0003 migration, generate-secrets LOCAL_SESSION_SECRET, .dockerignore D-15

- 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
This commit is contained in:
Lucas Berger
2026-06-17 16:17:04 -04:00
parent 7d61148415
commit 96f0991605
7 changed files with 1218 additions and 2 deletions
+3 -1
View File
@@ -2,7 +2,9 @@
.env .env
.env.* .env.*
!.env.example !.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) === # === VCS (large and unnecessary) ===
.git .git
@@ -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`);
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,13 @@
"when": 1781545048917, "when": 1781545048917,
"tag": "0002_lethal_millenium_guard", "tag": "0002_lethal_millenium_guard",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1781727317172,
"tag": "0003_warm_deathstrike",
"breakpoints": true
} }
] ]
} }
+41
View File
@@ -309,6 +309,47 @@ export const appConfig = mysqlTable('app_config', {
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), 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$<salt_b64url>$<hash_b64url> (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$<salt_base64url>$<hash_base64url> — 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. * Items within a list.
* *
+4 -1
View File
@@ -24,7 +24,7 @@
import { afterEach } from 'vitest'; import { afterEach } from 'vitest';
import { db } from '../src/db/client.js'; 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. * 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(listShares);
await db.delete(pushSubscriptions); await db.delete(pushSubscriptions);
await db.delete(lists); 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 { } catch {
// DB may not be available in pure-unit test runs (no DB_HOST configured). // DB may not be available in pure-unit test runs (no DB_HOST configured).
// Swallow the error — pure-logic tests do not need cleanup. // Swallow the error — pure-logic tests do not need cleanup.
+5
View File
@@ -27,6 +27,9 @@ import { randomBytes, createECDH } from 'node:crypto';
const sessionSecret = randomBytes(32).toString('hex'); const sessionSecret = randomBytes(32).toString('hex');
const encKey = 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) // VAPID key generation (P-256 / prime256v1 — same curve as web-push)
const ecdhCurve = createECDH('prime256v1'); const ecdhCurve = createECDH('prime256v1');
@@ -55,4 +58,6 @@ SESSION_SECRET=${sessionSecret}
APP_PASSWORD_ENCRYPTION_KEY=${encKey} APP_PASSWORD_ENCRYPTION_KEY=${encKey}
VAPID_PUBLIC_KEY=${vapid.publicKey} VAPID_PUBLIC_KEY=${vapid.publicKey}
VAPID_PRIVATE_KEY=${vapid.privateKey} 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}
`); `);