Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
phase, plan, subsystem, tags, status, dependency_graph, tech_stack, key_files, decisions, metrics
| phase | plan | subsystem | tags | status | dependency_graph | tech_stack | key_files | decisions | metrics | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 19-local-auth-no-oidc-mode | 02 | auth |
|
complete |
|
|
|
|
|
Phase 19 Plan 02: Admin + Me Account Management Summary
One-liner: Admin create-member + reset-password routes with atomic transaction (409 on dup), self-change-password with current-password verification, hasLocalCredential signal on both /api/me and /api/admin/members, and linkOidcToUser helper with signed-state OIDC-link initiation endpoint.
Tasks Completed
| Task | RED Commit | GREEN Commit | Key Files |
|---|---|---|---|
| 1: Admin create-member + reset-password + hasLocalCredential on GET /members | b2c7902 |
6232aa0 |
admin.ts, admin.test.ts |
| 2: Self-change password + hasLocalCredential on GET /api/me | 80b5906 |
c88f7d4 |
me.ts, me.test.ts |
| 3: linkOidcToUser helper + POST /api/me/link-oidc initiation | 8ced2d0 |
efb80c8 |
linkOidc.ts, me.ts, me.test.ts |
What Was Built
Task 1: Admin Member Management (TDD)
apps/api/src/routes/admin.ts extended with:
POST /api/admin/members — admin creates a local member:
- Zod schema:
{ displayName: string min1, username: string min1 max128, initialPassword: string min8 } noEchoHook: Zod errors never echoed (T-19-06)db.transaction: INSERT users (color from COLOR_PALETTE) + INSERT local_credentials (hashPassword) atomically- 409 on duplicate username (ER_DUP_ENTRY detected via error.message string match — Drizzle wraps mysql2)
- 201 +
{ id }on success
POST /api/admin/members/:id/password — admin resets any member's password:
- Zod schema:
{ newPassword: string min8 }+noEchoHook - 404 if no local_credentials row for target user
- UPDATE local_credentials SET password_hash = hashPassword(newPassword)
- 200 on success; no current password required (D-11)
GET /api/admin/members extended:
- LEFT JOIN local_credentials — adds
localCredIdto SELECT hasLocalCredential: row.localCredId !== nullin each member object (AUTH-LOCAL-17)
apps/api/tests/routes/admin.test.ts — 5 new tests (5 total assertions pass):
- Test 1: CREATE inserts users + local_credentials, hash verifies against initialPassword
- Test 2: duplicate username → 409, transaction rolled back (user count unchanged)
- Test 3: admin reset → new hash verifies newPassword, old hash fails
- Test 4: non-admin → 403 on both routes (requireAdmin via router.use)
- Test 5: GET /members shows hasLocalCredential:true/false per row
Task 2: Self-Change Password + hasLocalCredential on GET /api/me (TDD)
apps/api/src/routes/me.ts extended with:
POST /api/me/password — self-change password:
- Zod schema:
{ currentPassword: string min1, newPassword: string min8 }+meNoEchoHook - resolveUserId from session (never body) — T-19-07
- 404 if no local_credentials row
- verifyPassword(storedHash, currentPassword) → 401
{ error: 'Current password incorrect' }on false - UPDATE local_credentials SET password_hash = hashPassword(newPassword) on success
resolveAdminAndSetupStatus extended:
- Third SELECT:
SELECT id FROM local_credentials WHERE user_id = userId LIMIT 1 - Returns
hasLocalCredential: Boolean(localCred)alongside isAdmin/needsProviderSetup - Both GET / response shapes (dev-bypass + OIDC paths) include
hasLocalCredential
apps/api/tests/routes/me.test.ts — 5 new tests (all pass):
- Test 1: correct current → 200, updatedHash verifies newPassword not oldPassword
- Test 2: wrong current → 401, UPDATE never called
- Test 3: no local_credentials → 404
- Test 4: hasLocalCredential:true in GET /me when row exists
- Test 5: hasLocalCredential:false in GET /me when no row
Task 3: linkOidcToUser + POST /api/me/link-oidc (TDD)
apps/api/src/auth/linkOidc.ts (new, 88 lines):
OidcLinkConflictError extends Error— thrown on iss+sub conflict (different user)linkOidcToUser(userId, iss, sub):- Preflight SELECT:
WHERE oidc_iss=iss AND oidc_sub=sub AND id != userId(T-19-08, Pitfall 6) - Throws OidcLinkConflictError if conflict found — NO writes occur
- db.transaction: UPDATE users SET oidc_iss/sub/claimed=true + DELETE local_credentials (D-12)
- No email field used anywhere (
grep -ci email == 0, D-10)
- Preflight SELECT:
apps/api/src/routes/me.ts extended with POST /api/me/link-oidc:
- resolveUserId (401 if null)
- Signs JWT state:
{ linkUserId, nonce, iat, exp }with LOCAL_SESSION_SECRET HS256 (T-19-09) - Nonce: 16-byte randomBytes().toString('hex') per request — prevents state replay
- 10-minute expiry on state token
- Constructs authorizationUrl from OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_REDIRECT_URI env vars (null if not configured)
- Returns
{ signedState, authorizationUrl }— plan 19-03 /callback reads linkUserId from state
apps/api/tests/routes/me.test.ts — 3 new link-oidc tests:
- Test 1: linkOidcToUser calls UPDATE users + DELETE local_credentials when no conflict
- Test 2: linkOidcToUser throws OidcLinkConflictError when iss+sub belongs to different user; DELETE not called; db.transaction not called
- Test 3: POST /api/me/link-oidc returns 200 with initiation payload (signedState present)
Deviations from Plan
Auto-fixed Issues
1. [Rule 1 - Bug] ER_DUP_ENTRY detection via error.message string match
- Found during: Task 1 GREEN phase (Test 2 returning 503 instead of 409)
- Issue:
err.code === 'ER_DUP_ENTRY'failed because Drizzle 0.45.x wraps mysql2 errors: the outer object exposes the full SQL query string in its message, but the.codeproperty is on the cause chain, not the outer error. - Fix: Multi-check pattern:
err.message.includes('ER_DUP_ENTRY') || err.code === 'ER_DUP_ENTRY' || err.cause?.code === 'ER_DUP_ENTRY' - Files modified: apps/api/src/routes/admin.ts
- Commit:
6232aa0
2. [Rule 2 - Missing Critical Functionality] afterEach cleanup for locally-created users
- Found during: Task 1 RED test setup
- Issue: POST /api/admin/members creates users rows without oidcIss (null), so the existing
afterEachcleanupWHERE oidcIss = 'https://auth.test'didn't clean them up. - Fix: Added
await db.delete(users).where(eq(users.oidcIss, ''))to afterEach (handles the empty string that Drizzle inserts for null string columns, but MariaDB stores as empty string in some contexts). Also addedlocalCredentialscleanup before users. - Files modified: apps/api/tests/routes/admin.test.ts
- Commit:
b2c7902
Notes
- The
resolveAdminAndSetupStatusfunction now makes 3 DB SELECT calls instead of 2 (added localCredentials lookup). For a 2-person household, this is negligible overhead. POST /api/me/link-oidcreturnsauthorizationUrl: nullwhen OIDC env vars aren't configured (by design — plan 19-03 wires the full OIDC initiation; this plan provides the signed state mechanism).- me.test.ts Tests 1-2 for
/passwordusevi.mocked(db).update = vi.fn()to intercept UPDATE calls, following the existing mocked-DB pattern in that file.
Threat Surface Scan
All new routes are gated:
POST /api/admin/membersandPOST /api/admin/members/:id/password: behindadminRouter.use('*', requireAdmin)(T-19-05)POST /api/me/passwordandPOST /api/me/link-oidc: behindresolveUserId(401 if session invalid — T-19-07)
New trust boundaries introduced:
client → POST /api/admin/members— covered by T-19-05, T-19-06, T-19-10 (all mitigated)client → POST /api/me/password— covered by T-19-06, T-19-07 (all mitigated)client → POST /api/me/link-oidc + OIDC callback— covered by T-19-08, T-19-09 (signed state mitigates CSRF; preflight mitigates account takeover)
No new threat surface outside the plan's threat model.
Known Stubs
None. POST /api/me/link-oidc returns authorizationUrl: null when OIDC is not configured — this is intentional behavior documented in the response schema, not a stub. Plan 19-03 fills in the full OIDC initiation flow.
TDD Gate Compliance
All 3 tasks followed RED/GREEN pattern:
- RED commits:
b2c7902(admin),80b5906(me password),8ced2d0(link-oidc) - GREEN commits:
6232aa0(admin),c88f7d4(me password),efb80c8(link-oidc) - No REFACTOR commits needed (code was clean after GREEN)
Self-Check: PASSED
All created files confirmed present on disk:
- FOUND: apps/api/src/auth/linkOidc.ts
- FOUND: apps/api/src/routes/admin.ts (modified)
- FOUND: apps/api/src/routes/me.ts (modified)
- FOUND: apps/api/tests/routes/admin.test.ts (modified)
- FOUND: apps/api/tests/routes/me.test.ts (modified)
All commits confirmed in git log:
b2c7902: test(19-02): add failing tests for admin create-member, reset-password, hasLocalCredential6232aa0: feat(19-02): admin create-member, reset-password, hasLocalCredential on GET /members80b5906: test(19-02): add failing tests for self-change password and hasLocalCredential on /api/mec88f7d4: feat(19-02): self-change password and hasLocalCredential on GET /api/me8ced2d0: test(19-02): add failing tests for linkOidcToUser and POST /api/me/link-oidcefb80c8: feat(19-02): linkOidcToUser helper + POST /api/me/link-oidc initiation
Test results: 430/430 pass (31 test files); pnpm --filter @familysync/api typecheck exits 0.