Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
387 lines
16 KiB
Markdown
387 lines
16 KiB
Markdown
---
|
|
phase: 12-initial-setup-wizard
|
|
reviewed: 2026-06-15T00:00:00Z
|
|
depth: standard
|
|
files_reviewed: 12
|
|
files_reviewed_list:
|
|
- apps/api/src/routes/setup.ts
|
|
- apps/api/src/lib/setupGuard.ts
|
|
- apps/api/src/index.ts
|
|
- apps/api/src/auth/user.ts
|
|
- apps/api/src/auth/middleware.ts
|
|
- apps/api/src/db/schema.ts
|
|
- apps/api/src/db/migrations/0002_lethal_millenium_guard.sql
|
|
- scripts/generate-secrets.mjs
|
|
- apps/pwa/src/api/client.ts
|
|
- apps/pwa/src/routes/SetupPage.tsx
|
|
- apps/pwa/src/App.tsx
|
|
- apps/api/tests/routes/setup.test.ts
|
|
findings:
|
|
critical: 1
|
|
warning: 5
|
|
info: 4
|
|
total: 10
|
|
status: issues_found
|
|
---
|
|
|
|
# Phase 12: Code Review Report
|
|
|
|
**Reviewed:** 2026-06-15
|
|
**Depth:** standard
|
|
**Files Reviewed:** 12
|
|
**Status:** issues_found
|
|
|
|
## Summary
|
|
|
|
Phase 12 adds the first-run setup wizard: a pre-auth `/api/setup/*` route surface, a 423 lock
|
|
guard, schema migration making `oidc_iss`/`oidc_sub` nullable with a `claimed` marker,
|
|
first-login-claims in `upsertUser`, a secret-generation helper script, and a multi-step React
|
|
wizard. The overall architecture is sound and the security decisions (VAPID keys env-only,
|
|
`noEchoHook` on credential endpoint, https-only for `oidcIssuer`, mounting before OIDC
|
|
middleware) are correctly implemented.
|
|
|
|
One blocker was found: the wizard UI skips calling `validateSetupVapid` entirely, so an
|
|
operator with invalid or missing VAPID keys can complete setup without any runtime feedback.
|
|
Five warnings cover an orphaned-row leak path, a duplicate-unclaimed-user race in the
|
|
credential endpoint, a misleading test mock comment that hides missing coverage, missing
|
|
`appExternalUrl` https enforcement, and the `oidcConfigFallbackMiddleware` permanently
|
|
mutating `process.env` with no mechanism to pick up DB changes after the first request.
|
|
Four info items cover minor quality gaps.
|
|
|
|
---
|
|
|
|
## Critical Issues
|
|
|
|
### CR-01: VAPID Validation Never Called During Wizard Flow
|
|
|
|
**File:** `apps/pwa/src/routes/SetupPage.tsx:27-34` and `apps/pwa/src/routes/SetupPage.tsx:452-487`
|
|
|
|
**Issue:** `validateSetupVapid` is exported from `client.ts` and has full backend
|
|
implementation (`POST /api/setup/validate/vapid`) but is never imported or called in
|
|
`SetupPage.tsx`. Step 2 calls only `validateSetupDb` then `validateSetupOidc`. The VAPID
|
|
structural check (`webpush.setVapidDetails()` 32-byte / 65-byte decode) is entirely skipped.
|
|
|
|
An operator whose `VAPID_PRIVATE_KEY` / `VAPID_PUBLIC_KEY` env vars are absent, corrupted,
|
|
or swapped will complete the entire wizard (HTTP 200 on every step including `POST
|
|
/api/setup/complete`) with no error, then silently fail to send any push notification. There
|
|
is no other point in the flow where the VAPID pair is validated before the app goes live.
|
|
|
|
**Fix:** Import `validateSetupVapid` in `SetupPage.tsx` and call it as a third validation
|
|
row inside `Step2Config.configMutation.onSuccess`, after the OIDC check. Add a
|
|
`ValidationRow` with `state={validationRows.vapid}` to surface the result. Both the
|
|
`validationRows` state type and `bothPassed` guard should include the vapid row:
|
|
|
|
```tsx
|
|
// In import block:
|
|
import {
|
|
validateSetupDb,
|
|
validateSetupOidc,
|
|
validateSetupVapid, // add
|
|
...
|
|
} from '../api/client.js';
|
|
|
|
// State:
|
|
const [validationRows, setValidationRows] = useState<Pick<ValidationRowStatus, 'db' | 'oidc' | 'vapid'>>({
|
|
db: 'idle',
|
|
oidc: 'idle',
|
|
vapid: 'idle',
|
|
});
|
|
|
|
// In onSuccess after OIDC passes:
|
|
try {
|
|
await validateSetupVapid();
|
|
setValidationRows({ db: 'success', oidc: 'success', vapid: 'success' });
|
|
setBothPassed(true); // rename to allPassed if preferred
|
|
} catch (vapidErr) {
|
|
setValidationRows((prev) => ({ ...prev, vapid: 'failure' }));
|
|
setFieldError('VAPID validation failed. Check that VAPID_PRIVATE_KEY and VAPID_PUBLIC_KEY are set in your Docker environment.');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Warnings
|
|
|
|
### WR-01: Orphaned User Row When Post-Insert Re-Select Returns Nothing
|
|
|
|
**File:** `apps/api/src/routes/setup.ts:259-279`
|
|
|
|
**Issue:** The `/credential` handler inserts the local user (lines 259-269), then re-selects
|
|
by `inserted.id` (lines 271-275) to get the full typed row. If the re-select returns nothing
|
|
(race/transient DB issue), the handler returns 503 at line 278 but **never deletes the
|
|
orphaned inserted row**. The catch block at line 292 only executes when
|
|
`validateEncryptAndStoreCredential` throws, not when the re-select fails.
|
|
|
|
An orphaned unclaimed row means `isSetupLocked()`'s effective-config check will not catch it
|
|
(no `member_credentials` row), and a subsequent `/credential` call will insert a second
|
|
unclaimed user. With two unclaimed users in the DB, `first-login-claims` in `upsertUser` will
|
|
claim the first one it finds (nondeterministic by insert order without `ORDER BY`).
|
|
|
|
**Fix:** Roll back the insert when the re-select returns nothing:
|
|
|
|
```typescript
|
|
const [localUser] = await db.select().from(users).where(eq(users.id, inserted.id)).limit(1);
|
|
|
|
if (!localUser) {
|
|
// Roll back the orphaned insert — re-select missed it (transient or race)
|
|
await db.delete(users).where(eq(users.id, inserted.id));
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### WR-02: Concurrent `/credential` Calls Create Multiple Unclaimed Admin Users
|
|
|
|
**File:** `apps/api/src/routes/setup.ts:243-307`
|
|
|
|
**Issue:** The guard (`isSetupLocked`) returns false until `setup_complete='true'` is set OR a
|
|
`member_credentials` row exists. Two concurrent POST `/api/setup/credential` requests both
|
|
pass `isSetupLocked()` (no credential rows yet), both insert a local user row with
|
|
`oidcIss=null`, `claimed=false`, `isAdmin=true`, and both call
|
|
`validateEncryptAndStoreCredential` with their respective `localUser.id`. The
|
|
`member_credentials` table has `UNIQUE(user_id)` — but since the two users have different
|
|
`id` values, both inserts succeed. The result is two unclaimed admin users in the DB.
|
|
|
|
`first-login-claims` in `upsertUser` uses `LIMIT 1` with no `ORDER BY`, so which of the two
|
|
unclaimed users gets claimed by the first OIDC login is non-deterministic. The second unclaimed
|
|
user remains in the DB forever as an orphaned admin row.
|
|
|
|
For a self-hosted two-person household app this is a low-probability race. But there is no DB
|
|
transaction or application-level uniqueness check preventing it.
|
|
|
|
**Fix (simplest):** Add a uniqueness check before inserting the local user. Query for existing
|
|
unclaimed users and return a 409 (or reuse the existing one) if found:
|
|
|
|
```typescript
|
|
// Before inserting the local user:
|
|
const [existingUnclaimed] = await db
|
|
.select()
|
|
.from(users)
|
|
.where(and(isNull(users.oidcIss), eq(users.claimed, false)))
|
|
.limit(1);
|
|
|
|
if (existingUnclaimed) {
|
|
// Reuse the existing unclaimed local user row instead of creating a duplicate.
|
|
// A prior /credential call created it; this call just re-validates and re-stores
|
|
// the credential (onDuplicateKeyUpdate in validateEncryptAndStoreCredential handles this).
|
|
const localUser = existingUnclaimed;
|
|
// ... proceed to validateEncryptAndStoreCredential(localUser.id, ...)
|
|
}
|
|
```
|
|
|
|
Alternatively, wrap the insert + credential store in a DB transaction and add a table-level
|
|
advisory lock, but the check-before-insert approach is sufficient for a 2-person household app.
|
|
|
|
---
|
|
|
|
### WR-03: Misleading Test Mock Comment Hides Missing Coverage for Duplicate Credential Calls
|
|
|
|
**File:** `apps/api/tests/routes/setup.test.ts:52-55`
|
|
|
|
**Issue:** The mock for `validateEncryptAndStoreCredential` has a comment: "Success: write a
|
|
mock credential row so the guard's effective-config check works" (line 53). The mock does NOT
|
|
write any row — it returns `undefined`. After a successful `/credential` call in tests, the
|
|
`member_credentials` table is empty. This means `isSetupLocked()`'s effective-config check
|
|
(credential row + VAPID env) does **not** trigger on a second `/credential` call in the test
|
|
environment — even though it would trigger in production once
|
|
`validateEncryptAndStoreCredential` has actually run.
|
|
|
|
As a direct consequence, there is no test asserting that a **second call to `/credential`
|
|
returns 423** (the effective-config branch). The test suite has no coverage for the duplicate
|
|
call scenario described in WR-02 above.
|
|
|
|
**Fix:** Either (a) have the mock write a real `member_credentials` row, or (b) add an
|
|
explicit test:
|
|
|
|
```typescript
|
|
it('returns 423 on a second /credential call when member_credentials row exists and VAPID env set', async () => {
|
|
// First call: let validateEncryptAndStoreCredential mock write a real credential row
|
|
// ... seed user + credential row + set VAPID env, then:
|
|
const res = await app.fetch(jsonRequest('POST', '/api/setup/credential', { ... }));
|
|
expect(res.status).toBe(423);
|
|
});
|
|
```
|
|
|
|
Also correct the comment to accurately describe what the mock does.
|
|
|
|
---
|
|
|
|
### WR-04: `appExternalUrl` Accepts `http://` URLs (No HTTPS Enforcement)
|
|
|
|
**File:** `apps/api/src/routes/setup.ts:66`
|
|
|
|
**Issue:** The `configSchema` validates `oidcIssuer` with an explicit
|
|
`.refine((v) => v.startsWith('https://'), ...)` but `appExternalUrl` only uses `.url()` with
|
|
no scheme restriction:
|
|
|
|
```typescript
|
|
appExternalUrl: z.string().url().max(512), // accepts http://
|
|
```
|
|
|
|
`appExternalUrl` is stored in `app_config` as `app_external_url` and consumed by
|
|
`oidcConfigFallbackMiddleware` as `OIDC_AUTH_EXTERNAL_URL`. An `http://` app URL means the
|
|
OIDC `redirect_uri` will be `http://…/callback`. Many OIDC providers (including Authelia)
|
|
reject non-HTTPS redirect URIs in production. An operator who accidentally enters `http://`
|
|
instead of `https://` will complete setup successfully but then fail every OIDC login with a
|
|
cryptic Authelia error.
|
|
|
|
**Fix:**
|
|
|
|
```typescript
|
|
appExternalUrl: z
|
|
.string()
|
|
.url()
|
|
.max(512)
|
|
.refine((v) => v.startsWith('https://'), { message: 'appExternalUrl must be an https URL' }),
|
|
```
|
|
|
|
---
|
|
|
|
### WR-05: `oidcConfigFallbackMiddleware` Permanently Mutates `process.env` With No Update Path
|
|
|
|
**File:** `apps/api/src/auth/middleware.ts:63-93`
|
|
|
|
**Issue:** `oidcConfigFallbackMiddleware` sets `process.env.OIDC_ISSUER`,
|
|
`process.env.OIDC_CLIENT_ID`, and `process.env.OIDC_AUTH_EXTERNAL_URL` the first time a
|
|
request arrives without those env vars set. Once written, `needsIssuer = !process.env.OIDC_ISSUER`
|
|
evaluates to `false` on every subsequent request — the DB is never queried again for the
|
|
lifetime of the process.
|
|
|
|
If the wizard is somehow re-run (DB edited to clear `setup_complete`, new values written to
|
|
`app_config`), the **old** values remain in `process.env` and are never replaced until the
|
|
container restarts. While re-running the wizard is not a supported workflow, this also creates
|
|
a subtle silent failure mode during initial development and testing: a test that sets
|
|
`process.env.OIDC_ISSUER=''` to simulate "no env var" will not work once another test or
|
|
import has already caused the middleware to populate the var.
|
|
|
|
**Fix:** The simplest mitigation is to document the behavior explicitly and add a guard that
|
|
re-reads from DB if the env var is an empty string (not just absent):
|
|
|
|
```typescript
|
|
const needsIssuer = !process.env.OIDC_ISSUER || process.env.OIDC_ISSUER === '';
|
|
```
|
|
|
|
A stronger fix would avoid mutating `process.env` globally and instead read from a
|
|
request-scoped context. But given that `@hono/oidc-auth` reads from `process.env` directly
|
|
and a container restart always clears the mutations, the documentation + empty-string guard is
|
|
sufficient for this household app.
|
|
|
|
---
|
|
|
|
## Info
|
|
|
|
### IN-01: `POST /api/setup/complete` Has No Prerequisite Validation
|
|
|
|
**File:** `apps/api/src/routes/setup.ts:317-327`
|
|
|
|
**Issue:** An operator can call `POST /api/setup/complete` without first completing
|
|
`POST /api/setup/credential`. This writes `setup_complete='true'` to `app_config` with no
|
|
unclaimed local user in the DB. On first OIDC login, `upsertUser` finds no unclaimed user to
|
|
claim, falls through to the normal insert path, and sets `shouldBeAdmin = false` because
|
|
`flagRow?.value === 'true'` (setup complete, so admin bootstrap is blocked). The first login
|
|
creates a non-admin user; there is no way to recover admin access without a DB edit.
|
|
|
|
This requires deliberate misuse of the wizard (calling `/complete` while bypassing the UI), so
|
|
it is not a realistic user scenario. The fix is to add a prerequisite check in `/complete`
|
|
that verifies an unclaimed local user with a credential exists before locking:
|
|
|
|
```typescript
|
|
// Before writing setup_complete:
|
|
const [unclaimed] = await db
|
|
.select({ id: users.id })
|
|
.from(users)
|
|
.where(and(isNull(users.oidcIss), eq(users.claimed, false)))
|
|
.limit(1);
|
|
if (!unclaimed) {
|
|
return c.json({ error: 'No credential configured — complete the credential step first' }, 400);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### IN-02: `generate-secrets.mjs` Imports Web-Push Source File Directly
|
|
|
|
**File:** `scripts/generate-secrets.mjs:23`
|
|
|
|
**Issue:** The script imports `'../apps/api/node_modules/web-push/src/index.js'` — the
|
|
package's source file — rather than the package entry point. This bypasses the `package.json`
|
|
`"main"` field and breaks if `web-push` restructures its `src/` directory in a future update.
|
|
The `src/index.js` path is a private implementation detail, not a stable API surface.
|
|
|
|
This works today (verified the file exists and exports `generateVAPIDKeys`), but it is brittle.
|
|
|
|
**Fix:** Import the package by name from the workspace root:
|
|
|
|
```javascript
|
|
// Option A: if web-push is hoisted or linked via pnpm workspace
|
|
import { generateVAPIDKeys } from 'web-push';
|
|
|
|
// Option B: explicit workspace resolution (more robust across pnpm hoist strategies)
|
|
import { createRequire } from 'node:module';
|
|
const require = createRequire(import.meta.url);
|
|
const { generateVAPIDKeys } = require('../apps/api/node_modules/web-push');
|
|
```
|
|
|
|
---
|
|
|
|
### IN-03: `validate/oidc` Endpoint Leaks Internal Network Error Details
|
|
|
|
**File:** `apps/api/src/routes/setup.ts:181-186`
|
|
|
|
**Issue:** The OIDC validation error response includes the raw error message:
|
|
|
|
```typescript
|
|
error: 'OIDC discovery failed: ' + (err instanceof Error ? err.message : String(err))
|
|
```
|
|
|
|
On a misconfigured or unreachable Authelia instance, this returns messages such as:
|
|
`"OIDC discovery failed: connect ECONNREFUSED 10.0.0.5:9091"` to the pre-auth client.
|
|
While this is a self-hosted operator-facing tool where the context is appropriate, it exposes
|
|
internal network topology (private IPs, port numbers) to the browser before authentication.
|
|
|
|
This is a bounded info item for a self-hosted app; the risk is low but the fix is trivial.
|
|
|
|
**Fix:** Return a generic message and log the detail server-side:
|
|
|
|
```typescript
|
|
} catch (err) {
|
|
console.error('[setup/validate/oidc] Discovery failed:', err instanceof Error ? err.message : String(err));
|
|
return c.json({ ok: false, error: 'OIDC discovery failed. Check the issuer URL and that Authelia is reachable from the server.' }, 400);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### IN-04: Email Field Not Trimmed Before Sending in Credential Submission
|
|
|
|
**File:** `apps/pwa/src/routes/SetupPage.tsx:742-748`
|
|
|
|
**Issue:** `saveDisabled` checks `email.trim().length === 0` (line 742) but the email is sent
|
|
to the API without trimming (line 748):
|
|
|
|
```typescript
|
|
completeMutation.mutate({ fastmailEmail: email, appPassword: password });
|
|
```
|
|
|
|
A trailing/leading space in the email field passes the non-empty check but is sent verbatim to
|
|
`validateEncryptAndStoreCredential`, which passes it to `createFastmailClient`. Fastmail CalDAV
|
|
will reject the username `user@fastmail.com ` (trailing space) with an authentication error.
|
|
The operator sees a generic "CalDAV validation failed" message and must diagnose the trailing
|
|
space themselves.
|
|
|
|
**Fix:**
|
|
|
|
```typescript
|
|
completeMutation.mutate({ fastmailEmail: email.trim(), appPassword: password });
|
|
```
|
|
|
|
Note: `appPassword` should deliberately NOT be trimmed — some generated passwords could
|
|
theoretically start/end with specific characters.
|
|
|
|
---
|
|
|
|
_Reviewed: 2026-06-15_
|
|
_Reviewer: Claude (gsd-code-reviewer)_
|
|
_Depth: standard_
|