eleven / messenger Public

server: books — hetzner expense computed from the Cloud API; claude.ai goes cumulative (#770)

The hetzner expense provider upgrades from operator-constant-only to
API-computed: with STATUS_HETZNER_TOKEN (a read-only Hetzner Cloud API
token) the generator sums the account's monthly server cost from
GET /v1/servers — pages followed via meta.pagination.next_page, each
server priced by the ONE server_type.prices[] entry matching its own
location.name (price_monthly.gross, VAT-included EUR, rounded once at
the total). Server compute only (primary IPs/backups/volumes price via
the separate /pricing endpoint, deliberately excluded). The operator
constant STATUS_HETZNER_MONTHLY_CENTS still overrides the API — operator
truth wins — and neither set omits the provider, as before. The result
rides the shared cost cache with a 24h default freshness (prices change
~never; STATUS_COSTS_MAX_AGE_HOURS still governs when set). Published
key shape is unchanged: {"provider":"hetzner","monthly_cents":n,
"currency":"eur"}.

Also per Paul: the claude.ai provider switches to a cumulative shape —
the honest figure is lifetime spend, not a monthly. New amount variant
to_date_cents via STATUS_CLAUDE_TO_DATE_CENTS (EUR, operator-attested
estimate); the page labels it "claude.ai · to date (approx.)" and the
schema allowlist now permits exactly one of month_to_date_cents |
monthly_cents | yearly_cents | to_date_cents.

Tests: dry-run gains a two-page hetzner servers fixture (pins
pagination, the per-location price match against decoy entries, and
cents rounding: 16.4288 + 4.5100 → 2094) plus a hetzner-constant
variant (constant wins AND the API is never called — no cache entry);
stale-cache now seeds/asserts hetzner too; omission unchanged. Contract
test pins the API math and the override via the regenerated books.json
and the new books-hetzner-constant.json fixture, and hzfix joins the
privacy leak greps.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a09faa3a126b45babe383a0299b0685b8d38d9a3 parent 5d15b68
7 files changed, +274 −86
  • deploy/status-publish.sh +179 −48
  • docs/status.md +51 −17
  • test/status-contract.test.mjs +35 −14
  • test/status-fixtures/books-hetzner-constant.json +1 −0
  • test/status-fixtures/books-no-expenses.json +1 −1
  • test/status-fixtures/books.json +1 −1
  • web/status/books.html +6 −5
diff --git a/deploy/status-publish.sh b/deploy/status-publish.sh
index 89e04ee..55ccfb3 100755
--- a/deploy/status-publish.sh
+++ b/deploy/status-publish.sh
@@ -81,8 +81,12 @@
# (infra/prod/status.tf; Put/Get/List on the status
# 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_APPLE_YEARLY_CENTS,
+# expense vars ride here too: STATUS_HETZNER_TOKEN (a
+# read-only Hetzner Cloud API token — computes the
+# monthly server cost; never echoed) and/or
+# STATUS_HETZNER_MONTHLY_CENTS (operator constant —
+# overrides the token when both are set),
+# STATUS_CLAUDE_TO_DATE_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
@@ -520,6 +524,67 @@ fetch_aws_cents() {
sqj "SELECT CAST(ROUND($amount * 100) AS INTEGER)"
}
+# hetzner_get_page <page> <outfile> — one authenticated GET of the Hetzner
+# Cloud servers list (Bearer auth; the token rides only in the header — -sS
+# keeps curl from ever printing it). Dry-run reads numbered fixture pages
+# instead, so the caller's pagination loop is exercised by the twin.
+hetzner_get_page() {
+ if [ -n "$DRY_RUN" ]; then
+ [ -f "$FIXDIR/hetzner-servers-$1.json" ] || return 1
+ cp "$FIXDIR/hetzner-servers-$1.json" "$2"
+ return
+ fi
+ [ -n "${STATUS_HETZNER_TOKEN:-}" ] || return 1
+ curl -sS --fail --max-time 30 \
+ -H "Authorization: Bearer $STATUS_HETZNER_TOKEN" \
+ "https://api.hetzner.cloud/v1/servers?page=$1&per_page=50" > "$2"
+}
+
+# fetch_hetzner_cents <month> — the account's monthly server cost, computed
+# from the Cloud API: GET /v1/servers, pages followed via
+# meta.pagination.next_page (hard cap 10 pages / 500 servers — a partial sum
+# must never publish). Each server embeds its type's per-location price list
+# (server_type.prices[]); the entry whose location matches the server's own
+# location.name prices it — price_monthly.gross, a decimal string, VAT
+# included. EXACTLY one entry may match per server or the fetch counts as
+# failed: no match means the server can't be honestly priced, and a duplicate
+# would double-count. The prices carry no currency field, so the published
+# `eur` is the documented constant (Hetzner bills in EUR), not parsed. The
+# figure is SERVER COMPUTE ONLY, deliberately: primary IPs, backups, volumes,
+# and traffic overages price via the separate /pricing endpoint, not
+# per-server — revisit if that delta ever matters. The <month> arg is unused
+# (a flat price list, not month-to-date); it exists for cached_cost_cents'
+# shared interface. STATUS_HETZNER_TOKEN is a READ-ONLY Cloud API token and
+# must never be echoed or logged; the operator constant
+# STATUS_HETZNER_MONTHLY_CENTS overrides this fetch entirely (expenses_object).
+fetch_hetzner_cents() {
+ local page=1 n=0 f next amt load="CREATE TABLE srv(j TEXT);"
+ local match="json_extract(p.value,'\$.location') = json_extract(s.j,'\$.location.name')"
+ while :; do
+ n=$((n + 1))
+ f="$TMP/hetzner-servers-$n.json"
+ hetzner_get_page "$page" "$f" || return 1
+ [ "$(sqj "SELECT json_valid(readfile('$f')) AND json_type(readfile('$f'), '\$.servers')='array'
+ AND json_type(readfile('$f'), '\$.meta.pagination')='object'")" = "1" ] || return 1
+ load="$load INSERT INTO srv SELECT value FROM json_each(readfile('$f'), '\$.servers');"
+ next=$(sqj "SELECT COALESCE(json_extract(readfile('$f'), '\$.meta.pagination.next_page'), '')")
+ [ -n "$next" ] || break
+ case "$next" in *[!0-9]*) return 1 ;; esac
+ [ "$n" -lt 10 ] || return 1
+ page=$next
+ done
+ # Exactly one matching price row per server (see above), and every matched
+ # amount a plain decimal — or the figure is omitted, never misstated.
+ [ "$(sqj "$load SELECT COUNT(*) FROM srv s
+ WHERE (SELECT COUNT(*) FROM json_each(s.j, '\$.server_type.prices') p WHERE $match) <> 1")" = "0" ] || return 1
+ while IFS= read -r amt; do
+ case "$amt" in ''|.|*[!0-9.]*|*.*.*) return 1 ;; esac
+ done < <(sqj "$load SELECT json_extract(p.value, '\$.price_monthly.gross')
+ FROM srv s, json_each(s.j, '\$.server_type.prices') p WHERE $match")
+ sqj "$load SELECT CAST(ROUND(COALESCE(SUM(json_extract(p.value, '\$.price_monthly.gross')), 0) * 100) AS INTEGER)
+ FROM srv s, json_each(s.j, '\$.server_type.prices') p WHERE $match"
+}
+
# fetch_anthropic_cents <month> — one live Admin API cost-report call:
# GET /v1/organizations/cost_report, daily buckets from the month start
# (limit=31 covers a full month in one page; no ending_at → runs to now, so
@@ -590,21 +655,25 @@ fetch_dnsimple_cents() {
WHERE json_extract(c.value, '\$.state')='collected'"
}
-# cached_cost_cents <provider> <month> — the shared fetch-through-cache for
-# 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
-# STATUS_COSTS_MAX_AGE_HOURS (default 6) or from a previous month (last
-# month's total is not this month's spend — a cross-month entry never
-# publishes). A failed refetch falls back to the valid-but-stale same-month
-# entry (stale beats absent for a month-to-date figure); no usable entry +
-# failure → echoes nothing and the provider is omitted.
+# cached_cost_cents <provider> <month> [default-age-hours] — the shared
+# fetch-through-cache for the API-computed providers (aws, hetzner, 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 STATUS_COSTS_MAX_AGE_HOURS
+# (default: the caller's default-age-hours arg, else 6 — hetzner passes 24,
+# its flat price list changes ~never) or from a previous month (last month's
+# total is not this month's spend — a cross-month entry never publishes; for
+# hetzner that just means one refetch at the month turn). A failed refetch
+# falls back to the valid-but-stale same-month entry (stale beats absent);
+# no usable entry + failure → echoes nothing and the provider is omitted.
+# The cache field name month_to_date_cents is generic storage — for hetzner
+# it holds the flat monthly figure.
cached_cost_cents() {
local provider=$1 month=$2 now have_cache="" cache_ts=0 cache_month="" cache_cents="" max_age_h cents
now=$(date -u +%s)
- max_age_h=${STATUS_COSTS_MAX_AGE_HOURS:-6}
+ max_age_h=${STATUS_COSTS_MAX_AGE_HOURS:-${3:-6}}
case "$max_age_h" in ''|*[!0-9]*) max_age_h=6 ;; esac
if [ -f "$COSTS_CACHE" ] && [ "$(sqj "SELECT json_valid(readfile('$COSTS_CACHE'))" 2>/dev/null)" = "1" ]; then
@@ -641,14 +710,22 @@ cached_cost_cents() {
# expenses_object — the expenses JSON object, or nothing when no provider
# resolves. Providers, in publish order:
# aws — Cost Explorer month-to-date (metered; cached above), USD.
-# hetzner — no billing API exists: the box is a flat-rate Hetzner Cloud
-# server, so the figure is the honest operator-set constant
-# STATUS_HETZNER_MONTHLY_CENTS (EUR — Hetzner bills in EUR).
-# Revisit if Hetzner ever ships a billing API.
+# hetzner — the flat-rate Hetzner Cloud servers. No billing API exists,
+# but the servers API prices each server (its type's embedded
+# per-location price list), so STATUS_HETZNER_TOKEN computes
+# the account's monthly server cost (fetch_hetzner_cents —
+# server compute only; cached, 24h default freshness). The
+# operator-set constant STATUS_HETZNER_MONTHLY_CENTS overrides
+# the API when set — operator truth wins. EUR either way
+# (Hetzner bills in EUR). Neither set → omitted.
# anthropic — Admin API cost report month-to-date (metered; cached), USD —
# 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).
+# claude.ai — the claude.ai subscriptions: no API for them, and the honest
+# figure Paul can attest is LIFETIME spend, not a monthly — so
+# the operator-set constant STATUS_CLAUDE_TO_DATE_CENTS (EUR)
+# publishes as to_date_cents, the cumulative shape. It's an
+# operator-attested estimate by nature, which is why the page
+# labels it "(approx.)".
# dnsimple — the product domains (elevenmessenger.com, 11ch.at,
# 11factor.org): billing-charges month-to-date (metered;
# cached), USD.
@@ -663,14 +740,18 @@ expenses_object() {
cents=$(cached_cost_cents aws "$month")
[ -n "$cents" ] && providers=$(sqj "SELECT json_object('provider','aws','month_to_date_cents',$cents,'currency','usd')")
+ # Operator constant first — when set, the API is never called (or cached).
cents=$(env_cents "${STATUS_HETZNER_MONTHLY_CENTS:-}")
+ [ -n "$cents" ] || cents=$(cached_cost_cents hetzner "$month" 24)
[ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','hetzner','monthly_cents',$cents,'currency','eur')")"
cents=$(cached_cost_cents anthropic "$month")
[ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','anthropic','month_to_date_cents',$cents,'currency','usd')")"
- cents=$(env_cents "${STATUS_CLAUDE_MONTHLY_CENTS:-}")
- [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','claude.ai','monthly_cents',$cents,'currency','usd')")"
+ # Cumulative, not monthly: the operator attests lifetime spend to date (an
+ # approximation by nature — the page carries the "(approx.)" marker).
+ cents=$(env_cents "${STATUS_CLAUDE_TO_DATE_CENTS:-}")
+ [ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','claude.ai','to_date_cents',$cents,'currency','eur')")"
cents=$(cached_cost_cents dnsimple "$month")
[ -n "$cents" ] && providers="${providers:+$providers,}$(sqj "SELECT json_object('provider','dnsimple','month_to_date_cents',$cents,'currency','usd')")"
@@ -953,40 +1034,76 @@ 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 six
- # providers resolve). The cost cache is repointed into FIXDIR — the dry run
- # 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.
+ # Expense fixtures (STATUS_DRY_EXPENSES=omit|stale-cache|hetzner-constant;
+ # default = all six providers resolve, hetzner from its API fixture). The
+ # cost cache is repointed into FIXDIR — the dry run never touches $DATA_DIR.
+ # The API fixtures carry fixture-only ids (request id, workspace id, charge
+ # references/domains, server names): 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)
- # No metered fixtures, no cache, no operator constants → every provider,
+ # No API fixtures, no cache, no operator constants → every provider,
# and then the whole expenses object, is omitted.
STATUS_HETZNER_MONTHLY_CENTS=""
- STATUS_CLAUDE_MONTHLY_CENTS=""
+ STATUS_CLAUDE_TO_DATE_CENTS=""
STATUS_APPLE_YEARLY_CENTS=""
;;
stale-cache)
- # 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).
+ # No API fixtures (all four "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_CLAUDE_TO_DATE_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),
+ 'hetzner', json_object('fetched_at', strftime('%s','now') - 999999, 'month', strftime('%Y-%m','now'), 'month_to_date_cents', 1399),
'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
+ # Default: hetzner's constant is cleared so its API fixture drives the
+ # figure (and an ambient operator env can't leak in);
+ # STATUS_DRY_EXPENSES=hetzner-constant sets it WITH the fixture still
+ # present, asserting the constant wins over a resolvable API path.
+ STATUS_HETZNER_MONTHLY_CENTS=""
+ [ "${STATUS_DRY_EXPENSES:-}" = hetzner-constant ] && STATUS_HETZNER_MONTHLY_CENTS=1400
+ STATUS_CLAUDE_TO_DATE_CENTS=229000
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}]}
+EOF
+ # Hetzner servers shape, TWO pages (exercises meta.pagination.next_page):
+ # each server carries its type's per-location price list, and only the
+ # entry matching the server's own location.name may price it — the
+ # decoy entries for the other location differ on purpose. Gross monthly
+ # EUR strings: 16.4288 (cpx31 @ hel1; NOT the fsn1 16.1840) + 4.5100
+ # (cx22 @ fsn1; NOT the hel1 4.6900) = 20.9388 → rounds to 2094 cents.
+ # Server names are fixture markers that must not survive aggregation.
+ cat > "$FIXDIR/hetzner-servers-1.json" <<'EOF'
+{"servers":[
+ {"id":101,"name":"hzfix-alpha","status":"running",
+ "location":{"id":3,"name":"hel1","description":"Helsinki DC Park 1","country":"FI","city":"Helsinki","network_zone":"eu-central"},
+ "server_type":{"id":104,"name":"cpx31","cores":4,"memory":8,"disk":160,"deprecated":false,
+ "prices":[
+ {"location":"fsn1","price_hourly":{"net":"0.0225","gross":"0.0268"},"price_monthly":{"net":"13.6000","gross":"16.1840"}},
+ {"location":"hel1","price_hourly":{"net":"0.0229","gross":"0.0273"},"price_monthly":{"net":"13.8057","gross":"16.4288"}}
+ ]}}
+],"meta":{"pagination":{"page":1,"per_page":1,"previous_page":null,"next_page":2,"last_page":2,"total_entries":2}}}
+EOF
+ cat > "$FIXDIR/hetzner-servers-2.json" <<'EOF'
+{"servers":[
+ {"id":102,"name":"hzfix-bravo","status":"running",
+ "location":{"id":1,"name":"fsn1","description":"Falkenstein DC Park 1","country":"DE","city":"Falkenstein","network_zone":"eu-central"},
+ "server_type":{"id":22,"name":"cx22","cores":2,"memory":4,"disk":40,"deprecated":false,
+ "prices":[
+ {"location":"fsn1","price_hourly":{"net":"0.0063","gross":"0.0075"},"price_monthly":{"net":"3.7900","gross":"4.5100"}},
+ {"location":"hel1","price_hourly":{"net":"0.0066","gross":"0.0079"},"price_monthly":{"net":"3.9400","gross":"4.6900"}}
+ ]}}
+],"meta":{"pagination":{"page":2,"per_page":1,"previous_page":1,"next_page":null,"last_page":2,"total_entries":2}}}
EOF
# Anthropic cost-report shape: two daily buckets, amounts are decimal
# strings of CENTS (150.25 + 99.5 → rounds to 250). The workspace id is
@@ -1049,7 +1166,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|dnsfix|\.example' "$outdir/fleet.json" "$outdir/books.json"; then
+ if grep -qE 'sub_fixture|cus_fixture|txn_fixture|req_fixture|ce_fixture|wrkspc_fixture|dnsfix|hzfix|\.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
@@ -1068,10 +1185,12 @@ EOF
fi
# Expenses: itemized by design, but the shape is still pinned — and the
# 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: 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
+ # from fixtures/env (hetzner computed from its servers-API fixture), with
+ # the API results freshly cached; hetzner-constant: same fixtures but the
+ # operator constant wins; omit: no expenses key at all; stale-cache: all
+ # four API fetches failed and the seeded same-month cache served
+ # (aws 999 / hetzner 1399 / anthropic 888 / dnsimple 777).
+ local aws_c hz_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)
@@ -1079,10 +1198,11 @@ EOF
{ echo "dry-run: expenses must be omitted entirely when no provider resolves" >&2; exit 1; }
;;
stale-cache)
- an_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].month_to_date_cents')")
- 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; }
+ hz_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].monthly_cents')")
+ 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[3].month_to_date_cents')")
+ [ "$aws_c" = "999" ] && [ "$hz_c" = "1399" ] && [ "$an_c" = "888" ] && [ "$dn_c" = "777" ] ||
+ { echo "dry-run: stale same-month cache must serve on fetch failure (got aws='$aws_c' hetzner='$hz_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')")
@@ -1090,10 +1210,21 @@ EOF
[ "$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; }
+ hz_c=$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[1].monthly_cents')")
+ if [ "${STATUS_DRY_EXPENSES:-}" = hetzner-constant ]; then
+ [ "$hz_c" = "1400" ] ||
+ { echo "dry-run: hetzner constant must override the API fixture — 1400 cents (got '$hz_c')" >&2; exit 1; }
+ # The constant must short-circuit the API entirely: nothing cached.
+ [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.hetzner') IS NULL")" = "1" ] ||
+ { echo "dry-run: hetzner constant must skip the API (a hetzner cache entry exists)" >&2; exit 1; }
+ else
+ [ "$hz_c" = "2094" ] ||
+ { echo "dry-run: hetzner servers fixture must yield 2094 cents — location-matched gross, rounded (got '$hz_c')" >&2; exit 1; }
+ [ "$(sqj "SELECT json_extract(readfile('$COSTS_CACHE'), '\$.hetzner.month_to_date_cents')")" = "2094" ] ||
+ { echo "dry-run: hetzner API result must be cached" >&2; exit 1; }
+ fi
+ [ "$(sqj "SELECT json_extract(readfile('$outdir/books.json'), '\$.expenses.providers[3].to_date_cents')")" = "229000" ] ||
+ { echo "dry-run: claude.ai constant must yield 229000 cents, cumulative (to_date_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" ] &&
diff --git a/docs/status.md b/docs/status.md
index 3fa2eb1..b1bd90b 100644
--- a/docs/status.md
+++ b/docs/status.md
@@ -133,9 +133,9 @@ configured / Stripe is unreachable):
"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": "hetzner", "monthly_cents": 2094, "currency": "eur" },
{ "provider": "anthropic", "month_to_date_cents": 250, "currency": "usd" },
- { "provider": "claude.ai", "monthly_cents": 2000, "currency": "usd" },
+ { "provider": "claude.ai", "to_date_cents": 229000, "currency": "eur" },
{ "provider": "dnsimple", "month_to_date_cents": 2450, "currency": "usd" },
{ "provider": "apple", "yearly_cents": 9900, "currency": "eur" }
] }
@@ -148,11 +148,13 @@ 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). Three
+mirror image of income's aggregation (see the privacy rules above). Four
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).
+yearly ones `yearly_cents`, and a cumulative operator-attested figure
+`to_date_cents` — each as billed, in its own billing currency (a yearly fee
+is published yearly, never divided into a fake monthly; a lifetime figure is
+published to date, never smeared into months).
- **aws** — month-to-date UnblendedCost from Cost Explorer (a us-east-1
global endpoint, regardless of where the bucket lives). CE bills ~$0.01
@@ -162,16 +164,34 @@ yearly fee is published yearly, never divided into a fake monthly).
`STATUS_COSTS_MAX_AGE_HOURS` (default 6) or from a previous month. A
failed refetch serves the same-month stale cache; failure with no cache
omits the entry.
-- **hetzner** — Hetzner has no billing API, so the flat-rate box publishes
- the honest operator-set constant `STATUS_HETZNER_MONTHLY_CENTS` (EUR);
- unset omits it. Revisit if Hetzner ships a billing API.
+- **hetzner** — the flat-rate Hetzner Cloud servers. Hetzner has no billing
+ API, but the servers API prices each server: with `STATUS_HETZNER_TOKEN`
+ (a **read-only** Cloud API token) the generator computes the account's
+ monthly server cost from `GET /v1/servers` (pagination followed via
+ `meta.pagination.next_page`) — each server's embedded
+ `server_type.prices[]` entry for its own location, `price_monthly.gross`
+ summed, rounded once at the total. The prices carry no currency field, so
+ the published `eur` is the documented constant (Hetzner bills in EUR).
+ The figure is **server compute only** — primary IPs, backups, volumes,
+ and traffic overages price via the separate `/pricing` endpoint and are
+ deliberately not included. Same cache treatment as AWS but with 24h
+ default freshness (a flat price list changes ~never;
+ `STATUS_COSTS_MAX_AGE_HOURS` still overrides). Exactly one price entry
+ must match per server and every amount must parse cleanly, or the fetch
+ fails — and a failed figure is omitted, never misstated. The operator-set
+ constant `STATUS_HETZNER_MONTHLY_CENTS` (EUR) **overrides the API** when
+ set — operator truth wins; neither set omits the provider.
- **anthropic** — the bot sidecars' API spend: month-to-date from the Admin
API cost report (`GET /v1/organizations/cost_report`, daily buckets summed;
amounts arrive as decimal cents, USD). Authenticated with
`STATUS_ANTHROPIC_ADMIN_KEY` — an *Admin* key with read-only usage scope,
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).
+- **claude.ai** — the claude.ai subscriptions: no API for them, and the
+ honest figure the operator can attest is **lifetime spend**, not a
+ monthly — so the operator-set constant `STATUS_CLAUDE_TO_DATE_CENTS`
+ (EUR) publishes as `to_date_cents`, the cumulative shape. It's an
+ estimate by nature, which is why the page labels it
+ "claude.ai · to date (approx.)".
- **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
@@ -252,7 +272,13 @@ 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=…`,
+ `STATUS_HETZNER_TOKEN=…` (a Hetzner Cloud API token minted **read-only**
+ in the project's Security → API tokens — it only lists servers here, and
+ is never echoed; store it in 1Password like the other per-service
+ tokens) and/or `STATUS_HETZNER_MONTHLY_CENTS=…` (operator constant —
+ overrides the token's API-computed figure when both are set),
+ `STATUS_CLAUDE_TO_DATE_CENTS=…` (cumulative lifetime claude.ai spend —
+ an operator-attested estimate, labelled "(approx.)" on the page),
`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=…`
@@ -287,15 +313,23 @@ origin unchanged.
network, no upload. `STATUS_DRY_CAPACITY=getting-full|at-capacity` swaps in
the capacity fixture set for that band (default: plenty).
`STATUS_DRY_EXPENSES=omit` removes every expense source (asserts the whole
- object is omitted); `STATUS_DRY_EXPENSES=stale-cache` fails both metered
+ object is omitted); `STATUS_DRY_EXPENSES=stale-cache` fails all four API
fetches against a seeded, past-max-age same-month cache (asserts the stale
- figures serve) — so the cache seams are testable end-to-end.
+ figures serve) — so the cache seams are testable end-to-end. The default
+ run computes hetzner from a two-page servers fixture (asserts pagination,
+ the per-location price match, and the cents rounding);
+ `STATUS_DRY_EXPENSES=hetzner-constant` sets the operator constant with the
+ fixture still resolvable (asserts the constant wins and the API is never
+ called).
- `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. It also allowlists the
hosts/capacity schema **and** the expenses schema (any extra key fails, so
a precise number or an account id can't ship), pins one fleet fixture per
- capacity band (`fleet-getting-full.json`, `fleet-at-capacity.json`), and
- pins expense omission (`books-no-expenses.json`). Regenerate fixtures from
- a fresh dry-run after changing the generator (the per-band ones via
- `STATUS_DRY_CAPACITY`, the no-expenses one via `STATUS_DRY_EXPENSES=omit`).
+ capacity band (`fleet-getting-full.json`, `fleet-at-capacity.json`), pins
+ expense omission (`books-no-expenses.json`), and pins hetzner's
+ API-computed cents + constant override (`books.json` /
+ `books-hetzner-constant.json`). Regenerate fixtures from a fresh dry-run
+ after changing the generator (the per-band ones via `STATUS_DRY_CAPACITY`,
+ the no-expenses one via `STATUS_DRY_EXPENSES=omit`, the hetzner-constant
+ one via `STATUS_DRY_EXPENSES=hetzner-constant`).
diff --git a/test/status-contract.test.mjs b/test/status-contract.test.mjs
index 2cdc46d..a277452 100644
--- a/test/status-contract.test.mjs
+++ b/test/status-contract.test.mjs
@@ -2,11 +2,13 @@
// 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,
-// books-no-expenses.json; regenerate with `./deploy/status-publish.sh
-// --dry-run <dir>` after changing the generator — the no-expenses variant via
-// STATUS_DRY_EXPENSES=omit) 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.
+// books-no-expenses.json, books-hetzner-constant.json; regenerate with
+// `./deploy/status-publish.sh --dry-run <dir>` after changing the generator —
+// the no-expenses variant via STATUS_DRY_EXPENSES=omit, the hetzner-constant
+// one via STATUS_DRY_EXPENSES=hetzner-constant) 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";
@@ -31,6 +33,7 @@ const fleetAC = JSON.parse(read("test/status-fixtures/fleet-at-capacity.json"));
const books = JSON.parse(read("test/status-fixtures/books.json"));
const booksDown = JSON.parse(read("test/status-fixtures/books-unavailable.json"));
const booksNoExp = JSON.parse(read("test/status-fixtures/books-no-expenses.json"));
+const booksHzConst = JSON.parse(read("test/status-fixtures/books-hetzner-constant.json"));
// Pull the FIELDS manifest out of a page's inline script.
function fields(html) {
@@ -143,9 +146,9 @@ await test("degraded books shape carries available:false + reason", () => {
// customer, transaction, or provider-account/workspace id may appear in
// published output.
await test("fixtures contain no per-payer ids or hostnames", () => {
- for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, carbon, contributors]) {
+ for (const j of [fleet, fleetGF, fleetAC, books, booksDown, booksNoExp, booksHzConst, carbon, contributors]) {
const s = JSON.stringify(j);
- assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|dnsfix|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80));
+ assert.ok(!/sub_|cus_|txn_|wrkspc_|req_fixture|ce_fixture|dnsfix|hzfix|\.example|\.sock/.test(s), "leak in " + s.slice(0, 80));
}
});
@@ -153,22 +156,24 @@ await test("fixtures contain no per-payer ids or hostnames", () => {
// aggregates, expenses itemize; the amounts are public on purpose). The
// 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.
+// for flat subscriptions, to_date_cents for a cumulative operator-attested
+// lifetime figure — each as billed: a yearly fee is never divided into a
+// fake monthly, a lifetime figure never smeared across months) + 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 —
+ // Each provider's key set carries exactly one of the four 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"],
+ "claude.ai": ["currency", "provider", "to_date_cents"],
"dnsimple": ["currency", "month_to_date_cents", "provider"],
"apple": ["currency", "provider", "yearly_cents"],
};
@@ -179,7 +184,7 @@ await test("expenses carry only the allowed itemized schema", () => {
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 ?? p.yearly_cents;
+ const cents = p.month_to_date_cents ?? p.monthly_cents ?? p.yearly_cents ?? p.to_date_cents;
assert.ok(Number.isInteger(cents) && cents >= 0, p.provider + " amount is integer cents ≥ 0");
}
});
@@ -191,6 +196,22 @@ await test("expenses omit entirely when no provider resolves", () => {
assert.equal(booksNoExp.available, true, "omission is independent of income availability");
});
+// Hetzner's figure is API-computed when the operator constant is unset: the
+// default fixture pins the servers-API math (two dry-run fixture pages, each
+// server priced by the ONE server_type.prices[] entry matching its own
+// location — gross monthly EUR 16.4288 + 4.5100 = 20.9388, rounded once at
+// the total to 2094 cents). And when STATUS_HETZNER_MONTHLY_CENTS is set the
+// constant wins over a resolvable API path — operator truth beats computed.
+// Either way the key shape is identical (monthly_cents, eur).
+await test("hetzner: API-computed cents with rounding; constant overrides", () => {
+ const hz = books.expenses.providers.find((p) => p.provider === "hetzner");
+ assert.deepEqual(hz, { provider: "hetzner", monthly_cents: 2094, currency: "eur" },
+ "API path: location-matched gross sum, rounded");
+ const hzc = booksHzConst.expenses.providers.find((p) => p.provider === "hetzner");
+ assert.deepEqual(hzc, { provider: "hetzner", monthly_cents: 1400, currency: "eur" },
+ "constant override: the operator figure publishes, same shape");
+});
+
// The carbon tally must stay internally consistent as entries accrue — the
// totals are the sum of the ledger, each entry's kg derives from its output
// tokens × the published factors, and each SOURCE's periods are chronological
diff --git a/test/status-fixtures/books-hetzner-constant.json b/test/status-fixtures/books-hetzner-constant.json
new file mode 100644
index 0000000..74247aa
--- /dev/null
+++ b/test/status-fixtures/books-hetzner-constant.json
@@ -0,0 +1 @@
+{"generated_at":"2026-07-12T06:44:21Z","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","to_date_cents":229000,"currency":"eur"},{"provider":"dnsimple","month_to_date_cents":2450,"currency":"usd"},{"provider":"apple","yearly_cents":9900,"currency":"eur"}]}}
diff --git a/test/status-fixtures/books-no-expenses.json b/test/status-fixtures/books-no-expenses.json
index 5591d17..fe4e933 100644
--- a/test/status-fixtures/books-no-expenses.json
+++ b/test/status-fixtures/books-no-expenses.json
@@ -1 +1 @@
-{"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}
+{"generated_at":"2026-07-12T06:44:21Z","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 935d582..4488346 100644
--- a/test/status-fixtures/books.json
+++ b/test/status-fixtures/books.json
@@ -1 +1 @@
-{"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"}]}}
+{"generated_at":"2026-07-12T06:44:20Z","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":2094,"currency":"eur"},{"provider":"anthropic","month_to_date_cents":250,"currency":"usd"},{"provider":"claude.ai","to_date_cents":229000,"currency":"eur"},{"provider":"dnsimple","month_to_date_cents":2450,"currency":"usd"},{"provider":"apple","yearly_cents":9900,"currency":"eur"}]}}
diff --git a/web/status/books.html b/web/status/books.html
index abbef14..41f47ab 100644
--- a/web/status/books.html
+++ b/web/status/books.html
@@ -148,7 +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",
+ "expenses.providers[].yearly_cents", "expenses.providers[].to_date_cents",
];
const $ = (id) => document.getElementById(id);
@@ -231,7 +231,7 @@ const EXPENSE_LABELS = {
"aws": "AWS · month to date",
"hetzner": "Hetzner · monthly flat",
"anthropic": "Anthropic API · month to date",
- "claude.ai": "claude.ai · monthly",
+ "claude.ai": "claude.ai · to date (approx.)",
"dnsimple": "DNSimple · month to date",
"apple": "Apple Developer · yearly",
};
@@ -243,9 +243,10 @@ function renderExpenses(exp) {
tiles.replaceChildren();
for (const p of providers) {
// 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");
+ // monthly, flat yearly, or cumulative to-date — each shown as billed
+ // (a yearly fee is never divided into a fake monthly; a lifetime figure
+ // is labelled to date, not smeared across months).
+ const cents = [p.month_to_date_cents, p.monthly_cents, p.yearly_cents, p.to_date_cents].find((v) => typeof v === "number");
if (typeof cents !== "number") continue;
const tile = document.createElement("div");
tile.className = "tile";