openvibers/openvibe.ai Commits
account-data: a person's sessions, credentials and runs, with the guard on every person column (#206)
Changes
@@@ -49,7 +49,7 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.- OpenVibe.Media (recordings `media.analyze` reads, over allow-listed https URLs or signed URLs)- provider APIs configured by the operator (OpenAI-compatible, OpenAI Responses, Anthropic, the HTTP seam), whisper.cppand the local model server `openvibe-llm.service` on this host--- `openvibe-contracts` v0.86.0, `openvibe-sdk` v0.67.0 (service tokens, events outbox),++- `openvibe-contracts` v0.86.0, `openvibe-sdk` v0.74.0 (service tokens, events outbox),`openvibe-shared` v2.3.1, pinned by release tarball## Capabilities@@@ -722,6 +722,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.241.0--- openvibe-sdk: v0.67.0++- openvibe-sdk: v0.74.0- openvibe-shared: v4.48.0<!-- versions:end -->@@@ -8,7 +8,7 @@"port": 4700,"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback","contracts": "openvibe-contracts v0.241.0",-- "sdk": "openvibe-sdk v0.67.0",++ "sdk": "openvibe-sdk v0.74.0","shared": "openvibe-shared v4.48.0","capabilities": ["ai.run.create",nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- OpenVibe.AI: the receipts of the ADR-033 account export and deletion deliveries this service applied++-- (openvibe-sdk/account-data's ACCOUNT_DATA_SCHEMA), so a redelivered export or deletion changes nothing.++-- The row holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.++++CREATE TABLE IF NOT EXISTS account_data_events (++ id TEXT PRIMARY KEY,++ kind TEXT NOT NULL,++ subject TEXT NOT NULL,++ outcome JSONB,++ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),++ sent_at TIMESTAMPTZ++);@@@ -14,7 +14,7 @@"express": "^4.21.2","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0","pg": "^8.23.0"},@@@ -1556,9 +1556,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.67.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "integrity": "sha512-s7NzjaNcowVcOIiup2z0ze+xsq/qnr++3pHSI8JzirqJPQ5JsBSUF5RkjOHyfk9bRKlmhY0fsNS6x4dsYylyqw==",++ "version": "0.74.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",++ "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -26,7 +26,7 @@"express": "^4.21.2","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0","pg": "^8.23.0"},nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++/**++ * Account export and deletion → AI (ADR-033; openvibe-sdk/account-data). What AI holds about a person:++ *++ * - their console and person sessions (Console sign-in and the AI chat), erased;++ * - the provider credentials they brought (server/credentials.js): the encrypted key, its hint and the budget, erased++ * with them (the key itself is never exported, only its hint);++ * - their runs: the workflows AI ran for them (runs.on_behalf_of is their SubjectRef) and the requests, citations and++ * private cache entries under them. Those are the person's own content, so they go with the account (ADR-033);++ * - the free-allowance usage the allowance ledger counts (server/free-allowance.js) and the usage counters: kept,++ * because the allowance and the spend must still reconcile across their period;++ * - their quota (their own limit) and the marker of what was last pushed to their modules: settings, they go;++ * - the route, template and workflow catalogues: the service's own, with created_by as the staff member's audit trail,++ * and the audit log itself, which says what was done and by whom.++ *++ * Telemetry samples that name a person are covered by the SDK itself (telemetry.json, then subject NULL).++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const ledger = 'money records: the ledger must still balance and reconcile, and the books are kept as the billing policy requires';++++// The runs AI made for the person: their SubjectRef sits in the JSON column on_behalf_of.++const PERSON_RUN = `on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = $1`;++const RUN_COLUMNS = 'id, workflow_key, workflow_version, template_key, template_version, route_key, status, model_key, provider_key, input, output, citations_count, error_code, error_detail, tokens_in, tokens_out, cost_usd, created_at, started_at, finished_at';++++const TABLES = [++ { table: 'console_sessions', subject: 'subject', file: 'console-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },++ { table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },++ { table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',++ columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'created_at', 'updated_at', 'last_used_at'] },++ { table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },++ { table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },++ { table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },++ { table: 'workflows', subject: 'created_by', file: null, erase: { keep: 'the workflow catalogue is the service\'s; created_by is the staff audit trail' } },++ // The person's own quota (server/quota.js): a limit that only means something while the account lives.++ { table: 'quotas', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null },++ // What the person used, counted against their allowance: the spend ledger, kept.++ { table: 'usage_counters', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null, erase: { keep: ledger } },++ // The audit trail of what was done (and by whom): kept, with the subject a tombstone.++ { table: 'audit_log', subject: 'actor', file: null, erase: { keep: 'the audit trail of what was done and by whom stays; the subject is a tombstone' } },++ // The marker of what was last pushed to the person's modules (server/user-modules.js): their own state.++ { table: 'ai_module_pushes', subject: 'subject_id', file: null },++];++++/** The runs AI made for the person, with their input and their answer. */++async function extraExport(db, subject) {++ const runs = await db.many(`SELECT ${RUN_COLUMNS} FROM runs WHERE ${PERSON_RUN} ORDER BY created_at DESC LIMIT 5000`, [subject]);++ return runs.length ? [{ name: 'runs.json', content: runs }] : [];++}++++/** The person's runs go, with the rows that hang under them (the requests, the citations, the cached answers). */++async function extraErase(t, subjects, counts) {++ const ids = (await t.many(`SELECT id FROM runs WHERE on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = ANY($1::text[])`, [subjects])).map((r) => r.id);++ if (!ids.length) return;++ counts.add(counts.erased, 'requests', await t.exec('DELETE FROM requests WHERE run_id = ANY($1::text[])', [ids]));++ counts.add(counts.erased, 'citations', await t.exec('DELETE FROM citations WHERE run_id = ANY($1::text[])', [ids]));++ counts.add(counts.erased, 'cache_entries', await t.exec('DELETE FROM cache_entries WHERE run_id = ANY($1::text[])', [ids]));++ counts.add(counts.erased, 'runs', await t.exec('DELETE FROM runs WHERE id = ANY($1::text[])', [ids]));++}++++function create({ db, log = console } = {}) {++ return createAccountData({ db, service: 'ai', tables: TABLES, extraExport, extraErase, log });++}++++module.exports = { create, TABLES, TOPICS, extraExport, extraErase };@@@ -17,10 +17,11 @@ const express = require('express');const { http } = require('openvibe-contracts');const { serviceAuth } = require('openvibe-contracts');const { parseDelivery } = require('openvibe-sdk/events');++const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');const TOPIC = 'bench.rating.changed';--function createBenchEvents({ bench, config, log = console, fetchImpl = globalThis.fetch, clientSecret = process.env.OV_OAUTH_CLIENT_SECRET }) {++function createBenchEvents({ bench, accountData = null, accountSend = null, config, log = console, fetchImpl = globalThis.fetch, clientSecret = process.env.OV_OAUTH_CLIENT_SECRET }) {const router = express.Router();const secrets = (config.events && config.events.secrets) || [];@@@ -46,6 +47,15 @@ function createBenchEvents({ bench, config, log = console, fetchImpl = globalThiif (!event || typeof event.event_id !== 'string' || !event.event_id) {return refused(res, 400, 'bench.bad_delivery', 'body must be { event: <envelope> }');}++ if (accountData && ACCOUNT_TOPICS.includes(event.event_type)) {++ try {++ const outcome = await accountData.apply(event, { send: accountSend });++ return res.status(200).json({ event_id: event.event_id, outcome });++ } catch (err) {++ log.warn(`[account-data] event ${event.event_id} (${event.event_type}) failed: ${err.message}`);++ return refused(res, 503, 'ai.account_retry', 'not applied; Events will retry');++ }++ }try {const out = await apply(event);return res.status(200).json({ event_id: event.event_id, ...out });@@@ -13,6 +13,8 @@ const { createRegistry } = require('./registry');const { createProviderPool } = require('./providers');const { createBenchEvidence } = require('./bench/evidence');const { createBenchEvents } = require('./bench/events');++const accountDataLib = require('./account-data');++const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');const { createFetcher } = require('./fetcher');const { createEngine } = require('./workflows/engine');const { createCache } = require('./cache');@@@ -73,7 +75,12 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowconst keys = createNetworkKeys({ config });const auth = createAuth({ config, log });// The Events consumer side: a signed bench.rating.changed webhook, subscribed at boot (server/bench/events.js).-- const benchEvents = createBenchEvents({ bench, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });++ const accountData = accountDataLib.create({ db, log });++ const accountSend = env.OV_OAUTH_CLIENT_SECRET ? createNetworkSender({++ networkInternalUrl: config.networkInternalUrl, clientId: config.console.clientId,++ 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, vault, benchEvents, runBudgets });let jwks = null;let keyLoaded = Promise.resolve(null);@@@ -95,6 +102,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowlet server = null;let benchSubscription = null;++ let accountSubscription = null;if (listen) userModules.start();if (listen) {server = await new Promise((resolve, reject) => {@@@ -106,6 +114,13 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowserver.requestTimeout = config.runs.maxWaitMs + 30000;log.log(`[ai] listening on http://${config.host}:${server.address().port} (${config.nodeEnv})`);benchSubscription = benchEvents.startSubscriptions({ port: server.address().port });++ if (config.events.subscribe && config.events.url && config.events.secrets.length && env.OV_OAUTH_CLIENT_SECRET) {++ accountSubscription = startSubscriptions({++ eventsUrl: config.events.url, endpoint: config.events.endpoint || `http://127.0.0.1:${server.address().port}/internal/events`,++ secret: config.events.secrets[0], networkInternalUrl: config.networkInternalUrl,++ clientId: config.console.clientId, clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl, log,++ });++ }const shared = await registry.getProvider('shared');log.log(`[ai] shared provider: ${shared.kind} ${shared.status}, credentials ${registry.publicProvider(shared).credentials}; stub fallback ${config.stubFallback ? 'on' : 'off'}`);}@@@ -113,6 +128,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.nowasync function close() {clearInterval(housekeeping);if (benchSubscription) benchSubscription.stop();++ if (accountSubscription) accountSubscription.stop();userModules.stop();if (jwks) jwks.stop();if (server) {nnew file mode 100644@@@ -0,0 +1,139 @@++'use strict';++/**++ * ADR-033: AI's part of an account export and deletion, through the signed POST /internal/events route with a++ * stand-in Network. Ada's console/person sessions and provider credentials go in her export, without the key; her++ * deletion erases them, keeps the free-allowance ledger and the staff-owned catalogues, and leaves Bo's rows. The++ * guard fails when a new table with a person column is neither covered nor exempted with a reason.++ */++const assert = require('assert');++const http = require('http');++const { uncovered, createNetworkSender } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, suite } = require('./helpers');++const { TABLES } = require('../server/account-data');++++const t = suite('account-data');++const SECRET = `whsec_${'fixture'.repeat(6)}`;++const A = 'usr_01JZ0000000000000000000AAA';++const B = 'usr_01JZ0000000000000000000BBB';++const EXP = 'exp_01JZ0000000000000000000EXP';++const DEL = 'del_01JZ0000000000000000000DEX';++++async function startNetworkStub() {++ const calls = [];++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_ai', token_type: 'Bearer', expires_in: 300 });++ calls.push({ url: req.url, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });++ return json(201, {});++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };++}++++const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });++++t.test('every table that names a person is covered or exempted with a reason', async () => {++ const stub = await startNetworkStub();++ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });++ try {++ const missing = await uncovered(h.db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });++ assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);++ } finally { await h.stop(); await stub.close(); }++});++++t.test('an export carries the person\'s sessions and credential hints, and a deletion erases them, keeps the ledger, and confirms once', async () => {++ const stub = await startNetworkStub();++ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });++ try {++ for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {++ await h.db.exec("INSERT INTO console_sessions (id_hash, subject, username, staff, csrf, created_at, expires_at) VALUES ($1, $2, $3, '{}', 'c', '2026-10-01', '2027-10-01')", [`h_${name}`, subject, name]);++ await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, $3, '2026-10-01', '2027-10-01')", [`p_${name}`, subject, name]);++ await h.db.exec("INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, created_at, updated_at) VALUES ('proj_1', $1, 'openai', 'v1:secret', $2, '{}', '2026-10-01', '2026-10-02')", [subject, `hint-${name}`]);++ await h.db.exec("INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at) VALUES ($1, 'openai', 'input-tokens', 1, 2, 10, '2026-10-01')", [subject]);++ await h.db.exec("INSERT INTO routes (key, version, primary_provider, created_by, created_at) VALUES ($1, 1, 'openai', $2, '2026-10-01')", [`route-${name}`, subject]);++ }++ const deliver = async (event) => {++ const body = JSON.stringify({ event, seq: 1 });++ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++++ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: EXP, subject: A }));++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);++ assert.ok(part, 'the part was pushed to Network');++ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));++ assert.strictEqual(files['console-sessions.json'].length, 1);++ assert.strictEqual(files['person-sessions.json'].length, 1);++ assert.strictEqual(files['provider-credentials.json'].length, 1);++ assert.strictEqual(files['provider-credentials.json'][0].key_hint, 'hint-ada');++ assert.ok(!JSON.stringify(part.body).includes('v1:secret'), 'the encrypted key is never exported');++ assert.ok(!JSON.stringify(part.body).includes(B), 'nobody else\'s rows');++++ const d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));++ assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM person_sessions WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM subject_credentials WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM free_allowance_usage WHERE subject = $1', [A])), 1, 'the allowance ledger stays');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM routes WHERE created_by = $1', [A])), 1, 'the staff catalogue stays');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [B])), 1, 'Bo stays');++ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);++ assert.ok(confirmation, 'the confirmation was sent');++ assert.ok(confirmation.body.erased.console_sessions >= 1);++ } finally { await h.stop(); await stub.close(); }++});++++t.test('their runs, quota and module marker go; the spend ledger and the audit trail stay', async () => {++ const stub = await startNetworkStub();++ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });++ try {++ for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {++ await h.db.exec(`INSERT INTO runs (id, workflow_key, workflow_version, status, requester_type, requester_id, on_behalf_of, input, input_hash, output, created_at)++ VALUES ($1, 'chat', 1, 'succeeded', 'service', 'live', $2, $3, $4, 'answer', '2026-10-01')`,++ [`run_${name}`, JSON.stringify({ type: 'user', id: subject }), `hello ${name}`, `h-${name}`]);++ await h.db.exec("INSERT INTO requests (run_id, operation, provider_key, status, created_at) VALUES ($1, 'chat', 'openai', 'ok', '2026-10-01')", [`run_${name}`]);++ await h.db.exec("INSERT INTO citations (run_id, source_type, title, created_at) VALUES ($1, 'web', 'a source', '2026-10-01')", [`run_${name}`]);++ await h.db.exec("INSERT INTO cache_entries (cache_key, scope, workflow_key, workflow_version, input_hash, privacy, output, run_id, created_at, expires_at) VALUES ($1, 'private', 'chat', 1, $2, 'private', 'cached', $3, '2026-10-01', 1)", [`ck_${name}`, `h-${name}`, `run_${name}`]);++ await h.db.exec("INSERT INTO quotas (scope_type, scope_id, \"window\", max_requests, created_at, updated_at) VALUES ('actor', $1, 'minute', 10, '2026-10-01', '2026-10-01')", [`user:${subject}`]);++ await h.db.exec("INSERT INTO usage_counters (scope_type, scope_id, \"window\", window_start, requests, tokens, cost_usd) VALUES ('actor', $1, 'minute', 1, 2, 30, 0.5)", [`user:${subject}`]);++ await h.db.exec("INSERT INTO audit_log (at, actor, action, target_type, target_id) VALUES ('2026-10-01', $1, 'credential.put', 'credential', 'x')", [subject]);++ await h.db.exec("INSERT INTO ai_module_pushes (subject_id, hash, pushed_at) VALUES ($1, 'hash', '2026-10-01')", [subject]);++ }++++ const deliver = async (event) => {++ const body = JSON.stringify({ event, seq: 1 });++ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++++ const r = await deliver(ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: EXP, subject: A }));++ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);++ const runs = part.body.files.find((f) => f.name === 'runs.json');++ assert.ok(runs, 'runs.json is in the part');++ assert.deepStrictEqual(runs.content.map((x) => x.id), ['run_ada']);++ assert.strictEqual(runs.content[0].input, 'hello ada');++++ const d = await deliver(ev('evt_01JZ0000000000000000000D02', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));++ assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM runs WHERE id = $1', ['run_ada'])), 0, 'her run is gone');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM requests WHERE run_id = $1', ['run_ada'])), 0, 'the request under it is gone');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM citations WHERE run_id = $1', ['run_ada'])), 0, 'the citation under it is gone');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM cache_entries WHERE run_id = $1', ['run_ada'])), 0, 'the cached answer is gone');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM quotas WHERE scope_id = $1', [`user:${A}`])), 0, 'her quota is gone');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM ai_module_pushes WHERE subject_id = $1', [A])), 0, 'her module marker is gone');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM usage_counters WHERE scope_id = $1', [`user:${A}`])), 1, 'the spend ledger stays');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM audit_log WHERE actor = $1', [A])), 1, 'the audit trail stays');++ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM runs WHERE id = $1', ['run_bo'])), 1, 'Bo\'s run stays');++ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);++ assert.ok(confirmation, 'the confirmation was sent');++ assert.strictEqual(confirmation.body.erased.runs, 1);++ assert.ok(confirmation.body.retained.usage_counters >= 1, 'the kept ledger is counted as retained');++ } finally { await h.stop(); await stub.close(); }++});
@@ -49,7 +49,7 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
@@ -49,7 +49,7 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
- OpenVibe.Media (recordings `media.analyze` reads, over allow-listed https URLs or signed URLs)
- OpenVibe.Media (recordings `media.analyze` reads, over allow-listed https URLs or signed URLs)
- provider APIs configured by the operator (OpenAI-compatible, OpenAI Responses, Anthropic, the HTTP seam), whisper.cpp
- provider APIs configured by the operator (OpenAI-compatible, OpenAI Responses, Anthropic, the HTTP seam), whisper.cpp
and the local model server `openvibe-llm.service` on this host
and the local model server `openvibe-llm.service` on this host
-- `openvibe-contracts` v0.86.0, `openvibe-sdk` v0.67.0 (service tokens, events outbox),
+- `openvibe-contracts` v0.86.0, `openvibe-sdk` v0.74.0 (service tokens, events outbox),
`openvibe-shared` v2.3.1, pinned by release tarball
`openvibe-shared` v2.3.1, pinned by release tarball
## Capabilities
## Capabilities
@@ -722,6 +722,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -722,6 +722,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.241.0
- openvibe-contracts: v0.241.0
-- openvibe-sdk: v0.67.0
+- openvibe-sdk: v0.74.0
- openvibe-shared: v4.48.0
- openvibe-shared: v4.48.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"port": 4700,
"port": 4700,
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
"domain": "ai.openvibe.services (the old ai.openvibe.network address was retired on 2026-10-10); the service runs on openvibe-ovh (/opt/openvibe.ai, unit openvibe-ai) on 127.0.0.1:4700 behind nginx; OpenVibe.Live and OpenVibe.Network call it on loopback",
"contracts": "openvibe-contracts v0.241.0",
"contracts": "openvibe-contracts v0.241.0",
- "sdk": "openvibe-sdk v0.67.0",
+ "sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.48.0",
"shared": "openvibe-shared v4.48.0",
"capabilities": [
"capabilities": [
"ai.run.create",
"ai.run.create",
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- OpenVibe.AI: the receipts of the ADR-033 account export and deletion deliveries this service applied
+-- (openvibe-sdk/account-data's ACCOUNT_DATA_SCHEMA), so a redelivered export or deletion changes nothing.
+-- The row holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.
+
+CREATE TABLE IF NOT EXISTS account_data_events (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ outcome JSONB,
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ sent_at TIMESTAMPTZ
+);
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1556,9 +1556,9 @@
@@ -1556,9 +1556,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.67.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "integrity": "sha512-s7NzjaNcowVcOIiup2z0ze+xsq/qnr++3pHSI8JzirqJPQ5JsBSUF5RkjOHyfk9bRKlmhY0fsNS6x4dsYylyqw==",
+ "version": "0.74.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
+ "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.241.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+/**
+ * Account export and deletion → AI (ADR-033; openvibe-sdk/account-data). What AI holds about a person:
+ *
+ * - their console and person sessions (Console sign-in and the AI chat), erased;
+ * - the provider credentials they brought (server/credentials.js): the encrypted key, its hint and the budget, erased
+ * with them (the key itself is never exported, only its hint);
+ * - their runs: the workflows AI ran for them (runs.on_behalf_of is their SubjectRef) and the requests, citations and
+ * private cache entries under them. Those are the person's own content, so they go with the account (ADR-033);
+ * - the free-allowance usage the allowance ledger counts (server/free-allowance.js) and the usage counters: kept,
+ * because the allowance and the spend must still reconcile across their period;
+ * - their quota (their own limit) and the marker of what was last pushed to their modules: settings, they go;
+ * - the route, template and workflow catalogues: the service's own, with created_by as the staff member's audit trail,
+ * and the audit log itself, which says what was done and by whom.
+ *
+ * Telemetry samples that name a person are covered by the SDK itself (telemetry.json, then subject NULL).
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const ledger = 'money records: the ledger must still balance and reconcile, and the books are kept as the billing policy requires';
+
+// The runs AI made for the person: their SubjectRef sits in the JSON column on_behalf_of.
+const PERSON_RUN = `on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = $1`;
+const RUN_COLUMNS = 'id, workflow_key, workflow_version, template_key, template_version, route_key, status, model_key, provider_key, input, output, citations_count, error_code, error_detail, tokens_in, tokens_out, cost_usd, created_at, started_at, finished_at';
+
+const TABLES = [
+ { table: 'console_sessions', subject: 'subject', file: 'console-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
+ { table: 'person_sessions', subject: 'subject', file: 'person-sessions.json', columns: ['username', 'created_at', 'expires_at', 'revoked_at'], order: 'created_at' },
+ { table: 'subject_credentials', subject: 'subject', file: 'provider-credentials.json', order: 'updated_at',
+ columns: ['owner', 'subject', 'provider', 'base_url', 'machine', 'key_hint', 'models', 'budget_usd_day', 'is_default', 'spend_key', 'created_at', 'updated_at', 'last_used_at'] },
+ { table: 'free_allowance_usage', subject: 'subject', file: 'free-allowance.json', erase: { keep: ledger } },
+ { table: 'routes', subject: 'created_by', file: null, erase: { keep: 'the route catalogue is the service\'s; created_by is the staff audit trail' } },
+ { table: 'templates', subject: 'created_by', file: null, erase: { keep: 'the template catalogue is the service\'s; created_by is the staff audit trail' } },
+ { table: 'workflows', subject: 'created_by', file: null, erase: { keep: 'the workflow catalogue is the service\'s; created_by is the staff audit trail' } },
+ // The person's own quota (server/quota.js): a limit that only means something while the account lives.
+ { table: 'quotas', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null },
+ // What the person used, counted against their allowance: the spend ledger, kept.
+ { table: 'usage_counters', subject: 'scope_id', value: (usr) => `user:${usr}`, file: null, erase: { keep: ledger } },
+ // The audit trail of what was done (and by whom): kept, with the subject a tombstone.
+ { table: 'audit_log', subject: 'actor', file: null, erase: { keep: 'the audit trail of what was done and by whom stays; the subject is a tombstone' } },
+ // The marker of what was last pushed to the person's modules (server/user-modules.js): their own state.
+ { table: 'ai_module_pushes', subject: 'subject_id', file: null },
+];
+
+/** The runs AI made for the person, with their input and their answer. */
+async function extraExport(db, subject) {
+ const runs = await db.many(`SELECT ${RUN_COLUMNS} FROM runs WHERE ${PERSON_RUN} ORDER BY created_at DESC LIMIT 5000`, [subject]);
+ return runs.length ? [{ name: 'runs.json', content: runs }] : [];
+}
+
+/** The person's runs go, with the rows that hang under them (the requests, the citations, the cached answers). */
+async function extraErase(t, subjects, counts) {
+ const ids = (await t.many(`SELECT id FROM runs WHERE on_behalf_of IS NOT NULL AND on_behalf_of::jsonb ->> 'type' = 'user' AND on_behalf_of::jsonb ->> 'id' = ANY($1::text[])`, [subjects])).map((r) => r.id);
+ if (!ids.length) return;
+ counts.add(counts.erased, 'requests', await t.exec('DELETE FROM requests WHERE run_id = ANY($1::text[])', [ids]));
+ counts.add(counts.erased, 'citations', await t.exec('DELETE FROM citations WHERE run_id = ANY($1::text[])', [ids]));
+ counts.add(counts.erased, 'cache_entries', await t.exec('DELETE FROM cache_entries WHERE run_id = ANY($1::text[])', [ids]));
+ counts.add(counts.erased, 'runs', await t.exec('DELETE FROM runs WHERE id = ANY($1::text[])', [ids]));
+}
+
+function create({ db, log = console } = {}) {
+ return createAccountData({ db, service: 'ai', tables: TABLES, extraExport, extraErase, log });
+}
+
+module.exports = { create, TABLES, TOPICS, extraExport, extraErase };
@@ -17,10 +17,11 @@ const express = require('express');
@@ -17,10 +17,11 @@ const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { parseDelivery } = require('openvibe-sdk/events');
const { parseDelivery } = require('openvibe-sdk/events');
+const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
const TOPIC = 'bench.rating.changed';
const TOPIC = 'bench.rating.changed';
-function createBenchEvents({ bench, config, log = console, fetchImpl = globalThis.fetch, clientSecret = process.env.OV_OAUTH_CLIENT_SECRET }) {
+function createBenchEvents({ bench, accountData = null, accountSend = null, config, log = console, fetchImpl = globalThis.fetch, clientSecret = process.env.OV_OAUTH_CLIENT_SECRET }) {
const router = express.Router();
const router = express.Router();
const secrets = (config.events && config.events.secrets) || [];
const secrets = (config.events && config.events.secrets) || [];
@@ -46,6 +47,15 @@ function createBenchEvents({ bench, config, log = console, fetchImpl = globalThi
@@ -46,6 +47,15 @@ function createBenchEvents({ bench, config, log = console, fetchImpl = globalThi
if (!event || typeof event.event_id !== 'string' || !event.event_id) {
if (!event || typeof event.event_id !== 'string' || !event.event_id) {
return refused(res, 400, 'bench.bad_delivery', 'body must be { event: <envelope> }');
return refused(res, 400, 'bench.bad_delivery', 'body must be { event: <envelope> }');
}
}
+ if (accountData && ACCOUNT_TOPICS.includes(event.event_type)) {
+ try {
+ const outcome = await accountData.apply(event, { send: accountSend });
+ return res.status(200).json({ event_id: event.event_id, outcome });
+ } catch (err) {
+ log.warn(`[account-data] event ${event.event_id} (${event.event_type}) failed: ${err.message}`);
+ return refused(res, 503, 'ai.account_retry', 'not applied; Events will retry');
+ }
+ }
try {
try {
const out = await apply(event);
const out = await apply(event);
return res.status(200).json({ event_id: event.event_id, ...out });
return res.status(200).json({ event_id: event.event_id, ...out });
@@ -13,6 +13,8 @@ const { createRegistry } = require('./registry');
@@ -13,6 +13,8 @@ const { createRegistry } = require('./registry');
const { createProviderPool } = require('./providers');
const { createProviderPool } = require('./providers');
const { createBenchEvidence } = require('./bench/evidence');
const { createBenchEvidence } = require('./bench/evidence');
const { createBenchEvents } = require('./bench/events');
const { createBenchEvents } = require('./bench/events');
+const accountDataLib = require('./account-data');
+const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');
const { createFetcher } = require('./fetcher');
const { createFetcher } = require('./fetcher');
const { createEngine } = require('./workflows/engine');
const { createEngine } = require('./workflows/engine');
const { createCache } = require('./cache');
const { createCache } = require('./cache');
@@ -73,7 +75,12 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -73,7 +75,12 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
const keys = createNetworkKeys({ config });
const keys = createNetworkKeys({ config });
const auth = createAuth({ config, log });
const auth = createAuth({ config, log });
// The Events consumer side: a signed bench.rating.changed webhook, subscribed at boot (server/bench/events.js).
// The Events consumer side: a signed bench.rating.changed webhook, subscribed at boot (server/bench/events.js).
- const benchEvents = createBenchEvents({ bench, config, log, fetchImpl, clientSecret: env.OV_OAUTH_CLIENT_SECRET });
+ const accountData = accountDataLib.create({ db, log });
+ const accountSend = env.OV_OAUTH_CLIENT_SECRET ? createNetworkSender({
+ networkInternalUrl: config.networkInternalUrl, clientId: config.console.clientId,
+ 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, vault, benchEvents, runBudgets });
const app = createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env, clock, fetchImpl, log, credentials, vault, benchEvents, runBudgets });
let jwks = null;
let jwks = null;
let keyLoaded = Promise.resolve(null);
let keyLoaded = Promise.resolve(null);
@@ -95,6 +102,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -95,6 +102,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
let server = null;
let server = null;
let benchSubscription = null;
let benchSubscription = null;
+ let accountSubscription = null;
if (listen) userModules.start();
if (listen) userModules.start();
if (listen) {
if (listen) {
server = await new Promise((resolve, reject) => {
server = await new Promise((resolve, reject) => {
@@ -106,6 +114,13 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -106,6 +114,13 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
server.requestTimeout = config.runs.maxWaitMs + 30000;
server.requestTimeout = config.runs.maxWaitMs + 30000;
log.log(`[ai] listening on http://${config.host}:${server.address().port} (${config.nodeEnv})`);
log.log(`[ai] listening on http://${config.host}:${server.address().port} (${config.nodeEnv})`);
benchSubscription = benchEvents.startSubscriptions({ port: server.address().port });
benchSubscription = benchEvents.startSubscriptions({ port: server.address().port });
+ if (config.events.subscribe && config.events.url && config.events.secrets.length && env.OV_OAUTH_CLIENT_SECRET) {
+ accountSubscription = startSubscriptions({
+ eventsUrl: config.events.url, endpoint: config.events.endpoint || `http://127.0.0.1:${server.address().port}/internal/events`,
+ secret: config.events.secrets[0], networkInternalUrl: config.networkInternalUrl,
+ clientId: config.console.clientId, clientSecret: env.OV_OAUTH_CLIENT_SECRET, fetch: fetchImpl, log,
+ });
+ }
const shared = await registry.getProvider('shared');
const shared = await registry.getProvider('shared');
log.log(`[ai] shared provider: ${shared.kind} ${shared.status}, credentials ${registry.publicProvider(shared).credentials}; stub fallback ${config.stubFallback ? 'on' : 'off'}`);
log.log(`[ai] shared provider: ${shared.kind} ${shared.status}, credentials ${registry.publicProvider(shared).credentials}; stub fallback ${config.stubFallback ? 'on' : 'off'}`);
}
}
@@ -113,6 +128,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
@@ -113,6 +128,7 @@ async function start({ config, db: givenDb = null, clock = { now: () => Date.now
async function close() {
async function close() {
clearInterval(housekeeping);
clearInterval(housekeeping);
if (benchSubscription) benchSubscription.stop();
if (benchSubscription) benchSubscription.stop();
+ if (accountSubscription) accountSubscription.stop();
userModules.stop();
userModules.stop();
if (jwks) jwks.stop();
if (jwks) jwks.stop();
if (server) {
if (server) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+'use strict';
+/**
+ * ADR-033: AI's part of an account export and deletion, through the signed POST /internal/events route with a
+ * stand-in Network. Ada's console/person sessions and provider credentials go in her export, without the key; her
+ * deletion erases them, keeps the free-allowance ledger and the staff-owned catalogues, and leaves Bo's rows. The
+ * guard fails when a new table with a person column is neither covered nor exempted with a reason.
+ */
+const assert = require('assert');
+const http = require('http');
+const { uncovered, createNetworkSender } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, suite } = require('./helpers');
+const { TABLES } = require('../server/account-data');
+
+const t = suite('account-data');
+const SECRET = `whsec_${'fixture'.repeat(6)}`;
+const A = 'usr_01JZ0000000000000000000AAA';
+const B = 'usr_01JZ0000000000000000000BBB';
+const EXP = 'exp_01JZ0000000000000000000EXP';
+const DEL = 'del_01JZ0000000000000000000DEX';
+
+async function startNetworkStub() {
+ const calls = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_ai', token_type: 'Bearer', expires_in: 300 });
+ calls.push({ url: req.url, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });
+ return json(201, {});
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
+}
+
+const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
+
+t.test('every table that names a person is covered or exempted with a reason', async () => {
+ const stub = await startNetworkStub();
+ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
+ try {
+ const missing = await uncovered(h.db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion AI applied' } });
+ assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
+ } finally { await h.stop(); await stub.close(); }
+});
+
+t.test('an export carries the person\'s sessions and credential hints, and a deletion erases them, keeps the ledger, and confirms once', async () => {
+ const stub = await startNetworkStub();
+ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
+ try {
+ for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {
+ await h.db.exec("INSERT INTO console_sessions (id_hash, subject, username, staff, csrf, created_at, expires_at) VALUES ($1, $2, $3, '{}', 'c', '2026-10-01', '2027-10-01')", [`h_${name}`, subject, name]);
+ await h.db.exec("INSERT INTO person_sessions (id_hash, subject, username, created_at, expires_at) VALUES ($1, $2, $3, '2026-10-01', '2027-10-01')", [`p_${name}`, subject, name]);
+ await h.db.exec("INSERT INTO subject_credentials (owner, subject, provider, key_enc, key_hint, models, created_at, updated_at) VALUES ('proj_1', $1, 'openai', 'v1:secret', $2, '{}', '2026-10-01', '2026-10-02')", [subject, `hint-${name}`]);
+ await h.db.exec("INSERT INTO free_allowance_usage (subject, provider, metric, period_start, period_end, free_used, updated_at) VALUES ($1, 'openai', 'input-tokens', 1, 2, 10, '2026-10-01')", [subject]);
+ await h.db.exec("INSERT INTO routes (key, version, primary_provider, created_by, created_at) VALUES ($1, 1, 'openai', $2, '2026-10-01')", [`route-${name}`, subject]);
+ }
+ const deliver = async (event) => {
+ const body = JSON.stringify({ event, seq: 1 });
+ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+
+ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: EXP, subject: A }));
+ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);
+ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
+ assert.ok(part, 'the part was pushed to Network');
+ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
+ assert.strictEqual(files['console-sessions.json'].length, 1);
+ assert.strictEqual(files['person-sessions.json'].length, 1);
+ assert.strictEqual(files['provider-credentials.json'].length, 1);
+ assert.strictEqual(files['provider-credentials.json'][0].key_hint, 'hint-ada');
+ assert.ok(!JSON.stringify(part.body).includes('v1:secret'), 'the encrypted key is never exported');
+ assert.ok(!JSON.stringify(part.body).includes(B), 'nobody else\'s rows');
+
+ const d = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));
+ assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM person_sessions WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM subject_credentials WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM free_allowance_usage WHERE subject = $1', [A])), 1, 'the allowance ledger stays');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM routes WHERE created_by = $1', [A])), 1, 'the staff catalogue stays');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM console_sessions WHERE subject = $1', [B])), 1, 'Bo stays');
+ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
+ assert.ok(confirmation, 'the confirmation was sent');
+ assert.ok(confirmation.body.erased.console_sessions >= 1);
+ } finally { await h.stop(); await stub.close(); }
+});
+
+t.test('their runs, quota and module marker go; the spend ledger and the audit trail stay', async () => {
+ const stub = await startNetworkStub();
+ const h = await boot({ env: { AI_EVENTS_SECRET: SECRET, AI_EVENTS_SUBSCRIBE: '0', OV_NETWORK_INTERNAL_URL: stub.url, OV_OAUTH_CLIENT_SECRET: 'ai-secret' } });
+ try {
+ for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {
+ await h.db.exec(`INSERT INTO runs (id, workflow_key, workflow_version, status, requester_type, requester_id, on_behalf_of, input, input_hash, output, created_at)
+ VALUES ($1, 'chat', 1, 'succeeded', 'service', 'live', $2, $3, $4, 'answer', '2026-10-01')`,
+ [`run_${name}`, JSON.stringify({ type: 'user', id: subject }), `hello ${name}`, `h-${name}`]);
+ await h.db.exec("INSERT INTO requests (run_id, operation, provider_key, status, created_at) VALUES ($1, 'chat', 'openai', 'ok', '2026-10-01')", [`run_${name}`]);
+ await h.db.exec("INSERT INTO citations (run_id, source_type, title, created_at) VALUES ($1, 'web', 'a source', '2026-10-01')", [`run_${name}`]);
+ await h.db.exec("INSERT INTO cache_entries (cache_key, scope, workflow_key, workflow_version, input_hash, privacy, output, run_id, created_at, expires_at) VALUES ($1, 'private', 'chat', 1, $2, 'private', 'cached', $3, '2026-10-01', 1)", [`ck_${name}`, `h-${name}`, `run_${name}`]);
+ await h.db.exec("INSERT INTO quotas (scope_type, scope_id, \"window\", max_requests, created_at, updated_at) VALUES ('actor', $1, 'minute', 10, '2026-10-01', '2026-10-01')", [`user:${subject}`]);
+ await h.db.exec("INSERT INTO usage_counters (scope_type, scope_id, \"window\", window_start, requests, tokens, cost_usd) VALUES ('actor', $1, 'minute', 1, 2, 30, 0.5)", [`user:${subject}`]);
+ await h.db.exec("INSERT INTO audit_log (at, actor, action, target_type, target_id) VALUES ('2026-10-01', $1, 'credential.put', 'credential', 'x')", [subject]);
+ await h.db.exec("INSERT INTO ai_module_pushes (subject_id, hash, pushed_at) VALUES ($1, 'hash', '2026-10-01')", [subject]);
+ }
+
+ const deliver = async (event) => {
+ const body = JSON.stringify({ event, seq: 1 });
+ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+
+ const r = await deliver(ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: EXP, subject: A }));
+ assert.deepStrictEqual([r.status, r.json.outcome], [200, 'exported']);
+ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
+ const runs = part.body.files.find((f) => f.name === 'runs.json');
+ assert.ok(runs, 'runs.json is in the part');
+ assert.deepStrictEqual(runs.content.map((x) => x.id), ['run_ada']);
+ assert.strictEqual(runs.content[0].input, 'hello ada');
+
+ const d = await deliver(ev('evt_01JZ0000000000000000000D02', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));
+ assert.deepStrictEqual([d.status, d.json.outcome], [200, 'erased']);
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM runs WHERE id = $1', ['run_ada'])), 0, 'her run is gone');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM requests WHERE run_id = $1', ['run_ada'])), 0, 'the request under it is gone');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM citations WHERE run_id = $1', ['run_ada'])), 0, 'the citation under it is gone');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM cache_entries WHERE run_id = $1', ['run_ada'])), 0, 'the cached answer is gone');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM quotas WHERE scope_id = $1', [`user:${A}`])), 0, 'her quota is gone');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM ai_module_pushes WHERE subject_id = $1', [A])), 0, 'her module marker is gone');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM usage_counters WHERE scope_id = $1', [`user:${A}`])), 1, 'the spend ledger stays');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM audit_log WHERE actor = $1', [A])), 1, 'the audit trail stays');
+ assert.strictEqual(Number(await h.db.value('SELECT count(*)::int FROM runs WHERE id = $1', ['run_bo'])), 1, 'Bo\'s run stays');
+ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
+ assert.ok(confirmation, 'the confirmation was sent');
+ assert.strictEqual(confirmation.body.erased.runs, 1);
+ assert.ok(confirmation.body.retained.usage_counters >= 1, 'the kept ledger is counted as retained');
+ } finally { await h.stop(); await stub.close(); }
+});