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)
Changes
@@@ -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).@@@ -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": [nnew file mode 100644@@@ -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++);@@@ -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"@@@ -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"},@@@ -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;}nnew file mode 100644@@@ -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 };@@@ -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 : {};@@@ -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 };}nnew file mode 100644@@@ -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();++})();
@@ -65,7 +65,8 @@ Called elsewhere, as the service principal `tips`:
@@ -65,7 +65,8 @@ Called elsewhere, as the service principal `tips`:
| Service | Grant | Why |
| Service | Grant | Why |
|---|---|---|
|---|---|---|
| OpenVibe.Billing | `billing.intent.create`, `billing.transfer.create` | checkout, and a tip from credit |
| 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.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` |
| 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
@@ -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.
`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.
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
## Moderation
[server/domain/moderation.js](server/domain/moderation.js), [server/domain/filter.js](server/domain/filter.js).
[server/domain/moderation.js](server/domain/moderation.js), [server/domain/filter.js](server/domain/filter.js).
@@ -10,7 +10,7 @@
@@ -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.",
"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.",
"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",
"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",
"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.",
"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": [
"features": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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
+);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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-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"
},
},
@@ -859,9 +859,9 @@
@@ -859,9 +859,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"
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"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-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"
},
},
@@ -30,6 +30,8 @@ const { createBillingClient } = require('./billing-client');
@@ -30,6 +30,8 @@ const { createBillingClient } = require('./billing-client');
const { createAdapters } = require('./delivery');
const { createAdapters } = require('./delivery');
const { createTipsOutbox } = require('./events/outbox');
const { createTipsOutbox } = require('./events/outbox');
const { consumerRouter } = require('./events/consumer');
const { consumerRouter } = require('./events/consumer');
+const accountDataLib = require('./domain/account-data');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createDomain } = require('./domain');
const { createDomain } = require('./domain');
const { createApiAuth } = require('./api/auth');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { v1Router } = require('./api/v1');
@@ -103,7 +105,13 @@ function createApp(opts = {}) {
@@ -103,7 +105,13 @@ function createApp(opts = {}) {
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
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
// 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).
// 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']
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 = {}) {
@@ -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.' }) }));
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;
return app;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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 };
@@ -22,21 +22,32 @@
@@ -22,21 +22,32 @@
*
*
* The signature (X-OpenVibe-Signature, HMAC-SHA256 of the raw body with TIPS_EVENTS_SECRET) is
* 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.
* 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 express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
+const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
+
const CONSUMER = 'tips-billing';
const CONSUMER = 'tips-billing';
const TABLE = 'tips_event_inbox'; // migrations/0001_initial.sql (inboxSchema)
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();
const router = express.Router();
// The inbox's transaction is the domain's MONEY one (serializable, with after-commit hooks).
// 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 });
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 }. */
/** Apply one envelope (also used by tests and the replay tool). Returns { duplicate, outcome }. */
async function apply(event) {
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) => {
const r = await inbox.once(CONSUMER, event.event_id, async (t) => {
if (event.source !== 'billing') return 'ignored:source';
if (event.source !== 'billing') return 'ignored:source';
const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
@@ -22,6 +22,7 @@ const { createApp } = require('./app');
@@ -22,6 +22,7 @@ const { createApp } = require('./app');
const { createValkey } = require('openvibe-sdk/valkey');
const { createValkey } = require('openvibe-sdk/valkey');
const { createRegistry } = require('openvibe-shared/metrics');
const { createRegistry } = require('openvibe-shared/metrics');
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, plan T1): the job timers and the relays stop taking new
* 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');
@@ -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.
* 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`.
* 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;
const { db, valkey, domain, keys, outbox } = app.locals;
return gracefulStop({
return gracefulStop({
name: 'Tips', server, deadlineExitCode: 0, exit, signals,
name: 'Tips', server, deadlineExitCode: 0, exit, signals,
@@ -38,6 +39,7 @@ function createLifecycle({ server, app, timers = [], exit, signals } = {}) {
@@ -38,6 +39,7 @@ function createLifecycle({ server, app, timers = [], exit, signals } = {}) {
() => keys.stop(),
() => keys.stop(),
() => domain.overlays.close(),
() => domain.overlays.close(),
() => outbox.stop(),
() => outbox.stop(),
+ ...extra,
],
],
close: [() => db.close(), () => { if (valkey) return valkey.close(); }],
close: [() => db.close(), () => { if (valkey) return valkey.close(); }],
});
});
@@ -72,7 +74,13 @@ async function start() {
@@ -72,7 +74,13 @@ async function start() {
});
});
server.keepAliveTimeout = 65_000;
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 };
return { server, app };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -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();
+})();