openvibers/openvibe.services Commits
Services: account export and deletion (ADR-033) — a person's runs deleted; manifests and releases stay with their project without them; audit logs kept; loopback /internal/events; subscriptions at boot (SDK v0.37.0) (#12)
Changes
@@@ -87,3 +87,6 @@ SERVICES_NETWORK_TIMEOUT_MS=5000EVENTS_URL=#SERVICES_EVENTS_ENABLED=1#EVENTS_RELAY_INTERVAL_MS=2000++# OpenVibe.Events → Services (ADR-033 account export and deletion): signs a delivery to POST /internal/events++# (comma-separated for rotation, 32+ characters each). Unset: the route answers 503 and no subscription is created.++#SERVICES_EVENTS_SECRET=@@@ -4,6 +4,12 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo## Unreleased++- Account export and deletion (ADR-033): `POST /internal/events` (loopback, `SERVICES_EVENTS_SECRET`) answers network.account.export_requested and network.account.deleted through `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.1).++ - **Deleted:** the person's playground and recipe runs.++ - **Authorless:** manifests and releases stay with their project, without the person.++ - **Kept:** the audit logs and staff trust tiers.++ - The two subscriptions are created at boot. Migration `0003_account_data.sql`, `test/account-data.test.js`.++### Changed- **The developer portal moved here from OpenVibe.Codes** (owner decision 2026-10-08; Codes became the open coding-agent@@@ -125,6 +125,24 @@ In Network (`server/identity/principals.js` / `server/db/database.js`, as for co- OAuth client `services`, name `OpenVibe.Services`, redirect URI `https://openvibe.services/auth/callback`.- Service grant `['services', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).++- Account export and deletion (ADR-033): `['services', 'events.subscription.manage', 'openvibe.events', []]` (the two++ account subscriptions, created at boot), then, last and once the release is live,++ `network.account.export.contribute` and `network.account.deletion.confirm` for audience `openvibe.network`.++++### Account export and deletion (ADR-033)++++`network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events`. The route is++loopback-only (nginx answers 404 for `/internal/`, and the handler refuses a forwarded request) and signed with++`SERVICES_EVENTS_SECRET`. They are answered by `server/domain/account-data.js` over `openvibe-sdk/account-data`, with++one receipt per export and deletion in `account_data_events` (migration 0003).++++- **Export:** the playground and recipe runs the person started, and the manifests and releases they created.++- **Deletion:**++ - Their runs are deleted.++ - Manifests and releases belong to their project and stay: `created_by` becomes `deleted` and the publisher,++ deprecator and reviser become NULL.++ - The resource index's owner becomes NULL.++ - The append-only release log, the control log and the staff trust tiers stay attributed, for accountability.The Services service manifest, `services.release.manage|read`, `services.resource.read` and `services.app-manifest@1` are released in openvibe-contracts v0.114.0 (they replaced the `codes.*` forms, retired that release); the CI contracts check is blocking.@@@ -8,7 +8,7 @@"port": 4930,"domain": "openvibe.services","contracts": "openvibe-contracts v0.114.0",-- "sdk": "openvibe-sdk v0.35.1",++ "sdk": "openvibe-sdk v0.37.0","shared": "openvibe-shared v2.15.0","packages": ["openvibe-contracts v0.113.0",nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- OpenVibe.Services: 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.114.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"},@@@ -906,9 +906,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.35.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",-- "integrity": "sha512-id+GBVYaTczHL6ZyOZJTE16KSJH/VNaEo3aIk9dxDdZSYHD2zWnSQlSfEfA+ffo6rgzoSm09/AqaR+a4y8QEPw==",++ "version": "0.37.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",++ "integrity": "sha512-afojId2KzOL4ZZrpwIeIAYUaX24ZOC3Nt+bdd1HSXm7aislt8WQNS62i8/d8W3udLr6rmSG29SjmnaRHUGfd4Q==","license": "MIT","engines": {"node": ">=20"@@@ -28,7 +28,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.114.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"},@@@ -58,6 +58,8 @@ const { assetVersion, send } = require('./render/layout');const { html } = require('./render/html');const { ServiceError } = require('./util');++const accountDataLib = require('./domain/account-data');++const { createNetworkSender } = require('openvibe-sdk/account-data');const PUBLIC_DIR = path.join(__dirname, '..', 'public');const VERSION = require('../package.json').version;// The resource index takes query strings; nothing there uploads.@@@ -148,6 +150,16 @@ async function createApp(opts = {}) {}));app.use(cookieParser());++ // ── OpenVibe.Events → Services (loopback only: nginx answers 404 for /internal/) ──++ // network.account.export_requested and network.account.deleted (ADR-033), answered by openvibe-sdk/account-data's++ // consumer over domain/account-data.js. It reads the raw body itself (the v2 signature covers it) and refuses a++ // request that came through a proxy. The sender posts to Network's internal routes with Services' own token.++ ctx.accountData = accountDataLib.create({ db: store.db, log });++ ctx.accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl || globalThis.fetch })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Services cannot answer account events'); });++ app.post('/internal/events', ctx.accountData.consumer({ secrets: config.events.secrets, send: ctx.accountSend, log }));++// ── Machine endpoints ───────────────────────────────────// Liveness: the process answers. What it can and cannot do is /api/ready's job.app.get('/api/health', (req, res, next) => outbox.status().then((events) => res.json({@@@ -145,6 +145,10 @@ function loadConfig(env = process.env) {enabled: bool(env.SERVICES_EVENTS_ENABLED, true),url: trim(env.EVENTS_URL || ''),intervalMs: Math.max(50, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),++ // OpenVibe.Events → Services (ADR-033 account export and deletion, server/domain/account-data.js): the++ // secret(s) that sign a delivery to POST /internal/events (comma-separated for rotation, 32+ characters each).++ // Unset: the route answers 503 and no subscription is created at boot.++ secrets: String(env.SERVICES_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),},// IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment annew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++++/**++ * Account export and deletion → Services (ADR-033; openvibe-sdk/account-data). Services stores a person as++ * `user:usr_…` (an app acts as `app:app_…`, which is never a person's).++ *++ * their own, deleted playground runs and recipe runs they started++ * project rows, authorless manifests and releases belong to the project and its app, not to the person who pressed++ * the button: they stay. created_by is NOT NULL there, so it becomes `deleted` (the resource++ * index then reports no owner); published_by, deprecated_by and revoked_by become NULL; the++ * resource index's owner becomes NULL++ * kept release_log (append-only by trigger), services_control_log and the trust tiers a staff++ * member set: the audit trail of who published, revoked, reviewed or changed what, kept for++ * accountability (counted as retained)++ *++ * The export carries what they started and what they created. Nothing exported is a secret: manifests hold no++ * credentials, and the control log is not exported.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const user = (usr) => `user:${usr}`;++const anonymize = { anonymize: {} };++++const TABLES = [++ { table: 'playground_runs', subject: 'actor', value: user, file: 'playground-runs.json', order: 'at', columns: ['id', 'at', 'project_id', 'app_id', 'kind', 'capability', 'credential', 'outcome', 'stage', 'http_status', 'code', 'detail'] },++ { table: 'services_recipe_runs', subject: 'subject', value: (usr) => [usr, user(usr)], file: 'recipe-runs.json', order: 'started_at' },++ { table: 'releases', subject: 'published_by', value: user, file: null, erase: anonymize },++ { table: 'releases', subject: 'deprecated_by', value: user, file: null, erase: anonymize },++ { table: 'releases', subject: 'revoked_by', value: user, file: null, erase: anonymize },++ { table: 'services_resources', subject: 'owner_subject', value: (usr) => [usr, user(usr)], file: null, erase: anonymize },++ { table: 'release_log', subject: 'actor', value: user, file: null, erase: { keep: 'the append-only release log is the audit trail of who published or revoked what' } },++ { table: 'trust', subject: 'set_by', value: user, file: null, erase: { keep: 'a trust tier names the staff member who set it, for accountability' } },++ { table: 'trust_history', subject: 'set_by', value: user, file: null, erase: { keep: 'the trust history names the staff member who set each tier, for accountability' } },++ { table: 'services_control_log', subject: 'actor_subject', value: (usr) => [usr, user(usr)], file: null, erase: { keep: 'the control-plane audit log, kept for accountability' } },++];++++/** Manifests and releases they created: exported, then made authorless in extraErase (created_by is NOT NULL). */++async function extraExport(db, subject) {++ const files = [];++ const manifests = await db.many(`SELECT id, kind, app_id, project_id, subject_id, version, body, created_at FROM manifests++ WHERE created_by = $1 ORDER BY created_at DESC LIMIT 5000`, [user(subject)]);++ if (manifests.length) files.push({ name: 'manifests.json', content: manifests });++ const releases = await db.many(`SELECT id, app_id, project_id, environment, kind, subject_id, name, version, status, notes, created_at++ FROM releases WHERE created_by = $1 ORDER BY created_at DESC LIMIT 5000`, [user(subject)]);++ if (releases.length) files.push({ name: 'releases.json', content: releases });++ return files;++}++++async function extraErase(t, subjects, counts) {++ const forms = subjects.map(user);++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE manifests SET created_by = 'deleted' WHERE created_by = ANY($1::text[])", [forms]));++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE releases SET created_by = 'deleted' WHERE created_by = ANY($1::text[])", [forms]));++}++++/** The account-data handle for Services' database (store.db). */++function create({ db, log = console } = {}) {++ return createAccountData({++ db, service: 'services', tables: TABLES, extraExport, extraErase, log,++ note: 'Manifests and releases belong to their project and stay there without your name.',++ });++}++++module.exports = { create, TABLES, TOPICS };@@@ -18,6 +18,7 @@ const { loadConfig } = require('./config');const { openDb, migrate, createStore } = require('./db');const { createApp } = require('./app');const { gracefulStop } = require('openvibe-sdk/service');++const { startSubscriptions } = require('openvibe-sdk/account-data');const { createRegistry } = require('openvibe-shared/metrics');/**@@@ -25,10 +26,10 @@ const { createRegistry } = require('openvibe-shared/metrics');* and the outbox relay stop and the store closes; past the deadline the process exits 0. Exported so a test can* inject `exit` and `signals: false`.*/--function createLifecycle({ server, ctx, exit, signals }) {++function createLifecycle({ server, ctx, exit, signals, extra = [] }) {return gracefulStop({name: 'Services', server, drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 0, exit, signals,-- stop: [() => ctx.keys.client.stop(), () => ctx.outbox.stop()],++ stop: [() => ctx.keys.client.stop(), () => ctx.outbox.stop(), ...extra],close: [() => ctx.store.close().catch(() => {})],});}@@@ -55,7 +56,13 @@ async function main() {// good keys through outages, exponential backoff, unknown-kid floods throttled, an unref'd timer.keys.client.start();-- const { stop: shutdown } = createLifecycle({ server, ctx });++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,++ // SERVICES_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],++ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ });++ const { stop: shutdown } = createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });return { app, server, ctx, shutdown };}nnew file mode 100644@@@ -0,0 +1,105 @@++'use strict';++/**++ * ADR-033: Services' part of an account export and of an account deletion, through the signed loopback route++ * POST /internal/events with a stand-in Network. A person's playground runs go; the manifests and releases they made++ * stay with their project without their id (created_by becomes 'deleted', the publisher NULL); the append-only release++ * log and staff trust tiers stay attributed; a redelivery erases nothing twice; a bad signature and a forwarded++ * request are refused.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { createNetworkSender } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/boot');++++const SECRET = `whsec_${'fixture'.repeat(6)}`;++++async function startNetworkStub() {++ const calls = [];++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_services', token_type: 'Bearer', expires_in: 300 });++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });++ return json(201, {});++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };++}++++(async () => {++ const stub = await startNetworkStub();++ const t = await boot({ env: { SERVICES_EVENTS_SECRET: SECRET }, accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'services', clientSecret: 'services-secret' }) });++ const db = t.ctx.store.db;++ const me = ids.newId('user');++ const other = ids.newId('user');++ const count = async (sql, args) => Number(await db.value(sql, args));++ const deliver = async (event, { secret = SECRET, headers = {} } = {}) => {++ const body = JSON.stringify({ event, seq: 1 });++ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });++ const at = new Date().toISOString();++ const prj = ids.newId('project');++ const app = ids.newId('app');++++ try {++ await check('the person\'s rows: a manifest, a release they published, a playground run, a log entry, a trust tier', async () => {++ await db.exec(`INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at)++ VALUES ('mfs_1', 'app', $1, $2, $1, '1.0.0', '{"name":"My app"}', '0.119.0', $3, $4)`, [app, prj, `user:${me}`, at]);++ await db.exec(`INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, created_by, created_at, published_by, published_at)++ VALUES ('rel_1', $1, $2, 'production', 'app', $1, 'My app', '1.0.0', 'mfs_1', 'published', $3, $4, $3, $4)`, [app, prj, `user:${me}`, at]);++ await db.exec(`INSERT INTO release_log (release_id, action, actor, at) VALUES ('rel_1', 'release.published', $1, $2)`, [`user:${me}`, at]);++ await db.exec(`INSERT INTO trust (app_id, tier, note, set_by, set_at) VALUES ($1, 'reviewed', '', $2, $3)`, [app, `user:${me}`, at]);++ for (const [id, who] of [['run_1', me], ['run_2', other]]) {++ await db.exec(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage)++ VALUES ($1, $2, $3, $4, $5, 'events', 'events.event.publish', 'access_token', 'ok', 'done')`, [id, at, `user:${who}`, prj, app]);++ }++ });++++ await check('the export carries what they made and ran, and nobody else\'s', async () => {++ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: me }));++ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'exported'], JSON.stringify(r.json));++ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');++ assert.strictEqual(part.auth, 'Bearer tok_services');++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['manifests.json', 'playground-runs.json', 'releases.json']);++ assert.strictEqual(part.body.files.find((f) => f.name === 'playground-runs.json').content.length, 1);++ assert.ok(!JSON.stringify(part.body).includes(other));++ });++++ await check('the deletion removes their runs, leaves the project\'s manifest and release authorless, keeps the audit trail; once', async () => {++ const event = ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: me });++ const r = await deliver(event);++ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'erased'], JSON.stringify(r.json));++ assert.strictEqual(await count('SELECT count(*) FROM playground_runs WHERE actor = $1', [`user:${me}`]), 0);++ assert.strictEqual(await count('SELECT count(*) FROM playground_runs WHERE actor = $1', [`user:${other}`]), 1);++ assert.strictEqual(await db.value("SELECT created_by FROM manifests WHERE id = 'mfs_1'"), 'deleted');++ const rel = await db.maybe("SELECT created_by, published_by, status FROM releases WHERE id = 'rel_1'");++ assert.deepStrictEqual([rel.created_by, rel.published_by, rel.status], ['deleted', null, 'published'], 'the release stays published');++ assert.strictEqual(await count('SELECT count(*) FROM release_log WHERE actor = $1', [`user:${me}`]), 1, 'the append-only log stays');++ assert.strictEqual(await count('SELECT count(*) FROM trust WHERE set_by = $1', [`user:${me}`]), 1, 'the staff tier stays attributed');++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');++ assert.strictEqual(conf.length, 1);++ assert.strictEqual(conf[0].body.erased.playground_runs, 1);++ assert.deepStrictEqual([conf[0].body.retained.release_log, conf[0].body.retained.trust], [1, 1]);++ assert.ok(conf[0].body.retained.tombstones >= 3);++ assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');++ assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);++ });++++ await check('the route refuses a bad signature and a request that came through a proxy', async () => {++ const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: me });++ assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);++ assert.strictEqual((await deliver(event, { headers: { 'X-Forwarded-For': '203.0.113.9' } })).status, 403);++ });++ } finally {++ await t.close();++ await stub.close();++ }++ done();++})();@@@ -44,7 +44,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');// One database per boot (PGlite, or SERVICES_TEST_STORE=pg: the containers), dropped when the boot closes.const testdb = await require('./db').testDb();-- const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow });++ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow, accountSend: opts.accountSend });await built.ctx.keys.ensure();if (opts.relay) built.ctx.outbox.start();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
@@ -87,3 +87,6 @@ SERVICES_NETWORK_TIMEOUT_MS=5000
@@ -87,3 +87,6 @@ SERVICES_NETWORK_TIMEOUT_MS=5000
EVENTS_URL=
EVENTS_URL=
#SERVICES_EVENTS_ENABLED=1
#SERVICES_EVENTS_ENABLED=1
#EVENTS_RELAY_INTERVAL_MS=2000
#EVENTS_RELAY_INTERVAL_MS=2000
+# OpenVibe.Events → Services (ADR-033 account export and deletion): signs a delivery to POST /internal/events
+# (comma-separated for rotation, 32+ characters each). Unset: the route answers 503 and no subscription is created.
+#SERVICES_EVENTS_SECRET=
@@ -4,6 +4,12 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
@@ -4,6 +4,12 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
## Unreleased
## Unreleased
+- Account export and deletion (ADR-033): `POST /internal/events` (loopback, `SERVICES_EVENTS_SECRET`) answers network.account.export_requested and network.account.deleted through `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.1).
+ - **Deleted:** the person's playground and recipe runs.
+ - **Authorless:** manifests and releases stay with their project, without the person.
+ - **Kept:** the audit logs and staff trust tiers.
+ - The two subscriptions are created at boot. Migration `0003_account_data.sql`, `test/account-data.test.js`.
+
### Changed
### Changed
- **The developer portal moved here from OpenVibe.Codes** (owner decision 2026-10-08; Codes became the open coding-agent
- **The developer portal moved here from OpenVibe.Codes** (owner decision 2026-10-08; Codes became the open coding-agent
@@ -125,6 +125,24 @@ In Network (`server/identity/principals.js` / `server/db/database.js`, as for co
@@ -125,6 +125,24 @@ In Network (`server/identity/principals.js` / `server/db/database.js`, as for co
- OAuth client `services`, name `OpenVibe.Services`, redirect URI `https://openvibe.services/auth/callback`.
- OAuth client `services`, name `OpenVibe.Services`, redirect URI `https://openvibe.services/auth/callback`.
- Service grant `['services', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).
- Service grant `['services', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).
+- Account export and deletion (ADR-033): `['services', 'events.subscription.manage', 'openvibe.events', []]` (the two
+ account subscriptions, created at boot), then, last and once the release is live,
+ `network.account.export.contribute` and `network.account.deletion.confirm` for audience `openvibe.network`.
+
+### Account export and deletion (ADR-033)
+
+`network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events`. The route is
+loopback-only (nginx answers 404 for `/internal/`, and the handler refuses a forwarded request) and signed with
+`SERVICES_EVENTS_SECRET`. They are answered by `server/domain/account-data.js` over `openvibe-sdk/account-data`, with
+one receipt per export and deletion in `account_data_events` (migration 0003).
+
+- **Export:** the playground and recipe runs the person started, and the manifests and releases they created.
+- **Deletion:**
+ - Their runs are deleted.
+ - Manifests and releases belong to their project and stay: `created_by` becomes `deleted` and the publisher,
+ deprecator and reviser become NULL.
+ - The resource index's owner becomes NULL.
+ - The append-only release log, the control log and the staff trust tiers stay attributed, for accountability.
The Services service manifest, `services.release.manage|read`, `services.resource.read` and `services.app-manifest@1` are released in openvibe-contracts v0.114.0 (they replaced the `codes.*` forms, retired that release); the CI contracts check is blocking.
The Services service manifest, `services.release.manage|read`, `services.resource.read` and `services.app-manifest@1` are released in openvibe-contracts v0.114.0 (they replaced the `codes.*` forms, retired that release); the CI contracts check is blocking.
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"port": 4930,
"port": 4930,
"domain": "openvibe.services",
"domain": "openvibe.services",
"contracts": "openvibe-contracts v0.114.0",
"contracts": "openvibe-contracts v0.114.0",
- "sdk": "openvibe-sdk v0.35.1",
+ "sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.15.0",
"shared": "openvibe-shared v2.15.0",
"packages": [
"packages": [
"openvibe-contracts v0.113.0",
"openvibe-contracts v0.113.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- OpenVibe.Services: 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.114.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.114.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -906,9 +906,9 @@
@@ -906,9 +906,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.35.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",
- "integrity": "sha512-id+GBVYaTczHL6ZyOZJTE16KSJH/VNaEo3aIk9dxDdZSYHD2zWnSQlSfEfA+ffo6rgzoSm09/AqaR+a4y8QEPw==",
+ "version": "0.37.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
+ "integrity": "sha512-afojId2KzOL4ZZrpwIeIAYUaX24ZOC3Nt+bdd1HSXm7aislt8WQNS62i8/d8W3udLr6rmSG29SjmnaRHUGfd4Q==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -28,7 +28,7 @@
@@ -28,7 +28,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.114.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.114.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -58,6 +58,8 @@ const { assetVersion, send } = require('./render/layout');
@@ -58,6 +58,8 @@ const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const { html } = require('./render/html');
const { ServiceError } = require('./util');
const { ServiceError } = require('./util');
+const accountDataLib = require('./domain/account-data');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
// The resource index takes query strings; nothing there uploads.
// The resource index takes query strings; nothing there uploads.
@@ -148,6 +150,16 @@ async function createApp(opts = {}) {
@@ -148,6 +150,16 @@ async function createApp(opts = {}) {
}));
}));
app.use(cookieParser());
app.use(cookieParser());
+ // ── OpenVibe.Events → Services (loopback only: nginx answers 404 for /internal/) ──
+ // network.account.export_requested and network.account.deleted (ADR-033), answered by openvibe-sdk/account-data's
+ // consumer over domain/account-data.js. It reads the raw body itself (the v2 signature covers it) and refuses a
+ // request that came through a proxy. The sender posts to Network's internal routes with Services' own token.
+ ctx.accountData = accountDataLib.create({ db: store.db, log });
+ ctx.accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl || globalThis.fetch })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Services cannot answer account events'); });
+ app.post('/internal/events', ctx.accountData.consumer({ secrets: config.events.secrets, send: ctx.accountSend, log }));
+
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
// Liveness: the process answers. What it can and cannot do is /api/ready's job.
// Liveness: the process answers. What it can and cannot do is /api/ready's job.
app.get('/api/health', (req, res, next) => outbox.status().then((events) => res.json({
app.get('/api/health', (req, res, next) => outbox.status().then((events) => res.json({
@@ -145,6 +145,10 @@ function loadConfig(env = process.env) {
@@ -145,6 +145,10 @@ function loadConfig(env = process.env) {
enabled: bool(env.SERVICES_EVENTS_ENABLED, true),
enabled: bool(env.SERVICES_EVENTS_ENABLED, true),
url: trim(env.EVENTS_URL || ''),
url: trim(env.EVENTS_URL || ''),
intervalMs: Math.max(50, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),
intervalMs: Math.max(50, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),
+ // OpenVibe.Events → Services (ADR-033 account export and deletion, server/domain/account-data.js): the
+ // secret(s) that sign a delivery to POST /internal/events (comma-separated for rotation, 32+ characters each).
+ // Unset: the route answers 503 and no subscription is created at boot.
+ secrets: String(env.SERVICES_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
},
},
// IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment a
// IndexNow (openvibe-shared/indexnow): a key makes search engines recrawl a page the moment a
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+
+/**
+ * Account export and deletion → Services (ADR-033; openvibe-sdk/account-data). Services stores a person as
+ * `user:usr_…` (an app acts as `app:app_…`, which is never a person's).
+ *
+ * their own, deleted playground runs and recipe runs they started
+ * project rows, authorless manifests and releases belong to the project and its app, not to the person who pressed
+ * the button: they stay. created_by is NOT NULL there, so it becomes `deleted` (the resource
+ * index then reports no owner); published_by, deprecated_by and revoked_by become NULL; the
+ * resource index's owner becomes NULL
+ * kept release_log (append-only by trigger), services_control_log and the trust tiers a staff
+ * member set: the audit trail of who published, revoked, reviewed or changed what, kept for
+ * accountability (counted as retained)
+ *
+ * The export carries what they started and what they created. Nothing exported is a secret: manifests hold no
+ * credentials, and the control log is not exported.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const user = (usr) => `user:${usr}`;
+const anonymize = { anonymize: {} };
+
+const TABLES = [
+ { table: 'playground_runs', subject: 'actor', value: user, file: 'playground-runs.json', order: 'at', columns: ['id', 'at', 'project_id', 'app_id', 'kind', 'capability', 'credential', 'outcome', 'stage', 'http_status', 'code', 'detail'] },
+ { table: 'services_recipe_runs', subject: 'subject', value: (usr) => [usr, user(usr)], file: 'recipe-runs.json', order: 'started_at' },
+ { table: 'releases', subject: 'published_by', value: user, file: null, erase: anonymize },
+ { table: 'releases', subject: 'deprecated_by', value: user, file: null, erase: anonymize },
+ { table: 'releases', subject: 'revoked_by', value: user, file: null, erase: anonymize },
+ { table: 'services_resources', subject: 'owner_subject', value: (usr) => [usr, user(usr)], file: null, erase: anonymize },
+ { table: 'release_log', subject: 'actor', value: user, file: null, erase: { keep: 'the append-only release log is the audit trail of who published or revoked what' } },
+ { table: 'trust', subject: 'set_by', value: user, file: null, erase: { keep: 'a trust tier names the staff member who set it, for accountability' } },
+ { table: 'trust_history', subject: 'set_by', value: user, file: null, erase: { keep: 'the trust history names the staff member who set each tier, for accountability' } },
+ { table: 'services_control_log', subject: 'actor_subject', value: (usr) => [usr, user(usr)], file: null, erase: { keep: 'the control-plane audit log, kept for accountability' } },
+];
+
+/** Manifests and releases they created: exported, then made authorless in extraErase (created_by is NOT NULL). */
+async function extraExport(db, subject) {
+ const files = [];
+ const manifests = await db.many(`SELECT id, kind, app_id, project_id, subject_id, version, body, created_at FROM manifests
+ WHERE created_by = $1 ORDER BY created_at DESC LIMIT 5000`, [user(subject)]);
+ if (manifests.length) files.push({ name: 'manifests.json', content: manifests });
+ const releases = await db.many(`SELECT id, app_id, project_id, environment, kind, subject_id, name, version, status, notes, created_at
+ FROM releases WHERE created_by = $1 ORDER BY created_at DESC LIMIT 5000`, [user(subject)]);
+ if (releases.length) files.push({ name: 'releases.json', content: releases });
+ return files;
+}
+
+async function extraErase(t, subjects, counts) {
+ const forms = subjects.map(user);
+ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE manifests SET created_by = 'deleted' WHERE created_by = ANY($1::text[])", [forms]));
+ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE releases SET created_by = 'deleted' WHERE created_by = ANY($1::text[])", [forms]));
+}
+
+/** The account-data handle for Services' database (store.db). */
+function create({ db, log = console } = {}) {
+ return createAccountData({
+ db, service: 'services', tables: TABLES, extraExport, extraErase, log,
+ note: 'Manifests and releases belong to their project and stay there without your name.',
+ });
+}
+
+module.exports = { create, TABLES, TOPICS };
@@ -18,6 +18,7 @@ const { loadConfig } = require('./config');
@@ -18,6 +18,7 @@ const { loadConfig } = require('./config');
const { openDb, migrate, createStore } = require('./db');
const { openDb, migrate, createStore } = require('./db');
const { createApp } = require('./app');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
+const { startSubscriptions } = require('openvibe-sdk/account-data');
const { createRegistry } = require('openvibe-shared/metrics');
const { createRegistry } = require('openvibe-shared/metrics');
/**
/**
@@ -25,10 +26,10 @@ const { createRegistry } = require('openvibe-shared/metrics');
@@ -25,10 +26,10 @@ const { createRegistry } = require('openvibe-shared/metrics');
* and the outbox relay stop and the store closes; past the deadline the process exits 0. Exported so a test can
* and the outbox relay stop and the store closes; past the deadline the process exits 0. Exported so a test can
* inject `exit` and `signals: false`.
* inject `exit` and `signals: false`.
*/
*/
-function createLifecycle({ server, ctx, exit, signals }) {
+function createLifecycle({ server, ctx, exit, signals, extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'Services', server, drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 0, exit, signals,
name: 'Services', server, drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 0, exit, signals,
- stop: [() => ctx.keys.client.stop(), () => ctx.outbox.stop()],
+ stop: [() => ctx.keys.client.stop(), () => ctx.outbox.stop(), ...extra],
close: [() => ctx.store.close().catch(() => {})],
close: [() => ctx.store.close().catch(() => {})],
});
});
}
}
@@ -55,7 +56,13 @@ async function main() {
@@ -55,7 +56,13 @@ async function main() {
// good keys through outages, exponential backoff, unknown-kid floods throttled, an unref'd timer.
// good keys through outages, exponential backoff, unknown-kid floods throttled, an unref'd timer.
keys.client.start();
keys.client.start();
- const { stop: shutdown } = createLifecycle({ server, ctx });
+ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
+ // SERVICES_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],
+ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ });
+ const { stop: shutdown } = createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
return { app, server, ctx, shutdown };
return { app, server, ctx, shutdown };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -0,0 +1,105 @@
+'use strict';
+/**
+ * ADR-033: Services' part of an account export and of an account deletion, through the signed loopback route
+ * POST /internal/events with a stand-in Network. A person's playground runs go; the manifests and releases they made
+ * stay with their project without their id (created_by becomes 'deleted', the publisher NULL); the append-only release
+ * log and staff trust tiers stay attributed; a redelivery erases nothing twice; a bad signature and a forwarded
+ * request are refused.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/boot');
+
+const SECRET = `whsec_${'fixture'.repeat(6)}`;
+
+async function startNetworkStub() {
+ const calls = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_services', token_type: 'Bearer', expires_in: 300 });
+ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });
+ return json(201, {});
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
+}
+
+(async () => {
+ const stub = await startNetworkStub();
+ const t = await boot({ env: { SERVICES_EVENTS_SECRET: SECRET }, accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'services', clientSecret: 'services-secret' }) });
+ const db = t.ctx.store.db;
+ const me = ids.newId('user');
+ const other = ids.newId('user');
+ const count = async (sql, args) => Number(await db.value(sql, args));
+ const deliver = async (event, { secret = SECRET, headers = {} } = {}) => {
+ const body = JSON.stringify({ event, seq: 1 });
+ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
+ const at = new Date().toISOString();
+ const prj = ids.newId('project');
+ const app = ids.newId('app');
+
+ try {
+ await check('the person\'s rows: a manifest, a release they published, a playground run, a log entry, a trust tier', async () => {
+ await db.exec(`INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at)
+ VALUES ('mfs_1', 'app', $1, $2, $1, '1.0.0', '{"name":"My app"}', '0.119.0', $3, $4)`, [app, prj, `user:${me}`, at]);
+ await db.exec(`INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, created_by, created_at, published_by, published_at)
+ VALUES ('rel_1', $1, $2, 'production', 'app', $1, 'My app', '1.0.0', 'mfs_1', 'published', $3, $4, $3, $4)`, [app, prj, `user:${me}`, at]);
+ await db.exec(`INSERT INTO release_log (release_id, action, actor, at) VALUES ('rel_1', 'release.published', $1, $2)`, [`user:${me}`, at]);
+ await db.exec(`INSERT INTO trust (app_id, tier, note, set_by, set_at) VALUES ($1, 'reviewed', '', $2, $3)`, [app, `user:${me}`, at]);
+ for (const [id, who] of [['run_1', me], ['run_2', other]]) {
+ await db.exec(`INSERT INTO playground_runs (id, at, actor, project_id, app_id, kind, capability, credential, outcome, stage)
+ VALUES ($1, $2, $3, $4, $5, 'events', 'events.event.publish', 'access_token', 'ok', 'done')`, [id, at, `user:${who}`, prj, app]);
+ }
+ });
+
+ await check('the export carries what they made and ran, and nobody else\'s', async () => {
+ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: me }));
+ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'exported'], JSON.stringify(r.json));
+ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');
+ assert.strictEqual(part.auth, 'Bearer tok_services');
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['manifests.json', 'playground-runs.json', 'releases.json']);
+ assert.strictEqual(part.body.files.find((f) => f.name === 'playground-runs.json').content.length, 1);
+ assert.ok(!JSON.stringify(part.body).includes(other));
+ });
+
+ await check('the deletion removes their runs, leaves the project\'s manifest and release authorless, keeps the audit trail; once', async () => {
+ const event = ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: me });
+ const r = await deliver(event);
+ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'erased'], JSON.stringify(r.json));
+ assert.strictEqual(await count('SELECT count(*) FROM playground_runs WHERE actor = $1', [`user:${me}`]), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM playground_runs WHERE actor = $1', [`user:${other}`]), 1);
+ assert.strictEqual(await db.value("SELECT created_by FROM manifests WHERE id = 'mfs_1'"), 'deleted');
+ const rel = await db.maybe("SELECT created_by, published_by, status FROM releases WHERE id = 'rel_1'");
+ assert.deepStrictEqual([rel.created_by, rel.published_by, rel.status], ['deleted', null, 'published'], 'the release stays published');
+ assert.strictEqual(await count('SELECT count(*) FROM release_log WHERE actor = $1', [`user:${me}`]), 1, 'the append-only log stays');
+ assert.strictEqual(await count('SELECT count(*) FROM trust WHERE set_by = $1', [`user:${me}`]), 1, 'the staff tier stays attributed');
+ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
+ assert.strictEqual(conf.length, 1);
+ assert.strictEqual(conf[0].body.erased.playground_runs, 1);
+ assert.deepStrictEqual([conf[0].body.retained.release_log, conf[0].body.retained.trust], [1, 1]);
+ assert.ok(conf[0].body.retained.tombstones >= 3);
+ assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
+ assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);
+ });
+
+ await check('the route refuses a bad signature and a request that came through a proxy', async () => {
+ const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: me });
+ assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);
+ assert.strictEqual((await deliver(event, { headers: { 'X-Forwarded-For': '203.0.113.9' } })).status, 403);
+ });
+ } finally {
+ await t.close();
+ await stub.close();
+ }
+ done();
+})();
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or SERVICES_TEST_STORE=pg: the containers), dropped when the boot closes.
// One database per boot (PGlite, or SERVICES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
- const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow });
+ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, indexnow: opts.indexnow, accountSend: opts.accountSend });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
if (opts.relay) built.ctx.outbox.start();
if (opts.relay) built.ctx.outbox.start();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });