openvibers/openvibe.watch Commits

Watch: account export and deletion (ADR-033) — a person's watches (with their checks and observations) and sessions deleted, watches exported; loopback /internal/events; subscriptions at boot (SDK v0.37.0) (#9)

OP OpenVibers committed 9 Oct 2026 · 676689c37a · 14 files, +220 −14

Changes

ddiff --git a/.env.example b/.env.example
iindex 5ec59eb..9022e18 100644
---- a/.env.example
++++ b/.env.example
@@@ -55,6 +55,9 @@ VALKEY_PREFIX=
# OpenVibe.Events. The outbox relay runs only when EVENTS_URL is set.
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_RELAY_INTERVAL_MS=2000
++# OpenVibe.Events → Watch (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.
++#WATCH_EVENTS_SECRET=
# Check worker (in-process scheduler)
#WATCH_WORKER=on
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex cb4da4e..45867ca 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,15 @@
What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry names
the step it landed.
++## 2026-10-08 — account export and deletion (ADR-033)
++
++- `POST /internal/events` (loopback, `WATCH_EVENTS_SECRET`) answers network.account.export_requested and
++ network.account.deleted through `server/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.36.0).
++- **Export:** the person's watches.
++- **Deletion:** their watches with every check run, observation and endpoint state under them, and their sessions.
++- The two subscriptions are created at boot. Migration `0003_account_data.sql`, nginx `location /internal/ { return 404; }`,
++ `test/account-data.test.js`.
++
## 2026-10-08 — the public site (T18 step 8)
- **Sign in with OpenVibe** at `/auth/login`: OAuth 2 authorization code with PKCE (S256) as the
ddiff --git a/README.md b/README.md
iindex 1545be6..f4fbd2f 100644
---- a/README.md
++++ b/README.md
@@@ -261,6 +261,19 @@ A watch owned by a service (`svc:…`) still records every observation and trigg
but the person-facing payloads name a `usr_…`/`agt_…` owner (and a trigger a `usr_…` recipient), so
those two events are logged as not emitted rather than sent with a payload the contract refuses.
++## Account export and deletion (ADR-033)
++
++`network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events`. It is
++loopback-only (nginx answers 404 for `/internal/`, and the handler refuses a forwarded request) and signed with
++`WATCH_EVENTS_SECRET`. They are answered by `server/account-data.js` over `openvibe-sdk/account-data`.
++
++- **Export:** the person's watch definitions, with how many check runs and observations each has.
++- **Deletion:** their watches go with every check run, observation and conditional-GET state under them, and so do
++ their sign-in sessions (never exported). A staff edit's `updated_by` on someone else's watch becomes NULL.
++- **Receipts:** `account_data_events` (migration 0003) keeps one per export and deletion, so a redelivery changes
++ nothing.
++- **Subscriptions:** the two are created at boot when missing.
++
## Security
Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@@ -310,8 +323,9 @@ Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
`openvibe-sdk/db`
- OpenVibe.Contracts (the watch contracts, service tokens, capability checks, problem details, ids,
the event envelope; `wch_`/`wco_`/`ckr_` in `lib/ids.js`)
--- OpenVibe.Network (signing key; service principal `watch`)
--- OpenVibe.Events (outbound events)
++- OpenVibe.Network (signing key; service principal `watch`; grants `network.account.export.contribute` and
++ `network.account.deletion.confirm` for account export and deletion, given last, once this release is live)
++- OpenVibe.Events (outbound events; the two account subscriptions, created at boot with `events.subscription.manage`)
## Capabilities
ddiff --git a/STATUS.json b/STATUS.json
iindex 30db540..b69b78e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"port": 4730,
"domain": "openvibe.watch (a public server-rendered site + a loopback-only API; vhost in deploy/nginx; README \"Public site\" and \"Public host\")",
"contracts": "openvibe-contracts v0.112.0",
-- "sdk": "openvibe-sdk v0.35.0",
++ "sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.13.2",
"capabilities": [
"watch.watch.read",
ddiff --git a/deploy/nginx/openvibe.watch.conf b/deploy/nginx/openvibe.watch.conf
iindex b9ef2c7..4b7cf50 100644
---- a/deploy/nginx/openvibe.watch.conf
++++ b/deploy/nginx/openvibe.watch.conf
@@@ -133,6 +133,10 @@ server {
proxy_pass http://127.0.0.1:4730;
}
++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
++ # Events posts to http://127.0.0.1:4730/internal/events directly; never proxied from outside (ADR-033).
++ location /internal/ { return 404; }
++
# ── Pages ───────────────────────────────────────────────
location / {
limit_req zone=watch_pages burst=20 nodelay;
ddiff --git a/migrations/0003_account_data.sql b/migrations/0003_account_data.sql
nnew file mode 100644
iindex 0000000..daacca0
---- /dev/null
++++ b/migrations/0003_account_data.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- OpenVibe.Watch: 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 52864fb..7ab5c05 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.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"
},
@@@ -785,9 +785,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.36.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
-- "integrity": "sha512-mb+VvCuFH3KnMw4LWvqMDEoF93rNEb+eQi8HfT6mj+RPN5eQFWMcc/DCqJ3L0Yzjr99tQ08+DLwaYdZDIlfD0A==",
++ "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 e319ba4..fb51ebb 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.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"
},
ddiff --git a/server/account-data.js b/server/account-data.js
nnew file mode 100644
iindex 0000000..fbe2df3
---- /dev/null
++++ b/server/account-data.js
@@@ -0,0 +1,43 @@
++'use strict';
++
++/**
++ * Account export and deletion → Watch (ADR-033; openvibe-sdk/account-data). What Watch holds about a person:
++ *
++ * watches their watches (owner_sub), with everything that hangs under a watch: its check runs, observations
++ * and conditional-GET state. Exported (the watch definitions, and how many checks and observations
++ * each has) and deleted whole.
++ * web_sessions their sign-in sessions on openvibe.watch (hashed cookie tokens): deleted, never exported.
++ *
++ * The watches go in extraErase, not the table map: their rows are found by watch id, so the children go first.
++ * updated_by on someone else's watch (a staff edit) becomes NULL. No secret is exported.
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const TABLES = [
++ { table: 'web_sessions', subject: 'subject', file: null },
++ { table: 'watches', subject: 'updated_by', file: null, erase: { anonymize: {} } },
++];
++
++async function extraExport(db, subject) {
++ const watches = await db.many(`SELECT w.id, w.project_id, w.name, w.status, w.source, w.cadence, w.extraction, w.comparison, w.condition,
++ w.action, w.budget, w.retention, w.labels, w.last_check_at, w.last_success_at, w.last_state, w.created_at, w.updated_at, w.deleted_at,
++ (SELECT count(*)::int FROM check_runs c WHERE c.watch_id = w.id) AS check_runs,
++ (SELECT count(*)::int FROM observations o WHERE o.watch_id = w.id) AS observations
++ FROM watches w WHERE w.owner_sub = $1 ORDER BY w.created_at DESC LIMIT 5000`, [subject]);
++ return watches.length ? [{ name: 'watches.json', content: watches }] : [];
++}
++
++async function extraErase(t, subjects, counts) {
++ const mine = '(SELECT id FROM watches WHERE owner_sub = ANY($1::text[]))';
++ counts.add(counts.erased, 'observations', await t.exec(`DELETE FROM observations WHERE watch_id IN ${mine}`, [subjects]));
++ counts.add(counts.erased, 'check_runs', await t.exec(`DELETE FROM check_runs WHERE watch_id IN ${mine}`, [subjects]));
++ counts.add(counts.erased, 'watch_endpoint_state', await t.exec(`DELETE FROM watch_endpoint_state WHERE watch_id IN ${mine}`, [subjects]));
++ counts.add(counts.erased, 'watches', await t.exec('DELETE FROM watches WHERE owner_sub = ANY($1::text[])', [subjects]));
++}
++
++/** The account-data handle for Watch's database (server/db.js). */
++function create({ db, log = console } = {}) {
++ return createAccountData({ db, service: 'watch', tables: TABLES, extraExport, extraErase, log });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/app.js b/server/app.js
iindex a04954e..53010ef 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -57,7 +57,7 @@ const CSP = "default-src 'self'; script-src 'self' 'unsafe-inline' https://openv
+ "connect-src 'self' https://openvibe.network https://openvibe.events https://cloudflareinsights.com; "
+ "frame-src 'self' https://openvibe.network; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self' https://openvibe.network";
--function createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log = console, limitsNow = null, sessions, keyStore, siteLimits, fetchImpl = globalThis.fetch }) {
++function createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log = console, limitsNow = null, sessions, keyStore, siteLimits, fetchImpl = globalThis.fetch, accountData = null, accountSend = null }) {
const app = express();
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy != null ? config.trustProxy : 'loopback');
@@@ -77,6 +77,14 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
if (req.path.startsWith('/api') || req.path.startsWith('/auth')) res.setHeader('Cache-Control', 'no-store');
next();
});
++ // ── OpenVibe.Events → Watch (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 server/account-data.js. It reads the raw body itself (the v2 signature covers it), so no body
++ // parser runs before it; a request that came through a proxy is refused.
++ if (accountData) {
++ const send = accountSend || (async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Watch cannot answer account events'); });
++ app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets || [], send, log }));
++ }
app.use('/api', express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] }));
app.get('/api/health', (_req, res) => {
ddiff --git a/server/config.js b/server/config.js
iindex 020c897..bc3a14c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -73,6 +73,10 @@ function load(env = process.env) {
events: {
url: strip(env.EVENTS_URL || ''),
relayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ // OpenVibe.Events → Watch (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 → 503,
++ // and no subscription is created at boot).
++ secrets: String(env.WATCH_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
},
// The check worker: every WATCH_TICK_MS, start checks for active watches whose turn has come.
ddiff --git a/server/index.js b/server/index.js
iindex cc646b1..2bba730 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -29,8 +29,10 @@ const { createSessions } = require('./sessions');
const { createKeyStore } = require('./auth/keys');
const { createSiteLimits } = require('./http/site-limits');
const { createApp } = require('./app');
++const accountDataLib = require('./account-data');
++const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');
--async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokenClient, lookupImpl, log = console, listen = true, limitsNow = null } = {}) {
++async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokenClient, lookupImpl, log = console, listen = true, limitsNow = null, accountSend: givenSend = null } = {}) {
config = config || load();
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
const db = givenDb || await openDb(config, { log });
@@@ -58,7 +60,13 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
const sessions = createSessions({ db, now });
const keyStore = createKeyStore({ config, fetchImpl, log });
const siteLimits = createSiteLimits({ now: limitsNow || (() => Date.now()) });
-- const app = createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log, limitsNow, sessions, keyStore, siteLimits });
++ // Account export and deletion (ADR-033): the table map, and the sender to Network's internal routes with Watch's own
++ // client-credentials token (a test hands in a stand-in).
++ const accountData = accountDataLib.create({ db, log });
++ const accountSend = givenSend || (config.oauth.clientSecret
++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
++ : null);
++ const app = createApp({ config, db, registry, check, observations, scheduler, auth, outbox, relay, now, log, limitsNow, sessions, keyStore, siteLimits, accountData, accountSend });
// One JWKS client for the process (the SDK shares it with verifyUserToken): refresh in the background
// on an unref'd timer, keeping the last good keys through a Network outage.
keys.start();
@@@ -81,10 +89,17 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
});
log.log(`[watch] listening on http://${config.host}:${server.address().port}`);
}
++ // The two account subscriptions at OpenVibe.Events, created when missing; off without EVENTS_URL,
++ // WATCH_EVENTS_SECRET or the client secret.
++ const subscriptions = listen ? startSubscriptions({
++ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: (config.events.secrets || [])[0],
++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl, log,
++ }) : null;
async function close() {
clearInterval(pruneTimer);
clearInterval(outboxPruneTimer);
++ if (subscriptions) subscriptions.stop();
keys.stop();
await scheduler.stop();
await relay.stop();
@@@ -95,7 +110,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
if (!givenDb) await db.close();
}
-- return { config, db, registry, observations, guard, fetcher, spacer, carriers, extract, condition, check, scheduler, outbox, relay, keys, keyLoaded, auth, sessions, keyStore, siteLimits, app, server, close };
++ return { config, db, registry, observations, guard, fetcher, spacer, carriers, extract, condition, check, scheduler, outbox, relay, keys, keyLoaded, auth, sessions, keyStore, siteLimits, accountData, app, server, close };
}
/**
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..c2d3a41
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * ADR-033: Watch's 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 watches (with their check runs and observations) and
++ * sign-in sessions go; someone else's watch stays; the export never carries a session; a redelivery erases nothing
++ * twice; a bad signature and a forwarded request are refused.
++ */
++const assert = require('assert');
++const http = require('http');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { boot, request, userToken, site, suite, watchDef, newUser } = require('./helpers');
++
++const t = suite('account-data');
++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_watch', 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)) };
++}
++
++const me = newUser();
++const other = newUser();
++let svc;
++let web;
++let stub;
++const deliver = async (event, { secret = SECRET, headers = {} } = {}) => {
++ const body = JSON.stringify({ event, seq: 1 });
++ const res = await fetch(`${svc.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 count = async (sql, args) => Number(await svc.db.value(sql, args));
++
++t('boot, and the person\'s watches, checks and sessions', async () => {
++ stub = await startNetworkStub();
++ web = await site({ '/price': () => ({ body: '19.99' }) });
++ svc = await boot({ env: { WATCH_EVENTS_SECRET: SECRET }, accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'watch', clientSecret: 'watch-secret' }) });
++ for (const [who, name] of [[me, 'my price'], [other, 'their price']]) {
++ const r = await request(svc.base, 'POST', '/api/v1/watches', { token: userToken(who), body: watchDef({ name, source: { kind: 'http', url: `${web.origin}/price`, format: null } }) });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual((await request(svc.base, 'POST', `/api/v1/watches/${r.body.watch.id}/check`, { token: userToken(who) })).status, 200);
++ }
++ await svc.signIn({ subject: me, username: 'me' });
++ assert.strictEqual(await count('SELECT count(*) FROM check_runs c JOIN watches w ON w.id = c.watch_id WHERE w.owner_sub = $1', [me]), 1);
++});
++
++t('the export carries the person\'s watches and no session', async () => {
++ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: me }));
++ assert.deepStrictEqual([r.status, 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_watch');
++ assert.deepStrictEqual(part.body.files.map((f) => f.name), ['watches.json']);
++ assert.deepStrictEqual(part.body.files[0].content.map((w) => [w.name, w.check_runs]), [['my price', 1]]);
++ assert.ok(!JSON.stringify(part.body).includes(other));
++});
++
++t('the deletion removes the person\'s watches with their checks and observations, and their sessions; 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.outcome], [200, 'erased'], JSON.stringify(r.json));
++ assert.strictEqual(await count('SELECT count(*) FROM watches WHERE owner_sub = $1', [me]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM web_sessions WHERE subject = $1', [me]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM check_runs WHERE watch_id NOT IN (SELECT id FROM watches)'), 0, 'no orphaned check runs');
++ assert.strictEqual(await count('SELECT count(*) FROM observations WHERE watch_id NOT IN (SELECT id FROM watches)'), 0, 'no orphaned observations');
++ assert.strictEqual(await count('SELECT count(*) FROM watches WHERE owner_sub = $1', [other]), 1, 'someone else\'s watch stays');
++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
++ assert.strictEqual(conf.length, 1);
++ assert.deepStrictEqual([conf[0].body.erased.watches, conf[0].body.erased.check_runs, conf[0].body.erased.web_sessions], [1, 1, 1]);
++ assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
++ assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);
++});
++
++t('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);
++});
++
++t('stop', async () => { await svc.stop(); await web.close(); await stub.close(); });
++
++t.run();
ddiff --git a/test/helpers.js b/test/helpers.js
iindex a45232c..9298ad9 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -49,7 +49,7 @@ const newAgent = () => require('openvibe-contracts').ids.newId('agent');
* the caller says otherwise (a test of the SSRF guard boots with allowPrivate: false).
* log: Watch's logger (default silent); limitsNow: the per-actor limiter's clock (default the wall clock).
*/
--async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, log = silent, limitsNow = null, allowPrivate = true } = {}) {
++async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, log = silent, limitsNow = null, allowPrivate = true, accountSend = null } = {}) {
// A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real
// to fetch and verify against (no test touches the internet). Its URL is overridable via env.
const jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });
@@@ -77,7 +77,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, tokenClient, now, lo
});
// One database per boot (PGlite, or WATCH_TEST_STORE=pg: the containers), dropped when the boot stops.
const testdb = await require('./db').testDb();
-- const h = await start({ config, db: testdb.db, log, lookupImpl, tokenClient, limitsNow, ...(now ? { now } : {}) });
++ const h = await start({ config, db: testdb.db, log, lookupImpl, tokenClient, limitsNow, accountSend, ...(now ? { now } : {}) });
const base = `http://127.0.0.1:${h.server.address().port}`;
/** A session cookie for a person, as /auth/callback would have set it. */