openvibers/openvibe.bot Commits
Bot: account export and deletion (ADR-033) — a person's robots removed as their owner would (OpenRe key revoked, history gone, sole devices revoked), their roles and queue places elsewhere removed, their id out of others' history, run jobs kept; loopback /internal/events; subscriptions at boot (SDK v0.37.0) (#46)
Changes
@@@ -35,6 +35,9 @@ OV_OAUTH_CLIENT_SECRET=# ── OpenVibe.Events ─────────────────────────────────────────# Relay of Bot's outbox (bot.*); off when unset (rows wait in bot_event_outbox).#EVENTS_URL=http://127.0.0.1:4300++# OpenVibe.Events → Bot (ADR-033 account export and deletion): signs a delivery to POST /internal/events++# (comma-separated for rotation, 32+ characters each). Unset: the route answers 503 and no subscription is created.++#BOT_EVENTS_SECRET=#EVENTS_RELAY_INTERVAL_MS=2000# ── Pairing and device link (ADR-043) ───────────────────────@@@ -43,7 +43,27 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.--- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.35.0 and `openvibe-shared` v2.15.0 (package.json).++- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.37.0 and `openvibe-shared` v2.15.0 (package.json).++- Account export and deletion (ADR-033): Network grants `events.subscription.manage` (openvibe.events) for the two++ subscriptions created at boot, then, last and once the release is live, `network.account.export.contribute` and++ `network.account.deletion.confirm` (openvibe.network).++++## Account export and deletion (ADR-033)++++`network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events`. The route is++loopback-only (nginx answers 404 for `/internal/`, and the handler refuses a forwarded request) and signed with++`BOT_EVENTS_SECRET`. They are answered by `server/account-data.js` over `openvibe-sdk/account-data`, with one receipt++per export and deletion in `account_data_events` (migration 0008).++++- **Export:** the person's robots, the roles they hold on other robots, their commands and their run jobs. Device++ credentials and publish keys are hashes and never exported.++- **Their robots:** each is removed the way its owner would remove it (`robots.remove`: the OpenRe stream key is++ revoked and the stream archived first). Its pairing codes, operators, queue, local profile and Live conversion go++ with it, and so does its command history. A device that served only their robots is revoked and loses its name;++ the row stays because run jobs reference it.++- **Elsewhere:** their operator role and queue place on other robots go, and their id leaves other robots' command++ history, e-stop marks, pairing codes and invitations.++- **Kept:** run jobs they started, whose usage seconds were reported to Billing.## Capabilities@@@ -6,11 +6,11 @@"port": 4630,"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)","contracts": "openvibe-contracts v0.112.0",-- "sdk": "openvibe-sdk v0.35.0",++ "sdk": "openvibe-sdk v0.37.0","shared": "openvibe-shared v2.15.0","packages": ["openvibe-contracts v0.112.0",-- "openvibe-sdk v0.35.0",++ "openvibe-sdk v0.37.0","openvibe-shared v2.13.0"],"capabilities": [@@@ -189,6 +189,10 @@ server {}location = /status.json { return 404; }++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──++ # Events posts to http://127.0.0.1:4630/internal/events directly; never proxied from outside (ADR-033).++ location /internal/ { return 404; }++# ── The legal pages, robots.txt, sitemap.xml and 404 page: still the frozen OpenVibe.Sites files ──# proxy_intercept_errors stays off, so this 404 page does not affect proxied responses.nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- OpenVibe.Bot: 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++);@@@ -15,7 +15,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0","ws": "^8.18.0"@@@ -860,9 +860,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.35.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",-- "integrity": "sha512-IF7XB97tU7Q+ccwssGx6QXXcfDPcVpl+mX3BjIr7C6NJlcmoqj9DbHiKrBxlVav0eG44Jf8zuXEwokrHDq3J0w==",++ "version": "0.37.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",++ "integrity": "sha512-afojId2KzOL4ZZrpwIeIAYUaX24ZOC3Nt+bdd1HSXm7aislt8WQNS62i8/d8W3udLr6rmSG29SjmnaRHUGfd4Q==","license": "MIT","engines": {"node": ">=20"@@@ -27,7 +27,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0","ws": "^8.18.0"nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++++/**++ * Account export and deletion → Bot (ADR-033; openvibe-sdk/account-data). What Bot holds about a person:++ *++ * their robots each removed the way its owner would remove it (domain.robots.remove: the robot's OpenRe++ * stream key is revoked and its stream archived, then the row goes, and with it, by++ * ON DELETE CASCADE, its pairing codes, operators, queue, local profile and Live conversion).++ * Its command history goes too, and a device that served only their robots is revoked++ * (domain.devices.revoke): run jobs reference devices and are billing records, so the device++ * row stays, revoked, without its name.++ * elsewhere their operator role and queue place on other people's robots go; their id leaves other++ * robots' command history (operator_subject), e-stop marks, pairing codes and invitations.++ * kept run jobs they started: usage seconds reported to Billing, counted as retained.++ *++ * Nothing exported is a secret: device credentials and publish keys are stored as hashes and never exported.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const anonymize = { anonymize: {} };++++const TABLES = [++ { table: 'robot_operators', subject: 'subject', file: 'operating.json', columns: ['robot_id', 'role', 'created_at'] },++ { table: 'robot_queue', subject: 'subject', file: null },++ { table: 'command_audit', subject: 'operator_subject', file: 'commands.json', columns: ['robot_id', 'kind', 'result', 'reason', 'at'], order: 'at', erase: anonymize },++ { table: 'robots', subject: 'estop_by', file: null, erase: anonymize },++ { table: 'pairing_codes', subject: 'created_by', file: null, erase: anonymize },++ { table: 'robot_operators', subject: 'added_by', file: null, erase: anonymize },++ { table: 'live_conversions', subject: 'owner_subject', file: null },++ { table: 'run_jobs', subject: 'subject', value: (usr) => [usr, `user:${usr}`], file: 'run-jobs.json', columns: ['id', 'class', 'state', 'wall_ms', 'exit_reason', 'created_at', 'finished_at'], erase: { keep: 'usage seconds reported to Billing' } },++];++++async function extraExport(db, subject) {++ const robots = await db.many(`SELECT id, name, profile_id, profile_version, access_policy, limits, created_at, updated_at FROM robots++ WHERE owner_subject = $1 ORDER BY created_at DESC`, [subject]);++ return robots.length ? [{ name: 'robots.json', content: robots }] : [];++}++++/** The account-data handle over Bot's domain (server/domain: robots.remove, devices.revoke). */++function create({ domain, log = console } = {}) {++ async function extraErase(t, subjects, counts) {++ const robots = await t.many('SELECT id FROM robots WHERE owner_subject = ANY($1::text[])', [subjects]);++ const ids = robots.map((r) => r.id);++ if (ids.length) {++ // Devices that served only these robots: revoked (run jobs reference them), and their name goes.++ const devices = await t.many(`SELECT id FROM devices WHERE revoked_at IS NULL AND jsonb_array_length(robot_ids) > 0++ AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(robot_ids) r WHERE NOT (r = ANY($1::text[])))`, [ids]);++ for (const d of devices) await domain.devices.revoke(d.id);++ counts.add(counts.retained, 'devices', devices.length);++ await t.exec(`UPDATE devices SET name = NULL WHERE NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(robot_ids) r WHERE NOT (r = ANY($1::text[])))++ AND jsonb_array_length(robot_ids) > 0`, [ids]);++ counts.add(counts.erased, 'command_audit', await t.exec('DELETE FROM command_audit WHERE robot_id = ANY($1::text[])', [ids]));++ // Each robot the way its owner would remove it (OpenRe key revoked first; a refusal there rolls this back and++ // Events retries).++ for (const id of ids) await domain.robots.remove(id);++ counts.add(counts.erased, 'robots', ids.length);++ }++ }++ return createAccountData({ db: domain.db, service: 'bot', tables: TABLES, extraExport, extraErase, log });++}++++module.exports = { create, TABLES, TOPICS };@@@ -27,6 +27,8 @@ const { createOpenRe } = require('./openre/client');const { createBotOutbox } = require('./events/outbox');const { createUsageRelay } = require('./jobs/metering');const { createDomain } = require('./domain');++const accountDataLib = require('./account-data');++const { createNetworkSender } = require('openvibe-sdk/account-data');const { createRealtime } = require('./realtime');const { createSimulator } = require('./sim');const { createOnvif } = require('./onvif');@@@ -86,6 +88,17 @@ function createApp(opts = {}) {next();});app.use(cookieParser());++++ // ── OpenVibe.Events → Bot (loopback only: nginx answers 404 for /internal/) ──++ // network.account.export_requested and network.account.deleted (ADR-033), answered by openvibe-sdk/account-data's++ // consumer over account-data.js. It reads the raw body itself (the v2 signature covers it) and refuses a request that++ // came through a proxy. The sender posts to Network's internal routes with Bot's own token.++ const accountData = accountDataLib.create({ domain, log });++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Bot cannot answer account events'); });++ app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));++// The OpenVibe Frame's scripts and the showcase stylesheet the front page links (content-hashed URLs).app.use('/shared', require('openvibe-shared/serve').handler());@@@ -150,6 +150,10 @@ function loadConfig(env = process.env) {events: {url: trim(env.EVENTS_URL || ''),intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),++ // OpenVibe.Events → Bot (ADR-033 account export and deletion, server/account-data.js): the secret(s) that sign++ // a delivery to POST /internal/events (comma-separated for rotation, 32+ characters each). Unset: the route++ // answers 503 and no subscription is created at boot.++ secrets: String(env.BOT_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),},// Pairing (ADR-043 decision 2): an 8-character Crockford base32 code, XXXX-XXXX, 10 minutes,@@@ -19,6 +19,7 @@ const { seedProfiles } = require('./profiles');const { createApp } = require('./app');const { createValkey } = require('openvibe-sdk/valkey');const { gracefulStop } = require('openvibe-sdk/service');++const { startSubscriptions } = require('openvibe-sdk/account-data');const { createRegistry } = require('openvibe-shared/metrics');async function main() {@@@ -62,6 +63,13 @@ async function main() {// WebSocket sockets are not tracked by server.close. drainMs bounds the drain; deadlineMs 5000 keeps the old 5 s// hard timer and deadlineExitCode 0 its exit 0. Bot's manifest declares no lifecycle.shutdown deadline, so the// kit's 5000 ms default is the right value.++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,++ // BOT_EVENTS_SECRET or the client secret.++ const subscriptions = startSubscriptions({++ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],++ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ });++const { stop: shutdown } = gracefulStop({name: 'Bot',server,@@@ -76,6 +84,7 @@ async function main() {() => hub.close().catch(() => {}),() => outbox.stop(),() => usage.stop(),++ () => { if (subscriptions) subscriptions.stop(); },],close: [() => db.close().catch(() => {}),nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * ADR-033: Bot's part of an account export and of an account deletion, through the signed loopback route++ * POST /internal/events with a stand-in Network. Alex owns a robot with a paired device and is an operator on Bob's++ * robot. The export carries Alex's robots and roles. The deletion removes Alex's robot the way its owner would (its++ * operators and command history with it), revokes the device that served only it, removes Alex's role on Bob's robot++ * and takes Alex's id out of Bob's command history, keeps Bob's robot, and confirms once.++ */++const assert = require('assert');++const http = require('http');++const { createNetworkSender } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/app');++++const SECRET = `whsec_${'fixture'.repeat(6)}`;++++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_bot', token_type: 'Bearer', expires_in: 300 });++ calls.push({ url: req.url, auth: req.headers.authorization, 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)) };++}++++(async () => {++ const stub = await startNetworkStub();++ const t = await boot({ env: { BOT_EVENTS_SECRET: SECRET }, appOpts: { accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'bot', clientSecret: 'shh' }) } });++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ const db = t.db;++ const count = async (sql, args) => Number(await db.value(sql, args));++ const deliver = async (event, { secret = SECRET, headers = {} } = {}) => {++ const body = JSON.stringify({ event, seq: 1 });++ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });++ let mine;++ let theirs;++ let deviceId;++++ try {++ await check('alex\'s robot with a paired device, and alex as an operator on bob\'s robot', async () => {++ const made = await t.robot(alex, { name: 'Alex rover' });++ mine = made.robot;++ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: mine.id, code: made.pairing.code } });++ assert.ok(paired.status < 300, paired.text);++ deviceId = paired.json.device_id || paired.json.device?.id || (await db.value("SELECT id FROM devices WHERE robot_ids @> $1::jsonb", [JSON.stringify([mine.id])]));++ theirs = (await t.robot(bob, { name: 'Bob rover' })).robot;++ const add = await t.call('POST', `/api/v1/robots/${theirs.id}/operators`, { user: bob, body: { subject: alex.subject, role: 'operator' } });++ assert.ok(add.status < 300, add.text);++ const at = new Date().toISOString();++ await db.exec(`INSERT INTO command_audit (robot_id, operator_subject, operator_kind, role, kind, result, at) VALUES++ ($1, $2, 'user', 'operator', 'drive', 'ack', $4), ($3, $5, 'user', 'operator', 'drive', 'ack', $4)`, [theirs.id, alex.subject, mine.id, at, bob.subject]);++ });++++ await check('the export carries alex\'s robots, roles and commands, and nothing of bob\'s', async () => {++ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: alex.subject }));++ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'exported'], JSON.stringify(r.json));++ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');++ assert.strictEqual(part.auth, 'Bearer tok_bot');++ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));++ assert.deepStrictEqual(files['robots.json'].map((x) => x.name), ['Alex rover']);++ assert.ok(files['operating.json'].some((o) => o.robot_id === theirs.id));++ assert.ok(!JSON.stringify(part.body).includes(bob.subject));++ assert.ok(!/credential|publish_key/i.test(JSON.stringify(part.body)), 'no device secret');++ });++++ await check('the deletion removes alex\'s robot, revokes its device, removes alex from bob\'s robot; once', async () => {++ const event = ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: alex.subject });++ const r = await deliver(event);++ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'erased'], JSON.stringify(r.json));++ assert.strictEqual(await count('SELECT count(*) FROM robots WHERE owner_subject = $1', [alex.subject]), 0);++ assert.strictEqual(await count('SELECT count(*) FROM command_audit WHERE robot_id = $1', [mine.id]), 0, 'her robot\'s history goes with it');++ const dev = await db.maybe('SELECT revoked_at, name FROM devices WHERE id = $1', [deviceId]);++ assert.ok(dev.revoked_at, 'the device that served only her robot is revoked');++ assert.strictEqual(dev.name, null);++ assert.strictEqual(await count('SELECT count(*) FROM robot_operators WHERE subject = $1', [alex.subject]), 0);++ assert.strictEqual(await count('SELECT count(*) FROM command_audit WHERE operator_subject = $1', [alex.subject]), 0);++ assert.strictEqual(await count('SELECT count(*) FROM command_audit WHERE robot_id = $1 AND operator_subject IS NULL', [theirs.id]), 1, 'bob keeps his robot\'s history, without her id');++ assert.strictEqual(await count('SELECT count(*) FROM robots WHERE id = $1', [theirs.id]), 1, 'bob\'s robot stays');++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');++ assert.strictEqual(conf.length, 1);++ // robot_operators: her owner row on her own robot and her operator role on bob's.++ assert.deepStrictEqual([conf[0].body.erased.robots, conf[0].body.erased.robot_operators, conf[0].body.retained.devices], [1, 2, 1]);++ assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');++ assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);++ });++++ await check('the route refuses a bad signature and a request that came through a proxy', async () => {++ const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: alex.subject });++ assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);++ assert.strictEqual((await deliver(event, { headers: { 'X-Forwarded-For': '203.0.113.9' } })).status, 403);++ });++ } finally {++ await t.close();++ await stub.close();++ }++ done();++})();
@@ -35,6 +35,9 @@ OV_OAUTH_CLIENT_SECRET=
@@ -35,6 +35,9 @@ OV_OAUTH_CLIENT_SECRET=
# ── OpenVibe.Events ─────────────────────────────────────────
# ── OpenVibe.Events ─────────────────────────────────────────
# Relay of Bot's outbox (bot.*); off when unset (rows wait in bot_event_outbox).
# Relay of Bot's outbox (bot.*); off when unset (rows wait in bot_event_outbox).
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_URL=http://127.0.0.1:4300
+# OpenVibe.Events → Bot (ADR-033 account export and deletion): signs a delivery to POST /internal/events
+# (comma-separated for rotation, 32+ characters each). Unset: the route answers 503 and no subscription is created.
+#BOT_EVENTS_SECRET=
#EVENTS_RELAY_INTERVAL_MS=2000
#EVENTS_RELAY_INTERVAL_MS=2000
# ── Pairing and device link (ADR-043) ───────────────────────
# ── Pairing and device link (ADR-043) ───────────────────────
@@ -43,7 +43,27 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -43,7 +43,27 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).
- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).
- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.
-- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.35.0 and `openvibe-shared` v2.15.0 (package.json).
+- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.37.0 and `openvibe-shared` v2.15.0 (package.json).
+- Account export and deletion (ADR-033): Network grants `events.subscription.manage` (openvibe.events) for the two
+ subscriptions created at boot, then, last and once the release is live, `network.account.export.contribute` and
+ `network.account.deletion.confirm` (openvibe.network).
+
+## Account export and deletion (ADR-033)
+
+`network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events`. The route is
+loopback-only (nginx answers 404 for `/internal/`, and the handler refuses a forwarded request) and signed with
+`BOT_EVENTS_SECRET`. They are answered by `server/account-data.js` over `openvibe-sdk/account-data`, with one receipt
+per export and deletion in `account_data_events` (migration 0008).
+
+- **Export:** the person's robots, the roles they hold on other robots, their commands and their run jobs. Device
+ credentials and publish keys are hashes and never exported.
+- **Their robots:** each is removed the way its owner would remove it (`robots.remove`: the OpenRe stream key is
+ revoked and the stream archived first). Its pairing codes, operators, queue, local profile and Live conversion go
+ with it, and so does its command history. A device that served only their robots is revoked and loses its name;
+ the row stays because run jobs reference it.
+- **Elsewhere:** their operator role and queue place on other robots go, and their id leaves other robots' command
+ history, e-stop marks, pairing codes and invitations.
+- **Kept:** run jobs they started, whose usage seconds were reported to Billing.
## Capabilities
## Capabilities
@@ -6,11 +6,11 @@
@@ -6,11 +6,11 @@
"port": 4630,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"contracts": "openvibe-contracts v0.112.0",
"contracts": "openvibe-contracts v0.112.0",
- "sdk": "openvibe-sdk v0.35.0",
+ "sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.15.0",
"shared": "openvibe-shared v2.15.0",
"packages": [
"packages": [
"openvibe-contracts v0.112.0",
"openvibe-contracts v0.112.0",
- "openvibe-sdk v0.35.0",
+ "openvibe-sdk v0.37.0",
"openvibe-shared v2.13.0"
"openvibe-shared v2.13.0"
],
],
"capabilities": [
"capabilities": [
@@ -189,6 +189,10 @@ server {
@@ -189,6 +189,10 @@ server {
}
}
location = /status.json { return 404; }
location = /status.json { return 404; }
+ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
+ # Events posts to http://127.0.0.1:4630/internal/events directly; never proxied from outside (ADR-033).
+ location /internal/ { return 404; }
+
# ── The legal pages, robots.txt, sitemap.xml and 404 page: still the frozen OpenVibe.Sites files ──
# ── The legal pages, robots.txt, sitemap.xml and 404 page: still the frozen OpenVibe.Sites files ──
# proxy_intercept_errors stays off, so this 404 page does not affect proxied responses.
# proxy_intercept_errors stays off, so this 404 page does not affect proxied responses.
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- OpenVibe.Bot: 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
+);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.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",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -860,9 +860,9 @@
@@ -860,9 +860,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.35.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
- "integrity": "sha512-IF7XB97tU7Q+ccwssGx6QXXcfDPcVpl+mX3BjIr7C6NJlcmoqj9DbHiKrBxlVav0eG44Jf8zuXEwokrHDq3J0w==",
+ "version": "0.37.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
+ "integrity": "sha512-afojId2KzOL4ZZrpwIeIAYUaX24ZOC3Nt+bdd1HSXm7aislt8WQNS62i8/d8W3udLr6rmSG29SjmnaRHUGfd4Q==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.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",
"ws": "^8.18.0"
"ws": "^8.18.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+
+/**
+ * Account export and deletion → Bot (ADR-033; openvibe-sdk/account-data). What Bot holds about a person:
+ *
+ * their robots each removed the way its owner would remove it (domain.robots.remove: the robot's OpenRe
+ * stream key is revoked and its stream archived, then the row goes, and with it, by
+ * ON DELETE CASCADE, its pairing codes, operators, queue, local profile and Live conversion).
+ * Its command history goes too, and a device that served only their robots is revoked
+ * (domain.devices.revoke): run jobs reference devices and are billing records, so the device
+ * row stays, revoked, without its name.
+ * elsewhere their operator role and queue place on other people's robots go; their id leaves other
+ * robots' command history (operator_subject), e-stop marks, pairing codes and invitations.
+ * kept run jobs they started: usage seconds reported to Billing, counted as retained.
+ *
+ * Nothing exported is a secret: device credentials and publish keys are stored as hashes and never exported.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const anonymize = { anonymize: {} };
+
+const TABLES = [
+ { table: 'robot_operators', subject: 'subject', file: 'operating.json', columns: ['robot_id', 'role', 'created_at'] },
+ { table: 'robot_queue', subject: 'subject', file: null },
+ { table: 'command_audit', subject: 'operator_subject', file: 'commands.json', columns: ['robot_id', 'kind', 'result', 'reason', 'at'], order: 'at', erase: anonymize },
+ { table: 'robots', subject: 'estop_by', file: null, erase: anonymize },
+ { table: 'pairing_codes', subject: 'created_by', file: null, erase: anonymize },
+ { table: 'robot_operators', subject: 'added_by', file: null, erase: anonymize },
+ { table: 'live_conversions', subject: 'owner_subject', file: null },
+ { table: 'run_jobs', subject: 'subject', value: (usr) => [usr, `user:${usr}`], file: 'run-jobs.json', columns: ['id', 'class', 'state', 'wall_ms', 'exit_reason', 'created_at', 'finished_at'], erase: { keep: 'usage seconds reported to Billing' } },
+];
+
+async function extraExport(db, subject) {
+ const robots = await db.many(`SELECT id, name, profile_id, profile_version, access_policy, limits, created_at, updated_at FROM robots
+ WHERE owner_subject = $1 ORDER BY created_at DESC`, [subject]);
+ return robots.length ? [{ name: 'robots.json', content: robots }] : [];
+}
+
+/** The account-data handle over Bot's domain (server/domain: robots.remove, devices.revoke). */
+function create({ domain, log = console } = {}) {
+ async function extraErase(t, subjects, counts) {
+ const robots = await t.many('SELECT id FROM robots WHERE owner_subject = ANY($1::text[])', [subjects]);
+ const ids = robots.map((r) => r.id);
+ if (ids.length) {
+ // Devices that served only these robots: revoked (run jobs reference them), and their name goes.
+ const devices = await t.many(`SELECT id FROM devices WHERE revoked_at IS NULL AND jsonb_array_length(robot_ids) > 0
+ AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(robot_ids) r WHERE NOT (r = ANY($1::text[])))`, [ids]);
+ for (const d of devices) await domain.devices.revoke(d.id);
+ counts.add(counts.retained, 'devices', devices.length);
+ await t.exec(`UPDATE devices SET name = NULL WHERE NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(robot_ids) r WHERE NOT (r = ANY($1::text[])))
+ AND jsonb_array_length(robot_ids) > 0`, [ids]);
+ counts.add(counts.erased, 'command_audit', await t.exec('DELETE FROM command_audit WHERE robot_id = ANY($1::text[])', [ids]));
+ // Each robot the way its owner would remove it (OpenRe key revoked first; a refusal there rolls this back and
+ // Events retries).
+ for (const id of ids) await domain.robots.remove(id);
+ counts.add(counts.erased, 'robots', ids.length);
+ }
+ }
+ return createAccountData({ db: domain.db, service: 'bot', tables: TABLES, extraExport, extraErase, log });
+}
+
+module.exports = { create, TABLES, TOPICS };
@@ -27,6 +27,8 @@ const { createOpenRe } = require('./openre/client');
@@ -27,6 +27,8 @@ const { createOpenRe } = require('./openre/client');
const { createBotOutbox } = require('./events/outbox');
const { createBotOutbox } = require('./events/outbox');
const { createUsageRelay } = require('./jobs/metering');
const { createUsageRelay } = require('./jobs/metering');
const { createDomain } = require('./domain');
const { createDomain } = require('./domain');
+const accountDataLib = require('./account-data');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createRealtime } = require('./realtime');
const { createRealtime } = require('./realtime');
const { createSimulator } = require('./sim');
const { createSimulator } = require('./sim');
const { createOnvif } = require('./onvif');
const { createOnvif } = require('./onvif');
@@ -86,6 +88,17 @@ function createApp(opts = {}) {
@@ -86,6 +88,17 @@ function createApp(opts = {}) {
next();
next();
});
});
app.use(cookieParser());
app.use(cookieParser());
+
+ // ── OpenVibe.Events → Bot (loopback only: nginx answers 404 for /internal/) ──
+ // network.account.export_requested and network.account.deleted (ADR-033), answered by openvibe-sdk/account-data's
+ // consumer over account-data.js. It reads the raw body itself (the v2 signature covers it) and refuses a request that
+ // came through a proxy. The sender posts to Network's internal routes with Bot's own token.
+ const accountData = accountDataLib.create({ domain, log });
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Bot cannot answer account events'); });
+ app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
+
// The OpenVibe Frame's scripts and the showcase stylesheet the front page links (content-hashed URLs).
// The OpenVibe Frame's scripts and the showcase stylesheet the front page links (content-hashed URLs).
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
@@ -150,6 +150,10 @@ function loadConfig(env = process.env) {
@@ -150,6 +150,10 @@ function loadConfig(env = process.env) {
events: {
events: {
url: trim(env.EVENTS_URL || ''),
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
+ // OpenVibe.Events → Bot (ADR-033 account export and deletion, server/account-data.js): the secret(s) that sign
+ // a delivery to POST /internal/events (comma-separated for rotation, 32+ characters each). Unset: the route
+ // answers 503 and no subscription is created at boot.
+ secrets: String(env.BOT_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
},
},
// Pairing (ADR-043 decision 2): an 8-character Crockford base32 code, XXXX-XXXX, 10 minutes,
// Pairing (ADR-043 decision 2): an 8-character Crockford base32 code, XXXX-XXXX, 10 minutes,
@@ -19,6 +19,7 @@ const { seedProfiles } = require('./profiles');
@@ -19,6 +19,7 @@ const { seedProfiles } = require('./profiles');
const { createApp } = require('./app');
const { createApp } = require('./app');
const { createValkey } = require('openvibe-sdk/valkey');
const { createValkey } = require('openvibe-sdk/valkey');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
+const { startSubscriptions } = require('openvibe-sdk/account-data');
const { createRegistry } = require('openvibe-shared/metrics');
const { createRegistry } = require('openvibe-shared/metrics');
async function main() {
async function main() {
@@ -62,6 +63,13 @@ async function main() {
@@ -62,6 +63,13 @@ async function main() {
// WebSocket sockets are not tracked by server.close. drainMs bounds the drain; deadlineMs 5000 keeps the old 5 s
// WebSocket sockets are not tracked by server.close. drainMs bounds the drain; deadlineMs 5000 keeps the old 5 s
// hard timer and deadlineExitCode 0 its exit 0. Bot's manifest declares no lifecycle.shutdown deadline, so the
// hard timer and deadlineExitCode 0 its exit 0. Bot's manifest declares no lifecycle.shutdown deadline, so the
// kit's 5000 ms default is the right value.
// kit's 5000 ms default is the right value.
+ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
+ // BOT_EVENTS_SECRET or the client secret.
+ const subscriptions = startSubscriptions({
+ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
+ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ });
+
const { stop: shutdown } = gracefulStop({
const { stop: shutdown } = gracefulStop({
name: 'Bot',
name: 'Bot',
server,
server,
@@ -76,6 +84,7 @@ async function main() {
@@ -76,6 +84,7 @@ async function main() {
() => hub.close().catch(() => {}),
() => hub.close().catch(() => {}),
() => outbox.stop(),
() => outbox.stop(),
() => usage.stop(),
() => usage.stop(),
+ () => { if (subscriptions) subscriptions.stop(); },
],
],
close: [
close: [
() => db.close().catch(() => {}),
() => db.close().catch(() => {}),
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * ADR-033: Bot's part of an account export and of an account deletion, through the signed loopback route
+ * POST /internal/events with a stand-in Network. Alex owns a robot with a paired device and is an operator on Bob's
+ * robot. The export carries Alex's robots and roles. The deletion removes Alex's robot the way its owner would (its
+ * operators and command history with it), revokes the device that served only it, removes Alex's role on Bob's robot
+ * and takes Alex's id out of Bob's command history, keeps Bob's robot, and confirms once.
+ */
+const assert = require('assert');
+const http = require('http');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/app');
+
+const SECRET = `whsec_${'fixture'.repeat(6)}`;
+
+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_bot', token_type: 'Bearer', expires_in: 300 });
+ calls.push({ url: req.url, auth: req.headers.authorization, 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)) };
+}
+
+(async () => {
+ const stub = await startNetworkStub();
+ const t = await boot({ env: { BOT_EVENTS_SECRET: SECRET }, appOpts: { accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'bot', clientSecret: 'shh' }) } });
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ const db = t.db;
+ const count = async (sql, args) => Number(await db.value(sql, args));
+ const deliver = async (event, { secret = SECRET, headers = {} } = {}) => {
+ const body = JSON.stringify({ event, seq: 1 });
+ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
+ let mine;
+ let theirs;
+ let deviceId;
+
+ try {
+ await check('alex\'s robot with a paired device, and alex as an operator on bob\'s robot', async () => {
+ const made = await t.robot(alex, { name: 'Alex rover' });
+ mine = made.robot;
+ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: mine.id, code: made.pairing.code } });
+ assert.ok(paired.status < 300, paired.text);
+ deviceId = paired.json.device_id || paired.json.device?.id || (await db.value("SELECT id FROM devices WHERE robot_ids @> $1::jsonb", [JSON.stringify([mine.id])]));
+ theirs = (await t.robot(bob, { name: 'Bob rover' })).robot;
+ const add = await t.call('POST', `/api/v1/robots/${theirs.id}/operators`, { user: bob, body: { subject: alex.subject, role: 'operator' } });
+ assert.ok(add.status < 300, add.text);
+ const at = new Date().toISOString();
+ await db.exec(`INSERT INTO command_audit (robot_id, operator_subject, operator_kind, role, kind, result, at) VALUES
+ ($1, $2, 'user', 'operator', 'drive', 'ack', $4), ($3, $5, 'user', 'operator', 'drive', 'ack', $4)`, [theirs.id, alex.subject, mine.id, at, bob.subject]);
+ });
+
+ await check('the export carries alex\'s robots, roles and commands, and nothing of bob\'s', async () => {
+ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: alex.subject }));
+ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'exported'], JSON.stringify(r.json));
+ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');
+ assert.strictEqual(part.auth, 'Bearer tok_bot');
+ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
+ assert.deepStrictEqual(files['robots.json'].map((x) => x.name), ['Alex rover']);
+ assert.ok(files['operating.json'].some((o) => o.robot_id === theirs.id));
+ assert.ok(!JSON.stringify(part.body).includes(bob.subject));
+ assert.ok(!/credential|publish_key/i.test(JSON.stringify(part.body)), 'no device secret');
+ });
+
+ await check('the deletion removes alex\'s robot, revokes its device, removes alex from bob\'s robot; once', async () => {
+ const event = ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: alex.subject });
+ const r = await deliver(event);
+ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'erased'], JSON.stringify(r.json));
+ assert.strictEqual(await count('SELECT count(*) FROM robots WHERE owner_subject = $1', [alex.subject]), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM command_audit WHERE robot_id = $1', [mine.id]), 0, 'her robot\'s history goes with it');
+ const dev = await db.maybe('SELECT revoked_at, name FROM devices WHERE id = $1', [deviceId]);
+ assert.ok(dev.revoked_at, 'the device that served only her robot is revoked');
+ assert.strictEqual(dev.name, null);
+ assert.strictEqual(await count('SELECT count(*) FROM robot_operators WHERE subject = $1', [alex.subject]), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM command_audit WHERE operator_subject = $1', [alex.subject]), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM command_audit WHERE robot_id = $1 AND operator_subject IS NULL', [theirs.id]), 1, 'bob keeps his robot\'s history, without her id');
+ assert.strictEqual(await count('SELECT count(*) FROM robots WHERE id = $1', [theirs.id]), 1, 'bob\'s robot stays');
+ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
+ assert.strictEqual(conf.length, 1);
+ // robot_operators: her owner row on her own robot and her operator role on bob's.
+ assert.deepStrictEqual([conf[0].body.erased.robots, conf[0].body.erased.robot_operators, conf[0].body.retained.devices], [1, 2, 1]);
+ assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
+ assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);
+ });
+
+ await check('the route refuses a bad signature and a request that came through a proxy', async () => {
+ const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: alex.subject });
+ assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);
+ assert.strictEqual((await deliver(event, { headers: { 'X-Forwarded-For': '203.0.113.9' } })).status, 403);
+ });
+ } finally {
+ await t.close();
+ await stub.close();
+ }
+ done();
+})();