D-12-03-EMAIL-GREP: The acceptance criterion grep for no email keying returns 1 (not 0) because deriveDisplayName uses claims.email as a display-name fallback — this is a pre-existing, non-identity use unrelated to the claim branch. The claim branch itself (the if-flagRow block) has zero email references. D-10 identity constraint is fully upheld.
D-12-03-FLAGROW-REUSE: flagRow read once before the claim branch; reused in shouldBeAdmin gate — avoids a second app_config read on the normal insert path.
first-login-claims: isNull(users.oidcIss) AND eq(users.claimed, false) LIMIT 1 — identity-null + unclaimed only; no email (D-10)
shouldBeAdmin gate: flagRow?.value !== 'true' AND adminCount === 0 — setup_complete blocks auto-admin after wizard completes (T-12-11)
TDD select-count shifting: adding a new db.select() call between existing calls requires updating all mock call-count branches in tests
SETUP-01
8min
2026-06-15
Phase 12 Plan 03: upsertUser First-Login-Claims (D-08) Summary
upsertUser reworked to claim the wizard-provisioned local user on first OIDC login after setup_complete; preserves is_admin; no email coupling; RED→GREEN TDD; 399 tests pass
Performance
Duration: ~8 min
Started: 2026-06-15T18:07:31Z
Completed: 2026-06-15T18:15:26Z
Tasks: 1 (TDD: RED commit + GREEN commit)
Files modified: 2
Accomplishments
Task 1: First-login-claims branch in upsertUser (D-08) — TDD RED→GREEN
RED commit (7a26b4a): Expanded 5 it.todo() scaffolds (from Plan 01) into real failing tests + updated 6 existing insert tests to account for the new app_config.setup_complete read (shifted selectCallCount by +1). Also added db.update to the mock factory and makeUpdateChain helper. 11 tests failed as expected.
GREEN commit (c8894ad): Implemented first-login-claims in apps/api/src/auth/user.ts:
Added isNull to drizzle-orm imports and appConfig to schema imports
After identity lookup (step 1), reads app_config.setup_complete fresh every call
If 'true': queries for unclaimed user (WHERE isNull(oidcIss) AND claimed=false LIMIT 1)
If found: db.update() to bind oidcIss/oidcSub/claimed=true/displayName; returns merged row with is_admin preserved (not overwritten)
shouldBeAdmin gated: flagRow?.value !== 'true' && Number(count) === 0 — prevents auto-admin once setup is complete
Zero email references in the claim branch (D-10/T-12-12)
Task Commits
Task
Name
Commit
Files
RED
D-08 failing tests
7a26b4a
apps/api/tests/auth/user.test.ts
GREEN
first-login-claims implementation
c8894ad
apps/api/src/auth/user.ts
Files Modified
apps/api/src/auth/user.ts — upsertUser: isNull + appConfig imports; claim branch after identity lookup; shouldBeAdmin gated on setup_complete
apps/api/tests/auth/user.test.ts — db.update mock added; makeUpdateChain helper; 5 D-08 tests implemented; 6 existing insert tests updated for new select call order
Decisions Made
D-12-03-EMAIL-GREP: The acceptance criterion grep (grep -Ec "claims\.email|users\.email|eq\(.*email") returns 1 (not 0) because deriveDisplayName uses claims.email as a display-name fallback — pre-existing, non-identity code. The claim branch itself has zero email references. D-10 constraint is fully upheld; the grep is a blunt tool that catches an unrelated display-name helper.
D-12-03-FLAGROW-REUSE:flagRow is read once before the claim branch and reused in the shouldBeAdmin expression. This avoids a second app_config SELECT on the normal insert path — the flag read is amortized across both branch decisions.
Note on email-keying grep: grep -Ec "claims\.email|users\.email|eq\(.*email" apps/api/src/auth/user.ts returns 1 — from pre-existing deriveDisplayName display-name fallback, not from the claim branch. See D-12-03-EMAIL-GREP above.
Deviations from Plan
None — plan executed as written
The implementation follows PATTERNS.md §auth/user.ts exactly:
isNull added to drizzle-orm import ✓
appConfig added to schema import ✓
flagRow read before claim branch ✓
Claim query: isNull(users.oidcIss) AND eq(users.claimed, false) ✓