eleven / messenger Public

server: public status dashboards — fleet state + the books (aggregates only) (#687)

The transparency direction from #550 Parts 11/12, running half: two
world-readable pages published every 5 minutes from the router box.

- deploy/status-publish.sh — box timer (lchat user; unit+timer written by
  its install step, mirroring fleet-backup.sh). Builds fleet.json (registry
  counts; awake = socket bound+listening via ss -xlH, since slept instances
  leave stale socket files) and books.json (Stripe aggregates: active
  subscriptions by tier, EUR MRR, gross collected this month; degrades to
  "unavailable" rather than publishing zeros). Privacy invariants commented
  and self-checked: no hostnames, no per-payer data, ever (decision #19 /
  invariant 12). --dry-run builds both JSONs from fake fixtures.
- web/status/{index,books}.html — self-contained static viewers (inline
  CSS/JS, relative fetches, light+dark, WCAG 2.2 AA, validated palette,
  Eleven brand mark copied from web/static/icon-192.png).
- infra/prod/status.tf — eleven-status bucket mirroring releases.tf
  (versioned, GET-only public read, status-publisher IAM user with no
  Delete). NOT applied.
- test/status-contract.test.mjs + fixtures — pins each page's FIELDS
  manifest against the generator's dry-run output; wired into npm test.
- docs/status.md + planet-scale.md roadmap note (dashboards shipped,
  ledger repo still to come). Changelog entry held for the go-live
  announcement.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 84a567bbbb80fc8339dc6ce695b372ee305ffdce parent da710a7
12 files changed, +1316 −2
  • deploy/status-publish.sh +504 −0
  • docs/planet-scale.md +4 −1
  • docs/status.md +117 −0
  • infra/prod/status.tf +122 −0
  • package.json +1 −1
  • test/status-contract.test.mjs +102 −0
  • test/status-fixtures/books-unavailable.json +1 −0
  • test/status-fixtures/books.json +1 −0
  • test/status-fixtures/fleet.json +1 −0
  • web/status/books.html +215 −0
  • web/status/icon-192.png binary
  • web/status/index.html +248 −0
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh
new file mode 100755
index 0000000..9bb86d6
--- /dev/null
+++ b/deploy/status-publish.sh
@@ -0,0 +1,504 @@
+#!/usr/bin/env bash
+# 🤖 Public status publish (issue #550 Parts 11/12): every 5 minutes, build two
+# small world-readable JSONs — fleet.json (fleet state) and books.json (the
+# books: Stripe aggregates) — and upload them plus the static viewer pages to
+# the public status bucket (infra/prod/status.tf). docs/status.md is the
+# operator guide.
+#
+# ============================ PRIVACY — NON-NEGOTIABLE ========================
+# Everything this script uploads is PUBLIC. The rules (decision #19 in #550:
+# income publishes AGGREGATED, never per-payer; invariant 12: no PII in our
+# stores, so none can leak here — but we hold the line at the aggregation
+# boundary anyway):
+#
+# fleet.json — AGGREGATES ONLY. Totals, counts, version distribution,
+# service up/down booleans, replication health. NEVER a hostname (member
+# spaces obviously, but also test-* canaries — the simple rule is NO
+# hostnames at all), never per-space member/message counts, never anything
+# keyed by space. Hostnames are read internally (to match sockets) and
+# must never reach the JSON.
+#
+# books.json — AGGREGATED INCOME ONLY. Active-subscription count (by tier —
+# tier names are the public price book), MRR total in EUR, gross collected
+# this month. NEVER customer ids, emails, subscription ids, per-payer
+# lines, or timestamps of individual payments. The Stripe key is read for
+# the API calls and must never be echoed, logged, or uploaded.
+#
+# Adding a field? It must be an aggregate over the whole fleet / all payers,
+# or it doesn't ship.
+# ==============================================================================
+#
+# Privilege: runs as the lchat user, not root — least privilege that works:
+# - /data/registry.db and /data/cloud.env are lchat-owned on the hardened
+# box (all units run as lchat since PR #186; /data is owned by it).
+# - `systemctl is-active` and `ss -xlH` are unprivileged read-only queries.
+# - the journal read (replication lag) needs the systemd-journal group,
+# granted via SupplementaryGroups in the unit below.
+# Only `install` (writing units, enabling the timer) needs root.
+#
+# Usage:
+# status-publish.sh # build + upload (what the timer runs)
+# status-publish.sh install # root: install unit + timer, enable
+# status-publish.sh --dry-run DIR # build both JSONs into DIR from
+# # self-constructed fake registry/Stripe
+# # fixtures; no box state touched, no
+# # upload — the committed contract test
+#
+# Prerequisites on the box:
+# /data/status.env — AWS creds for the status-publisher IAM user
+# (infra/prod/status.tf; Put/Get/List on the status
+# bucket only, no Delete). chown lchat, chmod 0600.
+# /opt/lchat/status-pages/ — the viewer pages, copied from web/status/
+# (index.html, books.html, icon-192.png).
+# aws CLI + sqlite3 (apt install awscli sqlite3).
+set -euo pipefail
+
+BUCKET=${STATUS_BUCKET:-eleven-status-427880590669}
+REGION=${STATUS_REGION:-eu-west-1}
+REGISTRY_DB=${STATUS_REGISTRY_DB:-/data/registry.db}
+CLOUD_ENV=${STATUS_CLOUD_ENV:-/data/cloud.env}
+STATUS_ENV=${STATUS_ENV_FILE:-/data/status.env}
+PAGES_DIR=${STATUS_PAGES_DIR:-/opt/lchat/status-pages}
+STRIPE_API=${STATUS_STRIPE_API:-https://api.stripe.com}
+SELF=/opt/lchat/status-publish.sh
+UNIT=/etc/systemd/system/lchat-status.service
+TIMER=/etc/systemd/system/lchat-status.timer
+
+DRY_RUN=""
+FIXDIR=""
+
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+
+# sq runs a query against an on-disk DB with a busy timeout (the router writes
+# the registry concurrently); sqj evaluates JSON expressions in :memory:. All
+# JSON this script emits is BUILT BY SQLITE's json functions, never by string
+# interpolation in bash — DB/Stripe-sourced strings (versions, tier keys) get
+# escaped correctly and can't break or inject into the output.
+sq() { sqlite3 -cmd '.timeout 3000' "$REGISTRY_DB" "$1"; }
+sqj() { sqlite3 :memory: "$1"; }
+
+now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
+
+# ---- box probes (each has a dry-run twin so --dry-run exercises the same
+# ---- aggregation code with no live systemctl/ss/journalctl) ------------------
+
+# svc_active <unit> — unprivileged read-only state query.
+svc_active() {
+ if [ -n "$DRY_RUN" ]; then grep -qxF "$1" "$FIXDIR/active-units.txt"; return; fi
+ systemctl is-active --quiet "$1"
+}
+
+# listening_sockets — the kernel's list of LISTENING unix sockets. This is the
+# awake test for hibernating hosts, and the choice matters:
+# - Socket-file EXISTENCE is NOT sound: an instance unlinks a stale socket
+# file on start (main.go listen()) but never unlinks on exit, so a slept
+# instance leaves the file behind. Existence would count every host that
+# has ever woken as awake forever.
+# - Dialing the socket (curl --unix-socket) IS sound but touches the
+# instance — a probe every 5 minutes must not risk interacting with the
+# activator's idle accounting or show up in instance logs.
+# - `ss -xlH` reads the socket table: an entry exists iff a process has the
+# path BOUND AND LISTENING right now (the activator's children bind the
+# same /run/lchat/<host>.sock path systemd units use). Zero side effects.
+listening_sockets() {
+ if [ -n "$DRY_RUN" ]; then cat "$FIXDIR/listening.txt"; return; fi
+ if command -v ss >/dev/null 2>&1; then ss -xlH 2>/dev/null || true; fi
+}
+
+# replication_lag_json — seconds since the fleet replicator (lchat-litestream,
+# deploy/fleet-backup.sh) last logged anything: an approximation of replication
+# lag (litestream logs each sync; a healthy replicator is chatty, a wedged one
+# goes silent). "null" when the journal is unreadable or empty — published as
+# null, never faked.
+replication_lag_json() {
+ if [ -n "$DRY_RUN" ]; then cat "$FIXDIR/replication-lag.txt"; return; fi
+ local line ts now
+ line=$(journalctl -u lchat-litestream -n1 -o json 2>/dev/null || true)
+ if [ -z "$line" ]; then echo "null"; return; fi
+ printf '%s' "$line" > "$TMP/journal.json"
+ ts=$(sqj "SELECT CAST(json_extract(readfile('$TMP/journal.json'), '\$.__REALTIME_TIMESTAMP') AS INTEGER) / 1000000" || echo "")
+ if [ -z "$ts" ] || [ "$ts" = "0" ]; then echo "null"; return; fi
+ now=$(date -u +%s)
+ echo $((now - ts))
+}
+
+# ---- fleet.json --------------------------------------------------------------
+
+build_fleet() {
+ local out=$1 now total systemd_n hib awake_json asleep_json versions
+ now=$(now_iso)
+
+ total=$(sq "SELECT COUNT(*) FROM routes WHERE kind='instance'")
+ hib=$(sq "SELECT COUNT(*) FROM routes WHERE kind='instance' AND hibernate=1")
+ systemd_n=$((total - hib))
+
+ # Awake = hibernating hosts whose socket is currently bound+listening (see
+ # listening_sockets above). Hostnames stay inside this loop — only the
+ # counts leave it. If `ss` is unavailable the split is published as null
+ # (unknown), never guessed.
+ if [ -n "$DRY_RUN" ] || command -v ss >/dev/null 2>&1; then
+ local listening awake=0 sock
+ listening=$(listening_sockets)
+ while IFS= read -r sock; do
+ [ -z "$sock" ] && continue
+ # -F fixed-string match: every socket path starts /run/lchat/, so one
+ # path can never be a substring of another host's line.
+ if printf '%s\n' "$listening" | grep -qF "$sock"; then awake=$((awake+1)); fi
+ done < <(sq "SELECT socket FROM routes WHERE kind='instance' AND hibernate=1")
+ awake_json=$awake
+ asleep_json=$((hib - awake))
+ else
+ awake_json=null
+ asleep_json=null
+ fi
+
+ # Version distribution: version strings and counts only (git SHAs /
+ # release versions — already public via the signed release feed). No
+ # hostnames, ever.
+ versions=$(sq "SELECT COALESCE(json_group_array(json_object('version', v, 'count', c)), json('[]'))
+ FROM (SELECT CASE WHEN version='' THEN 'unknown' ELSE version END AS v, COUNT(*) AS c
+ FROM routes WHERE kind='instance' GROUP BY v ORDER BY c DESC, v)")
+
+ local home_up=false push_up=false cloud_up=false connect_up=false rep_ok=false
+ svc_active lchat-home && home_up=true
+ svc_active lchat-push && push_up=true
+ svc_active lchat-cloud && cloud_up=true
+ svc_active lchat-connect && connect_up=true
+ svc_active lchat-litestream && rep_ok=true
+ local lag
+ lag=$(replication_lag_json)
+
+ # Assembled by sqlite json_object: the only interpolated values are
+ # script-controlled numerals/booleans and the sqlite-built versions array.
+ sqj "SELECT json_object(
+ 'generated_at', '$now',
+ 'instances', json_object(
+ 'total', $total,
+ 'systemd', $systemd_n,
+ 'hibernating', $hib,
+ 'awake', json('$awake_json'),
+ 'asleep', json('$asleep_json')),
+ 'versions', json('$(printf '%s' "$versions" | sed "s/'/''/g")'),
+ 'services', json_object(
+ 'home', json('$home_up'),
+ 'push', json('$push_up'),
+ 'cloud', json('$cloud_up'),
+ 'connect', json('$connect_up')),
+ 'replication', json_object(
+ 'healthy', json('$rep_ok'),
+ 'lag_seconds', json('$lag')))" > "$out"
+}
+
+# ---- books.json --------------------------------------------------------------
+
+# stripe_get <path-and-query> <outfile> — one authenticated GET. The key rides
+# only in the header; -sS keeps curl from ever printing it. Dry-run reads
+# numbered fixture files instead (same downstream aggregation code).
+STRIPE_FIXTURE_N=0
+stripe_get() {
+ if [ -n "$DRY_RUN" ]; then
+ STRIPE_FIXTURE_N=$((STRIPE_FIXTURE_N + 1))
+ local kind=subs
+ case "$1" in /v1/balance_transactions*) kind=bal ;; esac
+ cp "$FIXDIR/stripe-$kind-$STRIPE_FIXTURE_N.json" "$2"
+ return
+ fi
+ curl -sS --fail --max-time 30 -H "Authorization: Bearer $STRIPE_KEY" \
+ "$STRIPE_API$1" > "$2"
+}
+
+# fetch_pages <path> <query> <prefix> — follow Stripe's cursor pagination
+# (has_more + starting_after) into $TMP/<prefix>-N.json. Hard cap of 50 pages
+# (5000 objects) keeps a pathological account from wedging the run. Every
+# failure is an explicit `return 1` — callers run this inside an `if !`, where
+# set -e is suspended, and a half-fetched month must degrade to "unavailable",
+# never aggregate to zeros that read as "no income".
+fetch_pages() {
+ local path=$1 query=$2 prefix=$3 after="" n=0 f has_more
+ while :; do
+ n=$((n + 1))
+ f="$TMP/$prefix-$n.json"
+ stripe_get "$path?limit=100${query:+&$query}${after:+&starting_after=$after}" "$f" || return 1
+ # A page that isn't a valid list envelope is a failed/garbled fetch.
+ [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.data')='array'")" = "1" ] || return 1
+ has_more=$(sqj "SELECT json_extract(readfile('$f'), '\$.has_more')")
+ [ "$has_more" = "1" ] && [ "$n" -lt 50 ] || break
+ after=$(sqj "SELECT json_extract(readfile('$f'), '\$.data[#-1].id')")
+ [ -n "$after" ] || return 1
+ done
+ # Reset the fixture counter so the next endpoint's dry-run pages start at 1.
+ STRIPE_FIXTURE_N=0
+}
+
+books_unavailable() { # $1=out $2=reason (a fixed, script-controlled string)
+ sqj "SELECT json_object('generated_at', '$(now_iso)', 'available', json('false'), 'reason', '$2')" > "$1"
+}
+
+build_books() {
+ local out=$1 now month month_start
+ now=$(now_iso)
+
+ # The Stripe key comes from the cloud provisioner's env (billing.go reads the
+ # same var). Absent → billing isn't configured here; publish an honest
+ # "unavailable" rather than zeros that would read as "no income".
+ STRIPE_KEY=$(grep -E '^LCHAT_STRIPE_SECRET_KEY=' "$CLOUD_ENV" 2>/dev/null | head -n1 | cut -d= -f2- || true)
+ if [ -z "${STRIPE_KEY:-}" ]; then
+ books_unavailable "$out" "billing not configured"
+ return
+ fi
+
+ # Month boundary via sqlite (portable — GNU/BSD date flags differ).
+ month=$(sqj "SELECT strftime('%Y-%m','now')")
+ month_start=$(sqj "SELECT strftime('%s', datetime('now','start of month'))")
+
+ # The two aggregation sources (decision #19 — these are the ONLY Stripe
+ # reads, both fleet-wide):
+ # GET /v1/subscriptions?status=active&limit=100 (+ cursor pages)
+ # → active count, per-tier counts (metadata.tier = public price-book
+ # key), MRR from each item's price amount normalized to monthly.
+ # GET /v1/balance_transactions?created[gte]=<month start>&limit=100
+ # → gross collected this month (positive charge/payment amounts).
+ # Degrade to "unavailable" on any fetch failure — a Stripe outage must not
+ # publish half-numbers.
+ if ! fetch_pages /v1/subscriptions "status=active" subs ||
+ ! fetch_pages /v1/balance_transactions "created[gte]=$month_start" bal; then
+ books_unavailable "$out" "stripe unreachable"
+ return
+ fi
+
+ # Aggregate with sqlite json_each — never per-object output, only SUM/COUNT/
+ # GROUP BY. Per-payer fields (ids, customer, created) die inside this query.
+ {
+ echo "CREATE TABLE subs(j TEXT);"
+ echo "CREATE TABLE bal(j TEXT);"
+ local f
+ for f in "$TMP"/subs-*.json; do
+ echo "INSERT INTO subs SELECT value FROM json_each(readfile('$f'), '\$.data');"
+ done
+ for f in "$TMP"/bal-*.json; do
+ echo "INSERT INTO bal SELECT value FROM json_each(readfile('$f'), '\$.data');"
+ done
+ # MRR: unit_amount × quantity per item, normalized to a month. EUR items
+ # only (the price book is EUR; mixing currencies without FX would lie).
+ # Unknown intervals contribute 0 to MRR but still count as subscribers.
+ cat <<SQL
+SELECT json_object(
+ 'generated_at', '$now',
+ 'available', json('true'),
+ 'currency', 'eur',
+ 'month', '$month',
+ 'subscriptions', json_object(
+ 'active', (SELECT COUNT(*) FROM subs),
+ 'by_tier', (SELECT COALESCE(json_group_array(json_object('tier', tier, 'count', c)), json('[]'))
+ FROM (SELECT COALESCE(NULLIF(json_extract(j,'\$.metadata.tier'),''),'other') AS tier,
+ COUNT(*) AS c
+ FROM subs GROUP BY tier ORDER BY c DESC, tier))),
+ 'mrr_cents', CAST(ROUND(COALESCE((
+ SELECT SUM(
+ json_extract(i.value,'\$.price.unit_amount')
+ * COALESCE(json_extract(i.value,'\$.quantity'),1)
+ * (CASE json_extract(i.value,'\$.price.recurring.interval')
+ WHEN 'month' THEN 1.0
+ WHEN 'year' THEN 1.0/12.0
+ WHEN 'week' THEN 52.0/12.0
+ WHEN 'day' THEN 365.0/12.0
+ ELSE 0 END)
+ / COALESCE(json_extract(i.value,'\$.price.recurring.interval_count'),1))
+ FROM subs s, json_each(s.j, '\$.items.data') i
+ WHERE json_extract(i.value,'\$.price.currency')='eur'), 0)) AS INTEGER),
+ 'collected_month_cents', CAST(COALESCE((
+ SELECT SUM(json_extract(b.j,'\$.amount')) FROM bal b
+ WHERE json_extract(b.j,'\$.type') IN ('charge','payment')
+ AND json_extract(b.j,'\$.currency')='eur'
+ AND json_extract(b.j,'\$.amount') > 0), 0) AS INTEGER));
+SQL
+ } | sqlite3 :memory: > "$out"
+}
+
+# ---- upload ------------------------------------------------------------------
+
+upload() { # file key content-type cache-control
+ aws s3 cp "$1" "s3://$BUCKET/$2" --region "$REGION" \
+ --content-type "$3" --cache-control "$4" --no-progress >/dev/null
+}
+
+publish() {
+ command -v aws >/dev/null 2>&1 || { echo "aws CLI missing (apt install awscli)" >&2; exit 1; }
+ # Creds for the scoped status-publisher IAM user (never in tofu state; mint
+ # by hand per infra/prod/status.tf). The unit injects them via
+ # EnvironmentFile; a manual run sources the same file.
+ if [ -z "${AWS_ACCESS_KEY_ID:-}" ] && [ -f "$STATUS_ENV" ]; then
+ set -a; . "$STATUS_ENV"; set +a
+ fi
+
+ build_fleet "$TMP/fleet.json"
+ build_books "$TMP/books.json"
+
+ # JSONs: short cache so "as of" stays honest at the 5-minute cadence.
+ upload "$TMP/fleet.json" fleet.json "application/json" "max-age=60"
+ upload "$TMP/books.json" books.json "application/json" "max-age=60"
+
+ # The viewer pages + brand mark (from web/status/, staged on the box).
+ # Re-uploading tiny idempotent files every run self-heals a missed copy.
+ if [ -d "$PAGES_DIR" ]; then
+ [ -f "$PAGES_DIR/index.html" ] && upload "$PAGES_DIR/index.html" index.html "text/html; charset=utf-8" "max-age=300"
+ [ -f "$PAGES_DIR/books.html" ] && upload "$PAGES_DIR/books.html" books.html "text/html; charset=utf-8" "max-age=300"
+ [ -f "$PAGES_DIR/icon-192.png" ] && upload "$PAGES_DIR/icon-192.png" icon-192.png "image/png" "max-age=86400"
+ else
+ echo "warning: $PAGES_DIR missing — JSONs published, pages skipped (copy web/status/* there)" >&2
+ fi
+ echo "published to s3://$BUCKET (fleet + books, $(now_iso))"
+}
+
+# ---- install (root) ----------------------------------------------------------
+
+install_units() {
+ [ "$(id -u)" = 0 ] || { echo "install needs root (writes systemd units)" >&2; exit 1; }
+ [ -f "$STATUS_ENV" ] || { echo "missing $STATUS_ENV (AWS creds for status-publisher) — mint per infra/prod/status.tf" >&2; exit 1; }
+
+ if [ "$(readlink -f "$0" 2>/dev/null || echo "$0")" != "$SELF" ]; then
+ cp "$0" "$SELF" && chmod 0755 "$SELF"
+ fi
+ mkdir -p "$PAGES_DIR"
+
+ # Same hardening posture as the litestream unit. ReadWritePaths=/data is
+ # required even though we only SELECT: WAL readers take shared-memory locks
+ # (writes to the -shm file), so a read-only /data breaks registry reads.
+ cat > "$UNIT" <<'EOF'
+[Unit]
+Description=Eleven public status publish (fleet + books aggregates)
+Wants=network-online.target
+After=network-online.target
+
+[Service]
+Type=oneshot
+User=lchat
+Group=lchat
+SupplementaryGroups=systemd-journal
+EnvironmentFile=/data/status.env
+ExecStart=/opt/lchat/status-publish.sh
+NoNewPrivileges=true
+ProtectSystem=strict
+ReadWritePaths=/data
+ProtectHome=true
+PrivateTmp=true
+MemoryMax=256M
+EOF
+
+ # Persistent=false on purpose: a missed run's output is superseded by the
+ # next one five minutes later — catch-up publishes are pure noise.
+ cat > "$TIMER" <<'EOF'
+[Unit]
+Description=Eleven public status publish — every 5 minutes
+
+[Timer]
+OnBootSec=2min
+OnUnitActiveSec=5min
+RandomizedDelaySec=20
+Persistent=false
+
+[Install]
+WantedBy=timers.target
+EOF
+
+ systemctl daemon-reload
+ systemctl enable --now lchat-status.timer
+ echo "installed — verify with: systemctl list-timers lchat-status.timer; journalctl -u lchat-status -n 20"
+ echo "remember: copy web/status/{index.html,books.html,icon-192.png} to $PAGES_DIR"
+}
+
+# ---- dry-run: self-constructed fixtures, real aggregation code ----------------
+
+dry_run() {
+ local outdir=$1
+ mkdir -p "$outdir"
+ DRY_RUN=1
+ FIXDIR="$TMP/fixtures"
+ mkdir -p "$FIXDIR"
+ REGISTRY_DB="$FIXDIR/registry.db"
+ CLOUD_ENV="$FIXDIR/cloud.env"
+
+ # A fake registry: 6 instances (2 systemd-managed, 4 hibernating of which 2
+ # are awake), 2 service rows (must not count as instances), 3 versions.
+ sqlite3 "$REGISTRY_DB" <<'SQL'
+CREATE TABLE routes (
+ host TEXT PRIMARY KEY, kind TEXT NOT NULL, socket TEXT NOT NULL,
+ version TEXT NOT NULL DEFAULT '', hibernate INTEGER NOT NULL DEFAULT 0
+);
+INSERT INTO routes VALUES
+ ('alpha.example', 'instance', '/run/lchat/alpha.example.sock', 'v100', 0),
+ ('bravo.example', 'instance', '/run/lchat/bravo.example.sock', 'v100', 0),
+ ('charlie.example', 'instance', '/run/lchat/charlie.example.sock', 'v100', 1),
+ ('delta.example', 'instance', '/run/lchat/delta.example.sock', 'v101', 1),
+ ('echo.example', 'instance', '/run/lchat/echo.example.sock', 'v101', 1),
+ ('foxtrot.example', 'instance', '/run/lchat/foxtrot.example.sock', '', 1),
+ ('home.example', 'service', '/run/lchat/home.sock', '', 0),
+ ('push.example', 'service', '/run/lchat/push.sock', '', 0);
+SQL
+
+ # ss -xlH shape: charlie + delta listening (awake), echo + foxtrot not.
+ cat > "$FIXDIR/listening.txt" <<'EOF'
+u_str LISTEN 0 4096 /run/lchat/alpha.example.sock 12001 * 0
+u_str LISTEN 0 4096 /run/lchat/bravo.example.sock 12002 * 0
+u_str LISTEN 0 4096 /run/lchat/charlie.example.sock 12003 * 0
+u_str LISTEN 0 4096 /run/lchat/delta.example.sock 12004 * 0
+EOF
+ printf '%s\n' lchat-home lchat-push lchat-cloud lchat-litestream > "$FIXDIR/active-units.txt" # connect down on purpose
+ echo 42 > "$FIXDIR/replication-lag.txt"
+
+ # STATUS_DRY_BOOKS=unavailable exercises the degraded books path (no key).
+ if [ "${STATUS_DRY_BOOKS:-}" = unavailable ]; then
+ : > "$FIXDIR/cloud.env"
+ else
+ echo "LCHAT_STRIPE_SECRET_KEY=sk_test_dryrun_not_a_real_key" > "$FIXDIR/cloud.env"
+ fi
+
+ # Two subscription pages (exercises pagination) + one balance page. Shapes
+ # follow Stripe's list envelope; per-payer fields present in the fixture
+ # (ids, customer) must NOT survive into books.json — that's the test.
+ cat > "$FIXDIR/stripe-subs-1.json" <<'EOF'
+{"object":"list","has_more":true,"data":[
+ {"id":"sub_fixture_a","customer":"cus_fixture_a","status":"active","metadata":{"tier":"friends","space_id":"7"},
+ "items":{"data":[{"quantity":1,"price":{"unit_amount":400,"currency":"eur","recurring":{"interval":"month","interval_count":1}}}]}},
+ {"id":"sub_fixture_b","customer":"cus_fixture_b","status":"active","metadata":{"tier":"family","space_id":"9"},
+ "items":{"data":[{"quantity":1,"price":{"unit_amount":800,"currency":"eur","recurring":{"interval":"month","interval_count":1}}}]}}
+]}
+EOF
+ cat > "$FIXDIR/stripe-subs-2.json" <<'EOF'
+{"object":"list","has_more":false,"data":[
+ {"id":"sub_fixture_c","customer":"cus_fixture_c","status":"active","metadata":{"tier":"friends","space_id":"12"},
+ "items":{"data":[{"quantity":1,"price":{"unit_amount":4800,"currency":"eur","recurring":{"interval":"year","interval_count":1}}}]}}
+]}
+EOF
+ cat > "$FIXDIR/stripe-bal-1.json" <<'EOF'
+{"object":"list","has_more":false,"data":[
+ {"id":"txn_fixture_1","type":"charge","amount":400,"currency":"eur","created":1751328000},
+ {"id":"txn_fixture_2","type":"charge","amount":800,"currency":"eur","created":1751414400},
+ {"id":"txn_fixture_3","type":"refund","amount":-400,"currency":"eur","created":1751500800},
+ {"id":"txn_fixture_4","type":"payout","amount":-1200,"currency":"eur","created":1751587200}
+]}
+EOF
+
+ build_fleet "$outdir/fleet.json"
+ build_books "$outdir/books.json"
+
+ # Self-checks: valid JSON, and no per-payer fixture value leaked through.
+ sqj "SELECT CASE WHEN json_valid(readfile('$outdir/fleet.json')) AND json_valid(readfile('$outdir/books.json')) THEN 'ok' ELSE 'INVALID' END" \
+ | grep -qx ok || { echo "dry-run: output is not valid JSON" >&2; exit 1; }
+ if grep -qE 'sub_fixture|cus_fixture|txn_fixture|\.example' "$outdir/fleet.json" "$outdir/books.json"; then
+ echo "dry-run: PRIVACY LEAK — a per-payer id or hostname reached the output" >&2
+ exit 1
+ fi
+ echo "--- fleet.json ---"; cat "$outdir/fleet.json"
+ echo "--- books.json ---"; cat "$outdir/books.json"
+ echo "dry-run ok: valid JSON, no hostnames, no per-payer fields"
+}
+
+case "${1:-}" in
+ install) install_units ;;
+ --dry-run) [ -n "${2:-}" ] || { echo "usage: $0 --dry-run <outdir>" >&2; exit 1; }
+ dry_run "$2" ;;
+ "") publish ;;
+ *) echo "usage: $0 [install | --dry-run <outdir>]" >&2; exit 1 ;;
+esac
diff --git a/docs/planet-scale.md b/docs/planet-scale.md
index d1de754..d6bc78c 100644
--- a/docs/planet-scale.md
+++ b/docs/planet-scale.md
@@ -372,7 +372,10 @@ a seam). Build triggers are events, not dates.
- **Observability gauges** — wake latency, lease conflicts, replication lag
are loud log lines today, not metrics; Part 9's E2EE-safe metrics discipline
is still owed.
-- **The public ledger** — Part 11's append-only books repo + static viewer.
+- **The public ledger** — Part 11's append-only books repo. Partially
+ shipped: the public *status dashboards* exist (fleet state + aggregated
+ Stripe income, published from the box every 5 minutes — `docs/status.md`);
+ the itemized expense/disbursement ledger repo is still to come.
## The historical record
diff --git a/docs/status.md b/docs/status.md
new file mode 100644
index 0000000..f83e373
--- /dev/null
+++ b/docs/status.md
@@ -0,0 +1,117 @@
+# 🤖 Public status dashboards — fleet state & the books
+
+Two world-readable pages, published every 5 minutes from the router box: the
+running half of #550 Part 11/12's transparency direction (the append-only
+public *ledger* repo is still roadmap — see `docs/planet-scale.md`).
+
+- **Fleet status** (`index.html` + `fleet.json`) — how many instances exist,
+ how many are always-on vs hibernating, how many of the hibernating ones are
+ awake right now, the version distribution, whether the shared services
+ (home / push / cloud / connect) are up, and fleet-backup replication health.
+- **The books** (`books.html` + `books.json`) — income, in aggregate: active
+ paid subscriptions (total and by tier — tier names are the public price
+ book), MRR in EUR, and gross collected this month.
+
+Pieces: generator `deploy/status-publish.sh` (box timer, mirrors
+`deploy/fleet-backup.sh`), viewer pages `web/status/`, bucket + IAM
+`infra/prod/status.tf`, contract test `test/status-contract.test.mjs`.
+
+## The privacy rules (non-negotiable)
+
+Everything published is public, forever (the bucket is versioned). The
+aggregation boundary lives in the generator and holds by construction:
+
+- **Fleet: aggregates only.** Counts, version strings (already public via the
+ signed release feed), booleans. **Never a hostname** — member spaces
+ obviously, but also `test-*` canaries; the simple rule is *no hostnames at
+ all*. Never per-space member/message counts, never anything keyed by space.
+- **Books: aggregated income only** (decision #19 in #550: income publishes
+ aggregated, never per-payer; invariant 12: no PII in our stores). Never
+ customer ids, emails, subscription ids, per-payer lines, or timestamps of
+ individual payments. Expenses/disbursements publish *itemized* — but those
+ belong to the future ledger repo, not these dashboards.
+- Adding a field? It must be an aggregate over the whole fleet / all payers,
+ or it doesn't ship. The `--dry-run` self-check and the contract test both
+ fail if a fixture hostname or per-payer id reaches the output.
+
+## What's in the JSONs
+
+`fleet.json`:
+
+```json
+{
+ "generated_at": "2026-07-11T13:00:00Z",
+ "instances": { "total": 6, "systemd": 2, "hibernating": 4, "awake": 2, "asleep": 2 },
+ "versions": [ { "version": "v100", "count": 3 } ],
+ "services": { "home": true, "push": true, "cloud": true, "connect": true },
+ "replication": { "healthy": true, "lag_seconds": 42 }
+}
+```
+
+`awake`/`asleep` split the hibernating set by whether each host's unix socket
+is currently *bound and listening* (`ss -xlH`) — socket-file existence is not
+sound (instances never unlink on exit, so slept hosts leave stale files) and
+dialing the socket would touch the instance; the kernel socket table is
+side-effect-free. `lag_seconds` approximates replication lag as seconds since
+the litestream replicator last logged (null when unknown). Both `awake` and
+`asleep` are null if `ss` is unavailable — unknowns publish as null, never as
+guesses.
+
+`books.json` (or `{"available": false, "reason": "…"}` when billing isn't
+configured / Stripe is unreachable):
+
+```json
+{
+ "generated_at": "2026-07-11T13:00:00Z",
+ "available": true,
+ "currency": "eur",
+ "month": "2026-07",
+ "subscriptions": { "active": 3, "by_tier": [ { "tier": "friends", "count": 2 } ] },
+ "mrr_cents": 1600,
+ "collected_month_cents": 1200
+}
+```
+
+MRR sums each active subscription item's `unit_amount × quantity` normalized
+to a month (yearly ÷ 12, etc.), EUR items only. Collected-this-month is the
+gross of positive `charge`/`payment` balance transactions since the month
+started (refunds and payouts excluded — it's gross income, not net cash).
+
+## Install (router box)
+
+1. **Apply the infra** (deliberate, named — not yet applied):
+ `AWS_PROFILE=… tofu -chdir=infra/prod apply`, then mint the publisher key:
+ `aws iam create-access-key --user-name status-publisher` (keys never land
+ in tofu state).
+2. **Creds on the box**: write `/data/status.env` with
+ `AWS_ACCESS_KEY_ID=…` / `AWS_SECRET_ACCESS_KEY=…`; `chown lchat:lchat`,
+ `chmod 0600`.
+3. **Pages on the box**: `scp web/status/{index.html,books.html,icon-192.png}
+ root@<box>:/opt/lchat/status-pages/`.
+4. **Install the timer**: `scp deploy/status-publish.sh root@<box>:/opt/lchat/`
+ then `ssh root@<box> /opt/lchat/status-publish.sh install`. The unit runs
+ as the `lchat` user (least privilege: registry + cloud env are lchat-owned;
+ `systemctl is-active` / `ss` are unprivileged; the journal read rides
+ `SupplementaryGroups=systemd-journal`).
+5. **Verify**: `systemctl list-timers lchat-status.timer`,
+ `journalctl -u lchat-status -n 20`, then load the page.
+
+The dashboards live at
+`https://eleven-status-<account-id>.s3.<region>.amazonaws.com/index.html`
+(the `status_url` tofu output; account `427880590669`, region `eu-west-1` →
+`https://eleven-status-427880590669.s3.eu-west-1.amazonaws.com/index.html`,
+books at `…/books.html`). A friendlier hostname (e.g.
+`status.elevenmessenger.com` via CloudFront or a CNAME-compatible front) is
+deliberately later work — the pages use relative URLs so they work at any
+origin unchanged.
+
+## Testing
+
+- `./deploy/status-publish.sh --dry-run <dir>` builds both JSONs from
+ self-constructed fake registry/Stripe fixtures (exercising pagination and
+ the awake/asleep split) and fails on invalid JSON or a privacy leak. No box
+ state, no network, no upload.
+- `node test/status-contract.test.mjs` (in `npm test`) pins the pages' FIELDS
+ manifests against the dry-run fixtures in `test/status-fixtures/` — if the
+ generator's shape and the pages drift, it fails. Regenerate fixtures from a
+ fresh dry-run after changing the generator.
diff --git a/infra/prod/status.tf b/infra/prod/status.tf
new file mode 100644
index 0000000..62cc151
--- /dev/null
+++ b/infra/prod/status.tf
@@ -0,0 +1,122 @@
+# 🤖 The public status bucket (issue #550 Parts 11/12): world-readable
+# dashboards — fleet.json + books.json (published every 5 minutes by
+# deploy/status-publish.sh on the router box) and the two static viewer pages
+# (web/status/). Everything in it is AGGREGATE BY DESIGN: fleet counts and
+# version distribution with no hostnames, and Stripe income as totals only —
+# decision #19 (income publishes aggregated, never per-payer) and invariant 12
+# (no PII in our stores) are enforced upstream in the generator; this bucket
+# only ever receives what passed that gate. docs/status.md is the guide.
+#
+# NOT YET APPLIED — needs a deliberate, named
+# `AWS_PROFILE=… tofu apply` in infra/prod; plan only until then.
+
+resource "aws_s3_bucket" "status" {
+ bucket = "eleven-status-${local.account_id}"
+}
+
+resource "aws_s3_bucket_versioning" "status" {
+ bucket = aws_s3_bucket.status.id
+
+ versioning_configuration {
+ status = "Enabled"
+ }
+}
+
+# The JSONs are clobbered every 5 minutes (≈576 noncurrent versions/day), so
+# versions are pure rollback/forensic noise past a short window — 7 days is
+# plenty to reconstruct "what did the page say when".
+resource "aws_s3_bucket_lifecycle_configuration" "status" {
+ bucket = aws_s3_bucket.status.id
+
+ rule {
+ id = "expire-noncurrent-7d"
+ status = "Enabled"
+
+ filter {}
+
+ noncurrent_version_expiration {
+ noncurrent_days = 7
+ }
+ }
+}
+
+# Deliberately PUBLIC-READ, like the releases bucket (releases.tf) and against
+# the everything-else-blocked posture (backup.tf, hibernate.tf): a public
+# status page is the point — the content is aggregates that were reviewed for
+# publication before the generator ever wrote them (no hostnames, no per-payer
+# data, no PII exists upstream to leak). ACLs stay fully blocked; only the
+# bucket *policy* door is opened, and the GET-only policy below is the entire
+# public surface (no anonymous List — object names are known, enumeration
+# isn't offered).
+resource "aws_s3_bucket_public_access_block" "status" {
+ bucket = aws_s3_bucket.status.id
+
+ block_public_acls = true
+ ignore_public_acls = true
+ block_public_policy = false
+ restrict_public_buckets = false
+}
+
+data "aws_iam_policy_document" "status_public_read" {
+ statement {
+ sid = "PublicReadStatusObjects"
+ actions = ["s3:GetObject"]
+ resources = ["${aws_s3_bucket.status.arn}/*"]
+
+ principals {
+ type = "*"
+ identifiers = ["*"]
+ }
+ }
+}
+
+resource "aws_s3_bucket_policy" "status" {
+ bucket = aws_s3_bucket.status.id
+ policy = data.aws_iam_policy_document.status_public_read.json
+
+ # The policy grants public access, so the access block must relax
+ # block_public_policy before it can be attached.
+ depends_on = [aws_s3_bucket_public_access_block.status]
+}
+
+# The publisher's credentials: object put/get + list on this bucket, nothing
+# else in the account — and no DeleteObject, so a leaked publisher key can
+# overwrite the current dashboards (noncurrent versions survive; versioning is
+# the rollback) but never destroy history. Mint the access key by hand
+# (aws iam create-access-key --user-name status-publisher) — keys never land
+# in tofu state; they go in /data/status.env on the router box.
+resource "aws_iam_user" "status_publisher" {
+ name = "status-publisher"
+}
+
+data "aws_iam_policy_document" "status_publisher" {
+ statement {
+ sid = "ListStatusBucket"
+ actions = ["s3:ListBucket"]
+ resources = [aws_s3_bucket.status.arn]
+ }
+
+ statement {
+ sid = "WriteStatusObjects"
+ actions = [
+ "s3:GetObject",
+ "s3:PutObject",
+ ]
+ resources = ["${aws_s3_bucket.status.arn}/*"]
+ }
+}
+
+resource "aws_iam_user_policy" "status_publisher" {
+ name = "status-publisher-bucket"
+ user = aws_iam_user.status_publisher.name
+ policy = data.aws_iam_policy_document.status_publisher.json
+}
+
+output "status_bucket" {
+ value = aws_s3_bucket.status.bucket
+}
+
+# Where the dashboards live once published.
+output "status_url" {
+ value = "https://${aws_s3_bucket.status.bucket_regional_domain_name}/index.html"
+}
diff --git a/package.json b/package.json
index 14deb72..6a0bb41 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs
new file mode 100644
index 0000000..8b75180
--- /dev/null
+++ b/test/status-contract.test.mjs
@@ -0,0 +1,102 @@
+// 🤖 Contract test for the public status dashboards: the fields each page in
+// web/status/ declares it reads (its FIELDS manifest) must exist in the JSON
+// deploy/status-publish.sh generates. The fixtures in test/status-fixtures/
+// are the script's own --dry-run output (fleet.json, books.json; regenerate
+// with `./deploy/status-publish.sh --dry-run <dir>` after changing the
+// generator) plus the hand-pinned degraded shape (books-unavailable.json,
+// matching books_unavailable()). If this fails, page and generator have
+// drifted — fix whichever side changed, then the fixtures.
+// node test/status-contract.test.mjs
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+const read = (p) => readFileSync(new URL("../" + p, import.meta.url), "utf8");
+const fleet = JSON.parse(read("test/status-fixtures/fleet.json"));
+const books = JSON.parse(read("test/status-fixtures/books.json"));
+const booksDown = JSON.parse(read("test/status-fixtures/books-unavailable.json"));
+
+// Pull the FIELDS manifest out of a page's inline script.
+function fields(html) {
+ const m = html.match(/const FIELDS = \[([\s\S]*?)\];/);
+ assert.ok(m, "page declares a FIELDS manifest");
+ return [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
+}
+
+// Resolve a dotted path against an object; "seg[]" descends into the first
+// element of a non-empty array (the fixture must exercise the array).
+function resolve(obj, path) {
+ let cur = obj;
+ for (const seg of path.split(".")) {
+ if (cur === null || typeof cur !== "object") return undefined;
+ if (seg.endsWith("[]")) {
+ cur = cur[seg.slice(0, -2)];
+ if (!Array.isArray(cur) || cur.length === 0) return undefined;
+ cur = cur[0];
+ } else {
+ cur = cur[seg];
+ }
+ if (cur === undefined) return undefined;
+ }
+ return cur;
+}
+
+const indexHtml = read("web/status/index.html");
+const booksHtml = read("web/status/books.html");
+
+await test("fleet page fields all exist in the generator's fleet.json", () => {
+ for (const f of fields(indexHtml)) {
+ assert.notEqual(resolve(fleet, f), undefined, "fleet.json is missing " + f);
+ }
+});
+
+await test("books page fields all exist in one of the two books shapes", () => {
+ for (const f of fields(booksHtml)) {
+ const hit = resolve(books, f) !== undefined || resolve(booksDown, f) !== undefined;
+ assert.ok(hit, "neither books.json shape has " + f);
+ }
+});
+
+// A manifest entry nobody reads is drift too: each leaf property must appear
+// in the page's render code, not just the manifest.
+await test("every declared field is actually read by its page", () => {
+ for (const [html, fs] of [[indexHtml, fields(indexHtml)], [booksHtml, fields(booksHtml)]]) {
+ const body = html.slice(html.indexOf("];") + 2);
+ for (const f of fs) {
+ const leaf = f.split(".").pop().replace("[]", "");
+ assert.ok(body.includes(leaf), "manifest field " + f + " is never used");
+ }
+ }
+});
+
+// The degraded shape must be renderable: available flips the page, reason is
+// the message.
+await test("degraded books shape carries available:false + reason", () => {
+ assert.equal(booksDown.available, false);
+ assert.equal(typeof booksDown.reason, "string");
+});
+
+// Privacy re-check on the committed fixtures (the dry-run asserts this too —
+// belt and suspenders on what's in git): no fixture hostname, subscription,
+// customer, or transaction id may appear in published output.
+await test("fixtures contain no per-payer ids or hostnames", () => {
+ for (const j of [fleet, books, booksDown]) {
+ const s = JSON.stringify(j);
+ assert.ok(!/sub_|cus_|txn_|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80));
+ }
+});
+
+if (failures > 0) {
+ console.error(failures + " failure(s)");
+ process.exit(1);
+}
diff --git a/test/status-fixtures/books-unavailable.json b/test/status-fixtures/books-unavailable.json
new file mode 100644
index 0000000..b5f02ad
--- /dev/null
+++ b/test/status-fixtures/books-unavailable.json
@@ -0,0 +1 @@
+{"generated_at":"2026-07-11T13:13:47Z","available":false,"reason":"billing not configured"}
diff --git a/test/status-fixtures/books.json b/test/status-fixtures/books.json
new file mode 100644
index 0000000..b7aa4f2
--- /dev/null
+++ b/test/status-fixtures/books.json
@@ -0,0 +1 @@
+{"generated_at":"2026-07-11T13:13:47Z","available":true,"currency":"eur","month":"2026-07","subscriptions":{"active":3,"by_tier":[{"tier":"friends","count":2},{"tier":"family","count":1}]},"mrr_cents":1600,"collected_month_cents":1200}
diff --git a/test/status-fixtures/fleet.json b/test/status-fixtures/fleet.json
new file mode 100644
index 0000000..da820e3
--- /dev/null
+++ b/test/status-fixtures/fleet.json
@@ -0,0 +1 @@
+{"generated_at":"2026-07-11T13:13:47Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"versions":[{"version":"v100","count":3},{"version":"v101","count":2},{"version":"unknown","count":1}],"services":{"home":true,"push":true,"cloud":true,"connect":false},"replication":{"healthy":true,"lag_seconds":42}}
diff --git a/web/status/books.html b/web/status/books.html
new file mode 100644
index 0000000..be710bc
--- /dev/null
+++ b/web/status/books.html
@@ -0,0 +1,215 @@
+<!-- 🤖 Public "books" page (issue #550 Parts 11/12, decision #19: income
+ publishes AGGREGATED, never per-payer). Fully self-contained: inline
+ CSS/JS, no external requests except ./books.json (relative). Published by
+ deploy/status-publish.sh from Stripe aggregates — totals only. -->
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<meta name="color-scheme" content="light dark" />
+<title>Eleven — the books</title>
+<link rel="icon" type="image/png" href="./icon-192.png" />
+<style>
+ /* Same validated palette roles as index.html (dataviz six-checks, light +
+ dark); violet series hue, secondary ink for all small text (>=4.5:1). */
+ :root {
+ --plane: #f9f9f7; --surface: #fcfcfb;
+ --ink: #0b0b0b; --ink-2: #52514e;
+ --grid: #e1e0d9; --border: rgba(11,11,11,0.10);
+ --series: #4a3aa7;
+ --bad: #b23030; --bad-mark: #d03b3b;
+ --link: #4a3aa7;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --plane: #0d0d0d; --surface: #1a1a19;
+ --ink: #ffffff; --ink-2: #c3c2b7;
+ --grid: #2c2c2a; --border: rgba(255,255,255,0.10);
+ --series: #9085e9;
+ --bad: #e66767; --bad-mark: #e66767;
+ --link: #9085e9;
+ }
+ }
+ * { box-sizing: border-box; margin: 0; }
+ body {
+ background: var(--plane); color: var(--ink);
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
+ padding: 24px 16px 48px;
+ }
+ .wrap { max-width: 760px; margin: 0 auto; }
+ a { color: var(--link); }
+ a:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; }
+
+ header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
+ header img { width: 44px; height: 44px; border-radius: 10px; }
+ header h1 { font-size: 22px; font-weight: 650; }
+ header .brand { color: var(--ink-2); font-size: 14px; }
+ .lede { color: var(--ink-2); margin: 6px 0 2px; }
+ .asof { font-weight: 600; margin: 10px 0 24px; }
+ .asof .stale { color: var(--bad); font-weight: 600; }
+
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-bottom: 28px; }
+ .tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
+ .tile .label { font-size: 13px; color: var(--ink-2); }
+ .tile .value { font-size: 30px; font-weight: 600; margin-top: 2px; }
+
+ section h2 { font-size: 15px; font-weight: 650; margin: 0 0 10px; }
+ section { margin-bottom: 28px; }
+
+ /* Per-tier subscriptions: single-series horizontal bar list; rows are real
+ text (their own table view), bars aria-hidden. Thin mark, 4px rounded
+ data-end, square baseline. */
+ .bars { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
+ .bars li { display: grid; grid-template-columns: minmax(90px, 160px) 1fr 3.5em; align-items: center; gap: 12px; padding: 10px 16px; }
+ .bars li + li { border-top: 1px solid var(--grid); }
+ .bars .tier { font-size: 14px; font-weight: 600; text-transform: capitalize; overflow-wrap: anywhere; }
+ .bars .track { height: 12px; }
+ .bars .bar { height: 12px; min-width: 2px; background: var(--series); border-radius: 0 4px 4px 0; }
+ .bars .count { font-size: 14px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
+ .bars .empty { display: block; color: var(--ink-2); }
+
+ #unavailable, #error { display: none; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 24px; }
+ #error { border-color: var(--bad-mark); }
+ #unavailable.show, #error.show { display: block; }
+
+ footer { margin-top: 36px; color: var(--ink-2); font-size: 14px; }
+ footer p { margin-top: 6px; }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header>
+ <img src="./icon-192.png" alt="" />
+ <div>
+ <p class="brand">Eleven Messenger</p>
+ <h1>The books</h1>
+ </div>
+ </header>
+ <p class="lede">Eleven’s income, published as it stands — part of running the
+ service in the open. <strong>Aggregated by design:</strong> our billing records hold no
+ customer identities (Stripe does), and what appears here is totals only —
+ never a per-payer line, never an individual payment.</p>
+ <p class="asof">As of <time id="asof" datetime="">—</time><span id="staleness"></span></p>
+
+ <p id="error" role="alert">Couldn’t load the books feed. It may be mid-publish — this page retries every minute.</p>
+ <p id="unavailable" role="status">The books are unavailable right now: <span id="reason"></span>.</p>
+
+ <div id="books">
+ <section aria-labelledby="h-income">
+ <h2 id="h-income">Income</h2>
+ <div class="tiles">
+ <div class="tile"><div class="label">Active subscriptions</div><div class="value" id="t-active">—</div></div>
+ <div class="tile"><div class="label">Monthly recurring revenue</div><div class="value" id="t-mrr">—</div></div>
+ <div class="tile"><div class="label" id="l-collected">Collected this month</div><div class="value" id="t-collected">—</div></div>
+ </div>
+ </section>
+
+ <section aria-labelledby="h-tiers">
+ <h2 id="h-tiers">Subscriptions by tier</h2>
+ <ul class="bars" id="tiers"><li><span class="empty">Loading…</span></li></ul>
+ </section>
+ </div>
+
+ <footer>
+ <p><a href="./index.html">Fleet status</a></p>
+ <p>Published every 5 minutes from Stripe aggregates. Tier names are the
+ public price book; amounts are EUR. Expenses and disbursements will join
+ these pages as the public ledger lands (see the roadmap in
+ docs/planet-scale.md).</p>
+ </footer>
+</div>
+
+<script>
+// contract: the fields this page reads from ./books.json — kept in sync with
+// deploy/status-publish.sh and checked by test/status-contract.test.mjs.
+// (available/reason come from the degraded shape; the rest from the full one.)
+const FIELDS = [
+ "generated_at", "available", "reason",
+ "month", "currency",
+ "subscriptions.active", "subscriptions.by_tier[].tier", "subscriptions.by_tier[].count",
+ "mrr_cents", "collected_month_cents",
+];
+
+const $ = (id) => document.getElementById(id);
+const eur = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" });
+
+// All rendering is textContent / createElement — never innerHTML of fetched
+// data, even though the feed is our own.
+function render(d) {
+ const t = new Date(d.generated_at);
+ $("asof").dateTime = d.generated_at;
+ $("asof").textContent = t.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
+ const staleEl = $("staleness");
+ staleEl.textContent = "";
+ staleEl.className = "";
+ if (Date.now() - t.getTime() > 20 * 60 * 1000) {
+ staleEl.className = "stale";
+ staleEl.textContent = " — this feed looks stale";
+ }
+
+ if (!d.available) {
+ $("reason").textContent = d.reason || "not configured";
+ $("unavailable").classList.add("show");
+ $("books").style.display = "none";
+ return;
+ }
+ $("unavailable").classList.remove("show");
+ $("books").style.display = "";
+
+ $("t-active").textContent = String(d.subscriptions.active);
+ $("t-mrr").textContent = eur.format(d.mrr_cents / 100);
+ $("t-collected").textContent = eur.format(d.collected_month_cents / 100);
+ $("l-collected").textContent = "Collected in " + d.month;
+ void d.currency; // fixed "eur" today; the formatter would key on it if that changes
+
+ const list = $("tiers");
+ list.replaceChildren();
+ const tiers = d.subscriptions.by_tier || [];
+ const max = Math.max(1, ...tiers.map((x) => x.count));
+ for (const x of tiers) {
+ const li = document.createElement("li");
+ const name = document.createElement("span");
+ name.className = "tier";
+ name.textContent = x.tier;
+ const track = document.createElement("span");
+ track.className = "track";
+ track.setAttribute("aria-hidden", "true");
+ const bar = document.createElement("span");
+ bar.className = "bar";
+ bar.style.width = (100 * x.count / max) + "%";
+ bar.style.display = "block";
+ track.append(bar);
+ const count = document.createElement("span");
+ count.className = "count";
+ count.textContent = String(x.count);
+ li.append(name, track, count);
+ list.append(li);
+ }
+ if (!tiers.length) {
+ const li = document.createElement("li");
+ const s = document.createElement("span");
+ s.className = "empty";
+ s.textContent = "No active subscriptions yet.";
+ li.append(s);
+ list.append(li);
+ }
+}
+
+async function refresh() {
+ try {
+ const res = await fetch("./books.json", { cache: "no-cache" });
+ if (!res.ok) throw new Error("HTTP " + res.status);
+ render(await res.json());
+ $("error").classList.remove("show");
+ } catch (e) {
+ // Keep the previous render (no skeleton flash); surface the failure inline.
+ $("error").classList.add("show");
+ }
+}
+refresh();
+setInterval(refresh, 60 * 1000);
+void FIELDS;
+</script>
+</body>
+</html>
diff --git a/web/status/icon-192.png b/web/status/icon-192.png
new file mode 100644
index 0000000..3917fe1
Binary files /dev/null and b/web/status/icon-192.png differ
diff --git a/web/status/index.html b/web/status/index.html
new file mode 100644
index 0000000..6592be4
--- /dev/null
+++ b/web/status/index.html
@@ -0,0 +1,248 @@
+<!-- 🤖 Public fleet-status page (issue #550 Parts 11/12). Fully self-contained:
+ inline CSS/JS, no external requests except ./fleet.json (relative, so it
+ works at any origin). Published by deploy/status-publish.sh; the JSON it
+ reads is AGGREGATES ONLY — no hostnames, nothing per-space. -->
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<meta name="color-scheme" content="light dark" />
+<title>Eleven — fleet status</title>
+<link rel="icon" type="image/png" href="./icon-192.png" />
+<style>
+ /* Palette roles (validated: dataviz six-checks, light + dark). Series hue is
+ the violet slot — the Eleven brand family. Secondary ink is used for ALL
+ small text (>=4.5:1 both modes); the muted axis tone is deliberately
+ absent — it fails AA at body sizes. */
+ :root {
+ --plane: #f9f9f7; --surface: #fcfcfb;
+ --ink: #0b0b0b; --ink-2: #52514e;
+ --grid: #e1e0d9; --border: rgba(11,11,11,0.10);
+ --series: #4a3aa7;
+ --good: #006300; --good-mark: #0ca30c;
+ --bad: #b23030; --bad-mark: #d03b3b;
+ --link: #4a3aa7;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --plane: #0d0d0d; --surface: #1a1a19;
+ --ink: #ffffff; --ink-2: #c3c2b7;
+ --grid: #2c2c2a; --border: rgba(255,255,255,0.10);
+ --series: #9085e9;
+ --good: #0ca30c; --good-mark: #0ca30c;
+ --bad: #e66767; --bad-mark: #e66767;
+ --link: #9085e9;
+ }
+ }
+ * { box-sizing: border-box; margin: 0; }
+ body {
+ background: var(--plane); color: var(--ink);
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
+ padding: 24px 16px 48px;
+ }
+ .wrap { max-width: 760px; margin: 0 auto; }
+ a { color: var(--link); }
+ a:focus-visible { outline: 2px solid var(--series); outline-offset: 2px; border-radius: 4px; }
+
+ header { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
+ header img { width: 44px; height: 44px; border-radius: 10px; }
+ header h1 { font-size: 22px; font-weight: 650; }
+ header .brand { color: var(--ink-2); font-size: 14px; }
+ .lede { color: var(--ink-2); margin: 6px 0 2px; }
+ .asof { font-weight: 600; margin: 10px 0 24px; }
+ .asof .stale { color: var(--bad); font-weight: 600; }
+
+ .tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 28px; }
+ .tile { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; }
+ .tile .label { font-size: 13px; color: var(--ink-2); }
+ .tile .value { font-size: 30px; font-weight: 600; margin-top: 2px; }
+
+ section h2 { font-size: 15px; font-weight: 650; margin: 0 0 10px; }
+ section { margin-bottom: 28px; }
+
+ /* Services: word + glyph carry the state; color is supplementary (never
+ color alone). */
+ .services { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; list-style: none; padding: 0; }
+ .services li { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 16px; display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
+ .services .name { font-weight: 600; }
+ .services .state { font-size: 14px; font-weight: 600; white-space: nowrap; }
+ /* Replication is the odd one out (it carries a lag detail) — a full-width
+ row gives the detail room instead of wrapping inside a narrow tile. */
+ .services li:last-child { grid-column: 1 / -1; }
+ .services .state.up { color: var(--good); }
+ .services .state.down { color: var(--bad); }
+
+ /* Version distribution: a single-series horizontal bar list. Every row is
+ real text (version + count) so the list is its own table view; the bar is
+ decoration for the eye and hidden from AT. Thin mark, 4px rounded
+ data-end, square at the baseline. */
+ .bars { list-style: none; padding: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
+ .bars li { display: grid; grid-template-columns: minmax(90px, 160px) 1fr 3.5em; align-items: center; gap: 12px; padding: 10px 16px; }
+ .bars li + li { border-top: 1px solid var(--grid); }
+ .bars .ver { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; overflow-wrap: anywhere; }
+ .bars .track { height: 12px; }
+ .bars .bar { height: 12px; min-width: 2px; background: var(--series); border-radius: 0 4px 4px 0; }
+ .bars .count { font-size: 14px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; }
+ .bars .empty { display: block; color: var(--ink-2); }
+
+ #error { display: none; background: var(--surface); border: 1px solid var(--bad-mark); border-radius: 12px; padding: 14px 16px; margin-bottom: 24px; }
+ #error.show { display: block; }
+
+ footer { margin-top: 36px; color: var(--ink-2); font-size: 14px; }
+ footer p { margin-top: 6px; }
+</style>
+</head>
+<body>
+<div class="wrap">
+ <header>
+ <img src="./icon-192.png" alt="" />
+ <div>
+ <p class="brand">Eleven Messenger</p>
+ <h1>Fleet status</h1>
+ </div>
+ </header>
+ <p class="lede">Live aggregate health of the Eleven fleet — how many spaces are running,
+ awake, or hibernating, what they run, and whether the shared services are up.
+ Counts only: no space is ever named here.</p>
+ <p class="asof">As of <time id="asof" datetime="">—</time><span id="staleness"></span></p>
+
+ <p id="error" role="alert">Couldn’t load the status feed. It may be mid-publish — this page retries every minute.</p>
+
+ <section aria-labelledby="h-instances">
+ <h2 id="h-instances">Spaces</h2>
+ <div class="tiles">
+ <div class="tile"><div class="label">Instances</div><div class="value" id="t-total">—</div></div>
+ <div class="tile"><div class="label">Always-on</div><div class="value" id="t-systemd">—</div></div>
+ <div class="tile"><div class="label">Hibernating</div><div class="value" id="t-hib">—</div></div>
+ <div class="tile"><div class="label">Awake now</div><div class="value" id="t-awake">—</div></div>
+ <div class="tile"><div class="label">Asleep</div><div class="value" id="t-asleep">—</div></div>
+ </div>
+ </section>
+
+ <section aria-labelledby="h-services">
+ <h2 id="h-services">Services</h2>
+ <ul class="services" id="services"></ul>
+ </section>
+
+ <section aria-labelledby="h-versions">
+ <h2 id="h-versions">Version distribution</h2>
+ <ul class="bars" id="versions"><li><span class="empty">Loading…</span></li></ul>
+ </section>
+
+ <footer>
+ <p><a href="./books.html">The books — income, in aggregate</a></p>
+ <p>Published every 5 minutes from aggregate counters on the fleet.
+ By design it contains no hostnames and nothing about any individual space.</p>
+ </footer>
+</div>
+
+<script>
+// contract: the fields this page reads from ./fleet.json — kept in sync with
+// deploy/status-publish.sh and checked by test/status-contract.test.mjs.
+const FIELDS = [
+ "generated_at",
+ "instances.total", "instances.systemd", "instances.hibernating",
+ "instances.awake", "instances.asleep",
+ "versions[].version", "versions[].count",
+ "services.home", "services.push", "services.cloud", "services.connect",
+ "replication.healthy", "replication.lag_seconds",
+];
+
+const $ = (id) => document.getElementById(id);
+const num = (v) => (v === null || v === undefined) ? "—" : String(v);
+
+// All rendering is textContent / createElement — never innerHTML of fetched
+// data, even though the feed is our own.
+function render(d) {
+ const t = new Date(d.generated_at);
+ $("asof").dateTime = d.generated_at;
+ $("asof").textContent = t.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
+ // Honesty check: flag a feed older than 20 minutes (four missed publishes).
+ const staleEl = $("staleness");
+ staleEl.textContent = "";
+ staleEl.className = "";
+ if (Date.now() - t.getTime() > 20 * 60 * 1000) {
+ staleEl.className = "stale";
+ staleEl.textContent = " — this feed looks stale";
+ }
+
+ $("t-total").textContent = num(d.instances.total);
+ $("t-systemd").textContent = num(d.instances.systemd);
+ $("t-hib").textContent = num(d.instances.hibernating);
+ $("t-awake").textContent = num(d.instances.awake);
+ $("t-asleep").textContent = num(d.instances.asleep);
+
+ const svc = $("services");
+ svc.replaceChildren();
+ const rows = [
+ ["Home", d.services.home], ["Push", d.services.push],
+ ["Cloud", d.services.cloud], ["Connect", d.services.connect],
+ ["Replication", d.replication.healthy],
+ ];
+ for (const [name, up] of rows) {
+ const li = document.createElement("li");
+ const n = document.createElement("span");
+ n.className = "name";
+ n.textContent = name;
+ const s = document.createElement("span");
+ s.className = "state " + (up ? "up" : "down");
+ // The word is the state; the glyph + color are supplementary.
+ s.textContent = (up ? "✓ up" : "✕ down");
+ if (name === "Replication" && up && d.replication.lag_seconds !== null) {
+ s.textContent += " · " + d.replication.lag_seconds + "s ago";
+ }
+ li.append(n, s);
+ svc.append(li);
+ }
+
+ const list = $("versions");
+ list.replaceChildren();
+ const versions = d.versions || [];
+ const max = Math.max(1, ...versions.map((v) => v.count));
+ for (const v of versions) {
+ const li = document.createElement("li");
+ const ver = document.createElement("span");
+ ver.className = "ver";
+ ver.textContent = v.version;
+ const track = document.createElement("span");
+ track.className = "track";
+ track.setAttribute("aria-hidden", "true");
+ const bar = document.createElement("span");
+ bar.className = "bar";
+ bar.style.width = (100 * v.count / max) + "%";
+ bar.style.display = "block";
+ track.append(bar);
+ const count = document.createElement("span");
+ count.className = "count";
+ count.textContent = String(v.count);
+ li.append(ver, track, count);
+ list.append(li);
+ }
+ if (!versions.length) {
+ const li = document.createElement("li");
+ const s = document.createElement("span");
+ s.className = "empty";
+ s.textContent = "No versions reported.";
+ li.append(s);
+ list.append(li);
+ }
+}
+
+async function refresh() {
+ try {
+ const res = await fetch("./fleet.json", { cache: "no-cache" });
+ if (!res.ok) throw new Error("HTTP " + res.status);
+ render(await res.json());
+ $("error").classList.remove("show");
+ } catch (e) {
+ // Keep the previous render (no skeleton flash); surface the failure inline.
+ $("error").classList.add("show");
+ }
+}
+refresh();
+setInterval(refresh, 60 * 1000);
+void FIELDS;
+</script>
+</body>
+</html>