HOMELAB-1681: feat(cockpit): LD3.1a observer shell — Go backend, SvelteKit frontend, PWA, e2e #570

Merged
aaron merged 12 commits from plane/HOMELAB-1681-cockpit-shell into epic/LD3 2026-10-02 03:28:46 +00:00
Owner

Slice LD3.1a (HOMELAB-1681) of epic LD3 (Lab Director Cockpit): the credentialless observer shell. Class B/C, auto-land after review.

Epic branch note: epic/LD3 did not exist on origin, so this PR created it at the fork point cd6db02 (= live at fork time; origin/live has since moved to e35eefe). Branch discipline per the design brief: plane/HOMELAB-1681-cockpit-shell → epic/LD3 → live at epic close. Rebasing the epic onto current live is a close-time decision, not this PR's.

Design authority: .rpiv/artifacts/designs/ld3/cockpit-design-brief.md (R1–R12), .rpiv/artifacts/research/ld3/legacy-ui-scout.md, .rpiv/artifacts/research/ld3/dispatcher-surfaces-scout.md §6, contract §4/§7/§9.

What

New tree lab-director/cockpit/: its own Go module (replace … => ../dispatcher), a SvelteKit 2 / Svelte 5 runes / Tailwind 4 frontend built to a static SPA the Go binary embeds, and a Playwright suite that is committed and wired into CI.

Backend — config/ (env-only schema, collected-faults refusal; OIDC_ISSUER empty is dev mode, and any other OIDC key set while the issuer is empty is a hard refusal), auth/ (go-oidc v3 authorization-code flow, stateless AES-GCM session cookie with a rotatable keyring, RequireRole(operator) over urn:zitadel:iam:org:project:roles — the piece the legacy app never had), readmodel/ (one provider, one snapshot, a wiring census with configured|unwired|error|unknown and a sentence per source), sse/ (stdlib SSE: hub-level tickers so N clients cost one render, slow consumers dropped not buffered, no replay), server/ (Go 1.22 ServeMux, local middleware Chain, embedded SPA with fallback + precompressed-variant negotiation), cmd/cockpit/ (--version, hermetic --check).

Frontend — the three-pane shell (dashboard with the real wiring census / gates / chat placeholders, each naming the ticket that fills it), /login, /unauthorized, PWA manifest + offline-shell service worker, dark-first token spine with one accent and a hatch texture for "not backed by an observation".

Realtime — GET /api/v1/events: hello on connect, readmodel ticks, heartbeats. The SSE tick and GET /api/v1/readmodel are the same provider and the same struct; the shell bridges them into one store.

Why

The cockpit is the contract's observer surface (§7): adds reach, never power. It stores nothing — the session is a cookie, the read model is computed at read time, and anything it cannot read it declares as unwired with the keys or ticket that would change it (R4). 3.1a is credentialless by design, so it lands without an operator ruling; the deploy slice (3.1b) and its two credentials are separately gated.

Test plan (all run, evidence below)

Suite Command Result
Go go vet ./... && go test -race -count=1 ./... vet clean, 5/5 packages ok, 73 tests, race detector on
Web npm ci && npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json && npm run test && npm run build svelte-check 0 errors / 0 warnings, 62 tests / 9 files, build writes build/
E2E npm run e2e (real binary + real build, dev mode) 17/17 passed, chromium, 1.2m
Image docker build / run NOT RUN LOCALLY — no docker socket on the mgmt VM (documented environment fact). The image workflow is the first thing that will build it; its smoke runs the image as a server (health, read model, embedded SPA being the app rather than the placeholder, and a live SSE handshake) from a sibling container.

Manual server evidence (dev mode, COCKPIT_STATIC_DIR=web/build, read-model tick 2s / heartbeat 3s):

$ curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' :18090/api/v1/health
200 application/json; charset=utf-8
{"dev":true,...,"status":"ok","uptime_seconds":0,"version":{"version":"dev","commit":"none","built":"unknown"}}

