mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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>
75 lines
3.5 KiB
JavaScript
Executable file
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());
|