openvibers/openvibe.connect Commits

API keys for a share: programs call it without signing in, never its pages (CON-16 v0) (#22)

OP OpenVibers committed 10 days ago · 58c4997a92 · 11 files, +359 −12

A share answered only to people signed in to OpenVibe, so a model or an API on someone's computer could not be called
from a script or another app. The owner now makes API keys on the share's page; a request with one as a Bearer token
passes the gate. A key opens the share's API, never its pages (an HTML answer to a key is refused at the edge, so a
key posted in public cannot make a public website past the abuse gate); the app never sees the key; a wrong one is
401; each key has its own rate. Migration 0008 share_api_keys keeps only hashes; account export and deletion cover it.

Changes

ddiff --git a/README.md b/README.md
iindex 1f3cfe1..73f75a0 100644
---- a/README.md
++++ b/README.md
@@@ -93,6 +93,28 @@ 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)
++
++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
++computer (Ollama, llama.cpp's server, anything OpenAI-compatible) or an API of yours is called from a script, an editor or
++another app:
++
++```
++curl -H "Authorization: Bearer ovsk_…" https://llm--you.openvibe.website/v1/models
++```
++
++- **A key opens the share's API, never its pages.** An HTML answer to a key is refused at the edge (406), so a key
++ posted in public cannot turn a share into a public website past the sign-in gate. A browser still signs in.
++- **Your app never sees the key.** The edge drops it; your app sees `X-OpenVibe-Subject: key:<sak_…>` and
++ `X-OpenVibe-User: key: <name>`.
++- **A wrong, revoked or other share's key is 401**, never a redirect to sign in. Over its rate a key gets 429 with
++ `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`.
++
## API
With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
ddiff --git a/migrations/0008_share_api_keys.sql b/migrations/0008_share_api_keys.sql
nnew file mode 100644
iindex 0000000..c7e3f57
---- /dev/null
++++ b/migrations/0008_share_api_keys.sql
@@@ -0,0 +1,19 @@
++-- phase: expand
++-- API keys for a share (CON-16 v0). A share's owner makes a key and gives it to a program: a request that sends it as
++-- `Authorization: Bearer ovsk_…` passes the gate without signing in, so a model or an API on the owner's computer
++-- (Ollama, llama.cpp, a small service) can be called from a script or another app. A key opens the share's API, never
++-- its pages (an HTML answer to a key is refused at the edge, server/edge/proxy.js), so a key posted in public cannot
++-- turn a share into a public website. Only the key's hash is kept; it is shown once. Each key has its own rate.
++CREATE TABLE IF NOT EXISTS share_api_keys (
++ id text PRIMARY KEY, -- sak_<ulid>
++ share_id text NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
++ owner_subject text NOT NULL, -- usr_… (the share's owner)
++ name text NOT NULL,
++ key_prefix text NOT NULL, -- the first 12 characters, to tell keys apart
++ key_hash text NOT NULL UNIQUE, -- sha256 of the key
++ per_minute integer NOT NULL DEFAULT 60 CHECK (per_minute BETWEEN 1 AND 600),
++ created_at timestamptz NOT NULL DEFAULT now(),
++ last_used_at timestamptz,
++ revoked_at timestamptz
++);
++CREATE INDEX IF NOT EXISTS share_api_keys_share ON share_api_keys (share_id) WHERE revoked_at IS NULL;
ddiff --git a/server/api-keys.js b/server/api-keys.js
nnew file mode 100644
iindex 0000000..7ded9c4
---- /dev/null
++++ b/server/api-keys.js
@@@ -0,0 +1,91 @@
++'use strict';
++
++/**
++ * API keys for a share (CON-16 v0). The owner of a share makes a key, names it and gives it to a program. A request
++ * that sends it as `Authorization: Bearer ovsk_…` passes the gate without signing in (server/edge/proxy.js), so a model
++ * or an API on the owner's computer (Ollama, llama.cpp, a small service of theirs) can be called from a script, an
++ * editor or another app. What keeps it safe:
++ *
++ * - a key opens the share's API, never its pages: an HTML answer to a key is refused at the edge, so a key posted in
++ * public cannot turn a share into a public website past the sign-in gate (the abuse gate, CON-9);
++ * - 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.
++ *
++ * 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
++ */
++const crypto = require('crypto');
++
++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');
++
++class ApiKeyError extends Error {
++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
++}
++
++function createApiKeys(s, { now = () => Date.now() } = {}) {
++ const { db } = s;
++ 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),
++ created_at: iso(r.created_at), last_used_at: iso(r.last_used_at),
++ });
++ 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);
++ }
++
++ async function create(owner, share, input = {}) {
++ if (!owns(owner, share)) throw new ApiKeyError(404, 'share.not_found', 'No such share.');
++ if (share.status !== 'active') throw new ApiKeyError(410, 'share.revoked', 'That share is closed.');
++ const name = String(input.name || '').replace(/\s+/g, ' ').trim().slice(0, 60);
++ 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 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]);
++ return { key: view(await db.one('SELECT * FROM share_api_keys WHERE id = $1', [id])), token };
++ }
++
++ async function revoke(owner, share, id) {
++ if (!owns(owner, share) || !KEY_ID.test(String(id || ''))) throw new ApiKeyError(404, 'key.not_found', 'No such key.');
++ 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);
++ }
++
++ const touched = new Map(); // key id → when last_used_at was last written (at most once a minute)
++ async function check(share, token) {
++ if (!share || share.status !== 'active' || !TOKEN_RE.test(String(token || ''))) return null;
++ const r = await db.maybe('SELECT * FROM share_api_keys WHERE key_hash = $1 AND share_id = $2 AND revoked_at IS NULL', [sha256(token), share.id]);
++ if (!r) return null;
++ const t = now();
++ if (!touched.has(r.id) || t - touched.get(r.id) > 60_000) {
++ touched.set(r.id, t);
++ db.exec('UPDATE share_api_keys SET last_used_at = now() WHERE id = $1', [r.id]).catch(() => {});
++ }
++ return view(r);
++ }
++
++ const minutes = new Map(); // key id → { m, n }
++ function overRate(key) {
++ const m = Math.floor(now() / 60_000);
++ const c = minutes.get(key.id);
++ if (!c || c.m !== m) { minutes.set(key.id, { m, n: 1 }); return false; }
++ c.n++;
++ return c.n > key.per_minute;
++ }
++
++ return { list, create, revoke, check, overRate, PER_SHARE };
++}
++
++module.exports = { createApiKeys, ApiKeyError, TOKEN_RE, KEY_ID };
ddiff --git a/server/app.js b/server/app.js
iindex 13acd96..c64394c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -32,6 +32,7 @@ const { createNodeApi } = require('./http/node-api');
const { createNodeAuth } = require('./auth/node-auth');
const { createMachines } = require('./machines');
const { createHooks } = require('./hooks');
++const { createApiKeys } = require('./api-keys');
const { createProbe } = require('./probe');
const { createHookRoutes } = require('./http/hooks');
const { createServiceOutbox } = require('openvibe-sdk/events');
@@@ -83,7 +84,9 @@ async function createApp(opts = {}) {
if (waiting.length) log.log(`[OpenVibe.Connect] told ${waiting.length} person(s) ${share.host} is back`);
} });
const gate = createGate({ config, now: s.now });
-- const proxy = createProxy({ config, shares, tunnels, gate, log, now: s.now });
++ // API keys for a share (CON-16 v0): a program passes the gate with one; it opens the share's API, never its pages.
++ const apiKeys = createApiKeys(s, { now: s.now });
++ const proxy = createProxy({ config, shares, tunnels, gate, apiKeys, log, now: s.now });
const accountData = accountDataLib.create({ db: s.db, log });
const accountSend = opts.accountSend || (config.oauth.clientSecret
@@@ -100,7 +103,7 @@ async function createApp(opts = {}) {
});
const hooks = createHooks(s, { config, outbox, now: s.now });
-- const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, notifier, nodeAuth, machines, hooks, outbox, accountData, accountSend, log };
++ const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, apiKeys, notifier, nodeAuth, machines, hooks, outbox, accountData, accountSend, log };
ctx.probe = createProbe({ shares, proxy, config, log, now: s.now });
ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex ccf284a..c0419a7 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -36,7 +36,7 @@ function privateCache(value) {
return ['private', ...parts].join(', ');
}
--function createProxy({ config, shares, tunnels, gate, 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).
const inspector = require('./inspector').createInspector({ now });
const domain = config.shares.domain;
@@@ -104,6 +104,8 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
for (let i = 0; i + 1 < pairs.length; i += 2) {
const name = String(pairs[i]).toLowerCase();
if (DROP_IN.test(name)) continue;
++ // An API key (CON-16) is for the edge alone: the app never sees it, only X-OpenVibe-Subject key:<id>.
++ if (who.apiKey && name === 'authorization') continue;
if (HOP.has(name) && !(raw && (name === 'connection' || name === 'upgrade'))) continue;
out.push([name, String(pairs[i + 1])]);
}
@@@ -167,6 +169,12 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
});
res.end(page(title, text, link, { refresh, note, report, notify }));
}
++ /** Does a response's head say it is a web page (HTML or XHTML)? `headers` is [[name, value]] or an object. */
++ function isHtml(headers) {
++ const pairs = Array.isArray(headers) ? headers : Object.entries(headers || {});
++ const ct = pairs.find(([k]) => String(k).toLowerCase() === 'content-type');
++ return Boolean(ct) && /^\s*(text\/html|application\/xhtml\+xml)\b/i.test(String(ct[1]));
++ }
function wantsHtml(req) { return /text\/html/.test(String(req.headers.accept || '')) && (req.method === 'GET' || req.method === 'HEAD'); }
function sendProblem(req, res, status, code, title, text, extra = {}) {
if (wantsHtml(req)) return sendPage(res, status, title, text, extra);
@@@ -178,8 +186,18 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
/** The share, the person and the tunnel for a request, or an answer already sent. */
async function admit(req, res, share, parsedPath) {
-- const { ours } = gate.splitCookies(req.headers.cookie);
-- const who = ours ? gate.verify(ours, share) : null;
++ // An API key (CON-16) passes without signing in; a wrong or revoked one is refused, never sent to sign in.
++ const bearer = /^Bearer\s+(ovsk_\S+)$/i.exec(String(req.headers.authorization || ''));
++ let who = null;
++ 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 };
++ 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; }
++ } else {
++ const { ours } = gate.splitCookies(req.headers.cookie);
++ who = ours ? gate.verify(ours, share) : null;
++ }
if (!who) {
const url = grantUrl(share, parsedPath);
if (wantsHtml(req)) { res.writeHead(302, { location: url, 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store' }); res.end(); return null; }
@@@ -264,9 +282,17 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
let finished = false;
let headTimer = null;
let received = 0;
++ let blocked = false;
const id = tunnel.open({
onRes(m) {
clearTimeout(headTimer);
++ // A key opens the share's API, never its pages: an HTML answer to a key is refused here, so a key posted
++ // in public cannot make the share a public website past the sign-in gate (CON-16, CON-9).
++ if (who.apiKey && isHtml(m.headers)) {
++ blocked = true;
++ tunnel.abort(id, 'html_to_key');
++ return sendProblem(req, res, 406, 'api_only', 'Keys open this share\'s API', 'An API key opens what this share answers to programs, not its web pages. Open the page in a browser and sign in with OpenVibe.', { json: { error: 'api_only' } });
++ }
const status = Number(m.status);
if (!Number.isInteger(status) || status < 100 || status > 599 || status === 101) {
tunnel.abort(id, 'bad_status');
@@@ -277,6 +303,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
return undefined;
},
onData(buf) {
++ if (blocked) return;
addBytes(share, 'out', buf.length);
rec.out += buf.length;
if (!res.write(buf)) {
@@@ -284,9 +311,10 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
res.once('drain', () => tunnel.send({ t: 'resume', id }));
}
},
-- onEnd() { finished = true; tunnel.release(id); clearTimeout(headTimer); res.end(); },
++ onEnd() { finished = true; tunnel.release(id); clearTimeout(headTimer); if (!blocked) res.end(); },
onAbort(m) {
finished = true;
++ if (blocked) return;
clearTimeout(headTimer);
if (m && m.code) rec.error = String(m.code).slice(0, 40);
if (res.headersSent) { res.destroy(); return; }
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 3586684..a36ac16 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -28,9 +28,10 @@ const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { ShareError } = require('../shares');
const { HookError } = require('../hooks');
++const { ApiKeyError } = require('../api-keys');
function createApi(ctx) {
-- const { shares, tunnels, principal, proxy, machines, hooks } = ctx;
++ const { shares, tunnels, principal, proxy, machines, hooks, apiKeys } = ctx;
const r = asyncRouter();
r.use(express.json({ limit: '16kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
@@@ -42,7 +43,7 @@ function createApi(ctx) {
const shape = (s) => ({ ...s, connected: tunnels.isConnected(s.id) });
const guard = (fn) => async (req, res) => {
try { return await fn(req, res); } catch (err) {
-- if (err instanceof ShareError || err instanceof HookError) return fail(res, req, err.status, err.code, err.message);
++ if (err instanceof ShareError || err instanceof HookError || err instanceof ApiKeyError) return fail(res, req, err.status, err.code, err.message);
throw err;
}
};
@@@ -136,6 +137,26 @@ function createApi(ctx) {
return res.json({ hook: await hooks.remove(owner(req), hook.id) });
}));
++ // API keys (CON-16 v0): a program passes the gate with one. The key is in the answer that makes it, never again.
++ r.get('/shares/:id/api-keys', guard(async (req, res) => {
++ const share = await mine(req, res);
++ return share ? res.json({ api_keys: await apiKeys.list(share.id) }) : undefined;
++ }));
++ r.post('/shares/:id/api-keys', guard(async (req, res) => {
++ 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 });
++ res.set('Cache-Control', 'no-store');
++ return res.status(201).json({ api_key: made.key, key: made.token });
++ }));
++ r.delete('/shares/:id/api-keys/:key', guard(async (req, res) => {
++ const share = await mine(req, res);
++ if (!share) return undefined;
++ await apiKeys.revoke(owner(req), share, req.params.key);
++ return res.status(204).end();
++ }));
++
r.get('/shares/:id/requests', guard(async (req, res) => {
const share = await mine(req, res);
return share ? res.json({ requests: proxy.inspector.list(share.id, { after: req.query.after }), keep: proxy.inspector.KEEP }) : undefined;
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 8b5877a..43f53e8 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -24,9 +24,10 @@ const { send } = require('../render/layout');
const views = require('../views');
const { ShareError } = require('../shares');
const { HookError } = require('../hooks');
++const { ApiKeyError } = require('../api-keys');
function createPageRoutes(ctx) {
-- const { config, shares, tunnels, gate, proxy, machines, hooks } = ctx;
++ const { config, shares, tunnels, gate, proxy, machines, hooks, apiKeys } = ctx;
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '16kb' }));
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
@@@ -85,12 +86,13 @@ function createPageRoutes(ctx) {
async function showShare(req, res, share, extra = {}, status = 200) {
const tunnel = tunnels.get(share.id);
-- const [visits, mine, hookList] = await Promise.all([
++ const [visits, mine, hookList, keyList] = await Promise.all([
shares.visits(share.id),
share.status === 'active' ? machines.list(req.viewer.token) : Promise.resolve({ ok: true, machines: [] }),
share.status === 'active' ? hooks.list(share.id) : Promise.resolve([]),
++ share.status === 'active' && apiKeys ? apiKeys.list(share.id) : Promise.resolve([]),
]);
-- return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, requests: ctx.proxy ? ctx.proxy.inspector.list(share.id) : [], baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', machines: mine.ok ? mine.machines : null, hooks: hookList, ...extra }) }, status);
++ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, requests: ctx.proxy ? ctx.proxy.inspector.list(share.id) : [], baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', machines: mine.ok ? mine.machines : null, hooks: hookList, apiKeys: keyList, ...extra }) }, status);
}
r.get('/shares/:id', async (req, res) => {
@@@ -164,6 +166,33 @@ function createPageRoutes(ctx) {
}
});
++ r.post('/shares/:id/api-keys', async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ 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 });
++ 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) {
++ if (!(err instanceof ApiKeyError)) throw err;
++ return showShare(req, res, share, { error: err.message }, err.status || 422);
++ }
++ });
++
++ r.post('/shares/:id/api-keys/:key/revoke', async (req, res) => {
++ if (!guard(req, res)) return undefined;
++ const share = await mine(req, res);
++ if (!share) return undefined;
++ try {
++ await apiKeys.revoke(owner(req), share, req.params.key);
++ } catch (err) {
++ if (!(err instanceof ApiKeyError)) throw err;
++ return showShare(req, res, share, { error: err.message }, err.status || 404);
++ }
++ return showShare(req, res, share, { notice: 'Revoked. That key opens nothing from now on.' });
++ });
++
r.post('/shares/:id/hooks/:hook/remove', async (req, res) => {
if (!guard(req, res)) return undefined;
const share = await mine(req, res);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 19c16f2..4810a07 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -22,6 +22,9 @@ const TABLES = [
// the shares (ON DELETE CASCADE); the export names the addresses and their counts, never a webhook's body.
{ table: 'share_hooks', subject: 'owner_subject', value: (usr) => usr, file: 'webhooks.json', erase: 'delete',
columns: ['id', 'share_id', 'label', 'local_path', 'status', 'created_at', 'removed_at', 'received', 'delivered', 'last_received_at', 'last_delivered_at', 'last_status'] },
++ // API keys on a person's shares (CON-16): the export names each key, its start and its rate, never its hash.
++ { table: 'share_api_keys', subject: 'owner_subject', value: (usr) => usr, file: 'api-keys.json', erase: 'delete',
++ columns: ['id', 'share_id', 'name', 'key_prefix', 'per_minute', 'created_at', 'last_used_at', 'revoked_at'] },
{
table: 'shares', subject: 'owner_subject', value: (usr) => usr, file: 'shares.json', erase: 'delete',
columns: ['id', 'owner_username', 'label', 'host', 'local_port', 'mode', 'named', 'status', 'policy_version', 'key_prefix', 'note',
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 9b7380c..6d76b96 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -278,7 +278,29 @@ ${rows}
</form></section>`;
}
--function sharePage({ share, connected, tunnel = null, visits = [], requests = [], key = null, baseUrl, notice = null, error = null, staff = false, machines = [], hooks = [], now = Date.now() }) {
++/**
++ * 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.
++ */
++function apiKeysBlock(share, keys, made, now) {
++ const fresh = made ? `<div class="cn-newkey" role="status"><p><b>Your new key</b> for ${esc(made.key.name)}. Copy it now: it is not shown again.</p>
++<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>
++<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>
++<p>A program (a script, an editor, another app) sends a key as <code>Authorization: Bearer …</code> and passes without signing in: share a model on your computer, like Ollama or llama.cpp, and call it from anywhere. A key opens what this share answers to programs, never its web pages, and your app never sees the key. Revoke one and it stops at once.</p>
++${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>
++<button class="cn-btn" type="submit">${kit.icon('plus')} Make an API key</button>
++</form></section>`;
++}
++
++function sharePage({ share, connected, tunnel = null, visits = [], requests = [], key = null, baseUrl, notice = null, error = null, staff = false, machines = [], hooks = [], apiKeys = [], newApiKey = null, now = Date.now() }) {
const closed = share.status !== 'active';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`
@@@ -322,6 +344,7 @@ ${share.expires_at ? `<div><dt>${closed ? 'Ended' : 'Closes'}</dt><dd><time date
${!key && !closed && !share.node ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}
${closed ? '' : nodeBlock(share, machines, now)}
${closed ? '' : hooksBlock(share, hooks, now)}
++${closed ? '' : apiKeysBlock(share, apiKeys, newApiKey, now)}
<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>
${closed ? '' : `<section class="cn-inspector" aria-labelledby="cn-reqs-h" data-cn-reqs="${esc(share.id)}"><h2 id="cn-reqs-h">Requests</h2>
<p class="cn-fine">The last 100 through this share, newest first, added as they happen. Kept in memory only, never written down: no headers, no bodies, and query values show as "…".</p>
ddiff --git a/test/api-keys.test.js b/test/api-keys.test.js
nnew file mode 100644
iindex 0000000..9dab167
---- /dev/null
++++ b/test/api-keys.test.js
@@@ -0,0 +1,103 @@
++'use strict';
++/**
++ * API keys for a share (CON-16 v0): the owner makes a key (shown once), a program sends it as a Bearer token and
++ * passes the gate without signing in; the app never sees the key, only X-OpenVibe-Subject key:<id>; a key opens the
++ * share's API, never its pages (an HTML answer is refused); a wrong, revoked or other share's key is 401, never a
++ * sign-in redirect; each key has its own rate; only the owner manages keys; the page shows a new key once.
++ * node test/api-keys.test.js
++ */
++const assert = require('assert');
++const { boot, startLocalApp } = require('./helpers/boot');
++const { check, done } = require('./helpers/check');
++
++(async () => {
++ const t = await boot();
++ const app = await startLocalApp();
++ const alex = t.network.addUser('alex');
++ const bob = t.network.addUser('bob');
++ const made = await t.makeShare(alex, { label: 'llm', port: app.port });
++ const other = await t.makeShare(alex, { label: 'other', port: app.port });
++ const c = t.client({ key: made.key, port: app.port });
++ await c.ready();
++ const api = (path, headers = {}) => t.share(made.share.host, path, { headers: { accept: 'application/json', ...headers } });
++ let key, token, otherToken;
++
++ await check('the owner makes a key: named, a rate, the token once; nobody else can', async () => {
++ const r = await t.get(`/api/v1/shares/${made.share.id}/api-keys`, { as: alex, json: { name: 'laptop script', per_minute: 3 } });
++ assert.strictEqual(r.status, 201, r.text);
++ key = r.json().api_key;
++ token = r.json().key;
++ assert.match(token, /^ovsk_[A-Za-z0-9_-]{43}$/);
++ assert.match(key.id, /^sak_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.deepStrictEqual([key.name, key.per_minute, key.prefix], ['laptop script', 3, token.slice(0, 12)]);
++ const list = await t.get(`/api/v1/shares/${made.share.id}/api-keys`, { as: alex });
++ assert.strictEqual(list.json().api_keys.length, 1);
++ assert.ok(!list.text.includes(token), 'a list never shows the token');
++ assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}/api-keys`, { as: bob, json: { name: 'x' } })).status, 404, 'not on another person\'s share');
++ assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}/api-keys`, { as: alex, json: { name: '' } })).status, 422);
++ assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}/api-keys`, { as: alex, json: { name: 'x', per_minute: 9999 } })).status, 422);
++ const o = await t.get(`/api/v1/shares/${other.share.id}/api-keys`, { as: alex, json: { name: 'other' } });
++ otherToken = o.json().key;
++ const row = await t.ctx.s.db.one('SELECT key_hash FROM share_api_keys WHERE id = $1', [key.id]);
++ assert.ok(row.key_hash && !row.key_hash.includes(token), 'only the hash is kept');
++ });
++
++ await check('a key passes the gate; the app sees who, never the key', async () => {
++ const r = await api('/v1/models', { authorization: `Bearer ${token}` });
++ assert.strictEqual(r.status, 200, r.text);
++ const seen = app.seen[app.seen.length - 1];
++ assert.strictEqual(seen.url, '/v1/models');
++ assert.strictEqual(seen.headers.authorization, undefined, 'the key is dropped at the edge');
++ assert.strictEqual(seen.headers['x-openvibe-subject'], `key:${key.id}`);
++ assert.strictEqual(seen.headers['x-openvibe-user'], 'key: laptop script');
++ const post = await t.share(made.share.host, '/v1/chat/completions', { method: 'POST', body: '{"model":"x"}', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${token}` } });
++ assert.strictEqual(post.status, 200);
++ assert.strictEqual(post.json().body, '{"model":"x"}');
++ });
++
++ await check('a key opens the API, never a page: an HTML answer is refused (406)', async () => {
++ const r = await api('/page', { authorization: `Bearer ${token}` });
++ assert.strictEqual(r.status, 406, r.text);
++ assert.ok(!r.text.includes('a page'), 'not a byte of the page');
++ assert.strictEqual(r.json().error, 'api_only');
++ });
++
++ await check('a wrong, another share\'s or a malformed key is 401, never a sign-in redirect', async () => {
++ for (const bad of [`ovsk_${'A'.repeat(43)}`, otherToken]) {
++ const r = await t.share(made.share.host, '/', { headers: { accept: 'text/html', authorization: `Bearer ${bad}` } });
++ assert.strictEqual(r.status, 401, `${bad.slice(0, 12)}: ${r.status}`);
++ assert.ok(!r.headers.location, 'no redirect to sign in');
++ }
++ const plain = await t.share(made.share.host, '/', { headers: { accept: 'text/html', authorization: 'Bearer something-else' } });
++ assert.strictEqual(plain.status, 302, 'a token that is not a key leaves the usual gate alone');
++ });
++
++ await check('each key has its own rate (3 a minute here): the fourth is 429', async () => {
++ const statuses = [];
++ for (let i = 0; i < 2; i++) statuses.push((await api('/v1/models', { authorization: `Bearer ${token}` })).status);
++ // Two requests were made above (a GET and a POST) plus the page: the rate is already used.
++ assert.ok(statuses.includes(429), `over its rate: ${statuses}`);
++ const limited = await api('/v1/models', { authorization: `Bearer ${token}` });
++ assert.strictEqual(limited.status, 429);
++ assert.strictEqual(limited.headers['retry-after'], '60');
++ });
++
++ await check('the share page shows a new key once, lists keys by their start, and revokes', async () => {
++ const page = await t.get(`/shares/${made.share.id}/api-keys`, { as: alex, form: { name: 'editor', per_minute: '60' } });
++ assert.strictEqual(page.status, 201, page.text.slice(0, 300));
++ assert.match(page.text, /Your new key<\/b> for editor/);
++ assert.match(page.text, /<code id="cn-apikey">ovsk_[A-Za-z0-9_-]{43}<\/code>/);
++ const again = await t.get(`/shares/${made.share.id}`, { as: alex });
++ assert.ok(!/<code id="cn-apikey">/.test(again.text), 'shown once');
++ assert.match(again.text, /API keys for programs/);
++ assert.ok(again.text.includes(`${token.slice(0, 12)}…`), 'listed by its start');
++ const gone = await t.get(`/shares/${made.share.id}/api-keys/${key.id}/revoke`, { as: alex, method: 'POST', form: {} });
++ assert.strictEqual(gone.status, 200);
++ assert.strictEqual((await api('/v1/models', { authorization: `Bearer ${token}` })).status, 401, 'a revoked key stops at once');
++ assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}/api-keys/${key.id}`, { as: alex, method: 'DELETE' })).status, 404, 'already revoked');
++ });
++
++ c.stop();
++ app.close();
++ await done(t);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 12f33b6..f5d6625 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -161,6 +161,11 @@ function startLocalApp() {
const iv = setInterval(() => { n++; res.write(`data: tick ${n}\n\n`); if (n === 3) { clearInterval(iv); res.end(); } }, 20);
return;
}
++ if (req.url === '/page') {
++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
++ res.end('<!doctype html><title>a page</title><p>a page');
++ return;
++ }
if (req.url === '/big') {
res.writeHead(200, { 'content-type': 'application/octet-stream' });
res.end(Buffer.alloc(3 * 1024 * 1024, 7));