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
Changes
@@@ -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-segmenttypes. 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)@@@ -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",@@@ -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"@@@ -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 });@@@ -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;},@@@ -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;});},@@@ -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(); },@@@ -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 } };}));@@@ -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') {nnew file mode 100644@@@ -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();++})();
@@ -251,11 +251,12 @@ use a visitor's session against the API. Errors are RFC 9457 problems.
@@ -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.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.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.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. |
| `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
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
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)
## Capabilities (registered in openvibe-contracts v0.25.0)
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.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-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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
@@ -1015,9 +1015,9 @@
@@ -1015,9 +1015,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.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-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-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"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0"
@@ -48,11 +48,11 @@ function createApp(opts = {}) {
@@ -48,11 +48,11 @@ function createApp(opts = {}) {
const outbox = createCouponsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const outbox = createCouponsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
const publication = createPublication({ store, config, outbox });
const publication = createPublication({ store, config, outbox });
const merchants = createMerchants({ store });
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 reports = createReports({ store, config, coupons, merchants, publication });
const installs = createInstalls({ store, config });
const installs = createInstalls({ store, config });
const watches = createWatches({ store });
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 importer = createSourcesImporter({ store, config, merchants, coupons, fetchImpl, log });
const auth = opts.auth || createAuthClient(config);
const auth = opts.auth || createAuthClient(config);
const viewers = createViewerResolver({ auth, config, installs });
const viewers = createViewerResolver({ auth, config, installs });
@@ -147,7 +147,7 @@ function parseSubmission(body, now) {
@@ -147,7 +147,7 @@ function parseSubmission(body, now) {
};
};
}
}
-function createCoupons({ store, merchants, publication }) {
+function createCoupons({ store, merchants, publication, outbox = null }) {
const { db } = store;
const { db } = store;
const q = {
const q = {
byId: db.prepare('SELECT * FROM coupons WHERE id = ?'),
byId: db.prepare('SELECT * FROM coupons WHERE id = ?'),
@@ -392,24 +392,32 @@ function createCoupons({ store, merchants, publication }) {
@@ -392,24 +392,32 @@ function createCoupons({ store, merchants, publication }) {
*/
*/
function setStatus(c, target, { actor, note = '', traceparent } = {}) {
function setStatus(c, target, { actor, note = '', traceparent } = {}) {
const why = `${actor.startsWith('svc:') ? `service:${actor.slice(4)}` : 'staff'}${note ? `:${text(note, 200)}` : ''}`;
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. */
/** Staff: publish a code that waits for review. */
- function approve(c, { actor }) {
+ function approve(c, { actor, audit = true, traceparent } = {}) {
return store.tx(() => {
return store.tx(() => {
if (q.setReview.run(store.now(), c.id).changes !== 1) return get(c.id);
if (q.setReview.run(store.now(), c.id).changes !== 1) return get(c.id);
const after = 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());
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) });
publication.emit('coupons.coupon.updated', { type: 'coupon', id: c.id }, lifecyclePayload(after, { previous_status: c.status, reason: 'approved' }), { isPublic: publiclyListed(after) });
syncIndex(after);
syncIndex(after);
return after;
return after;
@@ -518,7 +526,7 @@ function createCoupons({ store, merchants, publication }) {
@@ -518,7 +526,7 @@ function createCoupons({ store, merchants, publication }) {
const out = [];
const out = [];
for (const id of q.byMerchantPending.all(merchant.id).map((r) => r.id)) {
for (const id of q.byMerchantPending.all(merchant.id).map((r) => r.id)) {
const c = get(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;
return out;
},
},
@@ -8,17 +8,27 @@
@@ -8,17 +8,27 @@
* (AI-extracted and imported codes still wait for their own review)
* (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
* disable a merchant → its page is gone (410), lookups stop, every code leaves active
* results and Search (tombstones); nothing is deleted
* 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');
const { ApiError } = require('../http/errors');
-function createModeration({ store, merchants, coupons }) {
+function createModeration({ store, merchants, coupons, outbox = null }) {
return {
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');
if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');
return store.tx(() => {
return store.tx(() => {
const after = merchants.setStatus(m, status);
const after = merchants.setStatus(m, status);
- if (status === 'active') coupons.publishWaitingOn(after, { actor });
+ const published = status === 'active' ? coupons.publishWaitingOn(after, { actor }) : [];
coupons.syncMerchant(after);
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;
return after;
});
});
},
},
@@ -10,9 +10,17 @@
@@ -10,9 +10,17 @@
* coupons and merchants (openvibe-publishing
* coupons and merchants (openvibe-publishing
* index-hooks), consumed by Search's
* index-hooks), consumed by Search's
* '*.index_document.*' subscription
* '*.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
* 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
* 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,
* 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 }) {
@@ -54,8 +62,24 @@ function createCouponsOutbox({ db, config, fetchImpl, now, log = console }) {
return outbox.enqueue(envelope, { traceparent });
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 {
return {
emit,
emit,
+ moderationAction,
outbox,
outbox,
enabled,
enabled,
start() { if (enabled) outbox.start(); },
start() { if (enabled) outbox.start(); },
@@ -209,7 +209,7 @@ function createApi(ctx) {
@@ -209,7 +209,7 @@ function createApi(ctx) {
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
if (!m) throw new ApiError(404, 'merchant.not_found', 'no such merchant');
const status = (req.body || {}).status;
const status = (req.body || {}).status;
if (!['active', 'disabled'].includes(status)) throw new ApiError(422, 'merchant.bad_status', 'status must be active or disabled');
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 } };
return { merchant: { ...merchantView(after), status: after.status } };
}));
}));
@@ -221,6 +221,14 @@ const DAY = 24 * HOUR;
@@ -221,6 +221,14 @@ const DAY = 24 * HOUR;
for (const e of all) {
for (const e of all) {
const v = contracts.validate('events.event-envelope@1', e);
const v = contracts.validate('events.event-envelope@1', e);
assert.ok(v.valid, `${e.event_type}: ${JSON.stringify(v.errors)}`);
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.deepStrictEqual(e.actor, { type: 'service', id: 'coupons' });
assert.doesNotMatch(JSON.stringify(e), /usr_[0-9A-Z]{26}/, `${e.event_type} names a person`);
assert.doesNotMatch(JSON.stringify(e), /usr_[0-9A-Z]{26}/, `${e.event_type} names a person`);
if (e.event_type === 'coupons.index_document.upserted') {
if (e.event_type === 'coupons.index_document.upserted') {
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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();
+})();