server/CLAUDE.md — package + auth server
Single static binary. Runs anywhere (deployed on Linux). No size constraints — normal async Rust. axum + tokio + libSQL. Packages are flat files on disk.
Access model (no tiers)
Packages are grouped into directories — both an access boundary and a
literal on-disk level: ./data/<game>/packages/<directory>/<name>.zip.
public/is visible to everyone, credential or not.- Every other directory is visible only to credentials granted it.
- One credential can hold grants for any number of directories.
- A grant covers the whole directory (list + download of everything in it). Per-package access = put the package in its own directory.
- Access directory ≠ UI category. The category is the FIRST segment of
name, i.e. the folder below the access directory, and has NO effect on auth. A grant is per directory, whatever categories sit inside it. - Access directory ≠ game either. The game is the level ABOVE
packages/, so the same directory name may exist under several games and a grant for it covers that name in every game.
Data model
-
Auth DB: libSQL. Local embedded file
./data/auth.dbby default; switching to remote Turso must require ONLY changing--db-url/PACENOTES_DB_URL(keep all queries portable, no local-file assumptions). Tables:tokens+grants(token_hash, directory). See migrations/0001_init.sql. -
Data root (
--data, default./data): ONE server serves every game.data/<game>/schema.toml install schema (S11) data/<game>/packages/<directory>/<name>.zip packages data/motd/<lang>.txt message of the day data/manifest.json every game, one document data/auth.db credentials (libSQL)Every level means exactly one thing: game, then the literal
packages/, then the access directory, then the package’sname— its path below the access directory, nested as deep as the operator likes. Nothing positional comes from a sidecar, which is whygameis NOT a sidecar key. -
Install schema (
<game>/schema.toml, S11):[[category]]entries withid(a category folder),select(one|any) andinstall_root(a token likegame, never a path);deny_unknown_fields, ids unique andvalid_id. Publishing is STRICT: no schema → the game publishes nothing (error); unparsable → same; a package in an undeclared category → skipped (warning). The manifest embeds each schema with the SHA-256 of its RAW bytes — the value installers compare. The server bundles the repository’sschemas/<game>.toml(store::BUNDLED_SCHEMAS) and warns when a data root’s copy differs: installers of that release would refuse it.motdandreportsare reserved data-root names, never games. -
Packages: game-root overlay zips, dumb payloads, never introspected by the server.
-
Sidecar metadata (optional):
<name>.tomlnext to its zip at whatever depth it lives, authored by hand. Position is NOT in here:display_name = "Numeric standard calls" # renames the LEAF only exclusive_group = "pacenote-config" requires = ["public/language/polish"] # "directory/name" refs description = "..." version = "2.0.0" # else "sha-<8 hex of sha256>"The sidecar is the ONLY source of these values (D5): nothing is inherited from a previous manifest, so the manifest is a pure function of zips + sidecars + schemas, and deleting a key removes it.
manifest genrefuses (exit 1, writes nothing) when a run would DROP a value the current manifest carries, unless--accept-metadata-loss;manifest migrate-to-sidecarsmoves manifest-only values into sidecars. Unknown keys are an ERROR (deny_unknown_fields): an old sidecar carryingcategory,tree_pathorgamewithholds ITS package instead of publishing silently-wrong metadata. The rest of the catalogue still publishes; the run exits 2. -
manifest.json (
shared::Manifest) at./data/manifest.jsonis whatGET /packagesserves. It is GENERATED:manifest genwalks<data>/<game>/packages/<directory>/**/*.zip, computes size/sha256/updated_at, merges each sidecar, validatesrequiresrefs, and writes the file ATOMICALLY (temp file + rename in the data dir). Hand-edits get overwritten — edit sidecars instead. Exit status: 0 all published; 2 written but something withheld (ERROR:lines); 1 could not run (nothing written, the previous manifest stays). Publishing = drop zip (+ optional sidecar) → runmanifest gen. No upload API in v1. -
Manifest is loaded at startup and hot-reloaded on file mtime change (checked lazily per request, cached with an RwLock — no inotify dep).
-
Game ids and directory names:
[a-z0-9_-]+(shared::valid_id), enforced bymanifest genand re-checked on manifest load.publicis reserved for anonymous access and must never appear ingrants. -
Package names:
shared::valid_name— a/-separated path with no\, no./..segment and no empty segment. Rejected or non-UTF-8 names are skipped with a warning rather than entering the manifest. -
manifest genwarns (does not fail) on the mis-filings that are easy to make: a category name where an access directory belongs, a category or access directory where a game belongs, a game folder withoutpackages/, an unknown game id, a sidecar with no zip, and a name that needs percent-encoding in a URL.
Security invariants
- Raw tokens are NEVER stored or logged. Store lowercase hex sha256 only.
Mint format:
pcn_+ 32 random bytes base64url (admin CLI prints it once). - Download path:
{game},{directory}and{name}from the URL are LOOKUP KEYS into the manifest, never joined into a filesystem path directly. Only a manifest hit yieldsdata/<game>/packages/<directory>/<name>.zip. This structurally prevents path traversal and is what makes a nested, wildcard{*name}safe — keep it that way. /motd’slangis the ONE path-ish input that cannot be validated by lookup, because it names a file no manifest lists. It is therefore restricted to[a-z]{2,8}before it touches the filesystem.- Statuses never enumerate: on a non-public directory, no live key → 401
WHETHER OR NOT the package exists; a live key without the grant, or no
such package → 404 — the same answer. No 403. A syntactically impossible
game/directory/name → 404 (nothing could exist). Body =
shared::ApiErrorJSON; a 401 carriesWWW-Authenticate: Bearer./packagesnever 4xxs — it silently filters to public + granted directories. - Token rows decode FAIL-CLOSED (an undecodable column is an error, never a
default). Decode from
row.get_value: libsql 0.6’s typedrow.get::<i64>PANICS on a type mismatch instead of returning an error. - Public packages never touch the auth DB, and
/packagesfalls back to the public listing on a DB error: a database blip must not take the free catalogue down. A bearer value not shaped like a minted key (pcn_+ 43 base64url chars) is rejected before any query. - No panics in request handlers: anyhow at edges →
impl IntoResponseerror type mapping to status + ApiError. - TLS is terminated by Caddy in front (deploy/Caddyfile). The binary listens plain HTTP on localhost/container network. Don’t add TLS crates.
Endpoints
GET /health→ 200 “ok”GET /whoami→ 200shared::KeyStatus { expires_at }for a live key, 401 otherwise. How a client validates a key.GET /packages[?game=<id>]→ shared::Manifest filtered to public + granted directories, and to one game when asked (absent = every game)GET /download/{game}/{directory}/{*name}→ zip stream (tokio fs → axum body), Content-Length from the file handle (503 if it disagrees with the manifest),application/zip, Content-Disposition naming the LEAF with an ASCIIfilenameand an RFC 6266filename*; auth per access model above.{*name}is a wildcard: a name spans every remaining segment.GET /motd?lang=xx→text/plainfromdata/motd/<lang>.txt,enfallback, 404 when there is none, no authPOST /report/conflict(reports.rs) →shared::ConflictReport; 202 empty / 400 / 413 / 429. Rate limit checked BEFORE parsing; body capped byDefaultBodyLimiton the route; nothing echoed; one JSON line per report indata/reports/conflicts-YYYY-MM-DD.jsonlwith the server’sreceived_at, never the client’s address.--trusted-proxy(PACENOTES_TRUSTED_PROXIES) decides whoseX-Forwarded-Forcounts. Requires serving withinto_make_service_with_connect_info::<SocketAddr>().manifest genturns the last 30 days of reports into per-pair warnings.- (v2, not now) POST /admin/* — credential management is a CLI subcommand
CLI (clap)
pacenotes-server serve --listen 127.0.0.1:8080 --data ./data --db-url file:./data/auth.dbpacenotes-server token mint --label "Jan" [--grant premium-voices]... [--expires-days N](repeatable –grant; a token with zero grants is valid but sees only public)pacenotes-server token grant <token-or-hash> <directory>pacenotes-server token ungrant <token-or-hash> <directory>pacenotes-server token revoke <token-or-hash>pacenotes-server token list(shows label, grants, expiry, revoked)pacenotes-server manifest migrate-to-sidecars --data ./data— one-off: copy values that exist only in manifest.json into sidecars (adds missing keys, never overwrites, never pins0.0.0/sha-…; idempotent).pacenotes-server manifest gen --data ./data [--accept-metadata-loss] [--accept-unpublish]— refuses (exit 1, writes nothing) when a game the current manifest serves would vanish because its schema is missing/broken, unless--accept-unpublish: an upgrade that forgotschema.tomlmust not empty the live catalogue. Scan + sidecar merge + validation as described under Data model. Warnings for: sidecar without zip, invalid directory name, top-level zips, danglingrequires, cross-directoryrequirespointing into a NON-public directory from a public package (a public package must not require protected content).
Testing
- Unit:
auth::decide(pure: token row + directory → decision) — exhaustive table test (exists in scaffold; extend, don’t delete). - Unit: sidecar merge +
requiresvalidation inmanifest gen. - Unit, beside each module: install-schema parsing/strictness
(
store/game_schema.rs), and inreports/validation, CIDR +X-Forwarded-Fortrust, the rate limiter andutc_date. Store tests generate throughfixture_tree, whosewrite_packageadds a zip’s category to its game’s schema and which skips the bundled-schema comparison; that comparison has its own tests. - Integration (
tests/<scenario>.rs, sharingtests/common/mod.rs): a real server on an ephemeral port, served WITH connect info likemain.rs, a tempdir data root (two games, public + two protected dirs, schemas) and in-memory libsql. Asserts the {anon, token-no-grants, token-dirA, token-dirA+dirB} × {public, dirA, dirB} matrix for /packages filtering and /download statuses (401 for no live key whether or not the package exists, 404 for no grant), expired/revoked,/whoami, a DB outage (tables dropped mid-run), cache headers/304, schemas in the listing, conflict reports (202/400/413/429, forged and trustedX-Forwarded-For), and percent-encoded traversal → 404. HTTP clients collapse literal../before sending, so traversal tests must encode it.
Conventions
- All config via flags with env fallbacks (PACENOTES_LISTEN, PACENOTES_DB_URL, PACENOTES_DATA_DIR).
- tracing for logs; never log Authorization headers. Every response gets
one INFO line on target
access(method, path+query, status, bytes, ms to the response head, 12-char token-HASH prefix or-/malformed, user agent) — production runs at INFO, whereTraceLayeris silent. - Cache headers:
/packagesprivate, no-store+Vary: Authorization; protected downloadsprivate, no-store; public downloadsno-cache. Every download carriesETag: "<sha256>"and honoursIf-None-Matchwith 304 — AFTER authorization, so a protected validator is never confirmed to a stranger. - Keep handlers thin; logic in
auth.rs/store.rsmodules as they grow. - Sidecar parsing needs a TOML crate (e.g.
toml) — fine, the server has no size budget.