From c8894adc3f4f70ba7f14035503f430f0c02b3da1 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Mon, 15 Jun 2026 14:14:48 -0400 Subject: [PATCH] feat(12-03): upsertUser first-login-claims branch (D-08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add isNull import from drizzle-orm; add appConfig to schema imports - After identity lookup, read app_config.setup_complete per call (D-10 freshness) - When setup_complete='true' and unclaimed user exists (isNull(oidcIss) AND claimed=false): claim it via db.update() — binds oidcIss/oidcSub, sets claimed=true, preserves is_admin - shouldBeAdmin gated: flagRow?.value !== 'true' AND admin COUNT === 0 (T-12-11) - No email keying in claim branch — isNull(oidcIss) AND claimed=false only (D-10/T-12-12) - 399 tests pass; typecheck clean Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/auth/user.ts | 57 ++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/apps/api/src/auth/user.ts b/apps/api/src/auth/user.ts index 3e3a383..aacd2e1 100644 --- a/apps/api/src/auth/user.ts +++ b/apps/api/src/auth/user.ts @@ -8,9 +8,9 @@ * Source: RESEARCH.md § "User upsert with color assignment" */ -import { and, eq, sql } from 'drizzle-orm'; +import { and, eq, isNull, sql } from 'drizzle-orm'; import { db } from '../db/client.js'; -import { users } from '../db/schema.js'; +import { users, appConfig } from '../db/schema.js'; /** * Accessible, visually-distinct palette for per-member member-color assignment. @@ -96,7 +96,42 @@ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: return existing[0]; } - // 2. Assign the first palette color NOT already in use by another member. + // 2. Check app_config.setup_complete and attempt first-login-claims (D-08). + // When setup is complete, the first OIDC login from an unknown identity claims + // the single unclaimed local user (oidcIss IS NULL AND claimed=false), binding + // the OIDC identity to the wizard-provisioned row. This preserves is_admin and + // the CalDAV credential stored by the wizard. + // MUST NOT match by email — query is strictly isNull(oidcIss) AND claimed=false (D-10). + const [flagRow] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'setup_complete')) + .limit(1); + + if (flagRow?.value === 'true') { + const [unclaimed] = await db + .select() + .from(users) + .where(and(isNull(users.oidcIss), eq(users.claimed, false))) + .limit(1); + + if (unclaimed) { + // Claim: bind the OIDC identity, mark claimed=true, update displayName if provided. + // is_admin is NOT overwritten — it was pre-set by the wizard (operator's intent). + await db + .update(users) + .set({ + oidcIss, + oidcSub, + claimed: true, + displayName: displayName ?? unclaimed.displayName, + }) + .where(eq(users.id, unclaimed.id)); + return { ...unclaimed, oidcIss, oidcSub, claimed: true }; + } + } + + // 3. Assign the first palette color NOT already in use by another member. // A plain COUNT(*) % palette collides under deletions: a deleted user // shifts the count so the next insert reuses an in-use slot (observed in // Gate 2 — two members both got #E8734A). Selecting the first unused color @@ -109,20 +144,18 @@ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: COLOR_PALETTE.find((c) => !usedColors.has(c)) ?? COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length]; - // 3. First-login-wins is_admin bootstrap (D-01). - // When zero admins currently exist, the first new user becomes admin. - // Phase 12 will tighten this to: first user after app_config.setup_complete. - // Until then, "first user when zero admins exist" is the bootstrap condition. - // This hook reads cleanly: Phase 12 adds a setup_complete check before the - // COUNT, so only first login AFTER setup is flagged — no restructuring needed. + // 4. First-login-wins is_admin bootstrap (D-01), tightened by Phase 12 (D-08). + // When setup_complete is true, a claimed admin already exists — new users must + // NOT auto-promote. Only grant admin when setup is not yet complete AND no admins + // exist (the original first-login-wins bootstrap for fresh pre-wizard instances). const [{ count }] = await db .select({ count: sql`COUNT(*)` }) .from(users) .where(eq(users.isAdmin, true)) .limit(1); - const shouldBeAdmin = Number(count) === 0; + const shouldBeAdmin = flagRow?.value !== 'true' && Number(count) === 0; - // 4. Insert new user row + // 5. Insert new user row // mysql2 has no RETURNING clause — use $returningId() then re-select const [inserted] = await db .insert(users) @@ -135,7 +168,7 @@ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: }) .$returningId(); - // 5. Re-select to return the full typed row + // 6. Re-select to return the full typed row const [newUser] = await db.select().from(users).where(eq(users.id, inserted.id)).limit(1); return newUser;