docs(12): map patterns to existing analogs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
48acf3ac95
commit
ea40176920
@@ -0,0 +1,564 @@
|
|||||||
|
# Phase 12: Initial Setup Wizard - Pattern Map
|
||||||
|
|
||||||
|
**Mapped:** 2026-06-15
|
||||||
|
**Files analyzed:** 10 new/modified files
|
||||||
|
**Analogs found:** 10 / 10
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Classification
|
||||||
|
|
||||||
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `apps/api/src/routes/setup.ts` | route | request-response | `apps/api/src/routes/admin.ts` | exact |
|
||||||
|
| `apps/api/src/lib/setupGuard.ts` | utility | request-response | `apps/api/src/lib/householdTimezone.ts` (compiled: `apps/api/dist/lib/householdTimezone.js`) | role-match |
|
||||||
|
| `apps/api/src/index.ts` (modify) | config | request-response | itself — pre-auth `/health` mounting pattern | exact |
|
||||||
|
| `apps/api/src/db/schema.ts` (modify) | model | CRUD | itself — `users`, `appConfig`, `memberCredentials` table definitions | exact |
|
||||||
|
| `apps/api/src/auth/user.ts` (modify) | service | request-response | itself — `upsertUser` first-login-wins block (lines 112–142) | exact |
|
||||||
|
| `apps/api/src/db/migrations/0002_*.sql` | migration | batch | `apps/api/src/db/migrations/0001_famous_mad_thinker.sql` | role-match |
|
||||||
|
| `scripts/generate-secrets.mjs` | utility | batch | `scripts/check-audit.mjs` (structure only; content is new) | partial |
|
||||||
|
| `apps/pwa/src/routes/SetupPage.tsx` | component | request-response | `apps/pwa/src/routes/AdminPage.tsx` | role-match |
|
||||||
|
| `apps/pwa/src/App.tsx` (modify) | component | request-response | itself — existing `Routes` block + `meQuery` gate pattern | exact |
|
||||||
|
| `apps/api/tests/setup.test.ts` | test | request-response | `apps/api/src/routes/admin.ts` (test patterns from same codebase convention) | role-match |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern Assignments
|
||||||
|
|
||||||
|
### `apps/api/src/routes/setup.ts` (route, request-response)
|
||||||
|
|
||||||
|
**Analog:** `apps/api/src/routes/admin.ts`
|
||||||
|
|
||||||
|
**Imports pattern** (admin.ts lines 22–36):
|
||||||
|
```typescript
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { Context } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { users, memberCredentials, appConfig } from '../db/schema.js';
|
||||||
|
import {
|
||||||
|
validateEncryptAndStoreCredential,
|
||||||
|
CredentialValidationError,
|
||||||
|
} from '../broker/credentialSync.js';
|
||||||
|
|
||||||
|
export const setupRouter = new Hono();
|
||||||
|
```
|
||||||
|
|
||||||
|
**noEchoHook pattern — copy exactly** (admin.ts lines 54–64):
|
||||||
|
```typescript
|
||||||
|
const noEchoHook = (result: { success: boolean }, c: Context) => {
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json({ error: 'Invalid request' }, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zod schema pattern for credential step** (admin.ts lines 47–52):
|
||||||
|
```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),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**validateEncryptAndStoreCredential call + error-handling pattern** (admin.ts lines 102–122):
|
||||||
|
```typescript
|
||||||
|
adminRouter.post('/credentials', zValidator('json', credentialSchema, noEchoHook), async (c) => {
|
||||||
|
const { userId, fastmailEmail, appPassword, providerType } = c.req.valid('json');
|
||||||
|
// T-10-10: NEVER log appPassword or c.req.valid('json') here
|
||||||
|
|
||||||
|
try {
|
||||||
|
await validateEncryptAndStoreCredential(userId, fastmailEmail, appPassword, providerType);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CredentialValidationError) {
|
||||||
|
return c.json({ error: 'Invalid request' }, 400);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
'[admin/POST /credentials] Unexpected error:',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ ok: true }, 200);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**app_config upsert pattern** (from compiled `apps/api/dist/lib/householdTimezone.js`, confirmed by admin.ts app_config usage):
|
||||||
|
```typescript
|
||||||
|
// Drizzle onDuplicateKeyUpdate upsert — the project standard for app_config writes
|
||||||
|
await db
|
||||||
|
.insert(appConfig)
|
||||||
|
.values({ key: 'oidc_issuer', value: issuer })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value: issuer } });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Guard pattern — FIRST statement in every handler** (D-10, per RESEARCH.md Pattern 3):
|
||||||
|
```typescript
|
||||||
|
// Copy this call at the top of every setup route handler — before any other logic
|
||||||
|
const locked = await isSetupLocked();
|
||||||
|
if (locked) return c.json({ error: 'Setup already complete' }, 423);
|
||||||
|
```
|
||||||
|
|
||||||
|
**VAPID structural validation pattern** (RESEARCH.md Pattern 7):
|
||||||
|
```typescript
|
||||||
|
import webpush from 'web-push';
|
||||||
|
try {
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
subject || 'mailto:validate@familysync.local',
|
||||||
|
publicKey, // from process.env.VAPID_PUBLIC_KEY or already-written app_config
|
||||||
|
privateKey, // from process.env.VAPID_PRIVATE_KEY only — NEVER from app_config
|
||||||
|
);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err instanceof Error ? err.message : 'VAPID validation failed' }, 400);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**DB connectivity validation pattern** (health.ts lines 16–27):
|
||||||
|
```typescript
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
// ...
|
||||||
|
try {
|
||||||
|
await db.execute(sql`SELECT 1`);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[setup/validate/db] DB round-trip failed:', err);
|
||||||
|
return c.json({ ok: false, error: 'DB unavailable' }, 503);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/lib/setupGuard.ts` (utility, request-response)
|
||||||
|
|
||||||
|
**Analog:** `apps/api/dist/lib/householdTimezone.js` (compiled output of `householdTimezone.ts`)
|
||||||
|
|
||||||
|
**app_config read pattern** (householdTimezone, confirmed from RESEARCH.md Pattern 2):
|
||||||
|
```typescript
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { appConfig, memberCredentials } from '../db/schema.js';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
/** Returns true if the wizard is already locked. Re-evaluated fresh — NEVER cache at module level. */
|
||||||
|
export async function isSetupLocked(): Promise<boolean> {
|
||||||
|
// Check 1: explicit setup_complete flag in app_config
|
||||||
|
const [flagRow] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'setup_complete'))
|
||||||
|
.limit(1);
|
||||||
|
if (flagRow?.value === 'true') return true;
|
||||||
|
|
||||||
|
// Check 2: effective configuration — member_credentials row exists AND VAPID env set
|
||||||
|
const [credRow] = await db
|
||||||
|
.select({ id: memberCredentials.id })
|
||||||
|
.from(memberCredentials)
|
||||||
|
.limit(1);
|
||||||
|
const vapidPresent = !!process.env.VAPID_PRIVATE_KEY && !!process.env.VAPID_PUBLIC_KEY;
|
||||||
|
return !!credRow && vapidPresent;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key constraint:** The return value MUST NOT be hoisted to a module-level variable. Callers must call `isSetupLocked()` as the first line of each handler. This is the same per-call freshness pattern as `db.select()` in the health router — no startup caching.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/index.ts` (modify — route mounting order)
|
||||||
|
|
||||||
|
**Analog:** itself, lines 35–55
|
||||||
|
|
||||||
|
**Pre-auth mount pattern to replicate** (index.ts lines 35–55):
|
||||||
|
```typescript
|
||||||
|
// OIDC callback — must be registered BEFORE oidcAuthMiddleware (T-02-02)
|
||||||
|
app.get('/callback', (c) => processOAuthCallback(c));
|
||||||
|
|
||||||
|
// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05)
|
||||||
|
app.route('/health', healthRouter);
|
||||||
|
|
||||||
|
// Dev-auth bypass — must be mounted BEFORE oidcAuthMiddleware (T-02-01)
|
||||||
|
app.use('/api/*', devAuthBypass());
|
||||||
|
|
||||||
|
// OIDC guard — protects all /api/* routes
|
||||||
|
if (!devBypassActive) {
|
||||||
|
app.use('/api/*', oidcAuthMiddleware());
|
||||||
|
app.use('/api/*', persistSessionCookie());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New mount line to insert — before `app.use('/api/*', devAuthBypass())`:**
|
||||||
|
```typescript
|
||||||
|
// /api/setup/* — pre-auth wizard surface; must mount BEFORE the /api/* middleware chain.
|
||||||
|
// Inserting here mirrors the /health pattern: pre-auth, no OIDC, no devAuthBypass needed.
|
||||||
|
import { setupRouter } from './routes/setup.js';
|
||||||
|
app.route('/api/setup', setupRouter); // ← INSERT before app.use('/api/*', devAuthBypass())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/db/schema.ts` (modify — users table + new app_config keys)
|
||||||
|
|
||||||
|
**Analog:** itself, lines 35–51 (users table) and lines 282–286 (appConfig table)
|
||||||
|
|
||||||
|
**Current users table definition** (schema.ts lines 35–51):
|
||||||
|
```typescript
|
||||||
|
export const users = mysqlTable(
|
||||||
|
'users',
|
||||||
|
{
|
||||||
|
id: int().primaryKey().autoincrement(),
|
||||||
|
oidcIss: varchar('oidc_iss', { length: 512 }).notNull(), // ← change to nullable
|
||||||
|
oidcSub: varchar('oidc_sub', { length: 256 }).notNull(), // ← change to nullable
|
||||||
|
displayName: varchar('display_name', { length: 256 }),
|
||||||
|
color: varchar('color', { length: 7 }).notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
isAdmin: boolean('is_admin').default(false).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required schema changes (D-07):**
|
||||||
|
```typescript
|
||||||
|
// Make oidcIss and oidcSub nullable (remove .notNull()):
|
||||||
|
oidcIss: varchar('oidc_iss', { length: 512 }), // WAS .notNull()
|
||||||
|
oidcSub: varchar('oidc_sub', { length: 256 }), // WAS .notNull()
|
||||||
|
// Add claimed marker:
|
||||||
|
claimed: boolean('claimed').default(false).notNull(), // false = pending wizard user
|
||||||
|
```
|
||||||
|
|
||||||
|
**appConfig table — unchanged, but new keys documented** (schema.ts lines 282–286):
|
||||||
|
```typescript
|
||||||
|
export const appConfig = mysqlTable('app_config', {
|
||||||
|
key: varchar('key', { length: 128 }).primaryKey(),
|
||||||
|
value: text('value'), // nullable
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||||
|
});
|
||||||
|
// Phase 12 new keys: 'oidc_issuer', 'oidc_client_id', 'vapid_public_key',
|
||||||
|
// 'app_external_url', 'setup_complete' (already exists from Phase 10)
|
||||||
|
// DO NOT add: 'vapid_private_key', 'app_password_encryption_key' — D-01 / SC-3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration backfill requirement** (RESEARCH.md Runtime State Inventory):
|
||||||
|
```sql
|
||||||
|
-- In 0002_*.sql — after altering the columns:
|
||||||
|
UPDATE users SET claimed = true WHERE oidc_iss IS NOT NULL;
|
||||||
|
-- Existing OIDC users are "effectively claimed" — prevents the claim query from matching them.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/auth/user.ts` (modify — upsertUser first-login-claims)
|
||||||
|
|
||||||
|
**Analog:** itself, lines 76–142
|
||||||
|
|
||||||
|
**Current first-login-wins block to replace** (user.ts lines 112–123):
|
||||||
|
```typescript
|
||||||
|
// 3. First-login-wins is_admin bootstrap (D-01).
|
||||||
|
// Phase 12 will tighten this to: first user after app_config.setup_complete.
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql<number>`COUNT(*)` })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.isAdmin, true))
|
||||||
|
.limit(1);
|
||||||
|
const shouldBeAdmin = Number(count) === 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replacement pattern (D-08 first-login-claims)** — insert between existing step 1 (look up by iss+sub) and existing step 4 (insert new user):
|
||||||
|
```typescript
|
||||||
|
// 2. Check setup_complete; if true, look for unclaimed local user (first-login-claims, D-08)
|
||||||
|
// MUST use oidcIss IS NULL + claimed=false — never email-keyed (D-10)
|
||||||
|
import { isNull } from 'drizzle-orm'; // add to imports at top of file
|
||||||
|
import { appConfig } from '../db/schema.js'; // add to imports
|
||||||
|
|
||||||
|
const [flagRow] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'setup_complete'))
|
||||||
|
.limit(1);
|
||||||
|
if (flagRow?.value === 'true') {
|
||||||
|
const [unclaimed] = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(and(isNull(users.oidcIss), eq(users.claimed, false)))
|
||||||
|
.limit(1);
|
||||||
|
if (unclaimed) {
|
||||||
|
await db.update(users).set({
|
||||||
|
oidcIss,
|
||||||
|
oidcSub,
|
||||||
|
claimed: true,
|
||||||
|
displayName: displayName ?? unclaimed.displayName,
|
||||||
|
}).where(eq(users.id, unclaimed.id));
|
||||||
|
return { ...unclaimed, oidcIss, oidcSub, claimed: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. No unclaimed user found — normal insert path
|
||||||
|
// isAdmin: only when setup_complete is false (no unclaimed user exists yet)
|
||||||
|
const shouldBeAdmin = flagRow?.value !== 'true' && Number(count) === 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import additions needed at top of user.ts** (add to existing `import { and, eq, sql } from 'drizzle-orm'`):
|
||||||
|
```typescript
|
||||||
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
import { users, appConfig } from '../db/schema.js'; // add appConfig
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/db/migrations/0002_*.sql` (migration, batch)
|
||||||
|
|
||||||
|
**Analog:** `apps/api/src/db/migrations/0001_famous_mad_thinker.sql`
|
||||||
|
|
||||||
|
**Migration workflow (NEVER drizzle-kit push — D-Task5-DDL):**
|
||||||
|
1. Edit `schema.ts` with the nullable + claimed changes.
|
||||||
|
2. Run: `pnpm --filter @familysync/api exec drizzle-kit generate`
|
||||||
|
3. Review the generated SQL — confirm it contains `ALTER COLUMN` (not DROP/recreate of existing data).
|
||||||
|
4. Run: `pnpm --filter @familysync/api exec drizzle-kit migrate`
|
||||||
|
|
||||||
|
**Expected SQL shape** (Pitfall 9 awareness — check for DROP CONSTRAINT before ADD CONSTRAINT on the unique index):
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `users`
|
||||||
|
MODIFY COLUMN `oidc_iss` varchar(512), -- remove NOT NULL
|
||||||
|
MODIFY COLUMN `oidc_sub` varchar(256), -- remove NOT NULL
|
||||||
|
ADD COLUMN `claimed` boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Backfill: existing OIDC users are already "claimed"
|
||||||
|
UPDATE `users` SET `claimed` = true WHERE `oidc_iss` IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `scripts/generate-secrets.mjs` (utility, batch)
|
||||||
|
|
||||||
|
**Analog:** `scripts/check-audit.mjs` (structure — plain ESM `.mjs`, no compilation)
|
||||||
|
|
||||||
|
**Core pattern** (RESEARCH.md Pattern 6):
|
||||||
|
```javascript
|
||||||
|
// scripts/generate-secrets.mjs — plain ESM; no TypeScript compilation needed
|
||||||
|
import { generateVAPIDKeys } from '../apps/api/node_modules/web-push/src/index.js';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
const vapid = generateVAPIDKeys();
|
||||||
|
const sessionSecret = randomBytes(32).toString('hex');
|
||||||
|
const encKey = randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
# FamilySync Bootstrap Secrets — generated ${new Date().toISOString()}
|
||||||
|
# Paste into docker-compose.yml environment block.
|
||||||
|
# Keep this output safe — these values cannot be recovered if lost.
|
||||||
|
|
||||||
|
SESSION_SECRET=${sessionSecret}
|
||||||
|
APP_PASSWORD_ENCRYPTION_KEY=${encKey}
|
||||||
|
VAPID_PUBLIC_KEY=${vapid.publicKey}
|
||||||
|
VAPID_PRIVATE_KEY=${vapid.privateKey}
|
||||||
|
`);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Root package.json script addition:**
|
||||||
|
```json
|
||||||
|
"generate-secrets": "node scripts/generate-secrets.mjs"
|
||||||
|
```
|
||||||
|
|
||||||
|
**VAPID output format** (VERIFIED: live execution per RESEARCH.md):
|
||||||
|
- `publicKey`: base64url, 87 chars (uncompressed EC P-256, 65 bytes)
|
||||||
|
- `privateKey`: base64url, 43 chars (raw P-256 scalar, 32 bytes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/pwa/src/routes/SetupPage.tsx` (component, request-response)
|
||||||
|
|
||||||
|
**Analog:** `apps/pwa/src/routes/AdminPage.tsx`
|
||||||
|
|
||||||
|
**Imports pattern** (AdminPage.tsx lines 26–37):
|
||||||
|
```typescript
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
// For SetupPage — replace admin-specific imports with setup-specific:
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'; // no router needed inside
|
||||||
|
// SetupPage-specific:
|
||||||
|
import { fetchSetupStatus, postSetupConfig, postSetupCredential, postSetupComplete } from '../api/client.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**TanStack Query mutation pattern** (AdminPage.tsx uses `useMutation`):
|
||||||
|
```typescript
|
||||||
|
const configMutation = useMutation({
|
||||||
|
mutationFn: postSetupConfig,
|
||||||
|
onSuccess: () => {
|
||||||
|
// advance to next step
|
||||||
|
setStep((s) => s + 1);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setError('Configuration failed. Check your inputs and try again.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step state pattern** (Claude's discretion per CONTEXT.md — use local state, not URL params):
|
||||||
|
```typescript
|
||||||
|
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1);
|
||||||
|
// Steps: 1=Welcome, 2=Config, 3=Validate, 4=Credential, 5=Complete
|
||||||
|
```
|
||||||
|
|
||||||
|
**Security constraint:** All copy is plain-text JSX children — no `dangerouslySetInnerHTML` (UI-SPEC security contract). Pattern confirmed in SetupBanner.tsx lines 81–117.
|
||||||
|
|
||||||
|
**No AppNav / BottomTabBar** — SetupPage renders standalone (per UI-SPEC §Routing). The App.tsx gate prevents authenticated routes from showing when unconfigured.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/pwa/src/App.tsx` (modify — setup gate + /setup route)
|
||||||
|
|
||||||
|
**Analog:** itself, lines 58–168
|
||||||
|
|
||||||
|
**Existing `meQuery` pattern to extend** (App.tsx lines 65–70):
|
||||||
|
```typescript
|
||||||
|
const meQuery = useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**New setup status query to add alongside meQuery:**
|
||||||
|
```typescript
|
||||||
|
const setupQuery = useQuery({
|
||||||
|
queryKey: ['setupStatus'],
|
||||||
|
queryFn: () => fetch('/api/setup/status').then((r) => r.json()) as Promise<{ setupComplete: boolean }>,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 0, // always fresh — guard must not be stale (mirrors D-10 spirit on client)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gate pattern to add in Routes block** (App.tsx lines 133–153 show the existing isAdmin gate pattern to copy):
|
||||||
|
```typescript
|
||||||
|
// New /setup route — rendered standalone (no AppNav/BottomTabBar)
|
||||||
|
<Route path="/setup" element={<SetupPage />} />
|
||||||
|
|
||||||
|
// Redirect gate: if setup not complete, send all routes to /setup
|
||||||
|
// Mirror the isAdmin loading-gate pattern (lines 144–150) for the loading state
|
||||||
|
{setupQuery.data?.setupComplete === false && <Navigate to="/setup" replace />}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import addition:**
|
||||||
|
```typescript
|
||||||
|
import { SetupPage } from './routes/SetupPage.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/tests/setup.test.ts` (test, request-response)
|
||||||
|
|
||||||
|
**Analog:** Existing test files under `apps/api/tests/` (same Vitest + Hono test convention)
|
||||||
|
|
||||||
|
**Test structure pattern** (from RESEARCH.md Validation Architecture — mirrors admin.test.ts conventions):
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { app } from '../src/index.js';
|
||||||
|
|
||||||
|
// Mock the DB and external calls — same pattern as admin.test.ts
|
||||||
|
vi.mock('../src/db/client.js', () => ({ db: mockDb }));
|
||||||
|
vi.mock('../src/broker/credentialSync.js', () => ({
|
||||||
|
validateEncryptAndStoreCredential: vi.fn(),
|
||||||
|
CredentialValidationError: class extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('POST /api/setup/complete — 423 guard (SETUP-04)', () => {
|
||||||
|
it('first call returns 200', async () => { /* ... */ });
|
||||||
|
it('second call returns 423', async () => { /* ... */ });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/setup/* when effectively configured (D-10)', () => {
|
||||||
|
it('returns 423 when member_credentials row exists AND VAPID env set', async () => { /* ... */ });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Patterns
|
||||||
|
|
||||||
|
### 1. app_config Key/Value Read
|
||||||
|
**Source:** `apps/api/dist/lib/householdTimezone.js` (compiled) + `apps/api/src/routes/admin.ts` (upsert usage)
|
||||||
|
**Apply to:** `setup.ts` (all config reads), `setupGuard.ts` (setup_complete read), `auth/user.ts` (setup_complete read in upsertUser)
|
||||||
|
```typescript
|
||||||
|
// READ:
|
||||||
|
const [row] = await db
|
||||||
|
.select({ value: appConfig.value })
|
||||||
|
.from(appConfig)
|
||||||
|
.where(eq(appConfig.key, 'some_key'))
|
||||||
|
.limit(1);
|
||||||
|
const val = row?.value ?? null;
|
||||||
|
|
||||||
|
// WRITE (upsert):
|
||||||
|
await db
|
||||||
|
.insert(appConfig)
|
||||||
|
.values({ key: 'some_key', value: theValue })
|
||||||
|
.onDuplicateKeyUpdate({ set: { value: theValue } });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. noEchoHook (credential endpoints)
|
||||||
|
**Source:** `apps/api/src/routes/admin.ts` lines 54–64
|
||||||
|
**Apply to:** `setup.ts` POST /api/setup/credential handler only
|
||||||
|
```typescript
|
||||||
|
const noEchoHook = (result: { success: boolean }, c: Context) => {
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json({ error: 'Invalid request' }, 400);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. CredentialValidationError error mapping
|
||||||
|
**Source:** `apps/api/src/routes/admin.ts` lines 108–119, `apps/api/src/broker/credentialSync.ts` lines 31–36
|
||||||
|
**Apply to:** `setup.ts` credential handler
|
||||||
|
```typescript
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof CredentialValidationError) {
|
||||||
|
return c.json({ error: 'Invalid request' }, 400); // no echo, no Zod details
|
||||||
|
}
|
||||||
|
console.error('[setup/credential] Unexpected error:', err instanceof Error ? err.message : String(err));
|
||||||
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. mysql2 insert + $returningId() re-select
|
||||||
|
**Source:** `apps/api/src/auth/user.ts` lines 126–141
|
||||||
|
**Apply to:** `setup.ts` local user creation step (mysql2 has no RETURNING clause)
|
||||||
|
```typescript
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({ /* ... */ })
|
||||||
|
.$returningId();
|
||||||
|
const [newUser] = await db.select().from(users).where(eq(users.id, inserted.id)).limit(1);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Hono router export + file-level doc comment
|
||||||
|
**Source:** `apps/api/src/routes/admin.ts` lines 1–37, `apps/api/src/routes/health.ts` lines 1–6
|
||||||
|
**Apply to:** `setup.ts`, all new route files
|
||||||
|
```typescript
|
||||||
|
export const setupRouter = new Hono();
|
||||||
|
// Mounted in index.ts: app.route('/api/setup', setupRouter)
|
||||||
|
// Mounted BEFORE app.use('/api/*', devAuthBypass()) — pre-auth surface.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No Analog Found
|
||||||
|
|
||||||
|
All Phase 12 files have close analogs in the codebase. No new patterns need to be sourced from RESEARCH.md examples alone — all implementation patterns are grounded in existing code.
|
||||||
|
|
||||||
|
| File | Note |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/generate-secrets.mjs` | Script structure from `scripts/check-audit.mjs` but the web-push + crypto logic is net-new. RESEARCH.md Pattern 6 is the authoritative reference for the output format. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Analog search scope:** `apps/api/src/routes/`, `apps/api/src/auth/`, `apps/api/src/db/`, `apps/api/src/lib/`, `apps/api/src/broker/`, `apps/pwa/src/`, `scripts/`
|
||||||
|
**Files read:** 14 source files
|
||||||
|
**Pattern extraction date:** 2026-06-15
|
||||||
Reference in New Issue
Block a user