/** * User identity upsert + stable per-member color assignment. * * Identity is keyed on oidc_iss + oidc_sub — never email (D-10). * Color is auto-assigned from a curated palette on first login, round-robin * by join order (D-06). Stable across sessions: re-upsert returns same row. * * Source: RESEARCH.md § "User upsert with color assignment" */ import { and, eq, isNull, sql } from 'drizzle-orm'; import { db } from '../db/client.js'; import { users, appConfig } from '../db/schema.js'; /** * Accessible, visually-distinct palette for per-member member-color assignment. * A new member is given the first entry not already in use (see upsertUser). * * Ordering matters: the SHARED-family calendar is reserved rose (#F25C7A, D-06), * so the warm near-rose hues (coral, amber) are placed LAST. Early members get * cool colors (blue, green, teal) that read clearly distinct from the shared * lane — otherwise a member's coral was mistaken for the shared rose. * Values are Claude's choice per D-06. */ export const COLOR_PALETTE: string[] = [ '#4A90D9', // calm blue '#5BA85A', // forest green '#3AAFA9', // teal '#9B6DC5', // soft purple '#E8A840', // warm amber (near shared rose — assigned only after cool colors) '#E8734A', // warm coral (closest to shared rose — assigned last) ]; /** Coerce an OIDC claim to a trimmed non-empty string, else undefined. */ const claimStr = (v: unknown): string | undefined => typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined; /** * Derive the best available display name from OIDC claims, in preference order: * 1. name — full name set by the IdP (most human-friendly) * 2. preferred_username — often the login handle; still readable * 3. email — readable but reveals contact info; acceptable fallback * 4. null — no usable claim; the UI degrades to a generic "Member" * * Returns null (NOT a synthetic `Member `) when no real claim is present so * we never persist an ugly sub-derived string. Whether Authelia emits * name/preferred_username/email in the ID TOKEN (not just at the userinfo * endpoint) is an operator config concern: Authelia 4.39+ requires a * `claims_policies` entry adding those claims to `id_token` for this client, * because @hono/oidc-auth reads ID-token claims only (no userinfo fetch). Until * that is set, every claim here is absent and the legend shows "Member". * * Shared by every call site that upserts a user (me.ts, events.ts resolveUserId) * so the write-path upsert agrees with /api/me. */ export function deriveDisplayName(claims: { name?: unknown; preferred_username?: unknown; email?: unknown; }): string | null { return ( claimStr(claims.name) ?? claimStr(claims.preferred_username) ?? claimStr(claims.email) ?? null ); } /** * Upsert a user by their OIDC identity (iss + sub). * * - If a row with matching (oidc_iss, oidc_sub) exists, return it unchanged. * - Otherwise, count current users to pick the next palette color, insert a * new row, then re-select and return it. * * Never keys on email or displayName for identity. displayName is stored as a * display hint only and may change without affecting identity. */ export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: string | null) { // 1. Look up by composite identity key (iss + sub) — never email const existing = await db .select() .from(users) .where(and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub))) .limit(1); if (existing[0]) { // Track the IdP display name authoritatively: when the caller supplies a // real (non-null) name that differs from what's stored, update it. This both // corrects rows created before robust claim derivation (blank → name) and // self-heals once an operator adds the name/email claims to Authelia's ID // token (e.g. a stale "Member …"/email → the real name) — no DB surgery. // A null displayName (no usable claim this request) never overwrites a good // stored value. if (displayName != null && displayName !== existing[0].displayName) { await db.update(users).set({ displayName }).where(eq(users.id, existing[0].id)); return { ...existing[0], displayName }; } return existing[0]; } // 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 // guarantees distinct, stable colors for up to COLOR_PALETTE.length members // (AUTH-03). Falls back to round-robin by count only once the palette is // exhausted (more members than colors). const usedRows = await db.select({ color: users.color }).from(users); const usedColors = new Set(usedRows.map((r) => r.color)); const color = COLOR_PALETTE.find((c) => !usedColors.has(c)) ?? COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length]; // 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 = flagRow?.value !== 'true' && Number(count) === 0; // 5. Insert new user row // claimed=true: an OIDC-created user is identity-bound at insert time and is // never a pending wizard bootstrap user. Setting this explicitly prevents the // TOCTOU guard in POST /credential (which counts WHERE oidc_iss IS NULL AND // claimed = false) from ever treating a fresh OIDC insert as an unclaimed // wizard row (WR-01). // mysql2 has no RETURNING clause — use $returningId() then re-select const [inserted] = await db .insert(users) .values({ oidcIss, oidcSub, displayName: displayName ?? null, color, isAdmin: shouldBeAdmin, claimed: true, }) .$returningId(); // 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; }