A verification pass re-read every finding from the original sweep against the
code on this branch rather than against the commit messages. It found that four
of them were still broken, two because the edit I made was inert. Commit
messages claim; code decides.
- **The Storybook typecheck was never on.** `tsconfig.json` listed `.storybook`
as a bare directory name, and tsc silently skips dot-prefixed directories in
that form — so the entry typechecked nothing at all. Proved it by planting
`export const __probe: string = 1` in `.storybook/preview.tsx` and watching
`bun run lint` pass. `.storybook/**/*` is what actually pulls it in; the same
probe now fails as it should.
- **The Moonlight stale-PIN reset was a no-op.** `submit.reset()` sat at the top
of `onSubmit`, immediately before `submit.mutate(...)` — which moves the status
to pending in the same update, so it cleared a flag that was already changing.
The green "PIN sent" note therefore still greeted the next pairing attempt over
an empty PIN box. It now resets on the transition that actually matters:
`pin_pending` going false → true.
- **The session⇄game controls had the enforcement flag inverted**, and I never
touched it. `enforced.length === 0 || …` reads an EMPTY list as "this build
enforces everything", when the contract says the opposite in as many words:
"Empty on a platform with no launch path (macOS), so the console can say so
instead of offering a switch that does nothing". On exactly the platform the
flag exists for, every control stayed live and reported success for an axis the
host would never act on. Absent still means "assume it acts" — that is the
compatible reading for an older host, and a different case from present-empty.
- **Logout stopped revoking after a restart.** The epoch was a module-level
counter starting at 1, so it revoked within one process run and then reset —
and since the seal key derives from the stable mgmt token, a cookie captured
before a restart unsealed fine and was accepted again for the rest of its
7-day TTL. One service restart undid the whole fix. It persists next to the
host's config now. Verified: log out, restart the console, the captured cookie
still 401s, a fresh login still works.
Two more the pass rated as partial, both worth closing:
- The plugin-UI response filter was a denylist of four header names, so
`Clear-Site-Data` sailed through — a plugin error page could wipe `pf_session`
and sign the operator out of the console, on our own origin, because the iframe
is same-origin by design. It is an allowlist now; a plugin-supplied CSP,
`X-Frame-Options` or CORS header no longer speaks for us either.
- A half-configured TLS setup now refuses to start instead of logging a warning
and serving anyway. Neither shape can work — one path missing puts the login
password on the LAN in the clear, and PUNKTFUNK_UI_SECURE without TLS marks the
cookie Secure so the browser drops it and login can never stick. Exiting with a
reason beats a console that looks fine and is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The console's login throttle was documented as per-IP and was not. Nitro's
`localFetch` hands the app a synthetic request whose socket has no
`remoteAddress`, so `getRequestIP()` returned undefined for every request and
every attempt was charged to one shared "unknown" bucket. Five wrong guesses
from any LAN peer locked out everyone — including the operator, and including
the update-apply route, which shares that budget. The Bun entry is the only
place the real peer is knowable, so it now stamps it into a header (deleting
any client-supplied copy first) and `peerAddress()` reads it back.
Verified on a real build bound to 0.0.0.0: seven wrong logins from 127.0.0.1
lock 127.0.0.1 out, a different peer still logs in on the first try, and a
request forging the header is charged to its real address.
Also on the way through:
- Installing an unreviewed package and adding a catalog source now re-ask for
the console password, like applying an update already did. A 7-day session
cookie should not be able to run new code on the host, and `store/install`
with `accept_unverified` did exactly that through the generic passthrough.
The gate sits at the trust boundary — adding a source, or a raw spec — not
on every install from a source the operator already chose to trust.
- The ui-credential denylist is matched against the normalised path too, so
`/api//v1/...` and friends can no longer walk around it.
- The console serves nosniff, a no-referrer policy, and a CSP that pins
frame-ancestors, object-src and base-uri.
- A plugin UI's response no longer re-emits the content-encoding that `fetch`
already decoded (which made compressed plugin pages fail to load), no longer
sets cookies on the console's origin, and OPTIONS reaches the plugin instead
of being refused 405 by us.
- An unreachable host reads as 502 on these routes, matching the passthrough,
instead of a bare 500.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>