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.*
|
.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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}
|
||||||
`);
|
`);
|
||||||
|
|||||||
Reference in New Issue
Block a user