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)

OP OpenVibers committed 9 Oct 2026 · 4e0898b0e0 · 12 files, +261 −16

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4b33bff..6910e9e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/README.md b/README.md
iindex afc9cd0..26cd495 100644
---- a/README.md
++++ b/README.md
@@@ -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)
ddiff --git a/STATUS.json b/STATUS.json
iindex 8a293a2..a987019 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/migrations/0003_account_data.sql b/migrations/0003_account_data.sql
nnew file mode 100644
iindex 0000000..2d2fd34
---- /dev/null
++++ b/migrations/0003_account_data.sql
@@@ -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
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex fb60a31..53cfc09 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 27b23d9..565d2cf 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/app.js b/server/app.js
iindex e791f03..f03d4ed 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 ─────────────────────────────
ddiff --git a/server/domain/account-data.js b/server/domain/account-data.js
nnew file mode 100644
iindex 0000000..4c5dc6d
---- /dev/null
++++ b/server/domain/account-data.js
@@@ -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 };
ddiff --git a/server/http/events.js b/server/http/events.js
iindex 757c678..cb6ade9 100644
---- a/server/http/events.js
++++ b/server/http/events.js
@@@ -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';
ddiff --git a/server/index.js b/server/index.js
iindex 6757003..f316150 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 };
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..e0bcc30
---- /dev/null
++++ b/test/account-data.test.js
@@@ -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();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 252e664..a7d9a60 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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}`;