Configuration #
Every variable the binary and the tools read, with its default. Nothing reads
a .env file: .env.example names the variables, and how they reach the
process — systemd, a container's environment, a shell — is yours to decide.
The server #
manaweb-appview, the manaweb binary. The first four have defaults, so it
starts with none of them set and syncs into manaweb.db in the working
directory.
| Variable | Default | What it is |
|---|---|---|
MANAWEB_DATABASE |
manaweb.db |
The SQLite file holding the card cache. |
MANAWEB_CATALOG |
catalog |
Where the exported catalog is written, and what is served for local development. |
MANAWEB_BIND |
127.0.0.1:8080 |
Address to listen on. 0.0.0.0:8080 to reach it from another device. |
MANAWEB_SYNC |
1 |
0 or false starts without the weekly Scryfall sync. |
MANAWEB_HASHES |
scryfall/hashes |
Where the artwork hashes are kept. No file yet is a store to start filling. |
MANAWEB_HASHES_CAP |
600 |
Artworks one refresh adds to that store. 0 leaves it alone. |
The client's dev server takes MANAWEB_DEV_HOSTS — a hostname, several
comma-separated, or any — for reaching it through a tunnel, which answers
on a name it has never heard of and refuses by default. That refusal is what
stops a page on another site pointing a name it owns at this machine and
then talking to the server as though it were the same origin, so any is
worth spending only on a tunnel whose name is awkward to predict, and naming
the host is better. What either can reach is held to the app and its
dependencies rather than to the whole checkout, because the lockfile at the
root would otherwise make the database and the notes beside it fair game.
That is how a phone gets at it: a camera needs a secure context, and the LAN
address the dev server answers on is not one, so plain HTTP reaches
everything but the scanner. The catalog passes through the same server
rather than being fetched from the binary directly, so one tunnel carries
both.
The store fills itself, and seeding it is what makes that quick. A server
given nothing starts with an empty one and adds a capped run per refresh,
which reaches a full index in months rather than days. Copying a built store
in — manaweb-artwork pull then hash, on a machine that can spend four
gigabytes and some hours — skips the wait, and the top-up keeps it current
from then on. Both arrive at the same file, so seeding is a shortcut rather
than a step, and a server nobody seeds still ends up with a scanner.
Absent and unreadable are different answers. No file is a store waiting
to be filled. One that will not parse stops the process, because the index is
what a scan retrieves against and a damaged store published as a good one
answers wrongly rather than not at all. Until there is one, the catalog is
published without an index and the log says artwork=0 beside a warning.
All three paths want a volume of their own in a container, and the image names one for each: the cache is 96MB and several minutes of Scryfall's bandwidth to rebuild, the catalog is what the upload reads back to decide what has moved, and the store is written by the server as it fills in. A store bind-mounted read-only is the shape to avoid, being the one the top-up cannot write.
A container that will not start may be holding a stranded cache. sqlx
checksums every migration it applies and refuses a database whose record
disagrees, so a migration edited after it shipped — 0003 was, in a commit
that only retargeted a comment — stops the process before it binds, with
applying migrations failed: migration 3 was previously applied but has been modified and nothing else. A test holds the four checksums now, so it cannot
happen again; a volume it already happened to is unstuck either by deleting
the database, the cache being derived from Scryfall and disposable, or by
writing the file's own sha384 over the recorded one:
UPDATE _sqlx_migrations SET checksum = x'<sha384 of the file>' WHERE version = <n>;
Deleting it is the one to reach for. It costs a download that happens weekly anyway, and it leaves a cache nobody has edited by hand.
The catalog directory does not hold every export ever made. Each one keeps what the manifest before it named, and keeps anything written in the last six hours whatever the manifests say, so nothing is taken from a client part way through fetching it — then removes the rest. A weekly refresh settles at roughly 27MB rather than adding 14MB a week forever, and a run of exports in one afternoon settles a few hours after the last of them. Only content-addressed names are touched, so anything else parked there survives.
The bucket #
Read by manaweb-objects, so by the server's own upload and by
manaweb-upload. Unset means no upload is attempted at all, which is the
right state for a checkout with no credentials.
| Variable | Default | What it is |
|---|---|---|
MANAWEB_S3_ENDPOINT |
unset | The bucket's own endpoint, not the domain it is served from. For R2, the account's S3 endpoint. |
MANAWEB_S3_BUCKET |
unset | Bucket name. |
MANAWEB_S3_KEY_ID |
unset | Access key id. |
MANAWEB_S3_SECRET |
unset | Secret access key. For an R2 API token, the SHA-256 of the token value. |
MANAWEB_S3_REGION |
auto |
What R2 wants, and then ignores. |
The endpoint is the one that decides: with it set, the other three are required and a missing one is an error rather than a silent skip.
The client #
Built into the bundle rather than read at run time, so changing one means
building again. Vite reads web/.env for these itself — the one file here
that is read, and not the same file as the server's.
| Variable | Default | What it is |
|---|---|---|
MANAWEB_WEB_RESOLVER |
https://bsky.social |
Where a handle is turned into a DID. |
A browser has no DNS, so somebody with one has to answer. The default is public and is not ours: it stays up when our box does not, and nobody signing in learns where we keep anything. What it has to be is a server that resolves a handle it has never hosted — a PDS running the reference implementation does, an AppView answering out of its own index does not, and a self-hosted handle that has never posted anywhere is exactly the case that tells them apart.
The prefix says which half of Manaweb a variable configures, and it is
longer than MANAWEB_ on purpose: that one names the server's variables, the
bucket's secret is among them, and only what matches the prefix can be read
here at all.
Signing in from a phone needs a client the PDS can fetch. An OAuth
server cannot read a metadata document off a laptop, so for localhost the
client is the hard-coded loopback one and everything about it is read out of
the id. That only answers for a loopback address: reached over a tunnel the
page has a public name, the loopback client is refused, and the dev server
serves a document of its own at /oauth/dev-client-metadata.json naming that
origin. Its scope is read from the committed document rather than written
twice, a scope missing there being refused at PAR rather than at sign-in.
Plain HTTP over the LAN gets neither — the name is not resolvable from
outside and the id has to be https — which is the second reason the tunnel
is how a phone reaches this.
The builder #
manaweb-artwork builds and measures the scanner index. What it works on is
an argument rather than a variable — the cache, a directory, how many to stop
at — and the one variable it reads only changes what it prints.
| Variable | Default | What it is |
|---|---|---|
MANAWEB_SHOTS |
unset | Set to anything, and photos reports every photograph instead of only the ones it got wrong. |
The full listing is what the bands in scanner.md were read
off, so it is the one to run when that table is being extended rather than
consulted.
Secrets #
The key pair is the only secret here, and it belongs in whatever the host
already uses — a password manager, a systemd credential, a container secret.
Not in the repo: .env is ignored and .env.example carries no values.
All four have to arrive as values. Where they are kept as references for something to resolve, handing that file to the process unresolved exports the reference itself, and the failure is a URI parse error from inside the S3 client rather than anything naming the cause.
Scope the R2 token to the one bucket, and give it object read as well as
write — the upload HEADs a name before sending it. See
architecture.md for why.