openvibers/openvibe.ai Commits

A person opts in, per app, before any app may use their own key (plan L55 C3) (#233)

OP OpenVibers committed 12 days ago · 52ff733328 · 11 files, +431 −15

* A person opts in, per app, before any app may use their own key (plan L55 C3).

Your AI (GET /you) grows 'Apps that may use my keys': each app the person allowed, per provider or for all, with a remove button. 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 (/you?allow=<app>&provider=<provider>, a GET shows the button, a same-origin POST changes it). Nothing uses a person's key by default, and taking the grant away takes effect at once. Every such use is audited: who, which service, which provider, which run.

* Document the allowed-keys route in OpenAPI (plan L55 C3).

* Key grants: only OpenVibe's own apps may be allowed

Review: the allow link took any app name and the page offered its button, so a crafted link could ask a person to
let an app that is not ours spend their key. A grant now names one of OpenVibe's apps (actor, codes, chat, live,
vision); any other name is refused (422) and the page shows no button.

Changes

ddiff --git a/migrations/0023_ai_key_grants.sql b/migrations/0023_ai_key_grants.sql
nnew file mode 100644
iindex 0000000..20f1db1
---- /dev/null
++++ b/migrations/0023_ai_key_grants.sql
@@@ -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)
++);
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 5afdc19..e6a0dea 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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' },
ddiff --git a/server/app.js b/server/app.js
iindex 11ffc54..ed8764e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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, keys
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 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 }));
ddiff --git a/server/credentials.js b/server/credentials.js
iindex f6bf302..1771810 100644
---- a/server/credentials.js
++++ b/server/credentials.js
@@@ -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() }, fetc
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. */
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 spend
secretKey();
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);
ddiff --git a/server/index.js b/server/index.js
iindex 2d5e680..28b2f76 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.now
clientSecret: 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') {
ddiff --git a/server/key-grants.js b/server/key-grants.js
nnew file mode 100644
iindex 0000000..0d90fe7
---- /dev/null
++++ b/server/key-grants.js
@@@ -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 };
ddiff --git a/server/me.js b/server/me.js
iindex 1e0f3a3..21baa5a 100644
---- a/server/me.js
++++ b/server/me.js
@@@ -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 || {});
ddiff --git a/server/openapi.js b/server/openapi.js
iindex e783d3f..329a805 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -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(),
} },
ddiff --git a/server/runs.js b/server/runs.js
iindex 86b091f..932d185 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -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;
ddiff --git a/server/you-page.js b/server/you-page.js
iindex ca15b90..298bc20 100644
---- a/server/you-page.js
++++ b/server/you-page.js
@@@ -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&rsquo;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(() => ({}));
ddiff --git a/test/key-grants.test.js b/test/key-grants.test.js
nnew file mode 100644
iindex 0000000..b6ab580
---- /dev/null
++++ b/test/key-grants.test.js
@@@ -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();