Commit Graph
10 Commits
Author SHA1 Message Date
Lucas BergerandClaude Sonnet 4.6 687f9dc9fa fix(12): WR-01 narrow TOCTOU guard and set claimed=true for OIDC inserts
- 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>
2026-06-15 16:46:41 -04:00
Lucas BergerandClaude Sonnet 4.6 c8894adc3f feat(12-03): upsertUser first-login-claims branch (D-08)
- 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>
2026-06-15 14:14:48 -04:00
Lucas Berger 72e0140f01 feat(10-02): add first-login-wins is_admin bootstrap in upsertUser (D-01)
- zero-admin COUNT check before INSERT: first user gets is_admin=true
- subsequent users (admin already exists) get is_admin=false
- existing-user early-return path unchanged (is_admin not modified)
- Phase-12 hook comment: tighten to first login after app_config.setup_complete
- adds 'import { sql }' from drizzle-orm
2026-06-13 14:35:11 -04:00
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00
Lucas Berger d4d5327fc4 fix(calendar): all-day off-by-one (exclusive DTEND) + member color too close to shared rose
All-day: a single-day all-day event displayed across two days. iCal all-day
DTEND is EXCLUSIVE (1-day event = DTSTART:24/DTEND:25) and the server occurrence
carries that exclusive end, but Schedule-X treats all-day end as INCLUSIVE.
hydrateEvents now subtracts one day (clamped to >= start) so a 1-day event shows
on one day and an N-day event spans N days. Write path was already correct
(verified against stored VEVENTs). +regression test.

Color: a member's coral (#E8734A) was mistaken for the shared-family rose
(#F25C7A). Reorder COLOR_PALETTE so warm near-rose hues (amber, coral) are
assigned LAST; early members get cool, clearly-distinct colors (blue/green/teal).
2026-06-07 18:37:56 -04:00
Lucas Berger f700182674 fix(auth): assign first UNUSED palette color (AUTH-03 distinct colors)
Gate 2 A3 fail: a second member (amelia) got the same color as the first (luc),
both #E8734A. Color was assigned by COUNT(*) % palette; a deleted spike user
shifted the count so two live members landed on the same slot. Replace with
'first palette color not already in use by another user' (fall back to count
round-robin only once the palette is exhausted) — guarantees distinct, stable
colors for up to palette length members. +1 regression test (deletion frees a
slot → next member fills it, no collision).
2026-06-07 18:14:20 -04:00
Lucas Berger 2c8f1a28af fix(auth): self-healing displayName; drop synthetic Member<sub> from storage
The legend showed 'Member 972be1a3' because Authelia does not emit
name/preferred_username/email in the ID TOKEN (only at the userinfo endpoint),
and @hono/oidc-auth reads ID-token claims only. The real fix is an Authelia
claims_policy adding those claims to id_token for the familysync client.

App-side robustness so it self-heals once Authelia is fixed (no DB surgery):
- deriveDisplayName now returns null (not a synthetic 'Member <sub>') when no
  real claim is present, so we never persist an ugly sub string; the UI degrades
  to a generic 'Member'.
- upsertUser now tracks the IdP name authoritatively: a non-null displayName that
  differs from the stored value updates the row (blank/stale 'Member …'/email →
  real name on next login). A null value never overwrites a good stored name.
2026-06-07 16:17:17 -04:00
Lucas Berger a99ef1daae refactor(260607-l6l): extract shared deriveDisplayName helper (BUG 2 DRY)
The displayName claim-preference logic (name → preferred_username → email →
sub fallback) was duplicated verbatim in me.ts and events.ts resolveUserId.
Extract it to auth/user.ts as deriveDisplayName and use it in both call sites,
so the rule has one definition. Update the events.test.ts user.js mock to keep
the real helper (spread importActual) while stubbing only upsertUser.
2026-06-07 15:37:18 -04:00
Lucas Berger 23c8bb3402 fix(260607-l6l): derive displayName from OIDC claims in me.ts + resolveUserId
BUG 2: Both me.ts and events.ts resolveUserId were passing email (often
absent) as displayName to upsertUser, resulting in blank legend names.
Also, upsertUser returned existing rows unchanged even when displayName
was null and a better value was now available.

- me.ts: derive displayName via name → preferred_username → email →
  "Member <sub-prefix>" fallback, checked defensively. Updated JSDoc.
- events.ts resolveUserId: same derivation so write-path upserts don't
  re-blank a correctly-set displayName.
- user.ts: when existing row has null displayName and caller supplies one,
  issue an UPDATE so already-existing blank rows are corrected on next login.

Authelia-side emission of name/preferred_username is an operator concern
(claim mappings / userinfo scope in authelia config) — out of scope here.
The code now reads whatever claims are present and falls back sensibly.
2026-06-07 15:27:40 -04:00
Lucas Berger baabfce9e2 feat(01-02): implement upsertUser with stable color assignment (AUTH-03)
- Export COLOR_PALETTE (6 accessible hex hues, round-robin assignment)
- upsertUser(oidcIss, oidcSub, displayName?) keyed on iss+sub never email
- First login: COUNT existing users → assign COLOR_PALETTE[count % len]
- Re-upsert: returns existing row unchanged (idempotent, no duplicate insert)
- Uses $returningId() + re-select pattern (mysql2 no RETURNING clause)
- All 6 tests pass (GREEN)
2026-06-04 10:21:04 -04:00