openvibers/openvibe.vip Commits
VIP: account export and deletion (ADR-033) — a member's memberships, preferences, cache and checkouts deleted with paid periods kept; a creator suspended without their name, plan versions kept; subscriptions at boot (SDK v0.37.0) (#19)
Changes
@@@ -80,7 +80,8 @@ Called elsewhere, as the service principal `vip`:| Service | Grant | Why ||---|---|---|| OpenVibe.Billing | `billing.intent.create`, `billing.subscription.manage`, `billing.entitlement.check` | checkout, credit-paid periods and cancel, the authoritative check |--| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`scripts/subscribe.js`) | the outbox relay; the three subscriptions for Billing's events |++| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` (`scripts/subscribe.js`, and at boot) | the outbox relay; the three subscriptions for Billing's events; 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) |## Run it@@@ -345,6 +346,21 @@ batches and debounces (one POST per 30 s window). A draft, an unchanged edit, anetwork plans (no public creator page) never ping, and a failed ping never takes a publish down.Unset: off — the key route is not mounted and nothing is sent (`test/indexnow.test.js`).++## 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 billing inbox, with one receipt per export++and deletion in `account_data_events`.++++- **A member:** the export carries their memberships, preferences, entitlements, checkouts and paid periods. The++ deletion removes the memberships, preferences, entitlement cache and checkout hand-offs. Paid periods are money++ records and stay, counted as retained.++- **A creator:** the export carries their profile, plans, plan versions and perks. Their creator row stays, because++ plans, perks and other people's memberships reference it, but it is suspended and loses its username, display name++ and bio. Plans, perks and gated rules lose `created_by`. Plan versions stay whole: they are the immutable terms++ members joined under.++- **Billing:** payments, refunds and subscriptions are Billing's records, and its own consumer answers for them.++## Before and after the Billing cutoverBilling runs in shadow mode until the Wave 8 cutover (Live stays authoritative for money,@@@ -5,7 +5,7 @@"deployed": true,"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy vip`, /opt/openvibe.vip, unit openvibe-vip, 127.0.0.1:4620 loopback only; https://openvibe.vip still serves the OpenVibe.Sites placeholder. Principal vip; three Events subscriptions for Billing's events (secret rotated 2026-09-24); checkout closed (VIP_CHECKOUT_PROVIDERS empty). On 2026-09-24 production held the Live import: 44 creators, 43 plans (one per creator), 0 memberships while Billing runs in shadow.","contracts": "openvibe-contracts v0.112.0",-- "sdk": "openvibe-sdk v0.35.0",++ "sdk": "openvibe-sdk v0.37.0","shared": "openvibe-shared v2.15.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","roadmap": "Wave 10",nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- OpenVibe.VIP: 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"@@@ -32,7 +32,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"},@@@ -27,6 +27,8 @@ const { createKeyProvider, createUserAuth } = require('./network');const { createBillingClient } = require('./billing-client');const { createVipOutbox } = 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');@@@ -104,7 +106,13 @@ async 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 VIP's own++ // client-credentials token; a test injects a stand-in through opts.accountSend.++ const accountData = accountDataLib.create({ db: domain.db, 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 });app.use('/internal', consumer.router);// Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.// opts.limitsNow: the limiter's clock (tests).@@@ -142,7 +150,7 @@ async 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, domain, keys, outbox, billing, consumer, metrics, indexnow });++ Object.assign(app.locals, { config, db, domain, keys, outbox, billing, consumer, metrics, indexnow, accountData });return app;}nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * Account export and deletion → VIP (ADR-033; openvibe-sdk/account-data). A person is here as a member or a creator.++ *++ * member their memberships, badge preferences, entitlement cache (Billing re-sends what is still true) and checkout++ * hand-offs are deleted. Their paid periods (Billing transaction ids, start, end, reversal) are money++ * records and stay, counted as retained: VIP's history must still reconcile with Billing's books.++ * creator their creator row stays, because plans, perks and other people's memberships reference it, but it is++ * suspended and loses its username, display name and bio; plans, perks and gated rules they made lose++ * created_by. Plan versions stay whole: they are the immutable terms members joined under (a trigger keeps++ * them so), counted as retained.++ *++ * Billing owns payments, balances, refunds and the subscriptions themselves; its own consumer answers for those.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const anonymize = { anonymize: {} };++++const TABLES = [++ { table: 'vip_memberships', subject: 'member_subject', file: 'memberships.json', columns: ['id', 'creator_id', 'plan_id', 'plan_version_id', 'origin', 'joined_at', 'terms_since', 'updated_at'], order: 'joined_at' },++ { table: 'vip_member_preferences', subject: 'member_subject', file: 'preferences.json', columns: ['creator_id', 'show_badge', 'listed', 'updated_at'], order: 'updated_at' },++ { table: 'vip_entitlement_projection', subject: 'member_subject', file: 'entitlements.json', columns: ['creator_subject', 'kind', 'active', 'expires_at', 'cancel_at_period_end', 'subscription_status'], order: 'synced_at' },++ { table: 'vip_checkouts', subject: 'member_subject', file: 'checkouts.json', columns: ['id', 'creator_id', 'plan_id', 'provider', 'status', 'created_at', 'updated_at'] },++ { table: 'vip_membership_periods', subject: 'member_subject', file: 'paid-periods.json', columns: ['id', 'creator_subject', 'billing_transaction_id', 'period_start', 'period_end', 'status', 'recorded_at'], order: 'recorded_at', erase: { keep: 'paid periods are money records that reconcile with Billing\'s books' } },++ { table: 'vip_membership_periods', subject: 'reversed_by', file: null, erase: anonymize },++ { table: 'vip_creators', subject: 'subject', file: 'creator-profile.json', columns: ['id', 'username', 'display_name', 'bio', 'status', 'created_at', 'updated_at'], erase: { keep: 'plans, perks and other people\'s memberships reference the creator row; it is suspended and its name and bio removed' } },++ { table: 'vip_plans', subject: 'created_by', file: 'plans.json', columns: ['id', 'creator_id', 'slug', 'status', 'billing_kind', 'latest_version', 'created_at', 'updated_at', 'archived_at'], erase: anonymize },++ { table: 'vip_plan_versions', subject: 'created_by', file: 'plan-versions.json', columns: ['id', 'plan_id', 'version', 'name', 'description', 'benefits', 'terms', 'change_note', 'created_at', 'published_at'], erase: { keep: 'plan versions are the immutable terms members joined under' } },++ { table: 'vip_perks', subject: 'created_by', file: 'perks.json', columns: ['id', 'creator_id', 'key', 'name', 'description', 'kind', 'status', 'created_at'], erase: anonymize },++ { table: 'vip_gated_resource_rules', subject: 'created_by', file: null, erase: anonymize },++];++++/** The creator row stays (it is referenced; counted as retained above) but is suspended and loses what names the person. */++async function extraErase(t, subjects) {++ await t.exec(`UPDATE vip_creators SET status = 'suspended', username = NULL, username_lc = NULL, display_name = NULL, bio = NULL,++ updated_at = $2 WHERE kind = 'creator' AND subject = ANY($1::text[])`, [subjects, new Date().toISOString()]);++}++++/** The account-data handle for VIP's database (domain.db). */++function create({ db, log = console } = {}) {++ return createAccountData({++ db, service: 'vip', tables: TABLES, extraErase, log,++ note: 'Payments, refunds and subscriptions are OpenVibe.Billing\'s records. Paid periods stay here for its books.',++ });++}++++module.exports = { create, TABLES, TOPICS };@@@ -24,9 +24,16 @@ const { http } = require('openvibe-contracts');const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');const { isUserSubject } = require('../util');++const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');++const CONSUMER = 'vip-billing';--function consumerRouter({ domain, config, log = console }) {++/**++ * accountData + accountSend: network.account.export_requested and network.account.deleted (ADR-033, subscribed at++ * boot) go to domain/account-data.js, outside the billing inbox: openvibe-sdk/account-data keeps its own receipt per++ * export and deletion id and throws when Network should be asked again (answered 500, so Events redelivers).++ */++function consumerRouter({ domain, config, log = console, accountData = null, accountSend = null }) {const router = express.Router();const inbox = createPgInbox(domain.db, { now: domain.now }); // idempotency_receipts: migrations/0001_initial.sqlconst { entitlements, periods } = domain;@@@ -87,6 +94,10 @@ function consumerRouter({ domain, config, log = console }) {/** Apply one envelope (also used by tests). 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 () => (event.source !== 'billing' ? 'ignored:source' : await handle(event)));return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };}@@@ -11,6 +11,7 @@const { loadConfig } = require('./config');const { createApp } = require('./app');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 relays stop taking new work@@@ -18,7 +19,7 @@ const { gracefulStop } = require('openvibe-sdk/service');* database closes; 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 { domain, keys, outbox, metrics } = app.locals;return gracefulStop({name: 'VIP', server, deadlineExitCode: 0, exit, signals,@@@ -27,6 +28,7 @@ function createLifecycle({ server, app, timers = [], exit, signals } = {}) {() => outbox.stop(),() => keys.stop(),() => { if (metrics && metrics.stop) metrics.stop(); },++ ...extra,],close: [() => domain.db.close()],});@@@ -60,7 +62,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,++ // VIP_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,97 @@++'use strict';++/**++ * ADR-033: VIP's part of an account export and of an account deletion, through the signed /internal/events route with++ * a stand-in Network. A member's memberships, preferences, entitlement cache and checkouts go and their paid periods++ * stay (Billing's books); a creator's row is suspended without its name, their plans lose created_by, and the plan++ * versions members joined under stay whole. Each deletion is confirmed once.++ */++const assert = require('assert');++const http = require('http');++const { boot, harness } = require('./helpers/app');++const { createNetworkSender } = require('openvibe-sdk/account-data');++++const { test, run } = harness('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_vip', 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({ accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'vip', clientSecret: 'shh' }) });++ const db = t.domain.db;++ const count = async (sql, args) => Number(await db.value(sql, args));++ const creator = t.network.newUser('rena', { role: 'streamer' });++ const member = t.network.newUser('mona');++ const other = t.network.newUser('otto');++ let plan;++ let creatorId;++++ test('a creator publishes a plan and two members pay for it', async () => {++ plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } })).json.plan;++ assert.ok(plan && plan.id);++ creatorId = await db.value('SELECT id FROM vip_creators WHERE subject = $1', [creator.subject]);++ await t.deliverAll(t.billing.pay(member.subject, creator.subject).events);++ await t.deliverAll(t.billing.pay(other.subject, creator.subject).events);++ await db.exec(`INSERT INTO vip_member_preferences (member_subject, creator_id, show_badge, listed, updated_at) VALUES ($1, $2, 1, 1, $3)++ ON CONFLICT (member_subject, creator_id) DO NOTHING`, [member.subject, creatorId, new Date().toISOString()]);++ assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [member.subject]), 1);++ assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', [member.subject]), 1);++ });++++ test('the member\'s export carries their memberships, entitlements and paid periods, and nobody else\'s', async () => {++ const r = await t.deliver(envelope('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: member.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_vip');++ const names = part.body.files.map((f) => f.name);++ for (const f of ['entitlements.json', 'paid-periods.json', 'preferences.json']) assert.ok(names.includes(f), `${f} in ${names}`);++ assert.ok(!JSON.stringify(part.body).includes(other.subject));++ });++++ test('deleting the member removes their memberships, preferences and cache, keeps the paid period, and confirms once', async () => {++ const body = envelope('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: member.subject });++ assert.strictEqual((await t.deliver(body)).status, 200);++ const m = [member.subject];++ assert.strictEqual(await count('SELECT count(*) FROM vip_memberships WHERE member_subject = $1', m), 0);++ assert.strictEqual(await count('SELECT count(*) FROM vip_member_preferences WHERE member_subject = $1', m), 0);++ assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', m), 0);++ assert.strictEqual(await count('SELECT count(*) FROM vip_checkouts WHERE member_subject = $1', m), 0);++ assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', m), 1, 'the paid period stays for Billing\'s books');++ assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', [other.subject]), 1, 'otto keeps his');++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');++ assert.strictEqual(conf.length, 1);++ assert.strictEqual(conf[0].body.retained.vip_membership_periods, 1);++ assert.strictEqual(conf[0].body.erased.vip_entitlement_projection, 1);++ assert.strictEqual((await t.deliver(body)).json.outcome, 'unchanged');++ });++++ test('deleting the creator suspends their row without its name, keeps the plan versions, and leaves members\' periods', async () => {++ const r = await t.deliver(envelope('evt_01JZ0000000000000000000D02', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE2', subject: creator.subject }));++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));++ const row = await db.maybe('SELECT * FROM vip_creators WHERE id = $1', [creatorId]);++ assert.deepStrictEqual([row.status, row.username, row.display_name, row.bio], ['suspended', null, null, null]);++ assert.strictEqual(await count('SELECT count(*) FROM vip_plans WHERE creator_id = $1 AND created_by IS NULL', [creatorId]), 1);++ assert.ok(await count('SELECT count(*) FROM vip_plan_versions WHERE plan_id = $1', [plan.id]) >= 1, 'the terms members joined under stay');++ assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [other.subject]), 1);++ 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.vip_creators, 1);++ });++++ try { await run(); } finally { await t.close(); await stub.close(); }++})();@@@ -47,7 +47,7 @@ async function boot(opts = {}) {const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')), debug() {} };// One database per boot (PGlite, or VIP_TEST_STORE=pg: the containers), dropped when the boot closes.const testdb = opts.testdb || await require('./db').testDb();-- const app = await createApp({ config, db: testdb.db, now: clock.now, log, indexnow: opts.indexnow });++ const app = await createApp({ config, db: testdb.db, now: clock.now, log, indexnow: opts.indexnow, accountSend: opts.accountSend });const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;const domain = app.locals.domain;
@@ -80,7 +80,8 @@ Called elsewhere, as the service principal `vip`:
@@ -80,7 +80,8 @@ Called elsewhere, as the service principal `vip`:
| Service | Grant | Why |
| Service | Grant | Why |
|---|---|---|
|---|---|---|
| OpenVibe.Billing | `billing.intent.create`, `billing.subscription.manage`, `billing.entitlement.check` | checkout, credit-paid periods and cancel, the authoritative check |
| OpenVibe.Billing | `billing.intent.create`, `billing.subscription.manage`, `billing.entitlement.check` | checkout, credit-paid periods and cancel, the authoritative check |
-| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` once (`scripts/subscribe.js`) | the outbox relay; the three subscriptions for Billing's events |
+| OpenVibe.Events | `events.event.publish`; `events.subscription.manage` (`scripts/subscribe.js`, and at boot) | the outbox relay; the three subscriptions for Billing's events; 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) |
## Run it
## Run it
@@ -345,6 +346,21 @@ batches and debounces (one POST per 30 s window). A draft, an unchanged edit, a
@@ -345,6 +346,21 @@ batches and debounces (one POST per 30 s window). A draft, an unchanged edit, a
network plans (no public creator page) never ping, and a failed ping never takes a publish down.
network plans (no public creator page) never ping, and a failed ping never takes a publish down.
Unset: off — the key route is not mounted and nothing is sent (`test/indexnow.test.js`).
Unset: off — the key route is not mounted and nothing is sent (`test/indexnow.test.js`).
+## 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 billing inbox, with one receipt per export
+and deletion in `account_data_events`.
+
+- **A member:** the export carries their memberships, preferences, entitlements, checkouts and paid periods. The
+ deletion removes the memberships, preferences, entitlement cache and checkout hand-offs. Paid periods are money
+ records and stay, counted as retained.
+- **A creator:** the export carries their profile, plans, plan versions and perks. Their creator row stays, because
+ plans, perks and other people's memberships reference it, but it is suspended and loses its username, display name
+ and bio. Plans, perks and gated rules lose `created_by`. Plan versions stay whole: they are the immutable terms
+ members joined under.
+- **Billing:** payments, refunds and subscriptions are Billing's records, and its own consumer answers for them.
+
## Before and after the Billing cutover
## Before and after the Billing cutover
Billing runs in shadow mode until the Wave 8 cutover (Live stays authoritative for money,
Billing runs in shadow mode until the Wave 8 cutover (Live stays authoritative for money,
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
"deployed": true,
"deployed": true,
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy vip`, /opt/openvibe.vip, unit openvibe-vip, 127.0.0.1:4620 loopback only; https://openvibe.vip still serves the OpenVibe.Sites placeholder. Principal vip; three Events subscriptions for Billing's events (secret rotated 2026-09-24); checkout closed (VIP_CHECKOUT_PROVIDERS empty). On 2026-09-24 production held the Live import: 44 creators, 43 plans (one per creator), 0 memberships while Billing runs in shadow.",
"deployment": "Deployed internally, not launched: deployed with `sudo ovhost deploy vip`, /opt/openvibe.vip, unit openvibe-vip, 127.0.0.1:4620 loopback only; https://openvibe.vip still serves the OpenVibe.Sites placeholder. Principal vip; three Events subscriptions for Billing's events (secret rotated 2026-09-24); checkout closed (VIP_CHECKOUT_PROVIDERS empty). On 2026-09-24 production held the Live import: 44 creators, 43 plans (one per creator), 0 memberships while Billing runs in shadow.",
"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",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"roadmap": "Wave 10",
"roadmap": "Wave 10",
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- OpenVibe.VIP: 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"
@@ -32,7 +32,7 @@
@@ -32,7 +32,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"
},
},
@@ -27,6 +27,8 @@ const { createKeyProvider, createUserAuth } = require('./network');
@@ -27,6 +27,8 @@ const { createKeyProvider, createUserAuth } = require('./network');
const { createBillingClient } = require('./billing-client');
const { createBillingClient } = require('./billing-client');
const { createVipOutbox } = require('./events/outbox');
const { createVipOutbox } = 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');
@@ -104,7 +106,13 @@ async function createApp(opts = {}) {
@@ -104,7 +106,13 @@ async 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 VIP's own
+ // client-credentials token; a test injects a stand-in through opts.accountSend.
+ const accountData = accountDataLib.create({ db: domain.db, 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 });
app.use('/internal', consumer.router);
app.use('/internal', consumer.router);
// Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.
// Per-actor limits on /api/v1 (api/actor-limits.js), counted once apiAuth resolved the caller.
// opts.limitsNow: the limiter's clock (tests).
// opts.limitsNow: the limiter's clock (tests).
@@ -142,7 +150,7 @@ async function createApp(opts = {}) {
@@ -142,7 +150,7 @@ async 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, domain, keys, outbox, billing, consumer, metrics, indexnow });
+ Object.assign(app.locals, { config, db, domain, keys, outbox, billing, consumer, metrics, indexnow, accountData });
return app;
return app;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * Account export and deletion → VIP (ADR-033; openvibe-sdk/account-data). A person is here as a member or a creator.
+ *
+ * member their memberships, badge preferences, entitlement cache (Billing re-sends what is still true) and checkout
+ * hand-offs are deleted. Their paid periods (Billing transaction ids, start, end, reversal) are money
+ * records and stay, counted as retained: VIP's history must still reconcile with Billing's books.
+ * creator their creator row stays, because plans, perks and other people's memberships reference it, but it is
+ * suspended and loses its username, display name and bio; plans, perks and gated rules they made lose
+ * created_by. Plan versions stay whole: they are the immutable terms members joined under (a trigger keeps
+ * them so), counted as retained.
+ *
+ * Billing owns payments, balances, refunds and the subscriptions themselves; its own consumer answers for those.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const anonymize = { anonymize: {} };
+
+const TABLES = [
+ { table: 'vip_memberships', subject: 'member_subject', file: 'memberships.json', columns: ['id', 'creator_id', 'plan_id', 'plan_version_id', 'origin', 'joined_at', 'terms_since', 'updated_at'], order: 'joined_at' },
+ { table: 'vip_member_preferences', subject: 'member_subject', file: 'preferences.json', columns: ['creator_id', 'show_badge', 'listed', 'updated_at'], order: 'updated_at' },
+ { table: 'vip_entitlement_projection', subject: 'member_subject', file: 'entitlements.json', columns: ['creator_subject', 'kind', 'active', 'expires_at', 'cancel_at_period_end', 'subscription_status'], order: 'synced_at' },
+ { table: 'vip_checkouts', subject: 'member_subject', file: 'checkouts.json', columns: ['id', 'creator_id', 'plan_id', 'provider', 'status', 'created_at', 'updated_at'] },
+ { table: 'vip_membership_periods', subject: 'member_subject', file: 'paid-periods.json', columns: ['id', 'creator_subject', 'billing_transaction_id', 'period_start', 'period_end', 'status', 'recorded_at'], order: 'recorded_at', erase: { keep: 'paid periods are money records that reconcile with Billing\'s books' } },
+ { table: 'vip_membership_periods', subject: 'reversed_by', file: null, erase: anonymize },
+ { table: 'vip_creators', subject: 'subject', file: 'creator-profile.json', columns: ['id', 'username', 'display_name', 'bio', 'status', 'created_at', 'updated_at'], erase: { keep: 'plans, perks and other people\'s memberships reference the creator row; it is suspended and its name and bio removed' } },
+ { table: 'vip_plans', subject: 'created_by', file: 'plans.json', columns: ['id', 'creator_id', 'slug', 'status', 'billing_kind', 'latest_version', 'created_at', 'updated_at', 'archived_at'], erase: anonymize },
+ { table: 'vip_plan_versions', subject: 'created_by', file: 'plan-versions.json', columns: ['id', 'plan_id', 'version', 'name', 'description', 'benefits', 'terms', 'change_note', 'created_at', 'published_at'], erase: { keep: 'plan versions are the immutable terms members joined under' } },
+ { table: 'vip_perks', subject: 'created_by', file: 'perks.json', columns: ['id', 'creator_id', 'key', 'name', 'description', 'kind', 'status', 'created_at'], erase: anonymize },
+ { table: 'vip_gated_resource_rules', subject: 'created_by', file: null, erase: anonymize },
+];
+
+/** The creator row stays (it is referenced; counted as retained above) but is suspended and loses what names the person. */
+async function extraErase(t, subjects) {
+ await t.exec(`UPDATE vip_creators SET status = 'suspended', username = NULL, username_lc = NULL, display_name = NULL, bio = NULL,
+ updated_at = $2 WHERE kind = 'creator' AND subject = ANY($1::text[])`, [subjects, new Date().toISOString()]);
+}
+
+/** The account-data handle for VIP's database (domain.db). */
+function create({ db, log = console } = {}) {
+ return createAccountData({
+ db, service: 'vip', tables: TABLES, extraErase, log,
+ note: 'Payments, refunds and subscriptions are OpenVibe.Billing\'s records. Paid periods stay here for its books.',
+ });
+}
+
+module.exports = { create, TABLES, TOPICS };
@@ -24,9 +24,16 @@ const { http } = require('openvibe-contracts');
@@ -24,9 +24,16 @@ const { http } = require('openvibe-contracts');
const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const { parseDelivery, createPgInbox } = require('openvibe-sdk/events');
const { isUserSubject } = require('../util');
const { isUserSubject } = require('../util');
+const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
+
const CONSUMER = 'vip-billing';
const CONSUMER = 'vip-billing';
-function consumerRouter({ domain, config, log = console }) {
+/**
+ * accountData + accountSend: network.account.export_requested and network.account.deleted (ADR-033, subscribed at
+ * boot) go to domain/account-data.js, outside the billing inbox: openvibe-sdk/account-data keeps its own receipt per
+ * export and deletion id and throws when Network should be asked again (answered 500, so Events redelivers).
+ */
+function consumerRouter({ domain, config, log = console, accountData = null, accountSend = null }) {
const router = express.Router();
const router = express.Router();
const inbox = createPgInbox(domain.db, { now: domain.now }); // idempotency_receipts: migrations/0001_initial.sql
const inbox = createPgInbox(domain.db, { now: domain.now }); // idempotency_receipts: migrations/0001_initial.sql
const { entitlements, periods } = domain;
const { entitlements, periods } = domain;
@@ -87,6 +94,10 @@ function consumerRouter({ domain, config, log = console }) {
@@ -87,6 +94,10 @@ function consumerRouter({ domain, config, log = console }) {
/** Apply one envelope (also used by tests). Returns { duplicate, outcome }. */
/** Apply one envelope (also used by tests). 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 () => (event.source !== 'billing' ? 'ignored:source' : await handle(event)));
const r = await inbox.once(CONSUMER, event.event_id, async () => (event.source !== 'billing' ? 'ignored:source' : await handle(event)));
return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
return r.duplicate ? { duplicate: true, outcome: null } : { duplicate: false, outcome: r.result };
}
}
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
const { loadConfig } = require('./config');
const { loadConfig } = require('./config');
const { createApp } = require('./app');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
+const { startSubscriptions } = require('openvibe-sdk/account-data');
/**
/**
* The process stop (openvibe-sdk/service, plan T1): the job timers and relays stop taking new work
* The process stop (openvibe-sdk/service, plan T1): the job timers and relays stop taking new work
@@ -18,7 +19,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
@@ -18,7 +19,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
* database closes; past the deadline the process exits 0, as the hand-rolled 5 s timer did. Exported
* database closes; 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`.
* 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 { domain, keys, outbox, metrics } = app.locals;
const { domain, keys, outbox, metrics } = app.locals;
return gracefulStop({
return gracefulStop({
name: 'VIP', server, deadlineExitCode: 0, exit, signals,
name: 'VIP', server, deadlineExitCode: 0, exit, signals,
@@ -27,6 +28,7 @@ function createLifecycle({ server, app, timers = [], exit, signals } = {}) {
@@ -27,6 +28,7 @@ function createLifecycle({ server, app, timers = [], exit, signals } = {}) {
() => outbox.stop(),
() => outbox.stop(),
() => keys.stop(),
() => keys.stop(),
() => { if (metrics && metrics.stop) metrics.stop(); },
() => { if (metrics && metrics.stop) metrics.stop(); },
+ ...extra,
],
],
close: [() => domain.db.close()],
close: [() => domain.db.close()],
});
});
@@ -60,7 +62,13 @@ async function start() {
@@ -60,7 +62,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,
+ // VIP_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,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+/**
+ * ADR-033: VIP's part of an account export and of an account deletion, through the signed /internal/events route with
+ * a stand-in Network. A member's memberships, preferences, entitlement cache and checkouts go and their paid periods
+ * stay (Billing's books); a creator's row is suspended without its name, their plans lose created_by, and the plan
+ * versions members joined under stay whole. Each deletion is confirmed once.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, harness } = require('./helpers/app');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+
+const { test, run } = harness('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_vip', 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({ accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'vip', clientSecret: 'shh' }) });
+ const db = t.domain.db;
+ const count = async (sql, args) => Number(await db.value(sql, args));
+ const creator = t.network.newUser('rena', { role: 'streamer' });
+ const member = t.network.newUser('mona');
+ const other = t.network.newUser('otto');
+ let plan;
+ let creatorId;
+
+ test('a creator publishes a plan and two members pay for it', async () => {
+ plan = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } })).json.plan;
+ assert.ok(plan && plan.id);
+ creatorId = await db.value('SELECT id FROM vip_creators WHERE subject = $1', [creator.subject]);
+ await t.deliverAll(t.billing.pay(member.subject, creator.subject).events);
+ await t.deliverAll(t.billing.pay(other.subject, creator.subject).events);
+ await db.exec(`INSERT INTO vip_member_preferences (member_subject, creator_id, show_badge, listed, updated_at) VALUES ($1, $2, 1, 1, $3)
+ ON CONFLICT (member_subject, creator_id) DO NOTHING`, [member.subject, creatorId, new Date().toISOString()]);
+ assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [member.subject]), 1);
+ assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', [member.subject]), 1);
+ });
+
+ test('the member\'s export carries their memberships, entitlements and paid periods, and nobody else\'s', async () => {
+ const r = await t.deliver(envelope('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: member.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_vip');
+ const names = part.body.files.map((f) => f.name);
+ for (const f of ['entitlements.json', 'paid-periods.json', 'preferences.json']) assert.ok(names.includes(f), `${f} in ${names}`);
+ assert.ok(!JSON.stringify(part.body).includes(other.subject));
+ });
+
+ test('deleting the member removes their memberships, preferences and cache, keeps the paid period, and confirms once', async () => {
+ const body = envelope('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: member.subject });
+ assert.strictEqual((await t.deliver(body)).status, 200);
+ const m = [member.subject];
+ assert.strictEqual(await count('SELECT count(*) FROM vip_memberships WHERE member_subject = $1', m), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM vip_member_preferences WHERE member_subject = $1', m), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', m), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM vip_checkouts WHERE member_subject = $1', m), 0);
+ assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', m), 1, 'the paid period stays for Billing\'s books');
+ assert.strictEqual(await count('SELECT count(*) FROM vip_entitlement_projection WHERE member_subject = $1', [other.subject]), 1, 'otto keeps his');
+ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
+ assert.strictEqual(conf.length, 1);
+ assert.strictEqual(conf[0].body.retained.vip_membership_periods, 1);
+ assert.strictEqual(conf[0].body.erased.vip_entitlement_projection, 1);
+ assert.strictEqual((await t.deliver(body)).json.outcome, 'unchanged');
+ });
+
+ test('deleting the creator suspends their row without its name, keeps the plan versions, and leaves members\' periods', async () => {
+ const r = await t.deliver(envelope('evt_01JZ0000000000000000000D02', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE2', subject: creator.subject }));
+ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
+ const row = await db.maybe('SELECT * FROM vip_creators WHERE id = $1', [creatorId]);
+ assert.deepStrictEqual([row.status, row.username, row.display_name, row.bio], ['suspended', null, null, null]);
+ assert.strictEqual(await count('SELECT count(*) FROM vip_plans WHERE creator_id = $1 AND created_by IS NULL', [creatorId]), 1);
+ assert.ok(await count('SELECT count(*) FROM vip_plan_versions WHERE plan_id = $1', [plan.id]) >= 1, 'the terms members joined under stay');
+ assert.strictEqual(await count('SELECT count(*) FROM vip_membership_periods WHERE member_subject = $1', [other.subject]), 1);
+ 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.vip_creators, 1);
+ });
+
+ try { await run(); } finally { await t.close(); await stub.close(); }
+})();
@@ -47,7 +47,7 @@ async function boot(opts = {}) {
@@ -47,7 +47,7 @@ async function boot(opts = {}) {
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')), debug() {} };
const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')), debug() {} };
// One database per boot (PGlite, or VIP_TEST_STORE=pg: the containers), dropped when the boot closes.
// One database per boot (PGlite, or VIP_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = opts.testdb || await require('./db').testDb();
const testdb = opts.testdb || await require('./db').testDb();
- const app = await createApp({ config, db: testdb.db, now: clock.now, log, indexnow: opts.indexnow });
+ const app = await createApp({ config, db: testdb.db, now: clock.now, log, indexnow: opts.indexnow, accountSend: opts.accountSend });
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
const domain = app.locals.domain;
const domain = app.locals.domain;