- Add devSessionCookieMiddleware() to devBypass.ts (production hard-guard FIRST)
- Issues local-session JWT cookie for DEV_USER when no cookie present under bypass
- Pure no-op when NODE_ENV=production, DEV_AUTH_BYPASS!=true, or secret not set
- Mount devSessionCookieMiddleware() after devAuthBypass() in index.ts
- Existing devBypass tests: 3/3 pass; typecheck: exit 0
apps/api/src/auth/linkOidc.ts (new):
- OidcLinkConflictError: thrown when iss+sub already belongs to a different user
- linkOidcToUser(userId, iss, sub): preflight SELECT for conflict, then db.transaction
(UPDATE users SET oidc_iss/sub/claimed + DELETE local_credentials); atomic, no email (D-10)
- 88 lines; no email in source (D-10/T-19-08 assertion passes)
apps/api/src/routes/me.ts:
- POST /api/me/link-oidc: resolveUserId (401 if null), sign state JWT
({ linkUserId, nonce, iat, exp } HS256 with LOCAL_SESSION_SECRET, 10-min window)
- Returns { signedState, authorizationUrl } — 19-03 /callback reads linkUserId from state
- authorizationUrl constructed from OIDC env vars when configured, null otherwise
- T-19-09: per-request nonce in state prevents CSRF/replay
RED phase for Task 3:
- Test 1: linkOidcToUser updates users.oidc_iss/sub and deletes local_credentials
- Test 2: linkOidcToUser throws OidcLinkConflictError on conflict, no local_cred deletion
- Test 3: POST /api/me/link-oidc returns initiation payload (state / authorizationUrl)
- POST /api/me/password: verifyPassword(current) gate before hashPassword(new) update
- 401 on wrong current password, 404 if no local_credentials row, 200 on success
- meNoEchoHook on /password route (T-19-06, never echo submitted password)
- resolveAdminAndSetupStatus extended with hasLocalCredential (AUTH-LOCAL-17)
- GET /api/me response includes hasLocalCredential alongside isAdmin/needsProviderSetup
RED phase for Task 2:
- Test 1: POST /api/me/password correct current → 200, new hash verifies newPassword
- Test 2: wrong currentPassword → 401, UPDATE not called (hash unchanged)
- Test 3: no local_credentials row → 404
- Test 4 (GET /api/me): hasLocalCredential:true/false based on local_credentials existence
- POST /api/admin/members: atomic tx (users + local_credentials), 409 on dup username
- POST /api/admin/members/:id/password: admin reset (no current-pwd required), 404 if no local cred
- GET /api/admin/members: LEFT JOIN local_credentials, hasLocalCredential in each member row
- noEchoHook on both POST routes (T-19-06); requireAdmin via router.use('*') remains first statement
- Dup-entry detection via error message string match (Drizzle wraps mysql2 ER_DUP_ENTRY)
RED phase for Task 1:
- Test 1: POST /api/admin/members creates users row + local_credentials, hash verifies
- Test 2: duplicate username returns 409, transaction rolled back (no orphaned user row)
- Test 3: admin reset password updates hash, old password no longer verifies
- Test 4: non-admin gets 403 on both POST /members and POST /members/:id/password
- Test 5: GET /api/admin/members returns hasLocalCredential:true/false per local cred existence
- node:crypto scrypt (N=16384, r=8, p=1, 32-byte output) — zero new dependencies (D-08)
- 16-byte random salt per hash; PHC-encoded format: scrypt$N$r$p$salt_b64url$hash_b64url
- timingSafeEqual for constant-time comparison (prevents timing oracle attacks, T-19-01)
- verifyPassword returns false on any error (never throws); passwords never logged
- All 5 unit tests pass (round-trip, wrong-pw, unique-salt, malformed-hash, PHC-shape)
oidcConfigFallbackMiddleware (Phase 12) reads OIDC config from app_config on
every /api/* request when OIDC_ISSUER/CLIENT_ID/AUTH_EXTERNAL_URL are absent.
CI's api job sets no OIDC env, so events/login tests (which mock db with a
partial query chain) 500'd on every request. Local runs passed only because
ambient .env supplied the vars. Set dummy OIDC config in vitest test.env so the
middleware always takes the env path — hermetic across CI and local.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- status returns { setupComplete, dbName } from process.env.DB_NAME (null fallback)
- only the DB name; never DB_HOST/DB_USER/DB_PASSWORD
- SetupStatusResponse carries dbName?: string | null for the PWA read-only field
- read app_config.vapid_public_key and compare to process.env.VAPID_PUBLIC_KEY
- mismatched/absent submitted key → 400 before the structural check
- VAPID_PRIVATE_KEY still env-only, never compared or returned (T-12-06)
- apps/api/src/auth/user.ts: upsertUser step-5 insert now sets claimed=true
for all OIDC-created users. An identity-bound OIDC user is never a pending
wizard bootstrap user; explicit claimed=true prevents ambiguity with the
(oidcIss IS NULL AND claimed=false) sentinel used by the TOCTOU guard and
isSetupLocked. First-login-claims path is unaffected (it updates a
pre-existing oidcIss=null row; this change only touches the fresh insert).
- apps/api/src/routes/setup.ts: TOCTOU guard in POST /credential now queries
WHERE oidc_iss IS NULL AND claimed = false FOR UPDATE, matching the exact
definition of a pending wizard bootstrap user. This provides defense-in-depth
against any future path that could produce claimed=false OIDC rows.
- apps/api/tests/auth/user.test.ts: new WR-01 test asserts that the fresh
OIDC insert sets claimed=true in the values passed to db.insert().
- apps/api/tests/routes/setup.test.ts: new WR-01 integration test seeds an
OIDC user with claimed=false (oidcIss NOT NULL) and verifies POST /credential
still succeeds (guard ignores the OIDC row, only counts local wizard rows).
All 402 API tests, 253 PWA tests, and typecheck pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
appExternalUrl is injected as OIDC_AUTH_EXTERNAL_URL (the redirect URI
base); Authelia rejects non-https redirect URIs in production. Added
.refine() guard matching the existing oidcIssuer pattern. Added test
that verifies http:// appExternalUrl is rejected with 400.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
isSetupLocked() now checks for an unclaimed local wizard user
(oidcIss IS NULL, claimed=false) before firing the effective-config
branch. During the credential→complete window, this sentinel prevents
a production container with VAPID env set from blocking POST /complete
with 423. The explicit setup_complete flag (Check 1) still locks
unconditionally once written. Adds regression test that sets VAPID env
explicitly (no beforeEach clearing) to reproduce the production scenario.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drizzle mysql2 execute() returns [rows, fields] for SELECT queries; the
generic type parameter alone does not correctly type the result. Use
unknown cast pattern consistent with admin.ts to access the count row.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without a prerequisite check, an operator could call POST /api/setup/complete
directly, setting setup_complete=true with no admin user or credential row,
leaving no recovery path without manual DB surgery.
Add an inner join check for an unclaimed user with an associated credential;
return 422 if absent. Update /complete tests to seed the prerequisite for
the success path and add an explicit 422 regression test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The catch block previously echoed err.message (which may contain internal
network addresses like ECONNREFUSED 192.168.1.50:9091) to the pre-auth
caller. Log the raw message server-side only and return a generic user-
facing string with no internal network detail.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The oidcConfigFallbackMiddleware permanently mutates process.env on first
request then never re-reads from DB. Log an explicit info message when each
value is written so operators can see when a restart is required to pick up
config changes, and add inline documentation of the single-write semantics
to prevent silent misconfiguration after a re-run of the wizard.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two concurrent POST /api/setup/credential requests could both pass
isSetupLocked(), observe no unclaimed row, and both insert — leaving two
unclaimed admin rows with no recovery path. Wrap the count-check + user
insert in a transaction with SELECT COUNT(*) ... FOR UPDATE to acquire a
row/gap lock, ensuring at most one unclaimed admin row is created.
Returns 409 when a concurrent request already holds an unclaimed row.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After $returningId() insert, if the re-select returns null the handler
returned 503 without deleting the just-inserted user row, leaving an
unclaimed admin row with no credential. Delete before returning 503 to
mirror the cleanup already present in the catch block.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- A2 CONFIRMED: @hono/oidc-auth reads OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_AUTH_EXTERNAL_URL
at per-request call time (env(c) → process.env), NOT at import time — fresh instance
boots cleanly without OIDC env vars
- Implement oidcConfigFallbackMiddleware in auth/middleware.ts: reads OIDC_ISSUER,
OIDC_CLIENT_ID, OIDC_AUTH_EXTERNAL_URL from app_config when process.env is absent,
injects into process.env before oidcAuthMiddleware() reads it (D-02/D-03/Recommendation a)
- Mount oidcConfigFallbackMiddleware before oidcAuthMiddleware() in index.ts so
wizard-configured instances work before a container restart
- Verify /api/setup mount order: line 49 < devAuthBypass line 54 (T-12-09/Pitfall 1)
- Fix push.test.ts vi.doMock for middleware.js: add oidcConfigFallbackMiddleware stub
- 394 tests pass | 5 todo (D-08 RED scaffolds); typecheck clean
- Fill setupRouter: GET /status, POST /config, POST /validate/{db,oidc,vapid},
POST /credential, POST /complete (SETUP-01/02)
- isSetupLocked() is FIRST statement in every handler; returns 423 if locked (SETUP-04/D-10)
- /status uses isSetupLocked() directly: covers both explicit + effective-config branches
- /config: zod-validates {oidcIssuer:https, oidcClientId, vapidPublicKey, appExternalUrl};
upserts oidc_issuer|oidc_client_id|vapid_public_key|app_external_url into app_config
- /validate/db: db.execute(sql`SELECT 1`); 200 ok, 503 on failure
- /validate/oidc: fetches discovery doc with AbortSignal.timeout(5000); reads oidc_issuer
from app_config; 200 ok, 400 on unreachable/non-2xx
- /validate/vapid: webpush.setVapidDetails() structural check; reads ONLY from process.env
(VAPID_PRIVATE_KEY never from app_config, never returned; T-12-06/SC-3)
- /credential: inserts local user (oidcIss=null, claimed=false, isAdmin=true) FIRST
(Pitfall 5 FK), then calls validateEncryptAndStoreCredential(); noEchoHook + error map
- /complete: upserts setup_complete='true'; 200 first call, 423 second (Pitfall 8/D-10)
- Mount setupRouter pre-auth in index.ts BEFORE devAuthBypass() (T-12-09/Pitfall 1)
- All 394 tests pass (5 todo = D-08 RED scaffolds); typecheck clean
- Add apps/api/src/lib/setupGuard.ts exporting isSetupLocked(): Promise<boolean>
(Wave-0 stub returns false; real DB impl ships in Plan 02)
Doc comment enforces D-10: re-evaluate fresh on every call, never module-cache
- Add apps/api/src/routes/setup.ts exporting setupRouter = new Hono()
(empty router; handlers + index.ts mount added in Plan 02)
Doc comment notes pre-auth surface position — before /api/* OIDC chain
- Remove .notNull() from users.oidc_iss and users.oidc_sub (wizard creates
local rows before OIDC identity is known; first-login-claims binds later)
- Add users.claimed boolean (default false NOT NULL) to distinguish pending
wizard rows from OIDC-bound rows (D-07)
- Add Phase 12 app_config key documentation + prohibition comment (D-01/SC-3)
- Generate migration 0002_lethal_millenium_guard.sql via drizzle-kit generate
(MODIFY COLUMN for nullable, ADD COLUMN claimed — no DROP/recreate)
- Append backfill: UPDATE users SET claimed=true WHERE oidc_iss IS NOT NULL
so existing OIDC users cannot be matched by first-login-claims (D-08)
- Apply migration via drizzle-kit migrate — users.claimed column verified in dev DB
These two files (from the WR-01 / IN-03 review fixes) had formatting that
failed `pnpm format:check`. No logic change — whitespace/wrapping only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The UPDATE and CREATE all-day branches each called getHouseholdTimezone(db)
independently, so a drain processing both an all-day create row and an
all-day update row issued two identical app_config SELECTs. Add a lazy
per-cycle TimezoneResolver (mirroring the existing clientCache thread-through)
created in runOutboxDrain and passed into dispatchRow. The read stays lazy —
cycles with no all-day work never touch the DB — but is shared across all
all-day rows in a cycle. Behavior unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The seed handler computed seeded from a pre-flight SELECT then returned
seeded:!alreadySet. Under a genuine concurrent race both requests can
SELECT the empty table, both enter the insert branch, and both return
seeded:true though only one row was actually written. Replace the
SELECT + conditional onDuplicateKeyUpdate with a single INSERT IGNORE
and derive seeded from affectedRows (1 = inserted, 0 = ignored/existing
row preserved, D-03). On MariaDB onDuplicateKeyUpdate(value=value)
reports affectedRows 1 for both insert and no-op, so it cannot
distinguish them; INSERT IGNORE can. timezone is bound via a
parameterized sql template and is already IANA-validated by zod. Adds a
test asserting seeded:false for a directly-pre-inserted row.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The GET /config/timezone handler SELECTed app_config then, on the unset
path, called getHouseholdTimezone(db) which re-issued the identical
SELECT before falling back (IN-01). The fallback decision also lived in
two places (IN-02). Route the handler through the centralized
resolveHouseholdTimezone(row?.value) added for WR-01: no redundant
round-trip, single source for the D-06 policy. Behavior unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The D-06 fallback used row?.value ?? process.env.TZ ?? Intl..., but ??
only short-circuits on null/undefined. A set-but-empty TZ ('' or ' ')
leaked through and yielded an invalid IANA zone that throws inside
Intl.DateTimeFormat({ timeZone }) downstream, silently dropping the
all-day reminder. Extract resolveHouseholdTimezone() which trims and
treats empty/whitespace candidate values (stored value and TZ) as
absent so they fall through to the Intl resolved zone. Adds RED->GREEN
unit tests for empty and whitespace-only TZ.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>