Meander has two deploy targets, used independently:
- GitHub Pages for walkthrough HTML — the common case. No Val Town involvement, no encryption, GitHub gates access via Pages permissions.
- Val Town for the comment backend (a small Hono val) — the server that handles email magic-code auth, JWT sessions, and the SQLite comment store.
Optionally, Val Town can also host walkthrough HTML blobs (in
addition to or instead of GitHub Pages). That path is opt-in via
encryptBlobs: true in meander.config.json and adds a separate
key ceremony. See encryption.md for the
threat model.
- A Val Town account.
- A Val Town API token — create one at val.town/settings/api.
Meander uses the token in two places, with different scope needs:
| Command | Scope needed |
|---|---|
meander deploy-val |
val:write (create/update vals + env vars) |
meander publish |
blob:write (upload walkthrough HTML) |
meander db key * |
val:write (manage val env vars) |
meander blob key * |
val:write (manage val env vars) |
For CI deploys of the comment backend (via
.github/workflows/valtown.yml), scope the token to
val:write only — nothing broader. No blob, no user. The
publish workflow needs blob:write separately.
Rotate by minting a new token and deleting the old one in Val
Town → Settings → API Tokens. Update the GitHub secret under
Settings → Secrets and variables → Actions.
export VALTOWN_TOKEN=vtwn_...VALTOWN_TOKEN— the API token you just created. If your CI uses a different secret name, setMEANDER_VALTOWN_TOKEN_ENV=MY_NAMEor pass--token-env MY_NAMEto the CLI.
The val itself reads several env vars, all set by meander deploy-val or one of the key ceremonies:
| Var | Set by | Purpose |
|---|---|---|
MEANDER_JWT_SECRET |
deploy-val (preserved) |
Signs session tokens. Rotation logs every user out. |
MEANDER_ADMIN_TOKEN |
deploy-val (preserved) |
Authorizes /admin/* endpoints used by db key ceremonies. |
MEANDER_ALLOWED_EMAIL_DOMAINS |
deploy-val |
Comma-separated allowlist for comment writes. Empty → refused. |
MEANDER_OUT_DIR |
deploy-val |
Blob-key prefix (default pages). |
MEANDER_DEMO_MODE |
deploy-val (--demo-mode) |
When true, writes return 403 + the UI shows a banner. |
MEANDER_DB_KEY_<n> |
meander db key init / rotate |
Comment-store wrapping key, generation N. Hex-encoded. |
MEANDER_DB_KEY_CURRENT |
meander db key init / rotate |
Integer pointing at the current generation. |
MEANDER_BLOB_KEY |
meander blob key init / rotate |
Blob wrapping key (only when encryptBlobs: true). |
deploy-val and the key ceremonies don't overlap: deploy-val
manages code + non-key config; the ceremonies own all key material.
Three steps, in order:
# 1. Deploy the val (creates it the first time, plants JWT secret +
# admin token, sets allowed-domains).
meander deploy-val --allowed-domains=gmail.com,example.com
# 2. Mint the comment-store wrapping key. Custodians need to be
# ready — this command prints shares immediately.
meander db key init walkthrough
# 3. Optional: if encryptBlobs: true in meander.config.json,
# mint the blob wrapping key + add it to your local env.
meander blob key init walkthrough
export MEANDER_BLOB_KEY=$(meander blob key show walkthrough)After step 2, the val accepts comment writes. After step 3, you
can meander publish with envelope-encrypted blobs.
| Flag | Purpose |
|---|---|
--allowed-domains=<csv> |
Email-domain allowlist for comment writes. Empty → writes refused. |
--out-dir=<name> |
Blob-key prefix (default pages). Must match what publish uses. |
--demo-mode |
Deploy with the demo banner + writes returning 403. Public showcases. |
--graceful |
Skip + exit 0 instead of erroring when VALTOWN_TOKEN is unset. |
--token-env=<NAME> |
Override the env-var name to read the bearer token from. |
When you've configured encryptBlobs: true in meander.config.json
and minted MEANDER_BLOB_KEY:
meander generate meander.config.json
meander publish meander.config.jsonpublish envelope-encrypts each generated HTML blob (per-blob DEK,
wrapped under MEANDER_BLOB_KEY) and uploads to Val Town blob
storage under keys like pages/<slug>/part-1.html. The CSS file
is uploaded plaintext (browsers can't read encrypted CSS). After
publish, your walkthrough is live at:
https://<username>-<valname>.web.val.run/<slug>/
When encryptBlobs: false (the default), publish uploads
plaintext bytes — MEANDER_BLOB_KEY is not needed.
Re-run generate + publish whenever the source files or
annotations change. The val itself only needs deploy-val again
when you want to ship a new server version or change non-key
config.
Walkthrough HTML can also be served from GitHub Pages. meander generate emits to <outDir>/ (default pages/); a Pages
workflow uploads that directory:
# .github/workflows/pages.yml — see this repo's own example
- run: pnpm exec meander generate meander.config.json
- uses: actions/upload-pages-artifact@<sha>
with:
path: pagesPages access control is GitHub's responsibility. Comments still
go through the Val Town val (cross-origin requests from the
Pages domain to *.val.run).
- The embedded comment client shows a Sign in to comment button in the top bar.
- Reader enters their email. The val sends a 6-digit code via Val Town's built-in email.
- Reader enters the code. The val returns a JWT valid for 30
days; the client stores it in
localStorageand attaches it to every comment write. - Email domains outside
MEANDER_ALLOWED_EMAIL_DOMAINSare rejected on both/api/auth/requestand the server-side check before a write commits.
meander deploy-val --demo-modeDemo-mode deploys:
- Show a dismissible "demo mode — comments aren't saved" banner in the UI.
- Return 403 on every comment-write endpoint.
- Still serve every page + render the composer (so visitors see the full experience).
Good for public demos where you want to show off the comment UI without collecting real discussions.
CI jobs without VALTOWN_TOKEN (fork PRs, demo setups) can pass
--graceful to deploy-val / publish. Meander logs a skip
message and exits 0 instead of failing the job:
- run: node dist/cli.mjs publish meander.config.json --graceful<outDir>/meander.css shared, plaintext
<outDir>/<slug>/index.html plaintext OR `ENVELOPE:1:...`
<outDir>/<slug>/part-<id>.html plaintext OR `ENVELOPE:1:...`
<outDir>/<slug>/documents.html plaintext OR `ENVELOPE:1:...`
<outDir>/<slug>/manifest.json plaintext build summary
<outDir> defaults to pages. Override via
meander.config.json's outDir field, and pass the same value
to deploy-val --out-dir=… so the val reads from the matching
prefix.
The val recognizes the ENVELOPE:1: prefix per-blob; you can
have a mix of plaintext + encrypted blobs under the same prefix
without breakage.
See operating.md for the day-2 ops runbook — key rotation, restoration drills, custodian responsibilities, backup strategy.