screentinker/scripts/mint-billing-token.js
ScreenTinker 385eda3cb1 feat(#146): owner-only CLI to mint billing:read tokens (scripts/mint-billing-token.js)
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>
2026-07-01 21:31:11 -05:00

75 lines
3.5 KiB
JavaScript
Executable file

#!/usr/bin/env node
'use strict';
// #146 BILLING — owner-only CLI to mint / revoke / list `billing:read` API tokens.
//
// node scripts/mint-billing-token.js --name "Bold invoicing" # mint (prints secret ONCE)
// node scripts/mint-billing-token.js --list # list billing tokens
// node scripts/mint-billing-token.js --revoke <id> # revoke one
//
// OWNER-ONLY BY CONSTRUCTION: this is a server-side script with NO network endpoint. The
// access control IS filesystem/shell access to the host — i.e. the platform owner. It is
// deliberately NOT in the workspace API-Tokens UI (that surface is workspace-scoped, self-
// service; a billing token grants platform-wide billing-read and must be issued by the owner).
// On the container: `docker exec screentinker node ../scripts/mint-billing-token.js --name "…"`.
//
// The logic lives in server/lib/billing-token.js (unit-tested); this file is a thin wrapper.
const { db } = require('../server/db/database');
const { mintBillingToken, revokeBillingToken, listBillingTokens } = require('../server/lib/billing-token');
function arg(flag) {
const i = process.argv.indexOf(flag);
return i !== -1 ? (process.argv[i + 1] || '') : undefined;
}
const has = (flag) => process.argv.includes(flag);
function fmtTime(t) { return t ? new Date(t * 1000).toISOString() : '—'; }
function main() {
if (has('--help') || has('-h')) {
console.log('Usage:\n --name "<label>" mint a billing:read token\n --list list billing tokens\n --revoke <id> revoke a billing token');
return 0;
}
if (has('--list')) {
const rows = listBillingTokens(db);
if (!rows.length) { console.log('No billing:read tokens.'); return 0; }
for (const r of rows) {
console.log(`${r.id} ${r.prefix}… "${r.name}" created=${fmtTime(r.created_at)} last_used=${fmtTime(r.last_used_at)} ${r.revoked_at ? 'REVOKED ' + fmtTime(r.revoked_at) : 'active'}`);
}
return 0;
}
const revokeId = arg('--revoke');
if (revokeId !== undefined) {
if (!revokeId) { console.error('ERROR: --revoke needs a token id (see --list)'); return 1; }
const res = revokeBillingToken(db, revokeId);
if (!res.ok) { console.error(`ERROR: ${res.reason}`); return 1; }
console.log(res.alreadyRevoked ? `Token ${revokeId} was already revoked.` : `✔ Revoked billing token ${revokeId}. It is refused on the next request.`);
return 0;
}
const name = arg('--name');
if (name === undefined) { console.error('ERROR: nothing to do. Use --name "<label>" to mint, --list, or --revoke <id>.'); return 1; }
let minted;
try { minted = mintBillingToken(db, { name }); }
catch (e) { console.error(`ERROR: ${e.message}`); return 1; }
console.log('');
console.log(`✔ Minted billing:read token "${minted.name}"`);
console.log(` id: ${minted.id} (use this to revoke)`);
console.log(` token: ${minted.secret}`);
console.log(' ^^^ STORE THIS NOW — it will NOT be shown again ^^^');
console.log(` scope: ${minted.scope} (read-only; authorizes ONLY GET /api/billing/usage)`);
console.log(` bound: owner=${minted.owner_email || minted.owner_id} workspace=${minted.workspace_id} (vestigial — billing is platform-global)`);
console.log('');
console.log(' ⚠ Run only as the platform OWNER on the host. Anyone holding this token can read');
console.log(` billing figures until revoked: node scripts/mint-billing-token.js --revoke ${minted.id}`);
console.log('');
return 0;
}
process.exit(main());