web+server: books gain DNSimple + Apple expense providers and a visible Income / Expenditure split (#766)
Two new itemized expense lines in the public books, and a clearer page:
- dnsimple — the product domains (elevenmessenger.com, 11ch.at, 11factor.org):
month-to-date from GET /v2/{account}/billing/charges (start_date/end_date,
per_page=100; a second page fails the fetch — a partial sum never
publishes). Amounts are decimal dollar strings with no currency field, so
the published usd is the documented constant (DNSimple's price book), not
parsed. Only `collected` charges sum — refunds are not spend. Auth =
STATUS_DNSIMPLE_TOKEN + STATUS_DNSIMPLE_ACCOUNT (account API token,
read-only use here, never echoed). Rides the shared 6h cost cache and the
same omit-on-failure discipline as AWS/Anthropic.
- apple — the Apple Developer Program membership: fixed yearly, no billing
API, so the operator-set constant STATUS_APPLE_YEARLY_CENTS (EUR),
published as billed (yearly_cents) — never divided into a fake monthly.
books.html restructures into two clearly-headed sections mirroring the fleet
page's h2/section pattern: Income (subscriptions, MRR, collected, by-tier —
aggregated by design) and Expenditure (the provider tiles — itemized by
design), each with a one-line explainer. The degraded income notice now
renders inside the Income section; Expenditure renders independently, so a
Stripe outage never hides spend the feed can still honestly state.
Contract test allowlists the two providers (per-provider key sets enforce
exactly one of month_to_date_cents | monthly_cents | yearly_cents), pins the
new publish order, and greps the dnsimple fixture markers; dry-run gains a
DNSimple charges fixture (state filter exercised — a refunded charge must
not count) + apple constant across all STATUS_DRY_EXPENSES variants;
fixtures regenerated; docs/status.md updated.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+194
−74
deploy/status-publish.sh+100 −21docs/status.md+35 −12test/status-contract.test.mjs+13 −7test/status-fixtures/books-no-expenses.json+1 −1test/status-fixtures/books.json+1 −1test/status-fixtures/fleet-at-capacity.json+1 −1test/status-fixtures/fleet-getting-full.json+1 −1test/status-fixtures/fleet.json+1 −1web/status/books.html+41 −29
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh| index 05bf8ca..89e04ee 100755 |
| --- a/deploy/status-publish.sh |
| +++ b/deploy/status-publish.sh |
| @@ -82,9 +82,12 @@ |
| # bucket, no Delete, plus ce:GetCostAndUsage for the |
| # expenses section). chown lchat, chmod 0600. Optional |
| # expense vars ride here too: STATUS_HETZNER_MONTHLY_CENTS, |
| -# STATUS_CLAUDE_MONTHLY_CENTS, STATUS_ANTHROPIC_ADMIN_KEY |
| -# (an sk-ant-admin… key with read-only usage scope — NOT |
| -# the sidecars' completion key). |
| +# STATUS_CLAUDE_MONTHLY_CENTS, STATUS_APPLE_YEARLY_CENTS, |
| +# STATUS_ANTHROPIC_ADMIN_KEY (an sk-ant-admin… key with |
| +# read-only usage scope — NOT the sidecars' completion |
| +# key), and STATUS_DNSIMPLE_TOKEN + STATUS_DNSIMPLE_ACCOUNT |
| +# (an account API token — read-only use here, never |
| +# echoed — plus the numeric account id). |
| # /opt/lchat/status-pages/ — the viewer pages, copied from web/status/ |
| # (index.html, books.html, carbon.html, carbon.json, |
| # contributors.html, contributors.json, icon-192.png). |
| @@ -549,9 +552,47 @@ fetch_anthropic_cents() { |
| FROM json_each(readfile('$f'), '\$.data') d, json_each(d.value, '\$.results') r" |
| } |
| +# fetch_dnsimple_cents <month> — one billing-charges page from DNSimple's API |
| +# (the product domains live there): GET /v2/{account}/billing/charges with |
| +# start_date=<month>-01 / end_date=<tomorrow> (ISO dates; tomorrow so today's |
| +# charges are included), per_page=100. A month of domain charges is a handful — |
| +# pagination.total_pages > 1 counts as failure (a partial sum must never |
| +# publish). Amounts are decimal strings of DOLLARS ("14.50"); the response |
| +# carries NO currency field, so the published currency is the documented |
| +# constant (DNSimple's price book is USD), not parsed. Only `collected` |
| +# charges sum — a refunded charge is not spend — and every summed amount must |
| +# be a plain decimal or the fetch counts as failed. STATUS_DNSIMPLE_TOKEN is |
| +# an account API token, read-only use here: it rides only in the |
| +# Authorization header (-sS keeps curl from printing it) and must never be |
| +# echoed or logged. Dry-run twin reads a fixture instead. |
| +fetch_dnsimple_cents() { |
| + local month=$1 f="$TMP/dnsimple-charges.json" end amt |
| + if [ -n "$DRY_RUN" ]; then |
| + [ -f "$FIXDIR/dnsimple-charges.json" ] || return 1 |
| + cp "$FIXDIR/dnsimple-charges.json" "$f" |
| + else |
| + [ -n "${STATUS_DNSIMPLE_TOKEN:-}" ] && [ -n "${STATUS_DNSIMPLE_ACCOUNT:-}" ] || return 1 |
| + end=$(sqj "SELECT date('now','+1 day')") |
| + curl -sS --fail --max-time 30 \ |
| + -H "Authorization: Bearer $STATUS_DNSIMPLE_TOKEN" \ |
| + "https://api.dnsimple.com/v2/$STATUS_DNSIMPLE_ACCOUNT/billing/charges?start_date=${month}-01&end_date=$end&per_page=100" \ |
| + > "$f" || return 1 |
| + fi |
| + [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.data')='array'")" = "1" ] || return 1 |
| + [ "$(sqj "SELECT COALESCE(json_extract(readfile('$f'), '\$.pagination.total_pages'), 1) <= 1")" = "1" ] || return 1 |
| + while IFS= read -r amt; do |
| + case "$amt" in ''|.|*[!0-9.]*|*.*.*) return 1 ;; esac |
| + done < <(sqj "SELECT json_extract(c.value, '\$.total_amount') |
| + FROM json_each(readfile('$f'), '\$.data') c |
| + WHERE json_extract(c.value, '\$.state')='collected'") |
| + sqj "SELECT CAST(ROUND(COALESCE(SUM(json_extract(c.value, '\$.total_amount')), 0) * 100) AS INTEGER) |
| + FROM json_each(readfile('$f'), '\$.data') c |
| + WHERE json_extract(c.value, '\$.state')='collected'" |
| +} |
| + |
| # cached_cost_cents <provider> <month> — the shared fetch-through-cache for |
| -# the metered providers (aws, anthropic — both fetch_<provider>_cents above). |
| -# Both APIs charge or rate-limit per request (Cost Explorer bills ~$0.01 per |
| +# the metered providers (aws, anthropic, dnsimple — fetch_<provider>_cents above). |
| +# These APIs charge or rate-limit per request (Cost Explorer bills ~$0.01 per |
| # query) and this script runs every 5 minutes — an uncached publisher would |
| # spend real money asking how much it spends. So each provider's result is |
| # cached in $COSTS_CACHE and refetched only when its entry is older than |
| @@ -608,6 +649,13 @@ cached_cost_cents() { |
| # the bot sidecars' API spend. |
| # claude.ai — the claude.ai subscription: fixed monthly, no API for it, so |
| # the operator-set constant STATUS_CLAUDE_MONTHLY_CENTS (USD). |
| +# dnsimple — the product domains (elevenmessenger.com, 11ch.at, |
| +# 11factor.org): billing-charges month-to-date (metered; |
| +# cached), USD. |
| +# apple — the Apple Developer Program membership: fixed YEARLY, no |
| +# billing API, so the operator-set constant |
| +# STATUS_APPLE_YEARLY_CENTS (EUR). Published as billed — |
| +# yearly_cents, never divided into a fake monthly. |
| expenses_object() { |
| local month providers="" cents |
| month=$(sqj "SELECT strftime('%Y-%m','now')") |
| @@ -624,6 +672,12 @@ expenses_object() { |
| cents=$(env_cents "${STATUS_CLAUDE_MONTHLY_CENTS:-}") |
| [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','claude.ai','monthly_cents',$cents,'currency','usd')")" |
| + cents=$(cached_cost_cents dnsimple "$month") |
| + [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','dnsimple','month_to_date_cents',$cents,'currency','usd')")" |
| + |
| + cents=$(env_cents "${STATUS_APPLE_YEARLY_CENTS:-}") |
| + [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','apple','yearly_cents',$cents,'currency','eur')")" |
| + |
| [ -n "$providers" ] || return 0 |
| sqj "SELECT json_object('month', '$month', 'providers', json('[$providers]'))" |
| } |
| @@ -899,11 +953,11 @@ EOF |
| echo "LCHAT_STRIPE_SECRET_KEY=sk_test_dryrun_not_a_real_key" > "$FIXDIR/cloud.env" |
| fi |
| - # Expense fixtures (STATUS_DRY_EXPENSES=omit|stale-cache; default = all four |
| + # Expense fixtures (STATUS_DRY_EXPENSES=omit|stale-cache; default = all six |
| # providers resolve). The cost cache is repointed into FIXDIR — the dry run |
| - # never touches $DATA_DIR. Both metered fixtures carry fixture-only ids |
| - # (request id, workspace id): only the parsed integers may survive into |
| - # books.json — the leak grep below is the proof. |
| + # never touches $DATA_DIR. The metered fixtures carry fixture-only ids |
| + # (request id, workspace id, charge references/domains): only the parsed |
| + # integers may survive into books.json — the leak grep below is the proof. |
| COSTS_CACHE="$FIXDIR/costs-cache.json" |
| case "${STATUS_DRY_EXPENSES:-}" in |
| omit) |
| @@ -911,21 +965,25 @@ EOF |
| # and then the whole expenses object, is omitted. |
| STATUS_HETZNER_MONTHLY_CENTS="" |
| STATUS_CLAUDE_MONTHLY_CENTS="" |
| + STATUS_APPLE_YEARLY_CENTS="" |
| ;; |
| stale-cache) |
| - # No metered fixtures (both "fetches" fail) but valid same-month cache |
| - # entries well past the max age → the stale figures serve rather than |
| - # vanishing (stale beats absent for month-to-date). |
| + # No metered fixtures (all three "fetches" fail) but valid same-month |
| + # cache entries well past the max age → the stale figures serve rather |
| + # than vanishing (stale beats absent for month-to-date). |
| STATUS_HETZNER_MONTHLY_CENTS="" |
| STATUS_CLAUDE_MONTHLY_CENTS="" |
| + STATUS_APPLE_YEARLY_CENTS="" |
| sqj "SELECT json_object( |
| 'aws', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 999), |
| - 'anthropic', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 888))" \ |
| + 'anthropic', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 888), |
| + 'dnsimple', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 777))" \ |
| > "$COSTS_CACHE" |
| ;; |
| *) |
| STATUS_HETZNER_MONTHLY_CENTS=1400 |
| STATUS_CLAUDE_MONTHLY_CENTS=2000 |
| + STATUS_APPLE_YEARLY_CENTS=9900 |
| # Cost Explorer shape; Amount 12.3399999 USD exercises the cents rounding (→ 1234). |
| cat > "$FIXDIR/aws-ce.json" <<'EOF' |
| {"ce_fixture_request_id":"req_fixture_1","ResultsByTime":[{"TimePeriod":{"Start":"2026-07-01","End":"2026-07-12"},"Total":{"UnblendedCost":{"Amount":"12.3399999","Unit":"USD"}},"Groups":[],"Estimated":true}]} |
| @@ -940,6 +998,21 @@ EOF |
| {"starting_at":"2026-07-02T00:00:00Z","ending_at":"2026-07-03T00:00:00Z", |
| "results":[{"amount":"99.5","currency":"USD","workspace_id":"wrkspc_fixture_1"}]} |
| ],"has_more":false,"next_page":null} |
| +EOF |
| + # DNSimple billing-charges shape (amounts = decimal DOLLAR strings, no |
| + # currency field, pagination envelope): two collected charges |
| + # (14.50 + 10.00 → 2450 cents) plus a refunded one that must NOT count — |
| + # the state filter is part of the contract. References and domains are |
| + # fixture markers that must not survive aggregation. |
| + cat > "$FIXDIR/dnsimple-charges.json" <<'EOF' |
| +{"data":[ |
| + {"invoiced_at":"2026-07-02T09:00:00Z","total_amount":"14.50","balance_amount":"0.00","reference":"dnsfix-1001","state":"collected", |
| + "items":[{"description":"Renewal dnsfix-one.example","amount":"14.50","product_id":11,"product_type":"domain-renewal","product_reference":"dnsfix-one.example"}]}, |
| + {"invoiced_at":"2026-07-03T09:00:00Z","total_amount":"10.00","balance_amount":"0.00","reference":"dnsfix-1002","state":"collected", |
| + "items":[{"description":"Renewal dnsfix-two.example","amount":"10.00","product_id":12,"product_type":"domain-renewal","product_reference":"dnsfix-two.example"}]}, |
| + {"invoiced_at":"2026-07-04T09:00:00Z","total_amount":"14.50","balance_amount":"0.00","reference":"dnsfix-1003","state":"refunded", |
| + "items":[{"description":"Register dnsfix-three.example","amount":"14.50","product_id":13,"product_type":"domain-registration","product_reference":"dnsfix-three.example"}]} |
| +],"pagination":{"current_page":1,"per_page":100,"total_entries":3,"total_pages":1}} |
| EOF |
| ;; |
| esac |
| @@ -976,7 +1049,7 @@ EOF |
| # 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|req_fixture|ce_fixture|wrkspc_fixture|\.example' "$outdir/fleet.json" "$outdir/books.json"; then |
| + if grep -qE 'sub_fixture|cus_fixture|txn_fixture|req_fixture|ce_fixture|wrkspc_fixture|dnsfix|\.example' "$outdir/fleet.json" "$outdir/books.json"; then |
| echo "dry-run: PRIVACY LEAK — a per-payer/provider-account id or hostname reached the output" >&2 |
| exit 1 |
| fi |
| @@ -994,11 +1067,11 @@ EOF |
| exit 1 |
| fi |
| # Expenses: itemized by design, but the shape is still pinned — and the |
| - # cache seams must behave (cached_cost_cents). Default: all four providers |
| + # cache seams must behave (cached_cost_cents). Default: all six providers |
| # from fixtures/env, with the metered results freshly cached; omit: no |
| - # expenses key at all; stale-cache: both metered fetches failed and the |
| - # seeded same-month cache served (aws 999 / anthropic 888). |
| - local aws_c an_c |
| + # expenses key at all; stale-cache: all three metered fetches failed and the |
| + # seeded same-month cache served (aws 999 / anthropic 888 / dnsimple 777). |
| + local aws_c an_c dn_c |
| aws_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[0].month_to_date_cents')") |
| case "${STATUS_DRY_EXPENSES:-}" in |
| omit) |
| @@ -1007,20 +1080,26 @@ EOF |
| ;; |
| stale-cache) |
| an_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].month_to_date_cents')") |
| - [ "$aws_c" = "999" ] && [ "$an_c" = "888" ] || |
| - { echo "dry-run: stale same-month cache must serve on fetch failure (got aws='$aws_c' anthropic='$an_c')" >&2; exit 1; } |
| + dn_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[2].month_to_date_cents')") |
| + [ "$aws_c" = "999" ] && [ "$an_c" = "888" ] && [ "$dn_c" = "777" ] || |
| + { echo "dry-run: stale same-month cache must serve on fetch failure (got aws='$aws_c' anthropic='$an_c' dnsimple='$dn_c')" >&2; exit 1; } |
| ;; |
| *) |
| an_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[2].month_to_date_cents')") |
| + dn_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[4].month_to_date_cents')") |
| [ "$aws_c" = "1234" ] || { echo "dry-run: CE fixture must yield 1234 cents (got '$aws_c')" >&2; exit 1; } |
| [ "$an_c" = "250" ] || { echo "dry-run: anthropic fixture must yield 250 cents (got '$an_c')" >&2; exit 1; } |
| + [ "$dn_c" = "2450" ] || { echo "dry-run: dnsimple fixture must yield 2450 cents — collected only (got '$dn_c')" >&2; exit 1; } |
| [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].monthly_cents')")" = "1400" ] || |
| { echo "dry-run: hetzner constant must yield 1400 cents" >&2; exit 1; } |
| [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[3].monthly_cents')")" = "2000" ] || |
| { echo "dry-run: claude.ai constant must yield 2000 cents" >&2; exit 1; } |
| + [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[5].yearly_cents')")" = "9900" ] || |
| + { echo "dry-run: apple constant must yield 9900 cents, as billed (yearly)" >&2; exit 1; } |
| [ -f "$COSTS_CACHE" ] && |
| [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.aws.month_to_date_cents')")" = "1234" ] && |
| - [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.anthropic.month_to_date_cents')")" = "250" ] || |
| + [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.anthropic.month_to_date_cents')")" = "250" ] && |
| + [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.dnsimple.month_to_date_cents')")" = "2450" ] || |
| { echo "dry-run: metered results must be cached per provider" >&2; exit 1; } |
| ;; |
| esac |
diff --git a/docs/status.md b/docs/status.md| index 5b6d74d..3fa2eb1 100644 |
| --- a/docs/status.md |
| +++ b/docs/status.md |
| @@ -10,10 +10,13 @@ public *ledger* repo is still roadmap — see `docs/planet-scale.md`). |
| band words — see below), 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 — plus infrastructure |
| - spend, itemized per provider (AWS, Hetzner, Anthropic API, claude.ai). |
| +- **The books** (`books.html` + `books.json`) — two clearly-headed halves. |
| + **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. **Expenditure**, itemized per provider (AWS, Hetzner, Anthropic API, |
| + claude.ai, DNSimple, Apple Developer). Each section renders independently: |
| + a degraded income feed shows its notice inside the Income section and never |
| + hides Expenditure. |
| - **The carbon tally** (`carbon.html` + `carbon.json`) — the running estimate |
| of the CO₂e cost of the LLM tokens spent *building* Eleven, as a cumulative |
| chart + ledger with everyday equivalents. Unlike the other two feeds, the |
| @@ -132,7 +135,9 @@ configured / Stripe is unreachable): |
| { "provider": "aws", "month_to_date_cents": 1234, "currency": "usd" }, |
| { "provider": "hetzner", "monthly_cents": 1400, "currency": "eur" }, |
| { "provider": "anthropic", "month_to_date_cents": 250, "currency": "usd" }, |
| - { "provider": "claude.ai", "monthly_cents": 2000, "currency": "usd" } |
| + { "provider": "claude.ai", "monthly_cents": 2000, "currency": "usd" }, |
| + { "provider": "dnsimple", "month_to_date_cents": 2450, "currency": "usd" }, |
| + { "provider": "apple", "yearly_cents": 9900, "currency": "eur" } |
| ] } |
| } |
| ``` |
| @@ -143,9 +148,11 @@ gross of positive `charge`/`payment` balance transactions since the month |
| started (refunds and payouts excluded — it's gross income, not net cash). |
| `expenses` is infrastructure spend, itemized per provider — the deliberate |
| -mirror image of income's aggregation (see the privacy rules above). Two |
| -provider shapes exist: metered providers carry `month_to_date_cents`, flat |
| -subscriptions carry `monthly_cents`; each in its own billing currency. |
| +mirror image of income's aggregation (see the privacy rules above). Three |
| +provider shapes exist, exactly one amount key each: metered providers carry |
| +`month_to_date_cents`, flat monthly subscriptions `monthly_cents`, flat |
| +yearly ones `yearly_cents` — each as billed, in its own billing currency (a |
| +yearly fee is published yearly, never divided into a fake monthly). |
| - **aws** — month-to-date UnblendedCost from Cost Explorer (a us-east-1 |
| global endpoint, regardless of where the bucket lives). CE bills ~$0.01 |
| @@ -165,6 +172,20 @@ subscriptions carry `monthly_cents`; each in its own billing currency. |
| not the sidecars' completion key. Same cache treatment as AWS. |
| - **claude.ai** — the claude.ai subscription: fixed monthly with no API for |
| it, so the operator-set constant `STATUS_CLAUDE_MONTHLY_CENTS` (USD). |
| +- **dnsimple** — the product domains (elevenmessenger.com, 11ch.at, |
| + 11factor.org): month-to-date from the billing-charges API |
| + (`GET https://api.dnsimple.com/v2/{account}/billing/charges` with |
| + `start_date`/`end_date`; `collected` charges summed — refunds are not |
| + spend). Amounts arrive as decimal dollar strings with **no currency field**, |
| + so the published `usd` is the documented constant (DNSimple's price book is |
| + USD), not parsed. Authenticated with `STATUS_DNSIMPLE_TOKEN` + |
| + `STATUS_DNSIMPLE_ACCOUNT` — an account API token, read-only use here, never |
| + echoed. Same cache treatment as AWS; more than one page of charges in a |
| + month counts as failure (a partial sum never publishes). |
| +- **apple** — the Apple Developer Program membership: fixed yearly with no |
| + billing API, so the operator-set constant `STATUS_APPLE_YEARLY_CENTS` |
| + (EUR), published as billed (`yearly_cents`) — never divided into a fake |
| + monthly. |
| No providers → the whole `expenses` object is omitted. Expenses ride the |
| degraded income-unavailable shape too — a Stripe outage doesn't hide spend |
| @@ -231,10 +252,12 @@ carries the sources and the "as of" date). |
| 2. **Creds on the box**: write `/data/status.env` with |
| `AWS_ACCESS_KEY_ID=…` / `AWS_SECRET_ACCESS_KEY=…` (the status-publisher |
| key also covers the Cost Explorer read), plus the optional expense vars: |
| - `STATUS_HETZNER_MONTHLY_CENTS=…`, `STATUS_CLAUDE_MONTHLY_CENTS=…`, and |
| - `STATUS_ANTHROPIC_ADMIN_KEY=…` (an `sk-ant-admin…` key with read-only |
| - usage scope — never the sidecars' completion key). `chown lchat:lchat`, |
| - `chmod 0600`. |
| + `STATUS_HETZNER_MONTHLY_CENTS=…`, `STATUS_CLAUDE_MONTHLY_CENTS=…`, |
| + `STATUS_APPLE_YEARLY_CENTS=…`, `STATUS_ANTHROPIC_ADMIN_KEY=…` (an |
| + `sk-ant-admin…` key with read-only usage scope — never the sidecars' |
| + completion key), and `STATUS_DNSIMPLE_TOKEN=…` + `STATUS_DNSIMPLE_ACCOUNT=…` |
| + (a DNSimple account API token — read-only use here, never echoed — plus |
| + the numeric account id). `chown lchat:lchat`, `chmod 0600`. |
| 3. **Pages on the box**: `scp web/status/{index.html,books.html,carbon.html,carbon.json,contributors.html,contributors.json,icon-192.png} |
| root@<box>:/opt/lchat/status-pages/`. |
| 4. **Install the timer**: `scp deploy/status-publish.sh root@<box>:/opt/lchat/` |
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs| index b516812..2cdc46d 100644 |
| --- a/test/status-contract.test.mjs |
| +++ b/test/status-contract.test.mjs |
| @@ -145,35 +145,41 @@ await test("degraded books shape carries available:false + reason", () => { |
| await test("fixtures contain no per-payer ids or hostnames", () => { |
| for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, carbon, contributors]) { |
| const s = JSON.stringify(j); |
| - assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80)); |
| + assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|dnsfix|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80)); |
| } |
| }); |
| // Expenses publish ITEMIZED by design — decision #19's other half (income |
| // aggregates, expenses itemize; the amounts are public on purpose). The |
| -// schema is still an ALLOWLIST: provider word + one amount + its own billing |
| -// currency, nothing else — so an account id, invoice id, or resource-level |
| -// line can't ship by accident. Currencies stay per-provider, never |
| -// FX-converted, and only usd/eur exist today. |
| +// schema is still an ALLOWLIST: provider word + EXACTLY ONE amount key |
| +// (month_to_date_cents for metered providers, monthly_cents / yearly_cents |
| +// for flat subscriptions, each as billed — a yearly fee is never divided into |
| +// a fake monthly) + its own billing currency, nothing else — so an account |
| +// id, invoice id, or resource-level line can't ship by accident. Currencies |
| +// stay per-provider, never FX-converted, and only usd/eur exist today. |
| await test("expenses carry only the allowed itemized schema", () => { |
| const exp = books.expenses; |
| assert.ok(exp, "books fixture carries expenses"); |
| assert.deepEqual(Object.keys(exp).sort(), ["month", "providers"], "expenses keys"); |
| assert.match(exp.month, /^\d{4}-\d{2}$/, "expenses.month is YYYY-MM"); |
| + // Each provider's key set carries exactly one of the three amount keys — |
| + // the deepEqual below enforces the exactly-one rule per provider. |
| const allowed = { |
| "aws": ["currency", "month_to_date_cents", "provider"], |
| "hetzner": ["currency", "monthly_cents", "provider"], |
| "anthropic": ["currency", "month_to_date_cents", "provider"], |
| "claude.ai": ["currency", "monthly_cents", "provider"], |
| + "dnsimple": ["currency", "month_to_date_cents", "provider"], |
| + "apple": ["currency", "provider", "yearly_cents"], |
| }; |
| // The dry-run's fixed fixture exercises every provider, in publish order. |
| assert.deepEqual(exp.providers.map((p) => p.provider), |
| - ["aws", "hetzner", "anthropic", "claude.ai"], "fixture exercises all providers"); |
| + ["aws", "hetzner", "anthropic", "claude.ai", "dnsimple", "apple"], "fixture exercises all providers"); |
| for (const p of exp.providers) { |
| assert.ok(p.provider in allowed, "unknown provider " + p.provider); |
| assert.deepEqual(Object.keys(p).sort(), allowed[p.provider], p.provider + " keys"); |
| assert.ok(["usd", "eur"].includes(p.currency), p.provider + " currency ∈ {usd,eur}, got " + p.currency); |
| - const cents = p.month_to_date_cents ?? p.monthly_cents; |
| + const cents = p.month_to_date_cents ?? p.monthly_cents ?? p.yearly_cents; |
| assert.ok(Number.isInteger(cents) && cents >= 0, p.provider + " amount is integer cents ≥ 0"); |
| } |
| }); |
diff --git a/test/status-fixtures/books-no-expenses.json b/test/status-fixtures/books-no-expenses.json| index 06d86c6..5591d17 100644 |
| --- a/test/status-fixtures/books-no-expenses.json |
| +++ b/test/status-fixtures/books-no-expenses.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-11T21:42:11Z","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} |
| +{"generated_at":"2026-07-12T06:20:50Z","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/books.json b/test/status-fixtures/books.json| index 2de01a3..935d582 100644 |
| --- a/test/status-fixtures/books.json |
| +++ b/test/status-fixtures/books.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-11T21:41:42Z","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,"expenses":{"month":"2026-07","providers":[{"provider":"aws","month_to_date_cents":1234,"currency":"usd"},{"provider":"hetzner","monthly_cents":1400,"currency":"eur"},{"provider":"anthropic","month_to_date_cents":250,"currency":"usd"},{"provider":"claude.ai","monthly_cents":2000,"currency":"usd"}]}} |
| +{"generated_at":"2026-07-12T06:20:49Z","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,"expenses":{"month":"2026-07","providers":[{"provider":"aws","month_to_date_cents":1234,"currency":"usd"},{"provider":"hetzner","monthly_cents":1400,"currency":"eur"},{"provider":"anthropic","month_to_date_cents":250,"currency":"usd"},{"provider":"claude.ai","monthly_cents":2000,"currency":"usd"},{"provider":"dnsimple","month_to_date_cents":2450,"currency":"usd"},{"provider":"apple","yearly_cents":9900,"currency":"eur"}]}} |
diff --git a/test/status-fixtures/fleet-at-capacity.json b/test/status-fixtures/fleet-at-capacity.json| index ac470f4..54e36a6 100644 |
| --- a/test/status-fixtures/fleet-at-capacity.json |
| +++ b/test/status-fixtures/fleet-at-capacity.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-11T18:41:14Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"critical","region":"EU · Germany"}]},"capacity":{"level":"at-capacity"},"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}} |
| +{"generated_at":"2026-07-12T06:20:50Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"critical","region":"EU · Germany"}]},"capacity":{"level":"at-capacity"},"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/test/status-fixtures/fleet-getting-full.json b/test/status-fixtures/fleet-getting-full.json| index 3e47143..11a868a 100644 |
| --- a/test/status-fixtures/fleet-getting-full.json |
| +++ b/test/status-fixtures/fleet-getting-full.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-11T18:41:14Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"comfortable","region":"EU · Germany"}]},"capacity":{"level":"getting-full"},"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}} |
| +{"generated_at":"2026-07-12T06:20:49Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"comfortable","region":"EU · Germany"}]},"capacity":{"level":"getting-full"},"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/test/status-fixtures/fleet.json b/test/status-fixtures/fleet.json| index a8cd4db..1fc1f1a 100644 |
| --- a/test/status-fixtures/fleet.json |
| +++ b/test/status-fixtures/fleet.json |
| @@ -1 +1 @@ |
| -{"generated_at":"2026-07-11T18:41:14Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"comfortable","region":"EU · Germany"}]},"capacity":{"level":"plenty"},"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}} |
| +{"generated_at":"2026-07-12T06:20:49Z","instances":{"total":6,"systemd":2,"hibernating":4,"awake":2,"asleep":2},"hosts":{"count":1,"boxes":[{"label":"hypatia-sketching","always_on":2,"awake":2,"asleep":2,"load":"comfortable","region":"EU · Germany"}]},"capacity":{"level":"plenty"},"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| index dbfee9b..abbef14 100644 |
| --- a/web/status/books.html |
| +++ b/web/status/books.html |
| @@ -58,9 +58,10 @@ |
| section h2 { font-size: 15px; font-weight: 650; margin: 0 0 10px; } |
| section { margin-bottom: 28px; } |
| - /* Infrastructure note sits directly under its tiles, in secondary ink. */ |
| - #infra-tiles { margin-bottom: 10px; } |
| + section h3 { font-size: 14px; font-weight: 650; margin: 18px 0 10px; } |
| .note { color: var(--ink-2); font-size: 14px; } |
| + /* One-line design explainer directly under each section heading. */ |
| + .explain { margin: -4px 0 12px; } |
| /* Per-tier subscriptions: single-series horizontal bar list; rows are real |
| text (their own table view), bars aria-hidden. Thin mark, 4px rounded |
| @@ -91,35 +92,39 @@ |
| <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="lede">Eleven’s income and spending, published as they stand — part |
| + of running the service in the open.</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">Income is unavailable right now: <span id="reason"></span>.</p> |
| <div id="books"> |
| + <!-- Two clearly-headed halves, the deliberate mirror image of each other: |
| + income aggregates, expenditure itemizes (decision #19). Each section |
| + renders independently — a degraded income feed shows its notice here, |
| + inside Income, and never hides Expenditure (or vice versa). --> |
| <section aria-labelledby="h-income" id="s-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> |
| + <p class="note explain"><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 id="unavailable" role="status">Income is unavailable right now: <span id="reason"></span>.</p> |
| + <div id="income-body"> |
| + <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> |
| + <h3 id="h-tiers">Subscriptions by tier</h3> |
| + <ul class="bars" id="tiers"><li><span class="empty">Loading…</span></li></ul> |
| </div> |
| </section> |
| - <section aria-labelledby="h-infra" id="s-infra" hidden> |
| - <h2 id="h-infra">Infrastructure</h2> |
| - <div class="tiles" id="infra-tiles"></div> |
| - <p class="note">Expenses publish itemized by design — one line per |
| - provider, in that provider’s own billing currency.</p> |
| - </section> |
| - |
| - <section aria-labelledby="h-tiers" id="s-tiers"> |
| - <h2 id="h-tiers">Subscriptions by tier</h2> |
| - <ul class="bars" id="tiers"><li><span class="empty">Loading…</span></li></ul> |
| + <section aria-labelledby="h-expenditure" id="s-expenditure" hidden> |
| + <h2 id="h-expenditure">Expenditure</h2> |
| + <p class="note explain"><strong>Itemized by design:</strong> one line per |
| + provider, in that provider’s own billing currency — never converted.</p> |
| + <div class="tiles" id="exp-tiles"></div> |
| </section> |
| </div> |
| @@ -143,6 +148,7 @@ const FIELDS = [ |
| "mrr_cents", "collected_month_cents", |
| "expenses.month", "expenses.providers[].provider", "expenses.providers[].currency", |
| "expenses.providers[].month_to_date_cents", "expenses.providers[].monthly_cents", |
| + "expenses.providers[].yearly_cents", |
| ]; |
| const $ = (id) => document.getElementById(id); |
| @@ -162,17 +168,18 @@ function render(d) { |
| staleEl.textContent = " — this feed looks stale"; |
| } |
| - // Income surfaces fold on the available flag; expenses ride BOTH shapes — |
| - // a Stripe outage must not hide infrastructure spend the feed can still |
| - // honestly state (and vice versa: no expenses just hides that section). |
| + // Income folds on the available flag INSIDE its own section — the degraded |
| + // notice renders under the Income heading, the heading stays. Expenses ride |
| + // BOTH shapes and render independently: a Stripe outage must not hide |
| + // infrastructure spend the feed can still honestly state (and vice versa: |
| + // no expenses just hides the Expenditure section). |
| if (!d.available) { |
| $("reason").textContent = d.reason || "not configured"; |
| $("unavailable").classList.add("show"); |
| } else { |
| $("unavailable").classList.remove("show"); |
| } |
| - $("s-income").hidden = !d.available; |
| - $("s-tiers").hidden = !d.available; |
| + $("income-body").hidden = !d.available; |
| renderExpenses(d.expenses); |
| if (!d.available) return; |
| @@ -225,15 +232,20 @@ const EXPENSE_LABELS = { |
| "hetzner": "Hetzner · monthly flat", |
| "anthropic": "Anthropic API · month to date", |
| "claude.ai": "claude.ai · monthly", |
| + "dnsimple": "DNSimple · month to date", |
| + "apple": "Apple Developer · yearly", |
| }; |
| function renderExpenses(exp) { |
| - const sec = $("s-infra"); |
| + const sec = $("s-expenditure"); |
| const providers = (exp && exp.providers) || []; |
| - const tiles = $("infra-tiles"); |
| + const tiles = $("exp-tiles"); |
| tiles.replaceChildren(); |
| for (const p of providers) { |
| - const cents = typeof p.month_to_date_cents === "number" ? p.month_to_date_cents : p.monthly_cents; |
| + // Exactly one amount key per provider: metered month-to-date, flat |
| + // monthly, or flat yearly — each shown as billed (a yearly fee is never |
| + // divided into a fake monthly). |
| + const cents = [p.month_to_date_cents, p.monthly_cents, p.yearly_cents].find((v) => typeof v === "number"); |
| if (typeof cents !== "number") continue; |
| const tile = document.createElement("div"); |
| tile.className = "tile"; |