Lint (eslint --max-warnings 0):
- index.ts: disable no-unsafe-argument on the type-only Context mismatch when
delegating to the OIDC handler inside the local-session skip wrapper
- localAuth.ts: handleLogout is sync (no await) — drop async (require-await)
- devBypass.ts: disable detect-possible-timing-attacks on the public well-known
dev-placeholder string compare (not a secret comparison)
- remove dead code / unused bindings flagged by no-unused-vars: makeTestApp
(localSession.test), makeUnauthContext + BrowserContext import (login.spec),
unused memberId (admin.test), unused txSelectCount counter (me.test)
- localAuthMiddleware.test / me.test: fix unused + reflow-detached
eslint-disable directives
Format: prettier --write across the 20 Phase-19 files that were never formatted.
Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char
TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key
regex hit on "credential atomically, 409-equivalent"). Neither is a real secret.
Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks),
PWA 266/266, API 452/452.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
- 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
- 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)
- 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>
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>
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>
- 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
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>
- Import sql from drizzle-orm in admin.ts
- Add onDuplicateKeyUpdate({ set: { value: sql\`value\` } }) to the
conditional INSERT in POST /config/timezone/seed so a concurrent seed
(or seed racing a PUT) cannot 500 on the app_config.key PK constraint
- Existing value is preserved per D-03 no-overwrite (no-op ODKU)
- seeded flag still reflects the pre-flight SELECT (winner: true, loser: false)
- Add tests: 403 access control, seeded:true on first seed, seeded:false
on second seed without throw (WR-02 idempotent race)
- Add appConfig + getHouseholdTimezone/isValidIanaTimezone imports to admin.ts
- Add timezoneSchema: z.object({ timezone: z.string().min(1).max(64).refine(isValidIanaTimezone) })
No noEchoHook — timezone strings are non-sensitive (T-18-06)
- GET /api/admin/config/timezone: returns { timezone, isExplicitlySet } using D-06 fallback
- PUT /api/admin/config/timezone: validates via timezoneSchema + upserts via onDuplicateKeyUpdate
- POST /api/admin/config/timezone/seed: SELECT-then-INSERT (no onDuplicateKeyUpdate) to enforce D-03 no-overwrite
- All three routes appended AFTER existing routes so line-41 requireAdmin covers them (T-18-03)
- All 25 admin.test.ts tests pass; 366/366 full API suite green; tsc --noEmit clean
- eventFieldsSchema (events.ts): rejects reminderLeadMinutes > 10080 with 400
- outboxPayloadSchema (outboxWorker.ts): hard-fails row when value exceeds 1-week cap
- 10080 = 1 week in minutes; matches UI select maximum
- Add reminderLeadMinutes: number | null to CalendarOccurrence interface (D-06)
- Import classifyValarms in expand.ts; derive series-level value once per VEVENT
- Add reminderLeadMinutes to both non-recurring and recurring occurrence construction
- Add reminderLeadMinutes to GET /api/events select for edit-mode pre-population
PUT /api/admin/calendars/:id/shared cleared the current shared calendar then
set the target in two non-transactional UPDATEs without checking the target
exists — a bad/stale id wiped the family shared lane and still returned ok.
Verify the target inside a transaction; return 404 when absent. Adds a
regression test (RED→GREEN).
- dev-bypass path: DB lookup for users.isAdmin (T-10-05 bypass skips OIDC not DB)
- OIDC path: same resolveAdminAndSetupStatus helper after upsertUser
- needsProviderSetup: true when no member_credentials row, false when one exists
- no /api/me/credential POST added here (Plan 03)
- import signalOutboxDrain from '../lib/outboxTrigger.js'
- Site 1 (POST /create): fire-and-forget signal after outbox insert, before 202
- Site 2 (PATCH edit-as-move): signal after await db.transaction() resolves, not inside callback (D-03)
- Site 3 (PATCH same-calendar update): signal after outbox insert, before 202
- Site 4 (DELETE): signal after outbox insert, before 202
- eslint.config.js: disable React Compiler rules (v7 flat.recommended enables
them; codebase does not use the Compiler); add e2e/ to disableTypeChecked
block; promote exhaustive-deps to error
- API broker: remove redundant as-casts (outboxWorker, poller, reminderScheduler,
expand, sync, vevent, spike); add targeted ical.js no-unsafe-assignment/argument
disables with justifying comments inside try blocks
- API routes/sse.ts: fix no-misused-promises on async writeSSE callback with
void+IIFE+catch pattern
- API routes/lists.ts: let → const for updateValues
- API tests: remove unused imports (beforeEach, eq, vi); rename unused vars
with _ prefix; remove unused lastActiveId assignment
- PWA components: void navigate() and void queryClient.invalidateQueries() on
all fire-and-forget call sites; fix CalendarShell explicit-type-casts;
Couldn't → HTML entity
- PWA test files: as unknown as Response for partial mock objects; string | null
type annotation on mockLastSyncedUid; remove async from test callbacks without
await; act(() => {}) not await act(async () => {}) for sync ops
- sw.ts: restructure Notification.data?.url access as let+if so disable
comments land on the exact violation lines; void self.skipWaiting()
- Create apps/api/src/routes/push.ts: GET /vapid-public-key, POST /subscription (upsert), DELETE /subscription (user-scoped)
- Wire pushRouter at /api/push in index.ts
- Call webpush.setVapidDetails() in isMainModule() guard before serve()
- Fix broken vi.getMockImplementation scaffold bug in push.test.ts (Rule 1)
- push.test.ts: all 4 tests GREEN
- Immediately after access check, return 403 if patch.isShared !== undefined
and !access.isOwner — blocks sharees from mutating list_shares
- Guard message: 'Only the list owner can change sharing settings'
- Sharees may still PATCH { name } (rename test stays green)
- Update stale comment: 'Reconcile list_shares on visibility change (owner only)'
- Closes T-04-08 (elevation of privilege) and T-04-05 (shared root cause)
- Wire publishListEvent fan-out in lists.ts after every write mutation (item:added/updated/deleted, list:updated/deleted)
- Add GET /api/sse/lists scoped endpoint in sse.ts: resolveUserId → 401 on null; getAccessibleListIds → subscribe only to accessible channels; 30s heartbeat; cleanup on disconnect (D-04/T-04-01/T-04-02)
- Create useListSSE.ts: bounded-backoff EventSource wrapper (250ms→500ms→1s→2s→4s→cap 8s); MAX_ATTEMPTS=6; withCredentials:true; close-before-retry prevents reconnect storm (Pitfall 3); invalidates ['list', listId] on open (D-10) and on each event; onStateChange('disconnected') after exhaustion (D-11)
- Create LiveSyncIndicator.tsx: connected=green dot; reconnecting=pulsing muted dot + label; disconnected=red dot + 'Updates paused' (role=alert); correct ARIA per UI-SPEC
- Wire useListSSE + LiveSyncIndicator into ListDetail header; retain refetchInterval:30000 polling fallback (D-12)
- All 8 useListSSE tests pass; all 54 API tests pass; both typechecks pass
- playwright-cli: live update confirmed (eggs item added via API appeared in browser without manual refresh)