openvibers/openvibe.coupons Commits

Moderation audit (ADR-022, WS-D task 1): coupons.moderation.action from the outbox in the same transaction when staff or a staff-capable service disables, expires, re-enables (coupon.disabled|expired|enabled, the note as reason) or approves (coupon.approved) someone else's code, or approves, disables or re-enables a shop (merchant.approved|disabled|enabled, with the codes it published); it names the staff member and never the submitter (owner_subject null) or the code; staff changing their own code reports nothing. Pins: openvibe-contracts 0.49.0 → 0.53.0

OP OpenVibers committed 26 Sept 2026 · f86f660d39 · 10 files, +146 −26


Changes

ddiff --git a/README.md b/README.md
iindex 121e871..9fb15fa 100644
---- a/README.md
++++ b/README.md
@@@ -251,11 +251,12 @@ use a visitor's session against the API. Errors are RFC 9457 problems.
| `coupons.coupon.disabled` | The code is taken down (by staff, a service, or because its last Sources evidence was removed). |
| `coupons.report.created` | A new report (not a deduplicated repeat). The payload is the outcome, reason, channel and day only. |
| `coupons.confidence.changed` | The confidence moved, because of reports or decay. The payload is from, to and status. |
++| `coupons.moderation.action` | Staff (or a service with `coupons.status.update` / `coupons.merchant.manage`) disabled, expired, re-enabled or approved someone else's code (`coupon.*`), or approved, disabled or re-enabled a shop (`merchant.*`). For the network's moderation audit log (ADR-022, `common.moderation-action@1`). It names the staff member who acted, never the submitter, and never the code. |
| `coupons.index_document.upserted` / `.deleted` | `search.index-document@1` documents for `coupon` and `merchant`, and tombstones when a code leaves active results. A resource that was never indexed gets no tombstone. |
The charter's `coupons.created|updated|expired|disabled` are these `coupons.coupon.*` 3-segment
types. Every envelope validates as `events.event-envelope@1` (tested), with actor
--`{type: service, id: coupons}`.
++`{type: service, id: coupons}`, except `coupons.moderation.action`, whose actor is the staff member.
## Capabilities (registered in openvibe-contracts v0.25.0)
ddiff --git a/package-lock.json b/package-lock.json
iindex ad447d7..0764cf8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
@@@ -1015,9 +1015,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.49.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
++ "version": "0.53.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
++ "integrity": "sha512-U+/QMB9dYHF+1ZS7ZZf+aYDokI8nuZmMZh1qOvisT7iB+QQ4uxr03E3wHm1/qOr+IHlt+7BzFaOPVw6K6cOSxA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 3eae702..81d3995 100644
---- a/package.json
++++ b/package.json
@@@ -20,7 +20,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v0.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
ddiff --git a/server/app.js b/server/app.js
iindex 79e40f6..7d7752f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -48,11 +48,11 @@ function createApp(opts = {}) {
const outbox = createCouponsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const publication = createPublication({ store, config, outbox });
const merchants = createMerchants({ store });
-- const coupons = createCoupons({ store, merchants, publication });
++ const coupons = createCoupons({ store, merchants, publication, outbox });
const reports = createReports({ store, config, coupons, merchants, publication });
const installs = createInstalls({ store, config });
const watches = createWatches({ store });
-- const moderation = createModeration({ store, merchants, coupons });
++ const moderation = createModeration({ store, merchants, coupons, outbox });
const importer = createSourcesImporter({ store, config, merchants, coupons, fetchImpl, log });
const auth = opts.auth || createAuthClient(config);
const viewers = createViewerResolver({ auth, config, installs });
ddiff --git a/server/domain/coupons.js b/server/domain/coupons.js
iindex 94a0795..fe7ceaa 100644
---- a/server/domain/coupons.js
++++ b/server/domain/coupons.js
@@@ -147,7 +147,7 @@ function parseSubmission(body, now) {
};
}
--function createCoupons({ store, merchants, publication }) {
++function createCoupons({ store, merchants, publication, outbox = null }) {
const { db } = store;
const q = {
byId: db.prepare('SELECT * FROM coupons WHERE id = ?'),
@@@ -392,24 +392,32 @@ function createCoupons({ store, merchants, publication }) {
*/
function setStatus(c, target, { actor, note = '', traceparent } = {}) {
const why = `${actor.startsWith('svc:') ? `service:${actor.slice(4)}` : 'staff'}${note ? `:${text(note, 200)}` : ''}`;
-- if (target === 'disabled') return store.tx(() => recompute(c.id, { reason: why, actor, forceStatus: 'disabled', traceparent }));
-- if (target === 'expired') {
-- if (c.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'enable the code before marking it expired');
-- return store.tx(() => recompute(c.id, { reason: why, actor, forceStatus: 'expired', traceparent }));
-- }
-- if (target === 'active') {
-- if (c.expires_at != null && c.expires_at <= store.now()) throw new ApiError(409, 'coupon.expiry_passed', 'its known expiry has passed; change the expiry first');
-- return store.tx(() => recompute(c.id, { reason: why, actor, forceStatus: 'unknown', traceparent }));
-- }
-- throw new ApiError(422, 'coupon.status_not_settable', 'status can be set to disabled, expired or active only; working/failed come from people\'s reports');
++ const force = { disabled: 'disabled', expired: 'expired', active: 'unknown' }[target];
++ if (!force) throw new ApiError(422, 'coupon.status_not_settable', 'status can be set to disabled, expired or active only; working/failed come from people\'s reports');
++ if (target === 'expired' && c.status === 'disabled') throw new ApiError(409, 'coupon.disabled', 'enable the code before marking it expired');
++ if (target === 'active' && c.expires_at != null && c.expires_at <= store.now()) throw new ApiError(409, 'coupon.expiry_passed', 'its known expiry has passed; change the expiry first');
++ return store.tx(() => {
++ const after = recompute(c.id, { reason: why, actor, forceStatus: force, traceparent });
++ // Staff or a staff-capable service acting on someone else's code: the moderation audit log (ADR-022).
++ if (after && after.status !== c.status && actor !== c.created_by) moderated(`coupon.${target === 'active' ? 'enabled' : target}`, c, actor, { reason: note ? text(note, 200) : null, details: { previous: c.status, status: after.status }, traceparent });
++ return after;
++ });
++ }
++
++ /** coupons.moderation.action for a staff or service action (never the submitter; see events/outbox.js). */
++ function moderated(action, c, actor, { reason = null, details = {}, traceparent } = {}) {
++ if (!outbox) return null;
++ const person = /^usr_/.test(String(actor || '')) ? actor : null;
++ return outbox.moderationAction({ action, target: { type: 'coupon', id: c.id }, actorSubject: person, reason, details: { merchant_id: c.merchant_id, origin: c.origin, ...details } }, { traceparent });
}
/** Staff: publish a code that waits for review. */
-- function approve(c, { actor }) {
++ function approve(c, { actor, audit = true, traceparent } = {}) {
return store.tx(() => {
if (q.setReview.run(store.now(), c.id).changes !== 1) return get(c.id);
const after = get(c.id);
q.history.run(c.id, c.status, after.status, c.confidence, after.confidence, 'staff:approved', actor, store.now());
++ if (audit && actor !== c.created_by) moderated('coupon.approved', c, actor, { traceparent });
publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(after, { previous_status: c.status, reason: 'approved' }), { isPublic: publiclyListed(after) });
syncIndex(after);
return after;
@@@ -518,7 +526,7 @@ function createCoupons({ store, merchants, publication }) {
const out = [];
for (const id of q.byMerchantPending.all(merchant.id).map((r) => r.id)) {
const c = get(id);
-- if (c.origin === 'member' || c.origin === 'staff') out.push(approve(c, { actor }));
++ if (c.origin === 'member' || c.origin === 'staff') out.push(approve(c, { actor, audit: false })); // the shop's approval is the audited action
}
return out;
},
ddiff --git a/server/domain/moderation.js b/server/domain/moderation.js
iindex d82ab66..521c0b9 100644
---- a/server/domain/moderation.js
++++ b/server/domain/moderation.js
@@@ -8,17 +8,27 @@
* (AI-extracted and imported codes still wait for their own review)
* disable a merchant → its page is gone (410), lookups stop, every code leaves active
* results and Search (tombstones); nothing is deleted
++ *
++ * Each also reaches Network's moderation audit log as coupons.moderation.action (ADR-022).
*/
const { ApiError } = require('../http/errors');
--function createModeration({ store, merchants, coupons }) {
++function createModeration({ store, merchants, coupons, outbox = null }) {
return {
-- setMerchantStatus(m, status, { actor = 'system' } = {}) {
++ setMerchantStatus(m, status, { actor = 'system', traceparent } = {}) {
if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');
return store.tx(() => {
const after = merchants.setStatus(m, status);
-- if (status === 'active') coupons.publishWaitingOn(after, { actor });
++ const published = status === 'active' ? coupons.publishWaitingOn(after, { actor }) : [];
coupons.syncMerchant(after);
++ // The moderation audit log (ADR-022): who approved, disabled or re-enabled the shop, never who proposed it.
++ if (outbox && m.status !== status && actor !== 'system') {
++ const action = status === 'disabled' ? 'merchant.disabled' : (m.status === 'pending' ? 'merchant.approved' : 'merchant.enabled');
++ outbox.moderationAction({
++ action, target: { type: 'merchant', id: m.id }, actorSubject: /^usr_/.test(String(actor)) ? actor : null,
++ details: { previous: m.status, status, ...(published.length ? { codes_published: published.length } : {}) },
++ }, { traceparent });
++ }
return after;
});
},
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex 3f64364..8bb809f 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -10,9 +10,17 @@
* coupons and merchants (openvibe-publishing
* index-hooks), consumed by Search's
* '*.index_document.*' subscription
++ * coupons.moderation.action staff (or a service holding the staff
++ * capability) disabled, expired, re-enabled or
++ * approved someone else's code, or approved,
++ * disabled or re-enabled a shop
++ * (common.moderation-action@1, ADR-022), for
++ * Network's moderation audit log
*
* Every envelope's actor is the service itself ({ type: 'service', id: 'coupons' }) and no payload
-- * names a person: submitters and reporters stay inside Coupons.
++ * names a person: submitters and reporters stay inside Coupons. The one exception is the staff
++ * member who took a moderation action, named in coupons.moderation.action (the audit log exists
++ * to say who acted); its target never names the submitter.
*
* emit() runs inside the SQLite transaction that makes the change, so an event exists if and only
* if its change committed. The relay publishes with Coupons' service token (events.event.publish,
@@@ -54,8 +62,24 @@ function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
return outbox.enqueue(envelope, { traceparent });
}
++ /**
++ * coupons.moderation.action, inside the caller's transaction. actorSubject: the staff member (null
++ * for a service); target: { type, id }, owner_subject always null. Never the code or its text.
++ */
++ function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
++ const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: null };
++ return emit({
++ event_type: 'coupons.moderation.action',
++ actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'coupons' },
++ subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
++ visibility: 'internal',
++ payload: { action, target: t, actor_subject: actorSubject || null, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },
++ }, { traceparent });
++ }
++
return {
emit,
++ moderationAction,
outbox,
enabled,
start() { if (enabled) outbox.start(); },
ddiff --git a/server/http/api.js b/server/http/api.js
iindex cdaa0dd..58d1910 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -209,7 +209,7 @@ function createApi(ctx) {
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
const status = (req.body || {}).status;
if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');
-- const after = ctx.moderation.setMerchantStatus(m, status, { actor });
++ const after = ctx.moderation.setMerchantStatus(m, status, { actor, traceparent: req.get('traceparent') });
return { merchant: { ...merchantView(after), status: after.status } };
}));
ddiff --git a/test/lifecycle.test.js b/test/lifecycle.test.js
iindex 4aa883b..88b44e3 100644
---- a/test/lifecycle.test.js
++++ b/test/lifecycle.test.js
@@@ -221,6 +221,14 @@ const DAY = 24 * HOUR;
for (const e of all) {
const v = contracts.validate('events.event-envelope@1', e);
assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)}`);
++ if (e.event_type === 'coupons.moderation.action') {
++ // The audit log names the staff member who acted, and nobody else (never a submitter).
++ assert.ok(contracts.validate('coupons.moderation.action@1', e.payload).valid);
++ assert.strictEqual(e.payload.target.owner_subject, null);
++ assert.ok(e.payload.actor_subject === null || e.payload.actor_subject === t.staff.subject);
++ assert.doesNotMatch(JSON.stringify(e).split(t.staff.subject).join(''), /usr_[0-9A-Z]{26}/, `${e.event_type} names someone other than staff`);
++ continue;
++ }
assert.deepStrictEqual(e.actor, { type: 'service', id: 'coupons' });
assert.doesNotMatch(JSON.stringify(e), /usr_[0-9A-Z]{26}/, `${e.event_type} names a person`);
if (e.event_type === 'coupons.index_document.upserted') {
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
nnew file mode 100644
iindex 0000000..cc4a984
---- /dev/null
++++ b/test/moderation-events.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * coupons.moderation.action (ADR-022): staff acting on someone else's code or on a shop goes to
++ * Network's moderation audit log, from the outbox in the same transaction. It names the staff
++ * member and never the submitter. Staff changing their own code reports nothing.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ await t.merchant({ name: 'Acme Outdoor', host: 'acme-outdoor.com' });
++ const moderation = () => t.events('coupons.moderation.action');
++ const setStatus = (id, body) => t.get(`/api/v1/coupons/${id}/status`, { as: t.staffToken(), json: body });
++ const valid = (e) => {
++ assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
++ const r = contracts.validate('coupons.moderation.action@1', e.payload);
++ assert.strictEqual(r.valid, true, JSON.stringify(r.errors));
++ assert.strictEqual(e.source, 'coupons');
++ assert.strictEqual(e.visibility, 'internal');
++ assert.deepStrictEqual(e.actor, { type: 'user', id: t.staff.subject });
++ assert.strictEqual(e.payload.actor_subject, t.staff.subject);
++ assert.strictEqual(e.payload.target.owner_subject, null, 'the submitter stays inside Coupons');
++ assert.ok(!JSON.stringify(e).includes(alice.subject));
++ };
++
++ await check('staff disabling their own code is not moderation: no event', async () => {
++ const r = await t.submit(t.staff, { host: 'acme-outdoor.com', code: 'STAFFOWN', title: 'Staff code' });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual((await setStatus(r.json().coupon.id, { status: 'disabled' })).status, 200);
++ assert.strictEqual(moderation().length, 0);
++ });
++
++ await check('staff disabling someone else\'s code: exactly one valid event', async () => {
++ const r = await t.submit(alice, { host: 'acme-outdoor.com', code: 'FAKE50', title: 'Too good to be true' });
++ assert.strictEqual(r.status, 201, r.text);
++ const id = r.json().coupon.id;
++ const off = await setStatus(id, { status: 'disabled', note: 'fabricated code' });
++ assert.strictEqual(off.status, 200, off.text);
++ const ev = moderation();
++ assert.strictEqual(ev.length, 1);
++ valid(ev[0]);
++ assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `coupon:${id}` });
++ assert.strictEqual(ev[0].payload.action, 'coupon.disabled');
++ assert.deepStrictEqual(ev[0].payload.target, { type: 'coupon', id, owner_subject: null });
++ assert.strictEqual(ev[0].payload.reason, 'fabricated code');
++ assert.strictEqual(ev[0].payload.details.previous, 'unknown');
++ assert.ok(!JSON.stringify(ev[0]).includes('FAKE50'), 'never the code itself');
++ });
++
++ await check('approving a shop members proposed: one merchant.approved, not one per code it publishes', async () => {
++ const r = await t.submit(alice, { url: 'https://www.new-gadgets.net/cart', code: 'GADGET5', title: 'Five off gadgets' });
++ assert.strictEqual(r.json().merchant.status, 'pending');
++ const n = moderation().length;
++ const ok = await t.get(`/staff/merchants/${r.json().merchant.id}/status`, { as: t.staff, form: { csrf: t.csrf(t.staff), status: 'active' } });
++ assert.strictEqual(ok.status, 200);
++ const ev = moderation();
++ assert.strictEqual(ev.length, n + 1);
++ valid(ev[n]);
++ assert.strictEqual(ev[n].payload.action, 'merchant.approved');
++ assert.deepStrictEqual(ev[n].payload.target, { type: 'merchant', id: r.json().merchant.id, owner_subject: null });
++ assert.deepStrictEqual(ev[n].payload.details, { previous: 'pending', status: 'active', codes_published: 1 });
++ });
++
++ await t.close();
++ done();
++})();