openvibers/openvibe.connect Commits
API keys v1 (CON-16): daily quotas, and what each key did today (#23)
Changes
@@@ -2,6 +2,12 @@What changed in OpenVibe Connect, newest first.++## Unreleased — 2026-11-03++++- API keys, v1 (CON-16): a key may have a daily quota (`per_day`, UTC days, kept across restarts; over it, 429++ `key_daily_quota` until midnight UTC), and the share page shows what each key did today: its requests, the MCP tools++ it called and the models it asked for, by name and count (migration `0009_share_api_key_usage`).++## 0.1.0 — 2026-11-02The first version (Website Connect v0, plan 2026-10-28 §5.9b; ADR-057 as reviewed 2026-11-02, OQ-13): your@@@ -93,7 +93,7 @@ page that names a brand (PayPal, Google, a bank…), or one with an urgent "verilike one becomes an automatic report for staff; Connect never closes a share by itself here, and nothing of the page iskept beyond the report's one line. An ordinary sign-in page for your own app is not reported.--### API keys for programs (CON-16 v0)++### API keys for programs (CON-16)A program can call a share without signing in: on the share's page, make an **API key** (a name and how many requests aminute it may make, 60 by default), and send it as `Authorization: Bearer ovsk_…`. That is how a model on your@@@ -112,8 +112,17 @@ curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/mo`Retry-After: 60`; the share's own limits still apply.- Only the key's hash is kept and it is shown once. At most ten live keys a share; revoking one stops it at once, andclosing the share stops them all. WebSocket upgrades take a key too.--- API: `GET`, `POST /api/v1/shares/:id/api-keys` (`{ name, per_minute }` → `{ api_key, key }`, the key once) and-- `DELETE /api/v1/shares/:id/api-keys/:sak_id`. Cases: `test/api-keys.test.js`.++- **A daily quota (v1).** A key may also have a number of requests a day (`per_day`, optional), counted in UTC days and++ kept in the database, so a restart does not reset it. Over it, a key gets 429 `key_daily_quota` with `Retry-After`++ until midnight UTC.++- **What each key did today (v1).** The share page and the list show each key's requests today (against its quota)++ and what it used: the MCP tools it called (JSON-RPC `tools/call`, by name, a batch counting each) and the models it++ asked for (an OpenAI- or Ollama-style `"model"` on a model path such as `/v1/chat/completions` or `/api/generate`).++ The edge reads the first 64 KB of a key's JSON POST for those names; names and counts are kept, never arguments,++ prompts or bodies.++- API: `GET`, `POST /api/v1/shares/:id/api-keys` (`{ name, per_minute, per_day }` → `{ api_key, key }`, the key once;++ the list adds `today` and `used: [{ what, n }]`) and `DELETE /api/v1/shares/:id/api-keys/:sak_id`. Cases:++ `test/api-keys.test.js`, `test/api-key-quotas.test.js`.## APInnew file mode 100644@@@ -0,0 +1,15 @@++-- phase: expand++-- CON-16 v1: a key's daily quota and what it was used for. The owner may cap a key's requests a day (per_day; NULL is++-- no daily cap beyond the per-minute rate and the share's own limits), counted in UTC days. The usage rows say, per key++-- and day, how many requests it made ('*'), which MCP tools it called ('tool:<name>', from JSON-RPC tools/call) and++-- which models it asked for ('model:<name>', from an OpenAI-style body): names and counts only, never arguments or++-- prompts. They go with the key.++ALTER TABLE share_api_keys ADD COLUMN IF NOT EXISTS per_day integer CHECK (per_day IS NULL OR per_day BETWEEN 1 AND 1000000);++CREATE TABLE IF NOT EXISTS share_api_key_usage (++ key_id text NOT NULL REFERENCES share_api_keys(id) ON DELETE CASCADE,++ day date NOT NULL,++ what text NOT NULL,++ n integer NOT NULL DEFAULT 0,++ last_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (key_id, day, what)++);@@@ -142,6 +142,11 @@.cn-hook-main code { overflow-wrap: anywhere; }.cn-hook-main small { color: var(--ovk-muted); font-size: 13px; overflow-wrap: anywhere; }.cn-hook-main small b { color: var(--ovk-text); }++/* A key's use today (CON-16 v1): its count against the quota, then the tools and models it used, inline. */++.cn-keyuse { display: grid; gap: 2px; }++.cn-hooks .cn-keyuse-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 2px 12px; font-size: 13px; }++.cn-hooks .cn-keyuse-list li { display: inline; padding: 0; border: 0; border-radius: 0; background: none; color: var(--ovk-muted); }++.cn-muted { color: var(--ovk-muted); }.cn-hook-add { display: flex; flex-wrap: wrap; align-items: end; gap: 10px 14px; }.cn-hook-add .cn-field { flex: 1 1 200px; }/* Which computer serves it (CON-4): the owner's machines, one row each with its button; the bound one's commands. */@@@ -11,10 +11,15 @@* - the key never reaches the app (the edge drops it); the app sees X-OpenVibe-Subject `key:<sak_…>`;* - only its hash is kept, it is shown once, at most ten live keys a share, each with its own rate per minute* (60 by default) on top of the share's own limits;-- * - revoking a key stops it at once; closing the share stops them all.++ * - revoking a key stops it at once; closing the share stops them all;++ * - (v1) a key may have a daily quota (per_day, UTC days, kept in the database so a restart does not reset it), and++ * the owner sees what each key did today: its requests, the MCP tools it called (JSON-RPC tools/call) and the++ * models it asked for (an OpenAI-style "model"), as names and counts, never arguments or prompts.*-- * create(owner, share, { name, per_minute }) → { key, token } list(shareId) → keys revoke(owner, share, id)-- * check(share, token) → key | null overRate(key) → true when over its minute++ * create(owner, share, { name, per_minute, per_day }) → { key, token } list(shareId) → keys (with today's use)++ * revoke(owner, share, id) check(share, token) → key | null overRate(key) → true when over its minute++ * overDay(key) → true when today's quota is spent (and counts this request otherwise)++ * audit(key, path, body) what a request's JSON body used: tools/call names, the model asked for*/const crypto = require('crypto');@@@ -22,6 +27,24 @@ const KEY_ID = /^sak_[0-9A-HJKMNP-TV-Z]{26}$/;const TOKEN_RE = /^ovsk_[A-Za-z0-9_-]{43}$/;const PER_SHARE = 10;const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');++const MODEL_PATH = /\/(?:chat\/completions|completions|embeddings|responses|messages|api\/chat|api\/generate|api\/embed(?:dings)?)(?:[/?]|$)/;++const NAME = (v) => String(v).replace(/[^\w.:/@+-]/g, '').slice(0, 80);++++/**++ * What a key's request used, from its JSON body: `tool:<name>` for each JSON-RPC tools/call (an MCP server; a batch++ * counts each), `model:<name>` for an OpenAI- or Ollama-style model request. At most five names; nothing else is read.++ */++function usageOf(path, body) {++ let j;++ try { j = JSON.parse(body); } catch { return []; }++ const out = [];++ for (const m of (Array.isArray(j) ? j : [j]).slice(0, 20)) {++ if (!m || typeof m !== 'object') continue;++ if (m.method === 'tools/call' && m.params && typeof m.params.name === 'string' && NAME(m.params.name)) out.push(`tool:${NAME(m.params.name)}`);++ else if (typeof m.model === 'string' && NAME(m.model) && MODEL_PATH.test(String(path || '').split('?')[0])) out.push(`model:${NAME(m.model)}`);++ }++ return [...new Set(out)].slice(0, 5);++}class ApiKeyError extends Error {constructor(status, code, message) { super(message); this.status = status; this.code = code; }@@@ -32,12 +55,25 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {const iso = (v) => (v ? new Date(v).toISOString() : null);const view = (r) => r && ({id: r.id, share_id: r.share_id, name: r.name, prefix: r.key_prefix, per_minute: Number(r.per_minute),++ per_day: r.per_day == null ? null : Number(r.per_day),created_at: iso(r.created_at), last_used_at: iso(r.last_used_at),});++ const today = () => new Date(now()).toISOString().slice(0, 10);++ const count = (keyId, what, by = 1) => db.exec(`INSERT INTO share_api_key_usage (key_id, day, what, n) VALUES ($1, $2, $3, $4)++ ON CONFLICT (key_id, day, what) DO UPDATE SET n = share_api_key_usage.n + excluded.n, last_at = now()`, [keyId, today(), what, by]).catch(() => {});const owns = (owner, share) => share && owner && share.owner_subject === owner.subject;async function list(shareId) {-- return (await db.many('SELECT * FROM share_api_keys WHERE share_id = $1 AND revoked_at IS NULL ORDER BY created_at', [shareId])).map(view);++ const keys = (await db.many('SELECT * FROM share_api_keys WHERE share_id = $1 AND revoked_at IS NULL ORDER BY created_at', [shareId])).map(view);++ if (!keys.length) return keys;++ const rows = await db.many('SELECT key_id, what, n FROM share_api_key_usage WHERE day = $1 AND key_id = ANY($2::text[]) ORDER BY n DESC, what', [today(), keys.map((k) => k.id)]);++ for (const k of keys) {++ const mine = rows.filter((r) => r.key_id === k.id);++ const total = mine.find((r) => r.what === '*');++ k.today = total ? Number(total.n) : 0;++ k.used = mine.filter((r) => r.what !== '*').slice(0, 8).map((r) => ({ what: r.what, n: Number(r.n) }));++ }++ return keys;}async function create(owner, share, input = {}) {@@@ -47,12 +83,14 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {if (!name) throw new ApiKeyError(422, 'key.name_required', 'Name the key after the program that will use it, like "laptop script".');const perMinute = input.per_minute == null || input.per_minute === '' ? 60 : Number(input.per_minute);if (!Number.isInteger(perMinute) || perMinute < 1 || perMinute > 600) throw new ApiKeyError(422, 'key.invalid_rate', 'A key allows 1 to 600 requests a minute.');++ const perDay = input.per_day == null || input.per_day === '' ? null : Number(input.per_day);++ if (perDay !== null && (!Number.isInteger(perDay) || perDay < 1 || perDay > 1000000)) throw new ApiKeyError(422, 'key.invalid_quota', 'A daily quota is 1 to 1,000,000 requests, or none.');const n = await db.one('SELECT count(*)::int AS n FROM share_api_keys WHERE share_id = $1 AND revoked_at IS NULL', [share.id]);if (n.n >= PER_SHARE) throw new ApiKeyError(409, 'key.limit', `A share can have ${PER_SHARE} API keys. Revoke one first.`);const token = `ovsk_${crypto.randomBytes(32).toString('base64url')}`;const id = s.newId('sak');-- await db.exec('INSERT INTO share_api_keys (id, share_id, owner_subject, name, key_prefix, key_hash, per_minute) VALUES ($1, $2, $3, $4, $5, $6, $7)',-- [id, share.id, owner.subject, name, token.slice(0, 12), sha256(token), perMinute]);++ await db.exec('INSERT INTO share_api_keys (id, share_id, owner_subject, name, key_prefix, key_hash, per_minute, per_day) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)',++ [id, share.id, owner.subject, name, token.slice(0, 12), sha256(token), perMinute, perDay]);return { key: view(await db.one('SELECT * FROM share_api_keys WHERE id = $1', [id])), token };}@@@ -61,6 +99,7 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {const r = await db.maybe('UPDATE share_api_keys SET revoked_at = now() WHERE id = $1 AND share_id = $2 AND revoked_at IS NULL RETURNING id', [id, share.id]);if (!r) throw new ApiKeyError(404, 'key.not_found', 'No such key.');minutes.delete(id);++ days.delete(id);}const touched = new Map(); // key id → when last_used_at was last written (at most once a minute)@@@ -85,7 +124,31 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {return c.n > key.per_minute;}-- return { list, create, revoke, check, overRate, PER_SHARE };++ // Today's requests per key: read from the database the first time a key is seen each day, then counted here and++ // written through, so a restart neither resets a quota nor loses the owner's numbers.++ const days = new Map(); // key id → { day, n }++ async function overDay(key) {++ const d = today();++ let c = days.get(key.id);++ if (!c || c.day !== d) {++ const r = await db.maybe("SELECT n FROM share_api_key_usage WHERE key_id = $1 AND day = $2 AND what = '*'", [key.id, d]).catch(() => null);++ c = { day: d, n: r ? Number(r.n) : 0 };++ days.set(key.id, c);++ }++ if (key.per_day != null && c.n >= key.per_day) return true;++ c.n++;++ count(key.id, '*');++ return false;++ }++++ function audit(key, path, body) {++ for (const what of usageOf(path, body)) count(key.id, what);++ }++++ /** Seconds until midnight UTC, when a spent daily quota opens again. */++ const untilTomorrow = () => { const t = now(); return Math.max(1, Math.ceil((Math.floor(t / 86_400_000 + 1) * 86_400_000 - t) / 1000)); };++++ return { list, create, revoke, check, overRate, overDay, audit, untilTomorrow, PER_SHARE };}--module.exports = { createApiKeys, ApiKeyError, TOKEN_RE, KEY_ID };++module.exports = { createApiKeys, ApiKeyError, usageOf, TOKEN_RE, KEY_ID };@@@ -36,6 +36,8 @@ function privateCache(value) {return ['private', ...parts].join(', ');}++const AUDIT_MAX = 64 * 1024;++function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = console, now = () => Date.now() }) {// The last requests through each share, for its owner's page (CON-8 v0; memory only).const inspector = require('./inspector').createInspector({ now });@@@ -192,8 +194,10 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = consif (bearer && apiKeys) {const key = await apiKeys.check(share, bearer[1]);if (!key) { sendProblem(req, res, 401, 'bad_key', 'That key does not open this share', 'The API key is wrong, revoked, or for another share.', { json: { error: 'bad_key' } }); return null; }-- who = { subject: `key:${key.id}`, username: `key: ${key.name}`, apiKey: key.id };++ who = { subject: `key:${key.id}`, username: `key: ${key.name}`, apiKey: key.id, key };if (apiKeys.overRate(key)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'key_rate_limited', 'Too many requests', `This key allows ${key.per_minute} requests a minute.`, { headers: { 'retry-after': '60' } }); return null; }++ // Its daily quota (CON-16 v1), in UTC days: spent, it opens again at midnight UTC.++ if (await apiKeys.overDay(key)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'key_daily_quota', 'Over today\'s quota', `This key allows ${key.per_day} requests a day. It opens again at midnight UTC.`, { headers: { 'retry-after': String(apiKeys.untilTomorrow()) }, json: { per_day: key.per_day } }); return null; }} else {const { ours } = gate.splitCookies(req.headers.cookie);who = ours ? gate.verify(ours, share) : null;@@@ -339,7 +343,12 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = consheadTimer.unref();tunnel.send({ t: 'req', id, method: req.method, path: url, headers: forwardHeaders(req, share, who, port) });++ // What a key's request used (CON-16 v1): the first 64 KB of a JSON POST are read for an MCP tools/call name or a++ // model name, counted per key and day; the body itself is not kept.++ const audited = who.key && apiKeys && req.method === 'POST' && /json/i.test(String(req.headers['content-type'] || '')) ? [] : null;++ let auditedBytes = 0;req.on('data', (chunk) => {++ if (audited && auditedBytes < AUDIT_MAX) { audited.push(chunk.subarray(0, AUDIT_MAX - auditedBytes)); auditedBytes += Math.min(chunk.length, AUDIT_MAX - auditedBytes); }received += chunk.length;addBytes(share, 'in', chunk.length);rec.in += chunk.length;@@@ -351,7 +360,10 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = constunnel.data(id, chunk);if (tunnel.congested) { req.pause(); tunnel.drained().then(() => req.resume()); }});-- req.on('end', () => { if (!finished) tunnel.end(id); });++ req.on('end', () => {++ if (!finished) tunnel.end(id);++ if (audited && audited.length) { try { apiKeys.audit(who.key, url, Buffer.concat(audited).toString('utf8')); } catch { /* the audit never stops a request */ } }++ });res.on('close', () => { if (!finished && !res.writableFinished) { finished = true; clearTimeout(headTimer); tunnel.abort(id, 'visitor_gone'); } });}@@@ -146,7 +146,7 @@ function createApi(ctx) {const share = await mine(req, res);if (!share) return undefined;const b = req.body || {};-- const made = await apiKeys.create(owner(req), share, { name: b.name, per_minute: b.per_minute });++ const made = await apiKeys.create(owner(req), share, { name: b.name, per_minute: b.per_minute, per_day: b.per_day });res.set('Cache-Control', 'no-store');return res.status(201).json({ api_key: made.key, key: made.token });}));@@@ -171,7 +171,7 @@ function createPageRoutes(ctx) {const share = await mine(req, res);if (!share) return undefined;try {-- const made = await apiKeys.create(owner(req), share, { name: req.body.name, per_minute: req.body.per_minute ? Number(req.body.per_minute) : undefined });++ const made = await apiKeys.create(owner(req), share, { name: req.body.name, per_minute: req.body.per_minute ? Number(req.body.per_minute) : undefined, per_day: req.body.per_day ? Number(req.body.per_day) : null });res.set('Cache-Control', 'no-store');return showShare(req, res, share, { notice: 'API key made. Copy it now: it is not shown again.', newApiKey: made }, 201);} catch (err) {@@@ -278,6 +278,19 @@ ${rows}</form></section>`;}++/**++ * A key's use today (CON-16 v1): its requests against its daily quota, and the MCP tools and models it used, by count.++ */++function keyUseToday(k) {++ if (!k.today) return '';++ const quota = k.per_day ? ` of ${esc(Number(k.per_day).toLocaleString('en-US'))}` : '';++ const used = (k.used || []).map((u) => {++ const [kind, ...rest] = String(u.what).split(':');++ return `<li><span class="cn-muted">${kind === 'tool' ? 'tool' : 'model'}</span> <code>${esc(rest.join(':'))}</code> ×${esc(u.n)}</li>`;++ }).join('');++ return `<div class="cn-keyuse"><small>Today: ${esc(Number(k.today).toLocaleString('en-US'))}${quota} requests</small>${used ? `<ul class="cn-keyuse-list">${used}</ul>` : ''}</div>`;++}++/*** API keys for the share (CON-16 v0): what a program sends to pass the gate. A new key is shown once, with the request* to try it; the list shows each key's start, its rate and when it was last used, and revokes it.@@@ -287,7 +300,8 @@ function apiKeysBlock(share, keys, made, now) {<div class="cn-copy"><code id="cn-apikey">${esc(made.token)}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-apikey" hidden>${kit.icon('copy')} Copy</button></div><p class="cn-hint">Try it: <code>curl -H "Authorization: Bearer ${esc(made.token)}" https://${esc(share.host)}/</code></p></div>` : '';const rows = keys.length ? `<ul class="cn-hooks">${keys.map((k) => `<li><div class="cn-hook-main"><b>${esc(k.name)}</b> <code>${esc(k.prefix)}…</code>--<small>${esc(k.per_minute)} a minute · made ${esc(ago(k.created_at, now))} · ${k.last_used_at ? `last used ${esc(ago(k.last_used_at, now))}` : 'not used yet'}</small></div>++<small>${esc(k.per_minute)} a minute${k.per_day ? ` · ${esc(Number(k.per_day).toLocaleString('en-US'))} a day` : ''} · made ${esc(ago(k.created_at, now))} · ${k.last_used_at ? `last used ${esc(ago(k.last_used_at, now))}` : 'not used yet'}</small>++${keyUseToday(k)}</div><form method="post" action="/shares/${esc(share.id)}/api-keys/${esc(k.id)}/revoke"><button class="cn-btn cn-btn-quiet" type="submit">${kit.icon('close')} Revoke</button></form></li>`).join('')}</ul>`: '<p class="cn-empty">No API keys yet.</p>';return `<section class="cn-hooks-sec" aria-labelledby="cn-keys-h"><h2 id="cn-keys-h">API keys for programs</h2>@@@ -296,6 +310,7 @@ ${fresh}${rows}<form class="cn-form cn-hook-add" method="post" action="/shares/${esc(share.id)}/api-keys"><label class="cn-field"><span>Name</span><input name="name" maxlength="60" required placeholder="laptop script"></label><label class="cn-field"><span>Requests a minute</span><input name="per_minute" type="number" min="1" max="600" value="60" inputmode="numeric"></label>++<label class="cn-field"><span>Requests a day <small>(optional)</small></span><input name="per_day" type="number" min="1" max="1000000" inputmode="numeric" placeholder="no daily cap"></label><button class="cn-btn" type="submit">${kit.icon('plus')} Make an API key</button></form></section>`;}nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++/**++ * API keys, v1 (CON-16): a key may have a daily quota, counted in UTC days and kept in the database (a restart does not++ * reset it); over it, 429 key_daily_quota until midnight UTC. The owner sees what each key did today: its requests, the++ * MCP tools it called (JSON-RPC tools/call, by name) and the models it asked for (an OpenAI-style "model"), names and++ * counts only.++ * node test/api-key-quotas.test.js++ */++const assert = require('assert');++const { boot, startLocalApp } = require('./helpers/boot');++const { check, done } = require('./helpers/check');++const { createApiKeys, usageOf } = require('../server/api-keys');++++const until = async (fn, ms = 2000) => {++ const end = Date.now() + ms;++ for (;;) {++ const v = await fn();++ if (v || Date.now() > end) return v;++ await new Promise((r) => setTimeout(r, 25));++ }++};++++(async () => {++ await check('what a body used: tools/call names (a batch counts each), a model on a model path, nothing else', () => {++ assert.deepStrictEqual(usageOf('/mcp', JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'search_files', arguments: { q: 'secret plans' } } })), ['tool:search_files']);++ assert.deepStrictEqual(usageOf('/mcp', JSON.stringify([{ method: 'tools/call', params: { name: 'a' } }, { method: 'tools/call', params: { name: 'b' } }, { method: 'tools/list' }])), ['tool:a', 'tool:b']);++ assert.deepStrictEqual(usageOf('/v1/chat/completions', '{"model":"llama3.2:3b","messages":[{"role":"user","content":"hi"}]}'), ['model:llama3.2:3b']);++ assert.deepStrictEqual(usageOf('/api/generate?x=1', '{"model":"qwen2.5"}'), ['model:qwen2.5'], 'Ollama\'s own API');++ assert.deepStrictEqual(usageOf('/orders', '{"model":"x"}'), [], 'a "model" field elsewhere is not a model request');++ assert.deepStrictEqual(usageOf('/mcp', '{"method":"initialize"}'), []);++ assert.deepStrictEqual(usageOf('/mcp', 'not json'), []);++ assert.deepStrictEqual(usageOf('/mcp', JSON.stringify({ method: 'tools/call', params: { name: '<b>evil</b> name;rm' } })), ['tool:bevil/bnamerm'], 'names are cleaned');++ });++++ const t = await boot();++ const app = await startLocalApp();++ const alex = t.network.addUser('alex');++ const made = await t.makeShare(alex, { label: 'mcp', port: app.port });++ const c = t.client({ key: made.key, port: app.port });++ await c.ready();++ const call = (token, path, { method = 'GET', body } = {}) => t.share(made.share.host, path, { method, body, headers: { accept: 'application/json', authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) } });++ let key, token;++ const usage = async (id) => Object.fromEntries((await t.ctx.s.db.many('SELECT what, n FROM share_api_key_usage WHERE key_id = $1', [id])).map((r) => [r.what, Number(r.n)]));++++ await check('a key may have a daily quota; a bad one changes nothing', async () => {++ const bad = await t.get(`/api/v1/shares/${made.share.id}/api-keys`, { as: alex, json: { name: 'x', per_day: 0 } });++ assert.strictEqual(bad.status, 422);++ assert.strictEqual(bad.json().code, 'key.invalid_quota', bad.text);++ const r = await t.get(`/api/v1/shares/${made.share.id}/api-keys`, { as: alex, json: { name: 'claude desktop', per_minute: 60, per_day: 3 } });++ assert.strictEqual(r.status, 201, r.text);++ key = r.json().api_key;++ token = r.json().key;++ assert.strictEqual(key.per_day, 3);++ });++++ await check('three requests pass; the fourth is 429 key_daily_quota until midnight UTC, and is not counted', async () => {++ assert.strictEqual((await call(token, '/v1/models')).status, 200);++ assert.strictEqual((await call(token, '/v1/chat/completions', { method: 'POST', body: '{"model":"llama3.2:3b","messages":[]}' })).status, 200);++ assert.strictEqual((await call(token, '/mcp', { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'search_files', arguments: { q: 'x' } } }) })).status, 200);++ const over = await call(token, '/v1/models');++ assert.strictEqual(over.status, 429, over.text);++ assert.strictEqual(over.json().error, 'key_daily_quota');++ assert.strictEqual(over.json().per_day, 3);++ const wait = Number(over.headers['retry-after']);++ assert.ok(wait >= 1 && wait <= 86400, `retry-after ${over.headers['retry-after']}`);++ const u = await until(async () => { const x = await usage(key.id); return x['tool:search_files'] && x; });++ assert.deepStrictEqual(u, { '*': 3, 'model:llama3.2:3b': 1, 'tool:search_files': 1 });++ assert.ok(!JSON.stringify(await t.ctx.s.db.many('SELECT * FROM share_api_key_usage')).includes('"q"'), 'no arguments kept');++ });++++ await check('a restart does not reset the quota: a fresh process reads today\'s count', async () => {++ const fresh = createApiKeys(t.ctx.s, { now: t.ctx.s.now });++ assert.strictEqual(await fresh.overDay(key), true);++ const unlimited = { ...key, id: 'sak_01M6FWGEVYZYQTP5ZPZ75CWB7P', per_day: null };++ assert.strictEqual(await fresh.overDay(unlimited), false, 'a key without a quota is never over its day');++ });++++ await check('the list and the share page show today\'s use: requests against the quota, tools and models', async () => {++ const list = await t.get(`/api/v1/shares/${made.share.id}/api-keys`, { as: alex });++ const k = list.json().api_keys.find((x) => x.id === key.id);++ assert.strictEqual(k.today, 3);++ assert.deepStrictEqual(k.used.map((u) => u.what).sort(), ['model:llama3.2:3b', 'tool:search_files']);++ const page = await t.get(`/shares/${made.share.id}`, { as: alex });++ assert.match(page.text, /3 a day/);++ assert.match(page.text, /Today: 3 of 3 requests/);++ assert.match(page.text, /<span class="cn-muted">tool<\/span> <code>search_files<\/code> ×1/);++ assert.match(page.text, /<span class="cn-muted">model<\/span> <code>llama3\.2:3b<\/code> ×1/);++ assert.match(page.text, /name="per_day" type="number" min="1" max="1000000"/);++ const form = await t.get(`/shares/${made.share.id}/api-keys`, { as: alex, form: { name: 'nightly job', per_minute: '10', per_day: '500' } });++ assert.strictEqual(form.status, 201);++ assert.match(form.text, /10 a minute · 500 a day/);++ });++++ c.stop();++ app.close();++ await done(t);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -2,6 +2,12 @@
@@ -2,6 +2,12 @@
What changed in OpenVibe Connect, newest first.
What changed in OpenVibe Connect, newest first.
+## Unreleased — 2026-11-03
+
+- API keys, v1 (CON-16): a key may have a daily quota (`per_day`, UTC days, kept across restarts; over it, 429
+ `key_daily_quota` until midnight UTC), and the share page shows what each key did today: its requests, the MCP tools
+ it called and the models it asked for, by name and count (migration `0009_share_api_key_usage`).
+
## 0.1.0 — 2026-11-02
## 0.1.0 — 2026-11-02
The first version (Website Connect v0, plan 2026-10-28 §5.9b; ADR-057 as reviewed 2026-11-02, OQ-13): your
The first version (Website Connect v0, plan 2026-10-28 §5.9b; ADR-057 as reviewed 2026-11-02, OQ-13): your
@@ -93,7 +93,7 @@ page that names a brand (PayPal, Google, a bank…), or one with an urgent "veri
@@ -93,7 +93,7 @@ page that names a brand (PayPal, Google, a bank…), or one with an urgent "veri
like one becomes an automatic report for staff; Connect never closes a share by itself here, and nothing of the page is
like one becomes an automatic report for staff; Connect never closes a share by itself here, and nothing of the page is
kept beyond the report's one line. An ordinary sign-in page for your own app is not reported.
kept beyond the report's one line. An ordinary sign-in page for your own app is not reported.
-### API keys for programs (CON-16 v0)
+### API keys for programs (CON-16)
A program can call a share without signing in: on the share's page, make an **API key** (a name and how many requests a
A program can call a share without signing in: on the share's page, make an **API key** (a name and how many requests a
minute it may make, 60 by default), and send it as `Authorization: Bearer ovsk_…`. That is how a model on your
minute it may make, 60 by default), and send it as `Authorization: Bearer ovsk_…`. That is how a model on your
@@ -112,8 +112,17 @@ curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/mo
@@ -112,8 +112,17 @@ curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/mo
`Retry-After: 60`; the share's own limits still apply.
`Retry-After: 60`; the share's own limits still apply.
- Only the key's hash is kept and it is shown once. At most ten live keys a share; revoking one stops it at once, and
- Only the key's hash is kept and it is shown once. At most ten live keys a share; revoking one stops it at once, and
closing the share stops them all. WebSocket upgrades take a key too.
closing the share stops them all. WebSocket upgrades take a key too.
-- API: `GET`, `POST /api/v1/shares/:id/api-keys` (`{ name, per_minute }` → `{ api_key, key }`, the key once) and
- `DELETE /api/v1/shares/:id/api-keys/:sak_id`. Cases: `test/api-keys.test.js`.
+- **A daily quota (v1).** A key may also have a number of requests a day (`per_day`, optional), counted in UTC days and
+ kept in the database, so a restart does not reset it. Over it, a key gets 429 `key_daily_quota` with `Retry-After`
+ until midnight UTC.
+- **What each key did today (v1).** The share page and the list show each key's requests today (against its quota)
+ and what it used: the MCP tools it called (JSON-RPC `tools/call`, by name, a batch counting each) and the models it
+ asked for (an OpenAI- or Ollama-style `"model"` on a model path such as `/v1/chat/completions` or `/api/generate`).
+ The edge reads the first 64 KB of a key's JSON POST for those names; names and counts are kept, never arguments,
+ prompts or bodies.
+- API: `GET`, `POST /api/v1/shares/:id/api-keys` (`{ name, per_minute, per_day }` → `{ api_key, key }`, the key once;
+ the list adds `today` and `used: [{ what, n }]`) and `DELETE /api/v1/shares/:id/api-keys/:sak_id`. Cases:
+ `test/api-keys.test.js`, `test/api-key-quotas.test.js`.
## API
## API
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+-- phase: expand
+-- CON-16 v1: a key's daily quota and what it was used for. The owner may cap a key's requests a day (per_day; NULL is
+-- no daily cap beyond the per-minute rate and the share's own limits), counted in UTC days. The usage rows say, per key
+-- and day, how many requests it made ('*'), which MCP tools it called ('tool:<name>', from JSON-RPC tools/call) and
+-- which models it asked for ('model:<name>', from an OpenAI-style body): names and counts only, never arguments or
+-- prompts. They go with the key.
+ALTER TABLE share_api_keys ADD COLUMN IF NOT EXISTS per_day integer CHECK (per_day IS NULL OR per_day BETWEEN 1 AND 1000000);
+CREATE TABLE IF NOT EXISTS share_api_key_usage (
+ key_id text NOT NULL REFERENCES share_api_keys(id) ON DELETE CASCADE,
+ day date NOT NULL,
+ what text NOT NULL,
+ n integer NOT NULL DEFAULT 0,
+ last_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (key_id, day, what)
+);
@@ -142,6 +142,11 @@
@@ -142,6 +142,11 @@
.cn-hook-main code { overflow-wrap: anywhere; }
.cn-hook-main code { overflow-wrap: anywhere; }
.cn-hook-main small { color: var(--ovk-muted); font-size: 13px; overflow-wrap: anywhere; }
.cn-hook-main small { color: var(--ovk-muted); font-size: 13px; overflow-wrap: anywhere; }
.cn-hook-main small b { color: var(--ovk-text); }
.cn-hook-main small b { color: var(--ovk-text); }
+/* A key's use today (CON-16 v1): its count against the quota, then the tools and models it used, inline. */
+.cn-keyuse { display: grid; gap: 2px; }
+.cn-hooks .cn-keyuse-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 2px 12px; font-size: 13px; }
+.cn-hooks .cn-keyuse-list li { display: inline; padding: 0; border: 0; border-radius: 0; background: none; color: var(--ovk-muted); }
+.cn-muted { color: var(--ovk-muted); }
.cn-hook-add { display: flex; flex-wrap: wrap; align-items: end; gap: 10px 14px; }
.cn-hook-add { display: flex; flex-wrap: wrap; align-items: end; gap: 10px 14px; }
.cn-hook-add .cn-field { flex: 1 1 200px; }
.cn-hook-add .cn-field { flex: 1 1 200px; }
/* Which computer serves it (CON-4): the owner's machines, one row each with its button; the bound one's commands. */
/* Which computer serves it (CON-4): the owner's machines, one row each with its button; the bound one's commands. */
@@ -11,10 +11,15 @@
@@ -11,10 +11,15 @@
* - the key never reaches the app (the edge drops it); the app sees X-OpenVibe-Subject `key:<sak_…>`;
* - the key never reaches the app (the edge drops it); the app sees X-OpenVibe-Subject `key:<sak_…>`;
* - only its hash is kept, it is shown once, at most ten live keys a share, each with its own rate per minute
* - only its hash is kept, it is shown once, at most ten live keys a share, each with its own rate per minute
* (60 by default) on top of the share's own limits;
* (60 by default) on top of the share's own limits;
- * - revoking a key stops it at once; closing the share stops them all.
+ * - revoking a key stops it at once; closing the share stops them all;
+ * - (v1) a key may have a daily quota (per_day, UTC days, kept in the database so a restart does not reset it), and
+ * the owner sees what each key did today: its requests, the MCP tools it called (JSON-RPC tools/call) and the
+ * models it asked for (an OpenAI-style "model"), as names and counts, never arguments or prompts.
*
*
- * create(owner, share, { name, per_minute }) → { key, token } list(shareId) → keys revoke(owner, share, id)
- * check(share, token) → key | null overRate(key) → true when over its minute
+ * create(owner, share, { name, per_minute, per_day }) → { key, token } list(shareId) → keys (with today's use)
+ * revoke(owner, share, id) check(share, token) → key | null overRate(key) → true when over its minute
+ * overDay(key) → true when today's quota is spent (and counts this request otherwise)
+ * audit(key, path, body) what a request's JSON body used: tools/call names, the model asked for
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
@@ -22,6 +27,24 @@ const KEY_ID = /^sak_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -22,6 +27,24 @@ const KEY_ID = /^sak_[0-9A-HJKMNP-TV-Z]{26}$/;
const TOKEN_RE = /^ovsk_[A-Za-z0-9_-]{43}$/;
const TOKEN_RE = /^ovsk_[A-Za-z0-9_-]{43}$/;
const PER_SHARE = 10;
const PER_SHARE = 10;
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
+const MODEL_PATH = /\/(?:chat\/completions|completions|embeddings|responses|messages|api\/chat|api\/generate|api\/embed(?:dings)?)(?:[/?]|$)/;
+const NAME = (v) => String(v).replace(/[^\w.:/@+-]/g, '').slice(0, 80);
+
+/**
+ * What a key's request used, from its JSON body: `tool:<name>` for each JSON-RPC tools/call (an MCP server; a batch
+ * counts each), `model:<name>` for an OpenAI- or Ollama-style model request. At most five names; nothing else is read.
+ */
+function usageOf(path, body) {
+ let j;
+ try { j = JSON.parse(body); } catch { return []; }
+ const out = [];
+ for (const m of (Array.isArray(j) ? j : [j]).slice(0, 20)) {
+ if (!m || typeof m !== 'object') continue;
+ if (m.method === 'tools/call' && m.params && typeof m.params.name === 'string' && NAME(m.params.name)) out.push(`tool:${NAME(m.params.name)}`);
+ else if (typeof m.model === 'string' && NAME(m.model) && MODEL_PATH.test(String(path || '').split('?')[0])) out.push(`model:${NAME(m.model)}`);
+ }
+ return [...new Set(out)].slice(0, 5);
+}
class ApiKeyError extends Error {
class ApiKeyError extends Error {
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
constructor(status, code, message) { super(message); this.status = status; this.code = code; }
@@ -32,12 +55,25 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {
@@ -32,12 +55,25 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {
const iso = (v) => (v ? new Date(v).toISOString() : null);
const iso = (v) => (v ? new Date(v).toISOString() : null);
const view = (r) => r && ({
const view = (r) => r && ({
id: r.id, share_id: r.share_id, name: r.name, prefix: r.key_prefix, per_minute: Number(r.per_minute),
id: r.id, share_id: r.share_id, name: r.name, prefix: r.key_prefix, per_minute: Number(r.per_minute),
+ per_day: r.per_day == null ? null : Number(r.per_day),
created_at: iso(r.created_at), last_used_at: iso(r.last_used_at),
created_at: iso(r.created_at), last_used_at: iso(r.last_used_at),
});
});
+ const today = () => new Date(now()).toISOString().slice(0, 10);
+ const count = (keyId, what, by = 1) => db.exec(`INSERT INTO share_api_key_usage (key_id, day, what, n) VALUES ($1, $2, $3, $4)
+ ON CONFLICT (key_id, day, what) DO UPDATE SET n = share_api_key_usage.n + excluded.n, last_at = now()`, [keyId, today(), what, by]).catch(() => {});
const owns = (owner, share) => share && owner && share.owner_subject === owner.subject;
const owns = (owner, share) => share && owner && share.owner_subject === owner.subject;
async function list(shareId) {
async function list(shareId) {
- return (await db.many('SELECT * FROM share_api_keys WHERE share_id = $1 AND revoked_at IS NULL ORDER BY created_at', [shareId])).map(view);
+ const keys = (await db.many('SELECT * FROM share_api_keys WHERE share_id = $1 AND revoked_at IS NULL ORDER BY created_at', [shareId])).map(view);
+ if (!keys.length) return keys;
+ const rows = await db.many('SELECT key_id, what, n FROM share_api_key_usage WHERE day = $1 AND key_id = ANY($2::text[]) ORDER BY n DESC, what', [today(), keys.map((k) => k.id)]);
+ for (const k of keys) {
+ const mine = rows.filter((r) => r.key_id === k.id);
+ const total = mine.find((r) => r.what === '*');
+ k.today = total ? Number(total.n) : 0;
+ k.used = mine.filter((r) => r.what !== '*').slice(0, 8).map((r) => ({ what: r.what, n: Number(r.n) }));
+ }
+ return keys;
}
}
async function create(owner, share, input = {}) {
async function create(owner, share, input = {}) {
@@ -47,12 +83,14 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {
@@ -47,12 +83,14 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {
if (!name) throw new ApiKeyError(422, 'key.name_required', 'Name the key after the program that will use it, like "laptop script".');
if (!name) throw new ApiKeyError(422, 'key.name_required', 'Name the key after the program that will use it, like "laptop script".');
const perMinute = input.per_minute == null || input.per_minute === '' ? 60 : Number(input.per_minute);
const perMinute = input.per_minute == null || input.per_minute === '' ? 60 : Number(input.per_minute);
if (!Number.isInteger(perMinute) || perMinute < 1 || perMinute > 600) throw new ApiKeyError(422, 'key.invalid_rate', 'A key allows 1 to 600 requests a minute.');
if (!Number.isInteger(perMinute) || perMinute < 1 || perMinute > 600) throw new ApiKeyError(422, 'key.invalid_rate', 'A key allows 1 to 600 requests a minute.');
+ const perDay = input.per_day == null || input.per_day === '' ? null : Number(input.per_day);
+ if (perDay !== null && (!Number.isInteger(perDay) || perDay < 1 || perDay > 1000000)) throw new ApiKeyError(422, 'key.invalid_quota', 'A daily quota is 1 to 1,000,000 requests, or none.');
const n = await db.one('SELECT count(*)::int AS n FROM share_api_keys WHERE share_id = $1 AND revoked_at IS NULL', [share.id]);
const n = await db.one('SELECT count(*)::int AS n FROM share_api_keys WHERE share_id = $1 AND revoked_at IS NULL', [share.id]);
if (n.n >= PER_SHARE) throw new ApiKeyError(409, 'key.limit', `A share can have ${PER_SHARE} API keys. Revoke one first.`);
if (n.n >= PER_SHARE) throw new ApiKeyError(409, 'key.limit', `A share can have ${PER_SHARE} API keys. Revoke one first.`);
const token = `ovsk_${crypto.randomBytes(32).toString('base64url')}`;
const token = `ovsk_${crypto.randomBytes(32).toString('base64url')}`;
const id = s.newId('sak');
const id = s.newId('sak');
- await db.exec('INSERT INTO share_api_keys (id, share_id, owner_subject, name, key_prefix, key_hash, per_minute) VALUES ($1, $2, $3, $4, $5, $6, $7)',
- [id, share.id, owner.subject, name, token.slice(0, 12), sha256(token), perMinute]);
+ await db.exec('INSERT INTO share_api_keys (id, share_id, owner_subject, name, key_prefix, key_hash, per_minute, per_day) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)',
+ [id, share.id, owner.subject, name, token.slice(0, 12), sha256(token), perMinute, perDay]);
return { key: view(await db.one('SELECT * FROM share_api_keys WHERE id = $1', [id])), token };
return { key: view(await db.one('SELECT * FROM share_api_keys WHERE id = $1', [id])), token };
}
}
@@ -61,6 +99,7 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {
@@ -61,6 +99,7 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {
const r = await db.maybe('UPDATE share_api_keys SET revoked_at = now() WHERE id = $1 AND share_id = $2 AND revoked_at IS NULL RETURNING id', [id, share.id]);
const r = await db.maybe('UPDATE share_api_keys SET revoked_at = now() WHERE id = $1 AND share_id = $2 AND revoked_at IS NULL RETURNING id', [id, share.id]);
if (!r) throw new ApiKeyError(404, 'key.not_found', 'No such key.');
if (!r) throw new ApiKeyError(404, 'key.not_found', 'No such key.');
minutes.delete(id);
minutes.delete(id);
+ days.delete(id);
}
}
const touched = new Map(); // key id → when last_used_at was last written (at most once a minute)
const touched = new Map(); // key id → when last_used_at was last written (at most once a minute)
@@ -85,7 +124,31 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {
@@ -85,7 +124,31 @@ function createApiKeys(s, { now = () => Date.now() } = {}) {
return c.n > key.per_minute;
return c.n > key.per_minute;
}
}
- return { list, create, revoke, check, overRate, PER_SHARE };
+ // Today's requests per key: read from the database the first time a key is seen each day, then counted here and
+ // written through, so a restart neither resets a quota nor loses the owner's numbers.
+ const days = new Map(); // key id → { day, n }
+ async function overDay(key) {
+ const d = today();
+ let c = days.get(key.id);
+ if (!c || c.day !== d) {
+ const r = await db.maybe("SELECT n FROM share_api_key_usage WHERE key_id = $1 AND day = $2 AND what = '*'", [key.id, d]).catch(() => null);
+ c = { day: d, n: r ? Number(r.n) : 0 };
+ days.set(key.id, c);
+ }
+ if (key.per_day != null && c.n >= key.per_day) return true;
+ c.n++;
+ count(key.id, '*');
+ return false;
+ }
+
+ function audit(key, path, body) {
+ for (const what of usageOf(path, body)) count(key.id, what);
+ }
+
+ /** Seconds until midnight UTC, when a spent daily quota opens again. */
+ const untilTomorrow = () => { const t = now(); return Math.max(1, Math.ceil((Math.floor(t / 86_400_000 + 1) * 86_400_000 - t) / 1000)); };
+
+ return { list, create, revoke, check, overRate, overDay, audit, untilTomorrow, PER_SHARE };
}
}
-module.exports = { createApiKeys, ApiKeyError, TOKEN_RE, KEY_ID };
+module.exports = { createApiKeys, ApiKeyError, usageOf, TOKEN_RE, KEY_ID };
@@ -36,6 +36,8 @@ function privateCache(value) {
@@ -36,6 +36,8 @@ function privateCache(value) {
return ['private', ...parts].join(', ');
return ['private', ...parts].join(', ');
}
}
+const AUDIT_MAX = 64 * 1024;
+
function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = console, now = () => Date.now() }) {
function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = console, now = () => Date.now() }) {
// The last requests through each share, for its owner's page (CON-8 v0; memory only).
// The last requests through each share, for its owner's page (CON-8 v0; memory only).
const inspector = require('./inspector').createInspector({ now });
const inspector = require('./inspector').createInspector({ now });
@@ -192,8 +194,10 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
@@ -192,8 +194,10 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
if (bearer && apiKeys) {
if (bearer && apiKeys) {
const key = await apiKeys.check(share, bearer[1]);
const key = await apiKeys.check(share, bearer[1]);
if (!key) { sendProblem(req, res, 401, 'bad_key', 'That key does not open this share', 'The API key is wrong, revoked, or for another share.', { json: { error: 'bad_key' } }); return null; }
if (!key) { sendProblem(req, res, 401, 'bad_key', 'That key does not open this share', 'The API key is wrong, revoked, or for another share.', { json: { error: 'bad_key' } }); return null; }
- who = { subject: `key:${key.id}`, username: `key: ${key.name}`, apiKey: key.id };
+ who = { subject: `key:${key.id}`, username: `key: ${key.name}`, apiKey: key.id, key };
if (apiKeys.overRate(key)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'key_rate_limited', 'Too many requests', `This key allows ${key.per_minute} requests a minute.`, { headers: { 'retry-after': '60' } }); return null; }
if (apiKeys.overRate(key)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'key_rate_limited', 'Too many requests', `This key allows ${key.per_minute} requests a minute.`, { headers: { 'retry-after': '60' } }); return null; }
+ // Its daily quota (CON-16 v1), in UTC days: spent, it opens again at midnight UTC.
+ if (await apiKeys.overDay(key)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'key_daily_quota', 'Over today\'s quota', `This key allows ${key.per_day} requests a day. It opens again at midnight UTC.`, { headers: { 'retry-after': String(apiKeys.untilTomorrow()) }, json: { per_day: key.per_day } }); return null; }
} else {
} else {
const { ours } = gate.splitCookies(req.headers.cookie);
const { ours } = gate.splitCookies(req.headers.cookie);
who = ours ? gate.verify(ours, share) : null;
who = ours ? gate.verify(ours, share) : null;
@@ -339,7 +343,12 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
@@ -339,7 +343,12 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
headTimer.unref();
headTimer.unref();
tunnel.send({ t: 'req', id, method: req.method, path: url, headers: forwardHeaders(req, share, who, port) });
tunnel.send({ t: 'req', id, method: req.method, path: url, headers: forwardHeaders(req, share, who, port) });
+ // What a key's request used (CON-16 v1): the first 64 KB of a JSON POST are read for an MCP tools/call name or a
+ // model name, counted per key and day; the body itself is not kept.
+ const audited = who.key && apiKeys && req.method === 'POST' && /json/i.test(String(req.headers['content-type'] || '')) ? [] : null;
+ let auditedBytes = 0;
req.on('data', (chunk) => {
req.on('data', (chunk) => {
+ if (audited && auditedBytes < AUDIT_MAX) { audited.push(chunk.subarray(0, AUDIT_MAX - auditedBytes)); auditedBytes += Math.min(chunk.length, AUDIT_MAX - auditedBytes); }
received += chunk.length;
received += chunk.length;
addBytes(share, 'in', chunk.length);
addBytes(share, 'in', chunk.length);
rec.in += chunk.length;
rec.in += chunk.length;
@@ -351,7 +360,10 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
@@ -351,7 +360,10 @@ function createProxy({ config, shares, tunnels, gate, apiKeys = null, log = cons
tunnel.data(id, chunk);
tunnel.data(id, chunk);
if (tunnel.congested) { req.pause(); tunnel.drained().then(() => req.resume()); }
if (tunnel.congested) { req.pause(); tunnel.drained().then(() => req.resume()); }
});
});
- req.on('end', () => { if (!finished) tunnel.end(id); });
+ req.on('end', () => {
+ if (!finished) tunnel.end(id);
+ if (audited && audited.length) { try { apiKeys.audit(who.key, url, Buffer.concat(audited).toString('utf8')); } catch { /* the audit never stops a request */ } }
+ });
res.on('close', () => { if (!finished && !res.writableFinished) { finished = true; clearTimeout(headTimer); tunnel.abort(id, 'visitor_gone'); } });
res.on('close', () => { if (!finished && !res.writableFinished) { finished = true; clearTimeout(headTimer); tunnel.abort(id, 'visitor_gone'); } });
}
}
@@ -146,7 +146,7 @@ function createApi(ctx) {
@@ -146,7 +146,7 @@ function createApi(ctx) {
const share = await mine(req, res);
const share = await mine(req, res);
if (!share) return undefined;
if (!share) return undefined;
const b = req.body || {};
const b = req.body || {};
- const made = await apiKeys.create(owner(req), share, { name: b.name, per_minute: b.per_minute });
+ const made = await apiKeys.create(owner(req), share, { name: b.name, per_minute: b.per_minute, per_day: b.per_day });
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
return res.status(201).json({ api_key: made.key, key: made.token });
return res.status(201).json({ api_key: made.key, key: made.token });
}));
}));
@@ -171,7 +171,7 @@ function createPageRoutes(ctx) {
@@ -171,7 +171,7 @@ function createPageRoutes(ctx) {
const share = await mine(req, res);
const share = await mine(req, res);
if (!share) return undefined;
if (!share) return undefined;
try {
try {
- const made = await apiKeys.create(owner(req), share, { name: req.body.name, per_minute: req.body.per_minute ? Number(req.body.per_minute) : undefined });
+ const made = await apiKeys.create(owner(req), share, { name: req.body.name, per_minute: req.body.per_minute ? Number(req.body.per_minute) : undefined, per_day: req.body.per_day ? Number(req.body.per_day) : null });
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
return showShare(req, res, share, { notice: 'API key made. Copy it now: it is not shown again.', newApiKey: made }, 201);
return showShare(req, res, share, { notice: 'API key made. Copy it now: it is not shown again.', newApiKey: made }, 201);
} catch (err) {
} catch (err) {
@@ -278,6 +278,19 @@ ${rows}
@@ -278,6 +278,19 @@ ${rows}
</form></section>`;
</form></section>`;
}
}
+/**
+ * A key's use today (CON-16 v1): its requests against its daily quota, and the MCP tools and models it used, by count.
+ */
+function keyUseToday(k) {
+ if (!k.today) return '';
+ const quota = k.per_day ? ` of ${esc(Number(k.per_day).toLocaleString('en-US'))}` : '';
+ const used = (k.used || []).map((u) => {
+ const [kind, ...rest] = String(u.what).split(':');
+ return `<li><span class="cn-muted">${kind === 'tool' ? 'tool' : 'model'}</span> <code>${esc(rest.join(':'))}</code> ×${esc(u.n)}</li>`;
+ }).join('');
+ return `<div class="cn-keyuse"><small>Today: ${esc(Number(k.today).toLocaleString('en-US'))}${quota} requests</small>${used ? `<ul class="cn-keyuse-list">${used}</ul>` : ''}</div>`;
+}
+
/**
/**
* API keys for the share (CON-16 v0): what a program sends to pass the gate. A new key is shown once, with the request
* API keys for the share (CON-16 v0): what a program sends to pass the gate. A new key is shown once, with the request
* to try it; the list shows each key's start, its rate and when it was last used, and revokes it.
* to try it; the list shows each key's start, its rate and when it was last used, and revokes it.
@@ -287,7 +300,8 @@ function apiKeysBlock(share, keys, made, now) {
@@ -287,7 +300,8 @@ function apiKeysBlock(share, keys, made, now) {
<div class="cn-copy"><code id="cn-apikey">${esc(made.token)}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-apikey" hidden>${kit.icon('copy')} Copy</button></div>
<div class="cn-copy"><code id="cn-apikey">${esc(made.token)}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-apikey" hidden>${kit.icon('copy')} Copy</button></div>
<p class="cn-hint">Try it: <code>curl -H "Authorization: Bearer ${esc(made.token)}" https://${esc(share.host)}/</code></p></div>` : '';
<p class="cn-hint">Try it: <code>curl -H "Authorization: Bearer ${esc(made.token)}" https://${esc(share.host)}/</code></p></div>` : '';
const rows = keys.length ? `<ul class="cn-hooks">${keys.map((k) => `<li><div class="cn-hook-main"><b>${esc(k.name)}</b> <code>${esc(k.prefix)}…</code>
const rows = keys.length ? `<ul class="cn-hooks">${keys.map((k) => `<li><div class="cn-hook-main"><b>${esc(k.name)}</b> <code>${esc(k.prefix)}…</code>
-<small>${esc(k.per_minute)} a minute · made ${esc(ago(k.created_at, now))} · ${k.last_used_at ? `last used ${esc(ago(k.last_used_at, now))}` : 'not used yet'}</small></div>
+<small>${esc(k.per_minute)} a minute${k.per_day ? ` · ${esc(Number(k.per_day).toLocaleString('en-US'))} a day` : ''} · made ${esc(ago(k.created_at, now))} · ${k.last_used_at ? `last used ${esc(ago(k.last_used_at, now))}` : 'not used yet'}</small>
+${keyUseToday(k)}</div>
<form method="post" action="/shares/${esc(share.id)}/api-keys/${esc(k.id)}/revoke"><button class="cn-btn cn-btn-quiet" type="submit">${kit.icon('close')} Revoke</button></form></li>`).join('')}</ul>`
<form method="post" action="/shares/${esc(share.id)}/api-keys/${esc(k.id)}/revoke"><button class="cn-btn cn-btn-quiet" type="submit">${kit.icon('close')} Revoke</button></form></li>`).join('')}</ul>`
: '<p class="cn-empty">No API keys yet.</p>';
: '<p class="cn-empty">No API keys yet.</p>';
return `<section class="cn-hooks-sec" aria-labelledby="cn-keys-h"><h2 id="cn-keys-h">API keys for programs</h2>
return `<section class="cn-hooks-sec" aria-labelledby="cn-keys-h"><h2 id="cn-keys-h">API keys for programs</h2>
@@ -296,6 +310,7 @@ ${fresh}${rows}
@@ -296,6 +310,7 @@ ${fresh}${rows}
<form class="cn-form cn-hook-add" method="post" action="/shares/${esc(share.id)}/api-keys">
<form class="cn-form cn-hook-add" method="post" action="/shares/${esc(share.id)}/api-keys">
<label class="cn-field"><span>Name</span><input name="name" maxlength="60" required placeholder="laptop script"></label>
<label class="cn-field"><span>Name</span><input name="name" maxlength="60" required placeholder="laptop script"></label>
<label class="cn-field"><span>Requests a minute</span><input name="per_minute" type="number" min="1" max="600" value="60" inputmode="numeric"></label>
<label class="cn-field"><span>Requests a minute</span><input name="per_minute" type="number" min="1" max="600" value="60" inputmode="numeric"></label>
+<label class="cn-field"><span>Requests a day <small>(optional)</small></span><input name="per_day" type="number" min="1" max="1000000" inputmode="numeric" placeholder="no daily cap"></label>
<button class="cn-btn" type="submit">${kit.icon('plus')} Make an API key</button>
<button class="cn-btn" type="submit">${kit.icon('plus')} Make an API key</button>
</form></section>`;
</form></section>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+/**
+ * API keys, v1 (CON-16): a key may have a daily quota, counted in UTC days and kept in the database (a restart does not
+ * reset it); over it, 429 key_daily_quota until midnight UTC. The owner sees what each key did today: its requests, the
+ * MCP tools it called (JSON-RPC tools/call, by name) and the models it asked for (an OpenAI-style "model"), names and
+ * counts only.
+ * node test/api-key-quotas.test.js
+ */
+const assert = require('assert');
+const { boot, startLocalApp } = require('./helpers/boot');
+const { check, done } = require('./helpers/check');
+const { createApiKeys, usageOf } = require('../server/api-keys');
+
+const until = async (fn, ms = 2000) => {
+ const end = Date.now() + ms;
+ for (;;) {
+ const v = await fn();
+ if (v || Date.now() > end) return v;
+ await new Promise((r) => setTimeout(r, 25));
+ }
+};
+
+(async () => {
+ await check('what a body used: tools/call names (a batch counts each), a model on a model path, nothing else', () => {
+ assert.deepStrictEqual(usageOf('/mcp', JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'search_files', arguments: { q: 'secret plans' } } })), ['tool:search_files']);
+ assert.deepStrictEqual(usageOf('/mcp', JSON.stringify([{ method: 'tools/call', params: { name: 'a' } }, { method: 'tools/call', params: { name: 'b' } }, { method: 'tools/list' }])), ['tool:a', 'tool:b']);
+ assert.deepStrictEqual(usageOf('/v1/chat/completions', '{"model":"llama3.2:3b","messages":[{"role":"user","content":"hi"}]}'), ['model:llama3.2:3b']);
+ assert.deepStrictEqual(usageOf('/api/generate?x=1', '{"model":"qwen2.5"}'), ['model:qwen2.5'], 'Ollama\'s own API');
+ assert.deepStrictEqual(usageOf('/orders', '{"model":"x"}'), [], 'a "model" field elsewhere is not a model request');
+ assert.deepStrictEqual(usageOf('/mcp', '{"method":"initialize"}'), []);
+ assert.deepStrictEqual(usageOf('/mcp', 'not json'), []);
+ assert.deepStrictEqual(usageOf('/mcp', JSON.stringify({ method: 'tools/call', params: { name: '<b>evil</b> name;rm' } })), ['tool:bevil/bnamerm'], 'names are cleaned');
+ });
+
+ const t = await boot();
+ const app = await startLocalApp();
+ const alex = t.network.addUser('alex');
+ const made = await t.makeShare(alex, { label: 'mcp', port: app.port });
+ const c = t.client({ key: made.key, port: app.port });
+ await c.ready();
+ const call = (token, path, { method = 'GET', body } = {}) => t.share(made.share.host, path, { method, body, headers: { accept: 'application/json', authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) } });
+ let key, token;
+ const usage = async (id) => Object.fromEntries((await t.ctx.s.db.many('SELECT what, n FROM share_api_key_usage WHERE key_id = $1', [id])).map((r) => [r.what, Number(r.n)]));
+
+ await check('a key may have a daily quota; a bad one changes nothing', async () => {
+ const bad = await t.get(`/api/v1/shares/${made.share.id}/api-keys`, { as: alex, json: { name: 'x', per_day: 0 } });
+ assert.strictEqual(bad.status, 422);
+ assert.strictEqual(bad.json().code, 'key.invalid_quota', bad.text);
+ const r = await t.get(`/api/v1/shares/${made.share.id}/api-keys`, { as: alex, json: { name: 'claude desktop', per_minute: 60, per_day: 3 } });
+ assert.strictEqual(r.status, 201, r.text);
+ key = r.json().api_key;
+ token = r.json().key;
+ assert.strictEqual(key.per_day, 3);
+ });
+
+ await check('three requests pass; the fourth is 429 key_daily_quota until midnight UTC, and is not counted', async () => {
+ assert.strictEqual((await call(token, '/v1/models')).status, 200);
+ assert.strictEqual((await call(token, '/v1/chat/completions', { method: 'POST', body: '{"model":"llama3.2:3b","messages":[]}' })).status, 200);
+ assert.strictEqual((await call(token, '/mcp', { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'search_files', arguments: { q: 'x' } } }) })).status, 200);
+ const over = await call(token, '/v1/models');
+ assert.strictEqual(over.status, 429, over.text);
+ assert.strictEqual(over.json().error, 'key_daily_quota');
+ assert.strictEqual(over.json().per_day, 3);
+ const wait = Number(over.headers['retry-after']);
+ assert.ok(wait >= 1 && wait <= 86400, `retry-after ${over.headers['retry-after']}`);
+ const u = await until(async () => { const x = await usage(key.id); return x['tool:search_files'] && x; });
+ assert.deepStrictEqual(u, { '*': 3, 'model:llama3.2:3b': 1, 'tool:search_files': 1 });
+ assert.ok(!JSON.stringify(await t.ctx.s.db.many('SELECT * FROM share_api_key_usage')).includes('"q"'), 'no arguments kept');
+ });
+
+ await check('a restart does not reset the quota: a fresh process reads today\'s count', async () => {
+ const fresh = createApiKeys(t.ctx.s, { now: t.ctx.s.now });
+ assert.strictEqual(await fresh.overDay(key), true);
+ const unlimited = { ...key, id: 'sak_01M6FWGEVYZYQTP5ZPZ75CWB7P', per_day: null };
+ assert.strictEqual(await fresh.overDay(unlimited), false, 'a key without a quota is never over its day');
+ });
+
+ await check('the list and the share page show today\'s use: requests against the quota, tools and models', async () => {
+ const list = await t.get(`/api/v1/shares/${made.share.id}/api-keys`, { as: alex });
+ const k = list.json().api_keys.find((x) => x.id === key.id);
+ assert.strictEqual(k.today, 3);
+ assert.deepStrictEqual(k.used.map((u) => u.what).sort(), ['model:llama3.2:3b', 'tool:search_files']);
+ const page = await t.get(`/shares/${made.share.id}`, { as: alex });
+ assert.match(page.text, /3 a day/);
+ assert.match(page.text, /Today: 3 of 3 requests/);
+ assert.match(page.text, /<span class="cn-muted">tool<\/span> <code>search_files<\/code> ×1/);
+ assert.match(page.text, /<span class="cn-muted">model<\/span> <code>llama3\.2:3b<\/code> ×1/);
+ assert.match(page.text, /name="per_day" type="number" min="1" max="1000000"/);
+ const form = await t.get(`/shares/${made.share.id}/api-keys`, { as: alex, form: { name: 'nightly job', per_minute: '10', per_day: '500' } });
+ assert.strictEqual(form.status, 201);
+ assert.match(form.text, /10 a minute · 500 a day/);
+ });
+
+ c.stop();
+ app.close();
+ await done(t);
+})().catch((e) => { console.error(e); process.exit(1); });