Server tasks (S1..S12)
Specs are in server/requirements.md, rules in server/CLAUDE.md, the
protocol in the root CLAUDE.md.
S1 — Compile and pin
- Build
pacenotes-serverand commit the lockfile - Wire tracing-subscriber with an env filter, defaulting to info
S2 — Config and skeleton
- Add a clap CLI:
servewith--listen,--packages,--db-urland env fallbacks - Add the axum router,
GET /health, and graceful shutdown on ctrl-c
S3 — Manifest store
- Load
manifest.jsonintoshared::Manifestbehind an RwLock, with a lazy mtime-based reload and directory-name validation - Serve
GET /packageswith public entries only - Add
manifest gen: scan the access directories for zips, record sha256, size andupdated_at - Merge sidecars in
manifest gen - Warn on dangling
requires, a sidecar without a zip, and a public package requiring a protected one - Unit-test the merge and the validation cases
S4 — Downloads
- Serve
GET /download/{directory}/{name}as a stream, with Content-Length and the zip content type - Assert that directory and name are lookup keys and never paths
S5 — libSQL auth store
- Apply
migrations/0001_init.sqlidempotently at startup - Parse the bearer header, hash it, and load the token row with its grants in one query
- Extract
TokenRow::is_liveso the listing filter anddecidecannot drift apart
S6 — Auth wiring
- Filter
/packagesto public plus granted directories, never 403 - Enforce
decideon/download - Never log tokens, and verify it against a live run
S7 — Credential admin CLI
-
token mintwith label, grants and expiry, printing the key once -
token grantandtoken ungrant, rejectingpublic -
token revokeandtoken list - Honour
expires_atandrevokedindecide
S8 — Integration and deploy
- Integration-test the {anon, no grants, dirA, dirA+dirB} × {public, dirA, dirB} matrix, plus expired, revoked, ungrant, 404 and traversal
- Write the deploy set: Containerfile, entrypoint, Podman Quadlet,
Caddy site block and
deploy/README.md - Deploy from CI: image built on a
v/*tag and pushed to private GHCR,deploy/pacenotes-updaterolls the Quadlet onto it - Author the sidecar
.tomlfiles for the real packages- Share
exclusive_group = "pacenote-config"across the config variants, and make each voice pack require its language pack - Set no categories: they come from the folder layout, and a
categorykey in a sidecar is a hard error
- Share
- Add a README snippet for the publish and access flows
S9 — Protocol rev. 2
- Make
manifest genrecurse, sonameis the zip’s path under its access directory at any depth - Skip names that
valid_namerejects, with a warning - Skip non-UTF-8 file names with a warning
- Make
/download/{directory}/{*name}a wildcard route - Serve
GET /motd?lang=xxwith anenfallback and no auth- Restrict
langto[a-z]{2,8}before it reaches the filesystem
- Restrict
- Skip
motd/inmanifest gen - Warn when a top-level folder is a known category, which means the access-directory level is missing
S9b — One server, every game
- Replace the packages root with a data root, laid out
<data>/<game>/packages/<directory>/<name>.zip - Walk games in
manifest genand takegamefrom the folder name- Reject
gameas a sidecar key
- Reject
- Filter
GET /packages?game=<id>, treating an unknown game as empty - Lead the download route with the game
- Add
valid_id,KNOWN_GAMES,Manifest::for_gameandPackageInfo::download_pathtoshared - Warn on every mis-filing case
- Integration-test two games holding the same directory and name
- Re-file the real
server/datatree to the layout
S10 — Conflict reports from installers
- Accept
POST /report/conflictwithout auth, bodyshared::ConflictReport, packages in install order - Treat every field as hostile: cap the body at 8 KiB, bound the counts, validate the ids and refs, drop invalid entries, echo nothing back
- Rate-limit per client and globally
- Take the client from
X-Forwarded-Foronly when the peer is a configured--trusted-proxy
- Take the client from
- Store append-only as JSONL under
data/reports/, stamped with the server’s own time- Reserve
reportsas a data-root name
- Reserve
- Warn in
manifest genabout pairs reported in the last 30 days - Keep every report free of user identity, and document that in
deploy/README.md
S11 — Per-game install schema
- Read
data/<game>/schema.toml: the categories, which ask for one pick, and a symbolicinstall_root- Keep the canonical copy at
schemas/<game>.toml, byte-exact
- Keep the canonical copy at
- Embed each game’s schema and the sha256 of its raw bytes in the manifest
- Publish strictly: no schema means the game publishes nothing, an unparsable schema skips the game, an undeclared category skips the package
- Document the rollout order in
deploy/README.md
S12 — Period-2 hardening
IDs point into DESIGN-REVIEW.md.
- Stop enumeration through status codes: 401 without a live key, 404 with one, no 403 at all (D3)
- Make token rows decode fail-closed (D8)
- Go through
get_value: libsql’s typed getter panics on a type mismatch rather than returning an error
- Go through
- Keep public downloads off the DB, fall back to the public listing on a DB error, and reject malformed tokens before any query (D9)
- Add
GET /whoami(D2) - Handle SIGTERM with a 20-second bounded drain (D10)
- Set
StopTimeout=30in the Quadlet, since podman’s 10 s would SIGKILL mid-drain
- Set
- Write
manifest.jsonatomically, skip only the broken package or game, and exit non-zero (S5) - Turn on WAL and
busy_timeoutfor the local DB (S7) - Return 503 when a file’s size differs from the manifest (S4)
- Log one INFO line per response, with a token-hash prefix (D11)
- Name the leaf in
Content-Disposition, RFC 6266 (D12) - Set cache headers: no-store and
Vary: Authorizationon/packages,ETagand 304 on public downloads (C2) - Make the manifest a pure function of zips, sidecars and schemas (D5)
- Derive a missing version as
sha-<8 hex> - Refuse to drop metadata unless told to, and add
manifest migrate-to-sidecars
- Derive a missing version as
- Write the log without colour codes when stdout is not a terminal