openvibers/openvibe.connect Commits
API keys for a share: programs call it without signing in, never its pages (CON-16 v0) (#22)
Changes
@@@ -93,6 +93,28 @@ 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)++++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`.++## APIWith a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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 };@@@ -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;@@@ -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; }@@@ -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;@@@ -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);@@@ -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',@@@ -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>nnew file mode 100644@@@ -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); });@@@ -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));
@@ -93,6 +93,28 @@ page that names a brand (PayPal, Google, a bank…), or one with an urgent "veri
@@ -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
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)
+
+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
## API
With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -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 };
@@ -32,6 +32,7 @@ const { createNodeApi } = require('./http/node-api');
@@ -32,6 +32,7 @@ const { createNodeApi } = require('./http/node-api');
const { createNodeAuth } = require('./auth/node-auth');
const { createNodeAuth } = require('./auth/node-auth');
const { createMachines } = require('./machines');
const { createMachines } = require('./machines');
const { createHooks } = require('./hooks');
const { createHooks } = require('./hooks');
+const { createApiKeys } = require('./api-keys');
const { createProbe } = require('./probe');
const { createProbe } = require('./probe');
const { createHookRoutes } = require('./http/hooks');
const { createHookRoutes } = require('./http/hooks');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createServiceOutbox } = require('openvibe-sdk/events');
@@ -83,7 +84,9 @@ async function createApp(opts = {}) {
@@ -83,7 +84,9 @@ async function createApp(opts = {}) {
if (waiting.length) log.log(`[OpenVibe.Connect] told ${waiting.length} person(s) ${share.host} is back`);
if (waiting.length) log.log(`[OpenVibe.Connect] told ${waiting.length} person(s) ${share.host} is back`);
} });
} });
const gate = createGate({ config, now: s.now });
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 accountData = accountDataLib.create({ db: s.db, log });
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
@@ -100,7 +103,7 @@ async function createApp(opts = {}) {
@@ -100,7 +103,7 @@ async function createApp(opts = {}) {
});
});
const hooks = createHooks(s, { config, outbox, now: s.now });
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.probe = createProbe({ shares, proxy, config, log, now: s.now });
ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
@@ -36,7 +36,7 @@ function privateCache(value) {
@@ -36,7 +36,7 @@ function privateCache(value) {
return ['private', ...parts].join(', ');
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).
// 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 });
const domain = config.shares.domain;
const domain = config.shares.domain;
@@ -104,6 +104,8 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -104,6 +104,8 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
for (let i = 0; i + 1 < pairs.length; i += 2) {
for (let i = 0; i + 1 < pairs.length; i += 2) {
const name = String(pairs[i]).toLowerCase();
const name = String(pairs[i]).toLowerCase();
if (DROP_IN.test(name)) continue;
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;
if (HOP.has(name) && !(raw && (name === 'connection' || name === 'upgrade'))) continue;
out.push([name, String(pairs[i + 1])]);
out.push([name, String(pairs[i + 1])]);
}
}
@@ -167,6 +169,12 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -167,6 +169,12 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
});
});
res.end(page(title, text, link, { refresh, note, report, notify }));
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 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 = {}) {
function sendProblem(req, res, status, code, title, text, extra = {}) {
if (wantsHtml(req)) return sendPage(res, status, 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 = () =>
@@ -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. */
/** The share, the person and the tunnel for a request, or an answer already sent. */
async function admit(req, res, share, parsedPath) {
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) {
if (!who) {
const url = grantUrl(share, parsedPath);
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; }
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 = () =>
@@ -264,9 +282,17 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
let finished = false;
let finished = false;
let headTimer = null;
let headTimer = null;
let received = 0;
let received = 0;
+ let blocked = false;
const id = tunnel.open({
const id = tunnel.open({
onRes(m) {
onRes(m) {
clearTimeout(headTimer);
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);
const status = Number(m.status);
if (!Number.isInteger(status) || status < 100 || status > 599 || status === 101) {
if (!Number.isInteger(status) || status < 100 || status > 599 || status === 101) {
tunnel.abort(id, 'bad_status');
tunnel.abort(id, 'bad_status');
@@ -277,6 +303,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -277,6 +303,7 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
return undefined;
return undefined;
},
},
onData(buf) {
onData(buf) {
+ if (blocked) return;
addBytes(share, 'out', buf.length);
addBytes(share, 'out', buf.length);
rec.out += buf.length;
rec.out += buf.length;
if (!res.write(buf)) {
if (!res.write(buf)) {
@@ -284,9 +311,10 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
@@ -284,9 +311,10 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
res.once('drain', () => tunnel.send({ t: 'resume', id }));
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) {
onAbort(m) {
finished = true;
finished = true;
+ if (blocked) return;
clearTimeout(headTimer);
clearTimeout(headTimer);
if (m && m.code) rec.error = String(m.code).slice(0, 40);
if (m && m.code) rec.error = String(m.code).slice(0, 40);
if (res.headersSent) { res.destroy(); return; }
if (res.headersSent) { res.destroy(); return; }
@@ -28,9 +28,10 @@ const { http } = require('openvibe-contracts');
@@ -28,9 +28,10 @@ const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { ShareError } = require('../shares');
const { ShareError } = require('../shares');
const { HookError } = require('../hooks');
const { HookError } = require('../hooks');
+const { ApiKeyError } = require('../api-keys');
function createApi(ctx) {
function createApi(ctx) {
- const { shares, tunnels, principal, proxy, machines, hooks } = ctx;
+ const { shares, tunnels, principal, proxy, machines, hooks, apiKeys } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.json({ limit: '16kb' }));
r.use(express.json({ limit: '16kb' }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
@@ -42,7 +43,7 @@ function createApi(ctx) {
@@ -42,7 +43,7 @@ function createApi(ctx) {
const shape = (s) => ({ ...s, connected: tunnels.isConnected(s.id) });
const shape = (s) => ({ ...s, connected: tunnels.isConnected(s.id) });
const guard = (fn) => async (req, res) => {
const guard = (fn) => async (req, res) => {
try { return await fn(req, res); } catch (err) {
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;
throw err;
}
}
};
};
@@ -136,6 +137,26 @@ function createApi(ctx) {
@@ -136,6 +137,26 @@ function createApi(ctx) {
return res.json({ hook: await hooks.remove(owner(req), hook.id) });
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) => {
r.get('/shares/:id/requests', guard(async (req, res) => {
const share = await mine(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;
return share ? res.json({ requests: proxy.inspector.list(share.id, { after: req.query.after }), keep: proxy.inspector.KEEP }) : undefined;
@@ -24,9 +24,10 @@ const { send } = require('../render/layout');
@@ -24,9 +24,10 @@ const { send } = require('../render/layout');
const views = require('../views');
const views = require('../views');
const { ShareError } = require('../shares');
const { ShareError } = require('../shares');
const { HookError } = require('../hooks');
const { HookError } = require('../hooks');
+const { ApiKeyError } = require('../api-keys');
function createPageRoutes(ctx) {
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();
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '16kb' }));
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 });
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) {
@@ -85,12 +86,13 @@ function createPageRoutes(ctx) {
async function showShare(req, res, share, extra = {}, status = 200) {
async function showShare(req, res, share, extra = {}, status = 200) {
const tunnel = tunnels.get(share.id);
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),
shares.visits(share.id),
share.status === 'active' ? machines.list(req.viewer.token) : Promise.resolve({ ok: true, machines: [] }),
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' ? 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) => {
r.get('/shares/:id', async (req, res) => {
@@ -164,6 +166,33 @@ function createPageRoutes(ctx) {
@@ -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) => {
r.post('/shares/:id/hooks/:hook/remove', async (req, res) => {
if (!guard(req, res)) return undefined;
if (!guard(req, res)) return undefined;
const share = await mine(req, res);
const share = await mine(req, res);
@@ -22,6 +22,9 @@ const TABLES = [
@@ -22,6 +22,9 @@ const TABLES = [
// the shares (ON DELETE CASCADE); the export names the addresses and their counts, never a webhook's body.
// 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',
{ 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'] },
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',
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',
columns: ['id', 'owner_username', 'label', 'host', 'local_port', 'mode', 'named', 'status', 'policy_version', 'key_prefix', 'note',
@@ -278,7 +278,29 @@ ${rows}
@@ -278,7 +278,29 @@ ${rows}
</form></section>`;
</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 closed = share.status !== 'active';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`
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
@@ -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>` : ''}
${!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 ? '' : nodeBlock(share, machines, now)}
${closed ? '' : hooksBlock(share, hooks, 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>
<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>
${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>
<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>
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -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); });
@@ -161,6 +161,11 @@ function startLocalApp() {
@@ -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);
const iv = setInterval(() => { n++; res.write(`data: tick ${n}\n\n`); if (n === 3) { clearInterval(iv); res.end(); } }, 20);
return;
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') {
if (req.url === '/big') {
res.writeHead(200, { 'content-type': 'application/octet-stream' });
res.writeHead(200, { 'content-type': 'application/octet-stream' });
res.end(Buffer.alloc(3 * 1024 * 1024, 7));
res.end(Buffer.alloc(3 * 1024 * 1024, 7));