Files
familysync/.planning/milestones/v1.1-phases/10-admin-role-settings/10-RESEARCH.md
T
2026-06-18 22:21:38 -04:00

46 KiB
Raw Blame History

Phase 10: Admin Role & Settings - Research

Researched: 2026-06-13 Domain: Role-gated admin API (Hono sub-router + middleware), Drizzle v1.1 DB migration, encrypted credential rotation (CalDAV PROPFIND), member self-service onboarding, React PWA gated route Confidence: HIGH


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: First-login-wins. When no admin exists, the first user to log in is flagged is_admin=true. Member-count-agnostic per-user boolean. Phase 12 interaction: tightened to "first user after app_config.setup_complete" (Phase 12 owns that gating, Phase 10 ships the column and the bootstrap logic). Dev note: under DEV_AUTH_BYPASS, DEV_USER (id 1) is injected without a DB upsert — must decide and document how bypass user acquires is_admin for local admin-UI verification.
  • D-02: New /admin route. A dedicated gated route, not an extension of the existing notifications SettingsSheet. An is_admin guard redirects non-admins away.
  • D-03: Expose isAdmin on /api/me. PWA uses it for UX gating only; server enforces the role on every /api/admin/* route (ADMIN-03 is always server-side).
  • D-04: Generic provider shape, Fastmail-only implementation. Provider/type discriminator on the credential model. No second provider built here; Gmail/other providers are wiring for backlog 999.1.
  • D-05: Per-member provider credential. member_credentials stays per-user. An admin can rotate ANY member's credential. Reuse existing crypto.ts encryption path.
  • D-06: Exclusive single-select shared-calendar designation. Setting a new shared calendar clears is_shared on any prior shared calendar. Radio/toggle, not independent multi-toggles.
  • D-07: Member self-service credential, member-scoped. A member with no member_credentials row gets a needsProviderSetup signal. They can enter/validate (CalDAV PROPFIND)/encrypt their OWN Fastmail app password, sharing the identical validate→encrypt→initial-sync path as admin rotation. Cross-member rotation stays admin-only.

Claude's Discretion

  • Migration packaging: ship is_admin, app_config, reminder_lead_minutes in one Phase-10 migration (generate+migrate, never push).
  • app_config shape: create with at least setup_complete flag for Phase 12. Simple key/value or single-row config — planner's call.
  • /api/admin/* route layout: sub-routes for members/credentials and shared-calendar — planner decides exact paths following existing routes/*.ts Hono pattern.
  • Server-side requireAdmin guard shape — planner's call.

Deferred Ideas (OUT OF SCOPE)

  • Full multi-provider support (Gmail/other) — backlog 999.1. Phase 10 lands only the generic credential shape.
  • Admin audit log / health dashboard / user CRUD — explicitly out of scope per REQUIREMENTS.md.
  • Multiple reminders per event — v1.2 stretch (unrelated to Phase 10). </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
ADMIN-01 Admin can view household members and update (rotate/re-enter) a member's Fastmail app password from the UI; validated against CalDAV (PROPFIND) before saving; stored encrypted; never displayed, logged, or echoed. Credential rotation path, encryptPassword, createFastmailClient+fetchCalendars PROPFIND validation, Zod hook Pitfall 7, self-service counterpart (D-07)
ADMIN-02 Admin can designate which synced calendar is the shared family calendar (calendars.is_shared) from the UI, replacing the manual DB write. Exclusive is_shared update via drizzle-orm db.update, calendar list endpoint
ADMIN-03 Admin Settings routes and UI are gated by a role check; a non-admin member cannot reach or invoke them. requireAdmin sub-router middleware (Pitfall 9), users.is_admin column, isAdmin on /api/me
</phase_requirements>

Summary

Phase 10 is primarily an API-plus-UI phase: it ships the v1.1 DB migration (three new columns/tables), builds a role-gated adminRouter behind requireAdmin middleware, exposes two API surfaces (/api/admin/credentials for ADMIN-01 and /api/admin/calendars/:id/shared for ADMIN-02), and adds a /admin route to the React PWA. It also delivers member self-service credential onboarding (D-07) — a member-scoped counterpart sharing the same validate→encrypt→initial-sync path.

The technical approach is well-defined by existing code. encryptPassword and decryptPassword in broker/crypto.ts are used verbatim. Credential validation reuses createFastmailClient + client.fetchCalendars() (a CalDAV PROPFIND) — the same path the poller and triggerTargetedResync already use. The initial-sync after save reuses triggerTargetedResync (already extracted in outboxWorker.ts). The Drizzle migration workflow is db:generate then db:migrate (documented in package.json scripts); the existing drizzle.config.ts and migrations/ directory are ready for a second migration file.

The single biggest implementation subtlety is Pitfall 7 (password never echoed): the @hono/zod-validator hook must return c.json({ error: 'Invalid request' }, 400) with NO received / value fields from Zod's error output, and no console.log of request bodies anywhere in admin routes. Pitfall 9 (admin guard inside the sub-router) is mechanically straightforward: call adminRouter.use('*', requireAdmin) as the first statement of the adminRouter so the guard applies before any route handler runs.

Primary recommendation: Build apps/api/src/routes/admin.ts as a new Hono sub-router, mount it on /api/admin in index.ts after the existing auth guards, apply requireAdmin with .use('*', ...) inside the router, then add the two admin API surfaces plus a member-credential-status endpoint. The self-service credential endpoint lives on /api/me/credential (member-scoped). One drizzle-kit migration adds all three v1.1 schema items in a single file.


Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
DB migration (is_admin, app_config, reminder_lead_minutes) Database / Storage Schema change; drizzle-kit owns it
Admin role check enforcement API / Backend Server always enforces; client flag is UX-only
isAdmin signal on /api/me API / Backend Browser / Client Server writes; PWA reads for nav gating
needsProviderSetup signal API / Backend Browser / Client Server knows if member_credentials row exists
First-login-wins admin bootstrap API / Backend upsertUser in auth/user.ts; never client-side
DEV_AUTH_BYPASS admin acquisition API / Backend Seed or bypass flag in devBypass.ts / DB seed
Credential validation (CalDAV PROPFIND) API / Backend Never client-side; credentials never sent to browser
Credential encryption/storage API / Backend Database / Storage crypto.ts + member_credentials row
Shared-calendar exclusive write API / Backend Database / Storage calendars.is_shared single-row transaction
/admin React route + gating Browser / Client react-router gated by meQuery.data?.isAdmin
Credential sheet UI Browser / Client Forms, validation UX, provider help text
Shared calendar picker UI Browser / Client Radio group, save button
Self-service onboarding banner Browser / Client Dismissal tied to needsProviderSetup becoming false
Admin nav entry (conditional) Browser / Client Rendered only when isAdmin = true

Standard Stack

No new npm packages are required for this phase. All libraries are already installed. The phase reuses the existing stack exclusively.

Core (already installed)

Library Installed Version Purpose Why
hono 4.12.23 HTTP framework, sub-router, middleware Already in use
@hono/zod-validator 0.8.0 Zod validation middleware with hook support Already in use
zod 3.25.x Schema validation Already in use
drizzle-orm 0.45.2 MariaDB query layer Already in use
drizzle-kit 0.31.10 Migration generation + execution Already in use
mysql2 3.22.4 MariaDB driver Already in use
tsdav 2.2.2 CalDAV PROPFIND client (credential validation) Already in use
react + react-router 19.x / 7.x PWA routing for /admin Already in use
lucide-react 1.17.0 Icons (ShieldCheck, KeyRound, etc.) Already in use
@tanstack/react-query 5.101.0 /api/me and /api/admin/* data fetching Already in use

Package Legitimacy Audit

No new packages are introduced in this phase. All packages listed below were already installed prior to Phase 10.

Package Registry Verdict Disposition
hono npm OK (SUS flag only because very recent publish; 44M/wk downloads) Approved — already installed
@hono/zod-validator npm OK Approved — already installed
drizzle-orm npm OK Approved — already installed
drizzle-kit npm OK Approved — already installed
lucide-react npm SUS (recent publish; 84M/wk downloads — established package) Approved — already installed

Packages removed due to SLOP verdict: none Packages flagged as suspicious [SUS]: hono and lucide-react flagged only due to recent version publish date; both are established packages with very high download counts and known source repos. No new installs required; risk is negligible since they are already in the lockfile.


Architecture Patterns

System Architecture Diagram

Browser (React PWA)
  │  GET /api/me → { user: { id, displayName, color, isAdmin, needsProviderSetup } }
  │  meQuery.data.isAdmin → render Admin NavLink / BottomTab
  │  /admin route mounted in BrowserRouter (gated by isAdmin redirect on mount)
  │
  │  Admin surfaces:
  │    GET  /api/admin/members          → list members + credential status
  │    POST /api/admin/credentials      → validate + encrypt + store credential for any member
  │    GET  /api/admin/calendars        → list synced calendars
  │    PUT  /api/admin/calendars/:id/shared → set exclusive is_shared flag
  │
  │  Self-service surface (member-scoped):
  │    POST /api/me/credential          → validate + encrypt + store OWN credential only
  │
  ▼
Hono API (apps/api/src)
  ├── /api/* ── devAuthBypass() → oidcAuthMiddleware() [existing]
  ├── /api/me  ── meRouter [modified: add isAdmin + needsProviderSetup]
  │     upsertUser() → now also writes is_admin on first login (first-login-wins)
  ├── /api/admin/* ── adminRouter [NEW]
  │     adminRouter.use('*', requireAdmin)  ← Pitfall 9: guard INSIDE the sub-router
  │     GET  /members          → SELECT users LEFT JOIN member_credentials
  │     POST /credentials      → zValidator(hook: no-echo) → PROPFIND → encryptPassword → upsert
  │     GET  /calendars        → SELECT calendars WHERE is_shared known
  │     PUT  /calendars/:id/shared → exclusive UPDATE (clear others, set one)
  └── /api/me/credential [NEW, member-scoped self-service]
        → same validate→encrypt→initial-sync path; userId always currentUser.id
  
DB (MariaDB via Drizzle)
  ├── users.is_admin BOOLEAN NOT NULL DEFAULT false        [v1.1 migration]
  ├── app_config table (key VARCHAR PK, value TEXT)        [v1.1 migration]
  ├── calendar_events.reminder_lead_minutes INT NULL        [v1.1 migration]
  ├── member_credentials.provider_type VARCHAR DEFAULT 'caldav' [v1.1 schema add]
  └── calendars.is_shared — already exists at schema line 89
  
broker/
  ├── crypto.ts   encryptPassword / decryptPassword    [REUSE, no changes]
  ├── client.ts   createFastmailClient                 [REUSE, no changes]
  └── outboxWorker.ts  triggerTargetedResync           [REUSE for initial-sync after save]
apps/api/src/
├── auth/
│   ├── user.ts          # upsertUser — add is_admin first-login-wins logic
│   └── devBypass.ts     # DEV_USER — seed is_admin=true or add flag
├── db/
│   ├── schema.ts        # add is_admin, app_config table, reminder_lead_minutes, provider_type
│   └── migrations/
│       └── 0001_v1_1_foundation.sql  # generated by drizzle-kit generate
├── routes/
│   ├── me.ts            # add isAdmin + needsProviderSetup to response
│   └── admin.ts         # NEW: adminRouter with requireAdmin guard
└── lib/
    └── requireAdmin.ts  # NEW: MiddlewareHandler that checks c.get('user').isAdmin

apps/pwa/src/
├── App.tsx              # add /admin <Route> + gated redirect
├── api/client.ts        # add isAdmin + needsProviderSetup to MeUser interface
├── routes/
│   └── AdminPage.tsx    # NEW: /admin page shell
└── components/
    ├── AppNav.tsx        # add conditional Admin NavLink (ShieldCheck icon)
    ├── BottomTabBar.tsx  # add conditional Admin tab
    ├── CredentialSheet.tsx  # NEW: shared sheet for admin + self-service
    └── SetupBanner.tsx   # NEW: needsProviderSetup dismissable banner

Pattern 1: requireAdmin Middleware Inside adminRouter (Pitfall 9)

What: Apply requireAdmin as .use('*', requireAdmin) as the FIRST call on the adminRouter — NOT only at the parent mount in index.ts. This ensures the guard executes for every route on the sub-router and cannot be accidentally bypassed by mounting order.

When to use: Every /api/admin/* route.

Example:

// Source: Hono docs /websites/hono_dev — sub-router middleware pattern
import { Hono } from 'hono';
import type { MiddlewareHandler } from 'hono';

// requireAdmin: reads c.get('user') (the same key devAuthBypass and the OIDC path set),
// checks is_admin on the resolved DB user, returns 403 if not admin.
// Must NOT log credentials or user claims.
export const requireAdmin: MiddlewareHandler = async (c, next) => {
  const user = c.get('user');
  // Under DEV_AUTH_BYPASS, user.id is DEV_USER.id (1); look up is_admin from DB.
  // Under OIDC, user comes from c.get('user') set by the existing resolveUserId pattern.
  // Pull is_admin from the DB users row for the current user id.
  const row = await db.select({ isAdmin: users.isAdmin })
    .from(users)
    .where(eq(users.id, user.id))
    .limit(1);
  if (!row[0]?.isAdmin) {
    return c.json({ error: 'Forbidden' }, 403);
  }
  await next();
};

// In apps/api/src/routes/admin.ts:
export const adminRouter = new Hono();
adminRouter.use('*', requireAdmin);  // FIRST — guards all sub-routes
adminRouter.get('/members', async (c) => { ... });
adminRouter.post('/credentials', zValidator('json', credentialSchema, hook), async (c) => { ... });
// ...

// In apps/api/src/index.ts (after existing auth guards):
app.route('/api/admin', adminRouter);

[CITED: https://hono.dev/docs/guides/best-practices — sub-router pattern; [CITED: 10-CONTEXT.md Pitfall 9]

Pattern 2: Zod Validator Hook — No Password Echo (Pitfall 7)

What: The @hono/zod-validator hook callback intercepts validation failures. Return a generic 400 response with NO issues, received, or value fields — this prevents the submitted password from being reflected back in the error response body or logs.

When to use: Every route that accepts a credential (app password) field in the request body.

Example:

// Source: honojs/middleware README — zValidator hook pattern [CITED: https://github.com/honojs/middleware/blob/main/packages/zod-validator/README.md]
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';

const credentialSchema = z.object({
  userId: z.number().int().positive(),
  providerType: z.literal('caldav'),
  fastmailEmail: z.string().email(),
  appPassword: z.string().min(1).max(500),
});

// The hook MUST return a response that never echoes .error.issues (which contains `received`)
// and never logs the body. Return a generic message only.
const noEchoHook = (result: SafeParseReturnType<unknown, unknown>, c: Context) => {
  if (!result.success) {
    // NEVER: return c.json(result.error, 400)  — that echoes the password
    // NEVER: console.log(result)
    return c.json({ error: 'Invalid request' }, 400);
  }
};

adminRouter.post(
  '/credentials',
  zValidator('json', credentialSchema, noEchoHook),
  async (c) => {
    const { userId, fastmailEmail, appPassword } = c.req.valid('json');
    // appPassword is NEVER logged here — no console.log(c.req.valid('json'))
    // validate → encrypt → store
  }
);

[CITED: https://github.com/honojs/middleware/blob/main/packages/zod-validator/README.md]

Pattern 3: Drizzle-Kit Generate + Migrate Workflow

What: pnpm --filter @familysync/api db:generate generates a new .sql migration file in apps/api/src/db/migrations/. pnpm --filter @familysync/api db:migrate applies pending migrations. NEVER run drizzle-kit push on a populated MariaDB — it emits false destructive diffs.

When to use: Every schema change. This phase ships one migration bundling all three v1.1 items.

Existing workflow (from codebase):

# 1. Edit apps/api/src/db/schema.ts (add is_admin, app_config table, reminder_lead_minutes, provider_type)
# 2. Generate the migration SQL
cd apps/api && pnpm db:generate
# → creates apps/api/src/db/migrations/0001_v1_1_foundation.sql
#   and updates apps/api/src/db/migrations/meta/_journal.json

# 3. Apply to local dev MariaDB (DB_HOST=127.0.0.1 from dev compose override)
cd apps/api && DB_HOST=127.0.0.1 DB_USER=... DB_PASSWORD=... DB_NAME=... pnpm db:migrate
# → runs the new migration against the populated DB

# 4. Verify applied
# MariaDB: SHOW COLUMNS FROM users; SHOW TABLES; 
# Confirm: is_admin column on users, app_config table, reminder_lead_minutes on calendar_events

[VERIFIED: codebase — apps/api/package.json scripts.db:generate and scripts.db:migrate; apps/api/drizzle.config.ts]

Note: drizzle.config.ts reads DB credentials from env at runtime. In CI (Gitea), the api job already runs pnpm db:migrate via the existing drizzle-kit migrate step — Phase 10's new migration file is picked up automatically by the journal.

Pattern 4: Validate → Encrypt → Initial-Sync (Shared Code Path)

What: The exact sequence for both admin credential rotation (D-05) and member self-service (D-07).

Signature (from existing codebase):

// Step 1: Validate credential against CalDAV (PROPFIND)
// Source: apps/api/src/broker/client.ts createFastmailClient
// Source: apps/api/src/broker/poller.ts — client.fetchCalendars() is the PROPFIND
import { createFastmailClient } from '../broker/client.js';

async function validateCredential(email: string, password: string): Promise<boolean> {
  try {
    const client = await createFastmailClient(email, password);
    await client.fetchCalendars();  // throws if auth fails
    return true;
  } catch {
    return false;
  }
}

// Step 2: Encrypt and store
// Source: apps/api/src/broker/crypto.ts encryptPassword
import { encryptPassword } from '../broker/crypto.js';

const encrypted = encryptPassword(appPassword);
// INSERT OR UPDATE member_credentials SET encrypted_password=encrypted, fastmail_email=email
// Use Drizzle onDuplicateKeyUpdate for upsert pattern (matching existing schema)

// Step 3: Trigger initial sync for the member
// Source: apps/api/src/broker/outboxWorker.ts triggerTargetedResync
// That function is private to outboxWorker.ts — extract it to a shared broker utility
// OR: call the poller's runPoll path via a targeted helper.
// Simplest approach: call syncCalendar directly after credential save,
// using the same client+davCalendars flow from poller.ts runPoll.
// (The outboxWorker already has triggerTargetedResync — promote it to exported or duplicate the pattern.)

[VERIFIED: codebase — apps/api/src/broker/crypto.ts, apps/api/src/broker/client.ts, apps/api/src/broker/outboxWorker.ts:271-348]

Initial sync after credential save: triggerTargetedResync in outboxWorker.ts (lines 302348) is the canonical post-save sync path. It calls loadClientForUser, client.fetchCalendars(), then syncCalendar. For admin routes, this function is currently private; the planner must either:

  • Export it from outboxWorker.ts, or
  • Extract the logic to a new broker/credentialSync.ts shared helper. The self-service path (member sets own credential) and the admin rotation path call the same function.

Pattern 5: First-Login-Wins Admin Bootstrap

What: In upsertUser (auth/user.ts), after the existing[0] early-return path but before the INSERT, check if zero admin users exist. If yes, set is_admin=true on the new user. This is the hook point that Phase 12 will extend by also checking app_config.setup_complete.

Phase-12-safe implementation:

// In upsertUser, after color assignment, before INSERT:
// "first user when zero admins exist" — Phase 12 tightens to "first user after setup_complete"
// by adding AND(eq(appConfig.setupComplete, true)) to the zero-admin check.
// Phase 10: simply check for zero existing admins.
const adminCount = await db.select({ count: sql<number>`COUNT(*)` })
  .from(users).where(eq(users.isAdmin, true));
const shouldBeAdmin = adminCount[0].count === 0;
// Then INSERT with isAdmin: shouldBeAdmin

[VERIFIED: codebase — apps/api/src/auth/user.ts upsertUser function (lines 76128); existing pattern extended]

DEV_AUTH_BYPASS user-1 admin acquisition: DEV_USER (id 1) is injected without a DB upsert (see devBypass.ts + me.ts short-circuit). Options:

  1. Recommended (seed): tests/global-setup.ts already seeds the dev MariaDB; add UPDATE users SET is_admin=1 WHERE id=1 to the seed if user 1 exists, or ensure the seed creates user 1 with is_admin=true. This is the cleanest because it matches the real DB state.
  2. Alt (bypass flag): The requireAdmin middleware checks users.isAdmin from the DB. For the bypass user (id=1), the DB row already exists from seeding (Phase 7 global-setup). A seed approach makes the bypass admin status durable across restarts without code path changes to requireAdmin.

The bypass path short-circuits in me.ts before upsertUser/api/me returns DEV_USER directly. requireAdmin must still look up users.isAdmin from the DB for the bypass user; the bypass only skips OIDC, not the DB lookup.

Pattern 6: Provider Discriminator on member_credentials

What: Add a provider_type column to member_credentials to support the generic shape (D-04). Default 'caldav' for all existing rows. This makes the model N-provider ready without breaking the existing data.

Schema addition (in schema.ts):

// In memberCredentials table, add:
providerType: varchar('provider_type', { length: 64 }).notNull().default('caldav'),
// Existing columns: userId, encryptedPassword, fastmailEmail — all unchanged

The Drizzle migration will ALTER TABLE member_credentials ADD COLUMN provider_type VARCHAR(64) NOT NULL DEFAULT 'caldav' — safe on populated MariaDB because it has a default value.

[VERIFIED: codebase — apps/api/src/db/schema.ts memberCredentials table (lines 5569)]

Pattern 7: Exclusive is_shared Update (ADMIN-02)

What: Setting a new shared calendar must atomically clear is_shared on any existing shared calendar and set it on the target. Use two Drizzle UPDATE statements (no MariaDB transaction required for this use case — the worst case of a race is a brief moment with zero or two shared calendars, which resolves on the next render).

// In the PUT /api/admin/calendars/:id/shared handler:
// Step 1: clear all shared flags
await db.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true));
// Step 2: set the target
await db.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetCalendarId));
// Note: if strong atomicity is needed, wrap in db.transaction()

[VERIFIED: codebase — apps/api/src/db/schema.ts calendars.isShared (line 89); Drizzle ORM update pattern from CONVENTIONS.md]

Anti-Patterns to Avoid

  • Guard only at parent mount: app.use('/api/admin/*', requireAdmin) in index.ts alone is NOT sufficient. The guard MUST also be the first .use('*', ...) inside adminRouter itself (Pitfall 9).
  • Echoing Zod errors for credential routes: return c.json(result.error, 400) exposes received (the submitted password value). Always return a generic message from the hook.
  • Logging request body in admin routes: No console.log(c.req.valid('json')) or console.log(body) anywhere in admin route handlers.
  • Using drizzle-kit push: Always db:generate then db:migrate — never db:push on the dev or production MariaDB.
  • Checking isAdmin only client-side: The PWA's isAdmin flag is for UX (show/hide nav entry, redirect non-admins). The 403 is always server-enforced on every /api/admin/* request.
  • Calling triggerTargetedResync before the credential is saved: Encrypt and upsert first, then trigger the sync; otherwise the poller would attempt to decrypt a not-yet-stored credential.
  • Duplicating credential endpoints in /api/setup/*: Phase 12 MUST reuse /api/admin/credentials — do not create parallel /api/setup/credentials routes (CONTEXT.md hard constraint).

Don't Hand-Roll

Problem Don't Build Use Instead Why
AES-256-GCM encryption Custom crypto encryptPassword in broker/crypto.ts Already built, tested, used by the poller — no changes needed
CalDAV auth validation Manual HTTP/XML PROPFIND createFastmailClient + client.fetchCalendars() tsdav handles PROPFIND, XML namespaces, error mapping
Zod validation middleware Custom body parsing zValidator from @hono/zod-validator Already used throughout routes/events.ts; hook pattern handles no-echo
DB migration tooling Raw ALTER TABLE scripts drizzle-kit generate + migrate Already set up; journal-tracked; safe on MariaDB
Role middleware Custom session/cookie check requireAdmin MiddlewareHandler reading c.get('user') + DB lookup Single point of enforcement; reuses the existing user-context pattern

Key insight: This phase's complexity is almost entirely in the careful wiring of existing primitives, not in building new ones. The crypto, CalDAV client, and migration tooling are mature. The main work is the API route structure, the zod hook discipline, and the guard placement.


Runtime State Inventory

Not a rename/refactor phase — this section is omitted.


Common Pitfalls

Pitfall 1: App Password Echoed in Error Response (Pitfall 7)

What goes wrong: A failing Zod validation on the credential body returns c.json(result.error, 400). Zod's error object contains issues[].received which includes the actual submitted value — the app password is now in the HTTP response body and potentially in logs.

Why it happens: The default zValidator behavior without a hook returns the full Zod error. Developers add console.log('validation error:', result) for debugging.

How to avoid: Always use the hook parameter on credential routes: zValidator('json', schema, (result, c) => { if (!result.success) return c.json({ error: 'Invalid request' }, 400); }). No console.log of request bodies in any routes/admin.ts handler.

Warning signs: Test for this by submitting a known-bad password and asserting the 400 response body does NOT contain the submitted value string.

Pitfall 2: requireAdmin Only at Parent Mount (Pitfall 9)

What goes wrong: app.use('/api/admin/*', requireAdmin) in index.ts does not protect routes if the adminRouter's internal routing bypasses the parent middleware (e.g., via direct import in tests, or future route restructuring).

Why it happens: Hono's middleware chain is path-prefix-based at the parent level. If tests import adminRouter directly rather than through app, the parent guard is never applied.

How to avoid: adminRouter.use('*', requireAdmin) as the FIRST statement in admin.ts — before any route definition. Integration tests must import app (not adminRouter directly) and assert 403 for a non-admin authenticated user on every admin route.

Warning signs: A test that imports adminRouter directly and calls admin routes without a 403 check.

Pitfall 3: DEV_AUTH_BYPASS User Not Admin in DB

What goes wrong: DEV_AUTH_BYPASS is active. User id 1 is injected. requireAdmin looks up users.is_admin from the DB. If the dev seed doesn't include is_admin=true for user 1, every /api/admin/* request returns 403 locally.

Why it happens: The bypass skips upsertUser, so the first-login-wins logic never runs for user 1. The DB row for user 1 might not exist at all (or exists with is_admin=false).

How to avoid: The Phase 7 global-setup.ts seed the test/dev DB. Add an upsert of user id 1 with is_admin=true to the seed. For local development (not test), ensure the dev docker-compose seed SQL creates user 1 with is_admin=true.

Warning signs: Admin page redirects immediately in dev mode; API returns 403 with DEV_AUTH_BYPASS=true.

Pitfall 4: drizzle-kit push on Populated MariaDB

What goes wrong: Running drizzle-kit push on the populated dev/production MariaDB triggers false destructive diffs — it may attempt to DROP and recreate tables that already have data, appearing to need schema reconciliation.

Why it happens: drizzle-kit push computes diffs against live schema and emits CREATE/DROP statements for columns it cannot safely ALTER. On MariaDB this can produce false "drop table" statements even for benign additions.

How to avoid: ALWAYS pnpm db:generate then pnpm db:migrate. The migration journal tracks what has been applied. Adding a NOT NULL column WITH a DEFAULT value (e.g. is_admin BOOLEAN NOT NULL DEFAULT false) is safe for ALTER TABLE ADD COLUMN on populated tables.

Warning signs: drizzle-kit push output suggests DROP or TRUNCATE statements.

Pitfall 5: Initial Sync Not Triggered After Credential Save

What goes wrong: Admin saves a credential; the member's calendar does not appear until the next 5-min poller tick.

Why it happens: The credential is encrypted and stored, but syncCalendar / triggerTargetedResync is not called after the save.

How to avoid: After a successful credential upsert, call the extracted triggerTargetedResync equivalent for the member's userId. This is a fire-and-forget call — the admin route returns 200 immediately; the sync runs in the background. For self-service (D-07), same behavior after the member saves their own credential.

Warning signs: No calendars visible immediately after credential save; calendars only appear after the next poll.

Pitfall 6: Member Self-Service Endpoint Allows Cross-Member Write

What goes wrong: POST /api/me/credential receives a userId parameter in the body and uses it, allowing a member to overwrite another member's credential.

Why it happens: Route handler reads userId from the request body instead of from the authenticated session.

How to avoid: The member self-service endpoint ALWAYS uses currentUserId from resolveUserId(c) — never from the request body. Only the admin endpoint (POST /api/admin/credentials) accepts a userId parameter, after the requireAdmin guard.

Warning signs: Test that a non-admin member cannot POST to /api/admin/credentials (403) and that /api/me/credential ignores any userId in the body.


Code Examples

Resolved User ID Pattern (existing, for requireAdmin)

// Source: apps/api/src/routes/events.ts resolveUserId — established project pattern
// [VERIFIED: codebase]
async function resolveUserId(c: Context): Promise<number | null> {
  const devUser = c.get('user') as { id: number } | undefined;
  if (devUser) return devUser.id;

  const auth = await getAuth(c);
  if (!auth) return null;

  const iss = (auth.iss as string | undefined) ?? '';
  const sub = auth.sub ?? '';
  const displayName = deriveDisplayName(auth);
  const user = await upsertUser(iss, sub, displayName);
  return user?.id ?? null;
}

createFastmailClient + fetchCalendars (CalDAV PROPFIND Validation)

// Source: apps/api/src/broker/client.ts (line 22-35) [VERIFIED: codebase]
export async function createFastmailClient(
  email: string,
  appPassword: string,
): Promise<FastmailClient> {
  return createDAVClient({
    serverUrl: 'https://caldav.fastmail.com',
    credentials: { username: email, password: appPassword },
    authMethod: 'Basic',
    defaultAccountType: 'caldav',
  });
}
// Usage in credential validation:
// const client = await createFastmailClient(email, appPassword);
// await client.fetchCalendars();  // throws on auth failure

encryptPassword Reuse

// Source: apps/api/src/broker/crypto.ts (lines 42-54) [VERIFIED: codebase]
// Returns JSON string: { iv, authTag, ciphertext } — all hex-encoded
export function encryptPassword(plaintext: string): string { ... }
// Usage: encryptPassword(appPassword) → stored in member_credentials.encrypted_password
// NEVER log plaintext or the return value

Drizzle Upsert Pattern for member_credentials

// Pattern from events.ts / existing upsert conventions [VERIFIED: codebase — CONVENTIONS.md]
await db.insert(memberCredentials)
  .values({
    userId: targetUserId,
    encryptedPassword: encrypted,
    fastmailEmail: email,
    providerType: 'caldav',
  })
  .onDuplicateKeyUpdate({
    set: {
      encryptedPassword: encrypted,
      fastmailEmail: email,
      providerType: 'caldav',
    },
  });
// Note: member_credentials currently has idx_member_credentials_user_id (not UNIQUE on userId).
// To use onDuplicateKeyUpdate, need a UNIQUE constraint on user_id (or use SELECT+INSERT/UPDATE).
// Current schema has only an index, not UNIQUE — migration must add UNIQUE(user_id)
// OR the admin route uses SELECT then UPDATE/INSERT logic instead.

Important schema note: member_credentials currently has only an index on user_id (not UNIQUE). For D-05 (one credential per member), the v1.1 migration should add UNIQUE(user_id) to member_credentials to enable the Drizzle upsert pattern and enforce the one-credential-per-member invariant. Alternatively, use a SELECT-then-UPDATE/INSERT pattern without the constraint — planner decides.

/api/me Response Extension

// Current (apps/api/src/routes/me.ts line 66-73) [VERIFIED: codebase]
return c.json({
  user: {
    id: user.id,
    displayName: user.displayName,
    color: user.color,
    // Add:
    isAdmin: user.isAdmin,               // boolean from users.is_admin
    needsProviderSetup: !hasCredential,  // true if no member_credentials row
  },
});
// PWA: MeUser interface in apps/pwa/src/api/client.ts gains isAdmin + needsProviderSetup

State of the Art

Old Approach Current Approach When Changed Impact
node-cron for scheduled tasks setInterval throughout Phase 9 fix Don't reintroduce node-cron; schedulers stay on setInterval
drizzle-kit push drizzle-kit generate + migrate Documented in MEMORY.md Never push on populated MariaDB
Manual DB write for is_shared Admin UI endpoint Phase 10 (this phase) Replaces the UPDATE calendars SET is_shared=1 manual step

Deprecated/outdated:

  • drizzle-kit push: documented as unsafe for this project; must never be used (MEMORY.md drizzle-mariadb-push-unsafe).
  • node-cron 4.2.1: silently skips ticks in long-running process (MEMORY.md node-cron-skips); replaced with setInterval.

Environment Availability

Dependency Required By Available Version Fallback
MariaDB (dev compose) db:migrate, integration tests Dev compose exposed on 3306 (from MEMORY.md dev-stack-bringup)
Node.js 22 LTS API runtime 22 (confirmed by CI config)
pnpm scripts (CI uses corepack)
playwright-cli Admin UI browser verification /usr/local/bin/playwright-cli

Missing dependencies with no fallback: none.


Validation Architecture

Test Framework

Property Value
Framework Vitest 4.x (apps/api), Vitest 4.x + @playwright/test (apps/pwa e2e)
Config file apps/api/vitest.config.ts, apps/pwa/playwright.config.ts
Quick run command pnpm --filter @familysync/api test
Full suite command pnpm --filter @familysync/api test && pnpm --filter @familysync/pwa test:e2e

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
ADMIN-03 GET /api/admin/members returns 403 for non-admin authenticated user integration pnpm --filter @familysync/api test -- --reporter=verbose Wave 0
ADMIN-03 requireAdmin blocks unauthenticated requests (401 from outer guard) unit same Wave 0
ADMIN-01 POST /api/admin/credentials validates CalDAV and stores encrypted; returns 200 integration same Wave 0
ADMIN-01 POST /api/admin/credentials with bad password returns 400 with no value echo unit same Wave 0
ADMIN-01 POST /api/me/credential sets only the current user's credential (not another's) integration same Wave 0
ADMIN-02 PUT /api/admin/calendars/:id/shared sets exclusive is_shared integration same Wave 0
ADMIN-03 /admin React route redirects to /calendar for non-admin user e2e (playwright-cli) pnpm --filter @familysync/pwa test:e2e Wave 0

Sampling Rate

  • Per task commit: pnpm --filter @familysync/api test
  • Per wave merge: pnpm --filter @familysync/api test && pnpm typecheck (both apps)
  • Phase gate: Full CI gate (pnpm lint && pnpm typecheck && pnpm test && pnpm test:e2e) before /gsd-verify-work

Wave 0 Gaps

  • apps/api/tests/routes/admin.test.ts — covers ADMIN-01, ADMIN-02, ADMIN-03 (403 assertion is a hard pitfall check)
  • apps/api/tests/auth/requireAdmin.test.ts — unit tests for the guard middleware
  • Admin route e2e in Playwright harness — minimal: assert /admin redirect for non-admin, assert Admin nav entry visible for admin user (requires seeding admin user in global-setup.ts)

Security Domain

security_enforcement is enabled (ASVS Level 1 per config.json).

Applicable ASVS Categories

ASVS Category Applies Standard Control
V2 Authentication yes (admin bootstrap) first-login-wins gated by existing OIDC session; no credential stored for auth
V3 Session Management inherited @hono/oidc-auth handles session JWT cookies (existing)
V4 Access Control yes — primary requireAdmin middleware on every /api/admin/* route; member-scoped self-service endpoint
V5 Input Validation yes — primary zod + @hono/zod-validator hook; no Zod error echo for credential fields
V6 Cryptography yes — primary AES-256-GCM via encryptPassword/decryptPassword in crypto.ts — NEVER hand-rolled
V7 Error Handling yes Generic 400 response for credential validation failures (no value echo)

Known Threat Patterns

Pattern STRIDE Standard Mitigation
Admin endpoint accessed by non-admin Elevation of Privilege requireAdmin inside adminRouter (.use('*', ...))
App password echoed in error response Information Disclosure zValidator hook returns generic 400 only
App password logged Information Disclosure No console.log of request bodies in admin routes
Non-admin member updates another member's credential Elevation of Privilege Self-service endpoint uses currentUserId from session only
DEV_AUTH_BYPASS bypasses admin check Elevation of Privilege requireAdmin always queries DB — DEV_USER gets is_admin from DB row, not from bypass flag
Credential stored in plaintext Information Disclosure encryptPassword (AES-256-GCM) required before any DB write
drizzle-kit push drops live data Tampering Enforced by project convention; use generate+migrate

Assumptions Log

# Claim Section Risk if Wrong
A1 triggerTargetedResync can be extracted from outboxWorker.ts (currently private) or duplicated as a shared utility without breaking the outbox drain cycle Architecture Patterns §4 Low risk — function has no side effects that couple it to the drain loop; it's a standalone fetch+sync helper
A2 Adding UNIQUE(user_id) to member_credentials is safe on the current data (two members, each with one credential row, no duplicates) Code Examples §Drizzle Upsert Pattern Low risk — schema note says only two household members exist; verify before generating migration
A3 client.fetchCalendars() throwing on auth failure is the correct PROPFIND validation signal (as used in poller.ts) Pattern 4 Confirmed by poller.ts line 43; LOW risk — tsdav raises on 401/403 from Fastmail

If this table were empty: All claims in this research were verified or cited — no user confirmation needed. A1A3 are minor implementation choices, not scope risks.


Open Questions

  1. UNIQUE constraint on member_credentials.user_id

    • What we know: current schema has idx_member_credentials_user_id (index, not unique). The one-credential-per-member invariant (D-05) is not currently enforced at the DB level.
    • What's unclear: whether any existing data would violate a unique constraint on user_id (unlikely given two household members, but unverified).
    • Recommendation: Add UNIQUE(user_id) in the v1.1 migration; enables clean Drizzle onDuplicateKeyUpdate for upsert. If data check is needed first, the planner adds a Wave 0 verification step.
  2. Initial-sync scope after credential save

    • What we know: triggerTargetedResync in outboxWorker.ts requires a known calendarUrl to target. After a fresh credential save, we don't know the calendar URLs yet.
    • What's unclear: whether to run a full runPoll-style sweep for the member (all their calendars) rather than targeting one URL.
    • Recommendation: Run the full per-member poll after credential save (create client, client.fetchCalendars(), syncCalendar for all returned DAV calendars). This is exactly what the poller does per credential. Extract or replicate the loop.
  3. Where needsProviderSetup lives on /api/me

    • What we know: CONTEXT.md says "planner's call" for signal surface.
    • What's unclear: whether to return it on /api/me directly or via a dedicated endpoint.
    • Recommendation: Return it on /api/me alongside isAdmin — the PWA already fetches /api/me on every mount; adding a boolean field avoids a second round-trip and matches D-03's precedent.

Sources

Primary (HIGH confidence)

  • apps/api/src/db/schema.ts — exact current schema: users, member_credentials, calendars, calendar_events (including is_shared at line 89)
  • apps/api/src/broker/crypto.tsencryptPassword/decryptPassword signatures (AES-256-GCM, node:crypto)
  • apps/api/src/broker/client.tscreateFastmailClient + fetchCalendars PROPFIND pattern
  • apps/api/src/broker/outboxWorker.tsloadClientForUser + triggerTargetedResync (lines 271348)
  • apps/api/src/broker/poller.ts — full credential poll cycle
  • apps/api/src/auth/user.tsupsertUser function (lines 76128); hook point for first-login-wins
  • apps/api/src/auth/devBypass.tsDEV_USER id=1 pattern; bypass without DB upsert
  • apps/api/src/routes/me.ts — current /api/me response shape
  • apps/api/src/index.ts — middleware mount order; route registration pattern
  • apps/api/drizzle.config.ts — migration config; src/db/migrations/ as output dir
  • apps/api/package.jsondb:generate and db:migrate scripts confirmed
  • apps/api/src/db/migrations/0000_baseline.sql — existing migration format reference
  • apps/pwa/src/App.tsx — react-router <Routes> pattern; existing route mounting
  • apps/pwa/src/api/client.tsMeUser / MeResponse interfaces; extension points
  • apps/pwa/src/components/AppNav.tsx — NavLink + Lucide icon pattern (CalendarDays, List)
  • apps/pwa/src/components/BottomTabBar.tsx — tab pattern for Admin tab addition
  • apps/pwa/src/components/SettingsSheet.tsx — bottom sheet role="dialog" pattern to reuse
  • .planning/phases/10-admin-role-settings/10-CONTEXT.md — locked decisions D-01..D-07
  • .planning/phases/10-admin-role-settings/10-UI-SPEC.md — design system, surfaces, copy contract

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • None — all implementation details grounded in codebase reads or official docs.

Metadata

Confidence breakdown:

  • Standard stack: HIGH — all packages are already installed; versions confirmed in package.json
  • Architecture: HIGH — research grounded in direct codebase reads of all key source files
  • Pitfalls: HIGH — Pitfall 7 and 9 grounded in CONTEXT.md + official docs; others grounded in codebase patterns
  • DB migration: HIGH — drizzle.config.ts, package.json scripts, and baseline migration format all confirmed

Research date: 2026-06-13 Valid until: 2026-07-13 (stable stack; no fast-moving dependencies)