$ curl -sS :18090/api/v1/readmodel | head -c 200
{"generated_at":"...","dev":true,"identity":{"subject":"dev:dev-anonymous","name":"dev-anonymous","dev":true,"operator":true,"roles":["operator"]},"sources":[{"name":"plane",...,"state":"unwired",...

$ curl -sS -N -H 'X-Cockpit-Dev-User: operator-a' -H 'Last-Event-ID: 12' :18090/api/v1/events | grep -E '^(id|event|retry):'
id: 1 / event: hello / retry: 3000   (hello echoes last_event_id=12 and replay: "none")
id: 2 event: readmodel
id: 3 event: heartbeat
id: 4 event: readmodel … id: 7 event: readmodel

$ curl -sS -o /dev/null -w 'size=%{size_download}\n' -H 'Accept-Encoding: br' :18090/
size=757      # the shell, brotli; 2552 identity

Two defects the e2e suite caught and this PR fixes (worth the reviewer's attention because they are the exact class of bug unit tests miss):

  1. authApi.me() went through the /api-prefixed client and called /api/auth/me → 404, which the shell rendered as "not signed in". The client now has request (API) and requestRoot (session surface), with a regression test.
  2. The SSE stream had no bridge into the read-model store, so the connection dot said "live" while the uptime stayed frozen at the fetch value. The layout now wires events.on('readmodel', …) → readmodel.apply(...) explicitly, and the e2e asserts the uptime actually ticks.

Slice boundary (hard)

In: lab-director/cockpit/**, .forgejo/workflows/cockpit-image-build.yaml, .forgejo/workflows/ci.yaml (additive: three new jobs, existing jobs byte-identical). One territory-adjacent file: lab-director/.dockerignore, required because R1 fixes the image's build context at lab-director/. It cannot affect the dispatcher image (that workflow's context is lab-director/dispatcher, so Docker reads the .dockerignore there).

Out (not touched): no prod manifests, no HTTPRoute, no ESO/secrets, no CNP — that is 3.1b, gated on the operator ruling (.rpiv/artifacts/designs/ld3/ratification-package-3.1b.md). No Plane read wiring (the client/transport seam is not built; plane renders unwired, or configured with an honest note). No NATS, no attach, no xterm, no STOP button. lab-director/dispatcher/** is unmodified — the cockpit imports dispatcher/events for the fleet-class vocabulary and nothing else.

Deviations from the brief, with reasons

  1. golang.org/x/crypto is not a direct dependency. It appears only transitively (via go-oidc's go-jose). Cookie encryption is AES-GCM, which is stdlib crypto/aes + crypto/cipher; adding an unused direct dep would be a lie in go.mod.
  2. The frontend lifts 5 UI primitives, not 9. button / card / separator / skeleton / tooltip are lifted (rescoped to the cockpit's tokens); dialog / sheet / input / sidebar are not, because 3.1a has no surface that uses them (STOP and the panes are 3.2). Shipping unused primitives is drift; the pattern is what was being lifted.
  3. web/.npmrc sets legacy-peer-deps=true. npm 10's resolver crashes (TypeError: … reading 'edgesOut') on vitest's optional-peer graph, and vitest must be >= 4.1.11 (below that is a critical advisory in @vitest/mocker). Result: 2 low-severity advisories, both transitive through bits-ui with no fix that does not regress the pinned stack. Stated in the file itself, with the cost ("an unmet peer stops being a hard error") written down.
  4. node:22-alpine in CI is pinned to the same digest as the Dockerfile's web stage so CI's node and the image's node cannot drift.
  5. @internationalized/date is an explicit devDependency (as in the legacy app): with legacy-peer-deps, npm 10 does not hoist bits-ui's own dependency, and the build fails without it. Found by a clean npm ci after a passing run — an npm-11-installed tree had masked it.

Open items for the landing reviewer

  • The image has never been built (no docker socket here). The cockpit-image-build job is the gate; if the runner's BuildKit handles the two-stage context as expected, it goes green, and the digest it prints is what 3.1b pins.
  • Zitadel "assert roles on authentication" must be ON for the project, or the urn:zitadel:iam:org:project:roles claim never reaches the ID token and every operator gets a 403. That is a Zitadel-side setting, recorded here and in the 3.1b package — the code is correct either way and fails closed.
  • The OIDC round trip is not e2e-covered (needs a live IdP); the 401/403 client paths are covered by interception and the server-side refusals by the Go suite. Stated in e2e/auth.spec.ts rather than implied.
  • CI watch: three new jobs in ci.yaml plus a new image workflow have never executed on this runner. cockpit-e2e is the riskiest (Playwright image + xvfb-run + a mounted prebuilt binary); if it fails for environment reasons rather than assertion reasons, the fallback is to drop E2E_HEADED and the Xvfb hop (kept for the brief's "headed chromium").
  • Pre-landing brief review + visual-verifier pass are owed by the epic's class-D gate (R11); this PR is where those artefacts should be attached.

Progress log: /home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1681a-builder.md.

Slice **LD3.1a (HOMELAB-1681)** of epic **LD3 (Lab Director Cockpit)**: the credentialless observer shell. Class B/C, auto-land after review. > **Epic branch note:** `epic/LD3` did not exist on origin, so this PR created it at the fork point `cd6db02` (= `live` at fork time; origin/live has since moved to `e35eefe`). Branch discipline per the design brief: `plane/HOMELAB-1681-cockpit-shell` → `epic/LD3` → `live` at epic close. Rebasing the epic onto current `live` is a close-time decision, not this PR's. Design authority: `.rpiv/artifacts/designs/ld3/cockpit-design-brief.md` (R1–R12), `.rpiv/artifacts/research/ld3/legacy-ui-scout.md`, `.rpiv/artifacts/research/ld3/dispatcher-surfaces-scout.md` §6, contract §4/§7/§9. ## What New tree `lab-director/cockpit/`: its own Go module (`replace … => ../dispatcher`), a SvelteKit 2 / Svelte 5 runes / Tailwind 4 frontend built to a static SPA the Go binary embeds, and a Playwright suite that is committed and wired into CI. **Backend** — `config/` (env-only schema, collected-faults refusal; `OIDC_ISSUER` empty is dev mode, and any *other* OIDC key set while the issuer is empty is a hard refusal), `auth/` (go-oidc v3 authorization-code flow, stateless AES-GCM session cookie with a rotatable keyring, `RequireRole(operator)` over `urn:zitadel:iam:org:project:roles` — the piece the legacy app never had), `readmodel/` (one provider, one snapshot, a wiring census with `configured|unwired|error|unknown` and a sentence per source), `sse/` (stdlib SSE: hub-level tickers so N clients cost one render, slow consumers dropped not buffered, no replay), `server/` (Go 1.22 ServeMux, local middleware Chain, embedded SPA with fallback + precompressed-variant negotiation), `cmd/cockpit/` (`--version`, hermetic `--check`). **Frontend** — the three-pane shell (dashboard with the real wiring census / gates / chat placeholders, each naming the ticket that fills it), `/login`, `/unauthorized`, PWA manifest + offline-shell service worker, dark-first token spine with one accent and a hatch texture for "not backed by an observation". **Realtime** — `GET /api/v1/events`: `hello` on connect, `readmodel` ticks, `heartbeat`s. The SSE tick and `GET /api/v1/readmodel` are the **same** provider and the same struct; the shell bridges them into one store. ## Why The cockpit is the contract's observer surface (§7): *adds reach, never power*. It stores nothing — the session is a cookie, the read model is computed at read time, and anything it cannot read it declares as `unwired` with the keys or ticket that would change it (R4). 3.1a is credentialless by design, so it lands without an operator ruling; the deploy slice (3.1b) and its two credentials are separately gated. ## Test plan (all run, evidence below) | Suite | Command | Result | |---|---|---| | Go | `go vet ./... && go test -race -count=1 ./...` | vet clean, **5/5 packages ok, 73 tests**, race detector on | | Web | `npm ci && npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.json && npm run test && npm run build` | svelte-check **0 errors / 0 warnings**, **62 tests / 9 files**, build writes `build/` | | E2E | `npm run e2e` (real binary + real build, dev mode) | **17/17 passed**, chromium, 1.2m | | Image | `docker build` / run | **NOT RUN LOCALLY** — no docker socket on the mgmt VM (documented environment fact). The image workflow is the first thing that will build it; its smoke runs the image as a server (health, read model, embedded SPA being the app rather than the placeholder, and a live SSE handshake) from a sibling container. | Manual server evidence (dev mode, `COCKPIT_STATIC_DIR=web/build`, read-model tick 2s / heartbeat 3s): ``` $ curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' :18090/api/v1/health 200 application/json; charset=utf-8 {"dev":true,...,"status":"ok","uptime_seconds":0,"version":{"version":"dev","commit":"none","built":"unknown"}} $ curl -sS :18090/api/v1/readmodel | head -c 200 {"generated_at":"...","dev":true,"identity":{"subject":"dev:dev-anonymous","name":"dev-anonymous","dev":true,"operator":true,"roles":["operator"]},"sources":[{"name":"plane",...,"state":"unwired",... $ curl -sS -N -H 'X-Cockpit-Dev-User: operator-a' -H 'Last-Event-ID: 12' :18090/api/v1/events | grep -E '^(id|event|retry):' id: 1 / event: hello / retry: 3000 (hello echoes last_event_id=12 and replay: "none") id: 2 event: readmodel id: 3 event: heartbeat id: 4 event: readmodel … id: 7 event: readmodel $ curl -sS -o /dev/null -w 'size=%{size_download}\n' -H 'Accept-Encoding: br' :18090/ size=757 # the shell, brotli; 2552 identity ``` Two defects the e2e suite caught and this PR fixes (worth the reviewer's attention because they are the exact class of bug unit tests miss): 1. `authApi.me()` went through the `/api`-prefixed client and called `/api/auth/me` → 404, which the shell rendered as "not signed in". The client now has `request` (API) and `requestRoot` (session surface), with a regression test. 2. The SSE stream had no bridge into the read-model store, so the connection dot said "live" while the uptime stayed frozen at the fetch value. The layout now wires `events.on('readmodel', …) → readmodel.apply(...)` explicitly, and the e2e asserts the uptime actually ticks. ## Slice boundary (hard) In: `lab-director/cockpit/**`, `.forgejo/workflows/cockpit-image-build.yaml`, `.forgejo/workflows/ci.yaml` (additive: three new jobs, existing jobs byte-identical). One territory-adjacent file: `lab-director/.dockerignore`, required because R1 fixes the image's build context at `lab-director/`. It cannot affect the dispatcher image (that workflow's context is `lab-director/dispatcher`, so Docker reads the `.dockerignore` there). Out (not touched): no prod manifests, no HTTPRoute, no ESO/secrets, no CNP — that is 3.1b, gated on the operator ruling (`.rpiv/artifacts/designs/ld3/ratification-package-3.1b.md`). No Plane read wiring (the client/transport seam is not built; `plane` renders `unwired`, or `configured` with an honest note). No NATS, no attach, no xterm, no STOP button. **`lab-director/dispatcher/**` is unmodified** — the cockpit imports `dispatcher/events` for the fleet-class vocabulary and nothing else. ## Deviations from the brief, with reasons 1. **`golang.org/x/crypto` is not a direct dependency.** It appears only transitively (via go-oidc's go-jose). Cookie encryption is AES-GCM, which is stdlib `crypto/aes` + `crypto/cipher`; adding an unused direct dep would be a lie in `go.mod`. 2. **The frontend lifts 5 UI primitives, not 9.** button / card / separator / skeleton / tooltip are lifted (rescoped to the cockpit's tokens); dialog / sheet / input / sidebar are not, because 3.1a has no surface that uses them (STOP and the panes are 3.2). Shipping unused primitives is drift; the *pattern* is what was being lifted. 3. **`web/.npmrc` sets `legacy-peer-deps=true`.** npm 10's resolver crashes (`TypeError: … reading 'edgesOut'`) on vitest's optional-peer graph, and vitest must be >= 4.1.11 (below that is a *critical* advisory in `@vitest/mocker`). Result: 2 low-severity advisories, both transitive through `bits-ui` with no fix that does not regress the pinned stack. Stated in the file itself, with the cost ("an unmet peer stops being a hard error") written down. 4. **`node:22-alpine` in CI is pinned to the same digest as the Dockerfile's web stage** so CI's node and the image's node cannot drift. 5. **`@internationalized/date` is an explicit devDependency** (as in the legacy app): with `legacy-peer-deps`, npm 10 does not hoist bits-ui's own dependency, and the build fails without it. Found by a clean `npm ci` after a passing run — an npm-11-installed tree had masked it. ## Open items for the landing reviewer - **The image has never been built** (no docker socket here). The `cockpit-image-build` job is the gate; if the runner's BuildKit handles the two-stage context as expected, it goes green, and the digest it prints is what 3.1b pins. - **Zitadel "assert roles on authentication"** must be ON for the project, or the `urn:zitadel:iam:org:project:roles` claim never reaches the ID token and every operator gets a 403. That is a Zitadel-side setting, recorded here and in the 3.1b package — the code is correct either way and fails closed. - **The OIDC round trip is not e2e-covered** (needs a live IdP); the 401/403 client paths are covered by interception and the server-side refusals by the Go suite. Stated in `e2e/auth.spec.ts` rather than implied. - **CI watch**: three new jobs in `ci.yaml` plus a new image workflow have never executed on this runner. `cockpit-e2e` is the riskiest (Playwright image + `xvfb-run` + a mounted prebuilt binary); if it fails for environment reasons rather than assertion reasons, the fallback is to drop `E2E_HEADED` and the Xvfb hop (kept for the brief's "headed chromium"). - Pre-landing brief review + visual-verifier pass are owed by the epic's class-D gate (R11); this PR is where those artefacts should be attached. Progress log: `/home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1681a-builder.md`.
The LD3.1a observer backend: an operator-only HTTP surface that stores nothing
and says so honestly.

- config/: environment-only schema validated as a whole, with collected faults
  and a refusal that names every one. OIDC_ISSUER empty is dev mode; ANY other
  OIDC key set while the issuer is empty is a refusal (ErrUnprotected) rather
  than an operator surface starting open because one key was missed.
- auth/: the OIDC authorization-code flow (go-oidc v3 + oauth2, the legacy
  app's plumbing pattern rewritten), a stateless AES-GCM session cookie with a
  versioned keyring (Keys[0] seals, every key opens, no auth_sessions table),
  and RequireRole(operator) over urn:zitadel:iam:org:project:roles — the piece
  the legacy app never had. 401 is JSON for an API client and a redirect to
  /auth/login for a browser; 403 is JSON for an API client and the app's own
  page for a browser.
- readmodel/: one provider, one snapshot type, and a wiring census in which
  every source is configured / unwired / error / unknown with a sentence an
  operator can act on. 3.1a ships Plane unwired by design.
- sse/: stdlib SSE — a hub whose tickers render each snapshot once for all
  subscribers, a slow consumer dropped rather than buffered, and no replay
  (whole-state read model). No WebSocket: that is reserved for LD3.3.
- server/: Go 1.22 ServeMux routing, a local middleware Chain, and the embedded
  SPA with SPA fallback plus precompressed-variant negotiation.
- cmd/cockpit/: the process, with --version and a hermetic --check that does not
  attempt OIDC discovery (a build has no IdP) and says so when one is set.
71 tests, no network and no wall clock a test cannot inject.

What the suite is actually pinning, rather than counting:

- config: the ambiguous-OIDC refusal (ErrUnprotected) with the offending keys
  named, every fault collected in one pass, the keyring grammar (base64 of
  exactly 32 bytes, no duplicate keys, padded and raw both accepted), and that
  Plane's wired/unwired state is a property of the VALUES rather than a flag.
- auth: seal/open round trip, tamper detection (a flipped ciphertext byte is
  ErrDecrypt), a rotation that reads cookies minted before it, an empty
  project-roles claim being an EMPTY role set (403) rather than a default grant,
  and the four authorization outcomes the middleware can produce. The relabel
  test documents what actually holds: the key id only chooses which key is tried
  first, so relabelling is not a bypass and a token whose key is gone cannot be
  rescued by naming one that is present.
- readmodel: uptime from an injected clock, a backwards clock clamped to zero,
  an unwired source naming the keys that would wire it, and the class vocabulary
  derived from the dispatcher module rather than re-declared here.
- sse: monotonic ids, fan-out, a stalled subscriber DROPPED rather than buffered
  (and the healthy peer surviving), and the wire framing of hello/readmodel/
  heartbeat through a real HTTP server — plus a concurrent publish-vs-cancel test
  whose assertion is the race detector itself.
- server: the health payload taking its process facts from the same provider the
  read model uses, the SPA fallback rules (a missing asset is a 404, a missing
  route is the shell, /api/* is JSON), precompressed negotiation for both assets
  and the shell document, cache headers including the service worker's no-cache,
  the security headers, and the middleware chain's order.
The operator-facing half (class D): SvelteKit 2 / Svelte 5 runes / Tailwind 4 /
bits-ui, built to a static SPA the Go binary embeds.

Design, deliberately not inherited from the legacy app:

- One accent (sodium amber) on a cold graphite ramp — NOT the legacy #3b82f6,
  which is the most common AI-default accent and the one ui-craft names. Radius
  STEPS (4px chip, 6px control, 10px pane) rather than one stamped value.
- The signature is the HATCH: every surface with no backing observation is drawn
  hatched rather than filled or hidden, so "we cannot see this" is a texture the
  eye learns. It is used for unwired sources, the empty panes and the dev-mode
  banner. The derived-state vocabulary inherits it in LD3.4.
- The header's LAMP: an 8px square that is lit when the stream is live. Status is
  never colour-only — the connection dot also spells out its state (WCAG 1.4.1).
- The three panes state their own emptiness AND their owner ticket, so an empty
  pane is a status rather than a mystery, and a placeholder cannot become
  permanent by accident.

Transports and stores:

- lib/api/client.ts: one fetch with the /api prefix and one WITHOUT it for the
  session surface. (The one-prefix version called /api/auth/me, which 404s — and
  the shell rendered that as "not signed in". Found by the e2e suite.)
- stores/events.svelte.ts: an EventSource store with named-event handling,
  browser-native reconnection, a slow-consumer message, and an auth probe after
  three consecutive failures, because EventSource exposes no status and an
  expired session otherwise looks like a dropped network forever.
- stores/readmodel.svelte.ts: the ONE read model. The SSE tick and the fetch
  render the same value; apply() keeps the identity the fetch resolved, because
  the broadcast payload carries none.
- +layout.svelte: identity -> snapshot -> stream, the 401/403 routing, and the
  explicit bridge that wires stream ticks into the read-model store.
- service-worker.ts: the offline shell only. /api/* and /auth/* are network-only
  — a cached read model is a stale fleet presented as current.

Pages: / (the three-pane shell with the wiring census and the invariants
footer), /login (real navigation to /auth/login, dev-mode note), /unauthorized
(the 403 page naming the required role and the roles the session does have).
Two suites, both runnable with one command each, and neither is decoration.

Vitest (62 tests, jsdom, $app/* aliased to hand-written mocks): the API client's
error envelope, the three stores (a twenty-line FakeSource makes every branch of
the event store reachable, including the auth probe and the slow-consumer drop),
the formatting helpers, and the components that carry the design rules — the
connection dot always spells out its state, an unwired source is hatched, a pane
states its own emptiness and owner.

Playwright (17 specs, the real binary serving the real build in dev mode):
- shell: the three-pane skeleton, the panes' owned emptiness, the stream going
  live, the uptime TICKING (the whole Go-ticker -> hub -> EventSource -> store ->
  component path in one assertion), the honest wiring census, the identity chip,
  the manifest and service-worker registration, the SPA fallback, and a missing
  asset being a 404 rather than the app.
- auth: the /auth/me round trip through the dev-mode header, two distinct dev
  identities, the dev login redirect, a 401 routing to /login and a 403 routing
  to the 403 page (both driven by route interception, which is the client
  contract), and an unknown API route answering JSON.

The OIDC authorization-code round trip against Zitadel is NOT covered here: it
needs a live IdP, a client secret and a human consent screen. It is asserted in
LD3.1b's verification plan, and its server-side refusals are covered by the Go
suite, where a non-dev handler can be built without discovery.

The legacy app's e2e was dead code (not in package.json, referenced by no
workflow). This one is in devDependencies, wired as `npm run e2e`, and run by
the cockpit-e2e CI job.
Multistage: node:22-alpine builds the SPA -> golang:1.24.1-alpine builds
cmd/cockpit with the SPA staged into cmd/cockpit/static/ -> distroless/static
nonroot (uid 65532, no shell, no libc). Both base images pinned by index digest,
re-derivable with crane.

The build context is lab-director/, not the module root: the cockpit's go.mod
carries `replace ... => ../dispatcher` (R1) and the frontend lives beside it, so
the context is the one directory holding both modules. lab-director/.dockerignore
keeps it a few MB by excluding node_modules (~250 MB) and .svelte-kit.

Two deliberate details:

- The Dockerfile DELETES cmd/cockpit/static/ before copying the real build. That
  directory holds a checked-in placeholder so a Go-only checkout can build and
  test without npm; leaving it in place would ship a "frontend not built" page in
  the published image.
- The build runs --version and --check itself, so an image that cannot start its
  own validation never reaches a tag.

There is no HEALTHCHECK: distroless has no shell to run one in, and the
Deployment's probes hit /api/v1/health (a read-only GET).
ci.yaml gains three jobs (additive only; the existing jobs are untouched):

- cockpit-test: go vet + go test -race in a golang:1.24 container over the dind
  socket (the same shape and the same reason as dispatcher-test — the :host
  runner has no C toolchain, and the SSE hub's drop/cancel behaviour is a race
  property that must be enforced by CI, not only locally). The whole
  lab-director/ tree is mounted because the module replaces ../dispatcher.
- cockpit-test (web): a node:22-alpine container pinned to the SAME digest the
  Dockerfile builds with, so CI's node and the image's node cannot drift; the
  checkout is mounted read-only and copied inside so node_modules never lands
  root-owned. svelte-check is required, not a nicety: the e2e specs are
  type-checked and a type error there is a test that silently asserts nothing.
- cockpit-e2e: the SPA and the binary are built on the runner's side of the dind
  socket, then the suite runs inside the digest-pinned official Playwright image
  (chromium + shared libraries + Xvfb in one maintained image, so no browser is
  installed into an unknown runner). Headed chromium under xvfb-run.

cockpit-image-build.yaml is modelled on dispatcher-image-build.yaml (same
trigger shape, preflight skip-with-reason, promotion rule, scoped HARBOR_PUSH_*
robot, size gate) with three cockpit-specific differences: the context is
lab-director/, pull_request also covers epic/** (ticket PRs target an epic
branch, and an image first built on the branch that lands it is first exercised
too late), and the smoke RUNS the image as a server — health, read model, the
embedded SPA actually being the app rather than the placeholder, and a live SSE
handshake carrying hello, heartbeat and readmodel from a sibling container. A tag
is pushed `latest` only from live; the 3.1b deploy slice pins the digest.
HOMELAB-1681: docs(cockpit): module README
Some checks failed
Plan in progress...
CI Review / helm-validate (pull_request) Successful in 9s
CI Review / receiver-test (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 6s
CI Review / alert-receiver-test (pull_request) Successful in 20s
CI Review / dispatcher-test (pull_request) Successful in 2m36s
CI Review / cockpit-test (pull_request) Successful in 2m53s
Build Lab Director Cockpit Image / build-and-push (pull_request) Failing after 2m59s
CI Review / cockpit-e2e (pull_request) Successful in 4m2s
a503fa7367
Package map, how to run each suite, the environment schema and the two keys that
decide the posture (OIDC_ISSUER, COCKPIT_COOKIE_KEYS), the authorization
outcome table, the realtime contract (one tick for N clients, drop the slow
consumer, no replay), the wiring census as it stands in 3.1a, and the explicit
list of what this slice does not do.
HOMELAB-1681: fix(ci): the image smoke's readiness grep never matched
Some checks failed
CI Review / receiver-test (pull_request) Successful in 6s
CI Review / helm-validate (pull_request) Successful in 7s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 6s
CI Review / alert-receiver-test (pull_request) Successful in 22s
Build Lab Director Cockpit Image / build-and-push (pull_request) Failing after 1m50s
CI Review / cockpit-test (pull_request) Successful in 2m9s
CI Review / dispatcher-test (pull_request) Successful in 2m35s
CI Review / cockpit-e2e (pull_request) Successful in 3m46s
6f2c9287c8
Run #1665 (the workflow's first run) failed the 'image serves and streams' smoke
against a container that had been healthy for the whole 30s wait: the readiness
loop grepped for slog TextHandler's QUOTED form (msg="listening"), but
TextHandler only quotes a value that needs it — a value with a space
(msg="session keyring") is quoted, a single word (msg=listening) is not.

Every other step in the run was green, including both probes of the built image
(--version and --check) and the CI test/e2e jobs, so the image itself built and
validated correctly; only this wait was wrong.

The pattern now accepts both forms and the comment records the quoting rule, so
the next reader does not reintroduce it.
HOMELAB-1681: fix(ci): the image smoke probe used the wrong hostname
Some checks failed
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 5s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
CI Review / alert-receiver-test (pull_request) Successful in 20s
Build Lab Director Cockpit Image / build-and-push (pull_request) Failing after 9s
CI Review / cockpit-test (pull_request) Successful in 2m10s
CI Review / dispatcher-test (pull_request) Successful in 2m11s
CI Review / cockpit-e2e (pull_request) Successful in 3m26s
503798dd83
Run #1668 cleared the readiness wait (the previous fix worked) and then failed
inside the probe with

  urllib.error.URLError: <urlopen error [Errno -5] Name has no usable address>

which reads as a DNS fault on the smoke network but was a typo: the probe opened
http://cockpit:8080 while the container on that network is named
ld-cockpit-smoke-server. The container name is now passed to the probe as argv,
so the two cannot drift again, and the probe prints the base URL it is about to
use so the next failure of this kind names the host in its first line.

(The dispatcher workflow's identical stub pattern interpolates ${stub} into the
container under test's environment, which is why it does not have this bug.)
HOMELAB-1681: fix(ci): the image smoke asserted a non-dev read-model shape
All checks were successful
CI Review / receiver-test (pull_request) Successful in 5s
CI Review / helm-validate (pull_request) Successful in 5s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 4s
CI Review / alert-receiver-test (pull_request) Successful in 19s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 17s
CI Review / cockpit-test (pull_request) Successful in 1m34s
CI Review / dispatcher-test (pull_request) Successful in 2m7s
CI Review / cockpit-e2e (pull_request) Successful in 3m39s
b688ecf399
Run #1670 got all the way through the smoke (health, read model, the embedded SPA
being the app rather than the placeholder, and a live SSE handshake carrying
hello/heartbeat/readmodel) and failed on one assertion that was simply wrong:

  FAIL readmodel has no identity without a session
       {"subject": "dev:dev-anonymous", ...}

The image is started in dev mode by design (no credential, no IdP to reach), and
dev mode is unprotected by definition — so an identity IS the expected answer
with no cookie and no dev header. The "no identity" case belongs to a non-dev
handler, which the Go suite asserts directly.

What the smoke now asserts instead is the property that actually matters for an
observer app: an identity that did not come from an IdP advertises itself as one
(dev: true, subject prefixed dev:) and never renders as a real login.
Author
Owner

CI green — and what the pipeline caught (builder note, 2026-10-02)

Head b688ecf399 — all jobs pass:

Run Job Result
#1671 cockpit-test ✅ 1m34s
#1671 cockpit-e2e ✅ 3m39s (headed chromium under xvfb-run in the digest-pinned Playwright image)
#1671 helm-validate, receiver-test, dispatcher-test, alert-receiver-test ✅ (untouched — the additive jobs did not disturb them)
#1672 build-and-push (cockpit-image-build) ✅ 17s successful on the workflow's 4th run

The image builds. That was the one thing I could not verify locally (no docker socket), so it is worth stating plainly: node:22-alpine → golang:1.24.1-alpine with the two-module context works, the web stage's npm ci works inside the container, --version and --check pass against the built image, and the serve-and-stream smoke passes (health, read model, the embedded SPA being the app rather than the placeholder, and a live SSE handshake carrying hello/heartbeat/readmodel).

Finding the image workflow took three iterations, and each one was a real defect in the smoke rather than in the product — recorded here because a reviewer should not have to reconstruct it from failing runs:

  1. #1665 — the readiness wait grepped slog's quoted form (msg="listening"); TextHandler only quotes a value that needs it, so a single word is msg=listening and the loop never matched. Failed after 30s against a container that had been healthy the whole time.
  2. #1668 — cleared that, then the probe opened http://cockpit:8080 while the container is named ld-cockpit-smoke-server. The traceback read as DNS failure (Errno -5 Name has no usable address) and was a typo. The host is now passed as argv, so the two cannot drift, and the probe prints its base URL first.
  3. #1670 — everything passed except one assertion I got wrong: the image runs in dev mode by design (no credential, no IdP), and dev mode is unprotected, so an identity is the expected answer with no cookie and no dev header. Replaced with the property that actually matters for an observer app: an identity that did not come from an IdP advertises itself as one (dev: true, subject prefixed dev:) and never renders as a real login.

Two earlier defects were caught before CI, by the committed e2e suite — and they are the reason this PR insists on a real one: authApi.me() was calling /api/auth/me (404 → the shell showed "not signed in" for an authenticated operator), and the SSE ticks were never bridged into the read-model store (the connection dot said "live" while the uptime stayed frozen). Neither is visible to a unit test.

Nothing else outstanding from my side. lab-director/dispatcher/** is unmodified, and the territory is as declared in the PR body.

## CI green — and what the pipeline caught (builder note, 2026-10-02) Head `b688ecf399` — all jobs pass: | Run | Job | Result | |---|---|---| | #1671 | `cockpit-test` | ✅ 1m34s | | #1671 | `cockpit-e2e` | ✅ 3m39s (headed chromium under `xvfb-run` in the digest-pinned Playwright image) | | #1671 | `helm-validate`, `receiver-test`, `dispatcher-test`, `alert-receiver-test` | ✅ (untouched — the additive jobs did not disturb them) | | #1672 | `build-and-push` (`cockpit-image-build`) | ✅ 17s successful on the workflow's 4th run | **The image builds.** That was the one thing I could not verify locally (no docker socket), so it is worth stating plainly: `node:22-alpine` → `golang:1.24.1-alpine` with the two-module context works, the `web` stage's `npm ci` works inside the container, `--version` and `--check` pass against the built image, and the serve-and-stream smoke passes (health, read model, the **embedded SPA being the app rather than the placeholder**, and a live SSE handshake carrying `hello`/`heartbeat`/`readmodel`). Finding the image workflow took three iterations, and each one was a real defect in the smoke rather than in the product — recorded here because a reviewer should not have to reconstruct it from failing runs: 1. **#1665** — the readiness wait grepped slog's *quoted* form (`msg="listening"`); `TextHandler` only quotes a value that needs it, so a single word is `msg=listening` and the loop never matched. Failed after 30s against a container that had been healthy the whole time. 2. **#1668** — cleared that, then the probe opened `http://cockpit:8080` while the container is named `ld-cockpit-smoke-server`. The traceback read as DNS failure (`Errno -5 Name has no usable address`) and was a typo. The host is now passed as argv, so the two cannot drift, and the probe prints its base URL first. 3. **#1670** — everything passed except one assertion I got wrong: the image runs in **dev mode by design** (no credential, no IdP), and dev mode is unprotected, so an identity *is* the expected answer with no cookie and no dev header. Replaced with the property that actually matters for an observer app: an identity that did not come from an IdP advertises itself as one (`dev: true`, `subject` prefixed `dev:`) and never renders as a real login. Two earlier defects were caught before CI, by the committed e2e suite — and they are the reason this PR insists on a real one: `authApi.me()` was calling `/api/auth/me` (404 → the shell showed "not signed in" for an authenticated operator), and the SSE ticks were never bridged into the read-model store (the connection dot said "live" while the uptime stayed frozen). Neither is visible to a unit test. Nothing else outstanding from my side. `lab-director/dispatcher/**` is unmodified, and the territory is as declared in the PR body.
Author
Owner

Review verdict 1/2 — correctness & security (fresh context)

Verdict: BLOCKERS: 1 | CONCERNS: 5 | SUGGESTIONS: 6 — for head b688ecf3990f5486412eeecd9c193e190a4dd0ff.

Independent fresh-context reviewer (not the implementer), primary sources only: the PR diff, the branch tree read-only, the design brief R1–R12, and acceptance criteria HOMELAB-1681. The reviewer additionally built the SPA and ran an independent headless-Chromium reproduction of B1.

Landing status: BLOCKED. One blocker is open; this PR will not be merged until it is fixed (see the landing worker's note below this comment).


Blockers

B1. The production (non-dev) CSP blocks the SPA's inline bootstrap script, so the authenticated cockpit renders a blank page.

  • Where: lab-director/cockpit/server/middleware.go:110-135 builds the CSP. The non-dev branch (lines 111-122) emits "script-src 'self'" (line 120) — no 'unsafe-inline', no nonce, no hash. The dev branch (lines 124-134) emits "script-src 'self' 'unsafe-inline' 'unsafe-eval'" (line 132). The mode is chosen at server/server.go:140 (SecurityHeaders(s.opts.Dev)), fed from cmd/cockpit/main.go:166 (Dev: cfg.Dev()), which is true only when OIDC_ISSUER is empty (config/config.go:213). So the strict CSP applies exactly in the deployed/authenticated configuration.
  • What is wrong: the document the Dockerfile ships contains an inline <script>. The committed source is web/src/app.html:18 (%sveltekit.body%) with no kit.csp configured (web/svelte.config.js:17-38), so SvelteKit injects its classic inline bootstrap + service-worker registration into index.html. Dockerfile:53 builds it and Dockerfile:97-99 stages that build as the embedded SPA. A policy of script-src 'self' with no hash/nonce blocks that inline script; the app never calls kit.start(...).
  • Proof (independent, reproduced twice — once against the build already in the worktree, once against a fresh npm run build from the branch source): serving the built index.html with the exact production CSP string and loading it in headless Chromium yields
    [error] Executing inline script violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a nonce ('nonce-…') is required…
    with appMounted: false, bodyText: "". With the dev CSP the same bundle mounts (appMounted: true, header/panes render). So the code is fine; the document is unreachable under the deployed policy.
  • Why it matters: the ticket's acceptance criteria ("login restricted to the operator group; live events stream") cannot be satisfied in the deployed configuration — the operator gets an empty page. It is also a security-relevant divergence: the CSP that is supposed to be the strict one is the only untested one, and it is broken.
  • Why no test caught it: every test that serves a real document runs dev mode. web/playwright.config.ts:6-15 and web/e2e/run-server.sh:5-11,37-44 (dev mode is "implied by leaving OIDC_ISSUER unset"), the image smoke .forgejo/workflows/cockpit-image-build.yaml:288-298 (-e COCKPIT_LOG_LEVEL=info, no issuer → dev), and server/server_test.go:5-15 (newTestServer always Dev: true). TestSecurityHeaders (server/server_test.go:359-384) asserts the dev CSP — it even requires ws: to be present (lines 379-381).
  • What would fix it: make the shipped document and the shipped policy agree, and test the pair in the mode that matters. Options: (a) compute the sha256 of the inline script(s) in the served index.html at startup (server/static.go:68 already holds the bytes; writeIndex at 257-275 is the one write site) and append 'sha256-…' to script-src, or (b) kit.csp nonce mode in web/svelte.config.js + a per-response nonce injected into both the header and index.html, or (c) fall back to 'unsafe-inline' on script-src (works, but gives up the XSS hardening). Whichever is chosen, add a check that the real build boots under the non-dev CSP; a Go test that asserts the header's hashes cover the embedded index.html's inline scripts is enough and needs no browser.

Concerns

C1. Dev mode is reachable by absence, and its one warning can be silenced by configuration.
config/load.go:140-160 refuses the ambiguous case (any of OIDC_CLIENT_ID/OIDC_CLIENT_SECRET/OIDC_REDIRECT_URL set while the issuer is empty → FaultIncompleteOIDC/ErrUnprotected). But an entirely empty OIDC environment is legal dev mode: every endpoint unprotected, and any caller picks its own identity via X-Cockpit-Dev-User, always granted the operator role (auth/middleware.go:167-182, Roles: []string{h.cfg.OperatorRole}). The only guard is the WARN at cmd/cockpit/main.go:82-88 — and newLogger (cmd/cockpit/main.go:302-311) sets the level from COCKPIT_LOG_LEVEL, so COCKPIT_LOG_LEVEL=error suppresses it. A Deployment whose four OIDC env keys are typo'd (or whose optional Secrets never populate) therefore starts an unprotected operator surface whose page celebrates being unprotected (web/src/lib/components/shell/app-header.svelte:50-57, web/src/routes/login/+page.svelte:51-59), possibly with no startup warning. R3 rules "issuer empty → dev mode", so this is not a blocker, but it is the weakest link in the credential-free posture 3.1b inherits. Fix: require a positive opt-in for dev mode (e.g. COCKPIT_DEV=1) and/or emit the dev warning to stderr unconditionally rather than through the levelled logger.

C2. The ID-token nonce is verified only when the IdP happens to return one.
auth/oidc.go:167-170: if idToken.Nonce != "" && stateEqual(idToken.Nonce, state.Nonce) != 1 { … }. The server always sends a nonce (auth/oidc.go:78, :93-96), and go-oidc only surfaces the claim — it never verifies it (pinned go-oidc v3.13.0, oidc/verify.go:253), so this conditional is the entire nonce check. A token response without a nonce is accepted, defeating the replay/injection binding the nonce exists for. The sealed state cookie (:137-145) still blocks CSRF and code-swapping, so this is defence-in-depth rather than a direct bypass. Fix: fail when idToken.Nonce == "".

C3. No PKCE on the authorization-code flow.
auth/oidc.go:93-96 builds AuthCodeURL(nonce, oidc.Nonce(nonce), oauth2.AccessTypeOnline) and :150 exchanges with no verifier (oauth2.S256ChallengeOption/VerifierOption are absent). For a confidential client whose secret never leaves the server the exposure is bounded, but current OAuth security guidance (RFC 9700 §2.1.1, OAuth 2.1) requires PKCE for browser-based code flows, and it is a few lines given the state cookie already exists. Not a blocker.

C4. Session role state is frozen at login; the 403 page tells the operator otherwise.
web/src/routes/unauthorized/+page.svelte:64-67 says "Ask an operator to grant … Revoking the role closes access; it deletes nothing." Actually the roles are copied into the sealed cookie at login (auth/roles.go:62-71, auth/session.go:74-93) and re-read from the cookie only (auth/middleware.go:157), with SessionDuration = 24 * time.Hour (config/config.go:109-111). So revocation takes up to 24h to bite, and a fresh grant does not take effect until the operator signs in again — neither of which the page (or the README) states. This is inherent to R3's stateless-cookie ruling, so the defect is the claim, not the design. Fix: correct the copy and record the revocation latency in the 3.1b verification plan.

C5. Server shutdown does not end SSE streams, so every restart with an open tab burns the full shutdown budget.
cmd/cockpit/main.go:176 gives the hub the signal context, but each stream's subscription is bound to the request context (sse/sse.go:350-351, :370-373), and cmd/cockpit/main.go:234-238 calls httpServer.Shutdown, which does not cancel active request contexts. There is no Hub-level shutdown and Stream selects on nothing process-wide. Measured on the built binary: SIGTERM with one curl -N /api/v1/events attached → shutdown requested; draining then, 15.13 s later, graceful shutdown did not finish: context deadline exceeded (ShutdownTimeout = 15s, config/load.go:53); the same binary with no stream attached shut down in 0.003 s. Against a 30 s terminationGracePeriod that is half the budget spent waiting on connections that will never go idle, on every restart. The other two unsubscribe paths are correct (client disconnect via r.Context(); write error via defer sub.Cancel()). Fix: give the hub a process context that Stream also selects on (or have shutdown cancel the hub ctx), so streams close immediately and drain is bounded by real work.

Suggestions

S1. The cockpit-e2e job writes root-owned files into the mounted checkout, which the sibling job deliberately avoids. .forgejo/workflows/ci.yaml:164 mounts the workspace read-write (-v "$PWD/lab-director/cockpit/web:/web") and runs npm ci + npm run build in it, and :167-168 writes the Go binary into the repo tree (-o /src/cockpit/web/ld-cockpit-e2e) — the latter is not covered by web/.gitignore. Compare the cockpit-test job's own stated rationale at :127 ("READ-ONLY and copied inside, so node_modules never lands root-owned in the workspace") and :133, and the e2e job's step 3, which does mount :ro (:184). On the shared :host runner this can leave artifacts that break the next job. Fix: copy-then-build as step 3 does, and emit the binary outside the checkout.

S2. Two exported auth helpers have no production caller, and one's doc comment is wrong. auth/oidc.go:285-295 (SetSessionCookie, "how the e2e/dev tooling mints a session") and :300-310 (SessionFrom, "The SSE handler uses it at connect time") — the SSE handler actually uses auth.IdentityFrom (server/server.go:173). An unused exported session-minting primitive is a footgun for LD3.2/3.3; delete both or fix the comment.

S3. Dangling doc reference. config/config.go:62 points at "Secrets.Resolved"; no Secrets type exists anywhere in the module (grepped .go files). Either implement it or drop the pointer before 3.1b copies the schema.

S4. COCKPIT_COOKIE_SECURE=false is accepted outside dev mode with only a WARN. config/load.go:85-92 applies it unconditionally and cmd/cockpit/main.go:93-96 warns. Since the deployment is HTTPS-only, refusing it when OIDC_ISSUER is set (where the cookie is a real credential) matches the "no unprotected operator surface by accident" posture the rest of the config enforces.

S5. The image digest is printed but never published. .forgejo/workflows/cockpit-image-build.yaml:375-381 reads the digest back with crane and echos it to the log; the comment calls it "the input to the 3.1b deploy pin", but nothing durable carries it (no tag, no artifact, no PR comment) — a human must copy it from a log. This faithfully mirrors dispatcher-image-build.yaml, so it is house style rather than a defect; still, publishing it (e.g. a v-<sha> tag or a step summary line) would make the 3.1b pin mechanical.

S6. CI Go patch version drifts from the shipped image. .forgejo/workflows/ci.yaml:118,167 use floating golang:1.24 while Dockerfile:62 pins golang:1.24.1-alpine@sha256:…. The comment argues the floor cannot drop below go 1.24.1; using the same pinned tag in both would remove the class of "green in CI, different toolchain in the image".

Territory / slice-boundary check

Passes, mechanically. From the diff the 94 changed paths are exactly lab-director/cockpit/** (91) + .forgejo/workflows/ci.yaml + .forgejo/workflows/cockpit-image-build.yaml + lab-director/.dockerignore; a filter for anything outside the declared IN list returned nothing. git diff --stat origin/epic/LD3 origin/plane/HOMELAB-1681-cockpit-shell -- lab-director/ → 92 files, zero under lab-director/dispatcher/. No prod/**, no HTTPRoute, no ExternalSecret, no CNP. The dispatcher image is unaffected by the new .dockerignore (its context is lab-director/dispatcher; only the cockpit workflow uses CONTEXT: lab-director). ci.yaml is additive-only, proven mechanically: the single hunk is @@ -90,3 +90,104 @@, 0 removed lines, first 92 lines byte-identical.

What the reviewer verified and found CORRECT

  • Auth fail-closed. Empty/missing/nil project-roles → []string{} → 403 (auth/roles.go:39-57, auth/middleware.go:157-163); a claim of the wrong JSON shape aborts the login (auth/oidc.go:171-175). No empty-role config can match "everything" (config/load.go:49,197-202). No "unknown ⇒ allow" path.
  • Session codec. Per-Seal crypto/rand nonce (auth/session.go:176-179); 32-byte key enforced; AAD binds version+key id; every failure returns a wrapped sentinel and no decrypt path returns success on garbage. Rotation correct (Keys[0] seals, all keys open). Cookie HttpOnly; Secure; SameSite=Lax; Path=/, Secure defaults true.
  • OIDC flow. state compared constant-time against the sealed, single-use cookie; no open redirect; signature/issuer/audience/expiry validated by go-oidc v3.13.0.
  • Routes. Only GET /api/v1/health, /api/v1/version, and the four /auth/* routes are public; /api/v1/readmodel, /api/v1/events and the SPA catch-all sit behind RequireOperator; no unauthenticated reachable route found that should not be.
  • SSE. Bounded buffer of 3, drop-not-block, no unbounded queue; sync.Once close makes publish-vs-cancel/drop safe; no goroutine leak; no replay; one render per tick shared by all clients. The concurrency test does exercise the race under -race.
  • Config refusal. Faults collected, sorted, returned as one error; Load never returns a partial config. No secret value reaches a message or log.
  • Supply chain. go.mod pins all four direct deps; replace … => ../dispatcher correct; go mod verify clean and go mod tidy a no-op. package-lock.json lockfileVersion 3, 247 packages, zero resolved entries lacking integrity, no ^/~; .npmrc only the documented legacy-peer-deps=true. OSV query on the pinned frontend stack returned no advisories (point-in-time).
  • Secrets. Whole-diff scan found no committed credential; only test fixtures (auth/auth_test.go:945, config/config_test.go:3314) and the ${{ secrets.HARBOR_PUSH_* }} references scoped to the non-PR push step; latest moves only on refs/heads/live. Docker base images digest-pinned; runtime distroless nonroot.
  • Builds and runs. go vet ./... clean and all Go packages' tests pass; 9 vitest files / 62 tests pass; a freshly built binary in dev mode served health, streamed hello/readmodel/heartbeat with monotonic ids. No external origins, no console.log.
  • Not verified: the live OIDC round trip against Zitadel (no IdP available); Forgejo's actual rendering/execution of the two workflows (read, not run); OSV is point-in-time.
## Review verdict 1/2 — correctness & security (fresh context) **Verdict: `BLOCKERS: 1 | CONCERNS: 5 | SUGGESTIONS: 6`** — for head `b688ecf3990f5486412eeecd9c193e190a4dd0ff`. Independent fresh-context reviewer (not the implementer), primary sources only: the PR diff, the branch tree read-only, the design brief R1–R12, and acceptance criteria HOMELAB-1681. The reviewer additionally built the SPA and ran an independent headless-Chromium reproduction of B1. > **Landing status: BLOCKED.** One blocker is open; this PR will not be merged until it is fixed (see the landing worker's note below this comment). --- ## Blockers **B1. The production (non-dev) CSP blocks the SPA's inline bootstrap script, so the authenticated cockpit renders a blank page.** - Where: `lab-director/cockpit/server/middleware.go:110-135` builds the CSP. The **non-dev** branch (lines 111-122) emits `"script-src 'self'"` (line 120) — no `'unsafe-inline'`, no nonce, no hash. The dev branch (lines 124-134) emits `"script-src 'self' 'unsafe-inline' 'unsafe-eval'"` (line 132). The mode is chosen at `server/server.go:140` (`SecurityHeaders(s.opts.Dev)`), fed from `cmd/cockpit/main.go:166` (`Dev: cfg.Dev()`), which is true only when `OIDC_ISSUER` is empty (`config/config.go:213`). So the strict CSP applies **exactly** in the deployed/authenticated configuration. - What is wrong: the document the Dockerfile ships contains an inline `<script>`. The committed source is `web/src/app.html:18` (`%sveltekit.body%`) with no `kit.csp` configured (`web/svelte.config.js:17-38`), so SvelteKit injects its classic inline bootstrap + service-worker registration into `index.html`. `Dockerfile:53` builds it and `Dockerfile:97-99` stages that build as the embedded SPA. A policy of `script-src 'self'` with no hash/nonce blocks that inline script; the app never calls `kit.start(...)`. - Proof (independent, reproduced twice — once against the build already in the worktree, once against a fresh `npm run build` from the branch source): serving the built `index.html` with the exact production CSP string and loading it in headless Chromium yields `[error] Executing inline script violates the following Content Security Policy directive 'script-src 'self''. Either the 'unsafe-inline' keyword, a hash ('sha256-…'), or a nonce ('nonce-…') is required…` with `appMounted: false`, `bodyText: ""`. With the dev CSP the same bundle mounts (`appMounted: true`, header/panes render). So the code is fine; the *document* is unreachable under the *deployed* policy. - Why it matters: the ticket's acceptance criteria ("login restricted to the operator group; live events stream") cannot be satisfied in the deployed configuration — the operator gets an empty page. It is also a security-relevant divergence: the CSP that is supposed to be the strict one is the only untested one, and it is broken. - Why no test caught it: every test that serves a real document runs dev mode. `web/playwright.config.ts:6-15` and `web/e2e/run-server.sh:5-11,37-44` (dev mode is "implied by leaving OIDC_ISSUER unset"), the image smoke `.forgejo/workflows/cockpit-image-build.yaml:288-298` (`-e COCKPIT_LOG_LEVEL=info`, no issuer → dev), and `server/server_test.go:5-15` (`newTestServer` always `Dev: true`). `TestSecurityHeaders` (`server/server_test.go:359-384`) asserts the *dev* CSP — it even requires `ws:` to be present (lines 379-381). - What would fix it: make the shipped document and the shipped policy agree, and test the pair in the mode that matters. Options: (a) compute the sha256 of the inline script(s) in the served `index.html` at startup (`server/static.go:68` already holds the bytes; `writeIndex` at 257-275 is the one write site) and append `'sha256-…'` to `script-src`, or (b) `kit.csp` nonce mode in `web/svelte.config.js` + a per-response nonce injected into both the header and `index.html`, or (c) fall back to `'unsafe-inline'` on `script-src` (works, but gives up the XSS hardening). Whichever is chosen, add a check that the real build boots under the non-dev CSP; a Go test that asserts the header's hashes cover the embedded `index.html`'s inline scripts is enough and needs no browser. ## Concerns **C1. Dev mode is reachable by *absence*, and its one warning can be silenced by configuration.** `config/load.go:140-160` refuses the ambiguous case (any of `OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET`/`OIDC_REDIRECT_URL` set while the issuer is empty → `FaultIncompleteOIDC`/`ErrUnprotected`). But an *entirely empty* OIDC environment is legal dev mode: every endpoint unprotected, and any caller picks its own identity via `X-Cockpit-Dev-User`, always granted the operator role (`auth/middleware.go:167-182`, `Roles: []string{h.cfg.OperatorRole}`). The only guard is the WARN at `cmd/cockpit/main.go:82-88` — and `newLogger` (`cmd/cockpit/main.go:302-311`) sets the level from `COCKPIT_LOG_LEVEL`, so `COCKPIT_LOG_LEVEL=error` suppresses it. A Deployment whose four OIDC env keys are typo'd (or whose optional Secrets never populate) therefore starts an unprotected operator surface whose page *celebrates* being unprotected (`web/src/lib/components/shell/app-header.svelte:50-57`, `web/src/routes/login/+page.svelte:51-59`), possibly with no startup warning. R3 rules "issuer empty → dev mode", so this is not a blocker, but it is the weakest link in the credential-free posture 3.1b inherits. Fix: require a positive opt-in for dev mode (e.g. `COCKPIT_DEV=1`) and/or emit the dev warning to stderr unconditionally rather than through the levelled logger. **C2. The ID-token nonce is verified only when the IdP happens to return one.** `auth/oidc.go:167-170`: `if idToken.Nonce != "" && stateEqual(idToken.Nonce, state.Nonce) != 1 { … }`. The server always sends a nonce (`auth/oidc.go:78`, `:93-96`), and go-oidc only surfaces the claim — it never verifies it (pinned `go-oidc v3.13.0`, `oidc/verify.go:253`), so this conditional is the *entire* nonce check. A token response without a nonce is accepted, defeating the replay/injection binding the nonce exists for. The sealed state cookie (`:137-145`) still blocks CSRF and code-swapping, so this is defence-in-depth rather than a direct bypass. Fix: fail when `idToken.Nonce == ""`. **C3. No PKCE on the authorization-code flow.** `auth/oidc.go:93-96` builds `AuthCodeURL(nonce, oidc.Nonce(nonce), oauth2.AccessTypeOnline)` and `:150` exchanges with no verifier (`oauth2.S256ChallengeOption`/`VerifierOption` are absent). For a confidential client whose secret never leaves the server the exposure is bounded, but current OAuth security guidance (RFC 9700 §2.1.1, OAuth 2.1) requires PKCE for browser-based code flows, and it is a few lines given the state cookie already exists. Not a blocker. **C4. Session role state is frozen at login; the 403 page tells the operator otherwise.** `web/src/routes/unauthorized/+page.svelte:64-67` says "Ask an operator to grant … **Revoking the role closes access**; it deletes nothing." Actually the roles are copied into the sealed cookie at login (`auth/roles.go:62-71`, `auth/session.go:74-93`) and re-read from the cookie only (`auth/middleware.go:157`), with `SessionDuration = 24 * time.Hour` (`config/config.go:109-111`). So revocation takes up to 24h to bite, and a fresh grant does not take effect until the operator signs in again — neither of which the page (or the README) states. This is inherent to R3's stateless-cookie ruling, so the defect is the claim, not the design. Fix: correct the copy and record the revocation latency in the 3.1b verification plan. **C5. Server shutdown does not end SSE streams, so every restart with an open tab burns the full shutdown budget.** `cmd/cockpit/main.go:176` gives the hub the signal context, but each stream's subscription is bound to the *request* context (`sse/sse.go:350-351`, `:370-373`), and `cmd/cockpit/main.go:234-238` calls `httpServer.Shutdown`, which does not cancel active request contexts. There is no `Hub`-level shutdown and `Stream` selects on nothing process-wide. Measured on the built binary: SIGTERM with one `curl -N /api/v1/events` attached → `shutdown requested; draining` then, 15.13 s later, `graceful shutdown did not finish: context deadline exceeded` (`ShutdownTimeout = 15s`, `config/load.go:53`); the same binary with no stream attached shut down in 0.003 s. Against a 30 s `terminationGracePeriod` that is half the budget spent waiting on connections that will never go idle, on every restart. The other two unsubscribe paths are correct (client disconnect via `r.Context()`; write error via `defer sub.Cancel()`). Fix: give the hub a process context that `Stream` also selects on (or have shutdown cancel the hub ctx), so streams close immediately and drain is bounded by real work. ## Suggestions **S1. The `cockpit-e2e` job writes root-owned files into the mounted checkout, which the sibling job deliberately avoids.** `.forgejo/workflows/ci.yaml:164` mounts the workspace read-write (`-v "$PWD/lab-director/cockpit/web:/web"`) and runs `npm ci` + `npm run build` in it, and `:167-168` writes the Go binary into the repo tree (`-o /src/cockpit/web/ld-cockpit-e2e`) — the latter is not covered by `web/.gitignore`. Compare the cockpit-test job's own stated rationale at `:127` ("READ-ONLY and copied inside, so node_modules never lands root-owned in the workspace") and `:133`, and the e2e job's step 3, which does mount `:ro` (`:184`). On the shared `:host` runner this can leave artifacts that break the next job. Fix: copy-then-build as step 3 does, and emit the binary outside the checkout. **S2. Two exported auth helpers have no production caller, and one's doc comment is wrong.** `auth/oidc.go:285-295` (`SetSessionCookie`, "how the e2e/dev tooling mints a session") and `:300-310` (`SessionFrom`, "The SSE handler uses it at connect time") — the SSE handler actually uses `auth.IdentityFrom` (`server/server.go:173`). An unused exported session-minting primitive is a footgun for LD3.2/3.3; delete both or fix the comment. **S3. Dangling doc reference.** `config/config.go:62` points at "`Secrets.Resolved`"; no `Secrets` type exists anywhere in the module (grepped `.go` files). Either implement it or drop the pointer before 3.1b copies the schema. **S4. `COCKPIT_COOKIE_SECURE=false` is accepted outside dev mode with only a WARN.** `config/load.go:85-92` applies it unconditionally and `cmd/cockpit/main.go:93-96` warns. Since the deployment is HTTPS-only, refusing it when `OIDC_ISSUER` is set (where the cookie is a real credential) matches the "no unprotected operator surface by accident" posture the rest of the config enforces. **S5. The image digest is printed but never published.** `.forgejo/workflows/cockpit-image-build.yaml:375-381` reads the digest back with `crane` and `echo`s it to the log; the comment calls it "the input to the 3.1b deploy pin", but nothing durable carries it (no tag, no artifact, no PR comment) — a human must copy it from a log. This faithfully mirrors `dispatcher-image-build.yaml`, so it is house style rather than a defect; still, publishing it (e.g. a `v-<sha>` tag or a step summary line) would make the 3.1b pin mechanical. **S6. CI Go patch version drifts from the shipped image.** `.forgejo/workflows/ci.yaml:118,167` use floating `golang:1.24` while `Dockerfile:62` pins `golang:1.24.1-alpine@sha256:…`. The comment argues the floor cannot drop below `go 1.24.1`; using the same pinned tag in both would remove the class of "green in CI, different toolchain in the image". ## Territory / slice-boundary check **Passes, mechanically.** From the diff the 94 changed paths are exactly `lab-director/cockpit/**` (91) + `.forgejo/workflows/ci.yaml` + `.forgejo/workflows/cockpit-image-build.yaml` + `lab-director/.dockerignore`; a filter for anything outside the declared IN list returned **nothing**. `git diff --stat origin/epic/LD3 origin/plane/HOMELAB-1681-cockpit-shell -- lab-director/` → 92 files, **zero** under `lab-director/dispatcher/`. No `prod/**`, no HTTPRoute, no ExternalSecret, no CNP. The dispatcher image is unaffected by the new `.dockerignore` (its context is `lab-director/dispatcher`; only the cockpit workflow uses `CONTEXT: lab-director`). **ci.yaml is additive-only**, proven mechanically: the single hunk is `@@ -90,3 +90,104 @@`, **0** removed lines, first 92 lines byte-identical. ## What the reviewer verified and found CORRECT - **Auth fail-closed.** Empty/missing/nil project-roles → `[]string{}` → 403 (`auth/roles.go:39-57`, `auth/middleware.go:157-163`); a claim of the wrong JSON shape aborts the login (`auth/oidc.go:171-175`). No empty-role config can match "everything" (`config/load.go:49,197-202`). No "unknown ⇒ allow" path. - **Session codec.** Per-`Seal` `crypto/rand` nonce (`auth/session.go:176-179`); 32-byte key enforced; AAD binds version+key id; every failure returns a wrapped sentinel and **no** decrypt path returns success on garbage. Rotation correct (`Keys[0]` seals, all keys open). Cookie `HttpOnly; Secure; SameSite=Lax; Path=/`, Secure defaults true. - **OIDC flow.** `state` compared constant-time against the sealed, single-use cookie; no open redirect; signature/issuer/audience/expiry validated by go-oidc v3.13.0. - **Routes.** Only `GET /api/v1/health`, `/api/v1/version`, and the four `/auth/*` routes are public; `/api/v1/readmodel`, `/api/v1/events` and the SPA catch-all sit behind `RequireOperator`; no unauthenticated reachable route found that should not be. - **SSE.** Bounded buffer of 3, drop-not-block, no unbounded queue; `sync.Once` close makes publish-vs-cancel/drop safe; no goroutine leak; no replay; one render per tick shared by all clients. The concurrency test does exercise the race under `-race`. - **Config refusal.** Faults collected, sorted, returned as one error; `Load` never returns a partial config. No secret value reaches a message or log. - **Supply chain.** `go.mod` pins all four direct deps; `replace … => ../dispatcher` correct; `go mod verify` clean and `go mod tidy` a no-op. `package-lock.json` lockfileVersion 3, 247 packages, zero resolved entries lacking `integrity`, no `^`/`~`; `.npmrc` only the documented `legacy-peer-deps=true`. OSV query on the pinned frontend stack returned no advisories (point-in-time). - **Secrets.** Whole-diff scan found no committed credential; only test fixtures (`auth/auth_test.go:945`, `config/config_test.go:3314`) and the `${{ secrets.HARBOR_PUSH_* }}` references scoped to the non-PR push step; `latest` moves only on `refs/heads/live`. Docker base images digest-pinned; runtime distroless nonroot. - **Builds and runs.** `go vet ./...` clean and all Go packages' tests pass; 9 vitest files / 62 tests pass; a freshly built binary in dev mode served health, streamed `hello`/`readmodel`/`heartbeat` with monotonic ids. No external origins, no `console.log`. - **Not verified:** the live OIDC round trip against Zitadel (no IdP available); Forgejo's actual rendering/execution of the two workflows (read, not run); OSV is point-in-time.
Author
Owner

Review verdict 2/2 — test adequacy (fresh context)

Verdict: BLOCKERS: 0 | CONCERNS: 4 | SUGGESTIONS: 5 — for head b688ecf3990f5486412eeecd9c193e190a4dd0ff.

Independent fresh-context reviewer (not the implementer). Findings below are mutation-verified where they claim load-bearingness: the reviewer rebuilt the binary + a fresh npm run build and re-ran the suites, and injected deliberate regressions to confirm tests fail.

Claims checked

Claim (from PR body/AC) Proven? Evidence (file:line)
e2e is committed, runnable, wired into CI (R11) Yes web/package.json:18 ("e2e": "playwright test"), :25; .forgejo/workflows/ci.yaml:153-193 job cockpit-e2e; executed CI run 1671 job 5 cockpit-e2e success 3m39s on head b688ecf. Independently reproduced: binary from the branch + fresh build → 17 passed (9.5s)
e2e cannot silently no-op Yes playwright.config.ts:26-27 (testDir: 'e2e', testMatch: '**/*.spec.ts') matches the two committed specs; --list → "Total: 17 tests in 2 files"; :30 forbidOnly under CI; webServer starts the real binary (:57-71, e2e/run-server.sh:26-44); no --passWithNoTests anywhere
Specs are type-checked Yes tsconfig.json:18-19 includes e2e/**/*.ts + playwright.config.ts; ci.yaml:140 runs svelte-check. Injected const deliberateTypeError: number = "not a number" into e2e/shell.spec.ts → 0 errors → 1 error (checked, not skipped)
Defect (a) fixed + regression test that would have failed Yes Fix: src/lib/api/client.ts:89 (request), :94 (requestRoot), :103 (requestRoot<MeResponse>('/auth/me')). Regression: client.test.ts:65-72. Mutation (restoring pre-fix request<MeResponse>('/auth/me')) → that test fails: expected '/api/auth/me' to be '/auth/me'
(a) "caught by the e2e suite" Not verified (provenance) The earliest web commit 4229ade already contains requestRoot + the bridge, so the pre-fix state is not in the branch history. Provenance unprovable; the regression artefacts' load-bearingness is proven by mutation
Defect (b) fixed + e2e asserts uptime really ticks Yes Fix: src/routes/+layout.svelte:62-64 (events.on('readmodel', …) → readmodel.apply(...)); assertion: e2e/shell.spec.ts:53-69 (non-— and text changes within 10s) against a 1s tick. Mutation (deleting the bridge, rebuilding) → exactly that spec fails (10.6s) while "the connection dot says so" (shell.spec.ts:43-51) still passes — i.e. it asserts a rendered value advancing, not a store write and not a mock call
AC: live events stream Yes shell.spec.ts:43-51 + :53-69; Go sse/sse_test.go:184-245, server/server_test.go:170-225; image smoke asserts hello+heartbeat+readmodel on the wire (CI run 1672 green)
AC: login restricted to the operator group Partly Denial logic proven and load-bearing: auth/auth_test.go:314-346 (401), :348-419 (403 + JSON + document), :421-443 (admit). Mutation (if !id.HasRole(...) → if false) → TestRequireOperatorForbidden fails. But no test drives a non-operator through the assembled route table — see C1
OIDC round trip not e2e-covered — disclosed, not hidden Yes (disclosed) e2e/auth.spec.ts:5-19 states what is real, what is not, and where the server side is asserted. Caveat: the Go suite never exercises the callback success path either — see C2
PR-body test counts ("73", "62 tests / 9 files", "17/17") Yes Go top-level = 73 tests (go test -race -count=1 ./... → 5 pkgs ok); vitest 62 passed / 9 files; playwright 17 passed + CI green

Blockers

None. Both claimed defect fixes carry regression coverage the reviewer proved load-bearing by mutation; the e2e suite executes for real in CI (run 1671 job 5) and locally; the AC's denial semantics are covered by a test that fails when the check is removed.

Concerns

  1. Authorization is never asserted through the assembled server route table (server/server_test.go, server/server.go:128-134). The only test server is dev mode (server_test.go:53-98), where RequireOperator admits everything (auth/middleware.go:167-182 gives every dev identity the operator role), and server_test.go has no 401/403 assertion at all. The e2e cannot close this (dev mode only; no role-less session spec). Mitigations verified: removing RequireOperator from server.go:134 is caught (server_test.go:156/:216 fail), and the role check is caught in the auth package. Residual gap: a role bypass between the route and the middleware, and zero coverage of the 401/403 wire contract for /api/v1/readmodel, /api/v1/events and the SPA catch-all. Structural cause: auth.NewHandler always performs discovery when the issuer is set (auth/middleware.go:90-101), so the server package cannot build a non-dev handler in a test today. Fix: an exported non-discovery constructor (or injectable provider) + two assertions through Server.Handler().
  2. The real login path has no test on either side of the chain (auth/oidc.go:73-98, :149-203). Grep across all _test.go: identityFromClaims, sessionCookie, SetSessionCookie, randomToken, ztClaims are referenced by no test; auth_test.go covers only callback refusals (:522-554) and dev-mode login (:512-520). The headline capability — code exchange → ID-token verification → role from the roles claim → sealed cookie → 302 — is proven only at its two ends. A wrong cookie attribute (oidc.go:261-271 untested), a wrong claim struct tag, or a broken post-verify branch would surface only in the operator-gated 3.1b deploy. The e2e gap is disclosed honestly (auth.spec.ts:11-18); the Go-side gap is not. An httptest fake IdP would cover most of it.
  3. The slow_consumer wire contract is asserted on the client only. Server emits event: error {code:"slow_consumer"} at sse/sse.go:379-382, but no Go test drives that branch; the client's test fabricates the payload (events.svelte.test.ts:157-162). That test passes even if the server stopped emitting it — a cross-boundary contract pinned on one side.
  4. The collected-faults test proves the loader refuses, not that the process refuses. config_test.go:118-121 + :127-142 pin the refusal and all 6 faults at the loader. But cmd/cockpit has no test file, so main.go:69-76 (run returns 2 on a faulted env) is never exercised, and the image smoke only asserts --check exits 0 on a good env — nothing asserts a non-zero exit on a bad one. Cheap fix: a main-package test calling run with a faulted env.

Suggestions

  1. sse/sse_test.go:318-347 has no assertion — a race-detector fixture, honestly labelled, but a plain go test passes it trivially; it drives Hub.Subscribe/Publish directly, so the handler's drop-frame path (sse.go:374-383) is neither raced nor asserted.
  2. The production buffer depth is never exercised: the drop test sets 1 via a test-only hook. DefaultBuffer = 3 (sse.go:62-65) is asserted nowhere.
  3. readmodel census: configured/unwired honestly asserted (readmodel_test.go:57-103), but error has no producer in non-test code and unknown is an unasserted fixture. Honest for 3.1a (nothing reads yet); the readmodel.go:14-20 doc advertises a closed four-state contract nothing pins.
  4. events.svelte.test.ts:76 asserts an argument passed to a mock (weakest kind); the URL contract is already pinned at client.test.ts:74-77.
  5. +layout.svelte:52-55 (authenticated-but-not-operator → /unauthorized) has no test and is unreachable in dev mode. Low risk (the server 403s the document first), but an untested branch in the file that owns the AC's client half.

e2e wired-into-CI: yes

  • Job: .forgejo/workflows/ci.yaml:153-193 — cockpit-e2e, no if:, no needs: gating; workflow triggers include pull_request: branches: [live, 'epic/**'] (ci.yaml:3-7), which base epic/LD3 satisfies.
  • Invocation: ci.yaml:192 — xvfb-run -a npx playwright test (CI=true, E2E_HEADED=1, E2E_PREBUILT=1, E2E_COCKPIT_BIN=/tmp/work/ld-cockpit-e2e).
  • npm run e2e exists (package.json:18) and @playwright/test is in devDependencies (:25) — neither was true of the legacy app's dead specs.
  • It ran: commit status for head b688ecf → cockpit-e2e success — 3m39s (runs/1671/jobs/5), alongside cockpit-test success 1m34s.
  • No-op checks clean: --list → 17 tests in 2 files; no --passWithNoTests, no test.skip/.only/xit/fit/t.Skip in the PR tree; forbidOnly under CI; specs and run-server.sh are tracked; the prebuilt binary keeps mode 755 through the CI cp -r, and run-server.sh:26-30 exits non-zero rather than silently continuing if it is not executable.
## Review verdict 2/2 — test adequacy (fresh context) **Verdict: `BLOCKERS: 0 | CONCERNS: 4 | SUGGESTIONS: 5`** — for head `b688ecf3990f5486412eeecd9c193e190a4dd0ff`. Independent fresh-context reviewer (not the implementer). Findings below are mutation-verified where they claim load-bearingness: the reviewer rebuilt the binary + a fresh `npm run build` and re-ran the suites, and injected deliberate regressions to confirm tests fail. ## Claims checked | Claim (from PR body/AC) | Proven? | Evidence (file:line) | |---|---|---| | e2e is committed, runnable, wired into CI (R11) | **Yes** | `web/package.json:18` (`"e2e": "playwright test"`), `:25`; `.forgejo/workflows/ci.yaml:153-193` job `cockpit-e2e`; **executed** CI run 1671 job 5 `cockpit-e2e success 3m39s` on head `b688ecf`. Independently reproduced: binary from the branch + fresh build → **17 passed (9.5s)** | | e2e cannot silently no-op | **Yes** | `playwright.config.ts:26-27` (`testDir: 'e2e'`, `testMatch: '**/*.spec.ts'`) matches the two committed specs; `--list` → "Total: 17 tests in 2 files"; `:30` `forbidOnly` under CI; `webServer` starts the real binary (`:57-71`, `e2e/run-server.sh:26-44`); no `--passWithNoTests` anywhere | | Specs are type-checked | **Yes** | `tsconfig.json:18-19` includes `e2e/**/*.ts` + `playwright.config.ts`; `ci.yaml:140` runs `svelte-check`. Injected `const deliberateTypeError: number = "not a number"` into `e2e/shell.spec.ts` → 0 errors → **1 error** (checked, not skipped) | | Defect (a) fixed + regression test that would have failed | **Yes** | Fix: `src/lib/api/client.ts:89` (`request`), `:94` (`requestRoot`), `:103` (`requestRoot<MeResponse>('/auth/me')`). Regression: `client.test.ts:65-72`. Mutation (restoring pre-fix `request<MeResponse>('/auth/me')`) → **that test fails**: `expected '/api/auth/me' to be '/auth/me'` | | (a) "caught by the e2e suite" | **Not verified (provenance)** | The earliest web commit `4229ade` already contains `requestRoot` + the bridge, so the pre-fix state is not in the branch history. Provenance unprovable; the regression artefacts' load-bearingness is proven by mutation | | Defect (b) fixed + e2e asserts uptime really ticks | **Yes** | Fix: `src/routes/+layout.svelte:62-64` (`events.on('readmodel', …) → readmodel.apply(...)`); assertion: `e2e/shell.spec.ts:53-69` (non-`—` **and** text changes within 10s) against a 1s tick. Mutation (deleting the bridge, rebuilding) → **exactly that spec fails (10.6s)** while "the connection dot says so" (`shell.spec.ts:43-51`) still passes — i.e. it asserts a rendered value advancing, not a store write and not a mock call | | AC: live events stream | **Yes** | `shell.spec.ts:43-51` + `:53-69`; Go `sse/sse_test.go:184-245`, `server/server_test.go:170-225`; image smoke asserts `hello`+`heartbeat`+`readmodel` on the wire (CI run 1672 green) | | AC: login restricted to the operator group | **Partly** | Denial logic proven *and* load-bearing: `auth/auth_test.go:314-346` (401), `:348-419` (403 + JSON + document), `:421-443` (admit). Mutation (`if !id.HasRole(...)` → `if false`) → `TestRequireOperatorForbidden` fails. But no test drives a **non-operator through the assembled route table** — see C1 | | OIDC round trip not e2e-covered — disclosed, not hidden | **Yes (disclosed)** | `e2e/auth.spec.ts:5-19` states what is real, what is not, and where the server side *is* asserted. Caveat: the Go suite never exercises the callback **success** path either — see C2 | | PR-body test counts ("73", "62 tests / 9 files", "17/17") | **Yes** | Go top-level = 73 tests (`go test -race -count=1 ./...` → 5 pkgs ok); vitest **62 passed / 9 files**; playwright **17 passed** + CI green | ## Blockers **None.** Both claimed defect fixes carry regression coverage the reviewer proved load-bearing by mutation; the e2e suite executes for real in CI (run 1671 job 5) and locally; the AC's denial semantics are covered by a test that fails when the check is removed. ## Concerns 1. **Authorization is never asserted through the assembled server route table** (`server/server_test.go`, `server/server.go:128-134`). The only test server is dev mode (`server_test.go:53-98`), where `RequireOperator` admits everything (`auth/middleware.go:167-182` gives every dev identity the operator role), and `server_test.go` has no 401/403 assertion at all. The e2e cannot close this (dev mode only; no role-less session spec). Mitigations verified: removing `RequireOperator` from `server.go:134` *is* caught (`server_test.go:156`/`:216` fail), and the role check is caught in the auth package. Residual gap: a role bypass between the route and the middleware, and zero coverage of the 401/403 wire contract for `/api/v1/readmodel`, `/api/v1/events` and the SPA catch-all. Structural cause: `auth.NewHandler` always performs discovery when the issuer is set (`auth/middleware.go:90-101`), so the server package cannot build a non-dev handler in a test today. Fix: an exported non-discovery constructor (or injectable provider) + two assertions through `Server.Handler()`. 2. **The real login path has no test on either side of the chain** (`auth/oidc.go:73-98`, `:149-203`). Grep across all `_test.go`: `identityFromClaims`, `sessionCookie`, `SetSessionCookie`, `randomToken`, `ztClaims` are referenced by **no test**; `auth_test.go` covers only callback *refusals* (`:522-554`) and dev-mode login (`:512-520`). The headline capability — code exchange → ID-token verification → role from the roles claim → sealed cookie → 302 — is proven only at its two ends. A wrong cookie attribute (`oidc.go:261-271` untested), a wrong claim struct tag, or a broken post-verify branch would surface only in the operator-gated 3.1b deploy. The *e2e* gap is disclosed honestly (`auth.spec.ts:11-18`); the Go-side gap is not. An `httptest` fake IdP would cover most of it. 3. **The `slow_consumer` wire contract is asserted on the client only.** Server emits `event: error {code:"slow_consumer"}` at `sse/sse.go:379-382`, but no Go test drives that branch; the client's test fabricates the payload (`events.svelte.test.ts:157-162`). That test passes even if the server stopped emitting it — a cross-boundary contract pinned on one side. 4. **The collected-faults test proves the loader refuses, not that the process refuses.** `config_test.go:118-121` + `:127-142` pin the refusal and all 6 faults at the loader. But `cmd/cockpit` has **no test file**, so `main.go:69-76` (`run` returns 2 on a faulted env) is never exercised, and the image smoke only asserts `--check` exits 0 on a good env — nothing asserts a non-zero exit on a bad one. Cheap fix: a `main`-package test calling `run` with a faulted env. ## Suggestions 1. `sse/sse_test.go:318-347` has **no assertion** — a race-detector fixture, honestly labelled, but a plain `go test` passes it trivially; it drives `Hub.Subscribe/Publish` directly, so the handler's drop-frame path (`sse.go:374-383`) is neither raced nor asserted. 2. The production buffer depth is never exercised: the drop test sets `1` via a test-only hook. `DefaultBuffer = 3` (`sse.go:62-65`) is asserted nowhere. 3. `readmodel` census: `configured`/`unwired` honestly asserted (`readmodel_test.go:57-103`), but `error` has **no producer** in non-test code and `unknown` is an unasserted fixture. Honest for 3.1a (nothing reads yet); the `readmodel.go:14-20` doc advertises a closed four-state contract nothing pins. 4. `events.svelte.test.ts:76` asserts an argument passed to a mock (weakest kind); the URL contract is already pinned at `client.test.ts:74-77`. 5. `+layout.svelte:52-55` (authenticated-but-not-operator → `/unauthorized`) has no test and is unreachable in dev mode. Low risk (the server 403s the document first), but an untested branch in the file that owns the AC's client half. ## e2e wired-into-CI: yes - Job: `.forgejo/workflows/ci.yaml:153-193` — `cockpit-e2e`, no `if:`, no `needs:` gating; workflow triggers include `pull_request: branches: [live, 'epic/**']` (`ci.yaml:3-7`), which base `epic/LD3` satisfies. - Invocation: `ci.yaml:192` — `xvfb-run -a npx playwright test` (`CI=true`, `E2E_HEADED=1`, `E2E_PREBUILT=1`, `E2E_COCKPIT_BIN=/tmp/work/ld-cockpit-e2e`). - `npm run e2e` exists (`package.json:18`) and `@playwright/test` is in devDependencies (`:25`) — neither was true of the legacy app's dead specs. - **It ran**: commit status for head `b688ecf` → `cockpit-e2e success — 3m39s` (runs/1671/jobs/5), alongside `cockpit-test success 1m34s`. - No-op checks clean: `--list` → 17 tests in 2 files; no `--passWithNoTests`, no `test.skip`/`.only`/`xit`/`fit`/`t.Skip` in the PR tree; `forbidOnly` under CI; specs and `run-server.sh` are tracked; the prebuilt binary keeps mode 755 through the CI `cp -r`, and `run-server.sh:26-30` exits non-zero rather than silently continuing if it is not executable.
Author
Owner

Landing worker — LANDING BLOCKED on B1 (no merge)

Head b688ecf3990f5486412eeecd9c193e190a4dd0ff. Both independent fresh-context reviews are posted above (correctness/security: 1 blocker / 5 concerns / 6 suggestions; test adequacy: 0 blockers / 4 concerns / 5 suggestions). CI on this head is green (run #1671 all jobs incl. cockpit-e2e 3m39s; run #1672 build-and-push pass).

Why no merge: B1 is a real, reproduced defect, independently confirmed by the landing worker.

  • Source facts: server/middleware.go non-dev branch emits script-src 'self' (no 'unsafe-inline', no nonce, no hash); web/svelte.config.js sets no kit.csp; web/src/app.html has no nonce placeholder.
  • Build artefact fact (landing worker, fresh npm ci + npm run build from the branch at b688ecf, scratch worktree): the produced build/index.html contains an inline <script> (the SvelteKit bootstrap: kit.start(app, element) + service-worker registration), with no src and no nonce.
  • Consequence: under the deployed (non-dev) CSP that inline script is blocked, kit.start never runs, and the authenticated cockpit renders a blank page — the ticket's acceptance criteria cannot be met in the deployed configuration.
  • No test catches it because every test that serves a real document runs dev mode (playwright.config.ts, e2e/run-server.sh, the image smoke, and server_test.go's newTestServer all set dev/unset OIDC_ISSUER), and TestSecurityHeaders asserts only the dev CSP.

Class-D visual check was deliberately not run — the landing pipeline halts at the blocker per the landing contract. The R11 visual-verifier pass remains owed and should be run on the fix commit (the fix is CSP-only and does not change the rendered UI, so dev-mode visual evidence gathered after the fix stays valid).

Path to land: a fix worker (fresh context) addresses B1 — make the shipped document and the shipped policy agree (hash-in-CSP computed from the embedded index.html, a kit.csp nonce, or an explicit documented 'unsafe-inline' fallback) and add a test that boots the real build under the non-dev CSP. Concerns C1–C5 (dev-mode opt-in + unconditional warning; nonce fail-closed; PKCE; revocation-latency copy; SSE shutdown) are posted for the fix worker's judgement; only B1 gates the merge. Then re-review the delta + run the class-D visual check + merge on green.

No CI run was triggered by this comment; the head is unchanged.

## Landing worker — LANDING BLOCKED on B1 (no merge) Head `b688ecf3990f5486412eeecd9c193e190a4dd0ff`. Both independent fresh-context reviews are posted above (correctness/security: **1 blocker / 5 concerns / 6 suggestions**; test adequacy: **0 blockers / 4 concerns / 5 suggestions**). CI on this head is **green** (run #1671 all jobs incl. `cockpit-e2e` 3m39s; run #1672 `build-and-push` pass). **Why no merge: B1 is a real, reproduced defect, independently confirmed by the landing worker.** - Source facts: `server/middleware.go` non-dev branch emits `script-src 'self'` (no `'unsafe-inline'`, no nonce, no hash); `web/svelte.config.js` sets **no** `kit.csp`; `web/src/app.html` has no nonce placeholder. - Build artefact fact (landing worker, fresh `npm ci` + `npm run build` from the branch at `b688ecf`, scratch worktree): the produced `build/index.html` contains an **inline `<script>`** (the SvelteKit bootstrap: `kit.start(app, element)` + service-worker registration), with no `src` and no nonce. - Consequence: under the deployed (non-dev) CSP that inline script is blocked, `kit.start` never runs, and the authenticated cockpit renders a **blank page** — the ticket's acceptance criteria cannot be met in the deployed configuration. - No test catches it because every test that serves a real document runs dev mode (`playwright.config.ts`, `e2e/run-server.sh`, the image smoke, and `server_test.go`'s `newTestServer` all set dev/unset `OIDC_ISSUER`), and `TestSecurityHeaders` asserts only the dev CSP. **Class-D visual check was deliberately not run** — the landing pipeline halts at the blocker per the landing contract. The R11 visual-verifier pass remains owed and should be run on the fix commit (the fix is CSP-only and does not change the rendered UI, so dev-mode visual evidence gathered after the fix stays valid). **Path to land:** a fix worker (fresh context) addresses B1 — make the shipped document and the shipped policy agree (hash-in-CSP computed from the embedded `index.html`, a `kit.csp` nonce, or an explicit documented `'unsafe-inline'` fallback) and add a test that boots the real build under the non-dev CSP. Concerns C1–C5 (dev-mode opt-in + unconditional warning; nonce fail-closed; PKCE; revocation-latency copy; SSE shutdown) are posted for the fix worker's judgement; only B1 gates the merge. Then re-review the delta + run the class-D visual check + merge on green. No CI run was triggered by this comment; the head is unchanged.
HOMELAB-1681: fix(cockpit): cover inline SPA bootstrap with CSP sha256 hashes (review B1)
All checks were successful
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 6s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 4s
CI Review / alert-receiver-test (pull_request) Successful in 21s
CI Review / cockpit-test (pull_request) Successful in 1m59s
CI Review / dispatcher-test (pull_request) Successful in 2m14s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 2m28s
CI Review / cockpit-e2e (pull_request) Successful in 3m39s
2e0d9b8aad
The deployed (non-dev) CSP emitted `script-src 'self'`, which blocks the
inline SvelteKit bootstrap that calls kit.start() and registers the service
worker. The authenticated cockpit therefore rendered a blank page; dev mode
(whose CSP carries 'unsafe-inline') hid the defect because every test served
the document in dev mode.

server/static.go now derives the CSP hash of every inline script from the very
bytes that are served (quote-aware, comment-aware byte scanner; `</script`
ends the content, as the tokenizer rules), and server/middleware.go appends
them to `script-src` in the deployed policy only. 'unsafe-inline' and nonces
stay out.

server/csp_test.go is the regression test that would have caught B1: it runs
with Dev:false, serves the real web/build document when it exists (and the
SvelteKit-shaped shell otherwise), and asserts the served CSP's hashes cover
exactly the served document's inline scripts, count > 0. Verified against the
real build in headless Chromium under the production CSP: bootstrap runs, app
mounts.
Author
Owner

Review verdict 3/3 — DELTA review of the B1 fix (fresh context)

Verdict: BLOCKERS: 0 | CONCERNS: 2 | SUGGESTIONS: 3
B1 closed: yes — for head 2e0d9b8aad90eb6828979c27b5c2fe86d329e0b6 (delta b688ecf399 → 2e0d9b8, 5 files under lab-director/cockpit/server/**).

Strongest single piece of evidence: in headless Chromium, the real served document + the real emitted production CSP executes the inline SvelteKit bootstrap and the app mounts (__sveltekit_… = object, body.innerText = "LD Cockpit … DASHBOARD", 8452 body bytes), while the defect policy script-src 'self' yields undefined / empty body (834 bytes) and Chromium asks for exactly the hash the Go extractor emits (sha256-Hj9EQp+tH5RiA1YN1/29IPBse3J/oZZ+FMk235CGftE=).

Independent fresh-context reviewer (not the implementer, not the earlier reviewer). It verified against the bytes the image actually ships: docker build stages web/build over cmd/cockpit/static/ (Dockerfile:91-92), the binary embeds it (cmd/cockpit/embed.go //go:embed all:static), NewSPAHandler hashes that index.html (static.go:79-81), and the same instance serves it (server.go:133, static.go:466).

Blockers

None. Every reintroduction of B1 the reviewer could construct is caught (see Mutation check).

Concerns

C1. The only witness that the REAL built document is covered is skipped in CI, so the B1 guarantee rests on a hand-written fixture.
TestProductionCSPCoversTheRealBuild (csp_test.go:176-179) t.Skips when ../web/build/index.html is absent. That file is gitignored (web/.gitignore:3), and the required cockpit-test job runs go test -race -count=1 ./... (.forgejo/workflows/ci.yaml:119) on a fresh actions/checkout@v4 (ci.yaml:101) before it builds the frontend (ci.yaml:142). The reviewer reproduced the exact CI condition locally (hid web/build) and observed --- SKIP: TestProductionCSPCoversTheRealBuild. So in CI the only Dev:false document witness is the literal svelteKitShapedShell (csp_test.go:84-113) plus the placeholder. Why it matters: the defect class is "the served document contains an inline script the policy does not cover"; a parser blind spot the fixture happens not to exercise would go green in CI while the deployed shell goes blank — precisely the failure mode this commit fixes. The commit's headless-Chromium claim was reproduced, but no artifact of it is committed, so it is not continuous. Fix: make cockpit-test build the frontend before go test, or commit a real build/index.html as server/testdata/ and point the test at it.

C2. The extractor diverges from the HTML tokenizer in fail-closed, but production-coloured, ways. Pinned by direct probes of inlineScriptHashes:

  • <!-- occurring inside a non-script attribute value aborts the whole scan: <p title="<!--">then</p><script>real();</script> → 0 hashes (static.go:146-152 matches the <!-- inside the attribute, then breaks when no --> follows, so the real script is never reached) → that script would be blocked = a B1-shaped blank page. The shape is one edit of a hand-maintained web/src/app.html away.
  • </scriptx truncates the body (static.go:174 indexFold(doc[contentStart:], "</script") accepts any suffix), whereas the tokenizer does not end the element there → hash mismatch.
  • The script-data double-escape case (<script>\n<!--<script>alert(1)</script>-->\n</script>) hashes a prefix.
    Why it is not a blocker: every one of these drops or mismatches a hash, i.e. the script is blocked (visible), never silently authorized; and none is reachable from the current build — the reviewer re-ran npm run build with src/app.html converted to CRLF and the real built index.html still carries no < in any attribute and no CR in the bootstrap body, and Chromium accepted the emitted hash for it. The test's independent regex (csp_test.go:25-53) does catch the first shape if a fixture contains it; it cannot see the real build in CI (C1).

Suggestions

S1. Mirror the tokenizer's newline preprocessing, or assert its precondition. static.go:178 hashes raw bytes; the HTML tokenizer replaces CR and CRLF with LF before a script's child text exists, so a CR inside a script body makes the emitted hash unusable. Proven in Chromium: for a body a();\r\nb(); Chromium demands sha256-wrEMctykYIJXhAyfCKKphjLeSVuUKhNQ4uVZWiS2cTQ= (normalized) while static.go:178 produces sha256-GkkofoCWtVa7bC5kRRvGFNdkKxD7K5NvuBTRjvkYI3Y= (raw) → blocked. Empirically unreachable today (adapter-static writes the injected bootstrap with LF; git grep -Il $'\r' 2e0d9b8 -- lab-director/cockpit is empty). Cheap guard: reject a \r inside an extracted body at startup rather than ship a hash that cannot match.

S2. InlineScriptHashes() hands out the internal slice (static.go:102). Nothing mutates it today (middleware.go:154 appends into a fresh []string{"'self'"}), but a future caller could corrupt the policy for every response; return a copy or state the immutability contract.

S3. The test's "is this inline?" check is coarser than production in a way that can spuriously fail. csp_test.go:60 skips any script whose attribute run merely contains src, while production parses attribute names (static.go:217 hasAttribute). On <script data-src="x">v();</script> production correctly hashes it and the test expects no hash → a false failure for a correct implementation. Tighten it to an attribute-name match, and add the <!---in-attribute fixture from C2.

Mutation check (required)

Setup in /tmp only (the read-only worktree was never written to): git archive 2e0d9b8 lab-director | tar -x -C /tmp/delta570, copy in web/build, baseline go test ./... all ok. Each mutation applied on a pristine checkout, then go test ./server/:

Mutation (regression introduced) Result
M1 server.go:140 SecurityHeaders(s.opts.Dev, s.spa.InlineScriptHashes()) → SecurityHeaders(s.opts.Dev, nil) — the exact B1 revert FAIL — TestProductionCSPCoversInlineScripts, TestProductionCSPCoversTheRealBuild
M1a same as M1 with web/build hidden (the CI condition) FAIL — TestProductionCSPCoversInlineScripts/sveltekit-shaped_shell still caught it
M2 static.go:79 inlineScriptHashes: inlineScriptHashes(index) → nil FAIL — incl. TestNewSPAHandlerDerivesHashesFromTheServedBytes
M3 middleware.go:119 productionCSP(scriptHashes) → productionCSP(nil) FAIL
M4 static.go:178 hash bytes.TrimSpace(content) (right script, wrong bytes) FAIL
M5 static.go:174 case-sensitive bytes.Index(..., "</script>") FAIL — TestInlineScriptHashExtraction/upper_case_tags
M6 static.go:169 drop the hasAttribute(..., "src") skip FAIL — external-script and src-fallback subcases
M7 static.go:146 disable comment skipping FAIL — …/a_comment_mentioning_a_script_is_not_a_script

All seven mutations failed; the pristine tree passes go vet ./... clean and go test -race -count=1 ./... green (CI's exact command). Independent non-Go browser control: with the emitted policy the cockpit mounts; with script-src 'self' it is blank and Chromium logs the violation naming the byte-identical hash. The precompressed path serves the same policy (Accept-Encoding: br), and the committed Playwright suite passes 17/17 in dev mode against the real binary.

Territory check

git show --stat 2e0d9b8 → 5 files, 638 insertions, 18 deletions, all lab-director/cockpit/server/*; the diff under review is byte-identical to the real commit delta. SecurityHeaders has exactly one caller (server.go:140); the dev CSP block is byte-identical to b688ecf; no 'unsafe-inline'/'unsafe-eval'/nonce- reaches the deployed policy; hashes are computed once in NewSPAHandler (static.go:79), no per-request cost. Robustness: 1.78M randomised documents through the extractor produced no panic and no hang.

Not verified

The image smoke and a real docker build (no docker socket) — the embed path is covered by reading Dockerfile:91-92 + cmd/cockpit/embed.go and by npm run e2e passing against a binary from this tree. CI itself (the skip claim comes from reading ci.yaml:101/119/142 + reproducing the condition locally). Future SvelteKit/adapter versions. Service-worker registration under the production CSP is a probe artifact (identical with and without the fix); e2e/shell.spec.ts spec 15 passes in dev mode.

## Review verdict 3/3 — DELTA review of the B1 fix (fresh context) **Verdict: `BLOCKERS: 0 | CONCERNS: 2 | SUGGESTIONS: 3`** **`B1 closed: yes`** — for head `2e0d9b8aad90eb6828979c27b5c2fe86d329e0b6` (delta `b688ecf399` → `2e0d9b8`, 5 files under `lab-director/cockpit/server/**`). **Strongest single piece of evidence:** in headless Chromium, the real served document + the real emitted production CSP executes the inline SvelteKit bootstrap and the app mounts (`__sveltekit_…` = object, `body.innerText` = "LD Cockpit … DASHBOARD", 8452 body bytes), while the defect policy `script-src 'self'` yields `undefined` / empty body (834 bytes) and Chromium asks for exactly the hash the Go extractor emits (`sha256-Hj9EQp+tH5RiA1YN1/29IPBse3J/oZZ+FMk235CGftE=`). Independent fresh-context reviewer (not the implementer, not the earlier reviewer). It verified against the *bytes the image actually ships*: `docker build` stages `web/build` over `cmd/cockpit/static/` (`Dockerfile:91-92`), the binary embeds it (`cmd/cockpit/embed.go` `//go:embed all:static`), `NewSPAHandler` hashes that `index.html` (`static.go:79-81`), and the same instance serves it (`server.go:133`, `static.go:466`). ### Blockers None. Every reintroduction of B1 the reviewer could construct is caught (see Mutation check). ### Concerns **C1. The only witness that the REAL built document is covered is skipped in CI, so the B1 guarantee rests on a hand-written fixture.** `TestProductionCSPCoversTheRealBuild` (`csp_test.go:176-179`) `t.Skip`s when `../web/build/index.html` is absent. That file is gitignored (`web/.gitignore:3`), and the required `cockpit-test` job runs `go test -race -count=1 ./...` (`.forgejo/workflows/ci.yaml:119`) on a fresh `actions/checkout@v4` (`ci.yaml:101`) *before* it builds the frontend (`ci.yaml:142`). The reviewer reproduced the exact CI condition locally (hid `web/build`) and observed `--- SKIP: TestProductionCSPCoversTheRealBuild`. So in CI the only `Dev:false` document witness is the literal `svelteKitShapedShell` (`csp_test.go:84-113`) plus the placeholder. Why it matters: the defect class is "the served document contains an inline script the policy does not cover"; a parser blind spot the fixture happens not to exercise would go green in CI while the deployed shell goes blank — precisely the failure mode this commit fixes. The commit's headless-Chromium claim was reproduced, but no artifact of it is committed, so it is not continuous. Fix: make `cockpit-test` build the frontend before `go test`, or commit a real `build/index.html` as `server/testdata/` and point the test at it. **C2. The extractor diverges from the HTML tokenizer in fail-closed, but production-coloured, ways.** Pinned by direct probes of `inlineScriptHashes`: - `<!--` occurring inside a non-script attribute value aborts the whole scan: `<p title="<!--">then</p><script>real();</script>` → 0 hashes (`static.go:146-152` matches the `<!--` inside the attribute, then `break`s when no `-->` follows, so the real script is never reached) → that script would be blocked = a B1-shaped blank page. The shape is one edit of a hand-maintained `web/src/app.html` away. - `</scriptx` truncates the body (`static.go:174` `indexFold(doc[contentStart:], "</script")` accepts any suffix), whereas the tokenizer does not end the element there → hash mismatch. - The script-data double-escape case (`<script>\n<!--<script>alert(1)</script>-->\n</script>`) hashes a prefix. Why it is not a blocker: every one of these drops or mismatches a hash, i.e. the script is *blocked* (visible), never silently authorized; and none is reachable from the current build — the reviewer re-ran `npm run build` with `src/app.html` converted to CRLF and the real built `index.html` still carries no `<` in any attribute and no CR in the bootstrap body, and Chromium accepted the emitted hash for it. The test's independent regex (`csp_test.go:25-53`) does catch the first shape *if* a fixture contains it; it cannot see the real build in CI (C1). ### Suggestions **S1. Mirror the tokenizer's newline preprocessing, or assert its precondition.** `static.go:178` hashes raw bytes; the HTML tokenizer replaces CR and CRLF with LF before a script's child text exists, so a CR inside a script body makes the emitted hash unusable. Proven in Chromium: for a body `a();\r\nb();` Chromium demands `sha256-wrEMctykYIJXhAyfCKKphjLeSVuUKhNQ4uVZWiS2cTQ=` (normalized) while `static.go:178` produces `sha256-GkkofoCWtVa7bC5kRRvGFNdkKxD7K5NvuBTRjvkYI3Y=` (raw) → blocked. Empirically unreachable today (adapter-static writes the injected bootstrap with LF; `git grep -Il $'\r' 2e0d9b8 -- lab-director/cockpit` is empty). Cheap guard: reject a `\r` inside an extracted body at startup rather than ship a hash that cannot match. **S2. `InlineScriptHashes()` hands out the internal slice** (`static.go:102`). Nothing mutates it today (`middleware.go:154` appends into a fresh `[]string{"'self'"}`), but a future caller could corrupt the policy for every response; return a copy or state the immutability contract. **S3. The test's "is this inline?" check is coarser than production in a way that can spuriously fail.** `csp_test.go:60` skips any script whose attribute run merely *contains* `src`, while production parses attribute names (`static.go:217` `hasAttribute`). On `<script data-src="x">v();</script>` production correctly hashes it and the test expects no hash → a false failure for a correct implementation. Tighten it to an attribute-name match, and add the `<!--`-in-attribute fixture from C2. ### Mutation check (required) Setup in `/tmp` only (the read-only worktree was never written to): `git archive 2e0d9b8 lab-director | tar -x -C /tmp/delta570`, copy in `web/build`, baseline `go test ./...` all ok. Each mutation applied on a pristine checkout, then `go test ./server/`: | Mutation (regression introduced) | Result | |---|---| | M1 `server.go:140` `SecurityHeaders(s.opts.Dev, s.spa.InlineScriptHashes())` → `SecurityHeaders(s.opts.Dev, nil)` — **the exact B1 revert** | **FAIL** — `TestProductionCSPCoversInlineScripts`, `TestProductionCSPCoversTheRealBuild` | | M1a same as M1 with `web/build` hidden (**the CI condition**) | **FAIL** — `TestProductionCSPCoversInlineScripts/sveltekit-shaped_shell` still caught it | | M2 `static.go:79` `inlineScriptHashes: inlineScriptHashes(index)` → `nil` | **FAIL** — incl. `TestNewSPAHandlerDerivesHashesFromTheServedBytes` | | M3 `middleware.go:119` `productionCSP(scriptHashes)` → `productionCSP(nil)` | **FAIL** | | M4 `static.go:178` hash `bytes.TrimSpace(content)` (right script, wrong bytes) | **FAIL** | | M5 `static.go:174` case-sensitive `bytes.Index(..., "</script>")` | **FAIL** — `TestInlineScriptHashExtraction/upper_case_tags` | | M6 `static.go:169` drop the `hasAttribute(..., "src")` skip | **FAIL** — external-script and src-fallback subcases | | M7 `static.go:146` disable comment skipping | **FAIL** — `…/a_comment_mentioning_a_script_is_not_a_script` | All seven mutations failed; the pristine tree passes `go vet ./...` clean and `go test -race -count=1 ./...` green (CI's exact command). Independent non-Go browser control: with the emitted policy the cockpit mounts; with `script-src 'self'` it is blank and Chromium logs the violation naming the byte-identical hash. The precompressed path serves the same policy (`Accept-Encoding: br`), and the committed Playwright suite passes **17/17** in dev mode against the real binary. ### Territory check `git show --stat 2e0d9b8` → 5 files, 638 insertions, 18 deletions, all `lab-director/cockpit/server/*`; the diff under review is byte-identical to the real commit delta. `SecurityHeaders` has exactly one caller (`server.go:140`); the dev CSP block is byte-identical to `b688ecf`; no `'unsafe-inline'`/`'unsafe-eval'`/`nonce-` reaches the deployed policy; hashes are computed once in `NewSPAHandler` (`static.go:79`), no per-request cost. Robustness: 1.78M randomised documents through the extractor produced no panic and no hang. ### Not verified The image smoke and a real `docker build` (no docker socket) — the embed path is covered by reading `Dockerfile:91-92` + `cmd/cockpit/embed.go` and by `npm run e2e` passing against a binary from this tree. CI itself (the skip claim comes from reading `ci.yaml:101/119/142` + reproducing the condition locally). Future SvelteKit/adapter versions. Service-worker registration under the production CSP is a probe artifact (identical with and without the fix); `e2e/shell.spec.ts` spec 15 passes in dev mode.
Author
Owner

Class-D visual check (R11) — head 2e0d9b8

Run against the real binary built from 2e0d9b8 (dev mode, COCKPIT_STATIC_DIR=web/build), plus a production-CSP positive control. Viewports 1440×900 and 390×844; routes /, /login, /unauthorized. Screenshots captured for all six combinations.

Result: B1 is verifiably FIXED, but the check found one open blocker (the PWA service worker never installs) plus responsive/accessibility defects. Landing withheld — see the landing note.

B1 re-verified as fixed (positive + negative control)

Static server over the real build/, real SvelteKit document:

  • script-src 'self' 'sha256-jT5G81Y9O3oya+N28K/IVhaAaBXCxx36y9fLXVaTDbc=' (computed independently from the built index.html): app mounts (wordmark + shell rendered, bodyLen 805), zero CSP violations.
  • script-src 'self' (the old defect policy, negative control): blank page (bodyLen 0, no wordmark), console names the identical hash the Go extractor emits.

So the document and the policy now agree. Good.

BLOCKER — the PWA service worker never installs (silent, on every page load)

  • Symptom: every load logs TypeError: Failed to register a ServiceWorker for scope ('…/') with script ('…/service-worker.js'): ServiceWorker script evaluation failed. navigator.serviceWorker.getRegistrations() is 0 at t+1.5s and t+7.5s; navigator.serviceWorker.ready never resolves (times out); controller is null. No offline shell ever activates.
  • Root cause: web/src/service-worker.ts uses the SvelteKit service-worker globals build and files without importing them. The built artefact is literally const n="ld-cockpit-shell-v1",o=[...build,...files,"/index.html"];… — build/files are undefined identifiers at evaluation, so the worker throws a ReferenceError before it installs. grep confirms no import … from '$service-worker' and no ambient declare const build|files anywhere under src/. (new Function(src) parses fine — it is a runtime, not syntax, failure.)
  • Why it matters: "SvelteKit shell + PWA" is a named 3.1a deliverable (design brief lines 17 and 127), and the offline shell is the PWA's only behaviour. Shipping it silently broken — with an error on every page load — is exactly the "claim honesty" failure the landing review exists to catch.
  • Fix: import { build, files } from '$service-worker'; in src/service-worker.ts (a one-line change; the comment there already says "build and files come from SvelteKit's own manifest").

The e2e that claims to cover it is vacuous (R11 concern)

e2e/shell.spec.ts:95-114 asserts only navigator.serviceWorker.getRegistrations().length > 0. Reproduced against the real binary: that assertion passes in 194 ms on a transient registration object that dies immediately — after waitForFunction resolves, count is back to 0, ready times out, and the pageerror fires. The test never asserts active/activated/ready, so a green cockpit-e2e (3m39s, run #1679) says nothing about whether the worker works. This is the same class of failure the brief calls out ("the legacy repo's e2e was dead code — do not repeat that"). Suggest asserting await navigator.serviceWorker.ready (or registration.active.state === 'activated') after a real fetch-through.

HIGH — mobile horizontal overflow (all three routes)

At 390×844 the document is 522 px wide (scrollWidth 522 vs innerWidth 390) → a 132 px horizontal scroll on /, /login and /unauthorized. Offenders are in the always-present app header: a div.flex.items-center.gap-2 at x 315–522, the connection-dot button (356–436), the identity chip and the "sign out" button (444–522). The header does not wrap or shrink below ~520 px. Desktop 1440 is clean (scrollWidth == innerWidth).

MEDIUM — pointer targets below the 24 px minimum (WCAG 2.5.8 AA)

Header controls are 21 px tall (and 16 px for the identity chip): dev badge 33×21, connection-dot button 104×21, dev-anonymous 107×16, the three unwired buttons 48×21. None meets 24×24; none approaches the 44 px mobile guidance. (Desktop /: 7 elements < 24 px; mobile: 1–4.)

MEDIUM — small-text contrast below AA

Canvas-resolved ratios (my first probe mis-parsed oklch, so these are re-measured properly): muted/faint tokens at 10–12 px land at 3.91–4.19 : 1 against a 4.5 : 1 requirement. Counts: desktop / 29/48 checked, /login 8/21, /unauthorized 12/26; mobile similar. Representative: 403 · not authorized (3.91), the dt labels required role / your roles / subject (3.91), dev badge (4.19), the P-COCKPIT invariant lines (4.19). Body text is fine; it is the muted tokens that miss.

LOW — 10 px type

Many labels render at 10–11 px (26–27 elements on the dashboard: invariant ids, uptime / server time / sources meta-labels). Dense operational UI justifies small type, but 10 px is below the usual floor.

What is correct

All routes 200 and mounted; no CSP violations under the real production policy; connection dot reports data-state="live"; the uptime actually ticks (29s → 31s), so the SSE→read-model bridge works end-to-end; the wiring census renders 3 rows, all unwired, each with a real sentence naming the missing key/ticket; 3 panes; no console errors other than the service-worker one; no overflow at 1440.

Not measured

Real device/touch behaviour; Safari/Firefox (Chromium only); a11y tree (axe) — out of scope for this pass. The SW and overflow findings were independently reproduced more than once.

## Class-D visual check (R11) — head `2e0d9b8` Run against the real binary built from `2e0d9b8` (dev mode, `COCKPIT_STATIC_DIR=web/build`), plus a production-CSP positive control. Viewports 1440×900 and 390×844; routes `/`, `/login`, `/unauthorized`. Screenshots captured for all six combinations. **Result: B1 is verifiably FIXED, but the check found one open blocker (the PWA service worker never installs) plus responsive/accessibility defects. Landing withheld — see the landing note.** ### B1 re-verified as fixed (positive + negative control) Static server over the real `build/`, real SvelteKit document: - `script-src 'self' 'sha256-jT5G81Y9O3oya+N28K/IVhaAaBXCxx36y9fLXVaTDbc='` (computed independently from the built `index.html`): app **mounts** (wordmark + shell rendered, bodyLen 805), **zero CSP violations**. - `script-src 'self'` (the old defect policy, negative control): **blank page** (bodyLen 0, no wordmark), console names the identical hash the Go extractor emits. So the document and the policy now agree. Good. ### BLOCKER — the PWA service worker never installs (silent, on every page load) - **Symptom:** every load logs `TypeError: Failed to register a ServiceWorker for scope ('…/') with script ('…/service-worker.js'): ServiceWorker script evaluation failed`. `navigator.serviceWorker.getRegistrations()` is **0** at t+1.5s and t+7.5s; `navigator.serviceWorker.ready` **never resolves** (times out); `controller` is null. No offline shell ever activates. - **Root cause:** `web/src/service-worker.ts` uses the SvelteKit service-worker globals `build` and `files` **without importing them**. The built artefact is literally `const n="ld-cockpit-shell-v1",o=[...build,...files,"/index.html"];…` — `build`/`files` are undefined identifiers at evaluation, so the worker throws a `ReferenceError` before it installs. `grep` confirms no `import … from '$service-worker'` and no ambient `declare const build|files` anywhere under `src/`. (`new Function(src)` parses fine — it is a runtime, not syntax, failure.) - **Why it matters:** "SvelteKit shell + **PWA**" is a named 3.1a deliverable (design brief lines 17 and 127), and the offline shell is the PWA's only behaviour. Shipping it silently broken — with an error on every page load — is exactly the "claim honesty" failure the landing review exists to catch. - **Fix:** `import { build, files } from '$service-worker';` in `src/service-worker.ts` (a one-line change; the comment there already says "`build` and `files` come from SvelteKit's own manifest"). ### The e2e that claims to cover it is vacuous (R11 concern) `e2e/shell.spec.ts:95-114` asserts only `navigator.serviceWorker.getRegistrations().length > 0`. Reproduced against the real binary: that assertion **passes in 194 ms** on a *transient* registration object that dies immediately — after waitForFunction resolves, `count` is back to 0, `ready` times out, and the pageerror fires. The test never asserts `active`/`activated`/`ready`, so a green `cockpit-e2e` (3m39s, run #1679) says nothing about whether the worker works. This is the same class of failure the brief calls out ("the legacy repo's e2e was dead code — do not repeat that"). Suggest asserting `await navigator.serviceWorker.ready` (or `registration.active.state === 'activated'`) after a real fetch-through. ### HIGH — mobile horizontal overflow (all three routes) At 390×844 the document is **522 px wide** (`scrollWidth` 522 vs `innerWidth` 390) → a 132 px horizontal scroll on `/`, `/login` and `/unauthorized`. Offenders are in the always-present app header: a `div.flex.items-center.gap-2` at x 315–522, the connection-dot button (356–436), the identity chip and the "sign out" button (444–522). The header does not wrap or shrink below ~520 px. Desktop 1440 is clean (scrollWidth == innerWidth). ### MEDIUM — pointer targets below the 24 px minimum (WCAG 2.5.8 AA) Header controls are 21 px tall (and 16 px for the identity chip): `dev` badge 33×21, connection-dot button 104×21, `dev-anonymous` 107×16, the three `unwired` buttons 48×21. None meets 24×24; none approaches the 44 px mobile guidance. (Desktop `/`: 7 elements < 24 px; mobile: 1–4.) ### MEDIUM — small-text contrast below AA Canvas-resolved ratios (my first probe mis-parsed `oklch`, so these are re-measured properly): muted/faint tokens at 10–12 px land at **3.91–4.19 : 1** against a 4.5 : 1 requirement. Counts: desktop `/` 29/48 checked, `/login` 8/21, `/unauthorized` 12/26; mobile similar. Representative: `403 · not authorized` (3.91), the `dt` labels `required role` / `your roles` / `subject` (3.91), `dev` badge (4.19), the P-COCKPIT invariant lines (4.19). Body text is fine; it is the muted tokens that miss. ### LOW — 10 px type Many labels render at 10–11 px (26–27 elements on the dashboard: invariant ids, `uptime` / `server time` / `sources` meta-labels). Dense operational UI justifies small type, but 10 px is below the usual floor. ### What is correct All routes 200 and mounted; no CSP violations under the real production policy; connection dot reports `data-state="live"`; the **uptime actually ticks (29s → 31s)**, so the SSE→read-model bridge works end-to-end; the wiring census renders 3 rows, all `unwired`, each with a real sentence naming the missing key/ticket; 3 panes; no console errors other than the service-worker one; no overflow at 1440. ### Not measured Real device/touch behaviour; Safari/Firefox (Chromium only); a11y tree (axe) — out of scope for this pass. The SW and overflow findings were independently reproduced more than once.
Author
Owner

Landing worker — MERGE WITHHELD on a class-D blocker (head 2e0d9b8)

All three reviews are posted after the current head:

  • correctness/security (base b688ecf): 1 blocker (B1) · 5 concerns · 6 suggestions
  • test adequacy (base b688ecf): 0 blockers · 4 concerns · 5 suggestions
  • delta review of 2e0d9b8: 0 blockers · 2 concerns · 3 suggestions — B1 closed: yes (7/7 mutation regressions fail the suite)

CI is green on 2e0d9b8 (run #1679: cockpit-test, cockpit-e2e 3m39s, helm-validate, receiver-test, dispatcher-test, alert-receiver-test; run #1680: build-and-push 2m28s).

The merge is withheld because the class-D visual check (R11) found an open blocker that no test currently catches: the PWA service worker never installs. web/src/service-worker.ts uses SvelteKit's build/files globals without importing them from $service-worker, so the built service-worker.js contains [...build,...files,"/index.html"] and throws a ReferenceError on evaluation — reproduced: getRegistrations() = 0, navigator.serviceWorker.ready times out, and a console error on every page load. "SvelteKit shell + PWA" is a named 3.1a deliverable (design brief lines 17, 127).

Compounding it, the e2e that claims to cover the PWA (e2e/shell.spec.ts:95-114) asserts only registrations.length > 0, which passes in 194 ms on a transient registration that dies immediately — precisely the "dead e2e" failure R11 warns against.

Full evidence, plus the HIGH mobile-overflow (522 px content in a 390 px viewport) and the MEDIUM pointer-target/contrast findings, are in the class-D comment above. B1 itself is confirmed fixed (positive + negative control).

Recommended next step (small, one fix commit): add import { build, files } from '$service-worker';, strengthen shell.spec.ts to await navigator.serviceWorker.ready (not just a registration object), fix the header's mobile overflow, and bump the muted tokens to ≥4.5:1. Then re-run the delta review + visual check.

No merge was performed and no CI run was triggered by this comment; the head is unchanged.

## Landing worker — MERGE WITHHELD on a class-D blocker (head `2e0d9b8`) All three reviews are posted after the current head: - correctness/security (base `b688ecf`): 1 blocker (B1) · 5 concerns · 6 suggestions - test adequacy (base `b688ecf`): 0 blockers · 4 concerns · 5 suggestions - **delta review of `2e0d9b8`: 0 blockers · 2 concerns · 3 suggestions — `B1 closed: yes`** (7/7 mutation regressions fail the suite) CI is **green on `2e0d9b8`** (run #1679: `cockpit-test`, `cockpit-e2e` 3m39s, `helm-validate`, `receiver-test`, `dispatcher-test`, `alert-receiver-test`; run #1680: `build-and-push` 2m28s). **The merge is withheld because the class-D visual check (R11) found an open blocker that no test currently catches:** the PWA service worker never installs. `web/src/service-worker.ts` uses SvelteKit's `build`/`files` globals without importing them from `$service-worker`, so the built `service-worker.js` contains `[...build,...files,"/index.html"]` and throws a `ReferenceError` on evaluation — reproduced: `getRegistrations()` = 0, `navigator.serviceWorker.ready` times out, and a console error on every page load. "SvelteKit shell + **PWA**" is a named 3.1a deliverable (design brief lines 17, 127). Compounding it, the e2e that claims to cover the PWA (`e2e/shell.spec.ts:95-114`) asserts only `registrations.length > 0`, which passes in **194 ms** on a transient registration that dies immediately — precisely the "dead e2e" failure R11 warns against. Full evidence, plus the HIGH mobile-overflow (522 px content in a 390 px viewport) and the MEDIUM pointer-target/contrast findings, are in the class-D comment above. B1 itself is confirmed fixed (positive + negative control). **Recommended next step (small, one fix commit):** add `import { build, files } from '$service-worker';`, strengthen `shell.spec.ts` to await `navigator.serviceWorker.ready` (not just a registration object), fix the header's mobile overflow, and bump the muted tokens to ≥4.5:1. Then re-run the delta review + visual check. No merge was performed and no CI run was triggered by this comment; the head is unchanged.
HOMELAB-1681: fix(cockpit-web): service worker install, real SW e2e, responsive shell (review findings)
All checks were successful
CI Review / receiver-test (pull_request) Successful in 4s
CI Review / helm-validate (pull_request) Successful in 8s
Build Lab Director Cockpit Image / preflight (pull_request) Successful in 3s
CI Review / alert-receiver-test (pull_request) Successful in 20s
CI Review / cockpit-test (pull_request) Successful in 1m54s
CI Review / dispatcher-test (pull_request) Successful in 2m18s
Build Lab Director Cockpit Image / build-and-push (pull_request) Successful in 3m30s
CI Review / cockpit-e2e (pull_request) Successful in 3m47s
a6fd65feb4
The class-D visual check on 2e0d9b8 found one blocker and three
responsive/accessibility defects that the code reviews could not see,
because none of them is visible to a unit test. All four are in
lab-director/cockpit/web/**; no Go, server or CI change.

1. BLOCKER — the PWA service worker never installed. `service-worker.ts`
   used SvelteKit's `build`/`files` globals without importing them from
   `$service-worker`, so the built worker evaluated `[...build,...files,
   "/index.html"]` against undefined identifiers, threw a ReferenceError
   during registration, and was discarded: `getRegistrations()` stayed 0,
   `navigator.serviceWorker.ready` never resolved, and every page load
   logged "ServiceWorker script evaluation failed". The offline shell —
   the PWA's only behaviour, and a named 3.1a deliverable — did not exist.
   Fix: the documented `import { build, files, version } from
   '$service-worker'`, and the cache name now carries `version` so a
   deploy gets a fresh cache instead of a manual `-v1` bump.

2. The e2e that claimed to cover the PWA was vacuous (shell.spec.ts:95).
   It asserted only `getRegistrations().length > 0`, which passed in 194 ms
   on a transient registration object that died immediately. It now
   asserts reality: `navigator.serviceWorker.ready` resolves inside a 20s
   bound, the registration is `activated` and scoped to the origin, the
   page is actually controlled, and then — with the network emulated
   offline — a reload is answered from the worker's own cache with the
   wordmark and all three panes, while `/api/v1/readmodel` is NOT served
   from any cache and the shell renders its read-model error. Mutation
   proof: the spec fails against the pre-fix worker with "ready did not
   resolve within 20s (ServiceWorker script evaluation failed)" and passes
   against the fix.

3. HIGH — mobile horizontal overflow. Every route was 503px wide in a
   390px viewport (113px of sideways scroll) because the header could not
   wrap or shrink; the offending boxes were the identity chip and the
   sign-out button. The header is now a wrapping flex row (wordmark row +
   right-aligned status row on a phone, the single 48px row unchanged at
   1440px), with `min-w-0`/`truncate` on the identity chip so a long name
   shrinks instead of pushing.

4. MEDIUM — targets below the 24px floor and small-text contrast below
   4.5:1. The dev badge, the identity chip, the census status triggers and
   the wordmark were 16-21px tall; they now take a stated floor
   (`--target-min` 24px for dense rows, `--target-comfortable` 36px in the
   header — 44px would cost a 2xl header on a 390px screen). `--ink-faint`
   measured 3.91:1 on the pane surface and 3.24:1 on a hatch stripe; the
   ink steps move to 0.65/0.75, which clears 4.5:1 on ground, surface,
   raised and the hatch (worst case 4.7:1 / 6.8:1).

New net: e2e/responsive.spec.ts measures all three properties in the
browser on `/`, `/login` and `/unauthorized` at 390 and 1440. Mutation
proof: all 9 specs fail against the pre-fix UI (scrollWidth 503 vs 390,
sub-24px controls, sub-4.5:1 text) and pass after.

Verified: svelte-check 0 errors/warnings; vitest 62 passed (9 files);
playwright 26 passed (was 17); `go build ./... && go test ./server/...`
still green. Token contrast computed with an oklch->sRGB solver and
re-measured through canvas compositing in the real build.
Author
Owner

Review verdict 4/4 — DELTA review of the PWA/responsive fix (fresh context)

Verdict: BLOCKERS: 0 | CONCERNS: 4 | SUGGESTIONS: 3 — BLOCKER-CLOSED: yes — for head a6fd65feb42c1efdd75b91ef10ad092f20a00e84 (delta 2e0d9b8 → a6fd65f, 7 files, all lab-director/cockpit/web/**).

Independent fresh-context reviewer (not the implementer, not any earlier reviewer; it was explicitly barred from reading prior verdicts). It verified against the bytes the image ships and proved closure by mutation in both directions — a pre-fix worker fails the strengthened spec for the right reason, and a pre-fix UI fails the new responsive spec 9/9. The landing worker reproduced the same conclusions independently (positive + negative control, built artefact, headless Chromium).

Posted by the landing worker. One concern is a flake in the new test (C1) — recorded below; it is a false-red risk, not a false-green, and is not a merge blocker.


VERDICT: BLOCKERS: 0 | CONCERNS: 4 | SUGGESTIONS: 3

BLOCKER-CLOSED: yes — strongest evidence: I reverted only src/service-worker.ts to 2e0d9b8, rebuilt, and ran the spec:

✘ 1 [chromium] › e2e/shell.spec.ts:95 › ships the PWA shell: manifest served, service worker ACTIVE and answering offline (20.8s)
  Error: navigator.serviceWorker.ready did not resolve within 20s (Failed to register a ServiceWorker for scope
  ('http://127.0.0.1:18212/') with script ('http://127.0.0.1:18212/service-worker.js'): ServiceWorker script evaluation failed)
  1 failed

and the built fixed artefact resolves the import (no bare specifier left):

$ head -c 400 /tmp/review-a6fd65f/web/build/service-worker.js
const a=location.pathname.split("/").slice(0,-1).join("/"),
l=[a+"/_app/immutable/entry/app.6KWbxlDc.js", … ],p=[a+"/favicon.svg",a+"/icon-maskable.svg",a+"/icon.svg",a+"/manifest.webmanifest"],
o="1790911337390",n=`ld-cockpit-shell-${o}`,r=[...l,...p,"/index.html"];
$ grep -c '\$service-worker' build/service-worker.js   → 0
$ grep -cE '^\s*import ' build/service-worker.js       → 0

Blockers

None. The shipped artefact is correct: the PWA installs and activates, the e2e is load-bearing in both directions, the 390 px overflow is gone, and the contrast numbers are real (independently reproduced below). The defects I found are in the test and in cache hygiene, not in the deliverable — so per the brief's blocker definition (artefact wrong / acceptance criteria unmeetable / a test that proves something it doesn't) they are concerns, with the first one called out as the one I'd fix before trusting CI.

Concerns

  1. shell.spec.ts:172-175 is racy against the correct worker — it flaked on me. Immediately after navigator.serviceWorker.ready resolves, Chromium's registration.active.state can still be activating. My first (cold-server) run of the fixed tree:
    Expected: "activated"      Received: "activating"      (18 passed, 1 failed)
    
    The next 4 runs of the same command passed (14.7s, 2.5s, 2.4s, 2.3s) — so 1 failure in 5 runs, ~20% in my sample, on the cold path that CI always takes. This directly weakens the commit's "mutation proof … passes against the fix" and will flake the landing gate that merges on green CI. Fix: await page.waitForFunction(() => navigator.serviceWorker.controller?.state === 'activated') (or accept activating|activated at line 175 and keep the separate controller !== null wait, which is already there).
  2. The offline half of the spec is not proven load-bearing. context.setOffline(true) + page.reload() (shell.spec.ts:~200) can be satisfied by Chromium's HTTP disk cache — the SW's fetch() passes through and a cached 200 is indistinguishable from the SW cache's 200; reloaded?.status() cannot tell them apart. And because my mutation failed at the first assertion (ready), the offline assertions never executed under mutation, so the commit's "mutation proof" covers only the install claim, not the offline claim. Also readmodel-error is never asserted hidden before going offline (it was genuinely absent in dev-mode before that point, but nothing pins it). Fix: assert await page.evaluate(() => caches.match('/index.html').then(r => !!r)) (or caches.keys() naming the versioned cache) inside the offline block, and await expect(page.getByTestId('readmodel-error')).toBeHidden() before setOffline(true).
  3. activate deletes every cache on the origin, not just its own (src/service-worker.ts:56-60: keys.filter((key) => key !== CACHE)). On a shared origin this nukes unrelated apps' caches; and with a per-build version (concern 4) every deploy wipes the cache a still-open tab depends on, so a lazily-imported hashed chunk for that tab fails offline. Fix: filter on the prefix — keys.filter(k => k.startsWith('ld-cockpit-shell-') && k !== CACHE).
  4. The cache name is a build timestamp, so the "fresh cache per deploy" claim is coarser than it sounds. version is 1790911337390 — kit 2.70.3's default config.kit.version.name is Date.now() (node_modules/@sveltejs/kit/src/exports/vite/index.js:1717, src/types/ambient.d.ts:78-80). It changes every build (including dev-loop rebuilds), so it is a new cache name per rebuild, not per release; combined with concern 3 this deletes old caches constantly. Character set is digits only (safe), and it is never empty. Real but low-impact; a kit version.name set to the git SHA would be honest and stable within a build.

Suggestions

  1. source-row.svelte:53 — min-h-[var(--target-min)] on a <p> is cosmetic: a <p> is not a control and the spec's selector list (a,button,[role="button"],input,select,summary, responsive.spec.ts:125) never measures it. The real target-size fix there is the bits-ui Tooltip.Trigger (renders a <button>) growing via the inner span's min-h (source-row.svelte:47-53) — worth a comment saying so, and the <p> min-h only adds vertical space.
  2. The responsive net covers 390/1440 only; 320 px (the narrowest still-shipping phone) and the 640 px sm: wrap point are untested. hidden … sm:block on both separators (app-header.svelte:82, 88) means the first sm:-visible separator appears at 640 — untested.
  3. waitForTimeout(300) in all 9 responsive tests (responsive.spec.ts:169, 181, 189) is a fixed sleep before measurement; a page.waitForLoadState('networkidle') or a data-testid readiness wait would be deterministic and faster.

Claim-by-claim

Claim Closed? Evidence
1. SW never installed; import … from '$service-worker' is the documented idiom and version a real export yes Built artefact inlines build/files/version with no bare specifier (commands above); version is ambient node_modules/@sveltejs/kit/src/types/ambient.d.ts:80 and emitted at src/exports/vite/index.js:1717; resolveId for $service-worker at src/exports/vite/build/build_service_worker.js:84-90
2. The e2e was vacuous and is now load-bearing yes (with caveat C2) Mutation: pre-fix worker → 1 failed, named cause ServiceWorker script evaluation failed; fixed worker → passes 4/5 runs (1 flake, C1)
3. 390 px overflow fixed; offenders were identity chip + sign-out yes Pre-fix-UI mutation run: 9 failed, each scroll test scrollWidth 503 > clientWidth 390, offenders div[identity-chip], button "e2e-operator", button "sign out" at left 314 → right 503
4. Contrast floors met (ink-muted 6.8:1, ink-faint 4.7:1) yes Independent oklch→sRGB→WCAG computation over the hatch composite (color-mix(in oklch, edge 70%, transparent) 1px over the 7px gap, app.css:108-118): ink-muted 6.80, ink-faint 4.68; over ground/surface/raised 8.78/8.20/7.49 and 6.04/5.64/5.15. Old values reproduce the previous review's numbers: ink-faint 0.56 → 3.92 surface, 4.20 ground (the spec's own canvas measured 3.91/4.19)
"every route was 503px wide in a 390px viewport (113px of sideways scroll)" yes — measured, not guessed My independent run reports 503/390 = 113 px on /, /login, /unauthorized (the earlier 522/132 was a different measurement; the commit's number is the one I reproduce)
"all 9 specs fail against the pre-fix UI and pass after" yes Pre-fix UI mutation: 9 failed; fixed tree: the 9 responsive tests pass (they were among the 17 passed in my first run)
"playwright 26 passed" / spec passes deterministically against the fix no 1 of my 5 runs of shell.spec.ts -g "PWA shell" on the fixed tree failed with Expected: "activated", Received: "activating" (C1)
"Targets below 24px now take a stated floor" mostly The measured controls are buttons/links whose boxes the min-h grows; the <p> in source-row.svelte:53 is not a target (S1). Header controls at 36 px, not 44 px — stated and justified in the commit, and 24 px (2.5.8 AA) is what the spec enforces
No CI/Go change yes git diff --stat 2e0d9b8..a6fd65f = 7 files, all lab-director/cockpit/web/**
New spec is wired into CI yes playwright.config.ts testDir: 'e2e', testMatch: '**/*.spec.ts'; .forgejo/workflows/ci.yaml:192 runs xvfb-run -a npx playwright test with E2E_PREBUILT=1

Mutation check

  1. Worker mutation (direction ii). Copied src/service-worker.ts from 2e0d9b8, npm run build, ran E2E_PORT=18212 npx playwright test e2e/shell.spec.ts -g "PWA shell" → 1 failed, 0 passed, reason stated in the diagnostic: navigator.serviceWorker.ready did not resolve within 20s (… ServiceWorker script evaluation failed). Right reason, not a timeout artefact.
  2. Fixed tree (direction i). npx playwright test e2e/shell.spec.ts e2e/responsive.spec.ts → 17 passed, 1 failed (the failure was C1's activating race, not the fix). Then 4× -g "PWA shell" → 1 passed each.
  3. UI mutation (findings 3+4). Reverted app.css, app-header.svelte, identity-chip.svelte, source-row.svelte to 2e0d9b8 (kept the fixed SW), rebuilt, ran the full responsive spec → 9 failed, 0 passed, with scrollWidth 503 > clientWidth 390 on all three routes, the target-floor failures, and the contrast dump showing oklch(0.56 0.012 272) at ratio 3.91/4.19.
  4. Not run: I did not test the pre-fix UI with a working SW end-to-end beyond the above, and I did not measure the desktop header height in px (see below).

Not verified

  • The exact rendered header height at 1440 px (the commit's "single 48 px row"). Static reading: min-h-12 + py-1.5 with the tallest child at min-h-[var(--target-comfortable)] (36 px) = 48 px, but I did not measure it, and any taller child (e.g. the sm: version span with min-h-36 siblings) would grow the row.
  • 320 px and 640 px behaviour; focus-ring/target-spacing regressions at those widths.
  • Whether class="min-h-[var(--target-comfortable)]" on <ConnectionDot> is actually applied (I confirmed the component declares class?: string at connection-dot.svelte:19 but not that the root consumes it) — the e2e target measurement passing is consistent with either.
  • No bundle-size/runtime-dependency change was measured beyond git diff --stat showing no package.json edit.
  • I did not read /home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1681a-landing/** (no prior verdicts consumed).

Commands run

git -C …/LD3/infra-core log -1 --format=%B a6fd65f
git -C …/LD3/infra-core diff 2e0d9b8..a6fd65f [-- <file>]
cp -a …/LD3/infra-core/lab-director/cockpit/web /tmp/review-a6fd65f/web
go -C lab-director/cockpit build -o /tmp/review-a6fd65f/ld-cockpit-e2e ./cmd/cockpit
npm run build                                     (fixed, then SW-only mutation, then UI mutation)
head -c 400 build/service-worker.js; grep -c '\$service-worker' build/service-worker.js
E2E_PORT=18210 E2E_PREBUILT=1 E2E_COCKPIT_BIN=… npx playwright test e2e/shell.spec.ts e2e/responsive.spec.ts --reporter=list
E2E_PORT=18211 … npx playwright test e2e/shell.spec.ts -g "PWA shell"    (×4)
E2E_PORT=18212 … npx playwright test e2e/shell.spec.ts -g "PWA shell"    (mutated SW → 1 failed)
E2E_PORT=18213/18215 … npx playwright test e2e/responsive.spec.ts        (mutated UI → 9 failed)
python3 oklch→sRGB→WCAG contrast computation (inline, values quoted above)

Working copies: /tmp/review-a6fd65f/** (scratch only). The LD3 worktree at /home/aaron/repos/homelab-wt/LD3/infra-core was never modified (its src/ reverts were performed on the scratch copy; go build output and the Playwright run wrote outside the worktree).

## Review verdict 4/4 — DELTA review of the PWA/responsive fix (fresh context) **Verdict: `BLOCKERS: 0 | CONCERNS: 4 | SUGGESTIONS: 3`** — **`BLOCKER-CLOSED: yes`** — for head `a6fd65feb42c1efdd75b91ef10ad092f20a00e84` (delta `2e0d9b8` → `a6fd65f`, 7 files, all `lab-director/cockpit/web/**`). Independent fresh-context reviewer (not the implementer, not any earlier reviewer; it was explicitly barred from reading prior verdicts). It verified against the bytes the image ships and proved closure **by mutation in both directions** — a pre-fix worker fails the strengthened spec for the right reason, and a pre-fix UI fails the new responsive spec 9/9. The landing worker reproduced the same conclusions independently (positive + negative control, built artefact, headless Chromium). Posted by the landing worker. **One concern is a flake in the new test (C1) — recorded below; it is a false-red risk, not a false-green, and is not a merge blocker.** --- `VERDICT: BLOCKERS: 0 | CONCERNS: 4 | SUGGESTIONS: 3` `BLOCKER-CLOSED: yes` — strongest evidence: I reverted **only** `src/service-worker.ts` to `2e0d9b8`, rebuilt, and ran the spec: ``` ✘ 1 [chromium] › e2e/shell.spec.ts:95 › ships the PWA shell: manifest served, service worker ACTIVE and answering offline (20.8s) Error: navigator.serviceWorker.ready did not resolve within 20s (Failed to register a ServiceWorker for scope ('http://127.0.0.1:18212/') with script ('http://127.0.0.1:18212/service-worker.js'): ServiceWorker script evaluation failed) 1 failed ``` and the built fixed artefact resolves the import (no bare specifier left): ``` $ head -c 400 /tmp/review-a6fd65f/web/build/service-worker.js const a=location.pathname.split("/").slice(0,-1).join("/"), l=[a+"/_app/immutable/entry/app.6KWbxlDc.js", … ],p=[a+"/favicon.svg",a+"/icon-maskable.svg",a+"/icon.svg",a+"/manifest.webmanifest"], o="1790911337390",n=`ld-cockpit-shell-${o}`,r=[...l,...p,"/index.html"]; $ grep -c '\$service-worker' build/service-worker.js → 0 $ grep -cE '^\s*import ' build/service-worker.js → 0 ``` ## Blockers None. The shipped artefact is correct: the PWA installs and activates, the e2e is load-bearing in both directions, the 390 px overflow is gone, and the contrast numbers are real (independently reproduced below). The defects I found are in the *test* and in cache hygiene, not in the deliverable — so per the brief's blocker definition (artefact wrong / acceptance criteria unmeetable / a test that proves something it doesn't) they are concerns, with the first one called out as the one I'd fix before trusting CI. ## Concerns 1. **`shell.spec.ts:172-175` is racy against the *correct* worker — it flaked on me.** Immediately after `navigator.serviceWorker.ready` resolves, Chromium's `registration.active.state` can still be `activating`. My first (cold-server) run of the fixed tree: ``` Expected: "activated" Received: "activating" (18 passed, 1 failed) ``` The next 4 runs of the same command passed (14.7s, 2.5s, 2.4s, 2.3s) — so **1 failure in 5 runs, ~20% in my sample, on the cold path that CI always takes**. This directly weakens the commit's "mutation proof … passes against the fix" and will flake the landing gate that merges on green CI. Fix: `await page.waitForFunction(() => navigator.serviceWorker.controller?.state === 'activated')` (or accept `activating|activated` at line 175 and keep the separate `controller !== null` wait, which is already there). 2. **The offline half of the spec is not proven load-bearing.** `context.setOffline(true)` + `page.reload()` (shell.spec.ts:~200) can be satisfied by Chromium's HTTP disk cache — the SW's `fetch()` passes through and a cached 200 is indistinguishable from the SW cache's 200; `reloaded?.status()` cannot tell them apart. And because my mutation failed at the *first* assertion (`ready`), the offline assertions never executed under mutation, so the commit's "mutation proof" covers only the install claim, not the offline claim. Also `readmodel-error` is never asserted *hidden before* going offline (it was genuinely absent in dev-mode before that point, but nothing pins it). Fix: assert `await page.evaluate(() => caches.match('/index.html').then(r => !!r))` (or `caches.keys()` naming the versioned cache) inside the offline block, and `await expect(page.getByTestId('readmodel-error')).toBeHidden()` before `setOffline(true)`. 3. **`activate` deletes every cache on the origin, not just its own** (`src/service-worker.ts:56-60`: `keys.filter((key) => key !== CACHE)`). On a shared origin this nukes unrelated apps' caches; and with a per-build `version` (concern 4) every deploy wipes the cache a still-open tab depends on, so a lazily-imported hashed chunk for that tab fails offline. Fix: filter on the prefix — `keys.filter(k => k.startsWith('ld-cockpit-shell-') && k !== CACHE)`. 4. **The cache name is a build timestamp, so the "fresh cache per deploy" claim is coarser than it sounds.** `version` is `1790911337390` — kit 2.70.3's default `config.kit.version.name` is `Date.now()` (`node_modules/@sveltejs/kit/src/exports/vite/index.js:1717`, `src/types/ambient.d.ts:78-80`). It changes every build (including dev-loop rebuilds), so it is a new cache name per rebuild, not per release; combined with concern 3 this deletes old caches constantly. Character set is digits only (safe), and it is never empty. Real but low-impact; a kit `version.name` set to the git SHA would be honest and stable within a build. ## Suggestions 1. `source-row.svelte:53` — `min-h-[var(--target-min)]` on a `<p>` is cosmetic: a `<p>` is not a control and the spec's selector list (`a,button,[role="button"],input,select,summary`, responsive.spec.ts:125) never measures it. The real target-size fix there is the bits-ui `Tooltip.Trigger` (renders a `<button>`) growing via the inner span's `min-h` (source-row.svelte:47-53) — worth a comment saying so, and the `<p>` min-h only adds vertical space. 2. The responsive net covers 390/1440 only; 320 px (the narrowest still-shipping phone) and the 640 px `sm:` wrap point are untested. `hidden … sm:block` on both separators (app-header.svelte:82, 88) means the first `sm:`-visible separator appears at 640 — untested. 3. `waitForTimeout(300)` in all 9 responsive tests (responsive.spec.ts:169, 181, 189) is a fixed sleep before measurement; a `page.waitForLoadState('networkidle')` or a `data-testid` readiness wait would be deterministic and faster. ## Claim-by-claim | Claim | Closed? | Evidence | |---|---|---| | 1. SW never installed; `import … from '$service-worker'` is the documented idiom and `version` a real export | **yes** | Built artefact inlines `build`/`files`/`version` with no bare specifier (commands above); `version` is ambient `node_modules/@sveltejs/kit/src/types/ambient.d.ts:80` and emitted at `src/exports/vite/index.js:1717`; `resolveId` for `$service-worker` at `src/exports/vite/build/build_service_worker.js:84-90` | | 2. The e2e was vacuous and is now load-bearing | **yes** (with caveat C2) | Mutation: pre-fix worker → 1 failed, named cause `ServiceWorker script evaluation failed`; fixed worker → passes 4/5 runs (1 flake, C1) | | 3. 390 px overflow fixed; offenders were identity chip + sign-out | **yes** | Pre-fix-UI mutation run: `9 failed`, each scroll test `scrollWidth 503 > clientWidth 390`, offenders `div[identity-chip]`, `button "e2e-operator"`, `button "sign out"` at `left 314 → right 503` | | 4. Contrast floors met (ink-muted 6.8:1, ink-faint 4.7:1) | **yes** | Independent oklch→sRGB→WCAG computation over the hatch composite (`color-mix(in oklch, edge 70%, transparent)` 1px over the 7px gap, app.css:108-118): ink-muted **6.80**, ink-faint **4.68**; over ground/surface/raised 8.78/8.20/7.49 and 6.04/5.64/5.15. Old values reproduce the previous review's numbers: ink-faint 0.56 → 3.92 surface, 4.20 ground (the spec's own canvas measured 3.91/4.19) | | "every route was 503px wide in a 390px viewport (113px of sideways scroll)" | **yes — measured, not guessed** | My independent run reports 503/390 = 113 px on `/`, `/login`, `/unauthorized` (the earlier 522/132 was a different measurement; the commit's number is the one I reproduce) | | "all 9 specs fail against the pre-fix UI and pass after" | **yes** | Pre-fix UI mutation: `9 failed`; fixed tree: the 9 responsive tests pass (they were among the 17 passed in my first run) | | "playwright 26 passed" / spec passes deterministically against the fix | **no** | 1 of my 5 runs of `shell.spec.ts -g "PWA shell"` on the fixed tree failed with `Expected: "activated", Received: "activating"` (C1) | | "Targets below 24px now take a stated floor" | **mostly** | The measured controls are buttons/links whose boxes the `min-h` grows; the `<p>` in source-row.svelte:53 is not a target (S1). Header controls at 36 px, not 44 px — stated and justified in the commit, and 24 px (2.5.8 AA) is what the spec enforces | | No CI/Go change | **yes** | `git diff --stat 2e0d9b8..a6fd65f` = 7 files, all `lab-director/cockpit/web/**` | | New spec is wired into CI | **yes** | `playwright.config.ts` `testDir: 'e2e'`, `testMatch: '**/*.spec.ts'`; `.forgejo/workflows/ci.yaml:192` runs `xvfb-run -a npx playwright test` with `E2E_PREBUILT=1` | ## Mutation check 1. **Worker mutation (direction ii).** Copied `src/service-worker.ts` from `2e0d9b8`, `npm run build`, ran `E2E_PORT=18212 npx playwright test e2e/shell.spec.ts -g "PWA shell"` → **1 failed, 0 passed**, reason stated in the diagnostic: `navigator.serviceWorker.ready did not resolve within 20s (… ServiceWorker script evaluation failed)`. Right reason, not a timeout artefact. 2. **Fixed tree (direction i).** `npx playwright test e2e/shell.spec.ts e2e/responsive.spec.ts` → **17 passed, 1 failed** (the failure was C1's `activating` race, not the fix). Then 4× `-g "PWA shell"` → **1 passed** each. 3. **UI mutation (findings 3+4).** Reverted `app.css`, `app-header.svelte`, `identity-chip.svelte`, `source-row.svelte` to `2e0d9b8` (kept the fixed SW), rebuilt, ran the full responsive spec → **9 failed, 0 passed**, with `scrollWidth 503 > clientWidth 390` on all three routes, the target-floor failures, and the contrast dump showing `oklch(0.56 0.012 272)` at `ratio 3.91`/`4.19`. 4. Not run: I did not test the pre-fix UI with a working SW end-to-end beyond the above, and I did not measure the desktop header height in px (see below). ## Not verified - The exact rendered header height at 1440 px (the commit's "single 48 px row"). Static reading: `min-h-12` + `py-1.5` with the tallest child at `min-h-[var(--target-comfortable)]` (36 px) = 48 px, but I did not measure it, and any taller child (e.g. the `sm:` version span with `min-h-36` siblings) would grow the row. - 320 px and 640 px behaviour; focus-ring/target-spacing regressions at those widths. - Whether `class="min-h-[var(--target-comfortable)]"` on `<ConnectionDot>` is actually applied (I confirmed the component declares `class?: string` at `connection-dot.svelte:19` but not that the root consumes it) — the e2e target measurement passing is consistent with either. - No bundle-size/runtime-dependency change was measured beyond `git diff --stat` showing no `package.json` edit. - I did not read `/home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1681a-landing/**` (no prior verdicts consumed). ## Commands run ``` git -C …/LD3/infra-core log -1 --format=%B a6fd65f git -C …/LD3/infra-core diff 2e0d9b8..a6fd65f [-- <file>] cp -a …/LD3/infra-core/lab-director/cockpit/web /tmp/review-a6fd65f/web go -C lab-director/cockpit build -o /tmp/review-a6fd65f/ld-cockpit-e2e ./cmd/cockpit npm run build (fixed, then SW-only mutation, then UI mutation) head -c 400 build/service-worker.js; grep -c '\$service-worker' build/service-worker.js E2E_PORT=18210 E2E_PREBUILT=1 E2E_COCKPIT_BIN=… npx playwright test e2e/shell.spec.ts e2e/responsive.spec.ts --reporter=list E2E_PORT=18211 … npx playwright test e2e/shell.spec.ts -g "PWA shell" (×4) E2E_PORT=18212 … npx playwright test e2e/shell.spec.ts -g "PWA shell" (mutated SW → 1 failed) E2E_PORT=18213/18215 … npx playwright test e2e/responsive.spec.ts (mutated UI → 9 failed) python3 oklch→sRGB→WCAG contrast computation (inline, values quoted above) ``` Working copies: `/tmp/review-a6fd65f/**` (scratch only). The LD3 worktree at `/home/aaron/repos/homelab-wt/LD3/infra-core` was never modified (its `src/` reverts were performed on the scratch copy; `go build` output and the Playwright run wrote outside the worktree).
Author
Owner

Landing worker — class-D visual re-check on a6fd65f (R11)

Rebuilt from scratch (worktree .worktrees/final-570 @ a6fd65f, read/build only): npm run build (18.75s, + .br/.gz variants) and go build ./cmd/cockpit; served the real binary in dev mode; headless Chromium (channel: chromium), viewports 1440x900 / 390x844, routes /, /login, /unauthorized.

Result: clean. No blockers. The blocker from visual check #1 is verifiably fixed, and the desktop shell is unchanged.

Fixed — the PWA service worker installs, activates, and answers offline

  • Built artefact first: build/service-worker.js now inlines SvelteKit's manifest (const a=location.pathname.split("/")..., o="1790911380147", n=\ld-cockpit-shell-${o}`) — grep -c '$service-worker'= **0**, no bare...build/...files`, declaration header parses.
  • navigator.serviceWorker.ready resolved in 2004 ms; registration.active.state = activated; scope = origin root; navigator.serviceWorker.controller = /service-worker.js (the page is controlled). Zero service-worker evaluation errors.
  • The offline shell — the PWA's only behaviour: context.setOffline(true) + reload → HTTP 200, wordmark rendered, 3 panes; /api/v1/readmodel threw (never served from a cache) and [data-testid=readmodel-error] was visible.
  • Negative control (I reproduced the defect myself, not by trusting the fix): with the pre-fix worker served — plain + .br + .gz all replaced by const SHELL = [...build, ...files, '/index.html'] — on a fresh origin: ready did not resolve (15s), getRegistrations() = 0, pageerror ServiceWorker script evaluation failed. So the strengthened spec's assertions watch exactly the failure that existed.

Reproduction note for anyone re-running this: the server negotiates precompressed variants, so editing service-worker.js alone changes nothing for Chromium while a stale service-worker.js.br remains (my first control was invalid for this reason; curl shows the edit, the browser gets the old worker). All three variants must be replaced together.

Fixed — no horizontal overflow at 390 px (this was HIGH)

/, /login, /unauthorized at both 390x844 and 1440x900: scrollWidth == clientWidth (390 and 1440), offenders: [] in all six cases. Prior head: 503-522 px. Extra probe at 320 px: also clean, identity chip visible and the sign-out button visible/clickable.

No desktop regression

1440x900: header is the single 48 px row, 3 panes, identity chip + connection dot visible, 2 vertical separators visible, layout identical to before (screenshot inspected). Phone screenshot inspected too: wordmark row, status row, hatched wiring census, all three panes render.

Contrast — the commit's numbers are real (independently recomputed)

oklch -> sRGB -> WCAG: --color-ink-muted (0.75) 8.78 / 8.20 / 7.49 over ground / surface / raised, and 6.80 over the hatch stripe; --color-ink-faint (0.65) 6.04 / 5.64 / 5.15 and 4.68 over the hatch stripe. The token comment's "worst case ink-muted 6.8:1 and ink-faint 4.7:1" is accurate. --color-status-idle measures 4.22:1 on raised, but it is used only as a dot background (connection-dot, source-row), never as small text, so it is out of scope.

Observations (no action required to land)

  • (LOW / design) At 390 px the header is 128 px tall — it wraps to three rows (wordmark + unprotected / connection dot / dev badge + identity + sign out), where the component comment and the commit message say "wordmark row + right-aligned status row". Functionally correct with no overflow; worth a comment tidy-up, and ~15% of a 390 px viewport is chrome if a later slice revisits the frame.
  • (SUGGESTION) e2e/responsive.spec.ts resolves text backgrounds from getComputedStyle(...).backgroundColor only, so the hatch stripe (background-image) is never composited — the spec's contrast floor is the plain-surface case (5.15:1), while the token comment's documented worst case (4.52-4.68:1) is the stripe. The tokens are correct today; the test just cannot see the case the comment says defines the floor.
  • (CONCERN, test-side — see the delta verdict's C1) The new active === 'activated' assertion can race ready resolving while the worker is still activating: I observed active: "activating" at ready-resolution on two independent runs, and the reviewer's cold-path run failed Expected: "activated" | Received: "activating" (1 in 5). CI on a6fd65f passed (cockpit-e2e, 3m47s) and retries: 1 in CI masks most of it, so this is a false-red risk in the test, not a false-green about the product — it does not gate this merge, but it should be fixed before the suite is relied on as a gate for 3.1b.
## Landing worker — class-D visual re-check on `a6fd65f` (R11) Rebuilt from scratch (worktree `.worktrees/final-570` @ `a6fd65f`, read/build only): `npm run build` (18.75s, + `.br`/`.gz` variants) and `go build ./cmd/cockpit`; served the real binary in dev mode; headless Chromium (`channel: chromium`), viewports 1440x900 / 390x844, routes `/`, `/login`, `/unauthorized`. **Result: clean. No blockers. The blocker from visual check #1 is verifiably fixed, and the desktop shell is unchanged.** ### Fixed — the PWA service worker installs, activates, and answers offline - **Built artefact first:** `build/service-worker.js` now inlines SvelteKit's manifest (`const a=location.pathname.split("/")...`, `o="1790911380147"`, `n=\`ld-cockpit-shell-${o}\``) — `grep -c '\$service-worker'` = **0**, no bare `...build`/`...files`, declaration header parses. - `navigator.serviceWorker.ready` resolved in **2004 ms**; `registration.active.state` = **`activated`**; scope = origin root; `navigator.serviceWorker.controller` = `/service-worker.js` (the page is controlled). **Zero** service-worker evaluation errors. - **The offline shell — the PWA's only behaviour:** `context.setOffline(true)` + reload → HTTP **200**, wordmark rendered, **3 panes**; `/api/v1/readmodel` **threw** (never served from a cache) and `[data-testid=readmodel-error]` was **visible**. - **Negative control (I reproduced the defect myself, not by trusting the fix):** with the pre-fix worker served — plain + `.br` + `.gz` all replaced by `const SHELL = [...build, ...files, '/index.html']` — on a fresh origin: `ready` **did not resolve** (15s), `getRegistrations()` = **0**, pageerror `ServiceWorker script evaluation failed`. So the strengthened spec's assertions watch exactly the failure that existed. > Reproduction note for anyone re-running this: the server negotiates precompressed variants, so editing `service-worker.js` alone changes nothing for Chromium while a stale `service-worker.js.br` remains (my first control was invalid for this reason; `curl` shows the edit, the browser gets the old worker). All three variants must be replaced together. ### Fixed — no horizontal overflow at 390 px (this was HIGH) `/`, `/login`, `/unauthorized` at both 390x844 and 1440x900: `scrollWidth == clientWidth` (**390** and **1440**), `offenders: []` in all six cases. Prior head: 503-522 px. Extra probe at **320 px**: also clean, identity chip visible and the sign-out button visible/clickable. ### No desktop regression 1440x900: header is the **single 48 px row**, 3 panes, identity chip + connection dot visible, 2 vertical separators visible, layout identical to before (screenshot inspected). Phone screenshot inspected too: wordmark row, status row, hatched wiring census, all three panes render. ### Contrast — the commit's numbers are real (independently recomputed) oklch -> sRGB -> WCAG: `--color-ink-muted` (0.75) 8.78 / 8.20 / 7.49 over ground / surface / raised, and **6.80** over the hatch stripe; `--color-ink-faint` (0.65) 6.04 / 5.64 / 5.15 and **4.68** over the hatch stripe. The token comment's "worst case ink-muted 6.8:1 and ink-faint 4.7:1" is accurate. `--color-status-idle` measures 4.22:1 on `raised`, but it is used only as a dot background (`connection-dot`, `source-row`), never as small text, so it is out of scope. ### Observations (no action required to land) - **(LOW / design)** At 390 px the header is **128 px** tall — it wraps to *three* rows (wordmark + `unprotected` / connection dot / dev badge + identity + sign out), where the component comment and the commit message say "wordmark row + right-aligned status row". Functionally correct with no overflow; worth a comment tidy-up, and ~15% of a 390 px viewport is chrome if a later slice revisits the frame. - **(SUGGESTION)** `e2e/responsive.spec.ts` resolves text backgrounds from `getComputedStyle(...).backgroundColor` only, so the hatch stripe (`background-image`) is never composited — the spec's contrast floor is the plain-surface case (5.15:1), while the token comment's documented worst case (4.52-4.68:1) is the stripe. The tokens are correct today; the test just cannot see the case the comment says defines the floor. - **(CONCERN, test-side — see the delta verdict's C1)** The new `active === 'activated'` assertion can race `ready` resolving while the worker is still `activating`: I observed `active: "activating"` at `ready`-resolution on two independent runs, and the reviewer's cold-path run failed `Expected: "activated" | Received: "activating"` (1 in 5). CI on `a6fd65f` passed (`cockpit-e2e`, 3m47s) and `retries: 1` in CI masks most of it, so this is a **false-red risk in the test, not a false-green about the product** — it does not gate this merge, but it should be fixed before the suite is relied on as a gate for 3.1b.
aaron merged commit e525857813 into epic/LD3 2026-10-02 03:28:46 +00:00
Sign in to join this conversation.
No reviewers
No labels
operator-review
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Homelab/infra-core!570
No description provided.