openvibers/openvibe.codes Commits

Moderation audit (ADR-022, WS-D task 1): codes.moderation.action from the outbox in the same transaction: release.revoked when Codes staff revoke a release their project role would not let them manage (also a staff project deletion), app.trust_changed for every trust tier set (from, to, the note as reason); a member revoking their own release reports nothing. Pins: openvibe-contracts 0.51.0 → 0.53.0

OP OpenVibers committed 26 Sept 2026 · 28dbf08b9b · 10 files, +132 −16


Changes

ddiff --git a/README.md b/README.md
iindex 31fa7b9..5b9a956 100644
---- a/README.md
++++ b/README.md
@@@ -16,7 +16,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
- **Release metadata** keyed to Network app ids: validated app and mod manifests, releases (`draft → published → deprecated → revoked`), an append-only release log.
- **Trust tiers** per ADR-013 (`unreviewed`, `reviewed`, `first-party`) — **metadata only**: a tier changes defaults and discovery, never a grant check. Databases written with the earlier four names are migrated at boot (untrusted→unreviewed, verified/trusted→reviewed, platform-maintained→first-party), idempotently.
- **Playground run logs** (who ran what, outcome, stage, problem code; never a credential).
--- The events `codes.app.published`, `codes.app.deprecated`, `codes.app.revoked` (through the openvibe-sdk transactional outbox).
++- The events `codes.app.published`, `codes.app.deprecated`, `codes.app.revoked`, and `codes.moderation.action` for staff revocations and trust tier changes (the network's moderation audit log), through the openvibe-sdk transactional outbox.
- The generated reference (rendered from the pinned packages at boot; nothing hand-written that can drift) and the portal's pages.
## Does not own
ddiff --git a/docs/governance/moderation.md b/docs/governance/moderation.md
iindex 65d7113..67e890c 100644
---- a/docs/governance/moderation.md
++++ b/docs/governance/moderation.md
@@@ -35,6 +35,7 @@ From mildest to most serious. Moderators pick the mildest step that fixes the pr
- Revocation and deprecation reasons for releases are public on the release page, and changes to trust tiers are listed in the app's public trust history.
- Network keeps an append-only audit log of changes to projects, apps, credentials and grants; project admins can read it for their own project, and can download it with the project export.
++- A release revoked by Codes staff who could not manage it as project members, and every trust tier change, are also reported as `codes.moderation.action` to the network's moderation audit log, which OpenVibe staff can read.
- Reports and the identity of the people who report stay confidential.
## Appeals
ddiff --git a/package-lock.json b/package-lock.json
iindex 573af11..2889104 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,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.51.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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"
},
@@@ -1023,9 +1023,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.51.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.51.0",
-- "integrity": "sha512-XH+WF0iRJcThoNYE3Ys4oL52BzFwnyPX4Bzsq3MqJvDqdMWTQydvOTTHxdSbbtsTo7CIOZk3QNVy1HovyTId0A==",
++ "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 b61aaaf..0dfd9e8 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,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.51.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.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 64f526c..76c0041 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -54,7 +54,7 @@ function createApp(opts = {}) {
const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });
const network = createNetworkClient({ config, fetchImpl, log });
const outbox = createCodesOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
-- const trust = createTrust({ store });
++ const trust = createTrust({ store, outbox });
const releases = createReleases({ store, outbox, trust });
const playground = createPlayground({ store, config, network, keys, fetchImpl, log });
ddiff --git a/server/domain/releases.js b/server/domain/releases.js
iindex dbb2382..7d8cd9e 100644
---- a/server/domain/releases.js
++++ b/server/domain/releases.js
@@@ -168,12 +168,22 @@ function createReleases({ store, outbox, trust }) {
const why = String(reason || '').trim().slice(0, 500);
if (!why) fail(422, 'release.invalid', 'say why the release is revoked');
const wasPublic = r.status === 'published' || r.status === 'deprecated';
++ // Staff revoking a release their own project role would not let them manage is moderation
++ // (ADR-022): it goes to Network's audit log too. A member revoking their own release is not.
++ const byStaff = actor.kind === 'user' && actor.staff && !(actor.role && RANK[actor.role] >= RANK[manageRole(r.environment)]);
let env = null;
db.transaction(() => {
db.prepare("UPDATE releases SET status = 'revoked', revoked_by = ?, revoked_at = ?, revocation_reason = ? WHERE id = ?").run(actor.label, store.iso(), why, r.id);
writeLog(r.id, 'revoked', actor.label, { reason: why, was: r.status });
// A draft was never public: nobody downstream knows it, so there is nothing to announce.
if (wasPublic) env = event('codes.app.revoked', r, actor, { reason: why });
++ if (byStaff) {
++ const [ckind, cid] = String(r.created_by).split(':');
++ outbox.moderationAction({
++ action: 'release.revoked', target: { type: 'release', id: r.id, owner_subject: ckind === 'user' ? cid : null },
++ actorSubject: actor.subject, reason: why, details: { app_id: r.app_id, project_id: r.project_id, version: r.version, was: r.status },
++ }, { traceparent: actor.traceparent });
++ }
})();
return { release: get(r.id), event_id: env ? env.event_id : null };
}
@@@ -189,7 +199,7 @@ function createReleases({ store, outbox, trust }) {
* revocation (compatibility policy), so each emits codes.app.revoked as any revocation does.
* The caller has checked that the actor owns the project (or is staff).
*/
-- function retireProject({ actor, projectId, reason = 'project deleted by its owner' }) {
++ function retireProject({ actor, projectId, reason = actor.staff && actor.role !== 'owner' ? 'project deleted by OpenVibe staff' : 'project deleted by its owner' }) {
const rows = db.prepare("SELECT * FROM releases WHERE project_id = ? AND status != 'revoked' ORDER BY created_at, id").all(String(projectId));
const out = { revoked: [], deletedDrafts: [] };
for (const r of rows.filter((x) => x.status !== 'draft')) {
ddiff --git a/server/domain/trust.js b/server/domain/trust.js
iindex b47fd76..1ac6aca 100644
---- a/server/domain/trust.js
++++ b/server/domain/trust.js
@@@ -6,12 +6,13 @@
* METADATA ONLY. "A tier changes defaults and discovery, but never the grant check." Nothing in
* Codes (or anywhere) reads a tier to allow an action; the tier is shown next to releases and
* carried in codes.app.* event payloads. Every app starts unreviewed; Codes staff change it, with
-- * a note, and every change is kept. Older names are migrated at boot (server/db.js).
++ * a note, and every change is kept and reported to Network's moderation audit log
++ * (codes.moderation.action app.trust_changed, ADR-022). Older names are migrated at boot (server/db.js).
*/
const TIERS = ['unreviewed', 'reviewed', 'first-party'];
const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
--function createTrust({ store }) {
++function createTrust({ store, outbox = null }) {
const { db } = store;
function get(appId) {
@@@ -33,6 +34,13 @@ function createTrust({ store }) {
.run(appId, tier, clean, actor.label, t);
db.prepare('INSERT INTO trust_history (app_id, from_tier, to_tier, note, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(appId, before, tier, clean, actor.label, t);
++ if (outbox) {
++ const [kind, id] = String(actor.label).split(':');
++ outbox.moderationAction({
++ action: 'app.trust_changed', target: { type: 'app', id: appId }, actorSubject: kind === 'user' ? id : null,
++ reason: clean, details: { from: before, to: tier },
++ }, { traceparent: actor.traceparent });
++ }
})();
return get(appId);
}
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex e6a72e1..30f233c 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -6,6 +6,9 @@
* codes.app.published a release became public subject { type: 'app', id: app_… }
* codes.app.deprecated a public release was deprecated (payload: release id, version, kind,
* codes.app.revoked a public release was revoked environment, trust tier, reason…)
++ * codes.moderation.action Codes staff acted on someone else's app: revoked a release they
++ * could not manage as a member, or set a trust tier (ADR-022, for
++ * Network's moderation audit log; subject { type: 'moderation_action' })
*
* 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 Codes' OWN service token (events.event.publish,
@@@ -16,7 +19,7 @@ const { createClient } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
--const EVENT_TYPES = ['codes.app.published', 'codes.app.deprecated', 'codes.app.revoked'];
++const EVENT_TYPES = ['codes.app.published', 'codes.app.deprecated', 'codes.app.revoked', 'codes.moderation.action'];
function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
@@@ -50,8 +53,24 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
return outbox.enqueue(envelope, { traceparent });
}
++ /**
++ * codes.moderation.action (common.moderation-action@1), inside the caller's transaction. actor:
++ * the staff member's subject; 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: 'codes.moderation.action',
++ actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: 'codes' },
++ 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/test/docs.test.js b/test/docs.test.js
iindex 7579d6e..3f616e4 100644
---- a/test/docs.test.js
++++ b/test/docs.test.js
@@@ -19,11 +19,11 @@ const sdkPkg = require('openvibe-sdk/package.json');
await check('package.json pins the contracts, SDK and shared tags that are installed', async () => {
const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/(v[0-9.]+)$/) || [])[1];
-- assert.strictEqual(tag('openvibe-contracts'), 'v0.51.0');
++ assert.strictEqual(tag('openvibe-contracts'), 'v0.53.0');
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
assert.strictEqual(sdkPkg.version, '0.11.0');
-- // The contracts tag v0.51.0 carries the released codes manifest and codes.app-manifest@1.
++ // The contracts tag v0.53.0 carries the released codes manifest and codes.app-manifest@1.
assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.resolve('codes.app-manifest@1'));
});
@@@ -33,13 +33,13 @@ const sdkPkg = require('openvibe-sdk/package.json');
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the installed contracts version`);
-- assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.51.0'), `${p} links the pinned tag`);
-- if (contractsVersion !== '0.51.0') assert.ok(r.text.includes('(tag v0.51.0)'), `${p} says the tag differs from the package version`);
++ assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.53.0'), `${p} links the pinned tag`);
++ if (contractsVersion !== '0.53.0') assert.ok(r.text.includes('(tag v0.53.0)'), `${p} says the tag differs from the package version`);
assert.ok(r.text.includes(`openvibe-sdk v${sdkPkg.version}`), `${p} names the SDK version`);
}
const v = (await t.get('/api/v1/docs/versions')).json();
assert.strictEqual(v.contracts, contractsVersion);
-- assert.strictEqual(v.contracts_tag, 'v0.51.0');
++ assert.strictEqual(v.contracts_tag, 'v0.53.0');
assert.strictEqual(v.sdk, sdkPkg.version);
});
ddiff --git a/test/moderation-events.test.js b/test/moderation-events.test.js
nnew file mode 100644
iindex 0000000..3e6c0ef
---- /dev/null
++++ b/test/moderation-events.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++/**
++ * codes.moderation.action (ADR-022): Codes staff acting on someone else's app goes to Network's
++ * moderation audit log, in the transaction that makes the change.
++ * - staff revoking a release of a project they cannot manage: exactly one event (release.revoked)
++ * - a member revoking their own release: none (it is not moderation)
++ * - staff setting a trust tier: exactly one event (app.trust_changed, from and to); refused: none
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const manifests = require('../server/domain/manifests');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const owner = t.network.addUser('mona');
++ const staff = t.network.addUser('root', { role: 'admin' });
++ const projectId = await t.project(owner, 'Moderated');
++ const app = await t.app(owner, projectId, { name: 'Moderated' });
++ const base = `/projects/${projectId}/apps/${app.id}`;
++ const manifest = (version) => JSON.stringify({ ...manifests.template('app', { appId: app.id, projectId, environment: 'sandbox', name: 'Moderated' }), version });
++ 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 === 'codes.moderation.action');
++ const published = async (version) => {
++ const r = await t.get(`${base}/releases`, { as: owner, form: { kind: 'app', manifest: manifest(version), intent: 'create' } });
++ const id = r.headers.get('location').split('/').pop();
++ assert.strictEqual((await t.get(`/releases/${id}/publish`, { as: owner, form: {} })).status, 303);
++ return id;
++ };
++ const valid = (e) => {
++ assert.strictEqual(contracts.validate('events.event-envelope@1', e).valid, true, JSON.stringify(e));
++ const r = contracts.validate('codes.moderation.action@1', e.payload);
++ assert.strictEqual(r.valid, true, JSON.stringify(r.errors));
++ assert.strictEqual(e.source, 'codes');
++ assert.strictEqual(e.visibility, 'internal');
++ assert.deepStrictEqual(e.actor, { type: 'user', id: staff.subject });
++ };
++
++ await check('the owner revoking their own release is not moderation: no event', async () => {
++ const id = await published('1.0.0');
++ const r = await t.get(`/releases/${id}/revoke`, { as: owner, form: { reason: 'Mine to pull', confirm: '1' } });
++ assert.strictEqual(r.status, 303);
++ assert.strictEqual(moderation().length, 0);
++ });
++
++ await check('staff revoking someone else\'s release: exactly one valid codes.moderation.action', async () => {
++ const id = await published('1.1.0');
++ const r = await t.get(`/releases/${id}/revoke`, { as: staff, form: { reason: 'Malware in the bundle', confirm: '1' } });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ const ev = moderation();
++ assert.strictEqual(ev.length, 1);
++ valid(ev[0]);
++ assert.deepStrictEqual(ev[0].subject, { type: 'moderation_action', id: `release:${id}` });
++ assert.strictEqual(ev[0].payload.action, 'release.revoked');
++ assert.deepStrictEqual(ev[0].payload.target, { type: 'release', id, owner_subject: owner.subject });
++ assert.strictEqual(ev[0].payload.actor_subject, staff.subject);
++ assert.strictEqual(ev[0].payload.reason, 'Malware in the bundle');
++ assert.strictEqual(ev[0].payload.details.was, 'published');
++ });
++
++ await check('staff setting a trust tier: exactly one event; a refused change: none', async () => {
++ assert.strictEqual((await t.get('/staff/trust', { as: owner, form: { app_id: app.id, tier: 'reviewed', note: 'me' } })).status, 403);
++ assert.strictEqual(moderation().length, 1);
++ const r = await t.get('/staff/trust', { as: staff, form: { app_id: app.id, tier: 'reviewed', note: 'Read the source' } });
++ assert.strictEqual(r.status, 303);
++ const ev = moderation();
++ assert.strictEqual(ev.length, 2);
++ const e = ev[1];
++ valid(e);
++ assert.strictEqual(e.payload.action, 'app.trust_changed');
++ assert.deepStrictEqual(e.payload.target, { type: 'app', id: app.id, owner_subject: null });
++ assert.strictEqual(e.payload.reason, 'Read the source');
++ assert.deepStrictEqual(e.payload.details, { from: 'unreviewed', to: 'reviewed' });
++ });
++
++ await t.close();
++ done();
++})();