openvibers/openvibe.deals Commits
Deals: account export and deletion (ADR-033) — watches, votes, name and rate windows deleted; contributed offers, prices, products and reports stay authorless; staff records kept; subscriptions at boot (SDK v0.37.0) (#20)
Changes
@@@ -2,5 +2,6 @@## Unreleased++- Account export and deletion (ADR-033): `network.account.export_requested` and `network.account.deleted` are answered at `POST /internal/events` by `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0). The person's watches, votes, name and rate windows are deleted; what they contributed to the shared catalog stays without their id; staff moderation records stay attributed. The two subscriptions are created at boot. Migration `0003_account_data.sql` adds `account_data_events`. `test/account-data.test.js`.- openvibe-contracts moves from v0.79.0 to v0.97.0 (pin, lockfile and `node_modules`); nothing in the range breaks Deals, and the contracts' own service check is green. All twelve `deals.*` ids are now defined by the release, so `server/auth/capabilities.js` drops its local fallback for proposed ids and sends every check through the library's grant rule; `test/capabilities.test.js` pins that every guarded id is defined and that exact, prefix, denied and unknown ids answer as `capabilities.check()` does. README and STATUS.json name v0.97.0.- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision. Styling is unchanged.@@@ -111,6 +111,15 @@ decides them with the contracts grant rule.Consumed: `sources.item.created|updated|removed` at `POST /internal/events` (signed,`DEALS_EVENTS_SECRET`, inbox-deduplicated). They carry no prices, so they only wake the importer.++Account export and deletion (ADR-033): `network.account.export_requested` and `network.account.deleted` arrive at the++same route and are answered by `server/domain/account-data.js` (`openvibe-sdk/account-data`). An export part carries++the person's profile name, votes, watches, the offers, price reports, products and reports they contributed. A deletion++removes their watches (and the notifications under them), votes, name and rate-limit windows. Offers, price reports,++products, aliases and reports stay in the shared catalog with the person's id set to NULL, and a vote-ring flag's++evidence names them as `deleted`. A staff member's `moderation_log` rows stay attributed (counted as retained). The++two subscriptions are created at boot when missing (`startSubscriptions`, needs `EVENTS_URL` and++`DEALS_EVENTS_SECRET`); `account_data_events` keeps one receipt per export and deletion.++## Prices and freshness- Every price, shipping cost, condition and availability is a row in `deal_price_observations` with@@@ -309,7 +318,8 @@ Each grant is `[client, capability, audience]`:- `[deals, identity.subject.resolve, openvibe.network]`- `[deals, events.event.publish, openvibe.events]`--- `[deals, events.subscription.manage, openvibe.events]` (only to run `scripts/subscribe.js`)++- `[deals, events.subscription.manage, openvibe.events]` (`scripts/subscribe.js`, and the account subscriptions at boot)++- `[deals, network.account.export.contribute, openvibe.network]` and `[deals, network.account.deletion.confirm, openvibe.network]` (ADR-033; granted last, once this release is live)- `[deals, sources.item.read, openvibe.sources]`- `[deals, community.comment.write, openvibe.community]`- `[deals, community.comment.moderate, openvibe.community]` (optional: hides a disabled deal's thread)@@@ -8,7 +8,7 @@"port": 4840,"domain": "openvibe.deals","contracts": "openvibe-contracts v0.112.0",-- "sdk": "openvibe-sdk v0.35.0",++ "sdk": "openvibe-sdk v0.37.0","shared": "openvibe-shared v2.15.0","packages": ["openvibe-publishing v1.3.0",nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- OpenVibe.Deals: 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 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",++ "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"},@@@ -907,9 +907,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.35.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",-- "integrity": "sha512-IF7XB97tU7Q+ccwssGx6QXXcfDPcVpl+mX3BjIr7C6NJlcmoqj9DbHiKrBxlVav0eG44Jf8zuXEwokrHDq3J0w==",++ "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 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",++ "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"},@@@ -41,6 +41,8 @@ const { createPages } = require('./http/pages');const { createApi } = require('./http/api');const { createDiscoveryRoutes } = require('./http/discovery');const { createEvents } = require('./http/events');++const accountDataLib = require('./domain/account-data');++const { createNetworkSender } = require('openvibe-sdk/account-data');const { createDealsReadiness } = require('./observability');const { createActorLimits } = require('./http/actor-limits');const { createWorker } = require('./worker');@@@ -98,7 +100,14 @@ async function createApp(opts = {}) {const viewers = createViewerResolver({ auth, config, people });const worker = createWorker({ config, store, offers, hotness, indexing, listings, importer, limits, log });-- const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, limits, access, offers, flags, votes, listings, importer, auth, viewers, worker, indexnow };++ // Account export and deletion (ADR-033, domain/account-data.js), pushed to Network's internal routes with this service's++ // own client-credentials token; a test injects a stand-in through opts.accountSend.++ const accountData = accountDataLib.create({ db: store.db, log });++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })++ : null);++++ const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, limits, access, offers, flags, votes, listings, importer, auth, viewers, worker, indexnow, accountData, accountSend };const app = express();app.disable('x-powered-by');@@@ -169,7 +178,7 @@ async function createApp(opts = {}) {// ── Events consumer, API ────────────────────────────────// Never per-actor limited: Events pushes at its own pace, and a 429 would only make it retry and fall behind.-- app.use(createEvents({ config, store, importer }));++ app.use(createEvents({ config, store, importer, accountData: ctx.accountData, accountSend: ctx.accountSend }));app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));// ── Discovery, public pages ─────────────────────────────nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++++/**++ * Account export and deletion → Deals (ADR-033; openvibe-sdk/account-data). What Deals holds about a person:++ *++ * their own, deleted whole watches and saved searches (with the notifications they produced), votes (with the++ * ip hash they were cast with), the name cache (subject_projections) and their++ * rate-limit windows++ * shared catalog, made authorless offers they submitted, prices they reported, products and aliases they added,++ * reports they filed: other people's votes, watches and price history hang under them,++ * so the rows stay and the person's id becomes NULL. An observer source row is keyed by++ * the person (ref_id), so that key becomes deleted:<row id>; a vote-ring flag's++ * evidence (details.voters) names them as 'deleted'.++ * kept moderation_log: a staff member's merges, expiries and reviews stay attributed, for++ * accountability (counted as retained)++ *++ * The export carries the person's own rows and what they contributed; no secret or other person's data is stored in++ * any of these tables. Staff-only columns (who reviewed, disabled or merged an offer) are made authorless and not++ * exported as the person's contributions.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const anonymize = { anonymize: {} };++++const TABLES = [++ { table: 'subject_projections', subject: 'subject', file: 'profile.json', columns: ['subject', 'username', 'display_name', 'first_seen_at', 'refreshed_at'], order: 'refreshed_at' },++ { table: 'deal_votes', subject: 'subject', file: 'votes.json', columns: ['offer_id', 'value', 'via', 'created_at', 'updated_at'] },++ { table: 'deal_offers', subject: 'submitted_by', file: 'offers.json', columns: ['id', 'slug', 'title', 'description', 'url', 'store_id', 'product_id', 'category', 'status', 'expires_at', 'created_at', 'updated_at'], erase: anonymize },++ { table: 'deal_offers', subject: 'reviewed_by', file: null, erase: anonymize },++ { table: 'deal_offers', subject: 'disabled_by', file: null, erase: anonymize },++ { table: 'deal_offers', subject: 'merged_by', file: null, erase: anonymize },++ { table: 'deal_offer_sources', subject: 'submitted_by', file: null, erase: anonymize },++ { table: 'deal_price_observations', subject: 'observed_by', file: 'price-reports.json', columns: ['id', 'offer_id', 'observed_at', 'price', 'currency', 'shipping', 'shipping_note', 'condition', 'availability', 'note'], order: 'observed_at', erase: anonymize },++ { table: 'deal_products', subject: 'created_by', file: 'products.json', columns: ['id', 'slug', 'name', 'brand', 'category', 'description', 'created_at'], erase: anonymize },++ { table: 'deal_product_aliases', subject: 'created_by', file: null, erase: anonymize },++ { table: 'deal_flags', subject: 'reporter', file: 'reports.json', columns: ['id', 'offer_id', 'kind', 'reason', 'status', 'created_at'], erase: anonymize },++ { table: 'deal_flags', subject: 'resolved_by', file: null, erase: anonymize },++ { table: 'moderation_log', subject: 'actor', file: null, erase: { keep: 'staff moderation actions stay attributed for accountability' } },++ { table: 'rate_events', subject: 'key', file: null },++];++++/** Watches go here, not in TABLES: their notifications reference them, so those are deleted first. */++async function extraExport(db, subject) {++ const rows = await db.many(`SELECT id, kind, query, product_id, max_price, currency, notify, label, created_at, deleted_at++ FROM deal_watches WHERE subject = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);++ return rows.length ? [{ name: 'watches.json', content: rows }] : [];++}++++async function extraErase(t, subjects, counts) {++ counts.add(counts.erased, 'watch_notifications', await t.exec(`DELETE FROM watch_notifications++ WHERE watch_id IN (SELECT id FROM deal_watches WHERE subject = ANY($1::text[]))`, [subjects]));++ counts.add(counts.erased, 'deal_watches', await t.exec('DELETE FROM deal_watches WHERE subject = ANY($1::text[])', [subjects]));++ // An observer source is keyed by the person (UNIQUE (kind, ref_service, ref_id, ref_part)): the key becomes the row's own.++ counts.add(counts.retained, 'tombstones', await t.exec(`UPDATE deal_offer_sources SET ref_id = 'deleted:' || id++ WHERE ref_type = 'observer' AND ref_id = ANY($1::text[])`, [subjects]));++ // A vote-ring flag names its voters as evidence (details.voters, details.shared_offers): the person's votes are gone,++ // so their id leaves the evidence too and the flag stays for moderators.++ const flagged = await t.many(`SELECT id, details FROM deal_flags WHERE kind = 'vote_ring'++ AND EXISTS (SELECT 1 FROM unnest($1::text[]) s WHERE deal_flags.details LIKE '%' || s || '%')`, [subjects]);++ for (const f of flagged) {++ let d;++ try { d = JSON.parse(f.details || '{}'); } catch { continue; }++ if (Array.isArray(d.voters)) d.voters = d.voters.map((v) => (subjects.includes(v) ? 'deleted' : v));++ if (d.shared_offers && typeof d.shared_offers === 'object') for (const s of subjects) delete d.shared_offers[s];++ counts.add(counts.retained, 'tombstones', await t.exec('UPDATE deal_flags SET details = $1 WHERE id = $2', [JSON.stringify(d), f.id]));++ }++}++++/** The account-data handle for Deals' database (server/db.js, store.db). */++function create({ db, log = console } = {}) {++ return createAccountData({++ db, service: 'deals', tables: TABLES, extraExport, extraErase, log,++ note: 'Offers, price reports, products and reports you contributed stay in the shared catalog without your name.',++ });++}++++module.exports = { create, TABLES, TOPICS };@@@ -1,8 +1,9 @@'use strict';/**-- * Events consumer: POST /internal/events, the endpoint of Deals' OpenVibe.Events subscription to-- * `sources.item.*` (filter: payload.category = deals). The signature (X-OpenVibe-Signature v2,++ * Events consumer: POST /internal/events, the endpoint of Deals' OpenVibe.Events subscriptions to++ * `sources.item.*` (filter: payload.category = deals) and to network.account.export_requested and++ * network.account.deleted (ADR-033, answered by server/domain/account-data.js through openvibe-sdk/account-data). The signature (X-OpenVibe-Signature v2,* ±300 s; several comma-separated secrets allow rotation) and the exactly-once inbox come from the* chassis (openvibe-publishing/ingest.createEventConsumer): the receipt (consumer, event_id) and the* change commit in one PostgreSQL transaction, so a redelivery changes nothing.@@@ -16,8 +17,14 @@ const { createEventConsumer } = require('openvibe-publishing/ingest');const CONSUMER = 'deals-sources';const TYPES = new Set(['sources.item.created', 'sources.item.updated', 'sources.item.removed']);++const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');--function createEvents({ config, store, importer }) {++/**++ * accountData + accountSend: the account export and deletion handle and its sender to Network. A failure (Network++ * unreachable, a refusal worth retrying) throws, the inbox receipt rolls back and Events redelivers; account-data keeps++ * its own receipt per export and deletion id, so a redelivery never erases twice.++ */++function createEvents({ config, store, importer, accountData = null, accountSend = null }) {const router = express.Router();const consumer = createEventConsumer({ db: store.db, secrets: config.events.webhookSecrets, consumer: CONSUMER, now: store.now });@@@ -26,6 +33,10 @@ function createEvents({ config, store, importer }) {let r;try {r = await consumer.apply(raw, req.headers, async (event) => {++ if (ACCOUNT_TOPICS.includes(event.event_type)) {++ if (!accountData || !accountSend) throw new Error('account export and deletion are not configured');++ return await accountData.apply(event, { send: accountSend });++ }if (!TYPES.has(event.event_type) || event.source !== 'sources') return 'ignored';const category = event.payload && event.payload.category;if (category !== 'deals') return 'ignored';@@@ -7,6 +7,7 @@*/const { createApp } = require('./app');const { gracefulStop } = require('openvibe-sdk/service');++const { startSubscriptions } = require('openvibe-sdk/account-data');/*** The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the worker stops taking new@@@ -14,10 +15,10 @@ const { gracefulStop } = require('openvibe-sdk/service');* the deadline the process exits 0, as the hand-rolled 5 s timer did. 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: 'Deals', server, deadlineExitCode: 0, exit, signals,-- stop: [() => ctx.worker.stop()],++ stop: [() => ctx.worker.stop(), ...extra],close: [() => ctx.outbox.stop(), () => ctx.store.close()],});}@@@ -33,8 +34,14 @@ async function start() {server.keepAliveTimeout = 65_000;ctx.outbox.start();ctx.worker.start();++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,++ // DEALS_EVENTS_SECRET or the client secret. The Sources one stays scripts/subscribe.js's.++ const subscriptions = startSubscriptions({++ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.webhookSecrets[0],++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ });-- createLifecycle({ server, ctx });++ createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });return { server, ctx };}nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++/**++ * ADR-033: Deals' part of an account export and of an account deletion, through the signed /internal/events route++ * with a stand-in Network. The person makes real rows through the app (a watch, an offer with its price, a vote, a++ * report, a price report on someone else's offer); the export carries only theirs, and the deletion removes what is++ * theirs alone, leaves the shared catalog without their name, keeps staff records, and confirms once.++ */++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 EXP = 'exp_01JZ0000000000000000000EXP';++const DEL = 'del_01JZ0000000000000000000DEX';++++/** A stand-in for Network's internal routes: the token endpoint, the export part and the deletion confirmation. */++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_deals', 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)) };++}++++const envelope = (id, type, payload) => ({ event: { event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), actor: { type: 'service', id: 'network' }, subject: { type: 'account', id: payload.subject }, visibility: 'internal', payload }, seq: 1 });++++(async () => {++ const stub = await startNetworkStub();++ const t = await boot({ accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'deals', clientSecret: 'shh' }) });++ const [alice, bob] = ['alice', 'bob'].map((n) => t.network.addUser(n));++ const post = (body) => t.get('/internal/events', { body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, 'whsec-test') } });++ const db = t.ctx.store.db;++ const count = async (sql, args) => Number(await db.value(sql, args));++ let mine;++ let theirs;++++ try {++ await check('the person\'s rows are made through the app', async () => {++ assert.strictEqual((await t.get('/watches', { as: alice, form: { kind: 'keyword', query: 'headphones' } })).status, 303);++ theirs = await t.submit(bob, { url: 'https://audio.example/nc700', title: 'Noise cancelling headphones', price: '49.99', currency: 'USD' });++ mine = await t.submit(alice, { url: 'https://shop.example/kettle', title: 'Kettle by alice', price: '20', currency: 'EUR' });++ assert.strictEqual((await t.get(`/api/v1/offers/${theirs.slug}/vote`, { method: 'PUT', as: alice, json: { value: 1 } })).status, 200);++ assert.strictEqual((await t.get(`/api/v1/offers/${mine.slug}/vote`, { method: 'PUT', as: bob, json: { value: 1 } })).status, 200);++ const obs = await t.get(`/api/v1/offers/${theirs.slug}/observations`, { as: alice, json: { price: '44.00', currency: 'USD' } });++ assert.ok(obs.status < 300, obs.text.slice(0, 300));++ const flag = await t.get(`/api/v1/offers/${theirs.slug}/flags`, { as: alice, json: { kind: 'price_wrong', reason: 'cheaper now' } });++ assert.ok(flag.status < 300, flag.text.slice(0, 300));++ await db.exec(`INSERT INTO moderation_log (action, offer_id, actor, at) VALUES ('review', $1, $2, 1)`, [theirs.id, alice.subject]);++ await db.exec(`INSERT INTO deal_flags (id, offer_id, kind, origin, reason, details, dedupe_key, created_at, updated_at)++ VALUES ('dfl_ring', $1, 'vote_ring', 'system', 'covote', $2, 'ring:test', 1, 1)`,++ [theirs.id, JSON.stringify({ signal: 'covote', voters: [alice.subject, bob.subject], shared_offers: { [bob.subject]: 3 } })]);++ assert.ok(await count('SELECT count(*) FROM watch_notifications'), 'the watch notified (a row that hangs under the watch)');++ });++++ await check('an export part carries only the person\'s rows, with this service\'s token', async () => {++ const r = await post(JSON.stringify(envelope('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: EXP, subject: alice.subject })));++ assert.deepStrictEqual([r.status, r.json().outcome], [200, 'exported'], r.text);++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);++ assert.strictEqual(part.auth, 'Bearer tok_deals');++ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));++ assert.deepStrictEqual(Object.keys(files).sort(), ['offers.json', 'price-reports.json', 'profile.json', 'reports.json', 'votes.json', 'watches.json']);++ assert.deepStrictEqual(files['offers.json'].map((o) => o.title), ['Kettle by alice']);++ assert.deepStrictEqual(files['votes.json'].map((v) => v.offer_id), [theirs.id]);++ assert.ok(files['price-reports.json'].some((o) => o.price === '44.00'));++ assert.deepStrictEqual(files['watches.json'].map((w) => w.query), ['headphones']);++ assert.ok(!JSON.stringify(part.body).includes(bob.subject), 'nobody else\'s id');++ assert.ok(!JSON.stringify(part.body).includes('ip_hash'), 'no ip hash');++ });++++ await check('a deletion removes what is theirs, leaves the catalog authorless, keeps staff records, and confirms once', async () => {++ const body = JSON.stringify(envelope('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: alice.subject }));++ const r = await post(body);++ assert.deepStrictEqual([r.status, r.json().outcome], [200, 'erased'], r.text);++ const a = [alice.subject];++ assert.strictEqual(await count('SELECT count(*) FROM deal_watches WHERE subject = $1', a), 0);++ assert.strictEqual(await count('SELECT count(*) FROM deal_votes WHERE subject = $1', a), 0);++ assert.strictEqual(await count('SELECT count(*) FROM deal_votes WHERE subject = $1', [bob.subject]), 1, 'bob\'s vote on alice\'s offer stays');++ assert.strictEqual(await count('SELECT count(*) FROM deal_offers WHERE id = $1 AND submitted_by IS NULL', [mine.id]), 1, 'her offer stays, authorless');++ assert.strictEqual(await count('SELECT count(*) FROM deal_price_observations WHERE observed_by = $1', a), 0);++ assert.strictEqual(await count('SELECT count(*) FROM deal_flags WHERE reporter = $1', a), 0);++ assert.strictEqual(await count('SELECT count(*) FROM deal_offer_sources WHERE ref_id = $1 OR submitted_by = $1', a), 0);++ assert.strictEqual(await count('SELECT count(*) FROM subject_projections WHERE subject = $1', a), 0);++ assert.strictEqual(await count('SELECT count(*) FROM moderation_log WHERE actor = $1', a), 1, 'the staff record stays');++ const ring = JSON.parse(await db.value("SELECT details FROM deal_flags WHERE id = 'dfl_ring'"));++ assert.deepStrictEqual(ring.voters, ['deleted', bob.subject], 'her id leaves the vote-ring evidence');++ const conf = stub.calls.filter((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);++ assert.strictEqual(conf.length, 1);++ assert.strictEqual(conf[0].body.erased.deal_watches, 1);++ assert.strictEqual(conf[0].body.erased.deal_votes, 1);++ assert.ok(conf[0].body.erased.watch_notifications >= 1);++ assert.strictEqual(conf[0].body.retained.moderation_log, 1);++ assert.ok(conf[0].body.retained.tombstones >= 3);++++ const again = await post(body);++ assert.strictEqual(again.json().duplicate, true, 'the inbox answers a redelivery');++ assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1, 'confirmed once');++ });++++ await check('the route refuses a bad signature', async () => {++ const body = JSON.stringify(envelope('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: EXP, subject: alice.subject }));++ assert.strictEqual((await t.get('/internal/events', { body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, 'whsec-wrong-secret') } })).status, 401);++ });++ } finally {++ await t.close();++ await stub.close();++ }++ done();++})();@@@ -45,7 +45,7 @@ async function boot(opts = {}) {let built = null;async function start() {const config = configLib.load(env);-- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow });++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend });await require('openvibe-sdk/auth').jwksClient(`${config.networkInternalUrl}/api/.well-known/jwks`).keys();server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });t.base = `http://127.0.0.1:${server.address().port}`;
@@ -2,5 +2,6 @@
@@ -2,5 +2,6 @@
## Unreleased
## Unreleased
+- Account export and deletion (ADR-033): `network.account.export_requested` and `network.account.deleted` are answered at `POST /internal/events` by `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0). The person's watches, votes, name and rate windows are deleted; what they contributed to the shared catalog stays without their id; staff moderation records stay attributed. The two subscriptions are created at boot. Migration `0003_account_data.sql` adds `account_data_events`. `test/account-data.test.js`.
- openvibe-contracts moves from v0.79.0 to v0.97.0 (pin, lockfile and `node_modules`); nothing in the range breaks Deals, and the contracts' own service check is green. All twelve `deals.*` ids are now defined by the release, so `server/auth/capabilities.js` drops its local fallback for proposed ids and sends every check through the library's grant rule; `test/capabilities.test.js` pins that every guarded id is defined and that exact, prefix, denied and unknown ids answer as `capabilities.check()` does. README and STATUS.json name v0.97.0.
- openvibe-contracts moves from v0.79.0 to v0.97.0 (pin, lockfile and `node_modules`); nothing in the range breaks Deals, and the contracts' own service check is green. All twelve `deals.*` ids are now defined by the release, so `server/auth/capabilities.js` drops its local fallback for proposed ids and sends every check through the library's grant rule; `test/capabilities.test.js` pins that every guarded id is defined and that exact, prefix, denied and unknown ids answer as `capabilities.check()` does. README and STATUS.json name v0.97.0.
- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision. Styling is unchanged.
- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision. Styling is unchanged.
@@ -111,6 +111,15 @@ decides them with the contracts grant rule.
@@ -111,6 +111,15 @@ decides them with the contracts grant rule.
Consumed: `sources.item.created|updated|removed` at `POST /internal/events` (signed,
Consumed: `sources.item.created|updated|removed` at `POST /internal/events` (signed,
`DEALS_EVENTS_SECRET`, inbox-deduplicated). They carry no prices, so they only wake the importer.
`DEALS_EVENTS_SECRET`, inbox-deduplicated). They carry no prices, so they only wake the importer.
+Account export and deletion (ADR-033): `network.account.export_requested` and `network.account.deleted` arrive at the
+same route and are answered by `server/domain/account-data.js` (`openvibe-sdk/account-data`). An export part carries
+the person's profile name, votes, watches, the offers, price reports, products and reports they contributed. A deletion
+removes their watches (and the notifications under them), votes, name and rate-limit windows. Offers, price reports,
+products, aliases and reports stay in the shared catalog with the person's id set to NULL, and a vote-ring flag's
+evidence names them as `deleted`. A staff member's `moderation_log` rows stay attributed (counted as retained). The
+two subscriptions are created at boot when missing (`startSubscriptions`, needs `EVENTS_URL` and
+`DEALS_EVENTS_SECRET`); `account_data_events` keeps one receipt per export and deletion.
+
## Prices and freshness
## Prices and freshness
- Every price, shipping cost, condition and availability is a row in `deal_price_observations` with
- Every price, shipping cost, condition and availability is a row in `deal_price_observations` with
@@ -309,7 +318,8 @@ Each grant is `[client, capability, audience]`:
@@ -309,7 +318,8 @@ Each grant is `[client, capability, audience]`:
- `[deals, identity.subject.resolve, openvibe.network]`
- `[deals, identity.subject.resolve, openvibe.network]`
- `[deals, events.event.publish, openvibe.events]`
- `[deals, events.event.publish, openvibe.events]`
-- `[deals, events.subscription.manage, openvibe.events]` (only to run `scripts/subscribe.js`)
+- `[deals, events.subscription.manage, openvibe.events]` (`scripts/subscribe.js`, and the account subscriptions at boot)
+- `[deals, network.account.export.contribute, openvibe.network]` and `[deals, network.account.deletion.confirm, openvibe.network]` (ADR-033; granted last, once this release is live)
- `[deals, sources.item.read, openvibe.sources]`
- `[deals, sources.item.read, openvibe.sources]`
- `[deals, community.comment.write, openvibe.community]`
- `[deals, community.comment.write, openvibe.community]`
- `[deals, community.comment.moderate, openvibe.community]` (optional: hides a disabled deal's thread)
- `[deals, community.comment.moderate, openvibe.community]` (optional: hides a disabled deal's thread)
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"port": 4840,
"port": 4840,
"domain": "openvibe.deals",
"domain": "openvibe.deals",
"contracts": "openvibe-contracts v0.112.0",
"contracts": "openvibe-contracts v0.112.0",
- "sdk": "openvibe-sdk v0.35.0",
+ "sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.15.0",
"shared": "openvibe-shared v2.15.0",
"packages": [
"packages": [
"openvibe-publishing v1.3.0",
"openvibe-publishing v1.3.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- OpenVibe.Deals: 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 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
+ "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"
},
},
@@ -907,9 +907,9 @@
@@ -907,9 +907,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.35.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
- "integrity": "sha512-IF7XB97tU7Q+ccwssGx6QXXcfDPcVpl+mX3BjIr7C6NJlcmoqj9DbHiKrBxlVav0eG44Jf8zuXEwokrHDq3J0w==",
+ "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 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
+ "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"
},
},
@@ -41,6 +41,8 @@ const { createPages } = require('./http/pages');
@@ -41,6 +41,8 @@ const { createPages } = require('./http/pages');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createEvents } = require('./http/events');
const { createEvents } = require('./http/events');
+const accountDataLib = require('./domain/account-data');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createDealsReadiness } = require('./observability');
const { createDealsReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
@@ -98,7 +100,14 @@ async function createApp(opts = {}) {
@@ -98,7 +100,14 @@ async function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, config, people });
const viewers = createViewerResolver({ auth, config, people });
const worker = createWorker({ config, store, offers, hotness, indexing, listings, importer, limits, log });
const worker = createWorker({ config, store, offers, hotness, indexing, listings, importer, limits, log });
- const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, limits, access, offers, flags, votes, listings, importer, auth, viewers, worker, indexnow };
+ // Account export and deletion (ADR-033, domain/account-data.js), pushed to Network's internal routes with this service's
+ // own client-credentials token; a test injects a stand-in through opts.accountSend.
+ const accountData = accountDataLib.create({ db: store.db, log });
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
+ : null);
+
+ const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, limits, access, offers, flags, votes, listings, importer, auth, viewers, worker, indexnow, accountData, accountSend };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -169,7 +178,7 @@ async function createApp(opts = {}) {
@@ -169,7 +178,7 @@ async function createApp(opts = {}) {
// ── Events consumer, API ────────────────────────────────
// ── Events consumer, API ────────────────────────────────
// Never per-actor limited: Events pushes at its own pace, and a 429 would only make it retry and fall behind.
// Never per-actor limited: Events pushes at its own pace, and a 429 would only make it retry and fall behind.
- app.use(createEvents({ config, store, importer }));
+ app.use(createEvents({ config, store, importer, accountData: ctx.accountData, accountSend: ctx.accountSend }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
// ── Discovery, public pages ─────────────────────────────
// ── Discovery, public pages ─────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+
+/**
+ * Account export and deletion → Deals (ADR-033; openvibe-sdk/account-data). What Deals holds about a person:
+ *
+ * their own, deleted whole watches and saved searches (with the notifications they produced), votes (with the
+ * ip hash they were cast with), the name cache (subject_projections) and their
+ * rate-limit windows
+ * shared catalog, made authorless offers they submitted, prices they reported, products and aliases they added,
+ * reports they filed: other people's votes, watches and price history hang under them,
+ * so the rows stay and the person's id becomes NULL. An observer source row is keyed by
+ * the person (ref_id), so that key becomes deleted:<row id>; a vote-ring flag's
+ * evidence (details.voters) names them as 'deleted'.
+ * kept moderation_log: a staff member's merges, expiries and reviews stay attributed, for
+ * accountability (counted as retained)
+ *
+ * The export carries the person's own rows and what they contributed; no secret or other person's data is stored in
+ * any of these tables. Staff-only columns (who reviewed, disabled or merged an offer) are made authorless and not
+ * exported as the person's contributions.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const anonymize = { anonymize: {} };
+
+const TABLES = [
+ { table: 'subject_projections', subject: 'subject', file: 'profile.json', columns: ['subject', 'username', 'display_name', 'first_seen_at', 'refreshed_at'], order: 'refreshed_at' },
+ { table: 'deal_votes', subject: 'subject', file: 'votes.json', columns: ['offer_id', 'value', 'via', 'created_at', 'updated_at'] },
+ { table: 'deal_offers', subject: 'submitted_by', file: 'offers.json', columns: ['id', 'slug', 'title', 'description', 'url', 'store_id', 'product_id', 'category', 'status', 'expires_at', 'created_at', 'updated_at'], erase: anonymize },
+ { table: 'deal_offers', subject: 'reviewed_by', file: null, erase: anonymize },
+ { table: 'deal_offers', subject: 'disabled_by', file: null, erase: anonymize },
+ { table: 'deal_offers', subject: 'merged_by', file: null, erase: anonymize },
+ { table: 'deal_offer_sources', subject: 'submitted_by', file: null, erase: anonymize },
+ { table: 'deal_price_observations', subject: 'observed_by', file: 'price-reports.json', columns: ['id', 'offer_id', 'observed_at', 'price', 'currency', 'shipping', 'shipping_note', 'condition', 'availability', 'note'], order: 'observed_at', erase: anonymize },
+ { table: 'deal_products', subject: 'created_by', file: 'products.json', columns: ['id', 'slug', 'name', 'brand', 'category', 'description', 'created_at'], erase: anonymize },
+ { table: 'deal_product_aliases', subject: 'created_by', file: null, erase: anonymize },
+ { table: 'deal_flags', subject: 'reporter', file: 'reports.json', columns: ['id', 'offer_id', 'kind', 'reason', 'status', 'created_at'], erase: anonymize },
+ { table: 'deal_flags', subject: 'resolved_by', file: null, erase: anonymize },
+ { table: 'moderation_log', subject: 'actor', file: null, erase: { keep: 'staff moderation actions stay attributed for accountability' } },
+ { table: 'rate_events', subject: 'key', file: null },
+];
+
+/** Watches go here, not in TABLES: their notifications reference them, so those are deleted first. */
+async function extraExport(db, subject) {
+ const rows = await db.many(`SELECT id, kind, query, product_id, max_price, currency, notify, label, created_at, deleted_at
+ FROM deal_watches WHERE subject = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
+ return rows.length ? [{ name: 'watches.json', content: rows }] : [];
+}
+
+async function extraErase(t, subjects, counts) {
+ counts.add(counts.erased, 'watch_notifications', await t.exec(`DELETE FROM watch_notifications
+ WHERE watch_id IN (SELECT id FROM deal_watches WHERE subject = ANY($1::text[]))`, [subjects]));
+ counts.add(counts.erased, 'deal_watches', await t.exec('DELETE FROM deal_watches WHERE subject = ANY($1::text[])', [subjects]));
+ // An observer source is keyed by the person (UNIQUE (kind, ref_service, ref_id, ref_part)): the key becomes the row's own.
+ counts.add(counts.retained, 'tombstones', await t.exec(`UPDATE deal_offer_sources SET ref_id = 'deleted:' || id
+ WHERE ref_type = 'observer' AND ref_id = ANY($1::text[])`, [subjects]));
+ // A vote-ring flag names its voters as evidence (details.voters, details.shared_offers): the person's votes are gone,
+ // so their id leaves the evidence too and the flag stays for moderators.
+ const flagged = await t.many(`SELECT id, details FROM deal_flags WHERE kind = 'vote_ring'
+ AND EXISTS (SELECT 1 FROM unnest($1::text[]) s WHERE deal_flags.details LIKE '%' || s || '%')`, [subjects]);
+ for (const f of flagged) {
+ let d;
+ try { d = JSON.parse(f.details || '{}'); } catch { continue; }
+ if (Array.isArray(d.voters)) d.voters = d.voters.map((v) => (subjects.includes(v) ? 'deleted' : v));
+ if (d.shared_offers && typeof d.shared_offers === 'object') for (const s of subjects) delete d.shared_offers[s];
+ counts.add(counts.retained, 'tombstones', await t.exec('UPDATE deal_flags SET details = $1 WHERE id = $2', [JSON.stringify(d), f.id]));
+ }
+}
+
+/** The account-data handle for Deals' database (server/db.js, store.db). */
+function create({ db, log = console } = {}) {
+ return createAccountData({
+ db, service: 'deals', tables: TABLES, extraExport, extraErase, log,
+ note: 'Offers, price reports, products and reports you contributed stay in the shared catalog without your name.',
+ });
+}
+
+module.exports = { create, TABLES, TOPICS };
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'use strict';
/**
/**
- * Events consumer: POST /internal/events, the endpoint of Deals' OpenVibe.Events subscription to
- * `sources.item.*` (filter: payload.category = deals). The signature (X-OpenVibe-Signature v2,
+ * Events consumer: POST /internal/events, the endpoint of Deals' OpenVibe.Events subscriptions to
+ * `sources.item.*` (filter: payload.category = deals) and to network.account.export_requested and
+ * network.account.deleted (ADR-033, answered by server/domain/account-data.js through openvibe-sdk/account-data). The signature (X-OpenVibe-Signature v2,
* ±300 s; several comma-separated secrets allow rotation) and the exactly-once inbox come from the
* ±300 s; several comma-separated secrets allow rotation) and the exactly-once inbox come from the
* chassis (openvibe-publishing/ingest.createEventConsumer): the receipt (consumer, event_id) and the
* chassis (openvibe-publishing/ingest.createEventConsumer): the receipt (consumer, event_id) and the
* change commit in one PostgreSQL transaction, so a redelivery changes nothing.
* change commit in one PostgreSQL transaction, so a redelivery changes nothing.
@@ -16,8 +17,14 @@ const { createEventConsumer } = require('openvibe-publishing/ingest');
@@ -16,8 +17,14 @@ const { createEventConsumer } = require('openvibe-publishing/ingest');
const CONSUMER = 'deals-sources';
const CONSUMER = 'deals-sources';
const TYPES = new Set(['sources.item.created', 'sources.item.updated', 'sources.item.removed']);
const TYPES = new Set(['sources.item.created', 'sources.item.updated', 'sources.item.removed']);
+const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
-function createEvents({ config, store, importer }) {
+/**
+ * accountData + accountSend: the account export and deletion handle and its sender to Network. A failure (Network
+ * unreachable, a refusal worth retrying) throws, the inbox receipt rolls back and Events redelivers; account-data keeps
+ * its own receipt per export and deletion id, so a redelivery never erases twice.
+ */
+function createEvents({ config, store, importer, accountData = null, accountSend = null }) {
const router = express.Router();
const router = express.Router();
const consumer = createEventConsumer({ db: store.db, secrets: config.events.webhookSecrets, consumer: CONSUMER, now: store.now });
const consumer = createEventConsumer({ db: store.db, secrets: config.events.webhookSecrets, consumer: CONSUMER, now: store.now });
@@ -26,6 +33,10 @@ function createEvents({ config, store, importer }) {
@@ -26,6 +33,10 @@ function createEvents({ config, store, importer }) {
let r;
let r;
try {
try {
r = await consumer.apply(raw, req.headers, async (event) => {
r = await consumer.apply(raw, req.headers, async (event) => {
+ if (ACCOUNT_TOPICS.includes(event.event_type)) {
+ if (!accountData || !accountSend) throw new Error('account export and deletion are not configured');
+ return await accountData.apply(event, { send: accountSend });
+ }
if (!TYPES.has(event.event_type) || event.source !== 'sources') return 'ignored';
if (!TYPES.has(event.event_type) || event.source !== 'sources') return 'ignored';
const category = event.payload && event.payload.category;
const category = event.payload && event.payload.category;
if (category !== 'deals') return 'ignored';
if (category !== 'deals') return 'ignored';
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
*/
*/
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');
/**
/**
* The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the worker stops taking new
* The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the worker stops taking new
@@ -14,10 +15,10 @@ const { gracefulStop } = require('openvibe-sdk/service');
@@ -14,10 +15,10 @@ const { gracefulStop } = require('openvibe-sdk/service');
* the deadline the process exits 0, as the hand-rolled 5 s timer did. Exported so a test can inject
* the deadline the process exits 0, as the hand-rolled 5 s timer did. Exported so a test can inject
* `exit` and `signals: false`.
* `exit` and `signals: false`.
*/
*/
-function createLifecycle({ server, ctx, exit, signals }) {
+function createLifecycle({ server, ctx, exit, signals, extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'Deals', server, deadlineExitCode: 0, exit, signals,
name: 'Deals', server, deadlineExitCode: 0, exit, signals,
- stop: [() => ctx.worker.stop()],
+ stop: [() => ctx.worker.stop(), ...extra],
close: [() => ctx.outbox.stop(), () => ctx.store.close()],
close: [() => ctx.outbox.stop(), () => ctx.store.close()],
});
});
}
}
@@ -33,8 +34,14 @@ async function start() {
@@ -33,8 +34,14 @@ async function start() {
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
ctx.outbox.start();
ctx.outbox.start();
ctx.worker.start();
ctx.worker.start();
+ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
+ // DEALS_EVENTS_SECRET or the client secret. The Sources one stays scripts/subscribe.js's.
+ const subscriptions = startSubscriptions({
+ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.webhookSecrets[0],
+ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ });
- createLifecycle({ server, ctx });
+ createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
return { server, ctx };
return { server, ctx };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+/**
+ * ADR-033: Deals' part of an account export and of an account deletion, through the signed /internal/events route
+ * with a stand-in Network. The person makes real rows through the app (a watch, an offer with its price, a vote, a
+ * report, a price report on someone else's offer); the export carries only theirs, and the deletion removes what is
+ * theirs alone, leaves the shared catalog without their name, keeps staff records, and confirms once.
+ */
+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 EXP = 'exp_01JZ0000000000000000000EXP';
+const DEL = 'del_01JZ0000000000000000000DEX';
+
+/** A stand-in for Network's internal routes: the token endpoint, the export part and the deletion confirmation. */
+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_deals', 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)) };
+}
+
+const envelope = (id, type, payload) => ({ event: { event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), actor: { type: 'service', id: 'network' }, subject: { type: 'account', id: payload.subject }, visibility: 'internal', payload }, seq: 1 });
+
+(async () => {
+ const stub = await startNetworkStub();
+ const t = await boot({ accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'deals', clientSecret: 'shh' }) });
+ const [alice, bob] = ['alice', 'bob'].map((n) => t.network.addUser(n));
+ const post = (body) => t.get('/internal/events', { body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, 'whsec-test') } });
+ const db = t.ctx.store.db;
+ const count = async (sql, args) => Number(await db.value(sql, args));
+ let mine;
+ let theirs;
+
+ try {
+ await check('the person\'s rows are made through the app', async () => {
+ assert.strictEqual((await t.get('/watches', { as: alice, form: { kind: 'keyword', query: 'headphones' } })).status, 303);
+ theirs = await t.submit(bob, { url: 'https://audio.example/nc700', title: 'Noise cancelling headphones', price: '49.99', currency: 'USD' });
+ mine = await t.submit(alice, { url: 'https://shop.example/kettle', title: 'Kettle by alice', price: '20', currency: 'EUR' });
+ assert.strictEqual((await t.get(`/api/v1/offers/${theirs.slug}/vote`, { method: 'PUT', as: alice, json: { value: 1 } })).status, 200);
+ assert.strictEqual((await t.get(`/api/v1/offers/${mine.slug}/vote`, { method: 'PUT', as: bob, json: { value: 1 } })).status, 200);
+ const obs = await t.get(`/api/v1/offers/${theirs.slug}/observations`, { as: alice, json: { price: '44.00', currency: 'USD' } });
+ assert.ok(obs.status < 300, obs.text.slice(0, 300));
+ const flag = await t.get(`/api/v1/offers/${theirs.slug}/flags`, { as: alice, json: { kind: 'price_wrong', reason: 'cheaper now' } });
+ assert.ok(flag.status < 300, flag.text.slice(0, 300));
+ await db.exec(`INSERT INTO moderation_log (action, offer_id, actor, at) VALUES ('review', $1, $2, 1)`, [theirs.id, alice.subject]);
+ await db.exec(`INSERT INTO deal_flags (id, offer_id, kind, origin, reason, details, dedupe_key, created_at, updated_at)
+ VALUES ('dfl_ring', $1, 'vote_ring', 'system', 'covote', $2, 'ring:test', 1, 1)`,
+ [theirs.id, JSON.stringify({ signal: 'covote', voters: [alice.subject, bob.subject], shared_offers: { [bob.subject]: 3 } })]);
+ assert.ok(await count('SELECT count(*) FROM watch_notifications'), 'the watch notified (a row that hangs under the watch)');
+ });
+
+ await check('an export part carries only the person\'s rows, with this service\'s token', async () => {
+ const r = await post(JSON.stringify(envelope('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: EXP, subject: alice.subject })));
+ assert.deepStrictEqual([r.status, r.json().outcome], [200, 'exported'], r.text);
+ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
+ assert.strictEqual(part.auth, 'Bearer tok_deals');
+ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
+ assert.deepStrictEqual(Object.keys(files).sort(), ['offers.json', 'price-reports.json', 'profile.json', 'reports.json', 'votes.json', 'watches.json']);
+ assert.deepStrictEqual(files['offers.json'].map((o) => o.title), ['Kettle by alice']);
+ assert.deepStrictEqual(files['votes.json'].map((v) => v.offer_id), [theirs.id]);
+ assert.ok(files['price-reports.json'].some((o) => o.price === '44.00'));
+ assert.deepStrictEqual(files['watches.json'].map((w) => w.query), ['headphones']);
+ assert.ok(!JSON.stringify(part.body).includes(bob.subject), 'nobody else\'s id');
+ assert.ok(!JSON.stringify(part.body).includes('ip_hash'), 'no ip hash');
+ });
+
+ await check('a deletion removes what is theirs, leaves the catalog authorless, keeps staff records, and confirms once', async () => {
+ const body = JSON.stringify(envelope('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: DEL, subject: alice.subject }));
+ const r = await post(body);
+ assert.deepStrictEqual([r.status, r.json().outcome], [200, 'erased'], r.text);
+ const a = [alice.subject];
+ assert.strictEqual(await count('SELECT count(*) FROM deal_watches WHERE subject = $1', a), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM deal_votes WHERE subject = $1', a), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM deal_votes WHERE subject = $1', [bob.subject]), 1, 'bob\'s vote on alice\'s offer stays');
+ assert.strictEqual(await count('SELECT count(*) FROM deal_offers WHERE id = $1 AND submitted_by IS NULL', [mine.id]), 1, 'her offer stays, authorless');
+ assert.strictEqual(await count('SELECT count(*) FROM deal_price_observations WHERE observed_by = $1', a), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM deal_flags WHERE reporter = $1', a), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM deal_offer_sources WHERE ref_id = $1 OR submitted_by = $1', a), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM subject_projections WHERE subject = $1', a), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM moderation_log WHERE actor = $1', a), 1, 'the staff record stays');
+ const ring = JSON.parse(await db.value("SELECT details FROM deal_flags WHERE id = 'dfl_ring'"));
+ assert.deepStrictEqual(ring.voters, ['deleted', bob.subject], 'her id leaves the vote-ring evidence');
+ const conf = stub.calls.filter((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
+ assert.strictEqual(conf.length, 1);
+ assert.strictEqual(conf[0].body.erased.deal_watches, 1);
+ assert.strictEqual(conf[0].body.erased.deal_votes, 1);
+ assert.ok(conf[0].body.erased.watch_notifications >= 1);
+ assert.strictEqual(conf[0].body.retained.moderation_log, 1);
+ assert.ok(conf[0].body.retained.tombstones >= 3);
+
+ const again = await post(body);
+ assert.strictEqual(again.json().duplicate, true, 'the inbox answers a redelivery');
+ assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1, 'confirmed once');
+ });
+
+ await check('the route refuses a bad signature', async () => {
+ const body = JSON.stringify(envelope('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: EXP, subject: alice.subject }));
+ assert.strictEqual((await t.get('/internal/events', { body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, 'whsec-wrong-secret') } })).status, 401);
+ });
+ } finally {
+ await t.close();
+ await stub.close();
+ }
+ done();
+})();
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow });
+ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend });
await require('openvibe-sdk/auth').jwksClient(`${config.networkInternalUrl}/api/.well-known/jwks`).keys();
await require('openvibe-sdk/auth').jwksClient(`${config.networkInternalUrl}/api/.well-known/jwks`).keys();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
t.base = `http://127.0.0.1:${server.address().port}`;