The billing:read scope + dual-path gate were built but there was no way to MINT a token
(and it must NOT go in the workspace-scoped, self-service API-Tokens UI). Adds a server-side,
owner-only CLI — no new UI, no network endpoint. Owner-only BY CONSTRUCTION: it's a
host-side script, so filesystem/shell access = the platform owner.
- server/lib/billing-token.js (testable): mintBillingToken/revokeBillingToken/
listBillingTokens. Reuses the EXACT existing token path — same secret (st_ + 32 bytes
base64url), same SHA-256 hashing (hashToken), same api_tokens columns — no second format.
Resolves the platform OWNER (oldest platform_admin/superadmin; #14 collapsed superadmin ->
platform_admin so that's the top tier) and binds to their workspace. api_tokens.user_id +
workspace_id are BOTH NOT NULL (no platform-level token exists); the workspace binding is
VESTIGIAL for billing (billing:read is off-ladder -> can't reach any workspace router;
billing is platform-global), documented in-file rather than loosening NOT NULL pre-release.
- scripts/mint-billing-token.js: thin CLI wrapper. --name mints and prints the secret ONCE
(+ id, + "run as owner on host" warning), --list, --revoke <id> (soft revoke, mirrors the
dashboard DELETE).
Tests (4, test/billing-token-mint.test.js): minted row is scope EXACTLY billing:read with a
matching SHA-256 hash and no read/write/full/agency scope; the token reads GET
/api/billing/usage (200) but is refused on /api/devices (403) and /api/admin (401) — scope
isolation; revocation -> 401; mint requires a name; revoke refuses a non-billing id. CLI
smoked live (mint/list/revoke). Suite 310/310.
SPEC-vs-REALITY (again): spec said bcrypt + JSON `scopes`; this codebase uses SHA-256 + a
single `scope` TEXT column. Built to the real system.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>