Docs
- docs/ARCHITECTURE.md: how the pieces fit and why. The request path, the six
enforced layers, the data model, the scanner, the job queue, direct-play
playback, and the three protocol surfaces over one application.
- docs/decisions/: seven ADRs with the alternatives that were rejected —
SQLite-with-a-seam, direct-play-only, mark-missing-never-delete, the modular
monolith, the one item-access chokepoint, the table-backed queue, and
fixed-width timestamps.
- README: the layout section was stale (it predated catalog, home, scanner,
providers, engine, probe, playback, apitoken, arch and the dto packages) and
now lists what exists, plus links to the new docs.
Two real bugs, both found by verifying the docs instead of trusting them
1. The CLI could not run on a fresh install. `Config.Validate` created the data
and config directories, but the CLI never called it, so `vellum user add` —
the recovery path when you are locked out — failed with "unable to open
database file" until the server had booted at least once. Split the directory
creation into `Config.EnsureDirs`, call it from the CLI, and keep the media
root check out of it: an admin command must not fail because a library is
unmounted. The CLI tests now point DBPath inside DataDir, exactly as
LoadConfig derives it, so they exercise the fresh-install path; removing
EnsureDirs fails three of them.
2. A fresh clone could not build at all. `//go:embed all:dist` fails to compile
when web/dist is absent, and web/.gitignore expected a `dist/.gitkeep` that
was never created. Added it, so a clone builds and the server logs
"web UI not embedded; run the frontend build to include it" — the intended
behaviour for someone who only wants the API.
Also: the httpapi tests depended on a built frontend and FAILED rather than
skipping, so the suite was red on any fresh clone. findAsset and readAsset now
skip with a message naming the missing step. They still RUN when a bundle is
present (verified: zero skips here), so they have not been quietly disabled.
Verified from a simulated clone of tracked files only: go build exit 0, 25
packages pass, the server boots and applies all 12 migrations, and the CLI
creates a user and takes a backup. gofmt clean, vet exit 0, svelte-check 0/0.
The RomM replacement.
- registry: a games media type with ~35 ROM extensions across NES, SNES, GB/GBA,
N64, NDS, Genesis/Master System/Game Gear, PC Engine, Neo Geo Pocket,
WonderSwan, Atari, Coleco, Intellivision, and PlayStation.
- games: platform detection from the file extension, mapping each to an
EmulatorJS core. Platforms with no browser core are recognised but marked
not-playable so the UI offers a download instead of a broken player.
- migration 0005: game_saves keyed by (user, item, slot). Save states are user
data, not media — two people playing the same ROM keep their own progress.
- save endpoints with a 16 MiB cap and a validated slot name; the upload is
read through a LimitReader so an unbounded body is refused rather than
buffered.
- igdb provider: Twitch client-credentials flow with the token cached for its
~2 month lifetime, Apicalypse query body, and cover URLs normalised from
protocol-relative t_thumb to https t_cover_big.
- searchTitle: strips "(USA)", "[!]", "(Rev 1)" and similar release tags before
searching. "Sonic the Hedgehog (USA)" matched nothing; "Sonic the Hedgehog"
matches immediately. Stored titles keep the original.
- web: GamePlayer embeds EmulatorJS and adds Load/Save-to-server buttons.
Verified live against IGDB: Sonic matched (id 316900) with real cover art; a
4 KB save uploaded, listed, downloaded byte-identical, and deleted.
- comicvine provider for comics. ComicVine has no keyless tier, so the
provider is only registered when VELLUM_COMICVINE_API_KEY is set; without it
the server logs a note and comic enrichment completes as a no-op rather than
erroring.
- BaseURL is injectable so the response parsing is covered by a test against a
local server: candidate mapping, the required User-Agent, and the
status_code=100 invalid-key case (ComicVine returns HTTP 200 for that).
- descriptions are stripped of ComicVine's embedded HTML before storage.
Verified against a fake ComicVine server, and live that a comics library
enriches cleanly with no key configured.
Multi-user from the first migration, as planned.
- auth: argon2id password hashing (PHC encoded, parameters travel with the
hash), opaque session tokens stored only as SHA-256, login rate limiting,
and account enumeration resistance (unknown user hashes anyway)
- sessions: cookie or Bearer auth, HttpOnly + SameSite=Lax, revocation on
password change
- grants: library_grants is the single access-control chokepoint. Admins see
every library; everyone else sees exactly their can_read grants
- bootstrap: VELLUM_ADMIN_USER/PASSWORD create the first admin only on an
empty database. No default password is ever invented.
- httpapi: mounts modules on a shared mux, session middleware innermost
- httpx: shared JSON helpers, strict body decoding
Gate: two users see different libraries. Tests cover that, plus session
expiry, token-at-rest, grant upsert, and the full login/me/logout flow.
Bootstraps Vellum as a single Go binary with one SQLite database.
- core: env-driven config, slog logging, SQLite (WAL) with pooled pragmas,
embedded migration runner keyed on PRAGMA user_version
- migrations/0001_core.sql: identity, media, user state, infra. Media items
are global; all per-user state (progress, favorites, grants) is a join.
One polymorphic progress table covers every media kind.
- httpapi: request-ID, structured request logging, panic recovery, JSON
endpoints for /healthz and /api/v1/version
- multi-arch Dockerfile (pure-Go SQLite, CGO_ENABLED=0) with an entrypoint
that honours PUID/PGID like the rest of the self-hosted stack
- tests for migration idempotency, FK enforcement, WAL, config, middleware
Media does not exist yet. Nothing is exposed beyond health and version.