openvibers/openvibe.zone Commits
account-data: what Zone holds about a person, and the erasure of it (#87)
Changes
@@@ -122,6 +122,6 @@ AGPL-3.0, see LICENSE.<!-- versions:start -->- openvibe-contracts: v0.222.0--- openvibe-sdk: v0.73.0++- openvibe-sdk: v0.74.0- openvibe-shared: v4.48.0<!-- versions:end -->@@@ -8,11 +8,11 @@"port": 5050,"domain": "openvibe.zone","contracts": "openvibe-contracts v0.222.0",-- "sdk": "openvibe-sdk v0.73.0",++ "sdk": "openvibe-sdk v0.74.0","shared": "openvibe-shared v4.48.0","packages": ["openvibe-contracts v0.222.0",-- "openvibe-sdk v0.73.0",++ "openvibe-sdk v0.74.0","openvibe-shared v4.48.0"],"summary": "OpenVibe.Zone — private S3-compatible storage zones for OpenVibe projects (ADR-031 amendment). Zone owns the zone record and its control API; OpenVibe.Media owns the objects, bytes, keys, quotas and the S3 surface at s3.openvibe.media.",nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- OpenVibe.Zone: 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++);@@@ -17,7 +17,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.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"},@@@ -1163,9 +1163,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.73.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",-- "integrity": "sha512-XClxoVm9M6RA8eXlGgYZ0u6q9n5h79mCXqAAEg9fX21KgcqP69RmG+cX+6wrOf7TaEj7eSBWMxHjiIeIa9N7Dg==",++ "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"@@@ -24,7 +24,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.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.73.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"},nnew file mode 100644@@@ -0,0 +1,21 @@++'use strict';++/**++ * Account export and deletion → Zone (ADR-033; openvibe-sdk/account-data). What Zone holds about a person:++ *++ * - their telemetry: the requests they made against their zones, one aggregated sample per route and minute.++ * The SDK covers that table itself (telemetry.json, then the subject NULL on deletion);++ *++ * and nothing else. The zones belong to a project environment, the usage is counted per zone, hour and series,++ * and the idempotency and control logs name the caller (a service), not a person. So Zone's own table map is++ * empty, and the guard on every table with a person column has nothing left to find here.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++// Zone keeps no table of its own about a person: the telemetry samples are the SDK's own (see above).++const TABLES = [];++++function create({ db, log = console } = {}) {++ return createAccountData({ db, service: 'zone', tables: TABLES, log });++}++++module.exports = { create, TABLES, TOPICS };@@@ -28,6 +28,8 @@ const { createPrincipal } = require('./http/principal');const { createApi } = require('./http/api');const { createUsage } = require('./zone/usage');const { eventsIntake } = require('./http/events');++const accountDataLib = require('./account-data');++const { createNetworkSender } = require('openvibe-sdk/account-data');const { createPageRoutes } = require('./http/pages');const { createServiceReadiness } = require('./observability');const { createCallerLimits } = require('./http/caller-limits');@@@ -58,7 +60,13 @@ async function createApp(opts = {}) {const usage = createUsage({ s, log });const zones = createZones({ s, clients, config, log, metering: usage });const mediaLimits = opts.mediaLimits || require('./zone/media-limits').createMediaLimits({ config, fetchImpl, log });-- const ctx = { config, s, keys, sso, principal, log, clients, zones, usage, mediaLimits };++ // The account export and deletion (ADR-033, server/account-data.js): what Zone holds about a person, and the++ // erasure of it. The network sender posts Zone's part of an export back to Network with Zone's own token.++ const accountData = opts.accountData || accountDataLib.create({ db: s.db, log });++ const accountSend = opts.accountSend !== undefined ? opts.accountSend : (config.oauth.clientSecret ? createNetworkSender({++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl,++ }) : null);++ const ctx = { config, s, keys, sso, principal, log, clients, zones, usage, mediaLimits, accountData, accountSend };const app = express();app.disable('x-powered-by');@@@ -104,7 +112,8 @@ async function createApp(opts = {}) {// ── OpenVibe.Events → Zone (loopback only: nginx answers 404 for /internal/) ──// media.usage.recorded: Media's hourly rollups of each zone, kept for the usage view (server/zone/usage.js).-- app.post('/internal/events', eventsIntake({ secrets: config.events.secrets, usage, log }));++ // The two account topics (ADR-033): Zone's part of a person's export, and the erasure of what it keeps.++ app.post('/internal/events', eventsIntake({ secrets: config.events.secrets, usage, accountData, accountSend, log }));// ── Machine endpoints ───────────────────────────────────app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-zone', version: VERSION }));@@@ -1,14 +1,16 @@'use strict';/**-- * POST /internal/events: OpenVibe.Events delivers Zone's subscriptions here (media.usage.recorded). No body parser runs-- * before it: the v2 signature covers the raw body. Loopback only: a request that came through a proxy is refused.-- * 2xx once applied (or ignored with a reason); anything else makes Events retry.++ * POST /internal/events: OpenVibe.Events delivers Zone's subscriptions here (media.usage.recorded, and the two++ * account topics — network.account.export_requested and network.account.deleted — for ADR-033). No body parser++ * runs before it: the v2 signature covers the raw body. Loopback only: a request that came through a proxy is++ * refused. 2xx once applied (or ignored with a reason); anything else makes Events retry.*/const { parseDelivery } = require('openvibe-sdk/events');++const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');const LIMIT = 256 * 1024;--function eventsIntake({ secrets = [], usage, log = console, now = () => Date.now() }) {++function eventsIntake({ secrets = [], usage, accountData = null, accountSend = null, log = console, now = () => Date.now() }) {const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);const reply = (res, status, body) => {res.status(status).set('Cache-Control', 'no-store').type(status < 300 ? 'application/json' : 'application/problem+json').send(JSON.stringify(body));@@@ -30,6 +32,18 @@ function eventsIntake({ secrets = [], usage, log = console, now = () => Date.nowfor (const k of keys) { delivery = parseDelivery(raw, req.headers, k, { requireV2: true, now: now() }); if (delivery) break; }if (!delivery || !delivery.event || typeof delivery.event.event_type !== 'string') return problem(res, 401, 'zone.bad_signature', 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window');const ev = delivery.event;++ // The account export and deletion (ADR-033, server/account-data.js): Zone's part of a person's export, and++ // the erasure of what it keeps. The two account topics are answered here, next to Media's usage rollups.++ if (ACCOUNT_TOPICS.includes(ev.event_type)) {++ if (!accountData) return problem(res, 503, 'zone.account_disabled', 'no account-data module is configured');++ try {++ const outcome = await accountData.apply(ev, { send: accountSend });++ return reply(res, 200, { event_id: ev.event_id, outcome });++ } catch (err) {++ log.warn(`[account-data] event ${ev.event_id} (${ev.event_type}) failed: ${(err && err.message) || err}`);++ return problem(res, 503, 'zone.retry', 'not applied; Events will retry');++ }++ }try {const outcome = ev.event_type === usage.TOPIC ? await usage.apply(ev) : 'ignored:type';return reply(res, 200, { event_id: ev.event_id, outcome });@@@ -7,7 +7,7 @@const { createApp } = require('./app');const { gracefulStop } = require('openvibe-sdk/service');const { startTelemetryRetention } = require('openvibe-sdk/telemetry');--const { startSubscriptions } = require('openvibe-sdk/account-data');++const { startSubscriptions, TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');/*** The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the zone job stops, the JWKS@@@ -38,11 +38,12 @@ async function start() {ctx.keys.client.start();// The zone job (zone/zones.js tick): pushes what Media has not acknowledged and finishes deletions, every 15 s.ctx.zones.start();-- // Media's per-zone rollups (media.usage.recorded) at OpenVibe.Events, created when missing; off without EVENTS_URL,-- // ZONE_EVENTS_SECRET or the client secret.++ // Media's per-zone rollups (media.usage.recorded) at OpenVibe.Events, created when missing, and the two account++ // topics (ADR-033) for a person's export and deletion; off without EVENTS_URL, ZONE_EVENTS_SECRET or the client++ // secret.const subscriptions = startSubscriptions({eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],-- topics: [ctx.usage.TOPIC],++ topics: [ctx.usage.TOPIC, ...ACCOUNT_TOPICS],networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,});createLifecycle({ server, ctx, extra: [() => ctx.zones.stop(), () => { if (subscriptions) subscriptions.stop(); }] });nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * ADR-033: Zone's part of an account export and deletion, through the signed POST /internal/events route with the++ * stand-in Network. Ada's telemetry goes in her export (her own samples only), her deletion takes the subject off++ * them, and Bo's samples stay theirs. The guard fails when a new table with a person column is neither covered nor++ * exempted with a reason.++ */++const assert = require('assert');++const { uncovered } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/boot');++const { TABLES } = require('../server/account-data');++++const SECRET = `whsec_${'fixture'.repeat(6)}`;++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 t = await boot({ env: { ZONE_EVENTS_SECRET: SECRET } });++ const db = t.ctx.s.db;++ try {++ await check('every table that names a person is covered or exempted with a reason', async () => {++ const missing = await uncovered(db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Zone applied' } });++ assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);++ });++++ await check('an export carries the person\'s telemetry samples, and nobody else\'s', async () => {++ for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {++ await db.exec("INSERT INTO telemetry_samples (at, service, operation, resource, status, latency_ms, subject) VALUES (now(), 'zone', 'http.request', '/zones', '2xx', 12, $1)", [subject]);++ }++ const body = JSON.stringify({ event: ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: EXP, subject: A }), seq: 1 });++ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'exported');++ const parts = t.network.state.accountParts;++ const part = parts.find((p) => p.id === EXP);++ assert.ok(part, 'the part was posted to Network');++ const samples = part.body.files.find((f) => f.name === 'telemetry.json');++ assert.ok(samples, 'telemetry.json is in the part');++ assert.strictEqual(samples.content.length, 1, JSON.stringify(samples.content));++ assert.strictEqual(samples.content[0].resource, '/zones');++ assert.ok(!JSON.stringify(part.body).includes(B), 'nobody else\'s rows');++ });++++ await check('a deletion takes the subject off their samples and confirms once', async () => {++ const body = JSON.stringify({ event: ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }), seq: 1 });++ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'erased');++ assert.strictEqual(await db.value('SELECT subject FROM telemetry_samples WHERE subject = $1', [A]), null, 'her samples keep no subject');++ assert.strictEqual(await db.value('SELECT subject FROM telemetry_samples WHERE subject = $1', [B]), B, 'Bo stays');++ const confirmations = t.network.state.accountConfirmations;++ const confirmation = confirmations.find((c) => c.id === DEL);++ assert.ok(confirmation, 'the confirmation was sent');++ });++++ await check('an unsigned delivery is refused', async () => {++ const body = JSON.stringify({ event: ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: EXP, subject: A }), seq: 1 });++ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json' } });++ assert.strictEqual(res.status, 401, res.text);++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -14,6 +14,9 @@* answers what setUsage() stored for the project, or an empty network.project-usage-result@1.* POST /api/v1/projects/:project/export-tokens as Network mints them (owner/admin only,* sub app:app_<project ULID>, read-only caps, purpose export), recorded in exportTokens.++ * POST /internal/account-exports/:id/parts and POST /internal/account-deletions/:id/confirmations++ * (ADR-033: what a service posts back for a person's export and deletion), recorded in++ * accountParts and accountConfirmations.* Every request is recorded in `.requests` so tests can assert what was (not) called.*/const http = require('http');@@@ -56,6 +59,8 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.musers: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,++ // ADR-033: the account export parts a service posts back, and the deletion confirmations it sends.++ accountParts: [], accountConfirmations: [],};let issuer = null;let nextUserId = 1;@@@ -298,6 +303,15 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.mif (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });}++ // ADR-033: a service posts its part of a person's export here, and confirms a deletion here.++ if (url.pathname.startsWith('/internal/account-exports/') && url.pathname.endsWith('/parts') && req.method === 'POST') {++ st.accountParts.push({ id: url.pathname.split('/')[3], body: JSON.parse(raw.toString('utf8') || '{}') });++ return json(201, { ok: true });++ }++ if (url.pathname.startsWith('/internal/account-deletions/') && url.pathname.endsWith('/confirmations') && req.method === 'POST') {++ st.accountConfirmations.push({ id: url.pathname.split('/')[3], body: JSON.parse(raw.toString('utf8') || '{}') });++ return json(201, { ok: true });++ }if (url.pathname.startsWith('/api/v1/projects')) {const u = userFrom(req);if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
@@ -122,6 +122,6 @@ AGPL-3.0, see LICENSE.
@@ -122,6 +122,6 @@ AGPL-3.0, see LICENSE.
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
-- openvibe-sdk: v0.73.0
+- openvibe-sdk: v0.74.0
- openvibe-shared: v4.48.0
- openvibe-shared: v4.48.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,11 +8,11 @@
@@ -8,11 +8,11 @@
"port": 5050,
"port": 5050,
"domain": "openvibe.zone",
"domain": "openvibe.zone",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
- "sdk": "openvibe-sdk v0.73.0",
+ "sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.48.0",
"shared": "openvibe-shared v4.48.0",
"packages": [
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-contracts v0.222.0",
- "openvibe-sdk v0.73.0",
+ "openvibe-sdk v0.74.0",
"openvibe-shared v4.48.0"
"openvibe-shared v4.48.0"
],
],
"summary": "OpenVibe.Zone — private S3-compatible storage zones for OpenVibe projects (ADR-031 amendment). Zone owns the zone record and its control API; OpenVibe.Media owns the objects, bytes, keys, quotas and the S3 surface at s3.openvibe.media.",
"summary": "OpenVibe.Zone — private S3-compatible storage zones for OpenVibe projects (ADR-031 amendment). Zone owns the zone record and its control API; OpenVibe.Media owns the objects, bytes, keys, quotas and the S3 surface at s3.openvibe.media.",
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- OpenVibe.Zone: 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
+);
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.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.73.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",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1163,9 +1163,9 @@
@@ -1163,9 +1163,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.73.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
- "integrity": "sha512-XClxoVm9M6RA8eXlGgYZ0u6q9n5h79mCXqAAEg9fX21KgcqP69RmG+cX+6wrOf7TaEj7eSBWMxHjiIeIa9N7Dg==",
+ "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"
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.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.73.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",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+'use strict';
+/**
+ * Account export and deletion → Zone (ADR-033; openvibe-sdk/account-data). What Zone holds about a person:
+ *
+ * - their telemetry: the requests they made against their zones, one aggregated sample per route and minute.
+ * The SDK covers that table itself (telemetry.json, then the subject NULL on deletion);
+ *
+ * and nothing else. The zones belong to a project environment, the usage is counted per zone, hour and series,
+ * and the idempotency and control logs name the caller (a service), not a person. So Zone's own table map is
+ * empty, and the guard on every table with a person column has nothing left to find here.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+// Zone keeps no table of its own about a person: the telemetry samples are the SDK's own (see above).
+const TABLES = [];
+
+function create({ db, log = console } = {}) {
+ return createAccountData({ db, service: 'zone', tables: TABLES, log });
+}
+
+module.exports = { create, TABLES, TOPICS };
@@ -28,6 +28,8 @@ const { createPrincipal } = require('./http/principal');
@@ -28,6 +28,8 @@ const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createUsage } = require('./zone/usage');
const { createUsage } = require('./zone/usage');
const { eventsIntake } = require('./http/events');
const { eventsIntake } = require('./http/events');
+const accountDataLib = require('./account-data');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
@@ -58,7 +60,13 @@ async function createApp(opts = {}) {
@@ -58,7 +60,13 @@ async function createApp(opts = {}) {
const usage = createUsage({ s, log });
const usage = createUsage({ s, log });
const zones = createZones({ s, clients, config, log, metering: usage });
const zones = createZones({ s, clients, config, log, metering: usage });
const mediaLimits = opts.mediaLimits || require('./zone/media-limits').createMediaLimits({ config, fetchImpl, log });
const mediaLimits = opts.mediaLimits || require('./zone/media-limits').createMediaLimits({ config, fetchImpl, log });
- const ctx = { config, s, keys, sso, principal, log, clients, zones, usage, mediaLimits };
+ // The account export and deletion (ADR-033, server/account-data.js): what Zone holds about a person, and the
+ // erasure of it. The network sender posts Zone's part of an export back to Network with Zone's own token.
+ const accountData = opts.accountData || accountDataLib.create({ db: s.db, log });
+ const accountSend = opts.accountSend !== undefined ? opts.accountSend : (config.oauth.clientSecret ? createNetworkSender({
+ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl,
+ }) : null);
+ const ctx = { config, s, keys, sso, principal, log, clients, zones, usage, mediaLimits, accountData, accountSend };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -104,7 +112,8 @@ async function createApp(opts = {}) {
@@ -104,7 +112,8 @@ async function createApp(opts = {}) {
// ── OpenVibe.Events → Zone (loopback only: nginx answers 404 for /internal/) ──
// ── OpenVibe.Events → Zone (loopback only: nginx answers 404 for /internal/) ──
// media.usage.recorded: Media's hourly rollups of each zone, kept for the usage view (server/zone/usage.js).
// media.usage.recorded: Media's hourly rollups of each zone, kept for the usage view (server/zone/usage.js).
- app.post('/internal/events', eventsIntake({ secrets: config.events.secrets, usage, log }));
+ // The two account topics (ADR-033): Zone's part of a person's export, and the erasure of what it keeps.
+ app.post('/internal/events', eventsIntake({ secrets: config.events.secrets, usage, accountData, accountSend, log }));
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-zone', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-zone', version: VERSION }));
@@ -1,14 +1,16 @@
@@ -1,14 +1,16 @@
'use strict';
'use strict';
/**
/**
- * POST /internal/events: OpenVibe.Events delivers Zone's subscriptions here (media.usage.recorded). No body parser runs
- * before it: the v2 signature covers the raw body. Loopback only: a request that came through a proxy is refused.
- * 2xx once applied (or ignored with a reason); anything else makes Events retry.
+ * POST /internal/events: OpenVibe.Events delivers Zone's subscriptions here (media.usage.recorded, and the two
+ * account topics — network.account.export_requested and network.account.deleted — for ADR-033). No body parser
+ * runs before it: the v2 signature covers the raw body. Loopback only: a request that came through a proxy is
+ * refused. 2xx once applied (or ignored with a reason); anything else makes Events retry.
*/
*/
const { parseDelivery } = require('openvibe-sdk/events');
const { parseDelivery } = require('openvibe-sdk/events');
+const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
const LIMIT = 256 * 1024;
const LIMIT = 256 * 1024;
-function eventsIntake({ secrets = [], usage, log = console, now = () => Date.now() }) {
+function eventsIntake({ secrets = [], usage, accountData = null, accountSend = null, log = console, now = () => Date.now() }) {
const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);
const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);
const reply = (res, status, body) => {
const reply = (res, status, body) => {
res.status(status).set('Cache-Control', 'no-store').type(status < 300 ? 'application/json' : 'application/problem+json').send(JSON.stringify(body));
res.status(status).set('Cache-Control', 'no-store').type(status < 300 ? 'application/json' : 'application/problem+json').send(JSON.stringify(body));
@@ -30,6 +32,18 @@ function eventsIntake({ secrets = [], usage, log = console, now = () => Date.now
@@ -30,6 +32,18 @@ function eventsIntake({ secrets = [], usage, log = console, now = () => Date.now
for (const k of keys) { delivery = parseDelivery(raw, req.headers, k, { requireV2: true, now: now() }); if (delivery) break; }
for (const k of keys) { delivery = parseDelivery(raw, req.headers, k, { requireV2: true, now: now() }); if (delivery) break; }
if (!delivery || !delivery.event || typeof delivery.event.event_type !== 'string') return problem(res, 401, 'zone.bad_signature', 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window');
if (!delivery || !delivery.event || typeof delivery.event.event_type !== 'string') return problem(res, 401, 'zone.bad_signature', 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window');
const ev = delivery.event;
const ev = delivery.event;
+ // The account export and deletion (ADR-033, server/account-data.js): Zone's part of a person's export, and
+ // the erasure of what it keeps. The two account topics are answered here, next to Media's usage rollups.
+ if (ACCOUNT_TOPICS.includes(ev.event_type)) {
+ if (!accountData) return problem(res, 503, 'zone.account_disabled', 'no account-data module is configured');
+ try {
+ const outcome = await accountData.apply(ev, { send: accountSend });
+ return reply(res, 200, { event_id: ev.event_id, outcome });
+ } catch (err) {
+ log.warn(`[account-data] event ${ev.event_id} (${ev.event_type}) failed: ${(err && err.message) || err}`);
+ return problem(res, 503, 'zone.retry', 'not applied; Events will retry');
+ }
+ }
try {
try {
const outcome = ev.event_type === usage.TOPIC ? await usage.apply(ev) : 'ignored:type';
const outcome = ev.event_type === usage.TOPIC ? await usage.apply(ev) : 'ignored:type';
return reply(res, 200, { event_id: ev.event_id, outcome });
return reply(res, 200, { event_id: ev.event_id, outcome });
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
const { createApp } = require('./app');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
-const { startSubscriptions } = require('openvibe-sdk/account-data');
+const { startSubscriptions, TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
/**
/**
* The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the zone job stops, the JWKS
* The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the zone job stops, the JWKS
@@ -38,11 +38,12 @@ async function start() {
@@ -38,11 +38,12 @@ async function start() {
ctx.keys.client.start();
ctx.keys.client.start();
// The zone job (zone/zones.js tick): pushes what Media has not acknowledged and finishes deletions, every 15 s.
// The zone job (zone/zones.js tick): pushes what Media has not acknowledged and finishes deletions, every 15 s.
ctx.zones.start();
ctx.zones.start();
- // Media's per-zone rollups (media.usage.recorded) at OpenVibe.Events, created when missing; off without EVENTS_URL,
- // ZONE_EVENTS_SECRET or the client secret.
+ // Media's per-zone rollups (media.usage.recorded) at OpenVibe.Events, created when missing, and the two account
+ // topics (ADR-033) for a person's export and deletion; off without EVENTS_URL, ZONE_EVENTS_SECRET or the client
+ // secret.
const subscriptions = startSubscriptions({
const subscriptions = startSubscriptions({
eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
- topics: [ctx.usage.TOPIC],
+ topics: [ctx.usage.TOPIC, ...ACCOUNT_TOPICS],
networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
});
});
createLifecycle({ server, ctx, extra: [() => ctx.zones.stop(), () => { if (subscriptions) subscriptions.stop(); }] });
createLifecycle({ server, ctx, extra: [() => ctx.zones.stop(), () => { if (subscriptions) subscriptions.stop(); }] });
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * ADR-033: Zone's part of an account export and deletion, through the signed POST /internal/events route with the
+ * stand-in Network. Ada's telemetry goes in her export (her own samples only), her deletion takes the subject off
+ * them, and Bo's samples stay theirs. The guard fails when a new table with a person column is neither covered nor
+ * exempted with a reason.
+ */
+const assert = require('assert');
+const { uncovered } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/boot');
+const { TABLES } = require('../server/account-data');
+
+const SECRET = `whsec_${'fixture'.repeat(6)}`;
+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 t = await boot({ env: { ZONE_EVENTS_SECRET: SECRET } });
+ const db = t.ctx.s.db;
+ try {
+ await check('every table that names a person is covered or exempted with a reason', async () => {
+ const missing = await uncovered(db, { tables: TABLES, exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Zone applied' } });
+ assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
+ });
+
+ await check('an export carries the person\'s telemetry samples, and nobody else\'s', async () => {
+ for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {
+ await db.exec("INSERT INTO telemetry_samples (at, service, operation, resource, status, latency_ms, subject) VALUES (now(), 'zone', 'http.request', '/zones', '2xx', 12, $1)", [subject]);
+ }
+ const body = JSON.stringify({ event: ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: EXP, subject: A }), seq: 1 });
+ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'exported');
+ const parts = t.network.state.accountParts;
+ const part = parts.find((p) => p.id === EXP);
+ assert.ok(part, 'the part was posted to Network');
+ const samples = part.body.files.find((f) => f.name === 'telemetry.json');
+ assert.ok(samples, 'telemetry.json is in the part');
+ assert.strictEqual(samples.content.length, 1, JSON.stringify(samples.content));
+ assert.strictEqual(samples.content[0].resource, '/zones');
+ assert.ok(!JSON.stringify(part.body).includes(B), 'nobody else\'s rows');
+ });
+
+ await check('a deletion takes the subject off their samples and confirms once', async () => {
+ const body = JSON.stringify({ event: ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: A, aliases: [] }), seq: 1 });
+ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'erased');
+ assert.strictEqual(await db.value('SELECT subject FROM telemetry_samples WHERE subject = $1', [A]), null, 'her samples keep no subject');
+ assert.strictEqual(await db.value('SELECT subject FROM telemetry_samples WHERE subject = $1', [B]), B, 'Bo stays');
+ const confirmations = t.network.state.accountConfirmations;
+ const confirmation = confirmations.find((c) => c.id === DEL);
+ assert.ok(confirmation, 'the confirmation was sent');
+ });
+
+ await check('an unsigned delivery is refused', async () => {
+ const body = JSON.stringify({ event: ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: EXP, subject: A }), seq: 1 });
+ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json' } });
+ assert.strictEqual(res.status, 401, res.text);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -14,6 +14,9 @@
@@ -14,6 +14,9 @@
* answers what setUsage() stored for the project, or an empty network.project-usage-result@1.
* answers what setUsage() stored for the project, or an empty network.project-usage-result@1.
* POST /api/v1/projects/:project/export-tokens as Network mints them (owner/admin only,
* POST /api/v1/projects/:project/export-tokens as Network mints them (owner/admin only,
* sub app:app_<project ULID>, read-only caps, purpose export), recorded in exportTokens.
* sub app:app_<project ULID>, read-only caps, purpose export), recorded in exportTokens.
+ * POST /internal/account-exports/:id/parts and POST /internal/account-deletions/:id/confirmations
+ * (ADR-033: what a service posts back for a person's export and deletion), recorded in
+ * accountParts and accountConfirmations.
* Every request is recorded in `.requests` so tests can assert what was (not) called.
* Every request is recorded in `.requests` so tests can assert what was (not) called.
*/
*/
const http = require('http');
const http = require('http');
@@ -56,6 +59,8 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -56,6 +59,8 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
users: new Map(), byUsername: new Map(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
+ // ADR-033: the account export parts a service posts back, and the deletion confirmations it sends.
+ accountParts: [], accountConfirmations: [],
};
};
let issuer = null;
let issuer = null;
let nextUserId = 1;
let nextUserId = 1;
@@ -298,6 +303,15 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -298,6 +303,15 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
if (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });
if (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });
return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });
return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });
}
}
+ // ADR-033: a service posts its part of a person's export here, and confirms a deletion here.
+ if (url.pathname.startsWith('/internal/account-exports/') && url.pathname.endsWith('/parts') && req.method === 'POST') {
+ st.accountParts.push({ id: url.pathname.split('/')[3], body: JSON.parse(raw.toString('utf8') || '{}') });
+ return json(201, { ok: true });
+ }
+ if (url.pathname.startsWith('/internal/account-deletions/') && url.pathname.endsWith('/confirmations') && req.method === 'POST') {
+ st.accountConfirmations.push({ id: url.pathname.split('/')[3], body: JSON.parse(raw.toString('utf8') || '{}') });
+ return json(201, { ok: true });
+ }
if (url.pathname.startsWith('/api/v1/projects')) {
if (url.pathname.startsWith('/api/v1/projects')) {
const u = userFrom(req);
const u = userFrom(req);
if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');