openvibers/openvibe.coupons Commits

account-data: a person's watches and extension installs, with the guard on every person column (#148)

OP OpenVibers committed 14 days ago · ef58c40bc1 · 11 files, +145 −15

* account-data: a person's watches and extension installs, with the guard on every person column

* account-data: the receipts table comes from a migration, not from the runtime role

The boot-time ensureSchema() ran DDL as the serving role, which may not change
the schema; the table is now migration NNNN, like every other table here.

* ci: run the checks for this branch

* Merge remote-tracking branch 'origin/main' into adr033-account-data

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

Changes

ddiff --git a/README.md b/README.md
iindex 8a55cb1..1cab19d 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex cb6546d..aaf047d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/migrations/0004_account_data.sql b/migrations/0004_account_data.sql
nnew file mode 100644
iindex 0000000..71c5b98
---- /dev/null
++++ b/migrations/0004_account_data.sql
@@@ -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
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex a53784c..94b77e8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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"
ddiff --git a/package.json b/package.json
iindex 47043ce..3bd83a1 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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"
},
ddiff --git a/server/account-data.js b/server/account-data.js
nnew file mode 100644
iindex 0000000..4a0267f
---- /dev/null
++++ b/server/account-data.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex 34d2b74..b219923 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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.
ddiff --git a/server/events/consumer.js b/server/events/consumer.js
iindex 5cb1e78..92a9497 100644
---- a/server/events/consumer.js
++++ b/server/events/consumer.js
@@@ -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();
ddiff --git a/server/index.js b/server/index.js
iindex 0da272a..7490e6b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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 };
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..74fcf3a
---- /dev/null
++++ b/test/account-data.test.js
@@@ -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); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 324feed..f4c1a2f 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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}`;