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)

OP OpenVibers committed 9 Oct 2026 · 8ec21ad51a · 12 files, +244 −8

Changes

ddiff --git a/.env.example b/.env.example
iindex 2f5ce88..61d584f 100644
---- a/.env.example
++++ b/.env.example
@@@ -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) ───────────────────────
ddiff --git a/README.md b/README.md
iindex a914a9e..e9d9db4 100644
---- a/README.md
++++ b/README.md
@@@ -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
ddiff --git a/STATUS.json b/STATUS.json
iindex 893e9f9..f660a48 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/deploy/nginx/openvibe.bot.conf b/deploy/nginx/openvibe.bot.conf
iindex daec142..0a582bb 100644
---- a/deploy/nginx/openvibe.bot.conf
++++ b/deploy/nginx/openvibe.bot.conf
@@@ -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.
ddiff --git a/migrations/0008_account_data.sql b/migrations/0008_account_data.sql
nnew file mode 100644
iindex 0000000..73eb8d3
---- /dev/null
++++ b/migrations/0008_account_data.sql
@@@ -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
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 8c39d3b..3b947f8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex dac6236..f1b4923 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/account-data.js b/server/account-data.js
nnew file mode 100644
iindex 0000000..1439d82
---- /dev/null
++++ b/server/account-data.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex a1bef9c..3ddbc58 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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());
ddiff --git a/server/config.js b/server/config.js
iindex 053d7ef..5cb659a 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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,
ddiff --git a/server/index.js b/server/index.js
iindex 221993d..735d1d7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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(() => {}),
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..ff3617b
---- /dev/null
++++ b/test/account-data.test.js
@@@ -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();
++})();