openvibers/openvibe.tools Commits
account-data: the tool jobs a person owns, and the erasure of them (#148)
Changes
@@@ -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 -->@@@ -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",@@@ -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",@@@ -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"nnew file mode 100644@@@ -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 };@@@ -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 Networkconst 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],@@@ -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 = coconst 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 };nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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++);
@@ -641,6 +641,6 @@ counters and salt use Valkey through `VALKEY_URL`, with process-local state when
@@ -641,6 +641,6 @@ counters and salt use Valkey through `VALKEY_URL`, with process-local state when
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
-- openvibe-sdk: v0.67.0
+- openvibe-sdk: v0.74.0
- openvibe-shared: v4.48.0
- openvibe-shared: v4.48.0
<!-- versions:end -->
<!-- versions:end -->
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
},
},
"domain": "openvibe.tools and *.openvibe.tools",
"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)",
"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)",
"shared": "openvibe-shared v4.48.0 (each app's package.json; the root package.json pins none)",
"capabilities": [
"capabilities": [
"tools.job.create",
"tools.job.create",
@@ -20,8 +20,8 @@
@@ -20,8 +20,8 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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",
"pg": "^8.23.0",
"zxing-wasm": "3.1.5"
"zxing-wasm": "3.1.5"
}
}
@@ -1201,9 +1201,9 @@
@@ -1201,9 +1201,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1229,9 +1229,9 @@
@@ -1229,9 +1229,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"bwip-js": "4.11.4",
"bwip-js": "4.11.4",
"zxing-wasm": "3.1.5"
"zxing-wasm": "3.1.5"
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -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 };
@@ -31,6 +31,7 @@ const createDevRoutes = require('./dev/routes');
@@ -31,6 +31,7 @@ const createDevRoutes = require('./dev/routes');
const { DEV_TOOL_MAP, DEV_ALIASES } = require('./dev/config');
const { DEV_TOOL_MAP, DEV_ALIASES } = require('./dev/config');
const { CODES } = require('./seo/catalog');
const { CODES } = require('./seo/catalog');
const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
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();
const app = express();
// Network's sign-out-everywhere cutoffs for every Tools app (./revocation-events.js): before any body parser.
// 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
@@ -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).
// 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 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 });
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({
const guard = createGuard({
app: 'gateway', db: toolsDb.db, valkey: toolsValkey, contracts: require('openvibe-contracts'),
app: 'gateway', db: toolsDb.db, valkey: toolsValkey, contracts: require('openvibe-contracts'),
specs: [...require('./net/descriptors').SPECS, ...require('./dev/descriptors').SPECS],
specs: [...require('./net/descriptors').SPECS, ...require('./dev/descriptors').SPECS],
@@ -10,17 +10,25 @@
@@ -10,17 +10,25 @@
*
*
* The gateway owns the write, so it also bumps the shared Valkey version the readers watch: with it,
* 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.
* 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 express = require('express');
const { createPgRevocationStore } = require('openvibe-sdk/auth');
const { createPgRevocationStore } = require('openvibe-sdk/auth');
+const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
const TOPIC = 'network.user.token_valid_after';
const TOPIC = 'network.user.token_valid_after';
let store = null;
let store = null;
let storeDb = null;
let storeDb = null;
let storeValkey = 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. */
/** 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() {
async function cutoffs() {
if (store) return store;
if (store) return store;
@@ -46,6 +54,17 @@ function handler() {
@@ -46,6 +54,17 @@ function handler() {
for (const k of keys) { d = parseDelivery(raw, req.headers, k, { requireV2: true }); if (d) break; }
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' }); }
if (!d || !d.event) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
stats.received++;
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);
const outcome = await (await cutoffs()).apply(d.event);
if (outcome === 'revoked') {
if (outcome === 'revoked') {
stats.revoked++;
stats.revoked++;
@@ -71,12 +90,17 @@ async function ensureSubscription({ port, fetchImpl = globalThis.fetch, log = co
@@ -71,12 +90,17 @@ async function ensureSubscription({ port, fetchImpl = globalThis.fetch, log = co
const list = await fetchImpl(`${eventsUrl}/api/v1/subscriptions`, { headers });
const list = await fetchImpl(`${eventsUrl}/api/v1/subscriptions`, { headers });
if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`);
if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`);
const subs = ((await list.json()).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 };
module.exports = { handler, ensureSubscription, close, configure, stats, TOPIC, _cutoffs: cutoffs };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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
+);