eleven / messenger Public

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>
Paul Campbell fac3d75d24eb72fd377e87ae04a2a5a5ee1f00e6 parent 165d689
3 files changed, +34 −21
  • .miren/app.toml +15 −11
  • README.md +13 −7
  • main.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")