46 KiB
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 afterapp_config.setup_complete" (Phase 12 owns that gating, Phase 10 ships the column and the bootstrap logic). Dev note: underDEV_AUTH_BYPASS,DEV_USER(id 1) is injected without a DB upsert — must decide and document how bypass user acquiresis_adminfor local admin-UI verification. - D-02: New
/adminroute. A dedicated gated route, not an extension of the existing notificationsSettingsSheet. Anis_adminguard redirects non-admins away. - D-03: Expose
isAdminon/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_credentialsstays per-user. An admin can rotate ANY member's credential. Reuse existingcrypto.tsencryption path. - D-06: Exclusive single-select shared-calendar designation. Setting a new shared calendar clears
is_sharedon any prior shared calendar. Radio/toggle, not independent multi-toggles. - D-07: Member self-service credential, member-scoped. A member with no
member_credentialsrow gets aneedsProviderSetupsignal. 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_minutesin one Phase-10 migration (generate+migrate, never push). app_configshape: create with at leastsetup_completeflag 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 existingroutes/*.tsHono pattern.- Server-side
requireAdminguard 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]
Recommended Project Structure
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 302–348) 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.tsshared 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 76–128); 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:
- Recommended (seed):
tests/global-setup.tsalready seeds the dev MariaDB; addUPDATE users SET is_admin=1 WHERE id=1to the seed if user 1 exists, or ensure the seed creates user 1 withis_admin=true. This is the cleanest because it matches the real DB state. - Alt (bypass flag): The
requireAdminmiddleware checksusers.isAdminfrom 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 torequireAdmin.
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 55–69)]
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)inindex.tsalone is NOT sufficient. The guard MUST also be the first.use('*', ...)insideadminRouteritself (Pitfall 9). - Echoing Zod errors for credential routes:
return c.json(result.error, 400)exposesreceived(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'))orconsole.log(body)anywhere in admin route handlers. - Using drizzle-kit push: Always
db:generatethendb:migrate— neverdb:pushon the dev or production MariaDB. - Checking isAdmin only client-side: The PWA's
isAdminflag is for UX (show/hide nav entry, redirect non-admins). The 403 is always server-enforced on every/api/admin/*request. - Calling
triggerTargetedResyncbefore 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/credentialsroutes (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_enforcementis 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. A1–A3 are minor implementation choices, not scope risks.
Open Questions
-
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 DrizzleonDuplicateKeyUpdatefor upsert. If data check is needed first, the planner adds a Wave 0 verification step.
- What we know: current schema has
-
Initial-sync scope after credential save
- What we know:
triggerTargetedResyncinoutboxWorker.tsrequires a knowncalendarUrlto 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(),syncCalendarfor all returned DAV calendars). This is exactly what the poller does per credential. Extract or replicate the loop.
- What we know:
-
Where
needsProviderSetuplives on/api/me- What we know: CONTEXT.md says "planner's call" for signal surface.
- What's unclear: whether to return it on
/api/medirectly or via a dedicated endpoint. - Recommendation: Return it on
/api/mealongsideisAdmin— the PWA already fetches/api/meon 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(includingis_sharedat line 89)apps/api/src/broker/crypto.ts—encryptPassword/decryptPasswordsignatures (AES-256-GCM, node:crypto)apps/api/src/broker/client.ts—createFastmailClient+fetchCalendarsPROPFIND patternapps/api/src/broker/outboxWorker.ts—loadClientForUser+triggerTargetedResync(lines 271–348)apps/api/src/broker/poller.ts— full credential poll cycleapps/api/src/auth/user.ts—upsertUserfunction (lines 76–128); hook point for first-login-winsapps/api/src/auth/devBypass.ts—DEV_USERid=1 pattern; bypass without DB upsertapps/api/src/routes/me.ts— current/api/meresponse shapeapps/api/src/index.ts— middleware mount order; route registration patternapps/api/drizzle.config.ts— migration config;src/db/migrations/as output dirapps/api/package.json—db:generateanddb:migratescripts confirmedapps/api/src/db/migrations/0000_baseline.sql— existing migration format referenceapps/pwa/src/App.tsx— react-router<Routes>pattern; existing route mountingapps/pwa/src/api/client.ts—MeUser/MeResponseinterfaces; extension pointsapps/pwa/src/components/AppNav.tsx— NavLink + Lucide icon pattern (CalendarDays, List)apps/pwa/src/components/BottomTabBar.tsx— tab pattern for Admin tab additionapps/pwa/src/components/SettingsSheet.tsx— bottom sheetrole="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)
- [CITED: https://github.com/honojs/middleware/blob/main/packages/zod-validator/README.md] — zValidator hook signature and custom error response pattern
- [CITED: https://hono.dev/docs/guides/best-practices] — sub-router pattern via
app.route()
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)