openvibers/openvibe.help Commits
Help: account export and deletion (ADR-033) — a person's tickets and messages are exported on network.account.export_requested; on network.account.deleted their own tickets (and every message under them) are erased while a staff reply left in someone else's ticket is anonymized (openvibe-sdk/account-data, SDK v0.36.0), with /internal/events loopback-only and the two subscriptions created at boot
Changes
@@@ -32,5 +32,12 @@ OV_OAUTH_CLIENT_SECRET=#HELP_SEARCH_TIMEOUT_MS=2500#HELP_SEARCH_CACHE_MS=300000++# OpenVibe.Events → Help (ADR-033 account export and deletion): the secret signs a delivery (comma-separated for++# rotation, 32+ characters each; unset makes POST /internal/events answer 503); the url is where the two subscriptions++# are created at boot (off when unset); HELP_EVENTS_SUBSCRIBE=0 turns that off.++HELP_EVENTS_SECRET=++HELP_EVENTS_URL=++#HELP_EVENTS_ENDPOINT=http://127.0.0.1:5020/internal/events++# optional: defaults to true in production# COOKIE_SECURE=true@@@ -166,6 +166,27 @@ listed with ○ and is not counted as a pass.**OpenVibe.Services** before the first deploy. No new environment variable is required to run: `HELP_SEARCH_URL` hasa default.++## Account export and deletion++++A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers its++part (ADR-033). Help receives `network.account.export_requested` and `network.account.deleted` at++`POST /internal/events` (loopback only) — the two tables are mapped in++[server/identity/account-data.js](server/identity/account-data.js), and the boot-time subscriptions are created by++[server/events-consumer.js](server/events-consumer.js):++++- **Exported:** the tickets a person opened (`tickets.json`) and every message they wrote (`messages.json`), pushed to++ `POST /internal/account-exports/:id/parts` with this service's own token. Nothing here is a secret — Help stores no++ token, key or credential.++- **Erased:** the person's own tickets are deleted; the `ON DELETE CASCADE` on `help_messages.ticket_id` takes every++ message under them — from either side — with them.++- **Anonymized:** a staff reply the person wrote in someone else's ticket keeps its body — that ticket's owner still++ has to read their own conversation — and its `author` becomes NULL. (`help_messages.author` is nullable for exactly++ this reason.) Help then confirms with `POST /internal/account-deletions/:id/confirmations` and the counts.++++Environment: `HELP_EVENTS_SECRET` (comma-separated for rotation, 32+ characters each; unset makes the route answer++503), `HELP_EVENTS_URL` (or `EVENTS_URL`) is where the two subscriptions are created at boot (off when unset), and++`HELP_EVENTS_ENDPOINT` overrides the loopback endpoint; `HELP_EVENTS_SUBSCRIBE=0` turns the boot-time subscription off.++## Security (threat notes)Reporting a vulnerability: [SECURITY.md](SECURITY.md).@@@ -187,6 +208,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.116.0--- openvibe-sdk: v0.35.2++- openvibe-sdk: v0.36.0- openvibe-shared: v2.15.0<!-- versions:end -->@@@ -7,11 +7,11 @@"port": "5020","domain": "openvibe.help","contracts": "openvibe-contracts v0.115.0",-- "sdk": "openvibe-sdk v0.35.2",++ "sdk": "openvibe-sdk v0.36.0","shared": "openvibe-shared v2.14.0","packages": ["openvibe-contracts v0.115.0",-- "openvibe-sdk v0.35.2",++ "openvibe-sdk v0.36.0","openvibe-shared v2.14.0"],"summary": "The help and support centre for every OpenVibe site. The help centre is generated at boot from openvibe-contracts (every product manifest and every service manifest's site block): one page per site, one page per question, each text quoted verbatim with its manifest named. /search searches those articles with a fixed explainable score and the network through OpenVibe.Search's public API (cached 5 minutes, degrading to the articles alone when it fails). A signed-in person opens a support ticket that stays one conversation — site, subject, message, an optional page URL stored as text and never fetched — sees only their own, and replies; staff by their Network role claim read the queue, filter it and set the state. Tickets live in PostgreSQL (migrations/0002_tickets.sql), writes made with the session cookie must be same-origin, and opening and answering are limited per caller. Sign-in is OpenVibe.Network (OAuth 2 + PKCE), the pages are server-rendered through the OpenVibe Frame and work without JavaScript, and the crawl artifacts cover every site and every question.",@@@ -27,7 +27,8 @@"Crawl artifacts: a sitemap and llms-full.txt built from one page list (every site, every question), llms.txt listing the sites, JSON-LD for the home page, a site and a question","Sign-in with OpenVibe.Network (PKCE S256, server-side code exchange, httpOnly session cookies)","Truthful readiness (/api/ready: the database is required) and Prometheus metrics on loopback",-- "Tests: the catalog against the manifests, escaping, search scoring with an OpenVibe.Search stand-in, and the whole ticket lifecycle including limits"++ "Tests: the catalog against the manifests, escaping, search scoring with an OpenVibe.Search stand-in, and the whole ticket lifecycle including limits",++ "Account export and deletion (ADR-033): the tickets a person opened and every message they wrote are exported to OpenVibe.Network on network.account.export_requested; on network.account.deleted their own tickets (and, by cascade, every message under them) are erased while a staff reply they left in someone else's ticket is anonymized, with the counts confirmed back; the two Events topics are subscribed at boot (server/identity/account-data.js)"],"notYet": ["A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host",@@@ -99,6 +99,10 @@ server {# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5020/metrics) ──location = /metrics { return 404; }++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──++ # Events posts to http://127.0.0.1:5020/internal/events directly. Never proxied from outside (ADR-033).++ location /internal/ { return 404; }++# ── Pages, docs, robots.txt, sitemap, static assets ─────location / {proxy_pass http://127.0.0.1:5020;nnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- OpenVibe.Help: 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++);++++-- ADR-033: a deletion anonymizes the author of a staff reply left in someone else's ticket, and++-- openvibe-sdk/account-data does that by setting the subject column to NULL. help_messages.author is that++-- subject column (server/identity/account-data.js), so it can no longer be NOT NULL. The reply's body stays:++-- the ticket's owner still has to read their own conversation (migrations/0002_tickets.sql).++ALTER TABLE help_messages ALTER COLUMN author DROP NOT NULL;@@@ -16,7 +16,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"},@@@ -887,9 +887,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.35.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",-- "integrity": "sha512-HudKZJK3lZ9i0+yRdMrz9pJmIrYNTN8AFqbHFCz5jDCF68CfZwUJQyhE61pHODzGRyTvUi4DtAzkd0WEzIWx/g==",++ "version": "0.36.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",++ "integrity": "sha512-mb+VvCuFH3KnMw4LWvqMDEoF93rNEb+eQi8HfT6mj+RPN5eQFWMcc/DCqJ3L0Yzjr99tQ08+DLwaYdZDIlfD0A==","license": "MIT","engines": {"node": ">=20"@@@ -23,7 +23,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0","pg": "^8.23.0"},@@@ -34,6 +34,8 @@ const { createApi } = require('./http/api');const { createPageRoutes } = require('./http/pages');const { createServiceReadiness } = require('./observability');const { createCallerLimits } = require('./http/caller-limits');++const accountDataLib = require('./identity/account-data');++const { createNetworkSender } = require('openvibe-sdk/account-data');const { assetVersion, send } = require('./render/layout');const { html } = require('./render/html');@@@ -42,7 +44,7 @@ const VERSION = require('../package.json').version;/*** opts: config, store, now (clock), fetchImpl (Network), log, limitsNow, callerLimits (false: count nobody,-- * tests only), valkey++ * tests only), valkey, accountSend (a stand-in for Network's internal routes; tests only)*/async function createApp(opts = {}) {const config = opts.config || configLib.load();@@@ -59,6 +61,18 @@ async function createApp(opts = {}) {const search = createNetworkSearch({ config, fetchImpl, now: s.now, log });const ctx = { config, s, keys, sso, principal, catalog, search, log };++ // Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.++ // The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;++ // a test injects a stand-in through opts.accountSend.++ const accountData = accountDataLib.create({ db: s.db, log });++ // Without a client secret the service has no way to push a part or a confirmation: the route still answers++ // (bad signature, a forwarded request, no secret), and an event that really arrives asks Events to retry.++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Help cannot answer account events'); });++ ctx.accountData = accountData;++ ctx.accountSend = accountSend;++const app = express();app.disable('x-powered-by');app.set('trust proxy', config.trustProxy);@@@ -110,6 +124,12 @@ async function createApp(opts = {}) {app.use('/auth', sso.routes());{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'help', service: 'help', host: 'openvibe.help', name: 'OpenVibe.Help', profile: 'ugc' })); }++ // ── OpenVibe.Events → this service (loopback only) ──────++ // network.account.export_requested and network.account.deleted (ADR-033), answered by the SDK's consumer. It++ // reads the raw body itself, so it is mounted before any body parser (there is none above it), and nginx answers++ // 404 for /internal/ so this is reachable only on 127.0.0.1.++ app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));++// ── Static assets (content-hashed ?v= → immutable) ──────app.use('/shared', require('openvibe-shared/serve').handler());app.use(express.static(PUBLIC_DIR, {@@@ -62,6 +62,16 @@ function load(env = process.env) {sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',},cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++++ // OpenVibe.Events → this service (server/events-consumer.js). The secret signs a delivery (comma-separated++ // for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the++ // network.account.export_requested and network.account.deleted subscriptions are created at boot, off when++ // unset. Both topics are ADR-033: account export and deletion (server/identity/account-data.js).++ events: {++ secrets: String(env.HELP_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ url: trim(env.HELP_EVENTS_URL || env.EVENTS_URL || ''),++ endpoint: env.HELP_EVENTS_ENDPOINT || '',++ },};}nnew file mode 100644@@@ -0,0 +1,52 @@++'use strict';++++/**++ * OpenVibe.Events → Help. Help has no consumer of its own: the two topics it takes are account export and deletion++ * (ADR-033), and POST /internal/events is answered by openvibe-sdk/account-data's own consumer, mounted in++ * server/app.js over server/identity/account-data.js (the table map). What is left here is the boot-time half:++ * startSubscriptions() creates the two subscriptions at Events if they are missing, idempotently, retried with++ * backoff, and off when the URL or the secret is unset (HELP_EVENTS_SUBSCRIBE=0 off switch).++ *++ * The endpoint is this service's loopback /internal/events (Events posts to http://127.0.0.1:<port> directly; nginx++ * answers 404 there), and the token is this service's own for audience openvibe.events with++ * scope events.subscription.manage.++ */++const { serviceAuth } = require('openvibe-contracts');++const { TOPICS } = require('./identity/account-data');++++/** Create any missing subscription for TOPICS at Events (idempotent; retried in the background at boot). */++function startSubscriptions({ config, port, secret, eventsUrl = config.events.url, fetchImpl = globalThis.fetch, log = console }) {++ if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.HELP_EVENTS_SUBSCRIBE === '0') return null;++ const base = String(eventsUrl).replace(/\/+$/, '');++ const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;++ const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.events', scope: 'events.subscription.manage', fetchImpl });++ const call = async (method, path, body) => {++ const res = await fetchImpl(`${base}${path}`, { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000) });++ const json = await res.json().catch(() => ({}));++ return { status: res.status, ok: res.ok, body: json };++ };++ const attempt = async () => {++ const listed = await call('GET', '/api/v1/subscriptions');++ if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);++ const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);++ for (const topic of TOPICS) {++ if (mine.some((s) => s.topic_pattern === topic)) continue;++ const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });++ if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);++ if (r.ok) log.log(`[Events consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);++ }++ };++ const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];++ let i = 0;++ let timer = null;++ let stopped = false;++ const run = () => { timer = null; if (stopped) return; attempt().catch((err) => {++ if (stopped) return;++ if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Events consumer] subscriptions not created:', err.message);++ }); };++ timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();++ // Graceful stop: no further attempts (they run again at the next start).++ return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };++}++++module.exports = { startSubscriptions, TOPICS };nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++++/**++ * Account export and deletion → Help (ADR-033; openvibe-sdk/account-data). Help holds two things a person wrote:++ * a support ticket they opened and every message in the conversations they took part in. Both key the subject the++ * same way — `user:usr_…` (server/http/principal.js builds the requester that way, server/tickets/store.js writes++ * it into help_tickets.requester and help_messages.author).++ *++ * network.account.export_requested the person's tickets (tickets.json) and messages (messages.json), newest first,++ * pushed to Network (POST /internal/account-exports/:id/parts) with this++ * service's own token.++ * network.account.deleted the person's own tickets go (with every message under them), their replies in++ * other people's tickets are anonymized, and Help confirms with counts.++ *++ * The order matters. help_messages.ticket_id REFERENCES help_tickets(id) ON DELETE CASCADE, so help_tickets is erased++ * FIRST: deleting the person's own tickets takes every message under them with it, from either side. What is left++ * authored by this person is then only their replies in OTHER people's tickets (a staff reply). Those keep their body++ * — the ticket's owner still has to read their own conversation — but the author is anonymized to NULL (an empty++ * `{ anonymize: {} }` sets the subject column NULL and changes nothing else). Nothing here is a secret: no token, key++ * or credential is stored, so every column of both tables may be exported.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++/**++ * The tables that hold a person's rows, with the value the subject column really stores. Both store `user:usr_…`++ * (server/tickets/store.js takes the author straight from the principal's requester).++ *++ * help_tickets comes before help_messages on purpose (the ON DELETE CASCADE on help_messages.ticket_id): the person's++ * own tickets — and every message under them — are deleted first, and only the surviving staff replies they wrote in++ * someone else's ticket are anonymized.++ */++const TABLES = [++ { table: 'help_tickets', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tickets.json', erase: 'delete' },++ { table: 'help_messages', subject: 'author', value: (usr) => `user:${usr}`, file: 'messages.json', erase: { anonymize: {} } },++];++++/** The account-data handle for Help's store (server/db.js createStore). */++function create({ db, note = 'A page URL in a ticket is stored as text and never fetched.', log = console } = {}) {++ return createAccountData({ db, service: 'help', tables: TABLES, note, log });++}++++module.exports = { create, TABLES, TOPICS };@@@ -8,13 +8,14 @@ const { createApp } = require('./app');const { gracefulStop } = require('openvibe-sdk/service');/**-- * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops and the store closes.-- * Exported so a test can inject `exit` and `signals: false`.++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops, the store closes and++ * the Events subscriptions stop. Exported so a test can inject `exit` and `signals: false`. `extra` is what the++ * product adds (the Events subscriptions); a test that passes none keeps the skeleton's order.*/--function createLifecycle({ server, ctx, exit, signals, timers = [] }) {++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {return gracefulStop({name: 'OpenVibe.Help', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,-- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.s.close()],++ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],});}@@@ -28,7 +29,11 @@ async function start() {server.keepAliveTimeout = 65_000;ctx.keys.client.start();-- createLifecycle({ server, ctx });++ // Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without HELP_EVENTS_URL and++ // HELP_EVENTS_SECRET). The consumer itself is mounted in server/app.js.++ const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });++ const extra = [() => { if (subscriptions) subscriptions.stop(); }];++ createLifecycle({ server, ctx, extra });return { server, ctx };}nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++/**++ * ADR-033: Help's part of an account export (the tickets a person opened and their messages) and of an account++ * deletion, applied through the service's own /internal/events route with a stand-in Network. Real rows are made++ * through the service's ticket store (server/tickets/store.js), the signed delivery is answered by++ * openvibe-sdk/account-data, and the part only ever carries person A's rows. On deletion the person's own ticket++ * (and every message under it, by the ON DELETE CASCADE) goes, their staff reply in someone else's ticket keeps its++ * body and is anonymized, a redelivery erases nothing twice, and the route refuses a bad signature and a forwarded++ * request.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++const { createNetworkSender } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const helpStore = require('../server/tickets/store');++++const A = 'usr_01JZ0000000000000000000AAA';++const B = 'usr_01JZ0000000000000000000BBB';++const OLD = 'usr_01JZ0000000000000000000MRG';++const EXP = 'exp_01JZ0000000000000000000EXP';++const DEL = 'del_01JZ0000000000000000000DEX';++// Fixture secrets, built so they never look like a real key to a scanner.++const SECRET = `whsec_${'fixture'.repeat(6)}`;++const WRONG = `whsec_${'mismatch'.repeat(5)}`;++++const exportEvent = { event_id: 'evt_01JZ0000000000000000000E01', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };++const deleteEvent = { event_id: 'evt_01JZ0000000000000000000D01', event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [OLD] } };++const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });++++/** A stand-in for Network's internal routes: the token endpoint, the export part and the deletion confirmation. */++async function startNetworkStub({ partStatus = 201, confirmStatus = 201 } = {}) {++ const calls = [];++ const statusOf = (v) => (typeof v === 'function' ? v() : v);++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ 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_help', token_type: 'Bearer', expires_in: 300, scope: 'openvibe.network' });++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(raw.toString() || 'null') });++ return json(req.url.includes('/parts') ? statusOf(partStatus) : statusOf(confirmStatus), {});++ });++ });++ 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)) };++}++++/**++ * Make the rows through the store the API writes with (server/tickets/store.js): A opens a ticket, B opens one,++ * A is staff and replies in B's ticket, and B replies in A's ticket. Returns both ticket ids.++ */++async function makeRows(t) {++ const s = t.ctx.s;++ const aTicket = await helpStore.create(s, { requester: `user:${A}`, site: '', subject: 'a', body: 'a needs help' });++ const bTicket = await helpStore.create(s, { requester: `user:${B}`, site: '', subject: 'b', body: 'b needs help' });++ await helpStore.addMessage(s, bTicket.id, { author: `user:${A}`, role: 'staff', body: 'ask again' });++ await helpStore.addMessage(s, aTicket.id, { author: `user:${B}`, role: 'person', body: 'b replies here' });++ return { aTicket, bTicket };++}++++(async () => {++ await check('an export part carries only the person\'s tickets and messages, and no secret', async () => {++ const stub = await startNetworkStub();++ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'help', clientSecret: 'help-secret' });++ const t = await boot({ env: { HELP_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ await makeRows(t);++++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.json().outcome, 'exported');++++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);++ assert.ok(part, 'the part was pushed to Network');++ assert.strictEqual(part.auth, 'Bearer tok_help', 'with this service\'s own token');++ assert.strictEqual(part.body.subject, A);++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['messages.json', 'tickets.json']);++ const tickets = part.body.files.find((f) => f.name === 'tickets.json').content;++ assert.strictEqual(tickets.length, 1, 'only the person\'s own ticket');++ assert.strictEqual(tickets[0].subject, 'a');++ assert.strictEqual(tickets[0].requester, `user:${A}`);++ // A's messages: the opening message of their own ticket AND their staff reply in B's ticket.++ const messages = part.body.files.find((f) => f.name === 'messages.json').content;++ assert.deepStrictEqual(messages.map((m) => m.body).sort(), ['a needs help', 'ask again']);++ assert.ok(!JSON.stringify(part.body).includes('b needs help'), 'nobody else\'s ticket');++ assert.ok(!JSON.stringify(part.body).includes('b replies here'), 'nobody else\'s message');++ assert.ok(!/token|secret|password/i.test(JSON.stringify(part.body)), 'no secret is exported');++ } finally { await t.close(); await stub.close(); }++ });++++ await check('a deletion erases the person\'s own ticket (its messages cascade), anonymizes their staff reply elsewhere, and confirms with counts', async () => {++ const stub = await startNetworkStub();++ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'help', clientSecret: 'help-secret' });++ const t = await boot({ env: { HELP_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ const { aTicket, bTicket } = await makeRows(t);++ const s = t.ctx.s;++ const ticketsForA = () => s.db.value('SELECT count(*)::int FROM help_tickets WHERE requester = $1', [`user:${A}`]);++++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.json().outcome, 'erased');++ assert.strictEqual(await ticketsForA(), 0, 'the person\'s own ticket is gone');++ assert.strictEqual((await helpStore.messages(s, aTicket.id)).length, 0, 'every message under it cascaded away, from either side');++ assert.ok(await helpStore.get(s, bTicket.id), 'someone else\'s ticket stays');++++ // Their staff reply in B's ticket is anonymized (author NULL) and keeps its body, so B still reads it.++ const reply = await s.db.maybe("SELECT author, body FROM help_messages WHERE ticket_id = $1 AND body = 'ask again'", [bTicket.id]);++ assert.deepStrictEqual(reply, { author: null, body: 'ask again' }, 'the staff reply is anonymized, not deleted');++ const conversation = await helpStore.messages(s, bTicket.id);++ assert.strictEqual(conversation.length, 2, 'B\'s ticket is still readable');++ assert.ok(conversation.some((m) => m.body === 'ask again'), 'B can still read the reply');++++ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);++ assert.ok(confirmation, 'the confirmation was sent');++ // The person's own ticket is erased; its messages went with it by the ON DELETE CASCADE, so they are not++ // counted separately. The staff reply that survived in someone else's ticket is a tombstone.++ assert.deepStrictEqual(confirmation.body.erased, { help_tickets: 1 });++ assert.deepStrictEqual(confirmation.body.retained, { tombstones: 1 });++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));++++ // A redelivery must not erase again: a ticket A opens after the deletion stays.++ await helpStore.create(s, { requester: `user:${A}`, site: '', subject: 'written-later', body: 'later' });++ const again = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json().outcome, 'unchanged');++ assert.strictEqual(await ticketsForA(), 1, 'nothing was erased twice');++ } finally { await t.close(); await stub.close(); }++ });++++ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {++ const stub = await startNetworkStub();++ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'help', clientSecret: 'help-secret' });++ const t = await boot({ env: { HELP_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });++ assert.strictEqual(bad.status, 401);++++ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(forwarded.status, 403);++ } finally { await t.close(); await stub.close(); }++ });++++ done();++})();@@@ -38,7 +38,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true });++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;
@@ -32,5 +32,12 @@ OV_OAUTH_CLIENT_SECRET=
@@ -32,5 +32,12 @@ OV_OAUTH_CLIENT_SECRET=
#HELP_SEARCH_TIMEOUT_MS=2500
#HELP_SEARCH_TIMEOUT_MS=2500
#HELP_SEARCH_CACHE_MS=300000
#HELP_SEARCH_CACHE_MS=300000
+# OpenVibe.Events → Help (ADR-033 account export and deletion): the secret signs a delivery (comma-separated for
+# rotation, 32+ characters each; unset makes POST /internal/events answer 503); the url is where the two subscriptions
+# are created at boot (off when unset); HELP_EVENTS_SUBSCRIBE=0 turns that off.
+HELP_EVENTS_SECRET=
+HELP_EVENTS_URL=
+#HELP_EVENTS_ENDPOINT=http://127.0.0.1:5020/internal/events
+
# optional: defaults to true in production
# optional: defaults to true in production
# COOKIE_SECURE=true
# COOKIE_SECURE=true
@@ -166,6 +166,27 @@ listed with ○ and is not counted as a pass.
@@ -166,6 +166,27 @@ listed with ○ and is not counted as a pass.
**OpenVibe.Services** before the first deploy. No new environment variable is required to run: `HELP_SEARCH_URL` has
**OpenVibe.Services** before the first deploy. No new environment variable is required to run: `HELP_SEARCH_URL` has
a default.
a default.
+## Account export and deletion
+
+A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers its
+part (ADR-033). Help receives `network.account.export_requested` and `network.account.deleted` at
+`POST /internal/events` (loopback only) — the two tables are mapped in
+[server/identity/account-data.js](server/identity/account-data.js), and the boot-time subscriptions are created by
+[server/events-consumer.js](server/events-consumer.js):
+
+- **Exported:** the tickets a person opened (`tickets.json`) and every message they wrote (`messages.json`), pushed to
+ `POST /internal/account-exports/:id/parts` with this service's own token. Nothing here is a secret — Help stores no
+ token, key or credential.
+- **Erased:** the person's own tickets are deleted; the `ON DELETE CASCADE` on `help_messages.ticket_id` takes every
+ message under them — from either side — with them.
+- **Anonymized:** a staff reply the person wrote in someone else's ticket keeps its body — that ticket's owner still
+ has to read their own conversation — and its `author` becomes NULL. (`help_messages.author` is nullable for exactly
+ this reason.) Help then confirms with `POST /internal/account-deletions/:id/confirmations` and the counts.
+
+Environment: `HELP_EVENTS_SECRET` (comma-separated for rotation, 32+ characters each; unset makes the route answer
+503), `HELP_EVENTS_URL` (or `EVENTS_URL`) is where the two subscriptions are created at boot (off when unset), and
+`HELP_EVENTS_ENDPOINT` overrides the loopback endpoint; `HELP_EVENTS_SUBSCRIBE=0` turns the boot-time subscription off.
+
## Security (threat notes)
## Security (threat notes)
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -187,6 +208,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -187,6 +208,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.116.0
- openvibe-contracts: v0.116.0
-- openvibe-sdk: v0.35.2
+- openvibe-sdk: v0.36.0
- openvibe-shared: v2.15.0
- openvibe-shared: v2.15.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,11 +7,11 @@
@@ -7,11 +7,11 @@
"port": "5020",
"port": "5020",
"domain": "openvibe.help",
"domain": "openvibe.help",
"contracts": "openvibe-contracts v0.115.0",
"contracts": "openvibe-contracts v0.115.0",
- "sdk": "openvibe-sdk v0.35.2",
+ "sdk": "openvibe-sdk v0.36.0",
"shared": "openvibe-shared v2.14.0",
"shared": "openvibe-shared v2.14.0",
"packages": [
"packages": [
"openvibe-contracts v0.115.0",
"openvibe-contracts v0.115.0",
- "openvibe-sdk v0.35.2",
+ "openvibe-sdk v0.36.0",
"openvibe-shared v2.14.0"
"openvibe-shared v2.14.0"
],
],
"summary": "The help and support centre for every OpenVibe site. The help centre is generated at boot from openvibe-contracts (every product manifest and every service manifest's site block): one page per site, one page per question, each text quoted verbatim with its manifest named. /search searches those articles with a fixed explainable score and the network through OpenVibe.Search's public API (cached 5 minutes, degrading to the articles alone when it fails). A signed-in person opens a support ticket that stays one conversation — site, subject, message, an optional page URL stored as text and never fetched — sees only their own, and replies; staff by their Network role claim read the queue, filter it and set the state. Tickets live in PostgreSQL (migrations/0002_tickets.sql), writes made with the session cookie must be same-origin, and opening and answering are limited per caller. Sign-in is OpenVibe.Network (OAuth 2 + PKCE), the pages are server-rendered through the OpenVibe Frame and work without JavaScript, and the crawl artifacts cover every site and every question.",
"summary": "The help and support centre for every OpenVibe site. The help centre is generated at boot from openvibe-contracts (every product manifest and every service manifest's site block): one page per site, one page per question, each text quoted verbatim with its manifest named. /search searches those articles with a fixed explainable score and the network through OpenVibe.Search's public API (cached 5 minutes, degrading to the articles alone when it fails). A signed-in person opens a support ticket that stays one conversation — site, subject, message, an optional page URL stored as text and never fetched — sees only their own, and replies; staff by their Network role claim read the queue, filter it and set the state. Tickets live in PostgreSQL (migrations/0002_tickets.sql), writes made with the session cookie must be same-origin, and opening and answering are limited per caller. Sign-in is OpenVibe.Network (OAuth 2 + PKCE), the pages are server-rendered through the OpenVibe Frame and work without JavaScript, and the crawl artifacts cover every site and every question.",
@@ -27,7 +27,8 @@
@@ -27,7 +27,8 @@
"Crawl artifacts: a sitemap and llms-full.txt built from one page list (every site, every question), llms.txt listing the sites, JSON-LD for the home page, a site and a question",
"Crawl artifacts: a sitemap and llms-full.txt built from one page list (every site, every question), llms.txt listing the sites, JSON-LD for the home page, a site and a question",
"Sign-in with OpenVibe.Network (PKCE S256, server-side code exchange, httpOnly session cookies)",
"Sign-in with OpenVibe.Network (PKCE S256, server-side code exchange, httpOnly session cookies)",
"Truthful readiness (/api/ready: the database is required) and Prometheus metrics on loopback",
"Truthful readiness (/api/ready: the database is required) and Prometheus metrics on loopback",
- "Tests: the catalog against the manifests, escaping, search scoring with an OpenVibe.Search stand-in, and the whole ticket lifecycle including limits"
+ "Tests: the catalog against the manifests, escaping, search scoring with an OpenVibe.Search stand-in, and the whole ticket lifecycle including limits",
+ "Account export and deletion (ADR-033): the tickets a person opened and every message they wrote are exported to OpenVibe.Network on network.account.export_requested; on network.account.deleted their own tickets (and, by cascade, every message under them) are erased while a staff reply they left in someone else's ticket is anonymized, with the counts confirmed back; the two Events topics are subscribed at boot (server/identity/account-data.js)"
],
],
"notYet": [
"notYet": [
"A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host",
"A deployment: the service is not registered with OpenVibe.Services nor deployed by OpenVibe.Host",
@@ -99,6 +99,10 @@ server {
@@ -99,6 +99,10 @@ server {
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5020/metrics) ──
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5020/metrics) ──
location = /metrics { return 404; }
location = /metrics { return 404; }
+ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
+ # Events posts to http://127.0.0.1:5020/internal/events directly. Never proxied from outside (ADR-033).
+ location /internal/ { return 404; }
+
# ── Pages, docs, robots.txt, sitemap, static assets ─────
# ── Pages, docs, robots.txt, sitemap, static assets ─────
location / {
location / {
proxy_pass http://127.0.0.1:5020;
proxy_pass http://127.0.0.1:5020;
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- OpenVibe.Help: 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
+);
+
+-- ADR-033: a deletion anonymizes the author of a staff reply left in someone else's ticket, and
+-- openvibe-sdk/account-data does that by setting the subject column to NULL. help_messages.author is that
+-- subject column (server/identity/account-data.js), so it can no longer be NOT NULL. The reply's body stays:
+-- the ticket's owner still has to read their own conversation (migrations/0002_tickets.sql).
+ALTER TABLE help_messages ALTER COLUMN author DROP NOT NULL;
@@ -16,7 +16,7 @@
@@ -16,7 +16,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.116.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.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"
},
},
@@ -887,9 +887,9 @@
@@ -887,9 +887,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.35.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
- "integrity": "sha512-HudKZJK3lZ9i0+yRdMrz9pJmIrYNTN8AFqbHFCz5jDCF68CfZwUJQyhE61pHODzGRyTvUi4DtAzkd0WEzIWx/g==",
+ "version": "0.36.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
+ "integrity": "sha512-mb+VvCuFH3KnMw4LWvqMDEoF93rNEb+eQi8HfT6mj+RPN5eQFWMcc/DCqJ3L0Yzjr99tQ08+DLwaYdZDIlfD0A==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -23,7 +23,7 @@
@@ -23,7 +23,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.116.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.116.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.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"
},
},
@@ -34,6 +34,8 @@ const { createApi } = require('./http/api');
@@ -34,6 +34,8 @@ const { createApi } = require('./http/api');
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');
+const accountDataLib = require('./identity/account-data');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const { html } = require('./render/html');
@@ -42,7 +44,7 @@ const VERSION = require('../package.json').version;
@@ -42,7 +44,7 @@ const VERSION = require('../package.json').version;
/**
/**
* opts: config, store, now (clock), fetchImpl (Network), log, limitsNow, callerLimits (false: count nobody,
* opts: config, store, now (clock), fetchImpl (Network), log, limitsNow, callerLimits (false: count nobody,
- * tests only), valkey
+ * tests only), valkey, accountSend (a stand-in for Network's internal routes; tests only)
*/
*/
async function createApp(opts = {}) {
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
@@ -59,6 +61,18 @@ async function createApp(opts = {}) {
@@ -59,6 +61,18 @@ async function createApp(opts = {}) {
const search = createNetworkSearch({ config, fetchImpl, now: s.now, log });
const search = createNetworkSearch({ config, fetchImpl, now: s.now, log });
const ctx = { config, s, keys, sso, principal, catalog, search, log };
const ctx = { config, s, keys, sso, principal, catalog, search, log };
+ // Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
+ // The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
+ // a test injects a stand-in through opts.accountSend.
+ const accountData = accountDataLib.create({ db: s.db, log });
+ // Without a client secret the service has no way to push a part or a confirmation: the route still answers
+ // (bad signature, a forwarded request, no secret), and an event that really arrives asks Events to retry.
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Help cannot answer account events'); });
+ ctx.accountData = accountData;
+ ctx.accountSend = accountSend;
+
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
@@ -110,6 +124,12 @@ async function createApp(opts = {}) {
@@ -110,6 +124,12 @@ async function createApp(opts = {}) {
app.use('/auth', sso.routes());
app.use('/auth', sso.routes());
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'help', service: 'help', host: 'openvibe.help', name: 'OpenVibe.Help', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'help', service: 'help', host: 'openvibe.help', name: 'OpenVibe.Help', profile: 'ugc' })); }
+ // ── OpenVibe.Events → this service (loopback only) ──────
+ // network.account.export_requested and network.account.deleted (ADR-033), answered by the SDK's consumer. It
+ // reads the raw body itself, so it is mounted before any body parser (there is none above it), and nginx answers
+ // 404 for /internal/ so this is reachable only on 127.0.0.1.
+ app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
+
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
app.use(express.static(PUBLIC_DIR, {
app.use(express.static(PUBLIC_DIR, {
@@ -62,6 +62,16 @@ function load(env = process.env) {
@@ -62,6 +62,16 @@ function load(env = process.env) {
sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
},
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+
+ // OpenVibe.Events → this service (server/events-consumer.js). The secret signs a delivery (comma-separated
+ // for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the
+ // network.account.export_requested and network.account.deleted subscriptions are created at boot, off when
+ // unset. Both topics are ADR-033: account export and deletion (server/identity/account-data.js).
+ events: {
+ secrets: String(env.HELP_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ url: trim(env.HELP_EVENTS_URL || env.EVENTS_URL || ''),
+ endpoint: env.HELP_EVENTS_ENDPOINT || '',
+ },
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+'use strict';
+
+/**
+ * OpenVibe.Events → Help. Help has no consumer of its own: the two topics it takes are account export and deletion
+ * (ADR-033), and POST /internal/events is answered by openvibe-sdk/account-data's own consumer, mounted in
+ * server/app.js over server/identity/account-data.js (the table map). What is left here is the boot-time half:
+ * startSubscriptions() creates the two subscriptions at Events if they are missing, idempotently, retried with
+ * backoff, and off when the URL or the secret is unset (HELP_EVENTS_SUBSCRIBE=0 off switch).
+ *
+ * The endpoint is this service's loopback /internal/events (Events posts to http://127.0.0.1:<port> directly; nginx
+ * answers 404 there), and the token is this service's own for audience openvibe.events with
+ * scope events.subscription.manage.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+const { TOPICS } = require('./identity/account-data');
+
+/** Create any missing subscription for TOPICS at Events (idempotent; retried in the background at boot). */
+function startSubscriptions({ config, port, secret, eventsUrl = config.events.url, fetchImpl = globalThis.fetch, log = console }) {
+ if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.HELP_EVENTS_SUBSCRIBE === '0') return null;
+ const base = String(eventsUrl).replace(/\/+$/, '');
+ const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;
+ const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.events', scope: 'events.subscription.manage', fetchImpl });
+ const call = async (method, path, body) => {
+ const res = await fetchImpl(`${base}${path}`, { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) }, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(15000) });
+ const json = await res.json().catch(() => ({}));
+ return { status: res.status, ok: res.ok, body: json };
+ };
+ const attempt = async () => {
+ const listed = await call('GET', '/api/v1/subscriptions');
+ if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
+ const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);
+ for (const topic of TOPICS) {
+ if (mine.some((s) => s.topic_pattern === topic)) continue;
+ const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
+ if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
+ if (r.ok) log.log(`[Events consumer] subscription created: ${r.body.id} (${topic} → ${endpoint})`);
+ }
+ };
+ const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];
+ let i = 0;
+ let timer = null;
+ let stopped = false;
+ const run = () => { timer = null; if (stopped) return; attempt().catch((err) => {
+ if (stopped) return;
+ if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[Events consumer] subscriptions not created:', err.message);
+ }); };
+ timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();
+ // Graceful stop: no further attempts (they run again at the next start).
+ return { topics: TOPICS, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
+}
+
+module.exports = { startSubscriptions, TOPICS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+
+/**
+ * Account export and deletion → Help (ADR-033; openvibe-sdk/account-data). Help holds two things a person wrote:
+ * a support ticket they opened and every message in the conversations they took part in. Both key the subject the
+ * same way — `user:usr_…` (server/http/principal.js builds the requester that way, server/tickets/store.js writes
+ * it into help_tickets.requester and help_messages.author).
+ *
+ * network.account.export_requested the person's tickets (tickets.json) and messages (messages.json), newest first,
+ * pushed to Network (POST /internal/account-exports/:id/parts) with this
+ * service's own token.
+ * network.account.deleted the person's own tickets go (with every message under them), their replies in
+ * other people's tickets are anonymized, and Help confirms with counts.
+ *
+ * The order matters. help_messages.ticket_id REFERENCES help_tickets(id) ON DELETE CASCADE, so help_tickets is erased
+ * FIRST: deleting the person's own tickets takes every message under them with it, from either side. What is left
+ * authored by this person is then only their replies in OTHER people's tickets (a staff reply). Those keep their body
+ * — the ticket's owner still has to read their own conversation — but the author is anonymized to NULL (an empty
+ * `{ anonymize: {} }` sets the subject column NULL and changes nothing else). Nothing here is a secret: no token, key
+ * or credential is stored, so every column of both tables may be exported.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+/**
+ * The tables that hold a person's rows, with the value the subject column really stores. Both store `user:usr_…`
+ * (server/tickets/store.js takes the author straight from the principal's requester).
+ *
+ * help_tickets comes before help_messages on purpose (the ON DELETE CASCADE on help_messages.ticket_id): the person's
+ * own tickets — and every message under them — are deleted first, and only the surviving staff replies they wrote in
+ * someone else's ticket are anonymized.
+ */
+const TABLES = [
+ { table: 'help_tickets', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tickets.json', erase: 'delete' },
+ { table: 'help_messages', subject: 'author', value: (usr) => `user:${usr}`, file: 'messages.json', erase: { anonymize: {} } },
+];
+
+/** The account-data handle for Help's store (server/db.js createStore). */
+function create({ db, note = 'A page URL in a ticket is stored as text and never fetched.', log = console } = {}) {
+ return createAccountData({ db, service: 'help', tables: TABLES, note, log });
+}
+
+module.exports = { create, TABLES, TOPICS };
@@ -8,13 +8,14 @@ const { createApp } = require('./app');
@@ -8,13 +8,14 @@ const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
/**
/**
- * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops and the store closes.
- * Exported so a test can inject `exit` and `signals: false`.
+ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops, the store closes and
+ * the Events subscriptions stop. Exported so a test can inject `exit` and `signals: false`. `extra` is what the
+ * product adds (the Events subscriptions); a test that passes none keeps the skeleton's order.
*/
*/
-function createLifecycle({ server, ctx, exit, signals, timers = [] }) {
+function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'OpenVibe.Help', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
name: 'OpenVibe.Help', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.s.close()],
+ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],
});
});
}
}
@@ -28,7 +29,11 @@ async function start() {
@@ -28,7 +29,11 @@ async function start() {
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
ctx.keys.client.start();
ctx.keys.client.start();
- createLifecycle({ server, ctx });
+ // Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without HELP_EVENTS_URL and
+ // HELP_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
+ const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });
+ const extra = [() => { if (subscriptions) subscriptions.stop(); }];
+ createLifecycle({ server, ctx, extra });
return { server, ctx };
return { server, ctx };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+/**
+ * ADR-033: Help's part of an account export (the tickets a person opened and their messages) and of an account
+ * deletion, applied through the service's own /internal/events route with a stand-in Network. Real rows are made
+ * through the service's ticket store (server/tickets/store.js), the signed delivery is answered by
+ * openvibe-sdk/account-data, and the part only ever carries person A's rows. On deletion the person's own ticket
+ * (and every message under it, by the ON DELETE CASCADE) goes, their staff reply in someone else's ticket keeps its
+ * body and is anonymized, a redelivery erases nothing twice, and the route refuses a bad signature and a forwarded
+ * request.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const helpStore = require('../server/tickets/store');
+
+const A = 'usr_01JZ0000000000000000000AAA';
+const B = 'usr_01JZ0000000000000000000BBB';
+const OLD = 'usr_01JZ0000000000000000000MRG';
+const EXP = 'exp_01JZ0000000000000000000EXP';
+const DEL = 'del_01JZ0000000000000000000DEX';
+// Fixture secrets, built so they never look like a real key to a scanner.
+const SECRET = `whsec_${'fixture'.repeat(6)}`;
+const WRONG = `whsec_${'mismatch'.repeat(5)}`;
+
+const exportEvent = { event_id: 'evt_01JZ0000000000000000000E01', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };
+const deleteEvent = { event_id: 'evt_01JZ0000000000000000000D01', event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [OLD] } };
+const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
+
+/** A stand-in for Network's internal routes: the token endpoint, the export part and the deletion confirmation. */
+async function startNetworkStub({ partStatus = 201, confirmStatus = 201 } = {}) {
+ const calls = [];
+ const statusOf = (v) => (typeof v === 'function' ? v() : v);
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ 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_help', token_type: 'Bearer', expires_in: 300, scope: 'openvibe.network' });
+ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(raw.toString() || 'null') });
+ return json(req.url.includes('/parts') ? statusOf(partStatus) : statusOf(confirmStatus), {});
+ });
+ });
+ 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)) };
+}
+
+/**
+ * Make the rows through the store the API writes with (server/tickets/store.js): A opens a ticket, B opens one,
+ * A is staff and replies in B's ticket, and B replies in A's ticket. Returns both ticket ids.
+ */
+async function makeRows(t) {
+ const s = t.ctx.s;
+ const aTicket = await helpStore.create(s, { requester: `user:${A}`, site: '', subject: 'a', body: 'a needs help' });
+ const bTicket = await helpStore.create(s, { requester: `user:${B}`, site: '', subject: 'b', body: 'b needs help' });
+ await helpStore.addMessage(s, bTicket.id, { author: `user:${A}`, role: 'staff', body: 'ask again' });
+ await helpStore.addMessage(s, aTicket.id, { author: `user:${B}`, role: 'person', body: 'b replies here' });
+ return { aTicket, bTicket };
+}
+
+(async () => {
+ await check('an export part carries only the person\'s tickets and messages, and no secret', async () => {
+ const stub = await startNetworkStub();
+ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'help', clientSecret: 'help-secret' });
+ const t = await boot({ env: { HELP_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ await makeRows(t);
+
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.json().outcome, 'exported');
+
+ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
+ assert.ok(part, 'the part was pushed to Network');
+ assert.strictEqual(part.auth, 'Bearer tok_help', 'with this service\'s own token');
+ assert.strictEqual(part.body.subject, A);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['messages.json', 'tickets.json']);
+ const tickets = part.body.files.find((f) => f.name === 'tickets.json').content;
+ assert.strictEqual(tickets.length, 1, 'only the person\'s own ticket');
+ assert.strictEqual(tickets[0].subject, 'a');
+ assert.strictEqual(tickets[0].requester, `user:${A}`);
+ // A's messages: the opening message of their own ticket AND their staff reply in B's ticket.
+ const messages = part.body.files.find((f) => f.name === 'messages.json').content;
+ assert.deepStrictEqual(messages.map((m) => m.body).sort(), ['a needs help', 'ask again']);
+ assert.ok(!JSON.stringify(part.body).includes('b needs help'), 'nobody else\'s ticket');
+ assert.ok(!JSON.stringify(part.body).includes('b replies here'), 'nobody else\'s message');
+ assert.ok(!/token|secret|password/i.test(JSON.stringify(part.body)), 'no secret is exported');
+ } finally { await t.close(); await stub.close(); }
+ });
+
+ await check('a deletion erases the person\'s own ticket (its messages cascade), anonymizes their staff reply elsewhere, and confirms with counts', async () => {
+ const stub = await startNetworkStub();
+ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'help', clientSecret: 'help-secret' });
+ const t = await boot({ env: { HELP_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ const { aTicket, bTicket } = await makeRows(t);
+ const s = t.ctx.s;
+ const ticketsForA = () => s.db.value('SELECT count(*)::int FROM help_tickets WHERE requester = $1', [`user:${A}`]);
+
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.json().outcome, 'erased');
+ assert.strictEqual(await ticketsForA(), 0, 'the person\'s own ticket is gone');
+ assert.strictEqual((await helpStore.messages(s, aTicket.id)).length, 0, 'every message under it cascaded away, from either side');
+ assert.ok(await helpStore.get(s, bTicket.id), 'someone else\'s ticket stays');
+
+ // Their staff reply in B's ticket is anonymized (author NULL) and keeps its body, so B still reads it.
+ const reply = await s.db.maybe("SELECT author, body FROM help_messages WHERE ticket_id = $1 AND body = 'ask again'", [bTicket.id]);
+ assert.deepStrictEqual(reply, { author: null, body: 'ask again' }, 'the staff reply is anonymized, not deleted');
+ const conversation = await helpStore.messages(s, bTicket.id);
+ assert.strictEqual(conversation.length, 2, 'B\'s ticket is still readable');
+ assert.ok(conversation.some((m) => m.body === 'ask again'), 'B can still read the reply');
+
+ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
+ assert.ok(confirmation, 'the confirmation was sent');
+ // The person's own ticket is erased; its messages went with it by the ON DELETE CASCADE, so they are not
+ // counted separately. The staff reply that survived in someone else's ticket is a tombstone.
+ assert.deepStrictEqual(confirmation.body.erased, { help_tickets: 1 });
+ assert.deepStrictEqual(confirmation.body.retained, { tombstones: 1 });
+ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
+
+ // A redelivery must not erase again: a ticket A opens after the deletion stays.
+ await helpStore.create(s, { requester: `user:${A}`, site: '', subject: 'written-later', body: 'later' });
+ const again = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json().outcome, 'unchanged');
+ assert.strictEqual(await ticketsForA(), 1, 'nothing was erased twice');
+ } finally { await t.close(); await stub.close(); }
+ });
+
+ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {
+ const stub = await startNetworkStub();
+ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'help', clientSecret: 'help-secret' });
+ const t = await boot({ env: { HELP_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });
+ assert.strictEqual(bad.status, 401);
+
+ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(forwarded.status, 403);
+ } finally { await t.close(); await stub.close(); }
+ });
+
+ done();
+})();
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true });
+ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
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)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;