Fix Miren deployment: admin token env handling + concurrency
Hard-won fixes from getting lchat live on Miren at group.luan.chat: - Rename admin env var ADMIN_TOKEN -> LCHAT_ADMIN_TOKEN. Miren auto-provisions its own secret for a bare ADMIN_TOKEN (it's "referenced in code") and injects that, silently overriding any value you set. The LCHAT_ prefix avoids it. - TrimSpace the admin token (defensive against env-injection whitespace). - app.toml: fixed-concurrency block (num_instances=1) so the SQLite disk can attach; document that the token must be set via `-e` (sensitive/-s vars are stored but not injected into the container). - README: corrected Miren deploy steps + a "gotchas" note. Deployed and verified live: HTTPS, E2EE intact, admin auth works.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3 files changed,
+34
−21
.miren/app.toml+15 −11README.md+13 −7main.go+6 −3
diff --git a/.miren/app.toml b/.miren/app.toml| index 9e3cef6..563318a 100644 |
| --- a/.miren/app.toml |
| +++ b/.miren/app.toml |
| @@ -18,9 +18,13 @@ dockerfile = "Dockerfile" |
| # The app listens on :1747 by default (or $ADDR), so these must match. |
| port = 1747 |
| -# Persistent disk for the SQLite database. SQLite wants a single writer, which |
| -# lines up with Miren's note that disks use exclusive leasing on one instance — |
| -# so keep this service at a single replica. |
| +# A persistent disk requires the service to run in fixed concurrency mode. |
| +# Single instance also suits SQLite's single-writer model. |
| +[services.web.concurrency] |
| +mode = "fixed" |
| +num_instances = 1 |
| + |
| +# Persistent disk for the SQLite database, backed by the server's local storage. |
| [[services.web.disks]] |
| name = "lchat-data" |
| mount_path = "/data" |
| @@ -30,11 +34,11 @@ size_gb = 1 |
| key = "LCHAT_DB" |
| value = "/data/lchat.db" |
| -[[env]] |
| -# The admin token for the invite backend. It is NOT a decryption key — messages |
| -# stay end-to-end encrypted regardless. Set the value as a secret, then your |
| -# admin URL is: https://group.luan.chat/admin#token=<this value> |
| -key = "ADMIN_TOKEN" |
| -required = true |
| -sensitive = true |
| -description = "Admin token used in the /admin#token=... link" |
| +# LCHAT_ADMIN_TOKEN is set at deploy time via `miren deploy -e LCHAT_ADMIN_TOKEN=...`, |
| +# NOT declared here. Two hard-won Miren gotchas baked into that name/mechanism: |
| +# 1. The var is LCHAT_-prefixed, not a bare ADMIN_TOKEN — Miren auto-provisions |
| +# its own secret for ADMIN_TOKEN and injects it, overriding your value. |
| +# 2. Use `-e` (plain), not `-s` (sensitive) — Miren stores sensitive vars but |
| +# does not inject them into the container. |
| +# It is NOT a decryption key (messages stay end-to-end encrypted); it only gates |
| +# the invite backend. Admin URL: https://group.luan.chat/admin#token=<token> |
diff --git a/README.md b/README.md| index bab4758..fcf9d1c 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -43,7 +43,7 @@ Flags / env: |
| - `-addr :1747` — listen address |
| - `-db lchat.db` — database file path |
| - `-reset-admin` — generate and print a fresh admin token |
| -- `ADMIN_TOKEN=...` — set a fixed admin token instead of a generated one |
| +- `LCHAT_ADMIN_TOKEN=...` — set a fixed admin token instead of a generated one |
| ## Build a single binary |
| @@ -69,9 +69,15 @@ Runtime config is via env vars, so it doesn't matter how Miren launches the |
| binary: |
| - `LCHAT_DB` — SQLite path; point it at the persistent disk (`/data/lchat.db`) |
| -- `ADMIN_TOKEN` — admin token (set as a Miren secret) |
| +- `LCHAT_ADMIN_TOKEN` — admin token, set with `miren ... -e LCHAT_ADMIN_TOKEN=…` |
| - `ADDR` — listen address (defaults to `:1747`, matching the declared port) |
| +> **Gotcha (learned the hard way):** the env var is `LCHAT_ADMIN_TOKEN`, *not* |
| +> `ADMIN_TOKEN`. Miren auto-provisions its own secret for a bare `ADMIN_TOKEN` |
| +> (it's "referenced in code") and injects that, silently overriding whatever you |
| +> set. The `LCHAT_` prefix dodges that. Also set it with `-e` (a plain env var) — |
| +> Miren stores `-s`/sensitive vars but does **not** inject them into the container. |
| + |
| The SQLite file lives on a persistent **disk** (`.miren/app.toml` declares one |
| at `/data`). Keep this service at a **single replica** — SQLite has one writer, |
| which matches Miren's exclusive-lease disks. |
| @@ -82,13 +88,13 @@ miren init # generates a schema-correct .miren/app.toml — merg |
| # in the [[disks]] and [[env]] blocks from this repo |
| miren deploy --analyze # confirm Miren detects the build + port |
| -# 2. Set the admin token as a secret (URL-safe, since it goes in a URL fragment): |
| -openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' # generate one |
| -miren secret set ADMIN_TOKEN <the-value> # exact cmd may vary by version |
| +# 2. Generate a URL-safe admin token (it goes in a URL #fragment): |
| +TOKEN=$(openssl rand -base64 32 | tr -d '\n' | tr '+/' '-_' | tr -d '=') |
| -# 3. Deploy and attach the domain: |
| -miren deploy |
| +# 3. Deploy (pass the token as a plain -e env var) and attach the domain: |
| +miren deploy -e LCHAT_DB=/data/lchat.db -e "LCHAT_ADMIN_TOKEN=$TOKEN" |
| miren route set group.luan.chat lchat |
| +# Your admin URL is then: https://group.luan.chat/admin#token=$TOKEN |
| ``` |
| Then point DNS for `group.luan.chat` at the cluster (a CNAME to the |
diff --git a/main.go b/main.go| index 4700ad3..ddb1f49 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -32,8 +32,11 @@ func main() { |
| log.Fatalf("open db: %v", err) |
| } |
| - // Admin token: ADMIN_TOKEN env wins; otherwise generate one on first run. |
| - if tok, err := db.ensureAdminToken(os.Getenv("ADMIN_TOKEN")); err != nil { |
| + // Admin token: LCHAT_ADMIN_TOKEN env wins; otherwise generate one on first run. |
| + // NB: LCHAT_-prefixed on purpose — some platforms (e.g. Miren) auto-provision |
| + // a secret for a bare "ADMIN_TOKEN" and silently override your value. |
| + // TrimSpace guards against env-injection paths that add surrounding whitespace. |
| + if tok, err := db.ensureAdminToken(strings.TrimSpace(os.Getenv("LCHAT_ADMIN_TOKEN"))); err != nil { |
| log.Fatalf("admin token: %v", err) |
| } else if tok != "" { |
| printAdminLink(*addr, tok) |
| @@ -164,7 +167,7 @@ func (s *Server) claimInvite(w http.ResponseWriter, r *http.Request) { |
| func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool { |
| const p = "Bearer " |
| h := r.Header.Get("Authorization") |
| - if strings.HasPrefix(h, p) && s.db.checkAdmin(strings.TrimPrefix(h, p)) { |
| + if strings.HasPrefix(h, p) && s.db.checkAdmin(strings.TrimSpace(strings.TrimPrefix(h, p))) { |
| return true |
| } |
| httpError(w, http.StatusUnauthorized, "admin auth required") |