Server requirements
What server work has to satisfy. The RULES that constrain the code are in
server/CLAUDE.md; the protocol itself is in the root CLAUDE.md; how
work is verified is in the root requirements.md.
Publishing (manifest gen)
- Field precedence is sidecar > the previous
manifest.json> the protocol default, per field. That keeps a re-run idempotent — it can never downgrade metadata — while letting a publisher change one field by touching one line.size,sha256andupdated_atignore both and always come from the file. - A malformed sidecar is fatal, not a warning.
deny_unknown_fieldsplus a hard error means a typo such ascategoy = "sounds"can never publish a package with silently defaulted metadata. - Everything merely suspicious is a collected warning on stderr: a sidecar
with no zip, a dangling or ill-formed
requires, a public package requiring a protected one, a dependency in another game. - Warnings are returned as data (
Generated { manifest, warnings }) so they can be tested rather than only observed in a log. - The manifest is a pure function of zips, sidecars and schemas. A sidecar
without
versiongetssha-<first 8 hex of sha256>.
Serving
- A corrupt
manifest.jsonnever fails a request: log it and keep serving the last good copy. - Downloads stream, and
Content-Lengthcomes from the file handle rather than the manifest — a stale manifest must not be able to produce a truncated or over-long response. /packagesnever returns 4xx. An expired, revoked, unknown or malformed credential silently gets the anonymous listing, because status codes there would let a caller enumerate directory names./downloadresolves the package BEFORE deciding access, so a package that does not exist is 404 for everyone.TokenRow::is_liveis the single definition of “not revoked, not expired”. The listing filter and the download decision both call it, so they cannot drift apart.
Shape of the crate
serveris a lib plus a bin:src/lib.rsexports the modules,src/main.rsis only the CLI. Integration tests drive the real router and the real admin commands — a test that reimplements them proves nothing.- Credentials are identified on the CLI by the raw key or by the hash
(full, or the 12-character prefix
token listprints). An ambiguous prefix is an error, never a coin flip. token mintprints the key on stdout and the warning on stderr. It is the only place a raw key exists.- Grants are read per request and never cached, so
ungrantandrevoketake effect immediately. - base64url is twenty hand-written lines with known-vector tests, not a dependency.
Deploy
- Images are built in CI, never on the host. A
v/X.Y.Ztag runs.github/workflows/cd-release-on-tag.yml, which pushesghcr.io/<owner>/pacenotes-server:X.Y.Z(and:sha-<12>) as a PRIVATE package — never a release asset or workflow artifact, because the repository is public and so are its artifacts — then SSHes to the host to rundeploy/pacenotes-update X.Y.Z. - The Quadlet runs the LOCAL tag
localhost/pacenotes-server:currentwithPull=never; onlypacenotes-updatemoves that tag. A restart therefore never builds, never pulls and never changes the version, and works with the registry down. pacenotes-updateis the deploy key’s forcedcommand=. It takes ONLY anX.Y.Zversion fromSSH_ORIGINAL_COMMAND, pulls that exact image with the host’s ownread:packagestoken, keeps the running image as:previous, restarts, and waits for the healthcheck; an unhealthy container is rolled back to:previousand the script exits 1, failing the CI job.- Not
podman auto-updateand noAutoUpdate=: it cannot be scoped to one unit, and other containers on the host carryAutoUpdate, so every release would have updated them too. podman untagneeds the image AND the name: given a name alone it strips every name the image has,:currentincluded, and the prune that follows deletes the running image. Found by the script’s test, 2026-11-01.podman image prune -fwithout--external: the--externalform deletes buildah’s intermediate containers, killing any concurrent build.StartLimitIntervalSec=1hwithStartLimitBurst=3: a crash loop stops instead of restarting forever.CARGO_BUILD_JOBS=1is the Containerfile’s DEFAULT, for a build on the 3 GB host, where an OOM presents as a vanishing-layer error rather than an honest message. CI passesCARGO_BUILD_JOBS=4.libsqlis built without itsreplicationfeature. Embedded replicas are not something this server creates, andcore+remote+tlsstill satisfy “moving auth to Turso is a--db-urlchange”.- The container binds
0.0.0.0:8080and publishes no host port: Caddy terminates TLS and is the only route in. entrypoint.shrunsmanifest genbeforeserve. A failed generation does not stop the server; it serves the previous manifest.
Design decisions worth not relitigating
gamecomes from the folder level, not a sidecar key or a query parameter — one source of truth, and the operator’s layout is the thing they already maintain. Moving a package between games is amv, which is the point.- The game leads the download URL. With one server,
public/basiccan exist under every game, so a game-less path would be ambiguous and resolving it by first match would silently serve another game’s bytes. /packageswithout?gamereturns every game, because the installer is a multi-game wizard and one request is cheaper than four. The filter exists for clients that care about one.- Names may contain spaces (
style/Numeric Standarddoes). That is legal — it only has to survive a URL — somanifest genwarns and publishes rather than refusing. Clients must percent-encode. - A schema change needs an installer release first. Ship an installer that accepts both the old and the new hash, then switch the server’s file. Switching the server first blocks every installer in the wild.