docs(12): code review — 1 critical, 5 warnings, 4 info
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
d96dfae17b
commit
d8b4c98592
@@ -0,0 +1,386 @@
|
||||
---
|
||||
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_
|
||||
Reference in New Issue
Block a user