openvibers/openvibe.tips Commits

Tips: account export and deletion (ADR-033) — a creator's settings, overlays and moderator links deleted with the money records kept; a supporter through the existing erasure; subscriptions at boot (SDK v0.37.0) (#18)

OP OpenVibers committed 9 Oct 2026 · febac2cef0 · 10 files, +235 −12

Changes

ddiff --git a/README.md b/README.md
iindex 84eded0..64fea33 100644
---- a/README.md
++++ b/README.md
@@@ -65,7 +65,8 @@ Called elsewhere, as the service principal `tips`:
| Service | Grant | Why |
|---|---|---|
| OpenVibe.Billing | `billing.intent.create`, `billing.transfer.create` | checkout, and a tip from credit |
--| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`npm run subscribe`) | the outbox relay; the `billing.transaction.*` and `billing.receipt.*` subscriptions |
++| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` (`npm run subscribe`, and at boot) | the outbox relay; the `billing.transaction.*` and `billing.receipt.*` subscriptions; the two account subscriptions (created at boot when missing) |
++| OpenVibe.Network | `network.account.export.contribute`, `network.account.deletion.confirm` (granted last, once this release is live) | account export and deletion (ADR-033) |
| OpenVibe.Chat | `chat.message.send`, `chat.event.publish` (only with `TIPS_CHAT_ADAPTER=chat`) | donation lines and TTS through `/internal/chat/messages`, the alert through `/internal/chat/events` |
| OpenVibe.Network | `identity.subject.resolve` (only with `TIPS_CHAT_ADAPTER=chat`) | the creator's Live user id (Chat's room) from `/internal/identity/resolve` |
@@@ -227,6 +228,18 @@ Tips still waiting for their payment are kept until it settles or fails. Each er
`tips.interaction.erased`, whose `redacts` has OpenVibe.Events tombstone Tips' earlier events about it.
Stored API answers (Idempotency-Key replays) are pruned after a week.
++**Account export and deletion (ADR-033).** `network.account.export_requested` and `network.account.deleted`
++arrive at `POST /internal/events` and are answered by `server/domain/account-data.js` (`openvibe-sdk/account-data`),
++outside the money inbox, with one receipt per export and deletion in `account_data_events`.
++
++- **As a supporter:** the export is `/me/export`'s, and the deletion is the erasure above.
++- **As a creator:** the export adds their settings, overlays (never a token hash), goals, the tips they received
++ (without the supporters) and their moderators. The deletion removes their settings and page, overlays, overlay
++ tokens and alert history, moderator list and invitations, and the stored answers that name them. The tips they
++ received, the paid messages and media requests on them, their goals and their moderation log are kept and counted
++ as retained.
++- **As a moderator:** their moderator rows go; the moderation log keeps naming them.
++
## Moderation
[server/domain/moderation.js](server/domain/moderation.js), [server/domain/filter.js](server/domain/filter.js).
ddiff --git a/STATUS.json b/STATUS.json
iindex 36cf51a..602ec0c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,7 +10,7 @@
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy tips`, /opt/openvibe.tips, unit openvibe-tips, 127.0.0.1:4610 loopback only; https://openvibe.tips still serves the OpenVibe.Sites placeholder. Principal tips; Events subscriptions billing.transaction.* and billing.receipt.* -> /internal/events (secret rotated 2026-09-24); TIPS_CHAT_ADAPTER is empty (no chat side effects); TIPS_CHAT_ADAPTER=chat delivers through OpenVibe.Chat's ingress once tips is granted chat.message.send, chat.event.publish and identity.subject.resolve.",
"database": "PostgreSQL 18 through PgBouncer (transaction mode) as the system of record, Valkey 9 for shared state (per-actor limits, overlay fan-out, overlay stream slots), ADR-035: database ov_tips from OpenVibe.Host's data role, schema in migrations/ (0001_initial expand, 0002_drop_legacy_import contract), async on openvibe-sdk/db v0.28.0, money paths serializable with row locks, PostgreSQL outbox and inbox.",
"contracts": "openvibe-contracts v0.112.0",
-- "sdk": "openvibe-sdk v0.35.0",
++ "sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.15.0",
"notes": "Runtime, API, SSR pages, overlays, the Billing settlement consumer and the events outbox are built, tested and deployed, together with /metrics (loopback only), truthful /api/ready (Billing and Events checks), supporter privacy (anonymous, hidden amount, private message), goal and supporter-page settings, export and erasure (tips.interaction.erased), moderation (word filter, hide/show, invited moderators, tips.interaction.moderated) and per-actor limits. Capabilities (18 tips.*) and payload schemas are in openvibe-contracts. No billing.* event had arrived: Billing runs in shadow. A written internal threat review (docs/threat-review.md) with its fixes and test/security.test.js. Not done: an independent security review; the openvibe.tips launch. Live stays the place tips happen until the Billing cutover (blocked on the owner re-pointing the PowerChat webhook) and the launch rule in README.md holds.",
"features": [
ddiff --git a/migrations/0003_account_data.sql b/migrations/0003_account_data.sql
nnew file mode 100644
iindex 0000000..c073af7
---- /dev/null
++++ b/migrations/0003_account_data.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- OpenVibe.Tips: 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 8098aa4..8cfa178 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.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"
},
@@@ -859,9 +859,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 48c325b..280c35d 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.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 f961994..4096586 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -30,6 +30,8 @@ const { createBillingClient } = require('./billing-client');
const { createAdapters } = require('./delivery');
const { createTipsOutbox } = require('./events/outbox');
const { consumerRouter } = require('./events/consumer');
++const accountDataLib = require('./domain/account-data');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createDomain } = require('./domain');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
@@@ -103,7 +105,13 @@ function createApp(opts = {}) {
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
-- const consumer = consumerRouter({ domain, config, log });
++ // Account export and deletion (ADR-033, domain/account-data.js), pushed to Network's internal routes with Tips' own
++ // client-credentials token; a test injects a stand-in through opts.accountSend.
++ const accountData = accountDataLib.create({ domain, log });
++ const accountSend = opts.accountSend || (config.oauth.clientSecret
++ ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
++ : null);
++ const consumer = consumerRouter({ domain, config, log, accountData, accountSend });
// Service-to-service only: OpenVibe.Events calls 127.0.0.1:4610 directly; anything that came
// through nginx carries X-Forwarded-For and is refused (the signature is checked as well).
app.use('/internal', (req, res, next) => (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']
@@@ -148,7 +156,7 @@ function createApp(opts = {}) {
return res.status(500).type('html').send(layout.page({ title: 'Error', robots: 'noindex', body: pages.errorPage({ status: 500, title: 'Something went wrong', message: 'This one is on us. Please try again.' }) }));
});
-- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, adapters, billing, consumer, metrics, indexnow });
++ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, adapters, billing, consumer, metrics, indexnow, accountData });
return app;
}
ddiff --git a/server/domain/account-data.js b/server/domain/account-data.js
nnew file mode 100644
iindex 0000000..f7d9996
---- /dev/null
++++ b/server/domain/account-data.js
@@@ -0,0 +1,57 @@
++'use strict';
++
++/**
++ * Account export and deletion → Tips (ADR-033; openvibe-sdk/account-data). A person is here as a creator, a supporter
++ * or a moderator.
++ *
++ * creator their settings and page (creator_tip_profiles), overlays, overlay tokens (hashes only, never exported)
++ * and alert history, moderator list and invitations are deleted. The tips they received, the paid
++ * messages and media requests on them, their goals (contributions reference them) and their moderation
++ * log are money and accountability records: kept, counted as retained, so Billing's books and the
++ * totals still reconcile.
++ * supporter interactions.erase(), the erasure Tips already offers at POST /me/erase: the money record stays and the
++ * person goes (subject, name, message, TTS text, media link, checkout reference, stored API answers,
++ * overlay payloads, unsent events), each erased interaction emits tips.interaction.erased with
++ * `redacts`; one still waiting for its payment is kept and counted. The export is interactions.exportFor().
++ * moderator their moderator rows go; the moderation log keeps naming them (the creator's accountability).
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const money = 'money records: Billing\'s books and the creators\' totals must still reconcile';
++
++const TABLES = [
++ { table: 'creator_tip_profiles', subject: 'creator_subject', file: 'creator-profile.json', order: 'updated_at' },
++ { table: 'overlay_deliveries', subject: 'creator_subject', file: null },
++ { table: 'overlay_tokens', subject: 'creator_subject', file: 'overlay-tokens.json', columns: ['id', 'scopes', 'config_id', 'label', 'created_at', 'last_used_at', 'revoked_at'] },
++ { table: 'overlay_configs', subject: 'creator_subject', file: 'overlay-configs.json', columns: ['id', 'kind', 'name', 'settings', 'goal_id', 'created_at', 'updated_at'] },
++ { table: 'tip_moderators', subject: 'creator_subject', file: 'moderators.json', columns: ['moderator_subject', 'name', 'created_at', 'removed_at'] },
++ { table: 'tip_moderators', subject: 'moderator_subject', file: 'moderating.json', columns: ['creator_subject', 'created_at', 'removed_at'], kind: 'tip_moderators' },
++ { table: 'tip_moderator_invites', subject: 'creator_subject', file: null },
++ { table: 'tip_moderator_invites', subject: 'used_by', file: null, erase: { anonymize: {} } },
++ { table: 'tip_goals', subject: 'creator_subject', file: 'goals.json', columns: ['id', 'title', 'description', 'target_amount', 'currency', 'status', 'reached_at', 'closed_at', 'created_at'], erase: { keep: 'goal contributions (money records) reference them' } },
++ { table: 'tip_interactions', subject: 'creator_subject', file: 'tips-received.json', columns: ['id', 'kind', 'amount', 'currency', 'payment_state', 'settlement', 'origin', 'created_at', 'settled_at', 'reversed_at'], erase: { keep: money } },
++ { table: 'paid_messages', subject: 'creator_subject', file: null, erase: { keep: money } },
++ { table: 'paid_media_requests', subject: 'creator_subject', file: null, erase: { keep: money } },
++ { table: 'tip_moderation_log', subject: 'creator_subject', file: null, erase: { keep: 'the creator\'s moderation log, kept for accountability' } },
++ { table: 'tip_moderation_log', subject: 'actor', file: null, erase: { keep: 'the moderation log names the moderator, for the creator\'s accountability' }, kind: 'tip_moderation_log' },
++];
++
++/** The account-data handle over Tips' domain (server/domain/index.js): db and interactions. */
++function create({ domain, log = console } = {}) {
++ return createAccountData({
++ db: domain.db, service: 'tips', tables: TABLES, log,
++ note: 'Payments, balances and refunds are OpenVibe.Billing\'s records. Tips you sent keep their amount for the creator\'s books, without your name or message.',
++ extraExport: async (db, subject) => [{ name: 'tips-sent.json', content: await domain.interactions.exportFor(subject) }],
++ // Inside the erase transaction: interactions.erase() opens a savepoint in it (openvibe-sdk/db ambient).
++ extraErase: async (t, subjects, counts) => {
++ for (const subject of subjects) {
++ const r = await domain.interactions.erase(subject);
++ counts.add(counts.retained, 'tombstones', r.erased);
++ counts.add(counts.retained, 'tip_interactions_pending', r.kept_pending);
++ counts.add(counts.erased, 'api_idempotency', r.stored_answers_removed);
++ }
++ },
++ });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex 7f7bb53..aa133c7 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -22,21 +22,32 @@
*
* The signature (X-OpenVibe-Signature, HMAC-SHA256 of the raw body with TIPS_EVENTS_SECRET) is
* verified with openvibe-sdk's parseDelivery. Only events whose source is `billing` are applied.
++ *
++ * network.account.export_requested and network.account.deleted (ADR-033, subscribed at boot) go to
++ * domain/account-data.js instead, outside the money inbox: openvibe-sdk/account-data keeps its own receipt
++ * per export and deletion id (account_data_events) and throws when Network should be asked again, so a
++ * failure is answered 500 and Events redelivers.
*/
const express = require('express');
const { http } = require('openvibe-contracts');
const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
++const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
++
const CONSUMER = 'tips-billing';
const TABLE = 'tips_event_inbox'; // migrations/0001_initial.sql (inboxSchema)
--function consumerRouter({ domain, config, log = console }) {
++function consumerRouter({ domain, config, log = console, accountData = null, accountSend = null }) {
const router = express.Router();
// The inbox's transaction is the domain's MONEY one (serializable, with after-commit hooks).
const inbox = createPgInbox({ tx: (fn) => domain.tx(fn, domain.MONEY), maybe: (...a) => domain.db.maybe(...a) }, { table: TABLE, now: domain.now });
/** Apply one envelope (also used by tests and the replay tool). Returns { duplicate, outcome }. */
async function apply(event) {
++ if (ACCOUNT_TOPICS.includes(event.event_type)) {
++ if (!accountData || !accountSend) throw new Error('account export and deletion are not configured');
++ return { duplicate: false, outcome: await accountData.apply(event, { send: accountSend }) };
++ }
const r = await inbox.once(CONSUMER, event.event_id, async (t) => {
if (event.source !== 'billing') return 'ignored:source';
const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
ddiff --git a/server/index.js b/server/index.js
iindex a447dd5..a97ee58 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -22,6 +22,7 @@ const { createApp } = require('./app');
const { createValkey } = require('openvibe-sdk/valkey');
const { createRegistry } = require('openvibe-shared/metrics');
const { gracefulStop } = require('openvibe-sdk/service');
++const { startSubscriptions } = require('openvibe-sdk/account-data');
/**
* The process stop (openvibe-sdk/service, plan T1): the job timers and the relays stop taking new
@@@ -29,7 +30,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
* database and Valkey close; past 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, app, timers = [], exit, signals } = {}) {
++function createLifecycle({ server, app, timers = [], exit, signals, extra = [] } = {}) {
const { db, valkey, domain, keys, outbox } = app.locals;
return gracefulStop({
name: 'Tips', server, deadlineExitCode: 0, exit, signals,
@@@ -38,6 +39,7 @@ function createLifecycle({ server, app, timers = [], exit, signals } = {}) {
() => keys.stop(),
() => domain.overlays.close(),
() => outbox.stop(),
++ ...extra,
],
close: [() => db.close(), () => { if (valkey) return valkey.close(); }],
});
@@@ -72,7 +74,13 @@ async function start() {
});
server.keepAliveTimeout = 65_000;
-- createLifecycle({ server, app, timers });
++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
++ // TIPS_EVENTS_SECRET or the client secret. The billing.* ones stay 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.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ });
++ createLifecycle({ server, app, timers, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
return { server, app };
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..01d9296
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++/**
++ * ADR-033: Tips' part of an account export and of an account deletion, through the signed /internal/events route with
++ * a stand-in Network. A creator's settings, overlays and moderator links go while the money records stay (Billing's
++ * books and the totals reconcile); a supporter goes through the erasure Tips already offers (the amount stays, the
++ * person goes, a pending payment is kept); each deletion is confirmed once with its counts.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/app');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++
++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_tips', 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_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 });
++
++(async () => {
++ const stub = await startNetworkStub();
++ const t = await boot({ appOpts: { accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'tips', clientSecret: 'shh' }) } });
++ const { domain, billing } = t;
++ const count = async (sql, args) => Number(await t.db.value(sql, args));
++ const alex = await t.creator('alex');
++ const bea = await t.creator('bea');
++ const mod = t.network.newUser('moddy');
++ const buyer = t.network.newUser('buyer');
++ billing.fund(buyer.subject, 10_000);
++ const tip = (user, body) => t.call('POST', '/api/v1/checkout', { user, body: { creator: 'alex', ...body } });
++ let settled;
++
++ try {
++ await check('a creator, a supporter and moderators make their rows through the app', async () => {
++ assert.strictEqual((await t.call('POST', '/api/v1/goals', { user: alex, body: { title: 'Desk', target_amount: 100_000 } })).status, 201);
++ assert.strictEqual((await t.call('POST', '/api/v1/overlay-tokens', { user: alex, body: { scopes: ['alerts', 'goals'] } })).status, 201);
++ await domain.moderation.addModerator(t.db, alex.subject, mod.subject, { name: 'Moddy', addedBy: alex.subject });
++ await domain.moderation.addModerator(t.db, bea.subject, alex.subject, { name: 'Alex', addedBy: bea.subject });
++ settled = await tip(buyer, { amount: 40, message: 'my secret words', supporter_name: 'Buyer Person' });
++ assert.strictEqual(settled.status, 201, settled.text);
++ assert.strictEqual((await tip(buyer, { amount: 150, pay_with: 'checkout', provider: 'stripe', message: 'pending one' })).status, 201);
++ });
++
++ await check('the creator\'s export: their settings, overlays (no token hash), goals, tips received and moderators', async () => {
++ const r = await t.deliver(envelope('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: alex.subject }));
++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
++ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');
++ assert.strictEqual(part.auth, 'Bearer tok_tips');
++ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
++ for (const f of ['creator-profile.json', 'overlay-tokens.json', 'goals.json', 'tips-received.json', 'moderators.json', 'moderating.json']) assert.ok(files[f], `${f} is in the part`);
++ assert.ok(!JSON.stringify(files['overlay-tokens.json']).includes('token_hash'));
++ assert.ok(!JSON.stringify(files['tips-received.json']).includes(buyer.subject), 'the supporters of their tips are not theirs to read');
++ assert.ok(!JSON.stringify(files['tips-received.json']).includes('my secret words'));
++ });
++
++ await check('the supporter\'s export is the one /me/export gives', async () => {
++ await t.deliver(envelope('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: buyer.subject }));
++ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX2/parts');
++ const sent = part.body.files.find((f) => f.name === 'tips-sent.json').content;
++ assert.strictEqual(sent.interactions.length, 2);
++ assert.ok(JSON.stringify(sent).includes('my secret words'), 'their own message, to them');
++ });
++
++ await check('deleting the creator removes their settings, overlays and moderator links, and keeps the money records', async () => {
++ const before = await domain.interactions.totals(t.db, alex.subject);
++ const r = await t.deliver(envelope('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: alex.subject }));
++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
++ const a = [alex.subject];
++ assert.strictEqual(await count('SELECT count(*) FROM creator_tip_profiles WHERE creator_subject = $1', a), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM overlay_tokens WHERE creator_subject = $1', a), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM tip_moderators WHERE creator_subject = $1 OR moderator_subject = $1', a), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM creator_tip_profiles WHERE creator_subject = $1', [bea.subject]), 1, 'bea stays');
++ assert.deepStrictEqual(await domain.interactions.totals(t.db, alex.subject), before, 'the totals still reconcile');
++ assert.strictEqual(await count('SELECT count(*) FROM tip_goals WHERE creator_subject = $1', a), 1, 'the goal stays: contributions reference it');
++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
++ assert.strictEqual(conf.length, 1);
++ assert.strictEqual(conf[0].body.erased.creator_tip_profiles, 1);
++ assert.strictEqual(conf[0].body.erased.tip_moderators, 2);
++ assert.strictEqual(conf[0].body.retained.tip_interactions, 2);
++ assert.ok(conf[0].body.erased.api_idempotency >= 1, 'stored API answers that name the creator go too (the supporters\' checkout answers)');
++ });
++
++ await check('deleting the supporter erases them from their tips, keeps the amount and the pending payment, and confirms once', async () => {
++ const body = envelope('evt_01JZ0000000000000000000D02', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE2', subject: buyer.subject });
++ assert.strictEqual((await t.deliver(body)).status, 200);
++ const i = await domain.interactions.get(t.db, settled.json.interaction.id);
++ assert.deepStrictEqual([i.supporter_subject, i.supporter_name, i.message, i.amount], [null, null, null, 40]);
++ assert.ok(i.erased_at);
++ assert.strictEqual(await count('SELECT count(*) FROM tip_interactions WHERE supporter_subject = $1', [buyer.subject]), 1, 'the pending payment keeps its supporter');
++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE2/confirmations');
++ assert.strictEqual(conf.length, 1);
++ assert.strictEqual(conf[0].body.retained.tombstones, 1);
++ assert.strictEqual(conf[0].body.retained.tip_interactions_pending, 1);
++ assert.strictEqual((await t.deliver(body)).json.outcome, 'unchanged', 'a redelivery changes nothing');
++ assert.strictEqual(stub.calls.filter((c) => c.url.includes('DE2/confirmations')).length, 1);
++ });
++ } finally {
++ await t.close();
++ await stub.close();
++ }
++ done();
++})();