# 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]
```

### 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:**
```typescript
// 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:**
```typescript
// 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):**
```bash
# 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):**
```typescript
// 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.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:**
```typescript
// 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:
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):**
```typescript
// 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).

```typescript
// 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)

```typescript
// 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)

```typescript
// 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

```typescript
// 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

```typescript
// 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

```typescript
// 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. A1–A3 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.ts` — `encryptPassword`/`decryptPassword` signatures (AES-256-GCM, node:crypto)
- `apps/api/src/broker/client.ts` — `createFastmailClient` + `fetchCalendars` PROPFIND pattern
- `apps/api/src/broker/outboxWorker.ts` — `loadClientForUser` + `triggerTargetedResync` (lines 271–348)
- `apps/api/src/broker/poller.ts` — full credential poll cycle
- `apps/api/src/auth/user.ts` — `upsertUser` function (lines 76–128); hook point for first-login-wins
- `apps/api/src/auth/devBypass.ts` — `DEV_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.json` — `db: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.ts` — `MeUser` / `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)
- [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)
