sync: 2026-03-15 23:00 — 32 files from Alexandria
Some checks failed
Lint & Format / detect (push) Failing after 32s
Monorepo Lint / lint-shell (push) Failing after 31s
Monorepo Lint / lint-js (push) Failing after 30s
Lint & Format / js-lint (push) Has been skipped
Lint & Format / py-lint (push) Has been skipped
Lint & Format / sh-lint (push) Has been skipped
Lint & Format / go-lint (push) Has been skipped

RoadChain-SHA2048: 692327ce2e990f37
RoadChain-Identity: alexa@sovereign
RoadChain-Full: 692327ce2e990f37649b83e948241ac858c0d07146c6b42043e4770d638c44d5bada5639ad82c7aa8911d7042912c1d75b6bbce9a453637621b3903dc912a3a9537696cedf7a0870e3bf962ca44677793082aaae5c5433615885ad20fab1e80417202d11e93284483551ba9558f06809d2f3fa53c00a657277d7c183abe3ba187c1af6856a455071771757cca67ff2b74c5f855f23dd8cc8f5b3596c966b2344361fcbb74843e9d9d9ad66c5321ef64ce787f9d255d11e0d4e0ee571af4e09697964e22f6f629a11279b315c9a4563860b169ad93fa500b485297516ef2ba2039f76348c0d547cfa182e9b0bccee73f5e8b7db7e33d61e8199bb4464c2c30d03
This commit is contained in:
2026-03-15 23:00:06 -05:00
parent eb1d7952f7
commit ac7b9b5958
32 changed files with 1093 additions and 229 deletions

View File

