openvibers/openvibe.deals Commits

Moderation audit (ADR-022, WS-D task 1): deals.moderation.action from the outbox in the same transaction when a moderator edits, expires, disables, enables, reviews, merges or unmerges someone else's offer (offer.*, the submitter as owner_subject, the reason when given, changed field names only) or resolves or dismisses a report (flag.resolved|dismissed, the reporter as owner_subject); the submitter acting on their own offer and AI text deliveries report nothing. Pins: openvibe-contracts 0.49.0 → 0.53.0

OP OpenVibers committed 26 Sept 2026 · 98999bf3ad · 10 files, +144 −13


Changes

ddiff --git a/README.md b/README.md
iindex 8398e70..b5c8cfc 100644
---- a/README.md
++++ b/README.md
@@@ -104,6 +104,7 @@ from the proposals in [docs/capabilities-proposal/](docs/capabilities-proposal/)
| `deals.offer.expired` | submitter, moderator or stated end time | internal once expired |
| `deals.vote.changed` | a person's effective vote changed (payload: counts and hotness) | internal |
| `deals.watch.matched` | a watch matched a new observation (payload: `recipient`, offer, observation) — for Network's notification consumer, which does not exist yet | internal |
++| `deals.moderation.action` | a moderator edited, expired, disabled, enabled, reviewed, merged or unmerged someone else's offer (`offer.*`), or resolved or dismissed a report (`flag.*`); for the network's moderation audit log (ADR-022, `common.moderation-action@1`), never the text | internal |
| `deals.index_document.upserted\|deleted` | the OpenVibe.Search document (`search.index-document@1`) or tombstone for offers and products | internal |
Consumed: `sources.item.created|updated|removed` at `POST /internal/events` (signed,
ddiff --git a/package-lock.json b/package-lock.json
iindex ea7614e..5954b95 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 17e7469..8bf5a24 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 0a40b62..789c411 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -66,8 +66,8 @@ function createApp(opts = {}) {
const watches = createWatches({ config, store, reads, catalog, publication, outbox });
const limits = createLimits({ config, store });
const access = createAccess();
-- const offers = createOffers({ config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community });
-- const flags = createFlags({ config, store, reads, limits, access, publication, logAction: offers.logAction });
++ const offers = createOffers({ config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community, outbox });
++ const flags = createFlags({ config, store, reads, limits, access, publication, logAction: offers.logAction, outbox });
const votes = createVotes({ config, store, reads, hotness, limits, flags, people, outbox });
const listings = createListings({ store });
const importer = createImporter({ config, store, reads, catalog, offers, indexing, sources, log });
ddiff --git a/server/domain/flags.js b/server/domain/flags.js
iindex 5f62968..7540432 100644
---- a/server/domain/flags.js
++++ b/server/domain/flags.js
@@@ -11,7 +11,7 @@ const { ApiError, newId, text, iso } = require('./util');
const USER_KINDS = ['expired', 'price_wrong', 'spam', 'duplicate', 'other'];
--function createFlags({ config, store, reads, limits, access, publication, logAction }) {
++function createFlags({ config, store, reads, limits, access, publication, logAction, outbox = null }) {
const { db } = store;
const q = {
get: db.prepare('SELECT * FROM deal_flags WHERE id = ?'),
@@@ -63,7 +63,7 @@ function createFlags({ config, store, reads, limits, access, publication, logAct
return { flag: q.get.get(id), created: true };
}
-- function resolve(viewer, flagId, { status = 'resolved', resolution } = {}) {
++ function resolve(viewer, flagId, { status = 'resolved', resolution } = {}, { traceparent } = {}) {
if (!access.isModerator(viewer, 'deals.offer.moderate')) throw new ApiError(403, 'moderation.forbidden', 'Moderators only');
if (!['resolved', 'dismissed'].includes(status)) throw new ApiError(422, 'request.invalid', 'status must be resolved or dismissed');
const f = q.get.get(String(flagId || ''));
@@@ -73,6 +73,14 @@ function createFlags({ config, store, reads, limits, access, publication, logAct
return store.tx(() => {
q.resolve.run(status, actor, store.now(), text(resolution, { max: 300 }), store.now(), f.id);
logAction(`flag.${status === 'resolved' ? 'resolve' : 'dismiss'}`, { offerId: f.offer_id, actor, reason: resolution || null, before: { flag: f.id, status: 'open' }, after: { status } });
++ // A moderator's decision on a report goes to Network's moderation audit log (ADR-022).
++ if (outbox) {
++ const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;
++ outbox.moderationAction({
++ action: `flag.${status}`, target: { type: 'flag', id: f.id, owner_subject: f.reporter || null }, actorSubject: person,
++ reason: text(resolution, { max: 300 }) || null, details: { offer_id: f.offer_id, kind: f.kind, origin: f.origin },
++ }, { traceparent });
++ }
return { flag: q.get.get(f.id), changed: true };
});
}
ddiff --git a/server/domain/offers.js b/server/domain/offers.js
iindex 327d178..3927419 100644
---- a/server/domain/offers.js
++++ b/server/domain/offers.js
@@@ -28,7 +28,7 @@ function actorOf(viewer) {
}
function createOffers(deps) {
-- const { config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community = null } = deps;
++ const { config, store, reads, catalog, publication, indexing, hotness, watches, limits, access, community = null, outbox = null } = deps;
const { db } = store;
const q = {
byUrl: db.prepare("SELECT * FROM deal_offers WHERE url_norm = ? AND status <> 'disabled' ORDER BY created_at LIMIT 1"),
@@@ -70,6 +70,18 @@ function createOffers(deps) {
};
}
++ /**
++ * A moderator acting on an offer someone else submitted (or an import) is moderation: it also goes
++ * to Network's moderation audit log (ADR-022), in the caller's transaction. The submitter (or a
++ * service acting for them) acting on their own offer, and AI text deliveries, are not.
++ */
++ function moderated(viewer, root, action, { reason = null, details = {}, traceparent } = {}) {
++ if (!outbox || !viewer || (viewer.kind === 'service' && viewer.origin === 'ai')) return null;
++ if (viewer.subject && viewer.subject === root.submitted_by) return null;
++ const person = viewer.subject && /^usr_/.test(viewer.subject) ? viewer.subject : null;
++ return outbox.moderationAction({ action, target: { type: 'offer', id: root.id, owner_subject: root.submitted_by || null }, actorSubject: person, reason, details }, { traceparent });
++ }
++
function logAction(action, { offerId = null, targetId = null, actor, reason = null, before = null, after = null }) {
q.log.run(action, offerId, targetId, actor || 'svc:deals', reason, before ? JSON.stringify(before) : null, after ? JSON.stringify(after) : null, store.now());
}
@@@ -219,6 +231,7 @@ function createOffers(deps) {
db.prepare(`UPDATE deal_offers SET ${keys.map((k) => `${k} = @${k}`).join(', ')}, updated_at = @now WHERE id = @id`).run({ ...changes, now: store.now(), id: root.id });
const after = reads.get(root.id);
indexing.emitOffer('deals.offer.updated', after, { actor, extra: { changed: keys }, traceparent });
++ moderated(viewer, root, 'offer.edited', { details: { changed: keys }, traceparent });
indexing.reindex(after);
if (oldProduct && oldProduct !== after.product_id) indexing.indexProduct(catalog.product(oldProduct));
return { offer: after, changed: true };
@@@ -267,7 +280,11 @@ function createOffers(deps) {
if (!access.canEdit(viewer, root)) throw new ApiError(403, 'offer.forbidden', 'Only the person who submitted this deal or a moderator can mark it expired');
if (root.status !== 'active') throw new ApiError(409, 'offer.not_active', `This deal is ${root.status}`);
const why = access.isStaff(viewer) ? `moderator${reason ? `: ${text(reason, { max: 200 })}` : ''}` : 'submitter';
-- return store.tx(() => expireRow(root, { actor: actorOf(viewer), reason: why, traceparent }));
++ return store.tx(() => {
++ const after = expireRow(root, { actor: actorOf(viewer), reason: why, traceparent });
++ moderated(viewer, root, 'offer.expired', { reason: text(reason, { max: 200 }) || null, traceparent });
++ return after;
++ });
}
/** Worker: offers whose STATED expiry has passed. Unknown expiry is never assumed. */
@@@ -290,6 +307,7 @@ function createOffers(deps) {
const now = store.now();
db.prepare("UPDATE deal_offers SET status = 'disabled', disabled_at = ?, disabled_reason = ?, disabled_by = ?, updated_at = ? WHERE id = ?").run(now, why, actorOf(viewer), now, root.id);
logAction('disable', { offerId: root.id, actor: actorOf(viewer), reason: why, before: { status: root.status }, after: { status: 'disabled' } });
++ moderated(viewer, root, 'offer.disabled', { reason: why, details: { previous: root.status }, traceparent });
const after = reads.get(root.id);
indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'disabled' }, traceparent });
indexing.reindex(after);
@@@ -315,6 +333,7 @@ function createOffers(deps) {
db.prepare(`UPDATE deal_offers SET status = 'active', disabled_at = NULL, disabled_reason = NULL, disabled_by = NULL,
expired_at = NULL, expired_reason = NULL, expires_at = ?, updated_at = ? WHERE id = ?`).run(keepExpiry, now, root.id);
logAction('enable', { offerId: root.id, actor: actorOf(viewer), reason: text(reason, { max: 300 }), before: { status: root.status, disabled_reason: root.disabled_reason, expired_reason: root.expired_reason, expires_at: root.expires_at }, after: { status: 'active', expires_at: keepExpiry } });
++ moderated(viewer, root, 'offer.enabled', { reason: text(reason, { max: 300 }) || null, details: { previous: root.status }, traceparent });
const after = reads.get(root.id);
indexing.emitOffer('deals.offer.updated', after, { actor: actorOf(viewer), extra: { changed: ['status'], moderation: 'enabled' }, traceparent });
indexing.reindex(after);
@@@ -335,6 +354,7 @@ function createOffers(deps) {
const now = store.now();
db.prepare("UPDATE deal_offers SET review_state = 'reviewed', reviewed_by = ?, reviewed_at = ?, updated_at = ? WHERE id = ?").run(reviewer, now, now, root.id);
logAction('review', { offerId: root.id, actor: reviewer, reason: text(note, { max: 300 }), before: { review_state: 'pending' }, after: { review_state: 'reviewed' } });
++ moderated(viewer, root, 'offer.reviewed', { reason: text(note, { max: 300 }) || null, traceparent });
const after = reads.get(root.id);
indexing.emitOffer('deals.offer.updated', after, { actor: reviewer, extra: { changed: ['review_state'] }, traceparent });
indexing.reindex(after);
@@@ -355,6 +375,7 @@ function createOffers(deps) {
db.prepare('UPDATE deal_offers SET merged_into = ?, merged_at = ?, merged_by = ?, updated_at = ? WHERE id = ?').run(target.id, now, actor, now, dup.id);
const after = { target: tallies(target) };
logAction('merge', { offerId: dup.id, targetId: target.id, actor, reason: text(reason, { max: 300 }), before, after });
++ moderated(viewer, dup, 'offer.merged', { reason: text(reason, { max: 300 }) || null, details: { into: target.id }, traceparent });
hotness.snapshot(target.id, 'merge');
indexing.emitOffer('deals.offer.updated', target, { actor, extra: { changed: ['merged'], merged_offer_id: dup.id }, traceparent });
indexing.indexOffer(reads.get(dup.id));
@@@ -377,6 +398,7 @@ function createOffers(deps) {
const restored = reads.get(dup.id);
const after = { target: tallies(parentRoot), duplicate: tallies(restored) };
logAction('unmerge', { offerId: dup.id, targetId: parentRoot.id, actor, reason: text(reason, { max: 300 }), before, after });
++ moderated(viewer, dup, 'offer.unmerged', { reason: text(reason, { max: 300 }) || null, details: { from: parentRoot.id }, traceparent });
hotness.snapshot(parentRoot.id, 'unmerge');
hotness.snapshot(restored.id, 'unmerge');
indexing.emitOffer('deals.offer.updated', parentRoot, { actor, extra: { changed: ['unmerged'], unmerged_offer_id: dup.id }, traceparent });
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex 86c4075..294b0e1 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -8,6 +8,10 @@
* deals.vote.changed a person's effective vote changed (payload: counts, not identities)
* deals.watch.matched a watch matched a new observation (internal; Network's
* notification consumer is future work; Deals never emails)
++ * deals.moderation.action a moderator acted on someone else's offer or a report: edit,
++ * expire, disable, enable, review, merge, unmerge, flag
++ * resolution (common.moderation-action@1, ADR-022), for
++ * Network's moderation audit log
* deals.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks
* indexEvent), consumed by Search's '*.index_document.*'
* subscription
@@@ -52,8 +56,24 @@ function createDealsOutbox({ db, config, fetchImpl, now, log = console }) {
return outbox.enqueue(envelope, { traceparent });
}
++ /**
++ * deals.moderation.action, inside the caller's transaction. actorSubject: the moderator (null for
++ * a service acting without a person); target: { type, id, owner_subject? }. Never the content.
++ */
++ function moderationAction({ action, target, actorSubject, reason = null, details = {} }, { traceparent } = {}) {
++ const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: target.owner_subject || null };
++ return emit({
++ event_type: 'deals.moderation.action',
++ actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'deals' },
++ 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 0f55b9d..5041bbd 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -148,7 +148,7 @@ function createApi(ctx) {
}));
router.post('/flags/:id/resolve', guard('deals.offer.moderate'), jsonBody, run((req) => {
-- const r = flags.resolve(req.viewer, req.params.id, req.body || {});
++ const r = flags.resolve(req.viewer, req.params.id, req.body || {}, opts(req));
return { flag: flags.dto(r.flag), changed: r.changed };
}));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex ea3b2b5..37ee25e 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -400,7 +400,7 @@ function createPages(ctx) {
await act(req, res, '/mod', () => {
const status = { resolve: 'resolved', dismiss: 'dismissed' }[req.params.action];
if (!status) throw new ApiError(404, 'route.not_found', 'No such action');
-- flags.resolve(req.viewer, req.params.id, { status, resolution: req.body.resolution });
++ flags.resolve(req.viewer, req.params.id, { status, resolution: req.body.resolution }, { traceparent: req.ov && req.ov.traceparent });
return '/mod';
});
}));
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
nnew file mode 100644
iindex 0000000..9d6b413
---- /dev/null
++++ b/test/moderation-events.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * deals.moderation.action (ADR-022): a moderator acting on someone else's offer or on a report goes
++ * to Network's moderation audit log, from the outbox in the same transaction; the submitter acting
++ * on their own offer reports nothing.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const poster = t.network.addUser('poster');
++ const reporter = t.network.addUser('reporter');
++ const moderation = () => t.ctx.store.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all()
++ .map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === 'deals.moderation.action');
++ const valid = (e) => {
++ assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
++ const r = contracts.validate('deals.moderation.action@1', e.payload);
++ assert.strictEqual(r.valid, true, JSON.stringify(r.errors));
++ assert.strictEqual(e.source, 'deals');
++ assert.strictEqual(e.visibility, 'internal');
++ assert.deepStrictEqual(e.actor, { type: 'user', id: t.mod.subject });
++ assert.strictEqual(e.payload.actor_subject, t.mod.subject);
++ };
++
++ await check('the submitter editing and expiring their own offer reports nothing', async () => {
++ const o = await t.submit(poster, { url: 'https://s.example/own', title: 'My own offer', price: '5', currency: 'USD' });
++ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: poster, json: { title: 'My own offer, edited' } })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/expire`, { as: poster, json: {} })).status, 200);
++ assert.strictEqual(moderation().length, 0);
++ });
++
++ await check('a moderator disabling someone else\'s offer: exactly one valid event', async () => {
++ const o = await t.submit(poster, { url: 'https://s.example/spam', title: 'Spammy offer', price: '1', currency: 'USD' });
++ const r = await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Referral spam' } });
++ assert.strictEqual(r.status, 200, r.text);
++ const ev = moderation();
++ assert.strictEqual(ev.length, 1);
++ valid(ev[0]);
++ assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `offer:${o.id}` });
++ assert.strictEqual(ev[0].payload.action, 'offer.disabled');
++ assert.deepStrictEqual(ev[0].payload.target, { type: 'offer', id: o.id, owner_subject: poster.subject });
++ assert.strictEqual(ev[0].payload.reason, 'Referral spam');
++ assert.deepStrictEqual(ev[0].payload.details, { previous: 'active' });
++ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}/disable`, { as: t.mod, json: { reason: 'Again' } })).status, 200);
++ assert.strictEqual(moderation().length, 1, 'already disabled: nothing changed, nothing reported');
++ });
++
++ await check('a moderator editing someone else\'s offer: one offer.edited naming the fields, never the text', async () => {
++ const o = await t.submit(poster, { url: 'https://s.example/typo', title: 'Offer with a tpyo', price: '2', currency: 'USD' });
++ const n = moderation().length;
++ assert.strictEqual((await t.get(`/api/v1/offers/${o.id}`, { method: 'PATCH', as: t.mod, json: { title: 'Offer with a typo' } })).status, 200);
++ const ev = moderation();
++ assert.strictEqual(ev.length, n + 1);
++ valid(ev[n]);
++ assert.strictEqual(ev[n].payload.action, 'offer.edited');
++ assert.deepStrictEqual(ev[n].payload.details, { changed: ['title'] });
++ assert.ok(!JSON.stringify(ev[n]).includes('Offer with a typo'));
++ });
++
++ await check('a moderator dismissing a person\'s report: one flag.dismissed with the reporter as owner', async () => {
++ const o = await t.submit(poster, { url: 'https://s.example/reported', title: 'Reported offer', price: '3', currency: 'USD' });
++ assert.ok((await t.get(`/api/v1/offers/${o.id}/flags`, { as: reporter, json: { kind: 'expired', reason: 'Sold out' } })).status < 300);
++ const f = t.ctx.store.db.prepare('SELECT * FROM deal_flags WHERE offer_id = ?').get(o.id);
++ const n = moderation().length;
++ const r = await t.get(`/api/v1/flags/${f.id}/resolve`, { as: t.mod, json: { status: 'dismissed', resolution: 'Still in stock' } });
++ assert.strictEqual(r.status, 200, r.text);
++ const ev = moderation();
++ assert.strictEqual(ev.length, n + 1);
++ valid(ev[n]);
++ assert.strictEqual(ev[n].payload.action, 'flag.dismissed');
++ assert.deepStrictEqual(ev[n].payload.target, { type: 'flag', id: f.id, owner_subject: reporter.subject });
++ assert.strictEqual(ev[n].payload.reason, 'Still in stock');
++ assert.strictEqual(ev[n].payload.details.offer_id, o.id);
++ });
++
++ await t.close();
++ done();
++})();