feat(12-01): schema nullable oidc identity + claimed marker + 0002 migration
- Remove .notNull() from users.oidc_iss and users.oidc_sub (wizard creates local rows before OIDC identity is known; first-login-claims binds later) - Add users.claimed boolean (default false NOT NULL) to distinguish pending wizard rows from OIDC-bound rows (D-07) - Add Phase 12 app_config key documentation + prohibition comment (D-01/SC-3) - Generate migration 0002_lethal_millenium_guard.sql via drizzle-kit generate (MODIFY COLUMN for nullable, ADD COLUMN claimed — no DROP/recreate) - Append backfill: UPDATE users SET claimed=true WHERE oidc_iss IS NOT NULL so existing OIDC users cannot be matched by first-login-claims (D-08) - Apply migration via drizzle-kit migrate — users.claimed column verified in dev DB
This commit is contained in:
@@ -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,
|
"when": 1781374816375,
|
||||||
"tag": "0001_famous_mad_thinker",
|
"tag": "0001_famous_mad_thinker",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1781545048917,
|
||||||
|
"tag": "0002_lethal_millenium_guard",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -31,21 +31,35 @@ const varcharBin = (name: string) =>
|
|||||||
* Members of the household — identity keyed by oidc_iss + oidc_sub (never email, per D-10).
|
* 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).
|
* 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.
|
* 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(
|
export const users = mysqlTable(
|
||||||
'users',
|
'users',
|
||||||
{
|
{
|
||||||
id: int().primaryKey().autoincrement(),
|
id: int().primaryKey().autoincrement(),
|
||||||
oidcIss: varchar('oidc_iss', { length: 512 }).notNull(),
|
// Phase 12: nullable — set by first-login-claims (D-08) after wizard completes
|
||||||
oidcSub: varchar('oidc_sub', { length: 256 }).notNull(),
|
oidcIss: varchar('oidc_iss', { length: 512 }),
|
||||||
|
oidcSub: varchar('oidc_sub', { length: 256 }),
|
||||||
displayName: varchar('display_name', { length: 256 }),
|
displayName: varchar('display_name', { length: 256 }),
|
||||||
color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9'
|
color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9'
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
// v1.1 (Phase 10): admin role flag — first-login-wins; Phase 12 tightens bootstrap
|
// v1.1 (Phase 10): admin role flag — first-login-wins; Phase 12 tightens bootstrap
|
||||||
isAdmin: boolean('is_admin').default(false).notNull(),
|
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) => [
|
(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),
|
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.
|
* 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).
|
* 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.
|
* Do NOT add setup_complete gating logic here — Phase 12 owns that.
|
||||||
*/
|
*/
|
||||||
export const appConfig = mysqlTable('app_config', {
|
export const appConfig = mysqlTable('app_config', {
|
||||||
|
|||||||
Reference in New Issue
Block a user