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
Changes
@@@ -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@@@ -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@@@ -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",@@@ -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"},@@@ -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 });@@@ -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')) {@@@ -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);}@@@ -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(); },@@@ -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);});nnew file mode 100644@@@ -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();++})();
@@ -16,7 +16,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
@@ -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.
- **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.
- **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).
- **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.
- The generated reference (rendered from the pinned packages at boot; nothing hand-written that can drift) and the portal's pages.
## Does not own
## Does not own
@@ -35,6 +35,7 @@ From mildest to most serious. Moderators pick the mildest step that fixes the pr
@@ -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.
- 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.
- 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.
- Reports and the identity of the people who report stay confidential.
## Appeals
## Appeals
@@ -16,7 +16,7 @@
@@ -16,7 +16,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.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-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"
},
},
@@ -1023,9 +1023,9 @@
@@ -1023,9 +1023,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,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.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-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"
},
},
@@ -54,7 +54,7 @@ function createApp(opts = {}) {
@@ -54,7 +54,7 @@ function createApp(opts = {}) {
const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });
const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });
const network = createNetworkClient({ config, fetchImpl, log });
const network = createNetworkClient({ config, fetchImpl, log });
const outbox = createCodesOutbox({ db: store.db, config, fetchImpl, now: store.now, 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 releases = createReleases({ store, outbox, trust });
const playground = createPlayground({ store, config, network, keys, fetchImpl, log });
const playground = createPlayground({ store, config, network, keys, fetchImpl, log });
@@ -168,12 +168,22 @@ function createReleases({ store, outbox, trust }) {
@@ -168,12 +168,22 @@ function createReleases({ store, outbox, trust }) {
const why = String(reason || '').trim().slice(0, 500);
const why = String(reason || '').trim().slice(0, 500);
if (!why) fail(422, 'release.invalid', 'say why the release is revoked');
if (!why) fail(422, 'release.invalid', 'say why the release is revoked');
const wasPublic = r.status === 'published' || r.status === 'deprecated';
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;
let env = null;
db.transaction(() => {
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);
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 });
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.
// 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 (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 };
return { release: get(r.id), event_id: env ? env.event_id : null };
}
}
@@ -189,7 +199,7 @@ function createReleases({ store, outbox, trust }) {
@@ -189,7 +199,7 @@ function createReleases({ store, outbox, trust }) {
* revocation (compatibility policy), so each emits codes.app.revoked as any revocation does.
* 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).
* 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 rows = db.prepare("SELECT * FROM releases WHERE project_id = ? AND status != 'revoked' ORDER BY created_at, id").all(String(projectId));
const out = { revoked: [], deletedDrafts: [] };
const out = { revoked: [], deletedDrafts: [] };
for (const r of rows.filter((x) => x.status !== 'draft')) {
for (const r of rows.filter((x) => x.status !== 'draft')) {
@@ -6,12 +6,13 @@
@@ -6,12 +6,13 @@
* METADATA ONLY. "A tier changes defaults and discovery, but never the grant check." Nothing in
* 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
* 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
* 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 TIERS = ['unreviewed', 'reviewed', 'first-party'];
const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
const APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
-function createTrust({ store }) {
+function createTrust({ store, outbox = null }) {
const { db } = store;
const { db } = store;
function get(appId) {
function get(appId) {
@@ -33,6 +34,13 @@ function createTrust({ store }) {
@@ -33,6 +34,13 @@ function createTrust({ store }) {
.run(appId, tier, clean, actor.label, t);
.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 (?, ?, ?, ?, ?, ?)')
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);
.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);
return get(appId);
}
}
@@ -6,6 +6,9 @@
@@ -6,6 +6,9 @@
* codes.app.published a release became public subject { type: 'app', id: app_… }
* 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.deprecated a public release was deprecated (payload: release id, version, kind,
* codes.app.revoked a public release was revoked environment, trust tier, reason…)
* 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
* 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,
* 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');
@@ -16,7 +19,7 @@ const { createClient } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createEventsClient, createOutbox } = require('openvibe-sdk/events');
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 }) {
function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
const enabled = Boolean(config.events.url && config.oauth.clientSecret);
@@ -50,8 +53,24 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
@@ -50,8 +53,24 @@ function createCodesOutbox({ db, config, fetchImpl, now, log = console }) {
return outbox.enqueue(envelope, { traceparent });
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 {
return {
emit,
emit,
+ moderationAction,
outbox,
outbox,
enabled,
enabled,
start() { if (enabled) outbox.start(); },
start() { if (enabled) outbox.start(); },
@@ -19,11 +19,11 @@ const sdkPkg = require('openvibe-sdk/package.json');
@@ -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 () => {
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];
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-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
assert.strictEqual(sdkPkg.version, '0.11.0');
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.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.resolve('codes.app-manifest@1'));
assert.ok(contracts.resolve('codes.app-manifest@1'));
});
});
@@ -33,13 +33,13 @@ const sdkPkg = require('openvibe-sdk/package.json');
@@ -33,13 +33,13 @@ const sdkPkg = require('openvibe-sdk/package.json');
const r = await t.get(p);
const r = await t.get(p);
assert.strictEqual(r.status, 200, 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 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`);
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();
const v = (await t.get('/api/v1/docs/versions')).json();
assert.strictEqual(v.contracts, contractsVersion);
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);
assert.strictEqual(v.sdk, sdkPkg.version);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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();
+})();