docs(14.2): add code review report

This commit is contained in:
2026-10-12 00:18:37 +02:00
parent 8c6db0a522
commit 6258663724

View File

@@ -0,0 +1,274 @@
---
phase: 14.2-local-fonoteka-frontend-on-local-summercms-backend
reviewed: 2026-10-11T22:16:58Z
depth: standard
files_reviewed: 43
files_reviewed_list:
- modules/surf/uploads.go
- modules/surf/uploads_test.go
- modules/surf/accesslog.go
- modules/surf/accesslog_test.go
- modules/surf/router.go
- modules/surf/README.md
- docs/services/storage.md
- docs/services/logging.md
- docs/setup/configuration.md
- scripts/check-phase14.2.sh
- ../fonoteka.go/plugins/golem15/fonoteka/console/database.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/reindex.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/prune_notifications.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_marker_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_smoke_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/album_write_service_db_test.go
- ../fonoteka.go/scripts/dev-stack.sh
- ../fonoteka.go/.env.dev.example
- ../fonoteka.go/.gitignore
- ../fonoteka.go/config/storage.yaml
- ../fonoteka.go/config/http.yaml
- ../fonoteka.go/README.md
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-edit-rate-cover.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/wishlist.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/fonotekaApi.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/auth.setup.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/.env.example
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/smoke.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/login.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/fonoteka-item.spec.ts
- /media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/contracts/e2e-backend-switch.test.mjs
findings:
critical: 0
warning: 9
info: 11
total: 20
status: issues_found
---
# Phase 14.2: Code Review Report
**Reviewed:** 2026-10-11T22:16:58Z
**Depth:** standard
**Files Reviewed:** 43 (three repositories)
**Status:** issues_found
## Narrative Findings (AI reviewer)
## Summary
Reviewed the framework additions in summercms.go (the `storage.uploads.serve` route, the `http.access_log` request log, the router wiring, docs and the phase gate script), the fonoteka.go glue (`fonoteka:dev-seed`, `commandDatabase`/`commandBucket`, the local-only marker route, `dev-stack.sh`, env template, config and README) and the vue-fonoteka-app e2e changes (Go seed switch, new and repaired specs, contract test).
The four security properties the phase cares about hold in their main form:
- The marker route and the seed both depend on `console.DevEnvironment`. That function accepts only `local`/`dev`, and an unset `SUMMER_ENV` resolves to `production`. Tests cover the refusal cases.
- `attach.parsePublicBlobPath` and the `is_public` lookup block path traversal and private files on the uploads route. I checked this against `lagoon/attach/static.go` and `file.go`.
- A matched route never logs its query string, headers or raw path.
I found no BLOCKER. I did find nine WARNINGs:
- The access log's "no tokens in paths" promise breaks for 405 and 404 responses.
- The 499 remap can hide real 5xx responses from the gate.
- The uploads route serves a stored `text/html` content type on the app origin.
- `public_path_prefix` is not validated: a prefix that is not a path fails to mount, either loudly at boot or silently as 404s.
- The seed can take over existing accounts and tokens.
- The minted API token secret is written to a group/world-readable file.
- The gate can test a stale binary, and its 5xx log scan passes when the access log is off.
- The realtime spec can pass on another spec's publication.
## Warnings
### WR-01: Access log writes share and invitation tokens for 405 and 404 responses on token routes
**File:** `modules/surf/accesslog.go:70-72` (docs: `docs/services/logging.md` "Request log", `modules/surf/README.md`)
**Issue:** `path` is added whenever `entry.pattern == ""`. For a path that a token route matches but with another method, ServeMux answers 405 before any route handler runs, so `recordPattern` never fires. I verified this: `POST /public/secret-token` against `GET /public/{token}` returns 405. The same happens for a trailing-slash variant (`/public/<token>/`, 404) and for an OPTIONS request to a non-CORS path. fonoteka has bearer-secret paths of exactly this kind:
- `GET /_fonoteka/api/v1/public/{token}` and `public-wishlist/{token}`
- `GET /_fonoteka/api/v1/invitations/{token}`
- `POST /invitations/{token}/accept`, where a GET answers 405
So `POST /_fonoteka/api/v1/public/<share-token>` puts the share token into the log. The docs say the opposite: "the raw path of a matched route [is never logged] because tokens often travel in paths". Sunscreen redacts by key and pattern, not by a `path` value.
**Fix:** Log a path only when it cannot carry a route parameter, or redact the path segments. For example, log `path` only for a ServeMux 404 where no registered pattern matches any method, or replace every segment longer than N characters or matching a token shape with `{redacted}`:
```go
if entry.pattern == "" {
attrs = append(attrs, slog.String("path", redactPathSegments(req.URL.Path)))
}
// redactPathSegments replaces segments that look like identifiers/tokens
// (len >= 16, or [A-Za-z0-9_-]{8,} with a digit) with "{redacted}".
```
Add a test case with `POST /blog/posts/<secret>` (405) asserting the secret is absent. Fix the docs sentence in the same commit.
### WR-02: 499 remap hides real server errors whenever the client has gone away
**File:** `modules/surf/accesslog.go:61-63`
**Issue:** Every status >= 500 becomes 499 once the request context is cancelled, whatever caused the 5xx. A handler that panics, or that fails on a real DB or engine error, after the browser navigated away or Playwright closed the page, is logged as 499. That happens often: Nuxt aborts in-flight fetches on route change, and Playwright closes pages at the end of each test. `scripts/check-phase14.2.sh` `scan_go_log` (line 195) explicitly treats 499 as clean, so these real failures pass the gate. The comment's premise ("the error came from the cancellation") is assumed, not checked.
**Fix:** Keep the real status and add a separate attribute, so a grep for 5xx still finds every error and cancelled ones can be filtered on purpose:
```go
attrs = append(attrs, slog.Int("status", status))
if errors.Is(req.Context().Err(), context.Canceled) {
attrs = append(attrs, slog.Bool("client_gone", true))
}
```
If 499 is kept for nginx parity, apply it only when nothing was written, or record the original status as `upstream_status`, and have `scan_go_log` refuse `status=499` records that carry an upstream 5xx.
### WR-03: Uploads route serves the stored content type verbatim on the app origin (stored XSS via text/html)
**File:** `modules/surf/uploads.go:57-71` (behaviour in `modules/lagoon/attach/static.go:72-76`); docs claim in `docs/services/storage.md` "Serving files"
**Issue:** The route answers with the blob's stored `Content-Type`. `attach.Store` stores `http.DetectContentType` of the bytes, so a `.txt` upload (in `DefaultFileExtensions`) whose body starts with `<html>` is stored as `text/html`. A cabana file field in non-image mode on a public relation therefore yields a public blob served as `text/html` on the application origin. `X-Content-Type-Options: nosniff` does not help when the declared type is already `text/html`, so the docs sentence "a browser does not sniff a stored file into HTML or script" overstates the protection. The Nuxt app keeps `auth_token` in a JS-readable cookie on that origin. nginx in the PHP setup served by extension (`.txt` as `text/plain`), so this is also a behaviour difference. D-19 says Phase 20 reuses this route.
**Fix:** In `uploadsHandler`, add a sandboxing CSP and force download for anything outside an inline-safe allowlist:
```go
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self'; media-src 'self'; sandbox")
// after the type is known (wrap the ResponseWriter or move into attach):
// if !inlineSafe(ct) { w.Header().Set("Content-Disposition", "attachment") }
```
`inlineSafe` should cover image/jpeg, png, gif, webp, audio/*, video/* and application/pdf. Correct the docs sentence.
### WR-04: `storage.uploads.public_path_prefix` is not validated: a non-path prefix either fails boot or silently 404s every upload
**File:** `modules/surf/uploads.go:38-51`
**Issue:** The prefix is only trimmed. `joinPath` does not add a leading slash, so ServeMux treats whatever comes before the first `/` as a host pattern. I checked both cases:
- `storage/app/uploads/public` (no leading slash, a plausible WinterCMS-style value) registers `GET storage/app/uploads/public/{path...}` with host `storage`. It never matches, so every cover silently 404s.
- An absolute CDN URL (`https://cdn.example.com/uploads`) panics in ServeMux. `handleRoute` reports this as a misleading "route conflict" error.
A prefix containing `{` or `}` would also be parsed as a wildcard. The ownership check at line 43 compares against the same unnormalized prefix.
**Fix:** Validate when mounting and fail boot with a clear error:
```go
if !strings.HasPrefix(prefix, "/") || strings.ContainsAny(prefix, "{}") || strings.Contains(prefix, "//") {
return fmt.Errorf("surf: storage.uploads.serve needs storage.uploads.public_path_prefix to be an absolute path such as /storage/uploads, got %q", prefix)
}
```
Add test cases for `storage/uploads` and `https://cdn/x`.
### WR-05: `fonoteka:dev-seed` takes over existing accounts and tokens behind a single environment-name gate
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:72-78, 329-386, 258-280`
**Issue:** The only safeguard is `SUMMER_ENV` (`local` or `dev`), which can also come from a `.env` file next to the config. Inside that gate the seed changes existing accounts without saying so:
- **Existing user:** any user whose email equals `E2E_EMAIL` gets their password reset to `E2E_PASSWORD`, with the default `changeme`. The seed also activates them and clears `must_change_password`.
- **Existing token:** with `DEV_SEED_API_TOKEN` set, an existing `user_api_tokens` row with that hash, owned by any user, is reassigned to the seed user and un-revoked. The `unchanged` check ignores `scopes`.
- **The `dev` environment:** `dev` is a common name for shared, network-reachable servers. On such a server the seed creates `demo@example.com` / `changeme` with a `read write ai` API token.
Phase 20 imports production data, and a local copy of it plus this seed is the likely way a real account gets overwritten.
**Fix:** Add a second, data-based guard before opening the transaction. Refuse when the DSN host is not loopback (the same rule dev-stack.sh applies), and refuse to modify an existing user that the seed did not create unless `--force` is passed. One way is to mark seeded users, for example by checking for the `dev-seed-mcp` token. Never reassign a token row owned by another user: refuse instead. Consider dropping `dev` from the allowed set or documenting it as local-only.
### WR-06: Minted API token secret persisted to a group/world-readable file and echoed into Playwright output
**File:** `../fonoteka.go/scripts/dev-stack.sh:334`; `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:309`; `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts:129-133`
**Issue:** On creation the seed prints `api token: created inv_<secret> (shown once)`. `seed()` tees all seed output to `tmp/dev-stack/seed.out`, which is mode 664 in a 775 directory on this machine. This host has several users (`jin` owns the Nuxt checkout, `golem` runs the stack), so any local user can read a `read write ai` token and use it against `127.0.0.1:8423`. With the `ai` scope that spends the developer's configured AI keys. `seedE2EFixtures` runs the seed with `stdio: 'inherit'`, so a first-run secret also lands in Playwright and CI output. The README says the script "never prints a value".
**Fix:** Create the file with a restrictive umask, and filter the secret before writing it:
```bash
( umask 077; cd "$APP" && TMPDIR="$GO_TMP" bin/fonoteka fonoteka:dev-seed 2>&1 | tee "$RUN/seed.out" )
```
In runTinker.ts, use `stdio: ['ignore', 'pipe', 'inherit']` and drop lines matching `/api token: created/` before echoing. Alternatively, have the seed write the secret only to the terminal, for example behind a `--show-token` flag.
### WR-07: Phase gate `--e2e` can verify a stale binary
**File:** `scripts/check-phase14.2.sh:513-534` (with `../fonoteka.go/scripts/dev-stack.sh:538-546`)
**Issue:** When `dev-stack.sh status` succeeds, the gate reuses the running stack. `up --detach` then prints `already up` and returns without `summer build`, migrate or seed. The Playwright and go.log stages then exercise whatever binary was started earlier, possibly before the commits under review. Plan 04's SUMMARY admits the stack had to be restarted by hand so the binary would include the plan's framework change. A gate that can pass on old code does not fail closed.
**Fix:** Pick one of these:
- Always `down` and `up --detach` in `--e2e`.
- Record the git HEADs of summercms.go and fonoteka.go (and `bin/fonoteka` mtime or `go version -m`) in `tmp/dev-stack/build.id` at `up` time, and have `--e2e` refuse when they differ from the current HEADs.
### WR-08: go.log 5xx scan passes when the access log is off
**File:** `scripts/check-phase14.2.sh:189-201`
**Issue:** `scan_go_log` refuses only when it finds `status=5xx` or the search-failure line. If the developer's `.env.dev` lacks `SUMMER_HTTP__ACCESS_LOG=true`, there are no `http request` records at all, and the scan passes on an empty signal. `config/http.yaml` defaults the key to `false`, and dev-stack.sh does not require it (it is not in `REQUIRED_KEYS`).
**Fix:** Require evidence that the log was produced by this run:
```bash
grep -q 'msg="http request"' "$file" || { echo "refuse: $file has no http request records; set SUMMER_HTTP__ACCESS_LOG=true" >&2; return 1; }
```
Also add `SUMMER_HTTP__ACCESS_LOG` (and `SUMMER_STORAGE__UPLOADS__SERVE`) to `REQUIRED_KEYS`, or export them in `start_go`.
### WR-09: Realtime spec accepts any publication on the channel, including another spec's
**File:** `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/realtime.spec.ts:114-119`
**Issue:** The live assertion is `received.some(m => m.push?.channel === channel && m.push.pub !== undefined)`. It is evaluated over all frames received since the socket opened, before and after `createAlbum`. Other gate specs create albums in the same user's collection (`album-search` creates two, `album-edit-rate-cover` and `fonoteka-item` one each). If they run in parallel workers, or a previous push is still buffered, the test passes even when the album this spec created publishes nothing. The test can therefore pass without proving that this spec's write published anything.
**Fix:** Snapshot the frame count before the write and match the created album:
```ts
const before = received.length
const id = await createAlbum(page, `E2E Realtime ${Date.now()}`)
await expect.poll(() => received.slice(before).some(m =>
m.push?.channel === channel && JSON.stringify(m.push.pub ?? {}).includes(`"id":${id}`)), ...).toBe(true)
```
## Info
### IN-01: Env file parser exports arbitrary keys, so "never executed" is not quite true
**File:** `../fonoteka.go/scripts/dev-stack.sh:129-150`
**Issue:** Any `KEY=value` is exported, including `BASH_ENV`, `LD_PRELOAD`, `PATH` and `NODE_OPTIONS`. The `setsid bash -c` children source `$BASH_ENV`, so an env file line can still run code, which contradicts the comment and the README.
**Fix:** Export only keys matching `^(SUMMER_|NUXT_|E2E_|DEV_STACK_|DEV_SEED_)` and refuse others by name.
### IN-02: Postgres loopback check can be bypassed by `hostaddr` or a query `host`
**File:** `../fonoteka.go/scripts/dev-stack.sh:215-241`
**Issue:** For URI DSNs, a query `host=` is used only when the netloc host is empty, but pgx lets query parameters override the netloc. `postgres://u:p@localhost/db?host=prod.example` therefore passes the check and connects remotely. `hostaddr=` is ignored in both URI and key=value forms.
**Fix:** Let the query `host` override, check `hostaddr` as well, and refuse multi-host values.
### IN-03: `down` signals recorded process groups without checking they are still this stack's
**File:** `../fonoteka.go/scripts/dev-stack.sh:582-592`
**Issue:** After a crash or reboot, a recycled pgid in `tmp/dev-stack/pids` makes `down` send TERM and then KILL to an unrelated process group.
**Fix:** Record the start time or command line with the pgid (`ps -o lstart= -p`), and compare it before killing.
### IN-04: Seed validates `DEV_SEED_API_TOKEN` only after writing cover blobs, leaving orphans on refusal
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:168-197, 258-261`
**Issue:** `StoreAlbumPhoto` writes blobs before rows (see `attach.Store`'s contract). A bad `DEV_SEED_API_TOKEN` rolls back the transaction after three covers are written, and the files stay under `storage/app/uploads/public`. The same happens for any later failure.
**Fix:** Validate `DEV_SEED_API_TOKEN` and the token prefix before `lagoon.Transaction`. On rollback, delete the keys the run stored.
### IN-05: Seed's token "unchanged" check ignores expiry and scopes
**File:** `../fonoteka.go/plugins/golem15/fonoteka/console/dev_seed.go:292-300`
**Issue:** An expired or narrowed `dev-seed-mcp` token counts as present, so no usable token is minted and no secret is shown.
**Fix:** Add `(expires_at IS NULL OR expires_at > now())` to the query, and compare scopes.
### IN-06: `marker_ok` does not check the app name
**File:** `../fonoteka.go/scripts/dev-stack.sh:369`
**Issue:** Any SummerCMS app (for example another `sm-*-app` on the same port) passes `marker_ok` and `guard_marker` (`.backend == "summercms"` only).
**Fix:** Check `.backend == "summercms" and .app == "fonoteka"`.
### IN-07: statusRecorder records a 1xx informational status as the final status
**File:** `modules/surf/accesslog.go:106-111`
**Issue:** A handler that sends `103 Early Hints` and then 200 is logged with status 103.
**Fix:** Record only when `status >= 200` (or overwrite while `w.status < 200`).
### IN-08: Gate header overstates what the whole-suite Go runs check
**File:** `scripts/check-phase14.2.sh:3-5, 331-348`
**Issue:** The header says every stage fails on a go test run that "skips". The `go test ./...` runs in all three modules do not go through `detect`; only the named tests do.
**Fix:** Reword the header, or run the whole suites through `go_json` with an allowlist for intended skips.
### IN-09: Hard-coded absolute developer path in a framework script
**File:** `scripts/check-phase14.2.sh:51`
**Issue:** `PHASE142_NUXT` defaults to `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app`, which ties the framework repo to one machine layout. dev-stack.sh already requires `DEV_STACK_NUXT_DIR` from the env file.
**Fix:** Default to `$DEV_STACK_NUXT_DIR`, or refuse when `PHASE142_NUXT` is unset.
### IN-10: Search spec relies on the "other" album appearing on the first page after clearing
**File:** `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/album-search.spec.ts:46-47`
**Issue:** On a collection that has grown (left-over albums from failed runs, real data in the seeded user's collection), the freshly created `other` album may fall off the first page, depending on the list's sort and page size. The spec then becomes order-dependent.
**Fix:** Assert that the list is unfiltered another way (for example, the result count grows back), or search for `E2E Other ${stamp}` in a second step.
### IN-11: Test cleanups ignore errors; `goDir` mis-resolves a relative `E2E_GO_BIN`
**File:** `../fonoteka.go/plugins/golem15/fonoteka/dev_stack_test.go:106-111`; `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/tests/e2e/helpers/runTinker.ts:48-51`
**Issue:**
- **Test cleanup:** `TestDevSeedOpensDatabase` deletes `users` with the errors ignored. A foreign-key refusal leaves the seeded collections, albums and tokens in the shared container.
- **`goDir`:** `path.resolve(path.dirname(bin), '..')` resolves a relative `E2E_GO_BIN` against the Nuxt checkout's working directory, not the app's.
**Fix:** Check the cleanup `Exec(...).Error` with `t.Errorf`, and delete dependents first. Resolve `E2E_GO_BIN` to an absolute path, or refuse a relative one.
---
_Reviewed: 2026-10-11T22:16:58Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: standard_