openvibers/openvibe.coupons Commits
account-data: a person's watches and extension installs, with the guard on every person column (#148)
Changes
@@@ -491,7 +491,7 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.222.0--- openvibe-sdk: v0.73.0++- openvibe-sdk: v0.74.0- openvibe-shared: v4.48.0- openvibe-publishing: v1.3.0<!-- versions:end -->@@@ -8,13 +8,13 @@"port": 4850,"domain": "openvibe.coupons","contracts": "openvibe-contracts v0.222.0",-- "sdk": "openvibe-sdk v0.73.0",++ "sdk": "openvibe-sdk v0.74.0","shared": "openvibe-shared v4.48.0","packages": ["openvibe-publishing v1.3.0","openvibe-contracts v0.222.0","openvibe-shared v4.48.0",-- "openvibe-sdk v0.73.0"++ "openvibe-sdk v0.74.0"],"capabilities": ["coupons.coupon.submit",nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- OpenVibe.Coupons: the receipts of the ADR-033 account export and deletion deliveries this service applied++-- (openvibe-sdk/account-data's ACCOUNT_DATA_SCHEMA), so a redelivered export or deletion changes nothing.++-- The row holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.++++CREATE TABLE IF NOT EXISTS account_data_events (++ id TEXT PRIMARY KEY,++ kind TEXT NOT NULL,++ subject TEXT NOT NULL,++ outcome JSONB,++ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),++ sent_at TIMESTAMPTZ++);@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0","pg": "^8.23.0"},@@@ -1182,9 +1182,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.73.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",-- "integrity": "sha512-XClxoVm9M6RA8eXlGgYZ0u6q9n5h79mCXqAAEg9fX21KgcqP69RmG+cX+6wrOf7TaEj7eSBWMxHjiIeIa9N7Dg==",++ "version": "0.74.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",++ "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -28,7 +28,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0","pg": "^8.23.0"},nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++/**++ * Account export and deletion → Coupons (ADR-033; openvibe-sdk/account-data).++ *++ * A person's own rows are the merchants they watch (coupon_watches) and the browser-extension installs they made++ * (extension_installs: the label, scopes and dates, never the token hash). Both are exported and deleted with them.++ *++ * The catalogue stays: a coupon, a merchant, its domains and an application hint are community records; their++ * created_by becomes NULL on deletion (anonymize), because the catalogue's value is the code, not who added it.++ * coupons_index_revisions.owner is the index document's owning service, not a person.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const anonymize = { anonymize: {} };++++const TABLES = [++ { table: 'coupon_watches', subject: 'subject', file: 'watched-merchants.json', columns: ['merchant_id', 'created_at'], order: 'created_at' },++ { table: 'extension_installs', subject: 'subject', file: 'extension-installs.json', order: 'created_at',++ columns: ['label', 'token_hint', 'scopes', 'created_at', 'expires_at', 'last_used_at', 'revoked_at'] },++ // The catalogue stays, without its contributor.++ { table: 'coupons', subject: 'created_by', file: null, erase: anonymize },++ { table: 'coupon_merchants', subject: 'created_by', file: null, erase: anonymize },++ { table: 'coupon_merchant_domains', subject: 'created_by', file: null, erase: anonymize },++ { table: 'coupon_application_hints', subject: 'created_by', file: null, erase: anonymize },++ // The provenance of a coupon stays (where the code was published); the person who submitted it is erased from it.++ { table: 'coupon_sources', subject: 'submitted_by', file: null, erase: anonymize },++];++++function create({ db, log = console } = {}) {++ return createAccountData({ db, service: 'coupons', tables: TABLES, log });++}++++module.exports = { create, TABLES, TOPICS };@@@ -40,6 +40,8 @@ const { createActorLimits } = require('./http/actor-limits');const { createWorker } = require('./worker');const { assetVersion } = require('./render/layout');const { createWebhookRouter } = require('./events/consumer');++const accountDataLib = require('./account-data');++const { createNetworkSender } = require('openvibe-sdk/account-data');const PUBLIC_DIR = path.join(__dirname, '..', 'public');const VERSION = require('../package.json').version;@@@ -64,6 +66,10 @@ async function createApp(opts = {}) {});// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.const store = opts.store || await openStore(config, { now: opts.now, log });++ const accountData = opts.accountData || accountDataLib.create({ db: store.db, log });++ const accountSend = opts.accountSend || (config.oauth.clientSecret ? createNetworkSender({++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl,++ }) : null);// OpenVibe.Events through the shared openvibe-sdk transactional outbox (ADR-004, plan T1/T9): rows are// written inside the change's own transaction; moderationAction never names an owner_subject.@@@ -101,7 +107,7 @@ async function createApp(opts = {}) {const viewers = createViewerResolver({ auth, jwks, config, installs });const worker = createWorker({ config, coupons, importer, outbox, log });-- const ctx = { config, store, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker, indexnow };++ const ctx = { config, store, accountData, accountSend, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker, indexnow };const app = express();app.disable('x-powered-by');@@@ -161,7 +167,7 @@ async function createApp(opts = {}) {// ── Inbound events (OpenVibe.Events webhook, signed) ─────// Mounted before sign-in and body-parser: reads its own raw body for the HMAC.-- app.use(createWebhookRouter(express, { config, store, importer, log }));++ app.use(createWebhookRouter(express, { config, store, importer, accountData, accountSend, log }));// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.@@@ -13,8 +13,9 @@* HMAC and never touches cookies, sessions or the JSON parser.*/const { createEventConsumer } = require('openvibe-publishing/ingest');++const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');--function createWebhookRouter(express, { config, store, importer, log = console }) {++function createWebhookRouter(express, { config, store, importer, accountData = null, accountSend = null, log = console }) {const consumer = createEventConsumer({db: store.db,secrets: config.events.webhookSecrets,@@@ -29,6 +30,7 @@ function createWebhookRouter(express, { config, store, importer, log = console }router.post('/internal/events', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {const r = await consumer.apply(req.body, req.headers, async (event) => {const type = event && event.event_type;++ if (accountData && ACCOUNT_TOPICS.includes(type)) return accountData.apply(event, { send: accountSend });if (type === 'sources.item.created') {try {const result = await importer.run();@@@ -8,6 +8,7 @@const { createApp } = require('./app');const { gracefulStop } = require('openvibe-sdk/service');const { startTelemetryRetention } = require('openvibe-sdk/telemetry');++const { startSubscriptions } = require('openvibe-sdk/account-data');/*** The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the worker stops taking new@@@ -15,11 +16,11 @@ const { startTelemetryRetention } = require('openvibe-sdk/telemetry');* the deadline the process exits 0, as the hand-rolled 5 s timer did. Exported so a test can inject* `exit` and `signals: false`.*/--function createLifecycle({ server, ctx, exit, signals }) {++function createLifecycle({ server, ctx, exit, signals, extra = [] }) {return gracefulStop({name: 'Coupons', server, deadlineExitCode: 0, exit, signals,stop: [() => ctx.worker.stop()],-- close: [() => ctx.outbox.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.store.close()],++ close: [...extra, () => ctx.outbox.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.store.close()],});}@@@ -41,7 +42,14 @@ async function start() {ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);ctx.telemetryTick.unref();-- createLifecycle({ server, ctx });++ let accountSubscription = null;++ if (config.events.webhookSecrets.length && config.events.url && config.oauth.clientSecret) {++ accountSubscription = startSubscriptions({++ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.webhookSecrets[0],++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, log: console,++ });++ }++ createLifecycle({ server, ctx, extra: [() => { if (accountSubscription) accountSubscription.stop(); }] });return { server, ctx };}nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * ADR-033: Coupons' part of an account export and deletion, through the signed POST /internal/events route. A++ * person's watched merchants and extension installs are exported (never the token hash) and erased; the catalogue++ * they contributed to stays without their id; Bo's rows stay. The guard fails on a new table with a person column.++ */++const assert = require('assert');++const { uncovered } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/boot');++const { TABLES } = require('../server/account-data');++++const SECRET = `whsec_${'fixture'.repeat(6)}`;++const A = 'usr_01JZ0000000000000000000AAA';++const B = 'usr_01JZ0000000000000000000BBB';++++(async () => {++ const calls = [];++ const accountSend = async (path, body) => { calls.push({ path, body }); return { ok: true, status: 201 }; };++ const t = await boot({ env: { COUPONS_EVENTS_SECRET: SECRET }, accountSend });++ const db = t.ctx.store.db;++ const deliver = async (event) => {++ const raw = JSON.stringify({ event, seq: 1 });++ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });++ try {++ await check('every table that names a person is covered or exempted with a reason', async () => {++ const missing = await uncovered(db, {++ tables: TABLES,++ exempt: {++ account_data_events: 'the ledger of requests itself: which export and deletion Coupons applied',++ coupons_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",++ },++ });++ assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);++ });++++ await check('an export carries watches and installs without the token hash; a deletion erases them and anonymizes the catalogue', async () => {++ const merchant = 'mer_01JZ0000000000000000000MER';++ for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {++ await db.exec("INSERT INTO coupon_merchants (id, slug, name, status, created_by, created_at, updated_at) VALUES ($1, $2, $3, 'active', $4, 1, 1)", [`mer_${name}`, `m-${name}`, name, subject]);++ await db.exec('INSERT INTO coupon_watches (subject, merchant_id, created_at) VALUES ($1, $2, 1)', [subject, `mer_${name}`]);++ await db.exec("INSERT INTO extension_installs (id, subject, token_hash, token_hint, label, scopes, created_at, expires_at) VALUES ($1, $2, $3, 'cpx_abc', 'laptop', '[]', 1, 2)", [`cpi_${name}`, subject, `hash-${name}`]);++ }++ const exp = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EXP', subject: A }));++ assert.deepStrictEqual([exp.status, exp.json.outcome], [200, 'exported']);++ const part = calls.find((c) => c.path === '/internal/account-exports/exp_01JZ0000000000000000000EXP/parts');++ assert.ok(part, 'the part was pushed');++ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));++ assert.strictEqual(files['watched-merchants.json'].length, 1);++ assert.strictEqual(files['extension-installs.json'].length, 1);++ assert.ok(!JSON.stringify(part.body).includes('hash-ada'), 'the token hash is never exported');++++ const del = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DEX', subject: A, aliases: [] }));++ assert.deepStrictEqual([del.status, del.json.outcome], [200, 'erased']);++ assert.strictEqual(Number(await db.value('SELECT count(*)::int FROM coupon_watches WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT count(*)::int FROM extension_installs WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT count(*)::int FROM coupon_watches WHERE subject = $1', [B])), 1);++ assert.strictEqual(await db.value('SELECT created_by FROM coupon_merchants WHERE id = $1', ['mer_ada']), null, 'the catalogue row stays without its contributor');++ assert.strictEqual(await db.value('SELECT created_by FROM coupon_merchants WHERE id = $1', ['mer_bo']), B);++ const confirmation = calls.find((c) => c.path === '/internal/account-deletions/del_01JZ0000000000000000000DEX/confirmations');++ assert.ok(confirmation, 'the confirmation was sent');++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -42,7 +42,7 @@ async function boot(opts = {}) {let built = null;async function start() {const config = configLib.load(env);-- built = await createApp({ config, telemetrySink: opts.telemetrySink, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow });++ built = await createApp({ config, telemetrySink: opts.telemetrySink, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountData: opts.accountData, accountSend: opts.accountSend });try { await built.ctx.jwks.keys(); } catch { /* started without the key: readiness reports it */ }server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });t.base = `http://127.0.0.1:${server.address().port}`;
@@ -491,7 +491,7 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -491,7 +491,7 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
-- openvibe-sdk: v0.73.0
+- openvibe-sdk: v0.74.0
- openvibe-shared: v4.48.0
- openvibe-shared: v4.48.0
- openvibe-publishing: v1.3.0
- openvibe-publishing: v1.3.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,13 +8,13 @@
@@ -8,13 +8,13 @@
"port": 4850,
"port": 4850,
"domain": "openvibe.coupons",
"domain": "openvibe.coupons",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
- "sdk": "openvibe-sdk v0.73.0",
+ "sdk": "openvibe-sdk v0.74.0",
"shared": "openvibe-shared v4.48.0",
"shared": "openvibe-shared v4.48.0",
"packages": [
"packages": [
"openvibe-publishing v1.3.0",
"openvibe-publishing v1.3.0",
"openvibe-contracts v0.222.0",
"openvibe-contracts v0.222.0",
"openvibe-shared v4.48.0",
"openvibe-shared v4.48.0",
- "openvibe-sdk v0.73.0"
+ "openvibe-sdk v0.74.0"
],
],
"capabilities": [
"capabilities": [
"coupons.coupon.submit",
"coupons.coupon.submit",
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- OpenVibe.Coupons: the receipts of the ADR-033 account export and deletion deliveries this service applied
+-- (openvibe-sdk/account-data's ACCOUNT_DATA_SCHEMA), so a redelivered export or deletion changes nothing.
+-- The row holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.
+
+CREATE TABLE IF NOT EXISTS account_data_events (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ outcome JSONB,
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ sent_at TIMESTAMPTZ
+);
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1182,9 +1182,9 @@
@@ -1182,9 +1182,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.73.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
- "integrity": "sha512-XClxoVm9M6RA8eXlGgYZ0u6q9n5h79mCXqAAEg9fX21KgcqP69RmG+cX+6wrOf7TaEj7eSBWMxHjiIeIa9N7Dg==",
+ "version": "0.74.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
+ "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.73.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+/**
+ * Account export and deletion → Coupons (ADR-033; openvibe-sdk/account-data).
+ *
+ * A person's own rows are the merchants they watch (coupon_watches) and the browser-extension installs they made
+ * (extension_installs: the label, scopes and dates, never the token hash). Both are exported and deleted with them.
+ *
+ * The catalogue stays: a coupon, a merchant, its domains and an application hint are community records; their
+ * created_by becomes NULL on deletion (anonymize), because the catalogue's value is the code, not who added it.
+ * coupons_index_revisions.owner is the index document's owning service, not a person.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const anonymize = { anonymize: {} };
+
+const TABLES = [
+ { table: 'coupon_watches', subject: 'subject', file: 'watched-merchants.json', columns: ['merchant_id', 'created_at'], order: 'created_at' },
+ { table: 'extension_installs', subject: 'subject', file: 'extension-installs.json', order: 'created_at',
+ columns: ['label', 'token_hint', 'scopes', 'created_at', 'expires_at', 'last_used_at', 'revoked_at'] },
+ // The catalogue stays, without its contributor.
+ { table: 'coupons', subject: 'created_by', file: null, erase: anonymize },
+ { table: 'coupon_merchants', subject: 'created_by', file: null, erase: anonymize },
+ { table: 'coupon_merchant_domains', subject: 'created_by', file: null, erase: anonymize },
+ { table: 'coupon_application_hints', subject: 'created_by', file: null, erase: anonymize },
+ // The provenance of a coupon stays (where the code was published); the person who submitted it is erased from it.
+ { table: 'coupon_sources', subject: 'submitted_by', file: null, erase: anonymize },
+];
+
+function create({ db, log = console } = {}) {
+ return createAccountData({ db, service: 'coupons', tables: TABLES, log });
+}
+
+module.exports = { create, TABLES, TOPICS };
@@ -40,6 +40,8 @@ const { createActorLimits } = require('./http/actor-limits');
@@ -40,6 +40,8 @@ const { createActorLimits } = require('./http/actor-limits');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
const { assetVersion } = require('./render/layout');
const { assetVersion } = require('./render/layout');
const { createWebhookRouter } = require('./events/consumer');
const { createWebhookRouter } = require('./events/consumer');
+const accountDataLib = require('./account-data');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
@@ -64,6 +66,10 @@ async function createApp(opts = {}) {
@@ -64,6 +66,10 @@ async function createApp(opts = {}) {
});
});
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
const store = opts.store || await openStore(config, { now: opts.now, log });
const store = opts.store || await openStore(config, { now: opts.now, log });
+ const accountData = opts.accountData || accountDataLib.create({ db: store.db, log });
+ const accountSend = opts.accountSend || (config.oauth.clientSecret ? createNetworkSender({
+ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl,
+ }) : null);
// OpenVibe.Events through the shared openvibe-sdk transactional outbox (ADR-004, plan T1/T9): rows are
// OpenVibe.Events through the shared openvibe-sdk transactional outbox (ADR-004, plan T1/T9): rows are
// written inside the change's own transaction; moderationAction never names an owner_subject.
// written inside the change's own transaction; moderationAction never names an owner_subject.
@@ -101,7 +107,7 @@ async function createApp(opts = {}) {
@@ -101,7 +107,7 @@ async function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, jwks, config, installs });
const viewers = createViewerResolver({ auth, jwks, config, installs });
const worker = createWorker({ config, coupons, importer, outbox, log });
const worker = createWorker({ config, coupons, importer, outbox, log });
- const ctx = { config, store, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker, indexnow };
+ const ctx = { config, store, accountData, accountSend, outbox, publication, merchants, coupons, reports, installs, watches, moderation, importer, auth, jwks, viewers, worker, indexnow };
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -161,7 +167,7 @@ async function createApp(opts = {}) {
@@ -161,7 +167,7 @@ async function createApp(opts = {}) {
// ── Inbound events (OpenVibe.Events webhook, signed) ─────
// ── Inbound events (OpenVibe.Events webhook, signed) ─────
// Mounted before sign-in and body-parser: reads its own raw body for the HMAC.
// Mounted before sign-in and body-parser: reads its own raw body for the HMAC.
- app.use(createWebhookRouter(express, { config, store, importer, log }));
+ app.use(createWebhookRouter(express, { config, store, importer, accountData, accountSend, log }));
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.
// /auth/me is the navbar's who-is-signed-in read on every page, so it is not counted with sign-ins: 60 page views in 15 minutes made a signed-in visitor look signed out.
@@ -13,8 +13,9 @@
@@ -13,8 +13,9 @@
* HMAC and never touches cookies, sessions or the JSON parser.
* HMAC and never touches cookies, sessions or the JSON parser.
*/
*/
const { createEventConsumer } = require('openvibe-publishing/ingest');
const { createEventConsumer } = require('openvibe-publishing/ingest');
+const { TOPICS: ACCOUNT_TOPICS } = require('openvibe-sdk/account-data');
-function createWebhookRouter(express, { config, store, importer, log = console }) {
+function createWebhookRouter(express, { config, store, importer, accountData = null, accountSend = null, log = console }) {
const consumer = createEventConsumer({
const consumer = createEventConsumer({
db: store.db,
db: store.db,
secrets: config.events.webhookSecrets,
secrets: config.events.webhookSecrets,
@@ -29,6 +30,7 @@ function createWebhookRouter(express, { config, store, importer, log = console }
@@ -29,6 +30,7 @@ function createWebhookRouter(express, { config, store, importer, log = console }
router.post('/internal/events', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
router.post('/internal/events', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const r = await consumer.apply(req.body, req.headers, async (event) => {
const r = await consumer.apply(req.body, req.headers, async (event) => {
const type = event && event.event_type;
const type = event && event.event_type;
+ if (accountData && ACCOUNT_TOPICS.includes(type)) return accountData.apply(event, { send: accountSend });
if (type === 'sources.item.created') {
if (type === 'sources.item.created') {
try {
try {
const result = await importer.run();
const result = await importer.run();
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
const { createApp } = require('./app');
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
+const { startSubscriptions } = require('openvibe-sdk/account-data');
/**
/**
* The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the worker stops taking new
* The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the worker stops taking new
@@ -15,11 +16,11 @@ const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
@@ -15,11 +16,11 @@ const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
* the deadline the process exits 0, as the hand-rolled 5 s timer did. Exported so a test can inject
* the deadline the process exits 0, as the hand-rolled 5 s timer did. Exported so a test can inject
* `exit` and `signals: false`.
* `exit` and `signals: false`.
*/
*/
-function createLifecycle({ server, ctx, exit, signals }) {
+function createLifecycle({ server, ctx, exit, signals, extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'Coupons', server, deadlineExitCode: 0, exit, signals,
name: 'Coupons', server, deadlineExitCode: 0, exit, signals,
stop: [() => ctx.worker.stop()],
stop: [() => ctx.worker.stop()],
- close: [() => ctx.outbox.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.store.close()],
+ close: [...extra, () => ctx.outbox.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.store.close()],
});
});
}
}
@@ -41,7 +42,14 @@ async function start() {
@@ -41,7 +42,14 @@ async function start() {
ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
ctx.telemetryTick.unref();
ctx.telemetryTick.unref();
- createLifecycle({ server, ctx });
+ let accountSubscription = null;
+ if (config.events.webhookSecrets.length && config.events.url && config.oauth.clientSecret) {
+ accountSubscription = startSubscriptions({
+ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.webhookSecrets[0],
+ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, log: console,
+ });
+ }
+ createLifecycle({ server, ctx, extra: [() => { if (accountSubscription) accountSubscription.stop(); }] });
return { server, ctx };
return { server, ctx };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * ADR-033: Coupons' part of an account export and deletion, through the signed POST /internal/events route. A
+ * person's watched merchants and extension installs are exported (never the token hash) and erased; the catalogue
+ * they contributed to stays without their id; Bo's rows stay. The guard fails on a new table with a person column.
+ */
+const assert = require('assert');
+const { uncovered } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/boot');
+const { TABLES } = require('../server/account-data');
+
+const SECRET = `whsec_${'fixture'.repeat(6)}`;
+const A = 'usr_01JZ0000000000000000000AAA';
+const B = 'usr_01JZ0000000000000000000BBB';
+
+(async () => {
+ const calls = [];
+ const accountSend = async (path, body) => { calls.push({ path, body }); return { ok: true, status: 201 }; };
+ const t = await boot({ env: { COUPONS_EVENTS_SECRET: SECRET }, accountSend });
+ const db = t.ctx.store.db;
+ const deliver = async (event) => {
+ const raw = JSON.stringify({ event, seq: 1 });
+ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
+ try {
+ await check('every table that names a person is covered or exempted with a reason', async () => {
+ const missing = await uncovered(db, {
+ tables: TABLES,
+ exempt: {
+ account_data_events: 'the ledger of requests itself: which export and deletion Coupons applied',
+ coupons_index_revisions: "owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash",
+ },
+ });
+ assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
+ });
+
+ await check('an export carries watches and installs without the token hash; a deletion erases them and anonymizes the catalogue', async () => {
+ const merchant = 'mer_01JZ0000000000000000000MER';
+ for (const [subject, name] of [[A, 'ada'], [B, 'bo']]) {
+ await db.exec("INSERT INTO coupon_merchants (id, slug, name, status, created_by, created_at, updated_at) VALUES ($1, $2, $3, 'active', $4, 1, 1)", [`mer_${name}`, `m-${name}`, name, subject]);
+ await db.exec('INSERT INTO coupon_watches (subject, merchant_id, created_at) VALUES ($1, $2, 1)', [subject, `mer_${name}`]);
+ await db.exec("INSERT INTO extension_installs (id, subject, token_hash, token_hint, label, scopes, created_at, expires_at) VALUES ($1, $2, $3, 'cpx_abc', 'laptop', '[]', 1, 2)", [`cpi_${name}`, subject, `hash-${name}`]);
+ }
+ const exp = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EXP', subject: A }));
+ assert.deepStrictEqual([exp.status, exp.json.outcome], [200, 'exported']);
+ const part = calls.find((c) => c.path === '/internal/account-exports/exp_01JZ0000000000000000000EXP/parts');
+ assert.ok(part, 'the part was pushed');
+ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
+ assert.strictEqual(files['watched-merchants.json'].length, 1);
+ assert.strictEqual(files['extension-installs.json'].length, 1);
+ assert.ok(!JSON.stringify(part.body).includes('hash-ada'), 'the token hash is never exported');
+
+ const del = await deliver(ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DEX', subject: A, aliases: [] }));
+ assert.deepStrictEqual([del.status, del.json.outcome], [200, 'erased']);
+ assert.strictEqual(Number(await db.value('SELECT count(*)::int FROM coupon_watches WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT count(*)::int FROM extension_installs WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT count(*)::int FROM coupon_watches WHERE subject = $1', [B])), 1);
+ assert.strictEqual(await db.value('SELECT created_by FROM coupon_merchants WHERE id = $1', ['mer_ada']), null, 'the catalogue row stays without its contributor');
+ assert.strictEqual(await db.value('SELECT created_by FROM coupon_merchants WHERE id = $1', ['mer_bo']), B);
+ const confirmation = calls.find((c) => c.path === '/internal/account-deletions/del_01JZ0000000000000000000DEX/confirmations');
+ assert.ok(confirmation, 'the confirmation was sent');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
let built = null;
let built = null;
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = await createApp({ config, telemetrySink: opts.telemetrySink, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow });
+ built = await createApp({ config, telemetrySink: opts.telemetrySink, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountData: opts.accountData, accountSend: opts.accountSend });
try { await built.ctx.jwks.keys(); } catch { /* started without the key: readiness reports it */ }
try { await built.ctx.jwks.keys(); } catch { /* started without the key: readiness reports it */ }
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
t.base = `http://127.0.0.1:${server.address().port}`;