openvibers/openvibe.services Commits

Services: account export and deletion (ADR-033) — a person's runs deleted; manifests and releases stay with their project without them; audit logs kept; loopback /internal/events; subscriptions at boot (SDK v0.37.0) (#12)

OP OpenVibers committed 9 Oct 2026 · 40b3ae3a84 · 13 files, +241 −10

Changes

ddiff --git a/.env.example b/.env.example
iindex f9f5f75..ba5d215 100644
---- a/.env.example
++++ b/.env.example
@@@ -87,3 +87,6 @@ SERVICES_NETWORK_TIMEOUT_MS=5000
EVENTS_URL=
#SERVICES_EVENTS_ENABLED=1
#EVENTS_RELAY_INTERVAL_MS=2000
++# OpenVibe.Events → Services (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.
++#SERVICES_EVENTS_SECRET=
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex ce3d94d..73a6c57 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,12 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
## Unreleased
++- Account export and deletion (ADR-033): `POST /internal/events` (loopback, `SERVICES_EVENTS_SECRET`) answers network.account.export_requested and network.account.deleted through `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.1).
++ - **Deleted:** the person's playground and recipe runs.
++ - **Authorless:** manifests and releases stay with their project, without the person.
++ - **Kept:** the audit logs and staff trust tiers.
++ - The two subscriptions are created at boot. Migration `0003_account_data.sql`, `test/account-data.test.js`.
++
### Changed
- **The developer portal moved here from OpenVibe.Codes** (owner decision 2026-10-08; Codes became the open coding-agent
ddiff --git a/README.md b/README.md
iindex dd0ee06..4e067f5 100644
---- a/README.md
++++ b/README.md
@@@ -125,6 +125,24 @@ In Network (`server/identity/principals.js` / `server/db/database.js`, as for co
- OAuth client `services`, name `OpenVibe.Services`, redirect URI `https://openvibe.services/auth/callback`.
- Service grant `['services', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).
++- Account export and deletion (ADR-033): `['services', 'events.subscription.manage', 'openvibe.events', []]` (the two
++ account subscriptions, created at boot), then, last and once the release is live,
++ `network.account.export.contribute` and `network.account.deletion.confirm` for audience `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
++`SERVICES_EVENTS_SECRET`. They are answered by `server/domain/account-data.js` over `openvibe-sdk/account-data`, with
++one receipt per export and deletion in `account_data_events` (migration 0003).
++
++- **Export:** the playground and recipe runs the person started, and the manifests and releases they created.
++- **Deletion:**
++ - Their runs are deleted.
++ - Manifests and releases belong to their project and stay: `created_by` becomes `deleted` and the publisher,
++ deprecator and reviser become NULL.
++ - The resource index's owner becomes NULL.
++ - The append-only release log, the control log and the staff trust tiers stay attributed, for accountability.
The Services service manifest, `services.release.manage|read`, `services.resource.read` and `services.app-manifest@1` are released in openvibe-contracts v0.114.0 (they replaced the `codes.*` forms, retired that release); the CI contracts check is blocking.
ddiff --git a/STATUS.json b/STATUS.json
iindex 04ad240..814b8f6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"port": 4930,
"domain": "openvibe.services",
"contracts": "openvibe-contracts v0.114.0",
-- "sdk": "openvibe-sdk v0.35.1",
++ "sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.15.0",
"packages": [
"openvibe-contracts v0.113.0",
ddiff --git a/migrations/0003_account_data.sql b/migrations/0003_account_data.sql
nnew file mode 100644
iindex 0000000..736f725
---- /dev/null
++++ b/migrations/0003_account_data.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- OpenVibe.Services: 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 6e3d9a2..73aa339 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.114.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",
++ "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"
},
@@@ -906,9 +906,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.35.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",
-- "integrity": "sha512-id+GBVYaTczHL6ZyOZJTE16KSJH/VNaEo3aIk9dxDdZSYHD2zWnSQlSfEfA+ffo6rgzoSm09/AqaR+a4y8QEPw==",
++ "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 afa3295..b92d536 100644
---- a/package.json
++++ b/package.json
@@@ -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.114.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",
++ "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"
},
ddiff --git a/server/app.js b/server/app.js
iindex 164bfb0..4bd3556 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -58,6 +58,8 @@ const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const { ServiceError } = require('./util');
++const accountDataLib = require('./domain/account-data');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
// The resource index takes query strings; nothing there uploads.
@@@ -148,6 +150,16 @@ async function createApp(opts = {}) {
}));
app.use(cookieParser());
++ // ── OpenVibe.Events → Services (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 domain/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 Services' own token.
++ ctx.accountData = accountDataLib.create({ db: store.db, log });
++ ctx.accountSend = opts.accountSend || (config.oauth.clientSecret
++ ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl || globalThis.fetch })
++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Services cannot answer account events'); });
++ app.post('/internal/events', ctx.accountData.consumer({ secrets: config.events.secrets, send: ctx.accountSend, log }));
++
// ── Machine endpoints ───────────────────────────────────
// Liveness: the process answers. What it can and cannot do is /api/ready's job.
app.get('/api/health', (req, res, next) => outbox.status().then((events) => res.json({
ddiff --git a/server/config.js b/server/config.js
iindex df88aa1..538302c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -145,6 +145,10 @@ function loadConfig(env = process.env) {
enabled: bool(env.SERVICES_EVENTS_ENABLED, true),
url: trim(env.EVENTS_URL || ''),
intervalMs: Math.max(50, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),
++ // OpenVibe.Events → Services (ADR-033 account export and deletion, server/domain/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.SERVICES_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
},
// IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment a
ddiff --git a/server/domain/account-data.js b/server/domain/account-data.js
nnew file mode 100644
iindex 0000000..51da924
---- /dev/null
++++ b/server/domain/account-data.js
@@@ -0,0 +1,63 @@
++'use strict';
++
++/**
++ * Account export and deletion → Services (ADR-033; openvibe-sdk/account-data). Services stores a person as
++ * `user:usr_…` (an app acts as `app:app_…`, which is never a person's).
++ *
++ * their own, deleted playground runs and recipe runs they started
++ * project rows, authorless manifests and releases belong to the project and its app, not to the person who pressed
++ * the button: they stay. created_by is NOT NULL there, so it becomes `deleted` (the resource
++ * index then reports no owner); published_by, deprecated_by and revoked_by become NULL; the
++ * resource index's owner becomes NULL
++ * kept release_log (append-only by trigger), services_control_log and the trust tiers a staff
++ * member set: the audit trail of who published, revoked, reviewed or changed what, kept for
++ * accountability (counted as retained)
++ *
++ * The export carries what they started and what they created. Nothing exported is a secret: manifests hold no
++ * credentials, and the control log is not exported.
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const user = (usr) => `user:${usr}`;
++const anonymize = { anonymize: {} };
++
++const TABLES = [
++ { table: 'playground_runs', subject: 'actor', value: user, file: 'playground-runs.json', order: 'at', columns: ['id', 'at', 'project_id', 'app_id', 'kind', 'capability', 'credential', 'outcome', 'stage', 'http_status', 'code', 'detail'] },
++ { table: 'services_recipe_runs', subject: 'subject', value: (usr) => [usr, user(usr)], file: 'recipe-runs.json', order: 'started_at' },
++ { table: 'releases', subject: 'published_by', value: user, file: null, erase: anonymize },
++ { table: 'releases', subject: 'deprecated_by', value: user, file: null, erase: anonymize },
++ { table: 'releases', subject: 'revoked_by', value: user, file: null, erase: anonymize },
++ { table: 'services_resources', subject: 'owner_subject', value: (usr) => [usr, user(usr)], file: null, erase: anonymize },
++ { table: 'release_log', subject: 'actor', value: user, file: null, erase: { keep: 'the append-only release log is the audit trail of who published or revoked what' } },
++ { table: 'trust', subject: 'set_by', value: user, file: null, erase: { keep: 'a trust tier names the staff member who set it, for accountability' } },
++ { table: 'trust_history', subject: 'set_by', value: user, file: null, erase: { keep: 'the trust history names the staff member who set each tier, for accountability' } },
++ { table: 'services_control_log', subject: 'actor_subject', value: (usr) => [usr, user(usr)], file: null, erase: { keep: 'the control-plane audit log, kept for accountability' } },
++];
++
++/** Manifests and releases they created: exported, then made authorless in extraErase (created_by is NOT NULL). */
++async function extraExport(db, subject) {
++ const files = [];
++ const manifests = await db.many(`SELECT id, kind, app_id, project_id, subject_id, version, body, created_at FROM manifests
++ WHERE created_by = $1 ORDER BY created_at DESC LIMIT 5000`, [user(subject)]);
++ if (manifests.length) files.push({ name: 'manifests.json', content: manifests });
++ const releases = await db.many(`SELECT id, app_id, project_id, environment, kind, subject_id, name, version, status, notes, created_at
++ FROM releases WHERE created_by = $1 ORDER BY created_at DESC LIMIT 5000`, [user(subject)]);
++ if (releases.length) files.push({ name: 'releases.json', content: releases });
++ return files;
++}
++
++async function extraErase(t, subjects, counts) {
++ const forms = subjects.map(user);
++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE manifests SET created_by = 'deleted' WHERE created_by = ANY($1::text[])", [forms]));
++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE releases SET created_by = 'deleted' WHERE created_by = ANY($1::text[])", [forms]));
++}
++
++/** The account-data handle for Services' database (store.db). */
++function create({ db, log = console } = {}) {
++ return createAccountData({
++ db, service: 'services', tables: TABLES, extraExport, extraErase, log,
++ note: 'Manifests and releases belong to their project and stay there without your name.',
++ });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/index.js b/server/index.js
iindex 5bae711..72900cd 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -18,6 +18,7 @@ const { loadConfig } = require('./config');
const { openDb, migrate, createStore } = require('./db');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
++const { startSubscriptions } = require('openvibe-sdk/account-data');
const { createRegistry } = require('openvibe-shared/metrics');
/**
@@@ -25,10 +26,10 @@ const { createRegistry } = require('openvibe-shared/metrics');
* and the outbox relay stop and the store closes; past the deadline the process exits 0. Exported so a test can
* inject `exit` and `signals: false`.
*/
--function createLifecycle({ server, ctx, exit, signals }) {
++function createLifecycle({ server, ctx, exit, signals, extra = [] }) {
return gracefulStop({
name: 'Services', server, drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 0, exit, signals,
-- stop: [() => ctx.keys.client.stop(), () => ctx.outbox.stop()],
++ stop: [() => ctx.keys.client.stop(), () => ctx.outbox.stop(), ...extra],
close: [() => ctx.store.close().catch(() => {})],
});
}
@@@ -55,7 +56,13 @@ async function main() {
// good keys through outages, exponential backoff, unknown-kid floods throttled, an unref'd timer.
keys.client.start();
-- const { stop: shutdown } = createLifecycle({ server, ctx });
++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
++ // SERVICES_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 } = createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
return { app, server, ctx, shutdown };
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..c0e1979
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,105 @@
++'use strict';
++/**
++ * ADR-033: Services' part of an account export and of an account deletion, through the signed loopback route
++ * POST /internal/events with a stand-in Network. A person's playground runs go; the manifests and releases they made
++ * stay with their project without their id (created_by becomes 'deleted', the publisher NULL); the append-only release
++ * log and staff trust tiers stay attributed; a redelivery erases nothing twice; a bad signature and a forwarded
++ * request are refused.
++ */
++const assert = require('assert');
++const http = require('http');
++const { ids } = require('openvibe-contracts');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { boot, check, done } = require('./helpers/boot');
++
++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_services', 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: { SERVICES_EVENTS_SECRET: SECRET }, accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'services', clientSecret: 'services-secret' }) });
++ const db = t.ctx.store.db;
++ const me = ids.newId('user');
++ const other = ids.newId('user');
++ 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 });
++ const at = new Date().toISOString();
++ const prj = ids.newId('project');
++ const app = ids.newId('app');
++
++ try {
++ await check('the person\'s rows: a manifest, a release they published, a playground run, a log entry, a trust tier', async () => {
++ await db.exec(`INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at)
++ VALUES ('mfs_1', 'app', $1, $2, $1, '1.0.0', '{"name":"My app"}', '0.119.0', $3, $4)`, [app, prj, `user:${me}`, at]);
++ await db.exec(`INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, created_by, created_at, published_by, published_at)
++ VALUES ('rel_1', $1, $2, 'production', 'app', $1, 'My app', '1.0.0', 'mfs_1', 'published', $3, $4, $3, $4)`, [app, prj, `user:${me}`, at]);
++ await db.exec(`INSERT INTO release_log (release_id, action, actor, at) VALUES ('rel_1', 'release.published', $1, $2)`, [`user:${me}`, at]);
++ await db.exec(`INSERT INTO trust (app_id, tier, note, set_by, set_at) VALUES ($1, 'reviewed', '', $2, $3)`, [app, `user:${me}`, at]);
++ for (const [id, who] of [['run_1', me], ['run_2', other]]) {
++ await db.exec(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage)
++ VALUES ($1, $2, $3, $4, $5, 'events', 'events.event.publish', 'access_token', 'ok', 'done')`, [id, at, `user:${who}`, prj, app]);
++ }
++ });
++
++ await check('the export carries what they made and ran, and nobody else\'s', async () => {
++ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: me }));
++ 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_services');
++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['manifests.json', 'playground-runs.json', 'releases.json']);
++ assert.strictEqual(part.body.files.find((f) => f.name === 'playground-runs.json').content.length, 1);
++ assert.ok(!JSON.stringify(part.body).includes(other));
++ });
++
++ await check('the deletion removes their runs, leaves the project\'s manifest and release authorless, keeps the audit trail; once', async () => {
++ const event = ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: me });
++ 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 playground_runs WHERE actor = $1', [`user:${me}`]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM playground_runs WHERE actor = $1', [`user:${other}`]), 1);
++ assert.strictEqual(await db.value("SELECT created_by FROM manifests WHERE id = 'mfs_1'"), 'deleted');
++ const rel = await db.maybe("SELECT created_by, published_by, status FROM releases WHERE id = 'rel_1'");
++ assert.deepStrictEqual([rel.created_by, rel.published_by, rel.status], ['deleted', null, 'published'], 'the release stays published');
++ assert.strictEqual(await count('SELECT count(*) FROM release_log WHERE actor = $1', [`user:${me}`]), 1, 'the append-only log stays');
++ assert.strictEqual(await count('SELECT count(*) FROM trust WHERE set_by = $1', [`user:${me}`]), 1, 'the staff tier stays attributed');
++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
++ assert.strictEqual(conf.length, 1);
++ assert.strictEqual(conf[0].body.erased.playground_runs, 1);
++ assert.deepStrictEqual([conf[0].body.retained.release_log, conf[0].body.retained.trust], [1, 1]);
++ assert.ok(conf[0].body.retained.tombstones >= 3);
++ 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: me });
++ 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();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 4badeb2..0ba60e3 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or SERVICES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
-- const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow });
++ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow, accountSend: opts.accountSend });
await built.ctx.keys.ensure();
if (opts.relay) built.ctx.outbox.start();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });