screentinker/server/lib/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

84 lines
4.4 KiB
JavaScript

'use strict';
// #146 BILLING — owner-only minting of a `billing:read` scoped token. The logic lives here
// (testable); scripts/mint-billing-token.js is a thin CLI wrapper. Reuses the EXACT existing
// token shape — same secret format (st_ + 32 random bytes base64url), same SHA-256 hashing,
// same api_tokens columns — so a minted token verifies through the normal apiTokenAuth path.
//
// NOTE ON HASHING: the codebase hashes token secrets with SHA-256 (middleware/apiToken.js
// hashToken), NOT bcrypt, and stores a single `scope` TEXT column (not a JSON `scopes`
// array). We reuse that exact path rather than introduce a second token format.
//
// NOTE ON BINDING: api_tokens.user_id and workspace_id are BOTH NOT NULL — there is no
// platform-level (workspace-less) token today. A `billing:read` token's workspace binding
// is VESTIGIAL: the scope is off the read/write/full ladder, so tokenScopeGate/agencyGate
// reject it on every workspace router; it authorizes ONLY GET /api/billing/usage, which is
// platform-global and ignores the binding. Rather than loosen that NOT NULL for every token
// type right before release, we bind to the platform OWNER + their workspace and record why.
const crypto = require('crypto');
const { generateToken, hashToken, displayPrefix } = require('../middleware/apiToken');
const BILLING_SCOPE = 'billing:read';
// The platform OWNER = highest-privilege user. #14 collapsed superadmin → platform_admin,
// so PLATFORM_ROLES is the top tier; there is no finer "owner" tier. Oldest such user wins.
function resolveOwner(db) {
return db.prepare(
"SELECT id, email FROM users WHERE role IN ('platform_admin','superadmin') ORDER BY created_at ASC, rowid ASC LIMIT 1"
).get();
}
// A workspace to satisfy the NOT NULL FK — the owner's first workspace (as tenancy resolves
// it), else any workspace. Vestigial for billing (see header).
function resolveWorkspaceId(db, ownerId) {
const own = db.prepare(
'SELECT wm.workspace_id AS id FROM workspace_members wm WHERE wm.user_id = ? ORDER BY wm.joined_at ASC LIMIT 1'
).get(ownerId);
if (own) return own.id;
const any = db.prepare('SELECT id FROM workspaces ORDER BY rowid ASC LIMIT 1').get();
return any ? any.id : null;
}
// Mint a billing:read token. Returns { id, secret, prefix, name, scope, owner_id,
// workspace_id }. The secret is plaintext and returned ONCE — the caller must surface it and
// never store it. Throws if no owner/workspace exists.
function mintBillingToken(db, { name } = {}) {
const label = (name || '').trim();
if (!label) throw new Error('a token --name is required');
if (label.length > 100) throw new Error('name too long (max 100)');
const owner = resolveOwner(db);
if (!owner) throw new Error('no platform-admin/owner user exists — create one before minting a billing token');
const workspaceId = resolveWorkspaceId(db, owner.id);
if (!workspaceId) throw new Error('no workspace exists to satisfy the api_tokens FK');
const secret = generateToken();
const id = crypto.randomUUID();
db.prepare(`
INSERT INTO api_tokens (id, token_hash, prefix, name, user_id, workspace_id, scope, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'))
`).run(id, hashToken(secret), displayPrefix(secret), label, owner.id, workspaceId, BILLING_SCOPE);
return { id, secret, prefix: displayPrefix(secret), name: label, scope: BILLING_SCOPE, owner_id: owner.id, owner_email: owner.email, workspace_id: workspaceId };
}
// Soft-revoke (mirrors routes/tokens.js DELETE): sets revoked_at; apiTokenAuth refuses on
// the next request. Only revokes billing:read tokens (a safety rail for the CLI).
function revokeBillingToken(db, id) {
const row = db.prepare('SELECT id, scope, revoked_at FROM api_tokens WHERE id = ?').get(id);
if (!row) return { ok: false, reason: 'not found' };
if (row.scope !== BILLING_SCOPE) return { ok: false, reason: `token ${id} has scope '${row.scope}', not ${BILLING_SCOPE} — refuse (use the dashboard to revoke workspace tokens)` };
if (row.revoked_at) return { ok: true, alreadyRevoked: true };
db.prepare("UPDATE api_tokens SET revoked_at = strftime('%s','now') WHERE id = ?").run(id);
return { ok: true };
}
function listBillingTokens(db) {
return db.prepare(
'SELECT id, prefix, name, created_at, last_used_at, revoked_at FROM api_tokens WHERE scope = ? ORDER BY created_at DESC'
).all(BILLING_SCOPE);
}
module.exports = { mintBillingToken, revokeBillingToken, listBillingTokens, BILLING_SCOPE };