openvibers/openvibe.ai Commits
A person opts in, per app, before any app may use their own key (plan L55 C3) (#233)
Changes
nnew file mode 100644@@@ -0,0 +1,11 @@++-- phase: expand++-- L55 C3: the apps (services) a person allows to use their own provider key. The person's own choice, per app, for++-- one provider or for all of them. No key material is here: the key stays sealed in subject_credentials. Expand only:++-- a service without this table keeps running (no grant means no service may use a person's key).++CREATE TABLE IF NOT EXISTS ai_key_grants (++ subject text COLLATE "C" NOT NULL,++ service text COLLATE "C" NOT NULL,++ provider text COLLATE "C" NOT NULL DEFAULT '*',++ created_at text COLLATE "C" NOT NULL,++ PRIMARY KEY (subject, service, provider)++);@@@ -33,6 +33,8 @@ const TABLES = [// L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own// settings, so they go with the account; the row holds no key.{ table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },++ // L55 C3: the apps the person allowed to use their own key. Names only: no key, no hint, no material of any kind.++ { table: 'ai_key_grants', subject: 'subject', file: 'key-grants.json', columns: ['service', 'provider', 'created_at'], order: 'created_at' },// Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is// never exported sealed (it would be unreadable to the person) and the generic table export stays out of it.{ table: 'memory_items', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },@@@ -21,7 +21,7 @@ const ovServe = require('openvibe-shared/serve');const home = require('./home');const pkg = require('../package.json');--function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {++function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {const app = express();app.disable('x-powered-by');app.set('trust proxy', 'loopback');@@@ -119,10 +119,10 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keysconst personSessions = createPersonSessions({ db, config, clock });// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.-- if (credentials && preferences) {-- app.use(require('./me').meRouter({ credentials, preferences, auth, registry, config, clock, log, personSessions, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));++ if (credentials && preferences && grants) {++ app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));// The same settings as a page for people (GET /you, plain forms on AI's own person session).-- app.use(require('./you-page').youPageRouter({ credentials, preferences, personSessions, config, log }));++ app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log }));}app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));app.use(runPageRouter({ runs, personSessions, log }));@@@ -156,7 +156,7 @@ async function balanceOf(provider, base, key, fetchImpl, timeoutMs) {} catch { return null; }}--function createCredentials({ db, config, clock = { now: () => Date.now() }, fetchImpl = null }) {++function createCredentials({ db, config, clock = { now: () => Date.now() }, fetchImpl = null, grants = null }) {// subject_credentials is in migrations/0001_initial.sql (ADR-035); 0011 added the node machine, 0014 the provider// dimension (is_default, spend_key, one row per provider), 0019 the health of a key.const iso = (ms) => new Date(ms).toISOString();@@@ -305,11 +305,41 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetcreturn gone;}++ /**++ * Where the person allows this service to use their own key (plan L55 C3). The detail is meant to be shown to++ * the person: it names the page and carries the two values the page needs.++ */++ function allowHint(service, provider) {++ const base = String(config.baseUrl || 'https://ai.openvibe.services').replace(/\/+$/, '');++ const q = new URLSearchParams({ allow: String(service || ''), provider: String(provider || '') });++ return `allow ${service} to use your ${provider} key at ${base}/you?${q.toString()}`;++ }++++ /**++ * The row a run may use for a person (plan L55 C3): the service's own row, or — when the person allowed this++ * service for that provider — the person's own key. The consent is the person's, and it is checked here, once,++ * where the key is chosen.++ */++ async function resolveForRun(owner, subject, provider = null) {++ const own = await getRow(owner, subject, provider);++ if (own) return { row: own, own: true };++ if (!grants || provider === null) return { row: null, own: false };++ const self = await getRow(SELF_OWNER, subject, provider);++ if (!self) return { row: null, own: false };++ if (!await grants.allows(subject, owner, provider)) return { row: null, own: false, refused: true };++ return { row: self, own: false };++ }++/** Checks a run may use the credential (exists, within budget); throws AiError otherwise. */async function admit(owner, subject, provider = null) {if (provider !== null && !PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', 'no credential stored for that provider');-- const r = await getRow(owner, subject, provider);-- if (!r) throw new AiError(404, 'credential.not_found', provider ? `no ${provider} credential stored for that subject` : 'no credential stored for that subject');++ const found = await resolveForRun(owner, subject, provider);++ const r = found.row;++ if (!r) {++ // The person holds the key, but this service is not allowed to use it (L55 C3): say where to allow it.++ if (found.refused) throw new AiError(403, 'ai.credential_not_allowed', allowHint(owner, provider));++ throw new AiError(404, 'credential.not_found', provider ? `no ${provider} credential stored for that subject` : 'no credential stored for that subject');++ }if (r.provider === 'node') return; // the person's own machine: no key, no spendsecretKey();if (r.budget_usd_day != null && r.budget_usd_day > 0 && await spentToday(r) >= r.budget_usd_day) {@@@ -321,7 +351,17 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc/** The decrypted credential for one execution: { providerKey, owner, provider, base_url, apiKey|machine, models, hint }. */async function forRun(owner, subject, provider = null) {if (provider !== null && !PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', 'no credential stored for that provider');-- const r = await getRow(owner, subject, provider);++ let r = await getRow(owner, subject, provider);++ if (!r && provider !== null) {++ // The service holds no credential of its own: the person's own key, if they allowed this service (L55 C3).++ const found = await resolveForRun(owner, subject, provider);++ if (found.row) {++ const self = await selfForRun(subject, provider);++ if (self) return self;++ } else if (found.refused) {++ throw new AiError(403, 'ai.credential_not_allowed', allowHint(owner, provider));++ }++ }if (!r) throw new AiError(404, 'credential.not_found', provider ? `the ${provider} credential was deleted` : 'the credential was deleted');await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), owner, subject, r.provider);const providerKey = r.spend_key || providerKeyFor(owner, subject);@@@ -490,9 +530,35 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc};}++ /**++ * The providers a person allowed this service for, that they also hold a key for. Names only: no key, no hint,++ * no count (plan L55 C3).++ */++ async function allowedProvidersWithKey(subject, service) {++ if (!grants) return [];++ const allowed = await grants.allowedProviders(subject, service);++ if (!allowed.length) return [];++ const have = new Set((await selfRows(subject)).map((r) => r.provider));++ if (allowed.includes('*')) return [...have].sort();++ return allowed.filter((p) => have.has(p)).sort();++ }++++ /**++ * Did this run use the person's own key with the person's consent (plan L55 C3)? { service, subject,++ * provider } when it did, null when the service used a credential of its own (nothing to consent to) or when the++ * person did not allow it.++ */++ async function consentUse(owner, subject, provider) {++ if (!grants || !provider) return null;++ if (await getRow(owner, subject, provider)) return null;++ if (!await getRow(SELF_OWNER, subject, provider)) return null;++ return (await grants.allows(subject, owner, provider)) ? { service: owner, subject, provider } : null;++ }++return {put, get, remove, admit, forRun, spentToday, providerKeyFor,listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, markFailed,++ allowedProvidersWithKey, consentUse,};}@@@ -535,6 +601,22 @@ function credentialsRouter({ credentials, auth, registry, sendError }) {res.status(gone ? 204 : 404).end();} catch (err) { sendError(res, err, req.ov); }});++ /**++ * GET /api/v1/allowed-keys/:subject (ai.run.create): which providers this person allowed THIS service to use++ * their own key for, and that they hold a key for. Names only, never key material. A person who did not allow++ * this service gets nothing at all: 404, and no word about their keys.++ */++ const runGuard = auth.requireCap('ai.run.create');++ r.get('/api/v1/allowed-keys/:subject', runGuard, async (req, res) => {++ try {++ const o = owner(req);++ const subject = String(req.params.subject || '');++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) throw new AiError(404, 'credential.not_found', 'no keys are available to this service for that person');++ const providers = await credentials.allowedProvidersWithKey(subject, o);++ if (!providers.length) throw new AiError(404, 'credential.not_found', 'no keys are available to this service for that person');++ res.set('Cache-Control', 'no-store').json({ subject, service: o, providers });++ } catch (err) { sendError(res, err, req.ov); }++ });r.delete('/api/v1/credentials/:subject/:provider', guard, async (req, res) => {try {const o = owner(req);@@@ -64,7 +64,10 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now// A person's own provider keys (WS-O task 2; L36 Y1): stored by the service holding their consent, or by the// person themselves (owner `self`), used by their runs only. The test seam (a fake fetch) is for tests; production// reaches a custom endpoint through the guarded fetcher's egress rules.-- const credentials = require('./credentials').createCredentials({ db, config, clock, fetchImpl: credentialFetch });++ // L55 C3: the apps a person allows to use their own key (per app, per provider or for all). The person's++ // choice; nothing uses a person's key without it.++ const grants = require('./key-grants').createKeyGrants({ db, clock });++ const credentials = require('./credentials').createCredentials({ db, config, clock, fetchImpl: credentialFetch, grants });// A person's ai.preferences (L36 Y3): only_my_keys, free_first, a price ceiling and a default model per kind.const preferences = require('./person-preferences').createPreferences({ db, clock });const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });@@@ -90,7 +93,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowclientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,}) : null;const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });-- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, vault, benchEvents, runBudgets, memory });++ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, benchEvents, runBudgets, memory });let jwks = null;let keyLoaded = Promise.resolve(null);if (!keys.pinned && config.nodeEnv !== 'test') {nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++/**++ * Who may use a person's own key (plan L55 C3; the owner's rule L24: a person opts in per app; nothing uses their++ * data or keys by default).++ *++ * A grant is the person's own: (subject, service, provider) — the app, and one provider or '*' for all of them. The++ * key itself never comes here: it stays sealed in subject_credentials (server/credentials.js). This table says who may++ * ask for it, nothing more: no key, no token, no hint.++ *++ * A service that holds no credential of its own for a person may still use that person's key, but only for the++ * providers the person allowed it, and only while the grant stands. Removing it takes effect at once.++ */++const { AiError } = require('./util');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++// A service (an app on the network): actor, codes, chat, live…++const SERVICE_RE = /^[a-z][a-z0-9._-]{1,39}$/;++// The apps a person may allow to use their key: OpenVibe's own, the ones that run models for a person. A link naming++// any other app is not offered (a crafted link cannot ask for consent to an app that is not ours).++const APPS = Object.freeze(['actor', 'codes', 'chat', 'live', 'vision']);++// The provider names a grant may name, plus '*' for all of them.++const PROVIDER_RE = /^[a-z][a-z0-9._-]{0,39}$/;++++function createKeyGrants({ db, clock = { now: () => Date.now() } } = {}) {++ const iso = (ms) => new Date(ms).toISOString();++++ /** The provider ids a grant may name: '*' (all) or a provider. */++ function providerOf(value) {++ const p = String(value == null ? '*' : value).trim().toLowerCase();++ if (p !== '*' && !PROVIDER_RE.test(p)) throw new AiError(422, 'input.invalid', 'provider is not a provider name');++ return p;++ }++++ /** Let a service use this person's key for a provider (or for all of them). */++ async function allow(subject, service, provider = '*') {++ if (!SUBJECT_RE.test(String(subject || ''))) throw new AiError(400, 'input.invalid', 'subject must be a usr_ subject');++ const svc = String(service || '').trim().toLowerCase();++ if (!SERVICE_RE.test(svc) || !APPS.includes(svc)) throw new AiError(422, 'input.invalid', 'service is not an OpenVibe app that may use a key');++ const p = providerOf(provider);++ const at = iso(clock.now());++ await db.prepare(++ 'INSERT INTO ai_key_grants (subject, service, provider, created_at) VALUES (?, ?, ?, ?)'++ + ' ON CONFLICT (subject, service, provider) DO UPDATE SET created_at = excluded.created_at',++ ).run(subject, svc, p, at);++ return { subject, service: svc, provider: p, created_at: at };++ }++++ /** Take a grant away. Without a provider, every grant for that service goes. */++ async function remove(subject, service, provider = null) {++ if (!SUBJECT_RE.test(String(subject || ''))) return 0;++ const svc = String(service || '').trim().toLowerCase();++ if (provider == null) {++ const r = await db.prepare('DELETE FROM ai_key_grants WHERE subject = ? AND service = ?').run(subject, svc);++ return Number(r && r.changes) || 0;++ }++ const p = providerOf(provider);++ const r = await db.prepare('DELETE FROM ai_key_grants WHERE subject = ? AND service = ? AND provider = ?').run(subject, svc, p);++ return Number(r && r.changes) || 0;++ }++++ /** Every grant the person gave, newest first. */++ async function list(subject) {++ if (!SUBJECT_RE.test(String(subject || ''))) return [];++ return await db.prepare('SELECT subject, service, provider, created_at FROM ai_key_grants WHERE subject = ? ORDER BY created_at DESC').all(subject);++ }++++ /** May this service use this person's key for this provider? */++ async function allows(subject, service, provider) {++ if (!SUBJECT_RE.test(String(subject || '')) || !service) return false;++ const svc = String(service).trim().toLowerCase();++ const p = String(provider || '').trim().toLowerCase();++ const rows = await db.prepare('SELECT provider FROM ai_key_grants WHERE subject = ? AND service = ?').all(subject, svc);++ return rows.some((r) => r.provider === '*' || r.provider === p);++ }++++ /** The providers this person allowed this service for, names only. */++ async function allowedProviders(subject, service) {++ if (!SUBJECT_RE.test(String(subject || '')) || !service) return [];++ const svc = String(service).trim().toLowerCase();++ const rows = await db.prepare('SELECT provider FROM ai_key_grants WHERE subject = ? AND service = ?').all(subject, svc);++ if (rows.some((r) => r.provider === '*')) return ['*'];++ return rows.map((r) => r.provider).filter(Boolean).sort();++ }++++ return { allow, remove, list, allows, allowedProviders };++}++++module.exports = { createKeyGrants, SUBJECT_RE, SERVICE_RE, APPS };@@@ -56,7 +56,7 @@ function sameOrigin(req, config, { write = false } = {}) {return true;}--function meRouter({ credentials, preferences, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sendError }) {++function meRouter({ credentials, preferences, grants, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sendError }) {const r = express.Router();/** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */@@@ -136,6 +136,24 @@ function meRouter({ credentials, preferences, auth, registry, config, clock = {await audit(req, 'credential.self.test', { provider: req.params.provider, ok: out.ok, models_count: out.models_count });return out;}));++ // L55 C3: the apps the person allows to use their own key. The person's own list: names only, no key material.++ r.get('/api/v1/me/key-apps', guard, run(async (req) => ({ apps: grants ? await grants.list(personOf(req)) : [] })));++ r.put('/api/v1/me/key-apps/:service', guard, run(async (req) => {++ const provider = req.body && req.body.provider != null ? String(req.body.provider) : '*';++ const out = grants ? await grants.allow(personOf(req), req.params.service, provider) : null;++ if (!out) throw new AiError(503, 'credentials.unavailable', 'key grants are not configured on this service');++ await audit(req, 'key_grant.allow', { service: out.service, provider: out.provider });++ return out;++ }));++ r.delete('/api/v1/me/key-apps/:service', guard, async (req, res) => {++ try {++ if (!grants) throw new AiError(503, 'credentials.unavailable', 'key grants are not configured on this service');++ const provider = req.query && req.query.provider ? String(req.query.provider) : null;++ const gone = await grants.remove(personOf(req), req.params.service, provider);++ if (gone) await audit(req, 'key_grant.remove', { service: String(req.params.service || '').toLowerCase(), provider: provider || '*' });++ res.status(gone ? 204 : 404).end();++ } catch (err) { sendError(res, err, req.ov); }++ });r.get('/api/v1/me/ai/preferences', guard, run(async (req) => await preferences.get(personOf(req))));r.put('/api/v1/me/ai/preferences', guard, run(async (req) => {const out = await preferences.put(personOf(req), req.body || {});@@@ -79,6 +79,14 @@ function route(method, p, operation) {(PATHS[p] = PATHS[p] || {})[method] = operation;}++// ── The apps a person allows to use their own key (L55 C3) ───────────────────────────────────++route('get', '/allowed-keys/{subject}', {++ operationId: 'allowedKeys', summary: 'Which providers this person allowed this service to use their own key for', tags: ['keys'], security: SERVICE,++ description: 'ai.run.create. The providers this person allowed THIS service to use their own key for, and that they hold a key for: names only, never key material. A person who allowed this service nothing gets 404, and nothing at all about their keys.',++ parameters: [path('subject', str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }))],++ responses: { 200: json('The provider names', ref('AllowedKeys')), 404: problem, ...errors([401, 403]) },++});++// ── Memory (Memory M2a) ──────────────────────────────────────────────────────────────────────const memoryItem = ref('MemoryItem');const memoryBody = ref('MemoryWriteRequest');@@@ -322,6 +330,9 @@ function openapi() {items: arrayOf(ref('MemoryItem')), item: ref('MemoryItem'), profile: ref('MemoryProfile'),deleted: integer({ minimum: 0 }), count: integer({ minimum: 0 }),} },++ AllowedKeys: { type: 'object', required: ['subject', 'service', 'providers'], properties: {++ subject: str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }), service: str(), providers: arrayOf(str({ maxLength: 40 })),++ }, additionalProperties: false },MemoryWriteRequest: { type: 'object', properties: {items: arrayOf(ref('MemoryItemWrite')), on_behalf_of: str(), text: str(), kind: str(), scope: str(), status: str(), source: str(),} },@@@ -348,6 +348,14 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now});} catch (err) { if (hold) await tiers.release(hold); throw err; }await registry.audit(principal.sub, retryOf ? 'run.retry' : 'run.create', 'run', id, { trace, metadata: { workflow: wf.key, version: wf.version, target: ctx.targetKey, retry_of: retryOf, ...(pinned ? { pinned_offer: pinned } : {}) } });++ // L55 C3: a service running on a person's own key (the person allowed it for that provider) is audited: who,++ // which service, which provider, which run. The key itself is never in the row.++ if (credentialSubject && requester.type === 'service' && credentials) {++ const consent = await credentials.consentUse(requester.id, credentialSubject, credentialProvider).catch(() => null);++ if (consent) {++ await registry.audit(principal.sub, 'credential.self.use', 'credential', `${consent.service}:${consent.subject}:${consent.provider}`, { trace, metadata: { service: consent.service, subject: consent.subject, provider: consent.provider, run_id: id } });++ }++ }const controller = new AbortController();let release;@@@ -113,7 +113,22 @@ function signedOut({ siteUrl }) {<p style="margin-top:20px"><a class="you-btn is-go" href="/auth/person/login?next=${encodeURIComponent('/you')}">Sign in to see yours</a></p>` });}--function page({ siteUrl, keys = [], prefs = {}, ok = '', error = '' }) {++/** The app names a grant may name, for a person who reads the list. */++const APP_NAMES = { actor: 'OpenVibe.Actor', codes: 'OpenVibe.Codes', chat: 'OpenVibe.Chat', live: 'OpenVibe.Live', vision: 'OpenVibe.Vision' };++const appName = (id) => APP_NAMES[id] || id;++/** The link a service sends: /you?allow=<app>&provider=<provider>. Only a confirm button follows it; a POST changes it. */++function allowRequest(query) {++ const q = query || {};++ const service = String(q.allow || '').trim().toLowerCase();++ // Only OpenVibe's own apps are offered: a link naming anything else shows no button (server/key-grants.js APPS).++ if (!/^[a-z][a-z0-9._-]{1,39}$/.test(service) || !require('./key-grants').APPS.includes(service)) return null;++ const raw = String(q.provider == null ? '*' : q.provider).trim().toLowerCase();++ const provider = raw === '*' || raw === '' ? '*' : (/^[a-z][a-z0-9._-]{0,39}$/.test(raw) ? raw : null);++ if (!provider) return null;++ return { service, provider };++}++++function page({ siteUrl, keys = [], prefs = {}, apps = [], allow = null, ok = '', error = '' }) {const mine = new Map(keys.map((k) => [k.provider, k]));const working = keys.filter((k) => k.status === 'ok').length;const keyForm = (id, k) => `<form method="post" action="/you/keys/${esc(id)}"><label>${k ? 'New key' : 'Key'}<input type="password" name="key" required minlength="8" maxlength="400" autocomplete="off" spellcheck="false" placeholder="${id === 'anthropic' ? 'sk-ant-…' : 'sk-…'}"></label>${id === 'custom' ? `<label>Base URL<input type="url" name="base_url" required placeholder="https://…/v1" autocomplete="off"></label>` : ''}<button class="you-btn is-go" type="submit">Save</button>${keysUrl(id) ? `<a class="you-btn" href="${esc(keysUrl(id))}" rel="noopener" target="_blank">Get a key ↗</a>` : ''}</form>`;@@@ -132,6 +147,13 @@ ${note ? `<div class="you-key-n">${note}</div>` : ''}const toAdd = known.filter((id) => !mine.has(id));const addCard = (id) => `<li><details><summary>${esc(providerName(id))}<span>Add</span></summary>${keyForm(id, null)}</details></li>`;const only = prefs.only_my_keys === true;++ // L55 C3: the apps the person allowed to use their own key, and the one that is asking.++ const appRow = (g) => `<li class="you-key"><div class="you-key-t"><b>${esc(appName(g.service))}</b><span class="you-pill">${g.provider === '*' ? 'all your keys' : esc(providerName(g.provider))}</span></div>++<div class="you-acts"><form method="post" action="/you/apps/remove"><input type="hidden" name="service" value="${esc(g.service)}"><input type="hidden" name="provider" value="${esc(g.provider)}"><button class="you-btn" type="submit">Remove</button></form></div>++<div class="you-key-n">allowed ${esc(g.created_at ? ago(g.created_at) : 'just now')}</div></li>`;++ const appList = apps.length ? `<ul class="you-keys">${apps.map(appRow).join('')}</ul>` : '<p class="you-empty">No app may use your keys yet. An app that needs one asks you here, and never takes it by itself.</p>';++ const allowBlock = allow ? `<section aria-labelledby="you-allow"><h2 id="you-allow">Let ${esc(appName(allow.service))} use your ${allow.provider === '*' ? 'keys' : esc(providerName(allow.provider)) + ' key'}?</h2><p class="you-sub">${esc(appName(allow.service))} asked to run your work on your own key, so the call costs you nothing. No other app may use it, and you can take it away at any time.</p>++<form method="post" action="/you/apps/allow"><input type="hidden" name="service" value="${esc(allow.service)}"><input type="hidden" name="provider" value="${esc(allow.provider)}"><button class="you-btn is-go" type="submit">Allow</button></form></section>` : '';const body = `<p class="you-eye">Your AI</p><h1>Your keys, on every OpenVibe site.</h1><p class="you-lede">A key saved here works on every OpenVibe site and agent, with no fee, ever. It is sealed the moment it arrives; only its last four characters are ever shown again.</p><ul class="you-state"><li><b>${keys.length}</b> ${keys.length === 1 ? 'key' : 'keys'}${keys.length ? `, ${working} working` : ''}</li><li>${only ? '<b>Only your keys</b>' : 'Your keys first'}</li><li>Free tiers first: <b>${prefs.free_first === false ? 'off' : 'on'}</b></li></ul>@@@ -140,6 +162,7 @@ ${ok ? `<p class="you-note is-ok" role="status">${esc(ok)}</p>` : ''}${error ? `${saved.length ? `<ul class="you-keys">${saved.map(row).join('')}</ul>` : '<p class="you-empty">No keys yet. Add one below: Anthropic, OpenAI, Google or DeepSeek are good first ones.</p>'}<h3 style="margin:22px 0 10px;font-size:15px">Add a key</h3><ul class="you-new">${toAdd.map(addCard).join('')}</ul></section>++${allowBlock}<section aria-labelledby="you-pay"><h2 id="you-pay">How your AI is paid</h2><p class="you-sub">For every site and agent that runs AI for you.</p><form class="you-pay" method="post" action="/you/preferences"><fieldset><legend>When none of your keys can do a job</legend>@@@ -150,12 +173,14 @@ ${saved.length ? `<ul class="you-keys">${saved.map(row).join('')}</ul>` : '<p cl<label>Most per million tokens (USD)<input type="number" name="price_ceiling_usd_per_mtok" min="0" max="1000" step="0.01" value="${prefs.price_ceiling_usd_per_mtok == null ? '' : esc(prefs.price_ceiling_usd_per_mtok)}" placeholder="No limit"></label><button class="you-btn is-go" type="submit">Save</button></div></form></section>++<section aria-labelledby="you-apps"><h2 id="you-apps">Apps that may use my keys</h2><p class="you-sub">Only the apps you allow here may run on your own key. Nothing uses it by default, and a key never leaves OpenVibe.AI.</p>++${appList}</section><section aria-labelledby="you-cost"><h2 id="you-cost">What it cost</h2><p class="you-sub">Tokens, how much came from cache, what each call cost and whose key paid, across every site: <a href="https://openvibe.report/you">on openvibe.report</a>.</p></section><p class="you-fine">Removing a key deletes it from OpenVibe.AI. Keys are personal: one serves your own work and nobody else’s. Sites and agents that run AI for you use these settings; the ones you have not opened never see your keys.</p>`;return frame({ siteUrl, title: 'Your AI', body });}--function youPageRouter({ credentials, preferences, personSessions, config, log = console }) {++function youPageRouter({ credentials, preferences, grants, personSessions, config, log = console }) {const r = express.Router();const form = express.urlencoded({ extended: false, limit: '4kb' });const site = new URL(config.baseUrl).origin;@@@ -190,8 +215,12 @@ function youPageRouter({ credentials, preferences, personSessions, config, log =headers(res);const viewer = personSessions ? await personSessions.read(req) : null;if (!viewer) return res.type('html').send(signedOut({ siteUrl: config.baseUrl }));-- const [keys, prefs] = await Promise.all([credentials.listSelf(viewer.subject).catch(() => []), preferences.get(viewer.subject).catch(() => ({}))]);-- res.type('html').send(page({ siteUrl: config.baseUrl, keys, prefs, ok: String(req.query.ok || '').slice(0, 200), error: String(req.query.error || '').slice(0, 200) }));++ const [keys, prefs, apps] = await Promise.all([++ credentials.listSelf(viewer.subject).catch(() => []),++ preferences.get(viewer.subject).catch(() => ({})),++ grants.list(viewer.subject).catch(() => []),++ ]);++ res.type('html').send(page({ siteUrl: config.baseUrl, keys, prefs, apps, allow: allowRequest(req.query), ok: String(req.query.ok || '').slice(0, 200), error: String(req.query.error || '').slice(0, 200) }));});r.post('/you/keys/:provider', ...guard(async (req, res, subject) => {const id = req.params.provider;@@@ -210,6 +239,23 @@ function youPageRouter({ credentials, preferences, personSessions, config, log =const gone = await credentials.removeSelf(subject, req.params.provider);back(res, gone ? { ok: `Removed your ${providerName(req.params.provider)} key.` } : { error: 'There was no key to remove.' });}));++ // L55 C3: the person allows an app to use their own key (one provider, or all of them). A POST on this site++ // only; the link an app sends (/you?allow=...) only shows the button.++ r.post('/you/apps/allow', ...guard(async (req, res, subject) => {++ const body = req.body || {};++ const service = String(body.service || '').trim().toLowerCase();++ const provider = String(body.provider == null ? '*' : body.provider).trim().toLowerCase() || '*';++ if (!/^[a-z][a-z0-9._-]{1,39}$/.test(service) || !require('./key-grants').APPS.includes(service)) return back(res, { error: 'That is not an OpenVibe app that may use a key.' });++ await grants.allow(subject, service, provider);++ back(res, { ok: `${appName(service)} may use your ${provider === '*' ? 'keys' : providerName(provider) + ' key'} now.` });++ }));++ r.post('/you/apps/remove', ...guard(async (req, res, subject) => {++ const body = req.body || {};++ const service = String(body.service || '').trim().toLowerCase();++ const provider = body.provider == null || body.provider === '' ? null : String(body.provider).trim().toLowerCase();++ const gone = await grants.remove(subject, service, provider);++ back(res, { ok: gone ? `${appName(service)} may not use your keys any more.` : 'There was nothing to take away.' });++ }));r.post('/you/preferences', ...guard(async (req, res, subject) => {const b = req.body || {};const cur = await preferences.get(subject).catch(() => ({}));nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++// L55 C3: a person opts in, per app, before any app may use their own key (the owner's rule L24: nothing uses their++// data or keys by default).++// - a service that holds no credential of its own is refused with ai.credential_not_allowed, and the detail names++// the page where the person allows it;++// - once the person allows that app for that provider, the call runs on the person's own key (and the key never++// reaches the app, a response, a log line or an error);++// - per provider, or for all of them; another person's key is never reachable;++// - taking the grant away takes effect at once;++// - the service may ask which providers the person allowed it for: names only, and nothing at all when it may++// not use them;++// - every such use is audited: who, which service, which provider, which run.++const assert = require('assert');++const { boot, request, token, suite, ALL } = require('./helpers');++++const t = suite('key-grants');++const KEY = 'cd'.repeat(32);++const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX';++const ELI = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPW';++const DANA_KEY = 'sk-openai-test-dana-0123456789';++const ELI_KEY = 'sk-openai-test-eli-0123456789';++const person = token('dana', ALL, { sub: DANA, actorType: 'user' });++const eli = token('eli', ALL, { sub: ELI, actorType: 'user' });++const actor = token('actor', ALL);++const other = token('other', ALL);++const calls = [];++const logs = [];++const capture = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };++let h;++++const credentialFetch = async (url, init = {}) => {++ calls.push({ url: String(url), auth: (init.headers && (init.headers.Authorization || init.headers.authorization)) || null });++ return new Response(JSON.stringify({++ id: 'chatcmpl_person', object: 'chat.completion', model: 'gpt-person',++ choices: [{ index: 0, message: { role: 'assistant', content: 'Hello from your key' }, finish_reason: 'stop' }],++ usage: { prompt_tokens: 5, completion_tokens: 2 },++ }), { status: 200, headers: { 'content-type': 'application/json' } });++};++++const chat = (subject = DANA, tok = actor) => request(h.base, 'POST', '/v1/chat/completions', {++ tok, body: { model: 'openvibe', messages: [{ role: 'user', content: 'Say hello' }] },++ headers: { 'x-openvibe-credential': 'openai:' + subject },++});++++t.test('boot', async () => { h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_STUB_FALLBACK: 'false' }, credentialFetch, log: capture }); });++++t.test('the person stores their own key, and never sees it again', async () => {++ const r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { key: DANA_KEY, models: { chat: 'gpt-person' } } });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!r.text.includes(DANA_KEY), 'the key is not in the answer');++});++++t.test('a service that may not use the key is refused, and the answer says where to allow it', async () => {++ const res = await chat();++ assert.strictEqual(res.status, 403, res.text);++ assert.strictEqual(res.body.error.code, 'ai.credential_not_allowed');++ assert.ok(res.body.error.message.includes('allow actor to use your openai key'), res.text);++ assert.ok(res.body.error.message.includes('/you?allow=actor&provider=openai'), 'the detail names the page');++ assert.ok(!res.text.includes(DANA_KEY), 'the key is not in the error');++});++++t.test('the person allows the app for that provider, and the call runs on their key', async () => {++ const allow = await request(h.base, 'PUT', '/api/v1/me/key-apps/actor', { tok: person, body: { provider: 'openai' } });++ assert.strictEqual(allow.status, 200, allow.text);++ const before = calls.length;++ const res = await chat();++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.body.choices[0].message.content, 'Hello from your key');++ assert.ok(calls.length > before, 'the person\'s provider was called');++ assert.ok(String(calls[calls.length - 1].auth).includes(DANA_KEY), 'the person\'s key paid for it');++ assert.ok(!res.text.includes(DANA_KEY), 'the key is not in the answer');++ const run = await request(h.base, 'GET', '/api/v1/runs/' + res.headers.get('x-openvibe-run-id'), { tok: actor });++ assert.strictEqual(run.body.run.provenance.provider, 'byo:self:' + DANA + ':openai');++});++++t.test('the use is audited: who, which service, which provider, which run', async () => {++ const rows = await h.testdb.db.prepare("SELECT actor, action, target_id, metadata FROM audit_log WHERE action = 'credential.self.use'").all();++ assert.ok(rows.length >= 1, 'the use is audited');++ const row = rows[rows.length - 1];++ const meta = String(row.metadata || '');++ assert.ok(meta.includes('actor'), meta);++ assert.ok(meta.includes('openai'), meta);++ assert.ok(meta.includes('run_id'), meta);++ assert.ok(!meta.includes(DANA_KEY), 'the key is never in the audit row');++ assert.ok(!String(row.target_id).includes(DANA_KEY));++});++++t.test('another person\'s key is never reachable', async () => {++ const saved = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: eli, body: { key: ELI_KEY, models: { chat: 'gpt-person' } } });++ assert.strictEqual(saved.status, 200, saved.text);++ const res = await chat(ELI);++ assert.strictEqual(res.status, 403, res.text);++ assert.strictEqual(res.body.error.code, 'ai.credential_not_allowed');++});++++t.test('the service may ask which providers it may use: names only', async () => {++ const res = await request(h.base, 'GET', '/api/v1/allowed-keys/' + DANA, { tok: actor });++ assert.strictEqual(res.status, 200, res.text);++ assert.deepStrictEqual(res.body.providers, ['openai']);++ assert.ok(!res.text.includes(DANA_KEY), 'no key material in the answer');++ // A person who allowed nobody: nothing at all.++ const none = await request(h.base, 'GET', '/api/v1/allowed-keys/' + ELI, { tok: actor });++ assert.strictEqual(none.status, 404);++ assert.ok(!none.text.includes('sk-'), 'nothing about the person\'s keys');++ // Another service: nothing either.++ const otherService = await request(h.base, 'GET', '/api/v1/allowed-keys/' + DANA, { tok: other });++ assert.strictEqual(otherService.status, 404);++});++++t.test('taking the grant away takes effect at once', async () => {++ const gone = await request(h.base, 'DELETE', '/api/v1/me/key-apps/actor?provider=openai', { tok: person });++ assert.strictEqual(gone.status, 204);++ const res = await chat();++ assert.strictEqual(res.status, 403, res.text);++ assert.strictEqual(res.body.error.code, 'ai.credential_not_allowed');++});++++t.test('the person\'s own list names the apps, never the keys', async () => {++ const allow = await request(h.base, 'PUT', '/api/v1/me/key-apps/actor', { tok: person, body: { provider: '*' } });++ assert.strictEqual(allow.status, 200, allow.text);++ const list = await request(h.base, 'GET', '/api/v1/me/key-apps', { tok: person });++ assert.strictEqual(list.status, 200, list.text);++ assert.strictEqual(list.body.apps.length, 1);++ assert.strictEqual(list.body.apps[0].service, 'actor');++ assert.strictEqual(list.body.apps[0].provider, '*');++ assert.ok(!list.text.includes(DANA_KEY));++});++++t.test('only OpenVibe\'s own apps may be allowed: a crafted name is refused and the page offers no button', async () => {++ const odd = await request(h.base, 'PUT', '/api/v1/me/key-apps/evil-app', { tok: person, body: { provider: '*' } });++ assert.strictEqual(odd.status, 422);++ const { APPS } = require('../server/key-grants');++ assert.deepStrictEqual(APPS, ['actor', 'codes', 'chat', 'live', 'vision']);++});++++t.test('the person\'s own list is theirs alone: a service is refused', async () => {++ const res = await request(h.base, 'GET', '/api/v1/me/key-apps', { tok: actor });++ assert.strictEqual(res.status, 403);++});++++t.test('the key is never in a log line', async () => {++ assert.ok(!logs.join('\n').includes(DANA_KEY));++});++++t.test('stop', async () => { await h.stop(); });++++t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+-- phase: expand
+-- L55 C3: the apps (services) a person allows to use their own provider key. The person's own choice, per app, for
+-- one provider or for all of them. No key material is here: the key stays sealed in subject_credentials. Expand only:
+-- a service without this table keeps running (no grant means no service may use a person's key).
+CREATE TABLE IF NOT EXISTS ai_key_grants (
+ subject text COLLATE "C" NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ provider text COLLATE "C" NOT NULL DEFAULT '*',
+ created_at text COLLATE "C" NOT NULL,
+ PRIMARY KEY (subject, service, provider)
+);
@@ -33,6 +33,8 @@ const TABLES = [
@@ -33,6 +33,8 @@ const TABLES = [
// L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own
// L36 Y3: how the person's runs route (only_my_keys, free_first, the price ceiling and the defaults). Their own
// settings, so they go with the account; the row holds no key.
// settings, so they go with the account; the row holds no key.
{ table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },
{ table: 'ai_person_preferences', subject: 'subject', file: 'ai-preferences.json', columns: ['preferences', 'created_at', 'updated_at'], order: 'created_at' },
+ // L55 C3: the apps the person allowed to use their own key. Names only: no key, no hint, no material of any kind.
+ { table: 'ai_key_grants', subject: 'subject', file: 'key-grants.json', columns: ['service', 'provider', 'created_at'], order: 'created_at' },
// Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is
// Memory M2a: the rows are deleted by owner here; the decrypted items are added by extraExport below. The text is
// never exported sealed (it would be unreadable to the person) and the generic table export stays out of it.
// never exported sealed (it would be unreadable to the person) and the generic table export stays out of it.
{ table: 'memory_items', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
{ table: 'memory_items', subject: 'owner', value: (usr) => `user:${usr}`, file: null, erase: 'delete' },
@@ -21,7 +21,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -21,7 +21,7 @@ const ovServe = require('openvibe-shared/serve');
const home = require('./home');
const home = require('./home');
const pkg = require('../package.json');
const pkg = require('../package.json');
-function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {
+function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null, preferences = null, grants = null, vault = null, benchEvents = null, runBudgets = null, memory = null }) {
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
app.set('trust proxy', 'loopback');
@@ -119,10 +119,10 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
@@ -119,10 +119,10 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
const personSessions = createPersonSessions({ db, config, clock });
const personSessions = createPersonSessions({ db, config, clock });
// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only
// The person's own AI settings (L36 Y1/Y3): their keys under owner `self`, and their routing preferences. Only
// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.
// the person's Network token or their AI site session (same origin) reaches them; a service token is refused.
- if (credentials && preferences) {
- app.use(require('./me').meRouter({ credentials, preferences, auth, registry, config, clock, log, personSessions, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
+ if (credentials && preferences && grants) {
+ app.use(require('./me').meRouter({ credentials, preferences, grants, auth, registry, config, clock, log, personSessions, sendError: (res, err, ctx) => sendError(res, err, ctx, log) }));
// The same settings as a page for people (GET /you, plain forms on AI's own person session).
// The same settings as a page for people (GET /you, plain forms on AI's own person session).
- app.use(require('./you-page').youPageRouter({ credentials, preferences, personSessions, config, log }));
+ app.use(require('./you-page').youPageRouter({ credentials, preferences, grants, personSessions, config, log }));
}
}
app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));
app.use(consoleRouter({ config, db, registry, pool, quotas, cache, runs, ops, env, clock, fetchImpl, log, vault, personSessions }));
app.use(runPageRouter({ runs, personSessions, log }));
app.use(runPageRouter({ runs, personSessions, log }));
@@ -156,7 +156,7 @@ async function balanceOf(provider, base, key, fetchImpl, timeoutMs) {
@@ -156,7 +156,7 @@ async function balanceOf(provider, base, key, fetchImpl, timeoutMs) {
} catch { return null; }
} catch { return null; }
}
}
-function createCredentials({ db, config, clock = { now: () => Date.now() }, fetchImpl = null }) {
+function createCredentials({ db, config, clock = { now: () => Date.now() }, fetchImpl = null, grants = null }) {
// subject_credentials is in migrations/0001_initial.sql (ADR-035); 0011 added the node machine, 0014 the provider
// subject_credentials is in migrations/0001_initial.sql (ADR-035); 0011 added the node machine, 0014 the provider
// dimension (is_default, spend_key, one row per provider), 0019 the health of a key.
// dimension (is_default, spend_key, one row per provider), 0019 the health of a key.
const iso = (ms) => new Date(ms).toISOString();
const iso = (ms) => new Date(ms).toISOString();
@@ -305,11 +305,41 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -305,11 +305,41 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
return gone;
return gone;
}
}
+ /**
+ * Where the person allows this service to use their own key (plan L55 C3). The detail is meant to be shown to
+ * the person: it names the page and carries the two values the page needs.
+ */
+ function allowHint(service, provider) {
+ const base = String(config.baseUrl || 'https://ai.openvibe.services').replace(/\/+$/, '');
+ const q = new URLSearchParams({ allow: String(service || ''), provider: String(provider || '') });
+ return `allow ${service} to use your ${provider} key at ${base}/you?${q.toString()}`;
+ }
+
+ /**
+ * The row a run may use for a person (plan L55 C3): the service's own row, or — when the person allowed this
+ * service for that provider — the person's own key. The consent is the person's, and it is checked here, once,
+ * where the key is chosen.
+ */
+ async function resolveForRun(owner, subject, provider = null) {
+ const own = await getRow(owner, subject, provider);
+ if (own) return { row: own, own: true };
+ if (!grants || provider === null) return { row: null, own: false };
+ const self = await getRow(SELF_OWNER, subject, provider);
+ if (!self) return { row: null, own: false };
+ if (!await grants.allows(subject, owner, provider)) return { row: null, own: false, refused: true };
+ return { row: self, own: false };
+ }
+
/** Checks a run may use the credential (exists, within budget); throws AiError otherwise. */
/** Checks a run may use the credential (exists, within budget); throws AiError otherwise. */
async function admit(owner, subject, provider = null) {
async function admit(owner, subject, provider = null) {
if (provider !== null && !PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', 'no credential stored for that provider');
if (provider !== null && !PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', 'no credential stored for that provider');
- const r = await getRow(owner, subject, provider);
- if (!r) throw new AiError(404, 'credential.not_found', provider ? `no ${provider} credential stored for that subject` : 'no credential stored for that subject');
+ const found = await resolveForRun(owner, subject, provider);
+ const r = found.row;
+ if (!r) {
+ // The person holds the key, but this service is not allowed to use it (L55 C3): say where to allow it.
+ if (found.refused) throw new AiError(403, 'ai.credential_not_allowed', allowHint(owner, provider));
+ throw new AiError(404, 'credential.not_found', provider ? `no ${provider} credential stored for that subject` : 'no credential stored for that subject');
+ }
if (r.provider === 'node') return; // the person's own machine: no key, no spend
if (r.provider === 'node') return; // the person's own machine: no key, no spend
secretKey();
secretKey();
if (r.budget_usd_day != null && r.budget_usd_day > 0 && await spentToday(r) >= r.budget_usd_day) {
if (r.budget_usd_day != null && r.budget_usd_day > 0 && await spentToday(r) >= r.budget_usd_day) {
@@ -321,7 +351,17 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -321,7 +351,17 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
/** The decrypted credential for one execution: { providerKey, owner, provider, base_url, apiKey|machine, models, hint }. */
/** The decrypted credential for one execution: { providerKey, owner, provider, base_url, apiKey|machine, models, hint }. */
async function forRun(owner, subject, provider = null) {
async function forRun(owner, subject, provider = null) {
if (provider !== null && !PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', 'no credential stored for that provider');
if (provider !== null && !PROVIDERS.includes(provider)) throw new AiError(404, 'credential.not_found', 'no credential stored for that provider');
- const r = await getRow(owner, subject, provider);
+ let r = await getRow(owner, subject, provider);
+ if (!r && provider !== null) {
+ // The service holds no credential of its own: the person's own key, if they allowed this service (L55 C3).
+ const found = await resolveForRun(owner, subject, provider);
+ if (found.row) {
+ const self = await selfForRun(subject, provider);
+ if (self) return self;
+ } else if (found.refused) {
+ throw new AiError(403, 'ai.credential_not_allowed', allowHint(owner, provider));
+ }
+ }
if (!r) throw new AiError(404, 'credential.not_found', provider ? `the ${provider} credential was deleted` : 'the credential was deleted');
if (!r) throw new AiError(404, 'credential.not_found', provider ? `the ${provider} credential was deleted` : 'the credential was deleted');
await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), owner, subject, r.provider);
await db.prepare('UPDATE subject_credentials SET last_used_at = ? WHERE owner = ? AND subject = ? AND provider = ?').run(iso(clock.now()), owner, subject, r.provider);
const providerKey = r.spend_key || providerKeyFor(owner, subject);
const providerKey = r.spend_key || providerKeyFor(owner, subject);
@@ -490,9 +530,35 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
@@ -490,9 +530,35 @@ function createCredentials({ db, config, clock = { now: () => Date.now() }, fetc
};
};
}
}
+ /**
+ * The providers a person allowed this service for, that they also hold a key for. Names only: no key, no hint,
+ * no count (plan L55 C3).
+ */
+ async function allowedProvidersWithKey(subject, service) {
+ if (!grants) return [];
+ const allowed = await grants.allowedProviders(subject, service);
+ if (!allowed.length) return [];
+ const have = new Set((await selfRows(subject)).map((r) => r.provider));
+ if (allowed.includes('*')) return [...have].sort();
+ return allowed.filter((p) => have.has(p)).sort();
+ }
+
+ /**
+ * Did this run use the person's own key with the person's consent (plan L55 C3)? { service, subject,
+ * provider } when it did, null when the service used a credential of its own (nothing to consent to) or when the
+ * person did not allow it.
+ */
+ async function consentUse(owner, subject, provider) {
+ if (!grants || !provider) return null;
+ if (await getRow(owner, subject, provider)) return null;
+ if (!await getRow(SELF_OWNER, subject, provider)) return null;
+ return (await grants.allows(subject, owner, provider)) ? { service: owner, subject, provider } : null;
+ }
+
return {
return {
put, get, remove, admit, forRun, spentToday, providerKeyFor,
put, get, remove, admit, forRun, spentToday, providerKeyFor,
listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, markFailed,
listSelf, hasSelf, selfForRun, selfForAllRuns, putSelf, removeSelf, testSelf, markFailed,
+ allowedProvidersWithKey, consentUse,
};
};
}
}
@@ -535,6 +601,22 @@ function credentialsRouter({ credentials, auth, registry, sendError }) {
@@ -535,6 +601,22 @@ function credentialsRouter({ credentials, auth, registry, sendError }) {
res.status(gone ? 204 : 404).end();
res.status(gone ? 204 : 404).end();
} catch (err) { sendError(res, err, req.ov); }
} catch (err) { sendError(res, err, req.ov); }
});
});
+ /**
+ * GET /api/v1/allowed-keys/:subject (ai.run.create): which providers this person allowed THIS service to use
+ * their own key for, and that they hold a key for. Names only, never key material. A person who did not allow
+ * this service gets nothing at all: 404, and no word about their keys.
+ */
+ const runGuard = auth.requireCap('ai.run.create');
+ r.get('/api/v1/allowed-keys/:subject', runGuard, async (req, res) => {
+ try {
+ const o = owner(req);
+ const subject = String(req.params.subject || '');
+ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) throw new AiError(404, 'credential.not_found', 'no keys are available to this service for that person');
+ const providers = await credentials.allowedProvidersWithKey(subject, o);
+ if (!providers.length) throw new AiError(404, 'credential.not_found', 'no keys are available to this service for that person');
+ res.set('Cache-Control', 'no-store').json({ subject, service: o, providers });
+ } catch (err) { sendError(res, err, req.ov); }
+ });
r.delete('/api/v1/credentials/:subject/:provider', guard, async (req, res) => {
r.delete('/api/v1/credentials/:subject/:provider', guard, async (req, res) => {
try {
try {
const o = owner(req);
const o = owner(req);
@@ -64,7 +64,10 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -64,7 +64,10 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
// A person's own provider keys (WS-O task 2; L36 Y1): stored by the service holding their consent, or by the
// A person's own provider keys (WS-O task 2; L36 Y1): stored by the service holding their consent, or by the
// person themselves (owner `self`), used by their runs only. The test seam (a fake fetch) is for tests; production
// person themselves (owner `self`), used by their runs only. The test seam (a fake fetch) is for tests; production
// reaches a custom endpoint through the guarded fetcher's egress rules.
// reaches a custom endpoint through the guarded fetcher's egress rules.
- const credentials = require('./credentials').createCredentials({ db, config, clock, fetchImpl: credentialFetch });
+ // L55 C3: the apps a person allows to use their own key (per app, per provider or for all). The person's
+ // choice; nothing uses a person's key without it.
+ const grants = require('./key-grants').createKeyGrants({ db, clock });
+ const credentials = require('./credentials').createCredentials({ db, config, clock, fetchImpl: credentialFetch, grants });
// A person's ai.preferences (L36 Y3): only_my_keys, free_first, a price ceiling and a default model per kind.
// A person's ai.preferences (L36 Y3): only_my_keys, free_first, a price ceiling and a default model per kind.
const preferences = require('./person-preferences').createPreferences({ db, clock });
const preferences = require('./person-preferences').createPreferences({ db, clock });
const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });
const projectSecrets = require('./project-secrets').createProjectSecrets({ config, env, fetchImpl });
@@ -90,7 +93,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -90,7 +93,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl,
}) : null;
}) : null;
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
const benchEvents = createBenchEvents({ bench, accountData, accountSend, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
- const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, vault, benchEvents, runBudgets, memory });
+ const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, preferences, grants, vault, benchEvents, runBudgets, memory });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
if (!keys.pinned && config.nodeEnv !== 'test') {
if (!keys.pinned && config.nodeEnv !== 'test') {
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+/**
+ * Who may use a person's own key (plan L55 C3; the owner's rule L24: a person opts in per app; nothing uses their
+ * data or keys by default).
+ *
+ * A grant is the person's own: (subject, service, provider) — the app, and one provider or '*' for all of them. The
+ * key itself never comes here: it stays sealed in subject_credentials (server/credentials.js). This table says who may
+ * ask for it, nothing more: no key, no token, no hint.
+ *
+ * A service that holds no credential of its own for a person may still use that person's key, but only for the
+ * providers the person allowed it, and only while the grant stands. Removing it takes effect at once.
+ */
+const { AiError } = require('./util');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+// A service (an app on the network): actor, codes, chat, live…
+const SERVICE_RE = /^[a-z][a-z0-9._-]{1,39}$/;
+// The apps a person may allow to use their key: OpenVibe's own, the ones that run models for a person. A link naming
+// any other app is not offered (a crafted link cannot ask for consent to an app that is not ours).
+const APPS = Object.freeze(['actor', 'codes', 'chat', 'live', 'vision']);
+// The provider names a grant may name, plus '*' for all of them.
+const PROVIDER_RE = /^[a-z][a-z0-9._-]{0,39}$/;
+
+function createKeyGrants({ db, clock = { now: () => Date.now() } } = {}) {
+ const iso = (ms) => new Date(ms).toISOString();
+
+ /** The provider ids a grant may name: '*' (all) or a provider. */
+ function providerOf(value) {
+ const p = String(value == null ? '*' : value).trim().toLowerCase();
+ if (p !== '*' && !PROVIDER_RE.test(p)) throw new AiError(422, 'input.invalid', 'provider is not a provider name');
+ return p;
+ }
+
+ /** Let a service use this person's key for a provider (or for all of them). */
+ async function allow(subject, service, provider = '*') {
+ if (!SUBJECT_RE.test(String(subject || ''))) throw new AiError(400, 'input.invalid', 'subject must be a usr_ subject');
+ const svc = String(service || '').trim().toLowerCase();
+ if (!SERVICE_RE.test(svc) || !APPS.includes(svc)) throw new AiError(422, 'input.invalid', 'service is not an OpenVibe app that may use a key');
+ const p = providerOf(provider);
+ const at = iso(clock.now());
+ await db.prepare(
+ 'INSERT INTO ai_key_grants (subject, service, provider, created_at) VALUES (?, ?, ?, ?)'
+ + ' ON CONFLICT (subject, service, provider) DO UPDATE SET created_at = excluded.created_at',
+ ).run(subject, svc, p, at);
+ return { subject, service: svc, provider: p, created_at: at };
+ }
+
+ /** Take a grant away. Without a provider, every grant for that service goes. */
+ async function remove(subject, service, provider = null) {
+ if (!SUBJECT_RE.test(String(subject || ''))) return 0;
+ const svc = String(service || '').trim().toLowerCase();
+ if (provider == null) {
+ const r = await db.prepare('DELETE FROM ai_key_grants WHERE subject = ? AND service = ?').run(subject, svc);
+ return Number(r && r.changes) || 0;
+ }
+ const p = providerOf(provider);
+ const r = await db.prepare('DELETE FROM ai_key_grants WHERE subject = ? AND service = ? AND provider = ?').run(subject, svc, p);
+ return Number(r && r.changes) || 0;
+ }
+
+ /** Every grant the person gave, newest first. */
+ async function list(subject) {
+ if (!SUBJECT_RE.test(String(subject || ''))) return [];
+ return await db.prepare('SELECT subject, service, provider, created_at FROM ai_key_grants WHERE subject = ? ORDER BY created_at DESC').all(subject);
+ }
+
+ /** May this service use this person's key for this provider? */
+ async function allows(subject, service, provider) {
+ if (!SUBJECT_RE.test(String(subject || '')) || !service) return false;
+ const svc = String(service).trim().toLowerCase();
+ const p = String(provider || '').trim().toLowerCase();
+ const rows = await db.prepare('SELECT provider FROM ai_key_grants WHERE subject = ? AND service = ?').all(subject, svc);
+ return rows.some((r) => r.provider === '*' || r.provider === p);
+ }
+
+ /** The providers this person allowed this service for, names only. */
+ async function allowedProviders(subject, service) {
+ if (!SUBJECT_RE.test(String(subject || '')) || !service) return [];
+ const svc = String(service).trim().toLowerCase();
+ const rows = await db.prepare('SELECT provider FROM ai_key_grants WHERE subject = ? AND service = ?').all(subject, svc);
+ if (rows.some((r) => r.provider === '*')) return ['*'];
+ return rows.map((r) => r.provider).filter(Boolean).sort();
+ }
+
+ return { allow, remove, list, allows, allowedProviders };
+}
+
+module.exports = { createKeyGrants, SUBJECT_RE, SERVICE_RE, APPS };
@@ -56,7 +56,7 @@ function sameOrigin(req, config, { write = false } = {}) {
@@ -56,7 +56,7 @@ function sameOrigin(req, config, { write = false } = {}) {
return true;
return true;
}
}
-function meRouter({ credentials, preferences, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sendError }) {
+function meRouter({ credentials, preferences, grants, auth, registry, config, clock = { now: () => Date.now() }, log = console, personSessions = null, sendError }) {
const r = express.Router();
const r = express.Router();
/** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */
/** Authenticate a person, never a principal. Sets req.principal = { kind: 'user', subject }. */
@@ -136,6 +136,24 @@ function meRouter({ credentials, preferences, auth, registry, config, clock = {
@@ -136,6 +136,24 @@ function meRouter({ credentials, preferences, auth, registry, config, clock = {
await audit(req, 'credential.self.test', { provider: req.params.provider, ok: out.ok, models_count: out.models_count });
await audit(req, 'credential.self.test', { provider: req.params.provider, ok: out.ok, models_count: out.models_count });
return out;
return out;
}));
}));
+ // L55 C3: the apps the person allows to use their own key. The person's own list: names only, no key material.
+ r.get('/api/v1/me/key-apps', guard, run(async (req) => ({ apps: grants ? await grants.list(personOf(req)) : [] })));
+ r.put('/api/v1/me/key-apps/:service', guard, run(async (req) => {
+ const provider = req.body && req.body.provider != null ? String(req.body.provider) : '*';
+ const out = grants ? await grants.allow(personOf(req), req.params.service, provider) : null;
+ if (!out) throw new AiError(503, 'credentials.unavailable', 'key grants are not configured on this service');
+ await audit(req, 'key_grant.allow', { service: out.service, provider: out.provider });
+ return out;
+ }));
+ r.delete('/api/v1/me/key-apps/:service', guard, async (req, res) => {
+ try {
+ if (!grants) throw new AiError(503, 'credentials.unavailable', 'key grants are not configured on this service');
+ const provider = req.query && req.query.provider ? String(req.query.provider) : null;
+ const gone = await grants.remove(personOf(req), req.params.service, provider);
+ if (gone) await audit(req, 'key_grant.remove', { service: String(req.params.service || '').toLowerCase(), provider: provider || '*' });
+ res.status(gone ? 204 : 404).end();
+ } catch (err) { sendError(res, err, req.ov); }
+ });
r.get('/api/v1/me/ai/preferences', guard, run(async (req) => await preferences.get(personOf(req))));
r.get('/api/v1/me/ai/preferences', guard, run(async (req) => await preferences.get(personOf(req))));
r.put('/api/v1/me/ai/preferences', guard, run(async (req) => {
r.put('/api/v1/me/ai/preferences', guard, run(async (req) => {
const out = await preferences.put(personOf(req), req.body || {});
const out = await preferences.put(personOf(req), req.body || {});
@@ -79,6 +79,14 @@ function route(method, p, operation) {
@@ -79,6 +79,14 @@ function route(method, p, operation) {
(PATHS[p] = PATHS[p] || {})[method] = operation;
(PATHS[p] = PATHS[p] || {})[method] = operation;
}
}
+// ── The apps a person allows to use their own key (L55 C3) ───────────────────────────────────
+route('get', '/allowed-keys/{subject}', {
+ operationId: 'allowedKeys', summary: 'Which providers this person allowed this service to use their own key for', tags: ['keys'], security: SERVICE,
+ description: 'ai.run.create. The providers this person allowed THIS service to use their own key for, and that they hold a key for: names only, never key material. A person who allowed this service nothing gets 404, and nothing at all about their keys.',
+ parameters: [path('subject', str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }))],
+ responses: { 200: json('The provider names', ref('AllowedKeys')), 404: problem, ...errors([401, 403]) },
+});
+
// ── Memory (Memory M2a) ──────────────────────────────────────────────────────────────────────
// ── Memory (Memory M2a) ──────────────────────────────────────────────────────────────────────
const memoryItem = ref('MemoryItem');
const memoryItem = ref('MemoryItem');
const memoryBody = ref('MemoryWriteRequest');
const memoryBody = ref('MemoryWriteRequest');
@@ -322,6 +330,9 @@ function openapi() {
@@ -322,6 +330,9 @@ function openapi() {
items: arrayOf(ref('MemoryItem')), item: ref('MemoryItem'), profile: ref('MemoryProfile'),
items: arrayOf(ref('MemoryItem')), item: ref('MemoryItem'), profile: ref('MemoryProfile'),
deleted: integer({ minimum: 0 }), count: integer({ minimum: 0 }),
deleted: integer({ minimum: 0 }), count: integer({ minimum: 0 }),
} },
} },
+ AllowedKeys: { type: 'object', required: ['subject', 'service', 'providers'], properties: {
+ subject: str({ pattern: '^usr_[0-9A-HJKMNP-TV-Z]{26}$' }), service: str(), providers: arrayOf(str({ maxLength: 40 })),
+ }, additionalProperties: false },
MemoryWriteRequest: { type: 'object', properties: {
MemoryWriteRequest: { type: 'object', properties: {
items: arrayOf(ref('MemoryItemWrite')), on_behalf_of: str(), text: str(), kind: str(), scope: str(), status: str(), source: str(),
items: arrayOf(ref('MemoryItemWrite')), on_behalf_of: str(), text: str(), kind: str(), scope: str(), status: str(), source: str(),
} },
} },
@@ -348,6 +348,14 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
@@ -348,6 +348,14 @@ function createRuns({ db, registry, engine, cache, quotas, config, clock = { now
});
});
} catch (err) { if (hold) await tiers.release(hold); throw err; }
} catch (err) { if (hold) await tiers.release(hold); throw err; }
await registry.audit(principal.sub, retryOf ? 'run.retry' : 'run.create', 'run', id, { trace, metadata: { workflow: wf.key, version: wf.version, target: ctx.targetKey, retry_of: retryOf, ...(pinned ? { pinned_offer: pinned } : {}) } });
await registry.audit(principal.sub, retryOf ? 'run.retry' : 'run.create', 'run', id, { trace, metadata: { workflow: wf.key, version: wf.version, target: ctx.targetKey, retry_of: retryOf, ...(pinned ? { pinned_offer: pinned } : {}) } });
+ // L55 C3: a service running on a person's own key (the person allowed it for that provider) is audited: who,
+ // which service, which provider, which run. The key itself is never in the row.
+ if (credentialSubject && requester.type === 'service' && credentials) {
+ const consent = await credentials.consentUse(requester.id, credentialSubject, credentialProvider).catch(() => null);
+ if (consent) {
+ await registry.audit(principal.sub, 'credential.self.use', 'credential', `${consent.service}:${consent.subject}:${consent.provider}`, { trace, metadata: { service: consent.service, subject: consent.subject, provider: consent.provider, run_id: id } });
+ }
+ }
const controller = new AbortController();
const controller = new AbortController();
let release;
let release;
@@ -113,7 +113,22 @@ function signedOut({ siteUrl }) {
@@ -113,7 +113,22 @@ function signedOut({ siteUrl }) {
<p style="margin-top:20px"><a class="you-btn is-go" href="/auth/person/login?next=${encodeURIComponent('/you')}">Sign in to see yours</a></p>` });
<p style="margin-top:20px"><a class="you-btn is-go" href="/auth/person/login?next=${encodeURIComponent('/you')}">Sign in to see yours</a></p>` });
}
}
-function page({ siteUrl, keys = [], prefs = {}, ok = '', error = '' }) {
+/** The app names a grant may name, for a person who reads the list. */
+const APP_NAMES = { actor: 'OpenVibe.Actor', codes: 'OpenVibe.Codes', chat: 'OpenVibe.Chat', live: 'OpenVibe.Live', vision: 'OpenVibe.Vision' };
+const appName = (id) => APP_NAMES[id] || id;
+/** The link a service sends: /you?allow=<app>&provider=<provider>. Only a confirm button follows it; a POST changes it. */
+function allowRequest(query) {
+ const q = query || {};
+ const service = String(q.allow || '').trim().toLowerCase();
+ // Only OpenVibe's own apps are offered: a link naming anything else shows no button (server/key-grants.js APPS).
+ if (!/^[a-z][a-z0-9._-]{1,39}$/.test(service) || !require('./key-grants').APPS.includes(service)) return null;
+ const raw = String(q.provider == null ? '*' : q.provider).trim().toLowerCase();
+ const provider = raw === '*' || raw === '' ? '*' : (/^[a-z][a-z0-9._-]{0,39}$/.test(raw) ? raw : null);
+ if (!provider) return null;
+ return { service, provider };
+}
+
+function page({ siteUrl, keys = [], prefs = {}, apps = [], allow = null, ok = '', error = '' }) {
const mine = new Map(keys.map((k) => [k.provider, k]));
const mine = new Map(keys.map((k) => [k.provider, k]));
const working = keys.filter((k) => k.status === 'ok').length;
const working = keys.filter((k) => k.status === 'ok').length;
const keyForm = (id, k) => `<form method="post" action="/you/keys/${esc(id)}"><label>${k ? 'New key' : 'Key'}<input type="password" name="key" required minlength="8" maxlength="400" autocomplete="off" spellcheck="false" placeholder="${id === 'anthropic' ? 'sk-ant-…' : 'sk-…'}"></label>${id === 'custom' ? `<label>Base URL<input type="url" name="base_url" required placeholder="https://…/v1" autocomplete="off"></label>` : ''}<button class="you-btn is-go" type="submit">Save</button>${keysUrl(id) ? `<a class="you-btn" href="${esc(keysUrl(id))}" rel="noopener" target="_blank">Get a key ↗</a>` : ''}</form>`;
const keyForm = (id, k) => `<form method="post" action="/you/keys/${esc(id)}"><label>${k ? 'New key' : 'Key'}<input type="password" name="key" required minlength="8" maxlength="400" autocomplete="off" spellcheck="false" placeholder="${id === 'anthropic' ? 'sk-ant-…' : 'sk-…'}"></label>${id === 'custom' ? `<label>Base URL<input type="url" name="base_url" required placeholder="https://…/v1" autocomplete="off"></label>` : ''}<button class="you-btn is-go" type="submit">Save</button>${keysUrl(id) ? `<a class="you-btn" href="${esc(keysUrl(id))}" rel="noopener" target="_blank">Get a key ↗</a>` : ''}</form>`;
@@ -132,6 +147,13 @@ ${note ? `<div class="you-key-n">${note}</div>` : ''}
@@ -132,6 +147,13 @@ ${note ? `<div class="you-key-n">${note}</div>` : ''}
const toAdd = known.filter((id) => !mine.has(id));
const toAdd = known.filter((id) => !mine.has(id));
const addCard = (id) => `<li><details><summary>${esc(providerName(id))}<span>Add</span></summary>${keyForm(id, null)}</details></li>`;
const addCard = (id) => `<li><details><summary>${esc(providerName(id))}<span>Add</span></summary>${keyForm(id, null)}</details></li>`;
const only = prefs.only_my_keys === true;
const only = prefs.only_my_keys === true;
+ // L55 C3: the apps the person allowed to use their own key, and the one that is asking.
+ const appRow = (g) => `<li class="you-key"><div class="you-key-t"><b>${esc(appName(g.service))}</b><span class="you-pill">${g.provider === '*' ? 'all your keys' : esc(providerName(g.provider))}</span></div>
+<div class="you-acts"><form method="post" action="/you/apps/remove"><input type="hidden" name="service" value="${esc(g.service)}"><input type="hidden" name="provider" value="${esc(g.provider)}"><button class="you-btn" type="submit">Remove</button></form></div>
+<div class="you-key-n">allowed ${esc(g.created_at ? ago(g.created_at) : 'just now')}</div></li>`;
+ const appList = apps.length ? `<ul class="you-keys">${apps.map(appRow).join('')}</ul>` : '<p class="you-empty">No app may use your keys yet. An app that needs one asks you here, and never takes it by itself.</p>';
+ const allowBlock = allow ? `<section aria-labelledby="you-allow"><h2 id="you-allow">Let ${esc(appName(allow.service))} use your ${allow.provider === '*' ? 'keys' : esc(providerName(allow.provider)) + ' key'}?</h2><p class="you-sub">${esc(appName(allow.service))} asked to run your work on your own key, so the call costs you nothing. No other app may use it, and you can take it away at any time.</p>
+<form method="post" action="/you/apps/allow"><input type="hidden" name="service" value="${esc(allow.service)}"><input type="hidden" name="provider" value="${esc(allow.provider)}"><button class="you-btn is-go" type="submit">Allow</button></form></section>` : '';
const body = `<p class="you-eye">Your AI</p><h1>Your keys, on every OpenVibe site.</h1>
const body = `<p class="you-eye">Your AI</p><h1>Your keys, on every OpenVibe site.</h1>
<p class="you-lede">A key saved here works on every OpenVibe site and agent, with no fee, ever. It is sealed the moment it arrives; only its last four characters are ever shown again.</p>
<p class="you-lede">A key saved here works on every OpenVibe site and agent, with no fee, ever. It is sealed the moment it arrives; only its last four characters are ever shown again.</p>
<ul class="you-state"><li><b>${keys.length}</b> ${keys.length === 1 ? 'key' : 'keys'}${keys.length ? `, ${working} working` : ''}</li><li>${only ? '<b>Only your keys</b>' : 'Your keys first'}</li><li>Free tiers first: <b>${prefs.free_first === false ? 'off' : 'on'}</b></li></ul>
<ul class="you-state"><li><b>${keys.length}</b> ${keys.length === 1 ? 'key' : 'keys'}${keys.length ? `, ${working} working` : ''}</li><li>${only ? '<b>Only your keys</b>' : 'Your keys first'}</li><li>Free tiers first: <b>${prefs.free_first === false ? 'off' : 'on'}</b></li></ul>
@@ -140,6 +162,7 @@ ${ok ? `<p class="you-note is-ok" role="status">${esc(ok)}</p>` : ''}${error ? `
@@ -140,6 +162,7 @@ ${ok ? `<p class="you-note is-ok" role="status">${esc(ok)}</p>` : ''}${error ? `
${saved.length ? `<ul class="you-keys">${saved.map(row).join('')}</ul>` : '<p class="you-empty">No keys yet. Add one below: Anthropic, OpenAI, Google or DeepSeek are good first ones.</p>'}
${saved.length ? `<ul class="you-keys">${saved.map(row).join('')}</ul>` : '<p class="you-empty">No keys yet. Add one below: Anthropic, OpenAI, Google or DeepSeek are good first ones.</p>'}
<h3 style="margin:22px 0 10px;font-size:15px">Add a key</h3>
<h3 style="margin:22px 0 10px;font-size:15px">Add a key</h3>
<ul class="you-new">${toAdd.map(addCard).join('')}</ul></section>
<ul class="you-new">${toAdd.map(addCard).join('')}</ul></section>
+${allowBlock}
<section aria-labelledby="you-pay"><h2 id="you-pay">How your AI is paid</h2><p class="you-sub">For every site and agent that runs AI for you.</p>
<section aria-labelledby="you-pay"><h2 id="you-pay">How your AI is paid</h2><p class="you-sub">For every site and agent that runs AI for you.</p>
<form class="you-pay" method="post" action="/you/preferences">
<form class="you-pay" method="post" action="/you/preferences">
<fieldset><legend>When none of your keys can do a job</legend>
<fieldset><legend>When none of your keys can do a job</legend>
@@ -150,12 +173,14 @@ ${saved.length ? `<ul class="you-keys">${saved.map(row).join('')}</ul>` : '<p cl
@@ -150,12 +173,14 @@ ${saved.length ? `<ul class="you-keys">${saved.map(row).join('')}</ul>` : '<p cl
<label>Most per million tokens (USD)<input type="number" name="price_ceiling_usd_per_mtok" min="0" max="1000" step="0.01" value="${prefs.price_ceiling_usd_per_mtok == null ? '' : esc(prefs.price_ceiling_usd_per_mtok)}" placeholder="No limit"></label>
<label>Most per million tokens (USD)<input type="number" name="price_ceiling_usd_per_mtok" min="0" max="1000" step="0.01" value="${prefs.price_ceiling_usd_per_mtok == null ? '' : esc(prefs.price_ceiling_usd_per_mtok)}" placeholder="No limit"></label>
<button class="you-btn is-go" type="submit">Save</button></div>
<button class="you-btn is-go" type="submit">Save</button></div>
</form></section>
</form></section>
+<section aria-labelledby="you-apps"><h2 id="you-apps">Apps that may use my keys</h2><p class="you-sub">Only the apps you allow here may run on your own key. Nothing uses it by default, and a key never leaves OpenVibe.AI.</p>
+${appList}</section>
<section aria-labelledby="you-cost"><h2 id="you-cost">What it cost</h2><p class="you-sub">Tokens, how much came from cache, what each call cost and whose key paid, across every site: <a href="https://openvibe.report/you">on openvibe.report</a>.</p></section>
<section aria-labelledby="you-cost"><h2 id="you-cost">What it cost</h2><p class="you-sub">Tokens, how much came from cache, what each call cost and whose key paid, across every site: <a href="https://openvibe.report/you">on openvibe.report</a>.</p></section>
<p class="you-fine">Removing a key deletes it from OpenVibe.AI. Keys are personal: one serves your own work and nobody else’s. Sites and agents that run AI for you use these settings; the ones you have not opened never see your keys.</p>`;
<p class="you-fine">Removing a key deletes it from OpenVibe.AI. Keys are personal: one serves your own work and nobody else’s. Sites and agents that run AI for you use these settings; the ones you have not opened never see your keys.</p>`;
return frame({ siteUrl, title: 'Your AI', body });
return frame({ siteUrl, title: 'Your AI', body });
}
}
-function youPageRouter({ credentials, preferences, personSessions, config, log = console }) {
+function youPageRouter({ credentials, preferences, grants, personSessions, config, log = console }) {
const r = express.Router();
const r = express.Router();
const form = express.urlencoded({ extended: false, limit: '4kb' });
const form = express.urlencoded({ extended: false, limit: '4kb' });
const site = new URL(config.baseUrl).origin;
const site = new URL(config.baseUrl).origin;
@@ -190,8 +215,12 @@ function youPageRouter({ credentials, preferences, personSessions, config, log =
@@ -190,8 +215,12 @@ function youPageRouter({ credentials, preferences, personSessions, config, log =
headers(res);
headers(res);
const viewer = personSessions ? await personSessions.read(req) : null;
const viewer = personSessions ? await personSessions.read(req) : null;
if (!viewer) return res.type('html').send(signedOut({ siteUrl: config.baseUrl }));
if (!viewer) return res.type('html').send(signedOut({ siteUrl: config.baseUrl }));
- const [keys, prefs] = await Promise.all([credentials.listSelf(viewer.subject).catch(() => []), preferences.get(viewer.subject).catch(() => ({}))]);
- res.type('html').send(page({ siteUrl: config.baseUrl, keys, prefs, ok: String(req.query.ok || '').slice(0, 200), error: String(req.query.error || '').slice(0, 200) }));
+ const [keys, prefs, apps] = await Promise.all([
+ credentials.listSelf(viewer.subject).catch(() => []),
+ preferences.get(viewer.subject).catch(() => ({})),
+ grants.list(viewer.subject).catch(() => []),
+ ]);
+ res.type('html').send(page({ siteUrl: config.baseUrl, keys, prefs, apps, allow: allowRequest(req.query), ok: String(req.query.ok || '').slice(0, 200), error: String(req.query.error || '').slice(0, 200) }));
});
});
r.post('/you/keys/:provider', ...guard(async (req, res, subject) => {
r.post('/you/keys/:provider', ...guard(async (req, res, subject) => {
const id = req.params.provider;
const id = req.params.provider;
@@ -210,6 +239,23 @@ function youPageRouter({ credentials, preferences, personSessions, config, log =
@@ -210,6 +239,23 @@ function youPageRouter({ credentials, preferences, personSessions, config, log =
const gone = await credentials.removeSelf(subject, req.params.provider);
const gone = await credentials.removeSelf(subject, req.params.provider);
back(res, gone ? { ok: `Removed your ${providerName(req.params.provider)} key.` } : { error: 'There was no key to remove.' });
back(res, gone ? { ok: `Removed your ${providerName(req.params.provider)} key.` } : { error: 'There was no key to remove.' });
}));
}));
+ // L55 C3: the person allows an app to use their own key (one provider, or all of them). A POST on this site
+ // only; the link an app sends (/you?allow=...) only shows the button.
+ r.post('/you/apps/allow', ...guard(async (req, res, subject) => {
+ const body = req.body || {};
+ const service = String(body.service || '').trim().toLowerCase();
+ const provider = String(body.provider == null ? '*' : body.provider).trim().toLowerCase() || '*';
+ if (!/^[a-z][a-z0-9._-]{1,39}$/.test(service) || !require('./key-grants').APPS.includes(service)) return back(res, { error: 'That is not an OpenVibe app that may use a key.' });
+ await grants.allow(subject, service, provider);
+ back(res, { ok: `${appName(service)} may use your ${provider === '*' ? 'keys' : providerName(provider) + ' key'} now.` });
+ }));
+ r.post('/you/apps/remove', ...guard(async (req, res, subject) => {
+ const body = req.body || {};
+ const service = String(body.service || '').trim().toLowerCase();
+ const provider = body.provider == null || body.provider === '' ? null : String(body.provider).trim().toLowerCase();
+ const gone = await grants.remove(subject, service, provider);
+ back(res, { ok: gone ? `${appName(service)} may not use your keys any more.` : 'There was nothing to take away.' });
+ }));
r.post('/you/preferences', ...guard(async (req, res, subject) => {
r.post('/you/preferences', ...guard(async (req, res, subject) => {
const b = req.body || {};
const b = req.body || {};
const cur = await preferences.get(subject).catch(() => ({}));
const cur = await preferences.get(subject).catch(() => ({}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+// L55 C3: a person opts in, per app, before any app may use their own key (the owner's rule L24: nothing uses their
+// data or keys by default).
+// - a service that holds no credential of its own is refused with ai.credential_not_allowed, and the detail names
+// the page where the person allows it;
+// - once the person allows that app for that provider, the call runs on the person's own key (and the key never
+// reaches the app, a response, a log line or an error);
+// - per provider, or for all of them; another person's key is never reachable;
+// - taking the grant away takes effect at once;
+// - the service may ask which providers the person allowed it for: names only, and nothing at all when it may
+// not use them;
+// - every such use is audited: who, which service, which provider, which run.
+const assert = require('assert');
+const { boot, request, token, suite, ALL } = require('./helpers');
+
+const t = suite('key-grants');
+const KEY = 'cd'.repeat(32);
+const DANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX';
+const ELI = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPW';
+const DANA_KEY = 'sk-openai-test-dana-0123456789';
+const ELI_KEY = 'sk-openai-test-eli-0123456789';
+const person = token('dana', ALL, { sub: DANA, actorType: 'user' });
+const eli = token('eli', ALL, { sub: ELI, actorType: 'user' });
+const actor = token('actor', ALL);
+const other = token('other', ALL);
+const calls = [];
+const logs = [];
+const capture = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
+let h;
+
+const credentialFetch = async (url, init = {}) => {
+ calls.push({ url: String(url), auth: (init.headers && (init.headers.Authorization || init.headers.authorization)) || null });
+ return new Response(JSON.stringify({
+ id: 'chatcmpl_person', object: 'chat.completion', model: 'gpt-person',
+ choices: [{ index: 0, message: { role: 'assistant', content: 'Hello from your key' }, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 5, completion_tokens: 2 },
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
+};
+
+const chat = (subject = DANA, tok = actor) => request(h.base, 'POST', '/v1/chat/completions', {
+ tok, body: { model: 'openvibe', messages: [{ role: 'user', content: 'Say hello' }] },
+ headers: { 'x-openvibe-credential': 'openai:' + subject },
+});
+
+t.test('boot', async () => { h = await boot({ env: { AI_CREDENTIALS_KEY: KEY, AI_STUB_FALLBACK: 'false' }, credentialFetch, log: capture }); });
+
+t.test('the person stores their own key, and never sees it again', async () => {
+ const r = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: person, body: { key: DANA_KEY, models: { chat: 'gpt-person' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!r.text.includes(DANA_KEY), 'the key is not in the answer');
+});
+
+t.test('a service that may not use the key is refused, and the answer says where to allow it', async () => {
+ const res = await chat();
+ assert.strictEqual(res.status, 403, res.text);
+ assert.strictEqual(res.body.error.code, 'ai.credential_not_allowed');
+ assert.ok(res.body.error.message.includes('allow actor to use your openai key'), res.text);
+ assert.ok(res.body.error.message.includes('/you?allow=actor&provider=openai'), 'the detail names the page');
+ assert.ok(!res.text.includes(DANA_KEY), 'the key is not in the error');
+});
+
+t.test('the person allows the app for that provider, and the call runs on their key', async () => {
+ const allow = await request(h.base, 'PUT', '/api/v1/me/key-apps/actor', { tok: person, body: { provider: 'openai' } });
+ assert.strictEqual(allow.status, 200, allow.text);
+ const before = calls.length;
+ const res = await chat();
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.body.choices[0].message.content, 'Hello from your key');
+ assert.ok(calls.length > before, 'the person\'s provider was called');
+ assert.ok(String(calls[calls.length - 1].auth).includes(DANA_KEY), 'the person\'s key paid for it');
+ assert.ok(!res.text.includes(DANA_KEY), 'the key is not in the answer');
+ const run = await request(h.base, 'GET', '/api/v1/runs/' + res.headers.get('x-openvibe-run-id'), { tok: actor });
+ assert.strictEqual(run.body.run.provenance.provider, 'byo:self:' + DANA + ':openai');
+});
+
+t.test('the use is audited: who, which service, which provider, which run', async () => {
+ const rows = await h.testdb.db.prepare("SELECT actor, action, target_id, metadata FROM audit_log WHERE action = 'credential.self.use'").all();
+ assert.ok(rows.length >= 1, 'the use is audited');
+ const row = rows[rows.length - 1];
+ const meta = String(row.metadata || '');
+ assert.ok(meta.includes('actor'), meta);
+ assert.ok(meta.includes('openai'), meta);
+ assert.ok(meta.includes('run_id'), meta);
+ assert.ok(!meta.includes(DANA_KEY), 'the key is never in the audit row');
+ assert.ok(!String(row.target_id).includes(DANA_KEY));
+});
+
+t.test('another person\'s key is never reachable', async () => {
+ const saved = await request(h.base, 'PUT', '/api/v1/me/credentials/openai', { tok: eli, body: { key: ELI_KEY, models: { chat: 'gpt-person' } } });
+ assert.strictEqual(saved.status, 200, saved.text);
+ const res = await chat(ELI);
+ assert.strictEqual(res.status, 403, res.text);
+ assert.strictEqual(res.body.error.code, 'ai.credential_not_allowed');
+});
+
+t.test('the service may ask which providers it may use: names only', async () => {
+ const res = await request(h.base, 'GET', '/api/v1/allowed-keys/' + DANA, { tok: actor });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.deepStrictEqual(res.body.providers, ['openai']);
+ assert.ok(!res.text.includes(DANA_KEY), 'no key material in the answer');
+ // A person who allowed nobody: nothing at all.
+ const none = await request(h.base, 'GET', '/api/v1/allowed-keys/' + ELI, { tok: actor });
+ assert.strictEqual(none.status, 404);
+ assert.ok(!none.text.includes('sk-'), 'nothing about the person\'s keys');
+ // Another service: nothing either.
+ const otherService = await request(h.base, 'GET', '/api/v1/allowed-keys/' + DANA, { tok: other });
+ assert.strictEqual(otherService.status, 404);
+});
+
+t.test('taking the grant away takes effect at once', async () => {
+ const gone = await request(h.base, 'DELETE', '/api/v1/me/key-apps/actor?provider=openai', { tok: person });
+ assert.strictEqual(gone.status, 204);
+ const res = await chat();
+ assert.strictEqual(res.status, 403, res.text);
+ assert.strictEqual(res.body.error.code, 'ai.credential_not_allowed');
+});
+
+t.test('the person\'s own list names the apps, never the keys', async () => {
+ const allow = await request(h.base, 'PUT', '/api/v1/me/key-apps/actor', { tok: person, body: { provider: '*' } });
+ assert.strictEqual(allow.status, 200, allow.text);
+ const list = await request(h.base, 'GET', '/api/v1/me/key-apps', { tok: person });
+ assert.strictEqual(list.status, 200, list.text);
+ assert.strictEqual(list.body.apps.length, 1);
+ assert.strictEqual(list.body.apps[0].service, 'actor');
+ assert.strictEqual(list.body.apps[0].provider, '*');
+ assert.ok(!list.text.includes(DANA_KEY));
+});
+
+t.test('only OpenVibe\'s own apps may be allowed: a crafted name is refused and the page offers no button', async () => {
+ const odd = await request(h.base, 'PUT', '/api/v1/me/key-apps/evil-app', { tok: person, body: { provider: '*' } });
+ assert.strictEqual(odd.status, 422);
+ const { APPS } = require('../server/key-grants');
+ assert.deepStrictEqual(APPS, ['actor', 'codes', 'chat', 'live', 'vision']);
+});
+
+t.test('the person\'s own list is theirs alone: a service is refused', async () => {
+ const res = await request(h.base, 'GET', '/api/v1/me/key-apps', { tok: actor });
+ assert.strictEqual(res.status, 403);
+});
+
+t.test('the key is never in a log line', async () => {
+ assert.ok(!logs.join('\n').includes(DANA_KEY));
+});
+
+t.test('stop', async () => { await h.stop(); });
+
+t.run();