/** * 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, sql } from 'drizzle-orm' import { db } from '../db/client.js' import { users } from '../db/schema.js' /** * Accessible, visually-distinct palette for per-member color assignment. * Assigned round-robin by join order (COUNT of existing users at insert time). * Values are Claude's choice per D-06. */ export const COLOR_PALETTE: string[] = [ '#4A90D9', // calm blue '#E8734A', // warm coral '#5BA85A', // forest green '#9B6DC5', // soft purple '#E8A840', // warm amber '#3AAFA9', // teal ] /** 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. `Member ` — sub is always present; never blank * * Shared by every call site that upserts a user (me.ts, events.ts resolveUserId) * so a write-path upsert never overwrites a correctly-derived name with a worse * one. Whether Authelia emits name/preferred_username is an operator config * concern (userinfo scope + claim mappings) — out of scope here. */ export function deriveDisplayName( claims: { name?: unknown; preferred_username?: unknown; email?: unknown }, sub: string, ): string { return ( claimStr(claims.name) ?? claimStr(claims.preferred_username) ?? claimStr(claims.email) ?? `Member ${String(sub).slice(0, 8)}` ) } /** * 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, ) { // 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]) { // If the existing row has no displayName but the caller supplies one, update it now. // This corrects rows created before robust claim derivation was in place (BUG 2 fix). if (!existing[0].displayName && displayName) { await db .update(users) .set({ displayName }) .where(eq(users.id, existing[0].id)) return { ...existing[0], displayName } } return existing[0] } // 2. Count existing users to determine round-robin color slot const countResult = await db .select({ count: sql`COUNT(*)` }) .from(users) const count = Number(countResult[0]?.count ?? 0) const color = COLOR_PALETTE[count % COLOR_PALETTE.length] // 3. Insert new user row // mysql2 has no RETURNING clause — use $returningId() then re-select const [inserted] = await db .insert(users) .values({ oidcIss, oidcSub, displayName: displayName ?? null, color, }) .$returningId() // 4. 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 }