openvibers/openvibe.actor Commits
Actor: account export and deletion (ADR-033) — a person's tasks and daily spend are exported on network.account.export_requested and deleted whole on network.account.deleted (openvibe-sdk/account-data, SDK v0.36.0), with /internal/events loopback-only and the two subscriptions created at boot
Changes
@@@ -24,6 +24,13 @@ OV_OAUTH_CLIENT_SECRET=# OV_OAUTH_REDIRECT_URI=https://openvibe.actor/auth/callback# ACTOR_AUDIENCE=openvibe.actor++# OpenVibe.Events → Actor (ADR-033 account export and deletion): the secret signs a delivery (comma-separated for++# rotation, 32+ characters each; unset makes POST /internal/events answer 503); the url is where the two subscriptions++# are created at boot (off when unset); ACTOR_EVENTS_SUBSCRIBE=0 turns that off.++ACTOR_EVENTS_SECRET=++ACTOR_EVENTS_URL=++#ACTOR_EVENTS_ENDPOINT=http://127.0.0.1:4950/internal/events++# The model providers behind the agents (server/agents/catalog.js). An agent without its key is listed as unavailable.ACTOR_DEEPSEEK_API_KEY=# ACTOR_DEEPSEEK_MODEL=deepseek-flash@@@ -81,7 +81,6 @@ Rate cards are typed in by a person from each provider's price page, with the pa2. Coding through OpenVibe.Codes on hosted Run sandboxes.3. More agent platforms behind the same router, bring-your-own keys through OpenVibe.AI, and paid tiers through Billing.4. Personal agents (`agt_` principals on the Network): memory people control, schedules and Events triggers, an approval inbox, reachable from Chat and the Frame.--5. Account export and deletion (ADR-033). Until then, tasks expire after 30 days.## Configuration@@@ -111,6 +110,29 @@ Tests:- [test/tasks.test.js](test/tasks.test.js) runs a task end to end through the real adapters against stand-in providers, Tools and Search. It covers routing per class and mode, the cross-family check, hand-off on an error or a failed check, private mode never sending the text outside, hard budgets and the free tier, idempotency, cancel, the live stream, visibility, cross-site writes, and app tokens with capabilities.- The other files cover sign-in (PKCE, state, next=), discovery, the released contracts manifest matching the routes, per-caller limits, readiness, secrets never stored or logged, Actor never fetching a URL from a task, and the home page's size budgets.++## Account export and deletion++++A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers its++part (ADR-033). Actor receives `network.account.export_requested` and `network.account.deleted` at `POST /internal/events`++(loopback only) — the two tables are mapped in [server/identity/account-data.js](server/identity/account-data.js), and++the boot-time subscriptions are created by [server/events-consumer.js](server/events-consumer.js):++++- **Exported:** the tasks a person asked for (`tasks.json`) and what they spent per UTC day (`spend.json`), pushed to++ `POST /internal/account-exports/:id/parts` with this service's own token. Nothing here is a secret — Actor stores no++ token, key or credential.++- **Erased:** both tables hold the person's own rows, so they are deleted whole and nothing is kept; a task's event log++ (`task_events`) goes with the task by cascade. Actor then confirms with `POST /internal/account-deletions/:id/confirmations`++ and the counts.++- **Anonymized:** nothing. There is no row Actor keeps that was written by this person for another person to read.++++A task an app, agent or service ran for this person is not matched: its requester is `app:…`/`agent:…`/`service:…`, and++Network's deletion event carries only the person's `usr_…`, so only rows requested as `user:usr_…` are erased. The spend++row that does not name the person (`*`, the operator's daily ceiling) is a separate row and is kept.++++Environment: `ACTOR_EVENTS_SECRET` (comma-separated for rotation, 32+ characters each; unset makes the route answer++503), `ACTOR_EVENTS_URL` (or `EVENTS_URL`) is where the two subscriptions are created at boot (off when unset), and++`ACTOR_EVENTS_ENDPOINT` overrides the loopback endpoint; `ACTOR_EVENTS_SUBSCRIBE=0` turns the boot-time subscription off.++## Security (threat notes)Reporting a vulnerability: [SECURITY.md](SECURITY.md).@@@ -126,6 +148,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.115.0--- openvibe-sdk: v0.35.2++- openvibe-sdk: v0.36.0- openvibe-shared: v2.15.0<!-- versions:end -->@@@ -8,11 +8,11 @@"port": 4950,"domain": "openvibe.actor","contracts": "openvibe-contracts v0.115.0",-- "sdk": "openvibe-sdk v0.35.2",++ "sdk": "openvibe-sdk v0.36.0","shared": "openvibe-shared v2.15.0","packages": ["openvibe-contracts v0.115.0",-- "openvibe-sdk v0.35.2",++ "openvibe-sdk v0.36.0","openvibe-shared v2.13.3"],"summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.",@@@ -26,15 +26,15 @@"Sign-in with OpenVibe.Network (PKCE); people's own tokens and app tokens with actor.task.* capabilities","The public agent catalog with rate cards (GET /api/v1/agents) and a dry-run router (POST /api/v1/route)","Free tier per person and an operator daily ceiling; costs metered per call at the published rate cards",-- "Pages: home with the task box, your tasks, a task's live page, the agents, the API reference, updates; robots, sitemap, llms.txt"++ "Pages: home with the task box, your tasks, a task's live page, the agents, the API reference, updates; robots, sitemap, llms.txt",++ "Account export and deletion (ADR-033): the tasks a person asked for and their daily spend are exported to OpenVibe.Network on network.account.export_requested and deleted whole on network.account.deleted (a task's event log goes with it by cascade, and the operator's `*` spend ceiling stays), with the counts confirmed back; the two Events topics are subscribed at boot (server/identity/account-data.js)"],"notYet": ["Browser and desktop agents on OpenVibe's own servers (OpenVibe.Run workers), watchable and taken over","Coding tasks through OpenVibe.Codes on hosted sandboxes (listed as unavailable with the reason)","More agent platforms, bring-your-own keys through OpenVibe.AI, paid tiers through OpenVibe.Billing","Personal agents: memory, schedules, Events triggers, approvals, reach from Chat and the Frame",-- "Webhooks on task state changes",-- "Account export and deletion (ADR-033); tasks expire after 30 days meanwhile"++ "Webhooks on task state changes"],"capabilitiesRegistered": true,"serviceManifestRegistered": true,@@@ -111,6 +111,10 @@ server {# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4950/metrics) ──location = /metrics { return 404; }++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──++ # Events posts to http://127.0.0.1:4950/internal/events directly. Never proxied from outside (ADR-033).++ location /internal/ { return 404; }++# ── Pages, docs, robots.txt, sitemap, static assets ─────location / {proxy_pass http://127.0.0.1:4950;nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- OpenVibe.Actor: 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++);@@@ -16,7 +16,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"},@@@ -887,9 +887,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.35.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",-- "integrity": "sha512-HudKZJK3lZ9i0+yRdMrz9pJmIrYNTN8AFqbHFCz5jDCF68CfZwUJQyhE61pHODzGRyTvUi4DtAzkd0WEzIWx/g==",++ "version": "0.36.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",++ "integrity": "sha512-mb+VvCuFH3KnMw4LWvqMDEoF93rNEb+eQi8HfT6mj+RPN5eQFWMcc/DCqJ3L0Yzjr99tQ08+DLwaYdZDIlfD0A==","license": "MIT","engines": {"node": ">=20"@@@ -28,7 +28,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"},@@@ -30,6 +30,8 @@ const { createApi } = require('./http/api');const { createPageRoutes } = require('./http/pages');const { createActorReadiness } = require('./observability');const { createCallerLimits } = require('./http/caller-limits');++const accountDataLib = require('./identity/account-data');++const { createNetworkSender } = require('openvibe-sdk/account-data');const { assetVersion, send } = require('./render/layout');const { html } = require('./render/html');@@@ -38,7 +40,8 @@ const VERSION = require('../package.json').version;/*** opts: config, store, now (clock), fetchImpl (Network), providerFetch (model providers and OpenVibe services), log,-- * limitsNow, callerLimits (false: count nobody, tests only), valkey, adapters (tests: stand-in agents)++ * limitsNow, callerLimits (false: count nobody, tests only), valkey, adapters (tests: stand-in agents),++ * accountSend (a stand-in for Network's internal routes; tests only)*/async function createApp(opts = {}) {const config = opts.config || configLib.load();@@@ -54,6 +57,18 @@ async function createApp(opts = {}) {const principal = createPrincipal({ config, keys });const ctx = { config, s, keys, sso, stream, adapters, engine, principal, log };++ // Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.++ // The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;++ // a test injects a stand-in through opts.accountSend.++ const accountData = accountDataLib.create({ db: s.db, log });++ // Without a client secret the service has no way to push a part or a confirmation: the route still answers++ // (bad signature, a forwarded request, no secret), and an event that really arrives asks Events to retry.++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Actor cannot answer account events'); });++ ctx.accountData = accountData;++ ctx.accountSend = accountSend;++const app = express();app.disable('x-powered-by');app.set('trust proxy', config.trustProxy);@@@ -105,6 +120,12 @@ async function createApp(opts = {}) {app.use('/auth', sso.routes());{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'actor', service: 'actor', host: 'openvibe.actor', name: 'OpenVibe.Actor', profile: 'ugc' })); }++ // ── OpenVibe.Events → this service (loopback only) ──────++ // network.account.export_requested and network.account.deleted (ADR-033), answered by the SDK's consumer. It++ // reads the raw body itself, so it is mounted before any body parser (there is none above it), and nginx answers++ // 404 for /internal/ so this is reachable only on 127.0.0.1.++ app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));++// ── Static assets (content-hashed ?v= → immutable) ──────app.use('/shared', require('openvibe-shared/serve').handler());app.use(express.static(PUBLIC_DIR, {@@@ -79,6 +79,16 @@ function load(env = process.env) {maxRunning: Math.max(1, int(env.ACTOR_MAX_RUNNING, 4)),// A task that runs longer than this is stopped and fails (actor.task.timeout).taskTimeoutMs: Math.max(10_000, int(env.ACTOR_TASK_TIMEOUT_MS, 180_000)),++++ // OpenVibe.Events → this service (server/events-consumer.js). The secret signs a delivery (comma-separated++ // for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the++ // network.account.export_requested and network.account.deleted subscriptions are created at boot, off when++ // unset. Both topics are ADR-033: account export and deletion (server/identity/account-data.js).++ events: {++ secrets: String(env.ACTOR_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ url: trim(env.ACTOR_EVENTS_URL || env.EVENTS_URL || ''),++ endpoint: env.ACTOR_EVENTS_ENDPOINT || '',++ },};}nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++++/**++ * OpenVibe.Events → Actor. Actor has no consumer of its own: the two topics it takes are account export and deletion++ * (ADR-033), and POST /internal/events is answered by openvibe-sdk/account-data's own consumer, mounted in++ * server/app.js over server/identity/account-data.js (the table map). What is left here is the boot-time half:++ * startSubscriptions() creates the two subscriptions at Events if they are missing, idempotently, retried with++ * backoff, and off when the URL or the secret is unset (QUEST_EVENTS_SUBSCRIBE=0 style: ACTOR_EVENTS_SUBSCRIBE=0).++ *++ * The endpoint is this service's loopback /internal/events (Events posts to http://127.0.0.1:<port> directly; nginx++ * answers 404 there), and the token is this service's own for audience openvibe.events with++ * scope events.subscription.manage.++ */++const { serviceAuth } = require('openvibe-contracts');++const { TOPICS } = require('./identity/account-data');++++/** Create any missing subscription for TOPICS at Events (idempotent; retried in the background at boot). */++function startSubscriptions({ config, port, secret, eventsUrl = config.events.url, fetchImpl = globalThis.fetch, log = console }) {++ if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.ACTOR_EVENTS_SUBSCRIBE === '0') return null;++ const base = String(eventsUrl).replace(/\/+$/, '');++ const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;++ const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.events', scope: 'events.subscription.manage', fetchImpl });++ const call = async (method, path, body) => {++ const res = await fetchImpl(`${base}${path}`, { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000) });++ const json = await res.json().catch(() => ({}));++ return { status: res.status, ok: res.ok, body: json };++ };++ const attempt = async () => {++ const listed = await call('GET', '/api/v1/subscriptions');++ if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);++ const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);++ for (const topic of TOPICS) {++ if (mine.some((s) => s.topic_pattern === topic)) continue;++ const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });++ if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);++ if (r.ok) log.log(`[Events consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);++ }++ };++ const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];++ let i = 0;++ let timer = null;++ let stopped = false;++ const run = () => { timer = null; if (stopped) return; attempt().catch((err) => {++ if (stopped) return;++ if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Events consumer] subscriptions not created:', err.message);++ }); };++ timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();++ // Graceful stop: no further attempts (they run again at the next start).++ return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };++}++++module.exports = { startSubscriptions, TOPICS };nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++++/**++ * Account export and deletion → Actor (ADR-033; openvibe-sdk/account-data). Actor holds two things a person made:++ * the tasks they asked for and what they spent per UTC day, both keyed `requester = user:usr_…` (server/http/++ * principal.js builds the requester that way, server/tasks/store.js writes it). Both are the person's own.++ *++ * network.account.export_requested the person's tasks (tasks.json) and their per-day spend (spend.json), newest++ * first, pushed to Network (POST /internal/account-exports/:id/parts) with this++ * service's token.++ * network.account.deleted both go, and Actor confirms with counts.++ *++ * A task an app, agent or service ran for this person is NOT matched: its requester is `app:…`/`agent:…`/`service:…`,++ * and Network's deletion event carries only the person's `usr_…`, so only rows requested as `user:usr_…` are erased.++ * task_events holds no person column at all — its task_id REFERENCES tasks(id) ON DELETE CASCADE — so a task's event++ * log goes with the task and task_events is not in the map. Nothing here is a secret: no token, key or credential is++ * stored, so every column of both tables may be exported.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++/**++ * The tables that hold a person's rows, with the value the subject column really stores. Tasks and daily spend both++ * store `user:usr_…` (server/tasks/store.js takes the requester straight from the principal).++ */++const TABLES = [++ { table: 'tasks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tasks.json' },++ // The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a++ // reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is++ // the operator's total, a separate row.++ { table: 'spend_daily', subject: 'requester', value: (usr) => `user:${usr}`, file: 'spend.json' },++];++++/** The account-data handle for Actor's store (server/db.js createStore). */++function create({ db, note = 'A task row holds the task the person asked for, in their own words.', log = console } = {}) {++ return createAccountData({ db, service: 'actor', tables: TABLES, note, log });++}++++module.exports = { create, TABLES, TOPICS };@@@ -12,13 +12,14 @@ const PRUNE_EVERY_MS = 6 * 60 * 60_000;/*** The process stop (openvibe-sdk/service): the HTTP drain runs, then running tasks are stopped and recorded as-- * interrupted (the person can ask again; nothing re-runs behind their back), the JWKS refresher stops and the store-- * closes. Exported so a test can inject `exit` and `signals: false`.++ * interrupted (the person can ask again; nothing re-runs behind their back), the Events subscriptions stop, the JWKS++ * refresher stops and the store closes. Exported so a test can inject `exit` and `signals: false`. `extra` is what the++ * product adds (the Events subscriptions); a test that passes none keeps the skeleton's order.*/--function createLifecycle({ server, ctx, exit, signals, timers = [] }) {++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {return gracefulStop({name: 'Actor', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,-- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.engine.stop(), () => ctx.keys.client.stop(), () => ctx.s.close()],++ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.engine.stop(), () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],});}@@@ -39,7 +40,11 @@ async function start() {const prune = setInterval(() => { store.prune(ctx.s).catch((err) => console.warn('[Actor] prune failed:', err && err.message)); }, PRUNE_EVERY_MS);prune.unref();-- createLifecycle({ server, ctx, timers: [prune] });++ // Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without ACTOR_EVENTS_URL and++ // ACTOR_EVENTS_SECRET). The consumer itself is mounted in server/app.js.++ const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });++ const extra = [() => { if (subscriptions) subscriptions.stop(); }];++ createLifecycle({ server, ctx, timers: [prune], extra });return { server, ctx };}nnew file mode 100644@@@ -0,0 +1,139 @@++'use strict';++/**++ * ADR-033: Actor's part of an account export (a person's tasks and their daily spend) and of an account deletion,++ * applied through the service's own /internal/events route with a stand-in Network. Real rows are made through the++ * service's store, the signed delivery is answered by openvibe-sdk/account-data, and the part only ever carries person++ * A's rows. A redelivery erases nothing twice, and the route refuses a bad signature and a forwarded request.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++const { createNetworkSender } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const taskStore = require('../server/tasks/store');++++const A = 'usr_01JZ0000000000000000000AAA';++const B = 'usr_01JZ0000000000000000000BBB';++const OLD = 'usr_01JZ0000000000000000000MRG';++const EXP = 'exp_01JZ0000000000000000000EXP';++const DEL = 'del_01JZ0000000000000000000DEX';++// Fixture secrets, built so they never look like a real key to a scanner.++const SECRET = `whsec_${'fixture'.repeat(6)}`;++const WRONG = `whsec_${'mismatch'.repeat(5)}`;++++const exportEvent = { event_id: 'evt_01JZ0000000000000000000E01', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };++const deleteEvent = { event_id: 'evt_01JZ0000000000000000000D01', event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [OLD] } };++const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });++++/** A stand-in for Network's internal routes: the token endpoint, the export part and the deletion confirmation. */++async function startNetworkStub({ partStatus = 201, confirmStatus = 201 } = {}) {++ const calls = [];++ const statusOf = (v) => (typeof v === 'function' ? v() : v);++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ 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_actor', token_type: 'Bearer', expires_in: 300, scope: 'openvibe.network' });++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(raw.toString() || 'null') });++ return json(req.url.includes('/parts') ? statusOf(partStatus) : statusOf(confirmStatus), {});++ });++ });++ 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)) };++}++++/**++ * Make a task and one of its events for one person, through the store the API writes with (server/tasks/store.js).++ * insertTask also writes the person's spend row for today and bumps the requester `*` row, so a spend row exists too.++ */++async function makeTask(t, subject, title) {++ const s = t.ctx.s;++ const id = s.newId('tsk');++ await taskStore.insertTask(s, { id, requester: `user:${subject}`, task: title, mode: 'balanced', budget_task: 0.05, budget_day: 0.25, created_at: s.iso() });++ await taskStore.appendEvent(s, id, { kind: 'state', state: 'running' });++ return id;++}++++(async () => {++ await check('an export part carries only the person\'s tasks and daily spend, and no secret', async () => {++ const stub = await startNetworkStub();++ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'actor', clientSecret: 'actor-secret' });++ const t = await boot({ env: { ACTOR_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ await makeTask(t, A, 'person-a');++ await makeTask(t, B, 'person-b');++++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.json().outcome, 'exported');++++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);++ assert.ok(part, 'the part was pushed to Network');++ assert.strictEqual(part.auth, 'Bearer tok_actor', 'with this service\'s own token');++ assert.strictEqual(part.body.subject, A);++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['spend.json', 'tasks.json']);++ const tasks = part.body.files.find((f) => f.name === 'tasks.json').content;++ assert.strictEqual(tasks.length, 1);++ assert.strictEqual(tasks[0].task, 'person-a');++ const spend = part.body.files.find((f) => f.name === 'spend.json').content;++ assert.strictEqual(spend.length, 1, 'only A\'s spend row, not B\'s and not the `*` row');++ assert.strictEqual(spend[0].requester, `user:${A}`);++ assert.ok(!JSON.stringify(part.body).includes('person-b'), 'nobody else\'s rows');++ assert.ok(!JSON.stringify(part.body).includes(B), 'not B\'s requester either');++ assert.ok(!JSON.stringify(part.body).includes('"*"'), 'not the operator\'s `*` spend row');++ assert.ok(!/token|secret|password/i.test(JSON.stringify(part.body)), 'no secret is exported');++ } finally { await t.close(); await stub.close(); }++ });++++ await check('a deletion erases the person\'s tasks, their events (cascade) and spend once, keeps someone else and the `*` row, and confirms with counts', async () => {++ const stub = await startNetworkStub();++ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'actor', clientSecret: 'actor-secret' });++ const t = await boot({ env: { ACTOR_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ const aTask = await makeTask(t, A, 'person-a');++ await makeTask(t, B, 'person-b');++ const db = t.ctx.s.db;++ const count = async (sql, args = []) => await db.value(sql, args);++++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.json().outcome, 'erased');++ assert.strictEqual(await count('SELECT count(*)::int FROM tasks WHERE requester = $1', [`user:${A}`]), 0, 'the person\'s tasks are gone');++ assert.strictEqual(await count('SELECT count(*)::int FROM task_events WHERE task_id = $1', [aTask]), 0, 'their events went with them (cascade)');++ assert.strictEqual(await count('SELECT count(*)::int FROM spend_daily WHERE requester = $1', [`user:${A}`]), 0, 'their spend rows are gone');++ assert.strictEqual(await count('SELECT count(*)::int FROM tasks WHERE requester = $1', [`user:${B}`]), 1, 'someone else\'s task stays');++ assert.strictEqual(await count('SELECT count(*)::int FROM spend_daily WHERE requester = $1', [`user:${B}`]), 1, 'someone else\'s spend stays');++ assert.strictEqual(await count("SELECT count(*)::int FROM spend_daily WHERE requester = '*'"), 1, 'the operator\'s `*` ceiling row is kept');++++ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);++ assert.ok(confirmation, 'the confirmation was sent');++ assert.deepStrictEqual(confirmation.body.erased, { tasks: 1, spend_daily: 1 });++ assert.deepStrictEqual(confirmation.body.retained, {});++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));++++ // A redelivery must not erase again: rows written after the deletion stay.++ await makeTask(t, A, 'written-later');++ const again = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json().outcome, 'unchanged');++ assert.strictEqual(await count('SELECT count(*)::int FROM tasks WHERE requester = $1', [`user:${A}`]), 1, 'nothing was erased twice');++ } finally { await t.close(); await stub.close(); }++ });++++ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {++ const stub = await startNetworkStub();++ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'actor', clientSecret: 'actor-secret' });++ const t = await boot({ env: { ACTOR_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });++ assert.strictEqual(bad.status, 401);++++ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(forwarded.status, 403);++ } finally { await t.close(); await stub.close(); }++ });++++ done();++})();@@@ -42,7 +42,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters });++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -107,6 +107,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)','server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys','server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',++ 'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',};const root = path.join(__dirname, '..');const found = [];
@@ -24,6 +24,13 @@ OV_OAUTH_CLIENT_SECRET=
@@ -24,6 +24,13 @@ OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.actor/auth/callback
# OV_OAUTH_REDIRECT_URI=https://openvibe.actor/auth/callback
# ACTOR_AUDIENCE=openvibe.actor
# ACTOR_AUDIENCE=openvibe.actor
+# OpenVibe.Events → Actor (ADR-033 account export and deletion): the secret signs a delivery (comma-separated for
+# rotation, 32+ characters each; unset makes POST /internal/events answer 503); the url is where the two subscriptions
+# are created at boot (off when unset); ACTOR_EVENTS_SUBSCRIBE=0 turns that off.
+ACTOR_EVENTS_SECRET=
+ACTOR_EVENTS_URL=
+#ACTOR_EVENTS_ENDPOINT=http://127.0.0.1:4950/internal/events
+
# The model providers behind the agents (server/agents/catalog.js). An agent without its key is listed as unavailable.
# The model providers behind the agents (server/agents/catalog.js). An agent without its key is listed as unavailable.
ACTOR_DEEPSEEK_API_KEY=
ACTOR_DEEPSEEK_API_KEY=
# ACTOR_DEEPSEEK_MODEL=deepseek-flash
# ACTOR_DEEPSEEK_MODEL=deepseek-flash
@@ -81,7 +81,6 @@ Rate cards are typed in by a person from each provider's price page, with the pa
@@ -81,7 +81,6 @@ Rate cards are typed in by a person from each provider's price page, with the pa
2. Coding through OpenVibe.Codes on hosted Run sandboxes.
2. Coding through OpenVibe.Codes on hosted Run sandboxes.
3. More agent platforms behind the same router, bring-your-own keys through OpenVibe.AI, and paid tiers through Billing.
3. More agent platforms behind the same router, bring-your-own keys through OpenVibe.AI, and paid tiers through Billing.
4. Personal agents (`agt_` principals on the Network): memory people control, schedules and Events triggers, an approval inbox, reachable from Chat and the Frame.
4. Personal agents (`agt_` principals on the Network): memory people control, schedules and Events triggers, an approval inbox, reachable from Chat and the Frame.
-5. Account export and deletion (ADR-033). Until then, tasks expire after 30 days.
## Configuration
## Configuration
@@ -111,6 +110,29 @@ Tests:
@@ -111,6 +110,29 @@ Tests:
- [test/tasks.test.js](test/tasks.test.js) runs a task end to end through the real adapters against stand-in providers, Tools and Search. It covers routing per class and mode, the cross-family check, hand-off on an error or a failed check, private mode never sending the text outside, hard budgets and the free tier, idempotency, cancel, the live stream, visibility, cross-site writes, and app tokens with capabilities.
- [test/tasks.test.js](test/tasks.test.js) runs a task end to end through the real adapters against stand-in providers, Tools and Search. It covers routing per class and mode, the cross-family check, hand-off on an error or a failed check, private mode never sending the text outside, hard budgets and the free tier, idempotency, cancel, the live stream, visibility, cross-site writes, and app tokens with capabilities.
- The other files cover sign-in (PKCE, state, next=), discovery, the released contracts manifest matching the routes, per-caller limits, readiness, secrets never stored or logged, Actor never fetching a URL from a task, and the home page's size budgets.
- The other files cover sign-in (PKCE, state, next=), discovery, the released contracts manifest matching the routes, per-caller limits, readiness, secrets never stored or logged, Actor never fetching a URL from a task, and the home page's size budgets.
+## Account export and deletion
+
+A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers its
+part (ADR-033). Actor receives `network.account.export_requested` and `network.account.deleted` at `POST /internal/events`
+(loopback only) — the two tables are mapped in [server/identity/account-data.js](server/identity/account-data.js), and
+the boot-time subscriptions are created by [server/events-consumer.js](server/events-consumer.js):
+
+- **Exported:** the tasks a person asked for (`tasks.json`) and what they spent per UTC day (`spend.json`), pushed to
+ `POST /internal/account-exports/:id/parts` with this service's own token. Nothing here is a secret — Actor stores no
+ token, key or credential.
+- **Erased:** both tables hold the person's own rows, so they are deleted whole and nothing is kept; a task's event log
+ (`task_events`) goes with the task by cascade. Actor then confirms with `POST /internal/account-deletions/:id/confirmations`
+ and the counts.
+- **Anonymized:** nothing. There is no row Actor keeps that was written by this person for another person to read.
+
+A task an app, agent or service ran for this person is not matched: its requester is `app:…`/`agent:…`/`service:…`, and
+Network's deletion event carries only the person's `usr_…`, so only rows requested as `user:usr_…` are erased. The spend
+row that does not name the person (`*`, the operator's daily ceiling) is a separate row and is kept.
+
+Environment: `ACTOR_EVENTS_SECRET` (comma-separated for rotation, 32+ characters each; unset makes the route answer
+503), `ACTOR_EVENTS_URL` (or `EVENTS_URL`) is where the two subscriptions are created at boot (off when unset), and
+`ACTOR_EVENTS_ENDPOINT` overrides the loopback endpoint; `ACTOR_EVENTS_SUBSCRIBE=0` turns the boot-time subscription off.
+
## Security (threat notes)
## Security (threat notes)
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -126,6 +148,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -126,6 +148,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.115.0
- openvibe-contracts: v0.115.0
-- openvibe-sdk: v0.35.2
+- openvibe-sdk: v0.36.0
- openvibe-shared: v2.15.0
- openvibe-shared: v2.15.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,11 +8,11 @@
@@ -8,11 +8,11 @@
"port": 4950,
"port": 4950,
"domain": "openvibe.actor",
"domain": "openvibe.actor",
"contracts": "openvibe-contracts v0.115.0",
"contracts": "openvibe-contracts v0.115.0",
- "sdk": "openvibe-sdk v0.35.2",
+ "sdk": "openvibe-sdk v0.36.0",
"shared": "openvibe-shared v2.15.0",
"shared": "openvibe-shared v2.15.0",
"packages": [
"packages": [
"openvibe-contracts v0.115.0",
"openvibe-contracts v0.115.0",
- "openvibe-sdk v0.35.2",
+ "openvibe-sdk v0.36.0",
"openvibe-shared v2.13.3"
"openvibe-shared v2.13.3"
],
],
"summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.",
"summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.",
@@ -26,15 +26,15 @@
@@ -26,15 +26,15 @@
"Sign-in with OpenVibe.Network (PKCE); people's own tokens and app tokens with actor.task.* capabilities",
"Sign-in with OpenVibe.Network (PKCE); people's own tokens and app tokens with actor.task.* capabilities",
"The public agent catalog with rate cards (GET /api/v1/agents) and a dry-run router (POST /api/v1/route)",
"The public agent catalog with rate cards (GET /api/v1/agents) and a dry-run router (POST /api/v1/route)",
"Free tier per person and an operator daily ceiling; costs metered per call at the published rate cards",
"Free tier per person and an operator daily ceiling; costs metered per call at the published rate cards",
- "Pages: home with the task box, your tasks, a task's live page, the agents, the API reference, updates; robots, sitemap, llms.txt"
+ "Pages: home with the task box, your tasks, a task's live page, the agents, the API reference, updates; robots, sitemap, llms.txt",
+ "Account export and deletion (ADR-033): the tasks a person asked for and their daily spend are exported to OpenVibe.Network on network.account.export_requested and deleted whole on network.account.deleted (a task's event log goes with it by cascade, and the operator's `*` spend ceiling stays), with the counts confirmed back; the two Events topics are subscribed at boot (server/identity/account-data.js)"
],
],
"notYet": [
"notYet": [
"Browser and desktop agents on OpenVibe's own servers (OpenVibe.Run workers), watchable and taken over",
"Browser and desktop agents on OpenVibe's own servers (OpenVibe.Run workers), watchable and taken over",
"Coding tasks through OpenVibe.Codes on hosted sandboxes (listed as unavailable with the reason)",
"Coding tasks through OpenVibe.Codes on hosted sandboxes (listed as unavailable with the reason)",
"More agent platforms, bring-your-own keys through OpenVibe.AI, paid tiers through OpenVibe.Billing",
"More agent platforms, bring-your-own keys through OpenVibe.AI, paid tiers through OpenVibe.Billing",
"Personal agents: memory, schedules, Events triggers, approvals, reach from Chat and the Frame",
"Personal agents: memory, schedules, Events triggers, approvals, reach from Chat and the Frame",
- "Webhooks on task state changes",
- "Account export and deletion (ADR-033); tasks expire after 30 days meanwhile"
+ "Webhooks on task state changes"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"serviceManifestRegistered": true,
"serviceManifestRegistered": true,
@@ -111,6 +111,10 @@ server {
@@ -111,6 +111,10 @@ server {
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4950/metrics) ──
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4950/metrics) ──
location = /metrics { return 404; }
location = /metrics { return 404; }
+ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
+ # Events posts to http://127.0.0.1:4950/internal/events directly. Never proxied from outside (ADR-033).
+ location /internal/ { return 404; }
+
# ── Pages, docs, robots.txt, sitemap, static assets ─────
# ── Pages, docs, robots.txt, sitemap, static assets ─────
location / {
location / {
proxy_pass http://127.0.0.1:4950;
proxy_pass http://127.0.0.1:4950;
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- OpenVibe.Actor: 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
+);
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -887,9 +887,9 @@
@@ -887,9 +887,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.35.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
- "integrity": "sha512-HudKZJK3lZ9i0+yRdMrz9pJmIrYNTN8AFqbHFCz5jDCF68CfZwUJQyhE61pHODzGRyTvUi4DtAzkd0WEzIWx/g==",
+ "version": "0.36.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
+ "integrity": "sha512-mb+VvCuFH3KnMw4LWvqMDEoF93rNEb+eQi8HfT6mj+RPN5eQFWMcc/DCqJ3L0Yzjr99tQ08+DLwaYdZDIlfD0A==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -30,6 +30,8 @@ const { createApi } = require('./http/api');
@@ -30,6 +30,8 @@ const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createActorReadiness } = require('./observability');
const { createActorReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
+const accountDataLib = require('./identity/account-data');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const { html } = require('./render/html');
@@ -38,7 +40,8 @@ const VERSION = require('../package.json').version;
@@ -38,7 +40,8 @@ const VERSION = require('../package.json').version;
/**
/**
* opts: config, store, now (clock), fetchImpl (Network), providerFetch (model providers and OpenVibe services), log,
* opts: config, store, now (clock), fetchImpl (Network), providerFetch (model providers and OpenVibe services), log,
- * limitsNow, callerLimits (false: count nobody, tests only), valkey, adapters (tests: stand-in agents)
+ * limitsNow, callerLimits (false: count nobody, tests only), valkey, adapters (tests: stand-in agents),
+ * accountSend (a stand-in for Network's internal routes; tests only)
*/
*/
async function createApp(opts = {}) {
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
@@ -54,6 +57,18 @@ async function createApp(opts = {}) {
@@ -54,6 +57,18 @@ async function createApp(opts = {}) {
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
const ctx = { config, s, keys, sso, stream, adapters, engine, principal, log };
const ctx = { config, s, keys, sso, stream, adapters, engine, principal, log };
+ // Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
+ // The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
+ // a test injects a stand-in through opts.accountSend.
+ const accountData = accountDataLib.create({ db: s.db, log });
+ // Without a client secret the service has no way to push a part or a confirmation: the route still answers
+ // (bad signature, a forwarded request, no secret), and an event that really arrives asks Events to retry.
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Actor cannot answer account events'); });
+ ctx.accountData = accountData;
+ ctx.accountSend = accountSend;
+
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
@@ -105,6 +120,12 @@ async function createApp(opts = {}) {
@@ -105,6 +120,12 @@ async function createApp(opts = {}) {
app.use('/auth', sso.routes());
app.use('/auth', sso.routes());
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'actor', service: 'actor', host: 'openvibe.actor', name: 'OpenVibe.Actor', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'actor', service: 'actor', host: 'openvibe.actor', name: 'OpenVibe.Actor', profile: 'ugc' })); }
+ // ── OpenVibe.Events → this service (loopback only) ──────
+ // network.account.export_requested and network.account.deleted (ADR-033), answered by the SDK's consumer. It
+ // reads the raw body itself, so it is mounted before any body parser (there is none above it), and nginx answers
+ // 404 for /internal/ so this is reachable only on 127.0.0.1.
+ app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
+
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
app.use(express.static(PUBLIC_DIR, {
app.use(express.static(PUBLIC_DIR, {
@@ -79,6 +79,16 @@ function load(env = process.env) {
@@ -79,6 +79,16 @@ function load(env = process.env) {
maxRunning: Math.max(1, int(env.ACTOR_MAX_RUNNING, 4)),
maxRunning: Math.max(1, int(env.ACTOR_MAX_RUNNING, 4)),
// A task that runs longer than this is stopped and fails (actor.task.timeout).
// A task that runs longer than this is stopped and fails (actor.task.timeout).
taskTimeoutMs: Math.max(10_000, int(env.ACTOR_TASK_TIMEOUT_MS, 180_000)),
taskTimeoutMs: Math.max(10_000, int(env.ACTOR_TASK_TIMEOUT_MS, 180_000)),
+
+ // OpenVibe.Events → this service (server/events-consumer.js). The secret signs a delivery (comma-separated
+ // for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the
+ // network.account.export_requested and network.account.deleted subscriptions are created at boot, off when
+ // unset. Both topics are ADR-033: account export and deletion (server/identity/account-data.js).
+ events: {
+ secrets: String(env.ACTOR_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ url: trim(env.ACTOR_EVENTS_URL || env.EVENTS_URL || ''),
+ endpoint: env.ACTOR_EVENTS_ENDPOINT || '',
+ },
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+
+/**
+ * OpenVibe.Events → Actor. Actor has no consumer of its own: the two topics it takes are account export and deletion
+ * (ADR-033), and POST /internal/events is answered by openvibe-sdk/account-data's own consumer, mounted in
+ * server/app.js over server/identity/account-data.js (the table map). What is left here is the boot-time half:
+ * startSubscriptions() creates the two subscriptions at Events if they are missing, idempotently, retried with
+ * backoff, and off when the URL or the secret is unset (QUEST_EVENTS_SUBSCRIBE=0 style: ACTOR_EVENTS_SUBSCRIBE=0).
+ *
+ * The endpoint is this service's loopback /internal/events (Events posts to http://127.0.0.1:<port> directly; nginx
+ * answers 404 there), and the token is this service's own for audience openvibe.events with
+ * scope events.subscription.manage.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+const { TOPICS } = require('./identity/account-data');
+
+/** Create any missing subscription for TOPICS at Events (idempotent; retried in the background at boot). */
+function startSubscriptions({ config, port, secret, eventsUrl = config.events.url, fetchImpl = globalThis.fetch, log = console }) {
+ if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.ACTOR_EVENTS_SUBSCRIBE === '0') return null;
+ const base = String(eventsUrl).replace(/\/+$/, '');
+ const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;
+ const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.events', scope: 'events.subscription.manage', fetchImpl });
+ const call = async (method, path, body) => {
+ const res = await fetchImpl(`${base}${path}`, { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000) });
+ const json = await res.json().catch(() => ({}));
+ return { status: res.status, ok: res.ok, body: json };
+ };
+ const attempt = async () => {
+ const listed = await call('GET', '/api/v1/subscriptions');
+ if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
+ const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);
+ for (const topic of TOPICS) {
+ if (mine.some((s) => s.topic_pattern === topic)) continue;
+ const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
+ if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
+ if (r.ok) log.log(`[Events consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);
+ }
+ };
+ const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];
+ let i = 0;
+ let timer = null;
+ let stopped = false;
+ const run = () => { timer = null; if (stopped) return; attempt().catch((err) => {
+ if (stopped) return;
+ if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Events consumer] subscriptions not created:', err.message);
+ }); };
+ timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();
+ // Graceful stop: no further attempts (they run again at the next start).
+ return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
+}
+
+module.exports = { startSubscriptions, TOPICS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+
+/**
+ * Account export and deletion → Actor (ADR-033; openvibe-sdk/account-data). Actor holds two things a person made:
+ * the tasks they asked for and what they spent per UTC day, both keyed `requester = user:usr_…` (server/http/
+ * principal.js builds the requester that way, server/tasks/store.js writes it). Both are the person's own.
+ *
+ * network.account.export_requested the person's tasks (tasks.json) and their per-day spend (spend.json), newest
+ * first, pushed to Network (POST /internal/account-exports/:id/parts) with this
+ * service's token.
+ * network.account.deleted both go, and Actor confirms with counts.
+ *
+ * A task an app, agent or service ran for this person is NOT matched: its requester is `app:…`/`agent:…`/`service:…`,
+ * and Network's deletion event carries only the person's `usr_…`, so only rows requested as `user:usr_…` are erased.
+ * task_events holds no person column at all — its task_id REFERENCES tasks(id) ON DELETE CASCADE — so a task's event
+ * log goes with the task and task_events is not in the map. Nothing here is a secret: no token, key or credential is
+ * stored, so every column of both tables may be exported.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+/**
+ * The tables that hold a person's rows, with the value the subject column really stores. Tasks and daily spend both
+ * store `user:usr_…` (server/tasks/store.js takes the requester straight from the principal).
+ */
+const TABLES = [
+ { table: 'tasks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tasks.json' },
+ // The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a
+ // reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is
+ // the operator's total, a separate row.
+ { table: 'spend_daily', subject: 'requester', value: (usr) => `user:${usr}`, file: 'spend.json' },
+];
+
+/** The account-data handle for Actor's store (server/db.js createStore). */
+function create({ db, note = 'A task row holds the task the person asked for, in their own words.', log = console } = {}) {
+ return createAccountData({ db, service: 'actor', tables: TABLES, note, log });
+}
+
+module.exports = { create, TABLES, TOPICS };
@@ -12,13 +12,14 @@ const PRUNE_EVERY_MS = 6 * 60 * 60_000;
@@ -12,13 +12,14 @@ const PRUNE_EVERY_MS = 6 * 60 * 60_000;
/**
/**
* The process stop (openvibe-sdk/service): the HTTP drain runs, then running tasks are stopped and recorded as
* The process stop (openvibe-sdk/service): the HTTP drain runs, then running tasks are stopped and recorded as
- * interrupted (the person can ask again; nothing re-runs behind their back), the JWKS refresher stops and the store
- * closes. Exported so a test can inject `exit` and `signals: false`.
+ * interrupted (the person can ask again; nothing re-runs behind their back), the Events subscriptions stop, the JWKS
+ * refresher stops and the store closes. Exported so a test can inject `exit` and `signals: false`. `extra` is what the
+ * product adds (the Events subscriptions); a test that passes none keeps the skeleton's order.
*/
*/
-function createLifecycle({ server, ctx, exit, signals, timers = [] }) {
+function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'Actor', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
name: 'Actor', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.engine.stop(), () => ctx.keys.client.stop(), () => ctx.s.close()],
+ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.engine.stop(), () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],
});
});
}
}
@@ -39,7 +40,11 @@ async function start() {
@@ -39,7 +40,11 @@ async function start() {
const prune = setInterval(() => { store.prune(ctx.s).catch((err) => console.warn('[Actor] prune failed:', err && err.message)); }, PRUNE_EVERY_MS);
const prune = setInterval(() => { store.prune(ctx.s).catch((err) => console.warn('[Actor] prune failed:', err && err.message)); }, PRUNE_EVERY_MS);
prune.unref();
prune.unref();
- createLifecycle({ server, ctx, timers: [prune] });
+ // Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without ACTOR_EVENTS_URL and
+ // ACTOR_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
+ const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });
+ const extra = [() => { if (subscriptions) subscriptions.stop(); }];
+ createLifecycle({ server, ctx, timers: [prune], extra });
return { server, ctx };
return { server, ctx };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+'use strict';
+/**
+ * ADR-033: Actor's part of an account export (a person's tasks and their daily spend) and of an account deletion,
+ * applied through the service's own /internal/events route with a stand-in Network. Real rows are made through the
+ * service's store, the signed delivery is answered by openvibe-sdk/account-data, and the part only ever carries person
+ * A's rows. A redelivery erases nothing twice, and the route refuses a bad signature and a forwarded request.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const taskStore = require('../server/tasks/store');
+
+const A = 'usr_01JZ0000000000000000000AAA';
+const B = 'usr_01JZ0000000000000000000BBB';
+const OLD = 'usr_01JZ0000000000000000000MRG';
+const EXP = 'exp_01JZ0000000000000000000EXP';
+const DEL = 'del_01JZ0000000000000000000DEX';
+// Fixture secrets, built so they never look like a real key to a scanner.
+const SECRET = `whsec_${'fixture'.repeat(6)}`;
+const WRONG = `whsec_${'mismatch'.repeat(5)}`;
+
+const exportEvent = { event_id: 'evt_01JZ0000000000000000000E01', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };
+const deleteEvent = { event_id: 'evt_01JZ0000000000000000000D01', event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [OLD] } };
+const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
+
+/** A stand-in for Network's internal routes: the token endpoint, the export part and the deletion confirmation. */
+async function startNetworkStub({ partStatus = 201, confirmStatus = 201 } = {}) {
+ const calls = [];
+ const statusOf = (v) => (typeof v === 'function' ? v() : v);
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ 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_actor', token_type: 'Bearer', expires_in: 300, scope: 'openvibe.network' });
+ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(raw.toString() || 'null') });
+ return json(req.url.includes('/parts') ? statusOf(partStatus) : statusOf(confirmStatus), {});
+ });
+ });
+ 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)) };
+}
+
+/**
+ * Make a task and one of its events for one person, through the store the API writes with (server/tasks/store.js).
+ * insertTask also writes the person's spend row for today and bumps the requester `*` row, so a spend row exists too.
+ */
+async function makeTask(t, subject, title) {
+ const s = t.ctx.s;
+ const id = s.newId('tsk');
+ await taskStore.insertTask(s, { id, requester: `user:${subject}`, task: title, mode: 'balanced', budget_task: 0.05, budget_day: 0.25, created_at: s.iso() });
+ await taskStore.appendEvent(s, id, { kind: 'state', state: 'running' });
+ return id;
+}
+
+(async () => {
+ await check('an export part carries only the person\'s tasks and daily spend, and no secret', async () => {
+ const stub = await startNetworkStub();
+ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'actor', clientSecret: 'actor-secret' });
+ const t = await boot({ env: { ACTOR_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ await makeTask(t, A, 'person-a');
+ await makeTask(t, B, 'person-b');
+
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.json().outcome, 'exported');
+
+ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
+ assert.ok(part, 'the part was pushed to Network');
+ assert.strictEqual(part.auth, 'Bearer tok_actor', 'with this service\'s own token');
+ assert.strictEqual(part.body.subject, A);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['spend.json', 'tasks.json']);
+ const tasks = part.body.files.find((f) => f.name === 'tasks.json').content;
+ assert.strictEqual(tasks.length, 1);
+ assert.strictEqual(tasks[0].task, 'person-a');
+ const spend = part.body.files.find((f) => f.name === 'spend.json').content;
+ assert.strictEqual(spend.length, 1, 'only A\'s spend row, not B\'s and not the `*` row');
+ assert.strictEqual(spend[0].requester, `user:${A}`);
+ assert.ok(!JSON.stringify(part.body).includes('person-b'), 'nobody else\'s rows');
+ assert.ok(!JSON.stringify(part.body).includes(B), 'not B\'s requester either');
+ assert.ok(!JSON.stringify(part.body).includes('"*"'), 'not the operator\'s `*` spend row');
+ assert.ok(!/token|secret|password/i.test(JSON.stringify(part.body)), 'no secret is exported');
+ } finally { await t.close(); await stub.close(); }
+ });
+
+ await check('a deletion erases the person\'s tasks, their events (cascade) and spend once, keeps someone else and the `*` row, and confirms with counts', async () => {
+ const stub = await startNetworkStub();
+ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'actor', clientSecret: 'actor-secret' });
+ const t = await boot({ env: { ACTOR_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ const aTask = await makeTask(t, A, 'person-a');
+ await makeTask(t, B, 'person-b');
+ const db = t.ctx.s.db;
+ const count = async (sql, args = []) => await db.value(sql, args);
+
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.json().outcome, 'erased');
+ assert.strictEqual(await count('SELECT count(*)::int FROM tasks WHERE requester = $1', [`user:${A}`]), 0, 'the person\'s tasks are gone');
+ assert.strictEqual(await count('SELECT count(*)::int FROM task_events WHERE task_id = $1', [aTask]), 0, 'their events went with them (cascade)');
+ assert.strictEqual(await count('SELECT count(*)::int FROM spend_daily WHERE requester = $1', [`user:${A}`]), 0, 'their spend rows are gone');
+ assert.strictEqual(await count('SELECT count(*)::int FROM tasks WHERE requester = $1', [`user:${B}`]), 1, 'someone else\'s task stays');
+ assert.strictEqual(await count('SELECT count(*)::int FROM spend_daily WHERE requester = $1', [`user:${B}`]), 1, 'someone else\'s spend stays');
+ assert.strictEqual(await count("SELECT count(*)::int FROM spend_daily WHERE requester = '*'"), 1, 'the operator\'s `*` ceiling row is kept');
+
+ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
+ assert.ok(confirmation, 'the confirmation was sent');
+ assert.deepStrictEqual(confirmation.body.erased, { tasks: 1, spend_daily: 1 });
+ assert.deepStrictEqual(confirmation.body.retained, {});
+ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
+
+ // A redelivery must not erase again: rows written after the deletion stay.
+ await makeTask(t, A, 'written-later');
+ const again = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json().outcome, 'unchanged');
+ assert.strictEqual(await count('SELECT count(*)::int FROM tasks WHERE requester = $1', [`user:${A}`]), 1, 'nothing was erased twice');
+ } finally { await t.close(); await stub.close(); }
+ });
+
+ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {
+ const stub = await startNetworkStub();
+ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'actor', clientSecret: 'actor-secret' });
+ const t = await boot({ env: { ACTOR_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });
+ assert.strictEqual(bad.status, 401);
+
+ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(forwarded.status, 403);
+ } finally { await t.close(); await stub.close(); }
+ });
+
+ done();
+})();
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters });
+ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -107,6 +107,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -107,6 +107,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)',
'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)',
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
+ 'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
};
};
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
const found = [];
const found = [];