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)
Changes
@@@ -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@@@ -3,6 +3,15 @@What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry namesthe 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@@@ -261,6 +261,19 @@ A watch owned by a service (`svc:…`) still records every observation and triggbut the person-facing payloads name a `usr_…`/`agt_…` owner (and a trigger a `usr_…` recipient), sothose 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.++## SecurityReporting: [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@@@ -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",@@@ -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;nnew file mode 100644@@@ -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++);@@@ -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"@@@ -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"},nnew file mode 100644@@@ -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 };@@@ -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) => {@@@ -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.@@@ -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(), fetchconst 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(), fetchif (!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 };}/**nnew file mode 100644@@@ -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();@@@ -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. */
@@ -55,6 +55,9 @@ VALKEY_PREFIX=
@@ -55,6 +55,9 @@ VALKEY_PREFIX=
# OpenVibe.Events. The outbox relay runs only when EVENTS_URL is set.
# OpenVibe.Events. The outbox relay runs only when EVENTS_URL is set.
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_RELAY_INTERVAL_MS=2000
#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)
# Check worker (in-process scheduler)
#WATCH_WORKER=on
#WATCH_WORKER=on
@@ -3,6 +3,15 @@
@@ -3,6 +3,15 @@
What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry names
What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry names
the step it landed.
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)
## 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
- **Sign in with OpenVibe** at `/auth/login`: OAuth 2 authorization code with PKCE (S256) as the
@@ -261,6 +261,19 @@ A watch owned by a service (`svc:…`) still records every observation and trigg
@@ -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
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.
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
## Security
Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -310,8 +323,9 @@ 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-sdk/db`
- OpenVibe.Contracts (the watch contracts, service tokens, capability checks, problem details, ids,
- OpenVibe.Contracts (the watch contracts, service tokens, capability checks, problem details, ids,
the event envelope; `wch_`/`wco_`/`ckr_` in `lib/ids.js`)
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
## Capabilities
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"port": 4730,
"port": 4730,
"domain": "openvibe.watch (a public server-rendered site + a loopback-only API; vhost in deploy/nginx; README \"Public site\" and \"Public host\")",
"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",
"contracts": "openvibe-contracts v0.112.0",
- "sdk": "openvibe-sdk v0.35.0",
+ "sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.13.2",
"shared": "openvibe-shared v2.13.2",
"capabilities": [
"capabilities": [
"watch.watch.read",
"watch.watch.read",
@@ -133,6 +133,10 @@ server {
@@ -133,6 +133,10 @@ server {
proxy_pass http://127.0.0.1:4730;
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 ───────────────────────────────────────────────
# ── Pages ───────────────────────────────────────────────
location / {
location / {
limit_req zone=watch_pages burst=20 nodelay;
limit_req zone=watch_pages burst=20 nodelay;
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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
+);
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -785,9 +785,9 @@
@@ -785,9 +785,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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 };
@@ -57,7 +57,7 @@ const CSP = "default-src 'self'; script-src 'self' 'unsafe-inline' https://openv
@@ -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; "
+ "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";
+ "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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy != null ? config.trustProxy : 'loopback');
app.set('trust proxy', config.trustProxy != null ? config.trustProxy : 'loopback');
@@ -77,6 +77,14 @@ function createApp({ config, db, registry, check, observations, scheduler, auth,
@@ -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');
if (req.path.startsWith('/api') || req.path.startsWith('/auth')) res.setHeader('Cache-Control', 'no-store');
next();
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.use('/api', express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] }));
app.get('/api/health', (_req, res) => {
app.get('/api/health', (_req, res) => {
@@ -73,6 +73,10 @@ function load(env = process.env) {
@@ -73,6 +73,10 @@ function load(env = process.env) {
events: {
events: {
url: strip(env.EVENTS_URL || ''),
url: strip(env.EVENTS_URL || ''),
relayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
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.
// The check worker: every WATCH_TICK_MS, start checks for active watches whose turn has come.
@@ -29,8 +29,10 @@ const { createSessions } = require('./sessions');
@@ -29,8 +29,10 @@ const { createSessions } = require('./sessions');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createSiteLimits } = require('./http/site-limits');
const { createSiteLimits } = require('./http/site-limits');
const { createApp } = require('./app');
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();
config = config || load();
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
const db = givenDb || await openDb(config, { log });
const db = givenDb || await openDb(config, { log });
@@ -58,7 +60,13 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -58,7 +60,13 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
const sessions = createSessions({ db, now });
const sessions = createSessions({ db, now });
const keyStore = createKeyStore({ config, fetchImpl, log });
const keyStore = createKeyStore({ config, fetchImpl, log });
const siteLimits = createSiteLimits({ now: limitsNow || (() => Date.now()) });
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
// 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.
// on an unref'd timer, keeping the last good keys through a Network outage.
keys.start();
keys.start();
@@ -81,10 +89,17 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -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}`);
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() {
async function close() {
clearInterval(pruneTimer);
clearInterval(pruneTimer);
clearInterval(outboxPruneTimer);
clearInterval(outboxPruneTimer);
+ if (subscriptions) subscriptions.stop();
keys.stop();
keys.stop();
await scheduler.stop();
await scheduler.stop();
await relay.stop();
await relay.stop();
@@ -95,7 +110,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -95,7 +110,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
if (!givenDb) await db.close();
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 };
}
}
/**
/**
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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();
@@ -49,7 +49,7 @@ const newAgent = () => require('openvibe-contracts').ids.newId('agent');
@@ -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).
* 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).
* 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
// 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.
// 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] }) }) });
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
@@ -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.
// One database per boot (PGlite, or WATCH_TEST_STORE=pg: the containers), dropped when the boot stops.
const testdb = await require('./db').testDb();
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}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
/** A session cookie for a person, as /auth/callback would have set it. */
/** A session cookie for a person, as /auth/callback would have set it. */