Phase 12: Initial Setup Wizard #22

Merged
luckberg merged 76 commits from gsd/phase-12-initial-setup-wizard into main 2026-06-16 19:10:33 -04:00
4 changed files with 1088 additions and 3 deletions
Showing only changes of commit 703fad2ca2 - Show all commits
@@ -0,0 +1,7 @@
ALTER TABLE `users` MODIFY COLUMN `oidc_iss` varchar(512);--> statement-breakpoint
ALTER TABLE `users` MODIFY COLUMN `oidc_sub` varchar(256);--> statement-breakpoint
ALTER TABLE `users` ADD `claimed` boolean DEFAULT false NOT NULL;
--> statement-breakpoint
-- Phase 12 backfill (D-07): existing OIDC users are effectively claimed —
-- prevents first-login-claims (D-08) from matching rows that already have an identity bound.
UPDATE `users` SET `claimed` = true WHERE `oidc_iss` IS NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,13 @@
"when": 1781374816375,
"tag": "0001_famous_mad_thinker",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1781545048917,
"tag": "0002_lethal_millenium_guard",
"breakpoints": true
}
]
}
+27 -3
View File
@@ -31,21 +31,35 @@ const varcharBin = (name: string) =>
* Members of the household — identity keyed by oidc_iss + oidc_sub (never email, per D-10).
* Color auto-assigned from palette on first login (D-06).
* isAdmin: first-login-wins bootstrap (D-01); gated by app_config.setup_complete in Phase 12.
*
* Phase 12 additions (D-07):
* oidcIss / oidcSub: now nullable — wizard creates a local user row before OIDC identity
* is known; first-login-claims (D-08) binds them on first OIDC login.
* claimed: false = pending wizard user (no OIDC identity bound yet);
* true = identity already bound (existing OIDC users backfilled via 0002 migration).
*
* MariaDB null semantics: multiple NULL+NULL pairs are allowed in a unique index
* (NULLs are DISTINCT per ISO SQL / MariaDB), so the uniq_oidc_identity constraint
* correctly permits multiple unclaimed rows (D-07, RESEARCH Pitfall 9).
*/
export const users = mysqlTable(
'users',
{
id: int().primaryKey().autoincrement(),
oidcIss: varchar('oidc_iss', { length: 512 }).notNull(),
oidcSub: varchar('oidc_sub', { length: 256 }).notNull(),
// Phase 12: nullable — set by first-login-claims (D-08) after wizard completes
oidcIss: varchar('oidc_iss', { length: 512 }),
oidcSub: varchar('oidc_sub', { length: 256 }),
displayName: varchar('display_name', { length: 256 }),
color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9'
createdAt: timestamp('created_at').defaultNow().notNull(),
// v1.1 (Phase 10): admin role flag — first-login-wins; Phase 12 tightens bootstrap
isAdmin: boolean('is_admin').default(false).notNull(),
// Phase 12: claimed=false → unclaimed wizard row; claimed=true → OIDC identity bound (D-07)
claimed: boolean('claimed').default(false).notNull(),
},
(t) => [
// Composite unique key — identity is iss+sub, never email (D-10)
// Composite unique key — identity is iss+sub, never email (D-10).
// NULL+NULL pairs are DISTINCT in MariaDB unique indexes → multiple unclaimed rows allowed.
unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub),
],
);
@@ -277,6 +291,16 @@ export const pushSubscriptions = mysqlTable(
* after first-time setup; Phase 10's first-login-wins admin bootstrap reads it in Phase 12.
* Key: 'setup_complete', Value: 'true' | 'false' | null (not yet set → treated as false).
*
* Phase 12 additional keys (written by the wizard, never by this schema):
* - 'oidc_issuer' OIDC issuer URL configured by the operator (e.g. Authelia base URL)
* - 'oidc_client_id' OIDC client_id registered in Authelia
* - 'vapid_public_key' VAPID public key (base64url) for Web Push — safe to store here
* - 'app_external_url' External URL of the PWA (used in push payloads, OIDC redirect URI)
*
* PROHIBITION (D-01 / SC-3): NEVER add columns or keys for:
* - 'vapid_private_key' — injected via docker-compose.yml env only; never persisted
* - 'app_password_encryption_key' — injected via docker-compose.yml env only; never persisted
*
* Do NOT add setup_complete gating logic here — Phase 12 owns that.
*/
export const appConfig = mysqlTable('app_config', {