@@ -5,7 +5,7 @@
// Own your customers, subscriptions, invoices, and payments.
// Stripe is just a dumb card charger underneath.
const VERSION = '1.0.0';
const VERSION = '2.0.0';
// ─── Schema ──────────────────────────────────────────────────────────────
const SCHEMA = [
@@ -114,6 +114,37 @@ const SCHEMA = [
`CREATE INDEX IF NOT EXISTS idx_payments_customer ON payments(customer_id)`,
`CREATE INDEX IF NOT EXISTS idx_events_type ON events(type)`,
`CREATE INDEX IF NOT EXISTS idx_events_customer ON events(customer_id)`,
// v2: API keys for customers
`CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL REFERENCES customers(id),
key_hash TEXT NOT NULL,
key_prefix TEXT NOT NULL,
name TEXT DEFAULT 'default',
scopes TEXT DEFAULT '["api:read"]',
rate_limit INTEGER DEFAULT 1000,
last_used TEXT,
usage_count INTEGER DEFAULT 0,
status TEXT DEFAULT 'active',
expires_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
)`,
// v2: Usage metering
`CREATE TABLE IF NOT EXISTS usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL REFERENCES customers(id),
api_key_id TEXT REFERENCES api_keys(id),
endpoint TEXT NOT NULL,
method TEXT DEFAULT 'GET',
status_code INTEGER,
latency_ms INTEGER,
tokens_used INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_api_keys_customer ON api_keys(customer_id)`,
`CREATE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix)`,
`CREATE INDEX IF NOT EXISTS idx_usage_customer ON usage(customer_id)`,
`CREATE INDEX IF NOT EXISTS idx_usage_date ON usage(created_at)`,
];
// ─── Seed Plans ──────────────────────────────────────────────────────────
@@ -965,10 +996,129 @@ function handleHealth() {
'GET /invoices?customer_id=',
'GET /payments?customer_id=',
'POST /webhook',
'GET /keys?customer_id=',
'POST /keys',
'DELETE /keys?id=',
'GET /usage?customer_id=&days=30',
'POST /usage/record',
],
});
}
// ─── API Keys ───────────────────────────────────────────────────────────
async function handleApiKeys(request, db) {
const url = new URL(request.url);
if (request.method === 'GET') {
const customerId = url.searchParams.get('customer_id');
if (!customerId) return err('customer_id required');
const keys = await db.prepare(
"SELECT id, customer_id, key_prefix, name, scopes, rate_limit, last_used, usage_count, status, expires_at, created_at FROM api_keys WHERE customer_id = ? ORDER BY created_at DESC"
).bind(customerId).all();
return json({ keys: keys.results.map(k => ({ ...k, scopes: JSON.parse(k.scopes || '[]') })) });
}
if (request.method === 'POST') {
const body = await request.json();
const { customer_id, name, scopes, rate_limit, expires_in_days } = body;
if (!customer_id) return err('customer_id required');
// Generate API key: rp_live_ + 32 random hex chars
const rawKey = `rp_live_${randHex(16)}`;
const keyPrefix = rawKey.slice(0, 12);
// Hash the key for storage (don't store raw)
const enc = new TextEncoder();
const hashBuf = await crypto.subtle.digest('SHA-256', enc.encode(rawKey));
const keyHash = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
const id = `key_${uid()}`;
const expiresAt = expires_in_days
? new Date(Date.now() + expires_in_days * 86400000).toISOString()
: null;
await db.prepare(
'INSERT INTO api_keys (id, customer_id, key_hash, key_prefix, name, scopes, rate_limit, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).bind(id, customer_id, keyHash, keyPrefix, name || 'default', JSON.stringify(scopes || ['api:read']), rate_limit || 1000, expiresAt).run();
// Return the raw key ONLY on creation — can never be retrieved again
return json({ id, key: rawKey, prefix: keyPrefix, name: name || 'default', warning: 'Save this key now. It cannot be retrieved again.' }, 201);
}
if (request.method === 'DELETE') {
const keyId = url.searchParams.get('id');
if (!keyId) return err('id required');
await db.prepare("UPDATE api_keys SET status = 'revoked' WHERE id = ?").bind(keyId).run();
return json({ ok: true, revoked: keyId });
}
return err('Method not allowed', 405);
}
// ─── Usage / Metering ────────────────────────────────────────────────────
async function handleUsage(request, db) {
const url = new URL(request.url);
const customerId = url.searchParams.get('customer_id');
if (!customerId) return err('customer_id required');
const days = parseInt(url.searchParams.get('days') || '30');
const since = new Date(Date.now() - days * 86400000).toISOString();
// Daily breakdown
const daily = await db.prepare(
`SELECT date(created_at) as day, COUNT(*) as requests, SUM(tokens_used) as tokens,
AVG(latency_ms) as avg_latency, SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) as errors
FROM usage WHERE customer_id = ? AND created_at >= ? GROUP BY day ORDER BY day DESC`
).bind(customerId, since).all();
// Totals
const totals = await db.prepare(
`SELECT COUNT(*) as total_requests, SUM(tokens_used) as total_tokens,
AVG(latency_ms) as avg_latency, COUNT(DISTINCT endpoint) as unique_endpoints
FROM usage WHERE customer_id = ? AND created_at >= ?`
).bind(customerId, since).first();
// Top endpoints
const endpoints = await db.prepare(
`SELECT endpoint, method, COUNT(*) as count, AVG(latency_ms) as avg_latency
FROM usage WHERE customer_id = ? AND created_at >= ?
GROUP BY endpoint, method ORDER BY count DESC LIMIT 10`
).bind(customerId, since).all();
return json({
customer_id: customerId,
period: { days, since },
totals,
daily: daily.results,
top_endpoints: endpoints.results,
});
}
// ─── Record Usage (internal — called by API gateway) ─────────────────────
async function handleRecordUsage(request, db) {
const body = await request.json();
const { customer_id, api_key_id, endpoint, method, status_code, latency_ms, tokens_used } = body;
if (!customer_id || !endpoint) return err('customer_id and endpoint required');
await db.prepare(
'INSERT INTO usage (customer_id, api_key_id, endpoint, method, status_code, latency_ms, tokens_used) VALUES (?, ?, ?, ?, ?, ?, ?)'
).bind(customer_id, api_key_id || null, endpoint, method || 'GET', status_code || 200, latency_ms || 0, tokens_used || 0).run();
// Update API key usage count
if (api_key_id) {
await db.prepare(
"UPDATE api_keys SET usage_count = usage_count + 1, last_used = datetime('now') WHERE id = ?"
).bind(api_key_id).run();
}
return json({ ok: true });
}
function randHex(bytes) {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
return Array.from(arr).map(b => b.toString(16).padStart(2, '0')).join('');
}
// ─── Rate limiting ───────────────────────────────────────────────────────
const rl = new Map();
function rateLimit(ip, max = 30, windowSec = 60) {
@@ -1040,6 +1190,15 @@ export default {
case path === '/stats':
response = await handleStats(db);
break;
case path === '/keys' || path === '/api-keys':
response = await handleApiKeys(request, db);
break;
case path === '/usage':
response = await handleUsage(request, db);
break;
case path === '/usage/record' && request.method === 'POST':
response = await handleRecordUsage(request, db);
break;
default:
response = err('Not found', 404);
}