mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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>
This commit is contained in:
parent
677b17028e
commit
385eda3cb1
|
|
@ -57,6 +57,13 @@ aggregate; it must not touch the hot status path). Reads the rollup only. Return
|
|||
`{ month, days_in_month, days_elapsed, provisioned_screens, billable_screens,
|
||||
billable_screens_final?, is_final, tier, rate_usd, cost_usd, daily:[{day, active_screen_days}] }`.
|
||||
|
||||
**Minting a `billing:read` token — owner only:** billing tokens are minted server-side by
|
||||
the platform owner via `node scripts/mint-billing-token.js --name "<label>"` (printed ONCE;
|
||||
`--list` / `--revoke <id>` to manage). They are **intentionally NOT** in the workspace
|
||||
API-Tokens UI (that surface is workspace-scoped and self-service; a billing token grants
|
||||
platform-wide billing-read). The token authorizes ONLY `GET /api/billing/usage` — off the
|
||||
read/write/full ladder, refused everywhere else.
|
||||
|
||||
**Month-to-date rule:** for the current month the average is computed over **completed
|
||||
calendar days only** — today accrues live and appears in `daily` but is excluded from the
|
||||
running average until it completes, so a partial today doesn't drag the estimate.
|
||||
|
|
|
|||
74
scripts/mint-billing-token.js
Executable file
74
scripts/mint-billing-token.js
Executable file
|
|
@ -0,0 +1,74 @@
|
|||
#!/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());
|
||||
83
server/lib/billing-token.js
Normal file
83
server/lib/billing-token.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
'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 };
|
||||
84
server/test/billing-token-mint.test.js
Normal file
84
server/test/billing-token-mint.test.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
'use strict';
|
||||
|
||||
// #146 — owner-only billing:read token minting (server/lib/billing-token.js + the CLI it
|
||||
// backs). Booted server + in-process mint function. Verifies the minted row's exact shape
|
||||
// (scope + SHA-256 hash, nothing else), that the token reads billing but is refused
|
||||
// elsewhere (scope isolation), and that revocation takes effect.
|
||||
|
||||
const { test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
const crypto = require('node:crypto');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const PORT = 4021;
|
||||
const BASE = `http://127.0.0.1:${PORT}`;
|
||||
const DATA_DIR = path.join(os.tmpdir(), 'st-billmint-' + crypto.randomBytes(4).toString('hex'));
|
||||
process.env.DATA_DIR = DATA_DIR; // so requiring lib/billing-token's deps resolves this db too
|
||||
|
||||
const { mintBillingToken, revokeBillingToken, listBillingTokens } = require('../lib/billing-token');
|
||||
const { hashToken } = require('../middleware/apiToken');
|
||||
|
||||
let proc, db, minted;
|
||||
|
||||
before(async () => {
|
||||
const logFd = fs.openSync(path.join(os.tmpdir(), 'st-billmint.log'), 'w');
|
||||
proc = spawn('node', ['server.js'], {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
|
||||
stdio: ['ignore', logFd, logFd],
|
||||
});
|
||||
let up = false;
|
||||
for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await new Promise(r => setTimeout(r, 250)); }
|
||||
if (!up) throw new Error('server did not boot');
|
||||
db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));
|
||||
|
||||
// Register a user and promote to platform_admin so an OWNER + a workspace exist.
|
||||
const email = 'own' + crypto.randomBytes(4).toString('hex') + '@x.local';
|
||||
await (await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password: 'Passw0rd123' }) })).json();
|
||||
db.prepare("UPDATE users SET role = 'platform_admin' WHERE email = ?").run(email);
|
||||
|
||||
minted = mintBillingToken(db, { name: 'Bold invoicing' }); // the function the CLI wraps
|
||||
});
|
||||
after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
|
||||
|
||||
const S = (r) => r.status;
|
||||
const bearer = (t) => ({ headers: { Authorization: 'Bearer ' + t } });
|
||||
|
||||
test('mint creates an api_tokens row: scope EXACTLY billing:read, correct SHA-256 hash', () => {
|
||||
const row = db.prepare('SELECT * FROM api_tokens WHERE id = ?').get(minted.id);
|
||||
assert.ok(row, 'row exists');
|
||||
assert.equal(row.scope, 'billing:read', 'scope is exactly billing:read');
|
||||
assert.equal(row.token_hash, hashToken(minted.secret), 'stored hash matches the SHA-256 verification path');
|
||||
assert.ok(minted.secret.startsWith('st_'), 'same secret format as existing tokens');
|
||||
// does NOT carry any workspace-level scope
|
||||
for (const s of ['read', 'write', 'full', 'agency']) assert.notEqual(row.scope, s);
|
||||
assert.ok(row.user_id && row.workspace_id, 'bound to owner + a workspace (FK satisfied)');
|
||||
assert.equal(row.revoked_at, null, 'not revoked at mint');
|
||||
// listBillingTokens surfaces it
|
||||
assert.ok(listBillingTokens(db).some((t) => t.id === minted.id));
|
||||
});
|
||||
|
||||
test('minted token reads billing (200) but is REFUSED elsewhere (scope isolation)', async () => {
|
||||
assert.equal(S(await fetch(BASE + '/api/billing/usage', bearer(minted.secret))), 200, 'reads billing');
|
||||
const body = await (await fetch(BASE + '/api/billing/usage', bearer(minted.secret))).json();
|
||||
assert.equal(typeof body.billable_screens, 'number');
|
||||
assert.equal(S(await fetch(BASE + '/api/devices', bearer(minted.secret))), 403, 'refused on a workspace router');
|
||||
assert.equal(S(await fetch(BASE + '/api/admin/orgs', bearer(minted.secret))), 401, 'refused on an admin router');
|
||||
});
|
||||
|
||||
test('revocation: a revoked minted token is refused', async () => {
|
||||
assert.equal(S(await fetch(BASE + '/api/billing/usage', bearer(minted.secret))), 200, 'valid before revoke');
|
||||
const res = revokeBillingToken(db, minted.id);
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(S(await fetch(BASE + '/api/billing/usage', bearer(minted.secret))), 401, 'refused after revoke');
|
||||
});
|
||||
|
||||
test('mint requires a name; revoke refuses a non-billing token id', () => {
|
||||
assert.throws(() => mintBillingToken(db, { name: '' }), /name is required/);
|
||||
// revoke guard: a made-up id / non-billing scope is refused, not silently applied
|
||||
assert.equal(revokeBillingToken(db, 'no-such-id').ok, false);
|
||||
});
|
||||
Loading…
Reference in a new issue