openvibers/openvibe.tools Commits

account-data: the tool jobs a person owns, and the erasure of them (#148)

OP OpenVibers committed 14 days ago · 606fac3b2a · 9 files, +234 −19

Tools kept a person's tool jobs with no way to ask for them: the two account topics were never
subscribed and no module answered them. Now Tools answers.

- apps/gateway/server/account-data.js: the module. The tool jobs a person owns (tool_jobs.owner is
  'user:usr_…' for a signed-in person) are theirs — the job, its input, its files, its result — so
  they go in their export and go with the account on a deletion, with the rows that hang under the
  job (its event log, its references). The billing reading of an ended job is a money record: it
  stays, with the person in it a tombstone instead of a name. The usage counts, the tool opens, the
  domain history and the guard's abuse log are the service's own; the analytics are the tracker's
  own (its user_id is a local counter, not a subject).
- apps/gateway/server/revocation-events.js: the signed POST /internal/events answers the two account
  topics next to the sign-out cutoffs, and the subscription covers them too.
- apps/gateway/server/index.js: the module and the sender that posts Tools' part of an export back
  to Network.
- migrations/0009_account_data.sql: the receipts of the deliveries Tools applied.
- apps/gateway/test/account-data.test.js: the export carries their jobs and nobody else's, the
  deletion takes the jobs and the rows under them and leaves the money record without their name, an
  unsigned delivery is refused, and the guard finds no table with a person column that is neither
  covered nor exempted with a reason.

openvibe-sdk 0.74.0 in the gateway (the app that answers for a person); the other seven apps keep
their pin.

Changes

ddiff --git a/README.md b/README.md
iindex b859fd8..4eeb009 100644
---- a/README.md
++++ b/README.md
@@@ -641,6 +641,6 @@ counters and salt use Valkey through `VALKEY_URL`, with process-local state when
<!-- versions:start -->
- openvibe-contracts: v0.222.0
--- openvibe-sdk: v0.67.0
++- openvibe-sdk: v0.74.0
- openvibe-shared: v4.48.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex c7c8cd7..af89e1b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -17,7 +17,7 @@
},
"domain": "openvibe.tools and *.openvibe.tools",
"contracts": "openvibe-contracts v0.222.0 (the gateway, img, audio and docs apps' package.json; the root package.json pins none)",
-- "sdk": "openvibe-sdk v0.67.0 (each app's package.json)",
++ "sdk": "openvibe-sdk v0.74.0 (the gateway's package.json; the other apps are still on v0.67.0)",
"shared": "openvibe-shared v4.48.0 (each app's package.json; the root package.json pins none)",
"capabilities": [
"tools.job.create",
ddiff --git a/apps/gateway/package-lock.json b/apps/gateway/package-lock.json
iindex 31f5c37..64144d9 100644
---- a/apps/gateway/package-lock.json
++++ b/apps/gateway/package-lock.json
@@@ -20,8 +20,8 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"pg": "^8.23.0",
"zxing-wasm": "3.1.5"
}
@@@ -1201,9 +1201,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.67.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "integrity": "sha512-s7NzjaNcowVcOIiup2z0ze+xsq/qnr++3pHSI8JzirqJPQ5JsBSUF5RkjOHyfk9bRKlmhY0fsNS6x4dsYylyqw==",
++ "version": "0.74.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
++ "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
@@@ -1229,9 +1229,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.43.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.43.0",
-- "integrity": "sha512-OANEzCEB2QPgbkb9Yt0O67k6DxcK2pHMO1nhKYCjTI7OhUF8LW8EpLf6FJmpHmDTmpFTUq4pJ3lr3u2hPXMDYQ==",
++ "version": "4.48.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
++ "integrity": "sha512-JzASdCIvFTnJz3Xeng1e/j9WGZAVPpa6AvF60tgNf4TGFXm+PXQXyFp2o2JyHDkqF3L8HlY6CDVcIRGTQegjJQ==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/apps/gateway/package.json b/apps/gateway/package.json
iindex efd9116..043f073 100644
---- a/apps/gateway/package.json
++++ b/apps/gateway/package.json
@@@ -22,7 +22,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"bwip-js": "4.11.4",
"zxing-wasm": "3.1.5"
ddiff --git a/apps/gateway/server/account-data.js b/apps/gateway/server/account-data.js
nnew file mode 100644
iindex 0000000..b3d5f15
---- /dev/null
++++ b/apps/gateway/server/account-data.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * Account export and deletion → Tools (ADR-033; openvibe-sdk/account-data). What Tools holds about a person:
++ *
++ * - the tool jobs they own (tool_jobs.owner is 'user:usr_…' for a signed-in person): the job they asked for, its
++ * input, its files and its result. That is their own content, so it goes in their export and goes with the
++ * account on a deletion (ADR-033) — with the rows that hang under the job: its event log and its references;
++ * - the billing reading of an ended job (tool_job_billing_readings): a money record. The books must still
++ * reconcile, so it stays, with the person in it a tombstone instead of a name;
++ * - the usage counts (tool_job_usage), the tool opens (tool_opens_daily) and the domain history (the WHOIS and
++ * RDAP facts, kept per domain): the service's own, counted per project, per tool and per domain, never per
++ * person;
++ * - the analytics (analytics_events): the tracker's own rows. Its user_id is a local counter, not a subject, and
++ * the rows are not keyed by a person (the guard's exemption says so);
++ * - the guard's abuse log (guard_abuse): an address hash and a service principal, never a person.
++ *
++ * Telemetry samples that name a person are covered by the SDK itself (telemetry.json, then the subject NULL).
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++// The tables this service answers for. The person's tool jobs are erased together with the rows that hang under
++// them (the job's event log, its references), so they are not in the table map: the map deletes the job first and
++// those rows would stay behind. The guard (openvibe-sdk/account-data's uncovered) needs the table named, so it is
++// in GUARDED below.
++const TABLES = [];
++const GUARDED = ['tool_jobs'];
++
++// What of a job is the person's to read back: the job, its input, its files and its result.
++const JOB_COLUMNS = ['id', 'app', 'type', 'state', 'tool', 'project_id', 'input_json', 'files_json', 'result_json', 'created_at', 'updated_at', 'finished_at'];
++
++// A person's jobs carry their subject as the owner ('user:usr_…'); an app's or a browser session's jobs do not.
++const ownerForms = (subjects) => subjects.map((s) => `user:${s}`);
++
++/** The person's jobs, and the billing readings of those jobs. */
++async function extraExport(db, subject) {
++ const forms = ownerForms([subject]);
++ const jobs = await db.many(
++ `SELECT ${JOB_COLUMNS.map((c) => `"${c}"`).join(', ')} FROM tool_jobs WHERE owner = ANY($1::text[]) ORDER BY created_at DESC LIMIT 5000`,
++ [forms]);
++ if (!jobs.length) return [];
++ const files = [{ name: 'tool-jobs.json', content: jobs }];
++ const readings = await db.many(
++ 'SELECT job_id, reading, created_at FROM tool_job_billing_readings WHERE job_id = ANY($1::text[]) ORDER BY created_at DESC LIMIT 5000',
++ [jobs.map((j) => j.id)]);
++ if (readings.length) files.push({ name: 'billing-readings.json', content: readings });
++ return files;
++}
++
++/**
++ * The person's jobs go, with the rows that hang under them. The billing reading of a job is a money record: it
++ * stays, and the person in it becomes a tombstone (ADR-033).
++ */
++async function extraErase(t, subjects, counts) {
++ const forms = ownerForms(subjects);
++ const ids = (await t.many('SELECT id FROM tool_jobs WHERE owner = ANY($1::text[])', [forms])).map((r) => r.id);
++ if (!ids.length) return;
++ counts.add(counts.erased, 'tool_job_events', await t.exec('DELETE FROM tool_job_events WHERE job_id = ANY($1::text[])', [ids]));
++ counts.add(counts.erased, 'tool_job_references', await t.exec('DELETE FROM tool_job_references WHERE job_id = ANY($1::text[])', [ids]));
++ counts.add(counts.retained, 'tool_job_billing_readings', await t.exec(
++ `UPDATE tool_job_billing_readings SET reading = jsonb_set(reading, '{subject}', '"user:deleted"') WHERE job_id = ANY($1::text[]) AND reading ? 'subject'`, [ids]));
++ counts.add(counts.erased, 'tool_jobs', await t.exec('DELETE FROM tool_jobs WHERE id = ANY($1::text[])', [ids]));
++}
++
++function create({ db, log = console } = {}) {
++ return createAccountData({
++ db, service: 'tools', tables: TABLES, extraExport, extraErase, log,
++ note: 'Your tool jobs and the results you asked for are deleted. The usage they were billed for stays, without your name.',
++ });
++}
++
++module.exports = { create, TABLES, GUARDED, TOPICS, extraExport, extraErase };
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 26caa0a..eaaac08 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -31,6 +31,7 @@ const createDevRoutes = require('./dev/routes');
const { DEV_TOOL_MAP, DEV_ALIASES } = require('./dev/config');
const { CODES } = require('./seo/catalog');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
++const { createNetworkSender } = require('openvibe-sdk/account-data'); // ADR-033: Tools' part of an export, back to Network
const app = express();
// Network's sign-out-everywhere cutoffs for every Tools app (./revocation-events.js): before any body parser.
@@@ -63,7 +64,13 @@ let auth = null; // created below; the key check reads it at request time
// The one `tools` database and the shared Valkey (plan T8, decisions 3 and 4).
const toolsDb = require('../../_shared/db').openToolsDb({ createDb: require('openvibe-sdk/db').createDb, service: 'tools-gateway' });
const toolsValkey = require('../../_shared/db').openToolsValkey({ createValkey: require('openvibe-sdk/valkey').createValkey });
--require('./revocation-events').configure({ db: toolsDb.db, valkey: toolsValkey });
++// The account export and deletion (ADR-033, ./account-data.js): what Tools holds about a person, and the erasure
++// of it. The network sender posts Tools' part of an export back to Network with Tools' own token.
++const accountData = require('./account-data').create({ db: toolsDb.db, log: console });
++const accountSend = config.oauth.clientSecret ? createNetworkSender({
++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++}) : null;
++require('./revocation-events').configure({ db: toolsDb.db, valkey: toolsValkey, account: accountData, send: accountSend });
const guard = createGuard({
app: 'gateway', db: toolsDb.db, valkey: toolsValkey, contracts: require('openvibe-contracts'),
specs: [...require('./net/descriptors').SPECS, ...require('./dev/descriptors').SPECS],
ddiff --git a/apps/gateway/server/revocation-events.js b/apps/gateway/server/revocation-events.js
iindex bbe91da..db6f149 100644
---- a/apps/gateway/server/revocation-events.js
++++ b/apps/gateway/server/revocation-events.js
@@@ -10,17 +10,25 @@
*
* The gateway owns the write, so it also bumps the shared Valkey version the readers watch: with it,
* a revocation reaches every app on their next read rather than after their cache ttl.
++ *
++ * The same route answers the two account topics (ADR-033, ./account-data.js): what Tools holds about a person
++ * is exported on request and erased when the account goes. They arrive on the same signed endpoint.
*/
const express = require('express');
const { createPgRevocationStore } = require('openvibe-sdk/auth');
++const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
const TOPIC = 'network.user.token_valid_after';
let store = null;
let storeDb = null;
let storeValkey = null;
++let accountData = null;
++let accountSend = null;
/** The gateway calls this once, at boot, with the one `tools` database and the shared Valkey. */
--function configure({ db = null, valkey = null } = {}) { storeDb = db; storeValkey = valkey; store = null; }
++function configure({ db = null, valkey = null, account: account = null, send = null } = {}) {
++ storeDb = db; storeValkey = valkey; store = null; accountData = account; accountSend = send;
++}
async function cutoffs() {
if (store) return store;
@@@ -46,6 +54,17 @@ function handler() {
for (const k of keys) { d = parseDelivery(raw, req.headers, k, { requireV2: true }); if (d) break; }
if (!d || !d.event) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
stats.received++;
++ // The account topics (ADR-033): what Tools holds about a person is exported or erased here, not revoked.
++ if (ACCOUNT_TOPICS.includes(d.event.event_type)) {
++ if (!accountData) return res.status(503).json({ error: 'no account-data module is configured' });
++ try {
++ const outcome = await accountData.apply(d.event, { send: accountSend });
++ return res.json({ event_id: d.event.event_id || null, outcome });
++ } catch (err) {
++ console.warn(`[account-data] event ${d.event.event_id} (${d.event.event_type}) failed: ${(err && err.message) || err}`);
++ return res.status(503).json({ error: 'not applied; Events will retry' });
++ }
++ }
const outcome = await (await cutoffs()).apply(d.event);
if (outcome === 'revoked') {
stats.revoked++;
@@@ -71,12 +90,17 @@ async function ensureSubscription({ port, fetchImpl = globalThis.fetch, log = co
const list = await fetchImpl(`${eventsUrl}/api/v1/subscriptions`, { headers });
if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`);
const subs = ((await list.json()).subscriptions || []);
-- if (subs.some((s) => s.topic_pattern === TOPIC && s.endpoint === endpoint)) return 'exists';
-- const r = await fetchImpl(`${eventsUrl}/api/v1/subscriptions`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ topic_pattern: TOPIC, endpoint, secret }) });
-- if (r.status === 409) return 'exists';
-- if (!r.ok) throw new Error(`Events answered ${r.status} creating the ${TOPIC} subscription`);
-- log.log && log.log(`[Events] subscription created: ${TOPIC} → ${endpoint}`);
-- return 'created';
++ // The cutoff topic and the two account topics (ADR-033) share this endpoint and this secret.
++ let made = false;
++ for (const topic of [TOPIC, ...ACCOUNT_TOPICS]) {
++ if (subs.some((s) => s.topic_pattern === topic && s.endpoint === endpoint)) continue;
++ const r = await fetchImpl(`${eventsUrl}/api/v1/subscriptions`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ topic_pattern: topic, endpoint, secret }) });
++ if (r.status === 409) continue;
++ if (!r.ok) throw new Error(`Events answered ${r.status} creating the ${topic} subscription`);
++ made = true;
++ log.log && log.log(`[Events] subscription created: ${topic} → ${endpoint}`);
++ }
++ return made ? 'created' : 'exists';
}
module.exports = { handler, ensureSubscription, close, configure, stats, TOPIC, _cutoffs: cutoffs };
ddiff --git a/apps/gateway/test/account-data.test.js b/apps/gateway/test/account-data.test.js
nnew file mode 100644
iindex 0000000..0a3e27b
---- /dev/null
++++ b/apps/gateway/test/account-data.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++// The account export and deletion that reaches OpenVibe.Tools (ADR-033): the gateway's POST /internal/events
++// (signature v2, never through the proxy) answers the two account topics. Ada's tool jobs are in her export and
++// gone with the rows that hang under them; the billing reading of an ended job stays, without her name; Bo's jobs
++// stay his. The guard fails when a new table with a person column is neither covered nor exempted with a reason.
++const assert = require('assert');
++const http = require('http');
++const path = require('path');
++
++process.env.TOOLS_EVENTS_SECRET = 't'.repeat(40);
++const express = require('express');
++const { createDb } = require('openvibe-sdk/db');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { uncovered } = require('openvibe-sdk/account-data');
++const events = require('../server/revocation-events');
++const accountDataLib = require('../server/account-data');
++const { MIGRATIONS } = require('../../_shared/db');
++
++const A = 'usr_01JZ0000000000000000000AAA';
++const B = 'usr_01JZ0000000000000000000BBB';
++const EXP = 'exp_01JZ0000000000000000000EXP';
++const DEL = 'del_01JZ0000000000000000000DEX';
++
++const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
++
++(async () => {
++ const db = createDb({ pglite: true, service: 'account-data-test' });
++ await db.migrate({ dir: MIGRATIONS, log: { log() {}, warn() {}, error() {} } });
++
++ const accountData = accountDataLib.create({ db, log: { log() {}, warn() {}, error() {} } });
++ const sent = [];
++ const send = async (p, body) => { sent.push({ path: p, body }); return { ok: true, status: 201 }; };
++ events.configure({ db, account: accountData, send });
++
++ const app = express();
++ app.post('/internal/events', ...events.handler());
++ const srv = http.createServer(app);
++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
++ const url = `http://127.0.0.1:${srv.address().port}/internal/events`;
++ const post = (event, { secret = process.env.TOOLS_EVENTS_SECRET, headers = {} } = {}) => {
++ const body = JSON.stringify({ event, seq: 1 });
++ return fetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } })
++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
++ };
++
++ try {
++ // ── the guard: every table with a person column is covered or exempted with a reason ──
++ const missing = await uncovered(db, {
++ tables: [...accountDataLib.TABLES, ...accountDataLib.GUARDED],
++ exempt: {
++ account_data_events: 'the ledger of requests itself: which export and deletion Tools applied',
++ analytics_events: 'the tracker\'s own rows: its user_id is a local counter, not a subject, and the rows are not keyed by a person',
++ },
++ });
++ assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
++
++ // ── the export: their jobs, nobody else's ──
++ for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {
++ await db.exec(`INSERT INTO tool_jobs (id, app, type, owner, state, input_json, files_json, tool, project_id, created_at, updated_at)
++ VALUES ($1, 'img', 'png.thumbnail', $2, 'succeeded', $3, '[]', 'png.thumbnail', 'proj_1', 1, 2)`, [`job_${name}`, `user:${subject}`, `{"name":"${name}.png"}`]);
++ await db.exec("INSERT INTO tool_job_events (job_id, event, data, at) VALUES ($1, 'done', '{}', 2)", [`job_${name}`]);
++ await db.exec("INSERT INTO tool_job_references (job_id, ref, created_at) VALUES ($1, 'media_1', 2)", [`job_${name}`]);
++ await db.exec("INSERT INTO tool_job_billing_readings (job_id, app, reading, created_at) VALUES ($1, 'img', $2, 2)", [`job_${name}`, JSON.stringify({ subject: `user:${subject}`, quantity: 1 })]);
++ }
++
++ const r = await post(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: EXP, subject: A }));
++ assert.deepStrictEqual([r.status, r.body.outcome], [200, 'exported']);
++ const part = sent.find((c) => c.path === `/internal/account-exports/${EXP}/parts`);
++ assert.ok(part, 'the part was posted to Network');
++ const jobs = part.body.files.find((f) => f.name === 'tool-jobs.json');
++ assert.ok(jobs, 'tool-jobs.json is in the part');
++ assert.deepStrictEqual(jobs.content.map((j) => j.id), ['job_ada'], 'only her own job');
++ assert.ok(!JSON.stringify(part.body).includes(B), 'nobody else\'s rows');
++ const readings = part.body.files.find((f) => f.name === 'billing-readings.json');
++ assert.ok(readings && readings.content.length === 1, 'the billing reading of her job is in the part');
++
++ // ── the deletion: the jobs and the rows under them go; the money record stays, without her ──
++ const d = await post(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }));
++ assert.deepStrictEqual([d.status, d.body.outcome], [200, 'erased']);
++ assert.strictEqual(Number(await db.value('SELECT count(*)::int FROM tool_jobs WHERE owner = $1', [`user:${A}`])), 0, 'her job is gone');
++ assert.strictEqual(Number(await db.value('SELECT count(*)::int FROM tool_job_events WHERE job_id = $1', ['job_ada'])), 0, 'the event log under it is gone');
++ assert.strictEqual(Number(await db.value('SELECT count(*)::int FROM tool_job_references WHERE job_id = $1', ['job_ada'])), 0, 'the references under it are gone');
++ assert.strictEqual(Number(await db.value('SELECT count(*)::int FROM tool_jobs WHERE owner = $1', [`user:${B}`])), 1, 'Bo\'s job stays');
++ assert.strictEqual(Number(await db.value('SELECT count(*)::int FROM tool_job_events WHERE job_id = $1', ['job_bo'])), 1, 'Bo\'s event log stays');
++ const reading = await db.value("SELECT reading->>'subject' FROM tool_job_billing_readings WHERE job_id = 'job_ada'");
++ assert.strictEqual(reading, 'user:deleted', 'the money record stays, without her name');
++ const confirmation = sent.find((c) => c.path === `/internal/account-deletions/${DEL}/confirmations`);
++ assert.ok(confirmation, 'the confirmation was sent');
++ assert.ok(confirmation.body.erased.tool_jobs >= 1, 'the erased jobs are counted');
++
++ // ── the route: an unsigned delivery is refused, a proxied one never arrives ──
++ assert.strictEqual((await post(ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: EXP, subject: A }), { secret: 'y'.repeat(40) })).status, 401);
++ assert.strictEqual((await post(ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: EXP, subject: A }), { headers: { 'cf-connecting-ip': '1.2.3.4' } })).status, 404);
++ } finally {
++ srv.close();
++ await db.close();
++ events.close();
++ }
++ console.log('tools account-data: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/migrations/0009_account_data.sql b/migrations/0009_account_data.sql
nnew file mode 100644
iindex 0000000..681e9a2
---- /dev/null
++++ b/migrations/0009_account_data.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- OpenVibe.Tools: 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
++);