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:
The Val Town blob publish loop steps
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). An
encrypted blob makes the walkthrough private: the val asks the
reader to sign in before it decrypts, and omits the slug from /
for callers who can't open it. After publish, your walkthrough is
live at:
https://<username>-<valname>.web.val.run/<slug>/
Readers reach it by signing in with an email on
MEANDER_ALLOWED_EMAIL_DOMAINS; set that variable before you
publish encrypted blobs, or nobody (the operator included) can
read them or their comments. See encryption.md
for the cookie and what stays visible without one.
publish records the walkthrough as private as part of the upload,
which is how the val gates its comment reads without a blob probe.
Walkthroughs published before that record existed are treated as
private until the val or the next publish settles the question, so
re-run meander publish for each public walkthrough after you deploy
this val to restore anonymous comment reads on them without waiting
for the first request to derive it.
When encryptBlobs: false (the default), publish uploads
plaintext bytes - MEANDER_BLOB_KEY is not needed, and the
walkthrough is public.
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.