From 159f37fe6a1bedd406f128f407f971a9271c5286 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 14:11:39 -0400 Subject: [PATCH] docs(10): record planning completion, annotate roadmap waves, add pattern map --- .planning/ROADMAP.md | 15 +- .planning/STATE.md | 16 +- .../10-admin-role-settings/10-PATTERNS.md | 710 ++++++++++++++++++ 3 files changed, 731 insertions(+), 10 deletions(-) create mode 100644 .planning/phases/10-admin-role-settings/10-PATTERNS.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 87e155e..a3ef85e 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -170,12 +170,23 @@ Plans: **Folded-in scope** (from backlog 999.5, self-service member onboarding): the credential surface this phase builds is the same one a member needs on first login. Expose a `needsProviderSetup` signal (member has no `member_credentials` row) and let a member enter/validate (CalDAV PROPFIND) + encrypt their **own** Fastmail app password — the self-service counterpart of the admin-managed flow, sharing the validation/encryption/initial-sync path. Non-technical-friendly instructions (link to Fastmail's app-password page, required Calendars/CalDAV scope) are the hard UX constraint. Member-scoped: a member can only set their own credential; never log/echo the password. -**Plans**: 4 plans (4 waves) -Plans: +**Plans**: 4 plans (4 waves)Plans: +**Wave 1** + - [ ] 10-01-PLAN.md — v1.1 DB foundation migration (is_admin, provider_type+unique, reminder_lead_minutes, app_config) + dev-bypass admin seed + +**Wave 2** *(blocked on Wave 1 completion)* + - [ ] 10-02-PLAN.md — requireAdmin guard + first-login-wins bootstrap + /api/me isAdmin/needsProviderSetup (TDD) + +**Wave 3** *(blocked on Wave 2 completion)* + - [ ] 10-03-PLAN.md — adminRouter (members/credentials/calendars/shared) + member self-service credential, validate→encrypt→sync (TDD) + +**Wave 4** *(blocked on Wave 3 completion)* + - [ ] 10-04-PLAN.md — PWA /admin route + nav gating + CredentialSheet + SetupBanner (playwright-cli verified) + **UI hint**: yes ### Phase 11: Per-Event Reminders diff --git a/.planning/STATE.md b/.planning/STATE.md index cb4bcfa..dca1a9e 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,10 +2,10 @@ gsd_state_version: 1.0 milestone: v1.1 milestone_name: Operability & Polish -status: verifying -stopped_at: Completed 16-05-PLAN.md -last_updated: "2026-06-13T12:59:54.942Z" -last_activity: 2026-06-13 +status: executing +stopped_at: Phase 10 UI-SPEC approved +last_updated: "2026-06-13T18:10:25.965Z" +last_activity: "2026-06-13 - Completed quick task 260613-fp9: .gitea/.planning-only pushes skip the Docker publish" progress: total_phases: 19 completed_phases: 7 @@ -27,7 +27,7 @@ See: .planning/PROJECT.md (updated 2026-06-10) Phase: 999.1 Plan: Not started -Status: Phase complete — ready for verification +Status: Ready to execute Last activity: 2026-06-13 - Completed quick task 260613-fp9: .gitea/.planning-only pushes skip the Docker publish ### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action) @@ -235,9 +235,9 @@ Recent decisions affecting current work: ## Session Continuity -Last session: 2026-06-13T12:28:17.736Z -Stopped at: Completed 16-05-PLAN.md -Resume file: None +Last session: 2026-06-13T17:21:38.274Z +Stopped at: Phase 10 UI-SPEC approved +Resume file: .planning/phases/10-admin-role-settings/10-UI-SPEC.md ## Operator Next Steps diff --git a/.planning/phases/10-admin-role-settings/10-PATTERNS.md b/.planning/phases/10-admin-role-settings/10-PATTERNS.md new file mode 100644 index 0000000..7f72b63 --- /dev/null +++ b/.planning/phases/10-admin-role-settings/10-PATTERNS.md @@ -0,0 +1,710 @@ +# Phase 10: Admin Role & Settings - Pattern Map + +**Mapped:** 2026-06-13 +**Files analyzed:** 14 new/modified files +**Analogs found:** 13 / 14 + +--- + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `apps/api/src/db/schema.ts` | model | CRUD | self (existing schema.ts) | exact — extend in place | +| `apps/api/src/db/migrations/0001_v1_1_foundation.sql` | config | batch | `0000_baseline.sql` | exact | +| `apps/api/src/routes/admin.ts` (NEW) | controller | request-response | `apps/api/src/routes/push.ts` | role-match | +| `apps/api/src/lib/requireAdmin.ts` (NEW) | middleware | request-response | `apps/api/src/auth/devBypass.ts` | role-match | +| `apps/api/src/index.ts` | config | request-response | self (existing index.ts) | exact — extend in place | +| `apps/api/src/routes/me.ts` | controller | request-response | self (existing me.ts) | exact — extend in place | +| `apps/api/src/auth/user.ts` | service | CRUD | self (existing user.ts) | exact — extend in place | +| `apps/api/src/broker/crypto.ts` | utility | transform | — | reuse only, no changes | +| `apps/api/src/broker/client.ts` | utility | request-response | — | reuse only, no changes | +| `apps/api/src/broker/outboxWorker.ts` | service | event-driven | — | reuse `loadClientForUser` / `triggerTargetedResync` (promote to export) | +| `apps/pwa/src/App.tsx` | component | request-response | self (existing App.tsx) | exact — extend in place | +| `apps/pwa/src/api/client.ts` | utility | request-response | self (existing client.ts) | exact — extend in place | +| `apps/pwa/src/routes/AdminPage.tsx` (NEW) | component | request-response | `apps/pwa/src/routes/ListsIndex.tsx` | role-match | +| `apps/pwa/src/components/CredentialSheet.tsx` (NEW) | component | request-response | `apps/pwa/src/components/SettingsSheet.tsx` | exact | +| `apps/pwa/src/components/SetupBanner.tsx` (NEW) | component | event-driven | `apps/pwa/src/components/PermissionDeniedBanner.tsx` | role-match | + +--- + +## Pattern Assignments + +### `apps/api/src/db/schema.ts` — add columns + new table + +**Analog:** self — extend in place. + +**Existing import pattern** (lines 1–14): +```typescript +import { + mysqlTable, + mysqlEnum, + varchar, + text, + int, + date, + timestamp, + boolean, + index, + unique, + customType, +} from 'drizzle-orm/mysql-core'; +``` + +**Existing column patterns to copy for new columns:** + +`boolean` with NOT NULL DEFAULT false — copy from `calendarEvents.allDay` (line 127): +```typescript +allDay: boolean('all_day').default(false).notNull(), +``` + +`varchar` with length + notNull + default — copy from `memberCredentials.fastmailEmail` (line 64): +```typescript +fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(), +``` + +`int` nullable — copy from `calendarEvents.dtstartUtc` (line 125) but use `int`: +```typescript +dtstartUtc: timestamp('dtstart_utc'), // nullable = no .notNull() +``` + +**New `app_config` table — follow `pushSubscriptions` single-table pattern** (lines 236–257): +```typescript +export const pushSubscriptions = mysqlTable( + 'push_subscriptions', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + endpoint: varchar('endpoint', { length: 2048 }).notNull(), + ... + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [ + unique('uniq_push_endpoint').on(t.endpoint), + index('idx_push_subscriptions_user_id').on(t.userId), + ], +); +``` + +**`memberCredentials` `unique` constraint pattern** — copy from `calendars` (lines 91–100): +```typescript +unique('uniq_calendar_user_url').on(t.userId, t.url), +``` +Apply as `unique('uniq_member_credential_user').on(t.userId)` to enforce one-credential-per-member and enable `onDuplicateKeyUpdate`. + +**Changes to make:** +1. `users` table: add `isAdmin: boolean('is_admin').default(false).notNull()` +2. `memberCredentials` table: add `providerType: varchar('provider_type', { length: 64 }).notNull().default('caldav')` + add `unique('uniq_member_credential_user').on(t.userId)` to the index array +3. `calendarEvents` table: add `reminderLeadMinutes: int('reminder_lead_minutes')` (nullable — no `.notNull()`) +4. New `appConfig` table: `key VARCHAR PK, value TEXT, updatedAt timestamp` + +--- + +### `apps/api/src/db/migrations/0001_v1_1_foundation.sql` (NEW, generated) + +**Analog:** `apps/api/src/db/migrations/0000_baseline.sql` lines 1–16. + +**Migration file format** — each DDL statement separated by `--> statement-breakpoint`: +```sql +ALTER TABLE `users` ADD COLUMN `is_admin` boolean NOT NULL DEFAULT false; +--> statement-breakpoint +ALTER TABLE `member_credentials` ADD COLUMN `provider_type` varchar(64) NOT NULL DEFAULT 'caldav'; +--> statement-breakpoint +ALTER TABLE `member_credentials` ADD UNIQUE `uniq_member_credential_user`(`user_id`); +--> statement-breakpoint +ALTER TABLE `calendar_events` ADD COLUMN `reminder_lead_minutes` int; +--> statement-breakpoint +CREATE TABLE `app_config` ( ... ); +``` + +**Do not hand-write.** Run `pnpm --filter @familysync/api db:generate` after editing schema.ts; the file is generated automatically. Commit the output. + +--- + +### `apps/api/src/routes/admin.ts` (NEW) — admin sub-router + +**Analog:** `apps/api/src/routes/push.ts` (closest: Hono sub-router + zValidator + resolveUserId pattern) + +**Imports pattern** — copy from `push.ts` lines 15–26, substitute admin-specific imports: +```typescript +import { Hono } from 'hono'; +import type { Context, MiddlewareHandler } from 'hono'; +import { zValidator } from '@hono/zod-validator'; +import { z } from 'zod'; +import { eq, and } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { users, memberCredentials, calendars } from '../db/schema.js'; +import { encryptPassword } from '../broker/crypto.js'; +import { createFastmailClient } from '../broker/client.js'; +import { requireAdmin } from '../lib/requireAdmin.js'; +// Side-effect import for ContextVariableMap augmentation +import '../auth/devBypass.js'; +``` + +**Router + guard pattern** (Pitfall 9 — guard FIRST inside the sub-router): +```typescript +export const adminRouter = new Hono(); +adminRouter.use('*', requireAdmin); // ← MUST be first; guards every sub-route +``` + +**zValidator with no-echo hook** (Pitfall 7) — adapt from `push.ts` lines 60–67 (subscribeSchema): +```typescript +const credentialSchema = z.object({ + userId: z.number().int().positive(), + providerType: z.literal('caldav'), + fastmailEmail: z.string().email().max(256), + appPassword: z.string().min(1).max(500), +}); + +// Hook MUST never echo Zod issues (which contain .received = the password value) +const noEchoHook = (result: { success: boolean }, c: Context) => { + if (!result.success) return c.json({ error: 'Invalid request' }, 400); +}; + +adminRouter.post( + '/credentials', + zValidator('json', credentialSchema, noEchoHook), + async (c) => { + const { userId, fastmailEmail, appPassword } = c.req.valid('json'); + // NEVER log appPassword or c.req.valid('json') + // validate → encrypt → upsert → trigger-sync + }, +); +``` + +**Drizzle SELECT pattern** — copy from `events.ts` lines 165–177 (join + where): +```typescript +const rows = await db + .select({ ... }) + .from(users) + .leftJoin(memberCredentials, eq(memberCredentials.userId, users.id)) + .where(/* ... */); +``` + +**Drizzle upsert pattern** — copy from `events.ts` `onDuplicateKeyUpdate` usage (found in outboxWorker): +```typescript +await db.insert(memberCredentials) + .values({ userId, encryptedPassword: encrypted, fastmailEmail, providerType: 'caldav' }) + .onDuplicateKeyUpdate({ set: { encryptedPassword: encrypted, fastmailEmail, providerType: 'caldav' } }); +// Requires UNIQUE(user_id) added by v1.1 migration +``` + +**Exclusive is_shared update** — two sequential Drizzle UPDATEs (RESEARCH.md Pattern 7): +```typescript +await db.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true)); +await db.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId)); +``` + +--- + +### `apps/api/src/lib/requireAdmin.ts` (NEW) — role middleware + +**Analog:** `apps/api/src/auth/devBypass.ts` (MiddlewareHandler pattern) + +**Import + type pattern** (devBypass.ts lines 27–28): +```typescript +import type { MiddlewareHandler } from 'hono'; +``` + +**MiddlewareHandler signature** (devBypass.ts lines 58–76): +```typescript +export function devAuthBypass(): MiddlewareHandler { + return async (c, next) => { + c.set('user', DEV_USER); + await next(); + }; +} +``` + +**requireAdmin must be an inline `MiddlewareHandler`**, not a factory function (applied as `.use('*', requireAdmin)`): +```typescript +import type { MiddlewareHandler } from 'hono'; +import { eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { users } from '../db/schema.js'; + +export const requireAdmin: MiddlewareHandler = async (c, next) => { + const devUser = c.get('user') as { id: number } | undefined; + const userId = devUser?.id; + if (!userId) return c.json({ error: 'Forbidden' }, 403); + + // Always look up is_admin from DB — bypass only skips OIDC, not the DB check + const [row] = await db + .select({ isAdmin: users.isAdmin }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!row?.isAdmin) return c.json({ error: 'Forbidden' }, 403); + await next(); +}; +``` + +**ContextVariableMap augmentation** — include side-effect import from devBypass.ts (line 39): +```typescript +import '../auth/devBypass.js'; +``` + +--- + +### `apps/api/src/index.ts` — mount adminRouter + +**Analog:** self — extend in place. + +**Existing route mounting pattern** (lines 67–72): +```typescript +app.route('/api/me', meRouter); +app.route('/api/events', eventsRouter); +app.route('/api/lists', listsRouter); +app.route('/api/list-items', listItemsRouter); +app.route('/api/push', pushRouter); +``` + +**Add after the existing route block** (same style, behind the existing devAuthBypass → oidcAuthMiddleware band already covering `/api/*`): +```typescript +import { adminRouter } from './routes/admin.js'; +// ... +app.route('/api/admin', adminRouter); +``` + +No additional middleware needed at the `app` level — `requireAdmin` is applied inside `adminRouter` itself (Pitfall 9). + +--- + +### `apps/api/src/routes/me.ts` — add isAdmin + needsProviderSetup + +**Analog:** self — extend in place. + +**Current response shape** (lines 30–43 dev-bypass path, lines 66–73 OIDC path): +```typescript +return c.json({ + user: { + id: devUser.id, + displayName: devUser.displayName, + color: devUser.color, + }, +}); +``` + +**Pattern:** Both paths (dev-bypass + OIDC) must add `isAdmin` and `needsProviderSetup`. The dev-bypass path currently short-circuits WITHOUT a DB lookup — for `isAdmin` it MUST query the DB for user id 1 (same as requireAdmin). `needsProviderSetup` requires a COUNT/EXISTS on `memberCredentials` for the current user id. + +**DB import additions needed:** +```typescript +import { eq } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { users, memberCredentials } from '../db/schema.js'; +``` + +**needsProviderSetup lookup pattern** — copy Drizzle `.select().from().where().limit(1)` pattern from user.ts lines 79–82: +```typescript +const [cred] = await db + .select({ id: memberCredentials.id }) + .from(memberCredentials) + .where(eq(memberCredentials.userId, userId)) + .limit(1); +const needsProviderSetup = !cred; +``` + +--- + +### `apps/api/src/auth/user.ts` — first-login-wins is_admin bootstrap + +**Analog:** self — extend in place. + +**Insert block** (lines 112–122) — add `isAdmin` to the `.values({...})` call: +```typescript +// Before INSERT: check if zero admins exist (first-login-wins, D-01) +const [{ count }] = await db + .select({ count: sql`COUNT(*)` }) + .from(users) + .where(eq(users.isAdmin, true)); +const shouldBeAdmin = Number(count) === 0; + +const [inserted] = await db + .insert(users) + .values({ + oidcIss, + oidcSub, + displayName: displayName ?? null, + color, + isAdmin: shouldBeAdmin, // ← new + }) + .$returningId(); +``` + +**Import additions needed:** +```typescript +import { sql } from 'drizzle-orm'; +``` + +--- + +### `apps/api/src/broker/outboxWorker.ts` — promote triggerTargetedResync + +**Analog:** self — promote private function to export. + +**Current private function signature** (lines 302–348): +```typescript +async function triggerTargetedResync( + calendarUrl: string, + userId: number, + clientCache?: Map, +): Promise +``` + +**Change:** add `export` keyword. Admin routes (and member self-service) will import and call it after credential upsert. + +**Also export `loadClientForUser`** (lines 271–288) — needed for the initial full per-member sync (no known `calendarUrl` after first credential save): +```typescript +export async function loadClientForUser(userId: number): Promise +``` + +For the post-credential-save full sync (no specific `calendarUrl`), the admin route calls `loadClientForUser`, then `client.fetchCalendars()`, iterates each `davCal`, and calls `syncCalendar` for each — mirroring what the poller does per member. + +--- + +### `apps/pwa/src/App.tsx` — add /admin route + +**Analog:** self — extend in place. + +**Existing Routes block** (lines 121–127): +```typescript + + } /> + } /> + } /> + } /> + +``` + +**Add `/admin` route** with inline redirect guard: +```typescript +import { AdminPage } from './routes/AdminPage.js'; +// ... + + : + } +/> +``` + +**meQuery consumption pattern** (lines 63–68): +```typescript +const meQuery = useQuery({ + queryKey: ['me'], + queryFn: fetchMe, + retry: false, + staleTime: 5 * 60 * 1000, +}); +``` +The `isAdmin` guard on the route uses `meQuery.data?.user.isAdmin` — while `meQuery` is loading, `isAdmin` is `undefined` (falsy), so the route redirects. Add a loading gate if flash-of-redirect is a concern (planner's call). + +--- + +### `apps/pwa/src/api/client.ts` — add isAdmin + needsProviderSetup to MeUser + +**Analog:** self — extend in place. + +**Current MeUser interface** (lines 62–66): +```typescript +export interface MeUser { + id: number; + displayName: string | null; + color: string; +} +``` + +**Add fields:** +```typescript +export interface MeUser { + id: number; + displayName: string | null; + color: string; + isAdmin: boolean; // from users.is_admin + needsProviderSetup: boolean; // true when no member_credentials row exists +} +``` + +**API fetch functions pattern for admin routes** — copy from `createEvent` (lines 221–233): +```typescript +export async function createEvent(payload: CreateEventPayload): Promise { + const res = await fetch('/api/events/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + redirect: 'manual', + body: JSON.stringify(payload), + }); + handleAuthResponse(res, 'POST /api/events/create'); + return res.json() as Promise; +} +``` +Apply same pattern for `fetchAdminMembers`, `saveCredential`, `fetchAdminCalendars`, `setSharedCalendar`, `saveMyCredential`. + +--- + +### `apps/pwa/src/routes/AdminPage.tsx` (NEW) — /admin page shell + +**Analog:** `apps/pwa/src/routes/ListsIndex.tsx` (page-level component with TanStack Query + sections) + +**Page structure pattern** — copy AppNav/content layout from `App.tsx` content area style (lines 99–102): +```typescript +const contentStyle: React.CSSProperties = { + flex: 1, + minWidth: 0, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + position: 'relative', +}; +``` + +**TanStack Query fetch pattern** — copy from App.tsx meQuery (lines 63–68); admin page will add its own queries for members and calendars: +```typescript +const membersQuery = useQuery({ + queryKey: ['admin', 'members'], + queryFn: fetchAdminMembers, + retry: false, +}); +``` + +**Section label style** — per UI-SPEC, copy the pattern from `SettingsSheet.tsx` section headers: +```typescript +// 13px / weight 600 / var(--color-text-muted) / uppercase / letterSpacing 0.06em +{ + fontSize: 'var(--text-label-size)', + fontWeight: 600, + color: 'var(--color-text-muted)', + textTransform: 'uppercase', + letterSpacing: '0.06em', + marginBottom: 'var(--space-2)', +} +``` + +--- + +### `apps/pwa/src/components/CredentialSheet.tsx` (NEW) — credential bottom sheet + +**Analog:** `apps/pwa/src/components/SettingsSheet.tsx` (closest exact match: bottom sheet pattern, role="dialog", Escape key, focus management) + +**Bottom sheet structural pattern** (SettingsSheet.tsx lines 52–76): +```typescript +export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) { + const closeButtonRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [isOpen, onClose]); + + useEffect(() => { + if (isOpen && closeButtonRef.current) { + closeButtonRef.current.focus(); + } + }, [isOpen]); + // ... +} +``` + +**Sheet container style** (apply zIndex 301, backdrop 300, borderRadius 12px 12px 0 0 — matching SettingsSheet): +```typescript +// Backdrop +{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 300 } +// Sheet +{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'var(--color-surface)', + borderRadius: '12px 12px 0 0', padding: 'var(--space-6)', zIndex: 301 } +``` + +**ARIA pattern:** +```tsx +
+``` + +**Password input pattern** (UI-SPEC — never pre-filled, `type="password"`, `autocomplete="new-password"`): +```tsx + setPassword(e.target.value)} + style={{ /* ... full-width, border, borderRadius, fontSize */ }} +/> +``` + +**TanStack Query mutation pattern** — copy from PWA list mutation (useMutation with onSuccess invalidation): +```typescript +const credentialMutation = useMutation({ + mutationFn: saveCredential, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'members'] }); + queryClient.invalidateQueries({ queryKey: ['me'] }); // needsProviderSetup refresh + onClose(); + }, +}); +``` + +--- + +### `apps/pwa/src/components/SetupBanner.tsx` (NEW) — needsProviderSetup banner + +**Analog:** `apps/pwa/src/components/PermissionDeniedBanner.tsx` (conditional banner rendered from App.tsx level) + +**Pattern:** renders only when `meQuery.data?.user.needsProviderSetup === true`. No dismiss button per UI-SPEC — disappears when `needsProviderSetup` becomes false after save. + +**Banner style** (UI-SPEC Surface 4): +```typescript +{ + background: 'var(--color-surface-dim)', + border: '1px solid var(--color-border)', + borderRadius: 'var(--space-2)', + padding: 'var(--space-4)', + margin: 'var(--space-4)', +} +``` + +**`role="status"` for live announcement:** +```tsx +
+ {/* KeyRound icon + heading + body + CTA */} +
+``` + +--- + +## Shared Patterns + +### resolveUserId — auth helper per router + +**Source:** `apps/api/src/routes/push.ts` lines 37–49 (canonical copy in use across push, events, lists routers) + +**Apply to:** `apps/api/src/routes/admin.ts` (member self-service endpoint on `/api/me/credential` added to meRouter) + +```typescript +async function resolveUserId(c: Context): Promise { + 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; +} +``` + +### Hono sub-router mounting + +**Source:** `apps/api/src/index.ts` lines 67–72 + +```typescript +app.route('/api/admin', adminRouter); +``` + +**Apply to:** index.ts — adminRouter added to the existing route block (after the auth guards already cover `/api/*`). + +### Zod + zValidator (no hook = safe for non-credential fields) + +**Source:** `apps/api/src/routes/events.ts` lines 27–28 + 137 + +```typescript +import { zValidator } from '@hono/zod-validator'; +// Usage: +eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { ... }); +``` + +**Apply to:** non-credential admin routes (GET /members, GET /calendars, PUT /calendars/:id/shared). + +**For credential routes only — add the no-echo hook** (RESEARCH.md Pattern 2). Never return `result.error` directly for any route that accepts `appPassword`. + +### ContextVariableMap side-effect import + +**Source:** every route file (push.ts line 25, events.ts line 39, me.ts line 26) + +```typescript +import '../auth/devBypass.js'; +``` + +**Apply to:** `apps/api/src/routes/admin.ts` and `apps/api/src/lib/requireAdmin.ts`. + +### NavLink + Lucide icon (nav entry) + +**Source:** `apps/pwa/src/components/AppNav.tsx` lines 14–15 + `BottomTabBar.tsx` lines 76–98 + +```typescript +import { NavLink } from 'react-router'; +import { CalendarDays, List } from 'lucide-react'; +// NavLink usage: + ({ + ...tabBase, ...(isActive ? tabActiveOverride : {}), +})}> + +``` + +**Apply to:** `AppNav.tsx` (DesktopNav section) and `BottomTabBar.tsx` — add Admin entry with `ShieldCheck` icon (size 18/22), conditional on `isAdmin === true`. + +### CSS token inline style pattern + +**Source:** `apps/pwa/src/components/BottomTabBar.tsx` lines 27–45 + +```typescript +const tabBase: React.CSSProperties = { + fontSize: 'var(--text-label-size, 13px)', + color: 'var(--color-text-muted)', + fontFamily: 'var(--font-family-base)', + minHeight: '44px', +}; +``` + +**Apply to:** all new PWA components (AdminPage, CredentialSheet, SetupBanner). No hard-coded px except the 44px touch-target minimum. All color/typography/spacing references through `var(--token)`. + +### `handleAuthResponse` + `redirect: 'manual'` in fetch + +**Source:** `apps/pwa/src/api/client.ts` lines 51–58 + 80–83 + +```typescript +function handleAuthResponse(res: Response, label: string): void { + if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError(); + if (!res.ok) throw new Error(`${label} failed: ${res.status}`); +} +// Usage: +const res = await fetch('/api/admin/members', { credentials: 'include', redirect: 'manual' }); +handleAuthResponse(res, 'GET /api/admin/members'); +``` + +**Apply to:** all new `client.ts` fetch functions for admin and me/credential endpoints. + +--- + +## No Analog Found + +| File | Role | Data Flow | Reason | +|---|---|---|---| +| (none) | — | — | All files have close analogs in the existing codebase | + +--- + +## Analog Search Scope + +- `apps/api/src/routes/` — all route files +- `apps/api/src/auth/` — devBypass.ts, user.ts, middleware.ts +- `apps/api/src/broker/` — crypto.ts, client.ts, outboxWorker.ts +- `apps/api/src/db/` — schema.ts, migrations/ +- `apps/pwa/src/` — App.tsx, api/client.ts, components/, routes/ + +**Files scanned:** 15 source files read directly. + +**Pattern extraction date:** 2026-06-13