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:
+3
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
"tag": "0002_lethal_millenium_guard",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1781727317172,
|
||||
"tag": "0003_warm_deathstrike",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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$<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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user