openvibers/openvibe.connect Commits

API keys v1 (CON-16): daily quotas, and what each key did today (#23)

OP OpenVibers committed 10 days ago · aeaf746deb · 10 files, +238 −16

A key may have a daily quota (per_day, UTC days, counted in the database so a restart
does not reset it); over it the edge answers 429 key_daily_quota until midnight UTC.
The edge reads the first 64 KB of a key's JSON POST for an MCP tools/call name or a
model name and counts them per key and day (names and counts only, never arguments
or prompts). The share page and the list show each key's requests today against its
quota and the tools and models it used.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 10be45b..417fa89 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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-02
The first version (Website Connect v0, plan 2026-10-28 §5.9b; ADR-057 as reviewed 2026-11-02, OQ-13): your
ddiff --git a/README.md b/README.md
iindex 2cdba38..28a72db 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
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
`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
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
ddiff --git a/migrations/0009_share_api_key_usage.sql b/migrations/0009_share_api_key_usage.sql
nnew file mode 100644
iindex 0000000..62c08cf
---- /dev/null
++++ b/migrations/0009_share_api_key_usage.sql
@@@ -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)
++);
ddiff --git a/public/css/connect.css b/public/css/connect.css
iindex efcb209..16cec53 100644
---- a/public/css/connect.css
++++ b/public/css/connect.css
@@@ -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. */
ddiff --git a/server/api-keys.js b/server/api-keys.js
iindex 7ded9c4..0fa69e7 100644
---- a/server/api-keys.js
++++ b/server/api-keys.js
@@@ -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 };
ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex c0419a7..33bdb86 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -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 = cons
if (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 = cons
headTimer.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 = cons
tunnel.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'); } });
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex a36ac16..bbab26a 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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 });
}));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 43f53e8..8857870 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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) {
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 6d76b96..1968e86 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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>`;
}
ddiff --git a/test/api-key-quotas.test.js b/test/api-key-quotas.test.js
nnew file mode 100644
iindex 0000000..2d237b6
---- /dev/null
++++ b/test/api-key-quotas.test.js
@@@ -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); });