openvibers/openvibe.network Commits

L24 C1: the consent registry — data and AI by consent (#448)

OP OpenVibers committed 13 days ago · 02dad3965e · 17 files, +1150 −11

* L24 C1: the consent registry — data and AI by consent

Every use of a person's content or likeness for AI or data is off until
that person turns it on. Adds consents and consent_events (expand only),
the four purposes with the words the account page shows, the person's
PUT/GET/DELETE API behind the session, the service check behind
network.consent.read, network.consent.changed after each commit, the
expiry sweep, account export/deletion/merge, and "Your data choices" on
the account page. Pins openvibe-contracts v0.256.0.

* Tests: account for the consent registry

The migration list gains 0033_consents, the account-hub module list gains
consent.js, and the service-capability assertions gain network.consent.read
for live, vision and cam (the services that ask the check).

* Consent check: a recognition names its feature

Review of #448. A face.recognize check with no feature answered true for anyone with an active face choice, even one
whose feature list was still empty ("none yet"). Recognition always says which feature is about to run (verify, find,
cast or library), so the check now refuses a face.recognize request without one (422 consent.feature_required), the
same way app.data needs its client. test/consent.test.js covers it.

* Your data choices: the current choice is marked, kinds read as names, and the page says we never sell your data

Review of #448. On and Off stay equal in size and side by side; the one that holds now is outlined in the accent
(aria-pressed). The kinds read Streams, VODs, Clips… instead of lower-case ids, and the intro ends with the promise
that stays true: "We never sell your data."

Changes

ddiff --git a/migrations/0033_consents.sql b/migrations/0033_consents.sql
nnew file mode 100644
iindex 00000000..d8ccbf08
---- /dev/null
++++ b/migrations/0033_consents.sql
@@@ -0,0 +1,61 @@
++-- phase: expand
++-- The consent registry (L24, data and AI by consent, 2026-10-31; Contracts 0.256.0 network.consent@1).
++--
++-- Nothing a person makes is off-limits to AI or data use by design: each use is off until that person turns it on
++-- here. One row is one choice for one subject, purpose and client; a choice changed later (other kinds, another
++-- licence, another feature set) is a NEW row and the old one is revoked, never edited. The record is the proof of
++-- what was allowed, under which wording and when, so revoked rows are kept and never deleted.
++--
++-- `consent_events` is append-only: a trigger refuses UPDATE and DELETE. Account deletion revokes the active rows
++-- (events by 'deletion') and keeps them; they hold no content.
++--
++-- Expand only: an older release never reads these tables, and nothing here changes an existing one.
++CREATE TABLE IF NOT EXISTS consents (
++ id text PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ purpose text NOT NULL CHECK (purpose IN ('ai.train', 'data.public', 'face.recognize', 'app.data')),
++ kinds text[] NOT NULL DEFAULT '{}',
++ client text,
++ options jsonb NOT NULL DEFAULT '{}',
++ text_version text NOT NULL,
++ source text NOT NULL CHECK (source IN ('account', 'oauth', 'api', 'import:vision')),
++ granted_at timestamptz NOT NULL,
++ revoked_at timestamptz,
++ revoked_by text,
++ expires_at timestamptz,
++ CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CHECK (kinds <@ ARRAY['streams', 'vods', 'clips', 'voice', 'chat', 'posts', 'pictures', 'code']::text[]),
++ CHECK (purpose <> 'face.recognize' OR kinds = '{}'),
++ CHECK (client IS NULL OR client ~ '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$'),
++ CHECK (revoked_by IS NULL OR revoked_by IN ('person', 'app_revoked', 'merge', 'deletion', 'expired', 'import')),
++ CHECK (text_version ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}\.[0-9]{1,3}$'),
++ CHECK (revoked_at IS NULL OR revoked_at >= granted_at)
++);
++-- One active choice per (subject, purpose, client): NULL (OpenVibe itself) and '' are the same slot.
++CREATE UNIQUE INDEX IF NOT EXISTS consents_active
++ ON consents (subject, purpose, COALESCE(client, ''))
++ WHERE revoked_at IS NULL;
++CREATE INDEX IF NOT EXISTS consents_purpose_subject ON consents (purpose, subject);
++
++CREATE TABLE IF NOT EXISTS consent_events (
++ id bigserial PRIMARY KEY,
++ consent_id text NOT NULL,
++ subject text COLLATE "C" NOT NULL,
++ purpose text NOT NULL CHECK (purpose IN ('ai.train', 'data.public', 'face.recognize', 'app.data')),
++ client text,
++ action text NOT NULL CHECK (action IN ('granted', 'revoked', 'expired')),
++ "by" text NOT NULL CHECK ("by" IN ('person', 'app_revoked', 'merge', 'deletion', 'import')),
++ text_version text NOT NULL,
++ at timestamptz NOT NULL
++);
++CREATE INDEX IF NOT EXISTS consent_events_subject ON consent_events (subject, id DESC);
++CREATE INDEX IF NOT EXISTS consent_events_consent ON consent_events (consent_id, id);
++
++-- Append-only: nothing updates or deletes a consent event, ever. The history is the proof of the choices.
++CREATE OR REPLACE FUNCTION consent_events_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ RAISE EXCEPTION 'consent_events is append-only';
++END $$;
++DROP TRIGGER IF EXISTS consent_events_no_change ON consent_events;
++CREATE TRIGGER consent_events_no_change BEFORE UPDATE OR DELETE ON consent_events
++ FOR EACH ROW EXECUTE FUNCTION consent_events_append_only();
ddiff --git a/package-lock.json b/package-lock.json
iindex a7214317..5e528324 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.251.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.256.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.57.1",
"pg": "^8.23.0",
@@@ -1595,9 +1595,8 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.251.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.251.0",
-- "integrity": "sha512-aDTGrk/0FujqQT1IeL0hmhNnK4azNj579nn1LCm2cdt38OWSdNYyvazb/+rAHM7erCXcJrxEzBdlHd37HK+vJw==",
++ "version": "0.256.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.256.0",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex a68b5873..0b84d545 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.251.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.256.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.57.1",
"pg": "^8.23.0",
ddiff --git a/public/css/my.css b/public/css/my.css
iindex 508666b9..c784077d 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -264,3 +264,34 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
.recent-tool-star { border: 0; background: none; color: var(--text-secondary); cursor: pointer; padding: 6px; font-size: 12px; border-radius: 999px; }
.recent-tool-star[aria-pressed="true"] { color: var(--accent-light); }
.recent-tool-star:hover, .recent-tool-star:focus-visible { color: var(--accent-light); }
++
++/* Your data choices (L24): kit fold rows, one per purpose. Phone first: one column of equal-weight buttons, never
++ sideways. The kit's own /shared/kit.css draws the fold; these rules only lay out the choice inside it. */
++.consent-fold { margin: 0 0 10px; }
++.consent-fold .ovk-fold-body p { margin: 0 0 8px; }
++.consent-fold .consent-off { color: var(--text-secondary); }
++.consent-label { display: block; margin: 12px 0 6px; font-size: 12px; font-weight: 700; color: var(--text-secondary); }
++.consent-actions .btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
++.consent-kind-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 12px; }
++.consent-kind { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; font-size: 13px; color: var(--text-secondary); cursor: pointer; }
++.consent-kind input, .consent-adult input { width: 18px; height: 18px; flex: none; accent-color: var(--accent); }
++.consent-hint { margin: 4px 0 0; font-size: 12px; }
++.consent-note { margin: 10px 0; font-size: 12px; }
++.consent-licence-field { display: flex; flex-direction: column; gap: 6px; margin: 12px 0 0; font-size: 12px; font-weight: 700; color: var(--text-secondary); }
++.consent-licence-field select { min-height: 40px; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-input); color: var(--text); font: inherit; font-weight: 500; }
++.consent-adult { display: flex; align-items: center; gap: 8px; min-height: 44px; margin: 12px 0 0; font-size: 13px; font-weight: 650; color: var(--text); cursor: pointer; }
++.consent-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 14px 0 10px; }
++.consent-actions .btn { width: 100%; min-height: 44px; justify-content: center; margin: 0; }
++.consent-history { list-style: none; margin: 10px 0 0; padding: 0; font-size: 12px; color: var(--text-secondary); }
++.consent-history li { padding: 5px 0; border-top: 1px solid var(--border); }
++.consent-history li:first-child { border-top: 0; }
++.consent-empty { margin: 10px 0 0; font-size: 12px; }
++.consent-status { min-height: 1.2em; margin: 8px 0 0; font-size: 12px; }
++.consent-status.consent-error { color: #ef4444; }
++@media (min-width: 600px) {
++ .consent-kind-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
++ .consent-actions { grid-template-columns: 1fr 1fr; }
++ .consent-licence-field { flex-direction: row; align-items: center; }
++ .consent-licence-field select { flex: 1; min-width: 0; }
++}
++
ddiff --git a/public/js/my/consent.js b/public/js/my/consent.js
nnew file mode 100644
iindex 00000000..462e232e
---- /dev/null
++++ b/public/js/my/consent.js
@@@ -0,0 +1,144 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my): "Your data choices" (L24, 2026-10-31).
++ One kit fold row per purpose (the words from network.me-consent-result@1), off until the person turns it on.
++ Nothing is pre-ticked; On and Off are equal-weight buttons; the 18+ confirmation sits inside the row, never in a
++ modal. Classic script, global scope: it uses my/core.js's apiFetch/API and my/boot.js starts the page.
++ ═══════════════════════════════════════════════════════════════ */
++let consentDoc = null;
++
++const CONSENT_KIND_LABEL = {
++ streams: 'Streams', vods: 'VODs', clips: 'Clips', voice: 'Voice', chat: 'Chat',
++ posts: 'Posts', pictures: 'Pictures', code: 'Code',
++};
++const CONSENT_BY_LABEL = {
++ person: 'you', app_revoked: 'the app revoked', merge: 'an account merge', deletion: 'account deletion', import: 'an import',
++};
++
++function consentEsc(value) {
++ return String(value === null || value === undefined ? '' : value)
++ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
++}
++
++function consentWhen(at) {
++ const d = new Date(at);
++ return Number.isNaN(d.getTime()) ? String(at) : d.toLocaleString(undefined, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
++}
++
++/** The active rows of one purpose, newest first. */
++function consentActive(purposeId) {
++ return (consentDoc && consentDoc.consents ? consentDoc.consents : []).filter((c) => c.purpose === purposeId && c.on);
++}
++
++/** One line of history, never HTML from the server. */
++function consentHistoryList(purposeId) {
++ const rows = (consentDoc && consentDoc.history ? consentDoc.history : []).filter((h) => h.purpose === purposeId).slice(0, 8);
++ if (!rows.length) return '<p class="ovk-muted consent-empty">No changes yet: this purpose has never been on.</p>';
++ return `<ul class="consent-history">${rows.map((h) => {
++ const action = h.action === 'granted' ? 'On' : h.action === 'expired' ? 'Expired' : 'Off';
++ const who = CONSENT_BY_LABEL[h.by] || h.by;
++ const client = h.client ? ` · ${consentEsc(h.client)}` : '';
++ return `<li><b>${action}</b> · ${consentEsc(consentWhen(h.at))} · ${consentEsc(who)}${client}</li>`;
++ }).join('')}</ul>`;
++}
++
++/** One kit fold row for one purpose. */
++function consentRow(purpose) {
++ const active = consentActive(purpose.id);
++ const current = active[0] || null;
++ const state = active.length ? 'On' : 'Off by default';
++ const kinds = purpose.kinds.length ? `<div class="consent-kinds"><span class="consent-label">Which content</span>
++ <div class="consent-kind-grid">${purpose.kinds.map((kind) => {
++ const checked = current ? current.kinds.includes(kind) : false;
++ return `<label class="consent-kind"><input type="checkbox" class="consent-kind-box" value="${kind}"${checked ? ' checked' : ''}> ${consentEsc(CONSENT_KIND_LABEL[kind] || kind)}</label>`;
++ }).join('')}</div>
++ <p class="ovk-muted consent-hint">No kind ticked means every kind this purpose applies to.</p></div>` : '';
++ const licence = purpose.id === 'data.public' ? `<div class="consent-licence-field"><label for="consent-licence-${purpose.id}">Licence</label>
++ <select id="consent-licence-${purpose.id}" class="consent-licence">
++ <option value="">Choose a licence…</option>
++ <option value="cc-by-4.0"${current && current.options && current.options.licence === 'cc-by-4.0' ? ' selected' : ''}>CC BY 4.0 (credit OpenVibe)</option>
++ <option value="cc0-1.0"${current && current.options && current.options.licence === 'cc0-1.0' ? ' selected' : ''}>CC0 1.0 (public domain)</option>
++ </select></div>` : '';
++ const vision = purpose.id === 'face.recognize' ? '<p class="ovk-muted consent-note"><b>The features are chosen in OpenVibe.Vision:</b> verify, find, cast and library. This choice only allows recognition; Vision still asks which ones.</p>' : '';
++ const appNote = purpose.id === 'app.data' ? '<p class="ovk-muted consent-note"><b>An app asks on its own consent screen</b> when you connect one. This row is where you turn every app off again.</p>' : '';
++ const adult = purpose.adults_only ? `<label class="consent-adult"><input type="checkbox" class="consent-adult-box"> I am 18 or older.</label>` : '';
++ const onDisabled = purpose.id === 'app.data' ? ' disabled title="An app asks on its own consent screen"' : '';
++ return `<details class="ovk-fold consent-fold" data-consent-row="${consentEsc(purpose.id)}">
++ <summary><span>${consentEsc(purpose.title)} <span class="ovk-muted">· ${state}</span></span>
++ <svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg></summary>
++ <div class="ovk-fold-body">
++ <p>${consentEsc(purpose.allows)}</p>
++ <p class="consent-off"><b>Off:</b> ${consentEsc(purpose.off)}</p>
++ ${purpose.cannot_undo ? `<p class="ovk-muted"><b>What turning it off cannot undo:</b> ${consentEsc(purpose.cannot_undo)}</p>` : ''}
++ ${kinds}${licence}${vision}${appNote}${adult}
++ <div class="consent-actions">
++ <button type="button" class="btn consent-on" data-consent-on aria-pressed="${active.length ? 'true' : 'false'}"${onDisabled}>On</button>
++ <button type="button" class="btn consent-off-btn" data-consent-off aria-pressed="${active.length ? 'false' : 'true'}">Off</button>
++ </div>
++ ${consentHistoryList(purpose.id)}
++ </div>
++ </details>`;
++}
++
++function renderConsent() {
++ const box = document.getElementById('consent-choices');
++ if (!box) return;
++ if (!consentDoc) { box.innerHTML = '<div class="loading-spinner">Loading your choices…</div>'; return; }
++ box.innerHTML = (consentDoc.purposes || []).map(consentRow).join('');
++ box.querySelectorAll('[data-consent-on]').forEach((btn) => btn.addEventListener('click', () => saveConsent(btn, true)));
++ box.querySelectorAll('[data-consent-off]').forEach((btn) => btn.addEventListener('click', () => saveConsent(btn, false)));
++}
++
++function consentStatus(text, bad) {
++ const el = document.getElementById('consent-status');
++ if (!el) return;
++ el.textContent = text || '';
++ el.classList.toggle('consent-error', !!bad);
++}
++
++/** Turn one purpose on or off. On sends the wording it showed, the kinds, the licence or the features, and the 18+. */
++async function saveConsent(btn, on) {
++ const row = btn.closest('[data-consent-row]');
++ if (!row || !consentDoc) return;
++ const purpose = (consentDoc.purposes || []).find((p) => p.id === row.getAttribute('data-consent-row'));
++ if (!purpose) return;
++ const body = { on };
++ if (on) {
++ body.text_version = purpose.text_version;
++ body.kinds = [...row.querySelectorAll('.consent-kind-box:checked')].map((i) => i.value);
++ const current = consentActive(purpose.id)[0];
++ if (purpose.id === 'data.public') {
++ const licence = row.querySelector('.consent-licence');
++ if (!licence || !licence.value) { consentStatus('Choose a licence for the open dataset first.', true); return; }
++ body.options = { licence: licence.value };
++ } else if (purpose.id === 'face.recognize') {
++ // The features are Vision's; turning the purpose on again must never wipe them.
++ body.options = { features: (current && current.options && current.options.features) || [] };
++ }
++ if (purpose.adults_only) body.adult = !!(row.querySelector('.consent-adult-box') || {}).checked;
++ }
++ row.querySelectorAll('button, input, select').forEach((el) => { el.disabled = true; });
++ consentStatus(on ? 'Turning it on…' : 'Turning it off…', false);
++ try {
++ const data = await apiFetch('/api/v1/me/consent/' + encodeURIComponent(purpose.id), { method: 'PUT', body: JSON.stringify(body) });
++ consentDoc = data;
++ renderConsent();
++ consentStatus(on ? `${purpose.title}: on.` : `${purpose.title}: off.`, false);
++ } catch (err) {
++ row.querySelectorAll('button, input, select').forEach((el) => { el.disabled = false; });
++ consentStatus('Could not save that choice (' + (err && err.status ? 'HTTP ' + err.status : 'network error') + '). Nothing was turned on.', true);
++ }
++}
++
++async function loadConsent() {
++ const box = document.getElementById('consent-choices');
++ if (!box) return;
++ consentStatus('', false);
++ try {
++ consentDoc = await apiFetch('/api/v1/me/consent');
++ renderConsent();
++ } catch (err) {
++ consentDoc = null;
++ box.innerHTML = '<p class="muted">Your data choices could not be loaded. Nothing here is on until it loads and you turn it on.</p>';
++ consentStatus('Could not load your choices' + (err && err.status ? ' (HTTP ' + err.status + ')' : '') + '.', true);
++ }
++}
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
iindex 2327b40e..288bc126 100644
---- a/public/js/my/profile.js
++++ b/public/js/my/profile.js
@@@ -39,6 +39,7 @@ async function loadUser() {
loadAccounts();
renderMerge();
renderExport(); renderDeletion();
++ loadConsent();
if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
loadNotifPrefs(); loadQuietHours();
loadLinked();
ddiff --git a/public/my.html b/public/my.html
iindex 049e2b0d..77a48a22 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -31,6 +31,8 @@
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/my.css">
++<!-- The OpenVibe Kit's fold rows on "Your data choices" (L24): /shared/kit.css is the shared kit, nothing global. -->
++<link rel="stylesheet" href="/shared/kit.css">
</head>
<body>
@@@ -266,6 +268,15 @@
<!-- AI & Data Section: the ai.preferences module and every module record kept with this account -->
<div class="section" id="sec-data">
++ <!-- Your data choices (L24, 2026-10-31): every purpose is off until the person turns it on. One kit fold row
++ per purpose, the wording, the kinds, the 18+ confirmation inside the row, the history, and equal On/Off
++ buttons. Nothing is pre-ticked; the rows are drawn from GET /api/v1/me/consent. -->
++ <div class="card" id="consent-card">
++ <h3><span class="icon"><i class="fa-solid fa-toggle-on"></i></span> Your data choices</h3>
++ <p>AI training, the open dataset, face recognition and apps that use your content are <b>off until you turn them on</b>. Each choice is yours, and you can turn it off again at any time. Nothing here is on by default. We never sell your data.</p>
++ <div id="consent-choices"><div class="loading-spinner">Loading your choices…</div></div>
++ <p class="muted consent-status" id="consent-status" role="status"></p>
++ </div>
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AI preferences</h3>
<p style="margin-bottom:12px">How OpenVibe AI writes for you when you ask it for something, such as a blog draft. Saved to your account and used on every OpenVibe site.</p>
@@@ -385,6 +396,7 @@
<script src="/js/my/tools.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/profile.js" defer></script>
++<script src="/js/my/consent.js" defer></script>
<script src="/js/my/security.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/boot.js" defer></script>
ddiff --git a/server/consent/index.js b/server/consent/index.js
nnew file mode 100644
iindex 00000000..503de21c
---- /dev/null
++++ b/server/consent/index.js
@@@ -0,0 +1,518 @@
++'use strict';
++/**
++ * The consent registry (L24, 2026-10-31; Contracts 0.256.0 network.consent@1).
++ *
++ * Nothing a person makes is off-limits to AI or data use by design: each use is OFF until that person turns it on
++ * here. A choice is one row for (subject, purpose, client); changing it revokes the old row and adds a new one, so
++ * the record proves what was allowed, under which wording and when. `consent_events` is append-only.
++ *
++ * mine(actor) → network.me-consent-result@1: every purpose, the active choices, the history
++ * choose(actor, purpose, body) → network.me-consent-result@1: the person turns one purpose on or off
++ * revokeApp(actor, purpose, client)→ network.me-consent-result@1: one app's grant ends
++ * check({ purpose, kind, client, feature, subjects }) → network.consent-check-result@1
++ * sweep() marks expired rows and emits network.consent.changed
++ *
++ * The check is the only way a service decides. It is TRUE only for an active row that covers the purpose, the kind
++ * (empty kinds cover every kind), the client and the feature. Active means revoked_at is null and expires_at has not
++ * passed; every check computes that itself, so the expiry sweep is bookkeeping, never the reason a check is right.
++ * An unknown, deleted or merged-away subject answers false, exactly like someone who never chose: the answer never
++ * tells whether an account exists. A service that cannot reach Network treats every purpose as off.
++ *
++ * The change event (network.consent.changed@1) is queued through Network's outbox after the transaction commits,
++ * never inside it: a service that reads the event and asks again must already see the new row. It carries only the
++ * contract's fields, never the content a choice covers.
++ */
++const express = require('express');
++const { ids, validate, http } = require('openvibe-contracts');
++const eventRelay = require('../developer/event-relay');
++
++const TEXT_VERSION = '2026-10-31.1';
++const KINDS = ['streams', 'vods', 'clips', 'voice', 'chat', 'posts', 'pictures', 'code'];
++const WRITE_LIMIT = 30;
++const WRITE_WINDOW_MS = 60_000;
++const MAX_SUBJECTS = 500;
++const HISTORY_LIMIT = 200;
++const SWEEP_LIMIT = 500;
++const CHOICE_CONTRACT = 'network.consent-choice@1';
++const REQUEST_CONTRACT = 'network.consent-check-request@1';
++const RESULT_CONTRACT = 'network.consent-check-result@1';
++const MINE_CONTRACT = 'network.me-consent-result@1';
++
++/**
++ * The four purposes and the exact words the account page shows. Each defaults to off. `allows` says what turning it
++ * on permits, `off` what turning it off does, and `cannot_undo` what a person should know before they turn it off.
++ * The plain lines the owner asked for (2026-10-31): nothing is "never" again, it is "only if you turn it on".
++ */
++const PURPOSES = Object.freeze([
++ Object.freeze({
++ id: 'ai.train',
++ title: 'Train AI on what you choose',
++ allows: 'OpenVibe may train or fine-tune its models on the content you pick.',
++ off: 'OpenVibe trains nothing new on your content.',
++ cannot_undo: 'A model already trained keeps what it learned.',
++ adults_only: true,
++ kinds: Object.freeze([...KINDS]),
++ text_version: TEXT_VERSION,
++ }),
++ Object.freeze({
++ id: 'data.public',
++ title: 'Put your content in the open dataset',
++ allows: "Your content you pick goes into OpenVibe's open dataset, under the licence you choose.",
++ off: 'Your content stays out of the next dataset release.',
++ cannot_undo: 'Copies already downloaded stay out there; the next release leaves your content out.',
++ adults_only: true,
++ kinds: Object.freeze([...KINDS]),
++ text_version: TEXT_VERSION,
++ }),
++ Object.freeze({
++ id: 'face.recognize',
++ title: 'Recognise your face',
++ allows: 'Your face is recognised in streams, VODs and pictures, for the features you pick in OpenVibe.Vision.',
++ off: 'Recognition stops and nobody new is named.',
++ cannot_undo: 'A match already made cannot be unmade.',
++ adults_only: true,
++ kinds: Object.freeze([]),
++ text_version: TEXT_VERSION,
++ }),
++ Object.freeze({
++ id: 'app.data',
++ title: 'Let one app use your content',
++ allows: 'One named app may use your content for the purposes it showed you when you agreed.',
++ off: 'That app loses access at its next check.',
++ cannot_undo: 'A copy an app already downloaded stays with the app.',
++ adults_only: false,
++ kinds: Object.freeze([...KINDS]),
++ text_version: TEXT_VERSION,
++ }),
++]);
++const PURPOSE_BY_ID = new Map(PURPOSES.map((p) => [p.id, p]));
++
++class ConsentError extends Error {
++ constructor(status, code, detail, extra) { super(detail); this.status = status; this.code = code; this.extra = extra; }
++}
++const fail = (status, code, detail, extra) => { throw new ConsentError(status, code, detail, extra); };
++
++const iso = (v) => {
++ if (v === null || v === undefined) return null;
++ if (v instanceof Date) return v.toISOString();
++ const d = new Date(String(v));
++ return Number.isNaN(d.getTime()) ? String(v) : d.toISOString();
++};
++const atIso = (ms) => new Date(ms).toISOString();
++const asArray = (v) => {
++ if (Array.isArray(v)) return v;
++ if (typeof v === 'string') {
++ const inner = v.replace(/^\{|\}$/g, '');
++ return inner ? inner.split(',').map((x) => x.replace(/^"|"$/g, '')) : [];
++ }
++ return [];
++};
++const asJson = (v) => {
++ if (v && typeof v === 'object' && !Array.isArray(v)) return v;
++ if (typeof v === 'string') { try { const x = JSON.parse(v); return x && typeof x === 'object' && !Array.isArray(x) ? x : {}; } catch { return {}; } }
++ return {};
++};
++const activeAt = (row, ms) => !row.revoked_at && (!row.expires_at || Date.parse(String(row.expires_at)) > ms);
++
++/** The contract's view of one consents row. */
++function consentOf(row, ms) {
++ return {
++ id: row.id,
++ subject: row.subject,
++ purpose: row.purpose,
++ on: activeAt(row, ms),
++ kinds: asArray(row.kinds),
++ client: row.client || null,
++ options: asJson(row.options),
++ text_version: row.text_version,
++ source: row.source,
++ granted_at: iso(row.granted_at),
++ revoked_at: iso(row.revoked_at),
++ expires_at: iso(row.expires_at),
++ };
++}
++
++/** The contract's view of one consent_events row. */
++function historyOf(row) {
++ return {
++ consent_id: row.consent_id,
++ purpose: row.purpose,
++ client: row.client || null,
++ action: row.action,
++ at: iso(row.at),
++ by: row.by,
++ text_version: row.text_version,
++ };
++}
++
++/** The event's payload — the contract's fields only, and never the content the choice covers. */
++function eventPayload(row, on, at) {
++ return {
++ consent_id: row.id,
++ subject: row.subject,
++ purpose: row.purpose,
++ client: row.client || null,
++ on,
++ kinds: asArray(row.kinds),
++ text_version: row.text_version,
++ changed_at: at,
++ };
++}
++
++/** The envelope Network queues for network.consent.changed. */
++function eventEnvelope(payload) {
++ const env = {
++ event_id: ids.newId('event', Date.parse(payload.changed_at)),
++ event_type: 'network.consent.changed',
++ version: 1,
++ source: 'network',
++ actor: { type: 'user', id: payload.subject },
++ timestamp: payload.changed_at,
++ visibility: 'internal',
++ subject: { type: 'user', id: payload.subject },
++ payload,
++ };
++ const v = validate('events.event-envelope@1', env);
++ const pv = validate('network.consent.changed@1', payload);
++ if (!v.valid || !pv.valid) {
++ throw new Error(`consent: bad network.consent.changed event: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
++ }
++ return env;
++}
++
++/** Queue the change events, after their transaction committed. */
++async function publish(db, payloads = []) {
++ if (!payloads.length) return 0;
++ const writer = eventRelay.writerFor(db);
++ for (const payload of payloads) await writer.enqueue(db, eventEnvelope(payload));
++ try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ }
++ return payloads.length;
++}
++
++const EVENT_SELECT = `id, consent_id, subject, purpose, client, action, "by", text_version, at`;
++
++/**
++ * Revoke every active choice of one subject, inside the caller's transaction (account merge or deletion). The rows
++ * are kept; the returned payloads must be published by the caller after its transaction commits.
++ */
++async function revokeForSubject(t, subject, by, at) {
++ const rows = await t.prepare('SELECT * FROM consents WHERE subject = ? AND revoked_at IS NULL').all(subject);
++ const payloads = [];
++ for (const row of rows) {
++ await t.prepare('UPDATE consents SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL').run(at, by, row.id);
++ await t.prepare('INSERT INTO consent_events (consent_id, subject, purpose, client, action, "by", text_version, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(row.id, row.subject, row.purpose, row.client, 'revoked', by, row.text_version, at);
++ payloads.push(eventPayload({ ...row, revoked_at: at }, false, at));
++ }
++ return payloads;
++}
++
++/** Account merge: the account folded away never carries its choices over. Inside the merge's transaction. */
++async function revokeForMerge(t, from, { at }) {
++ return await revokeForSubject(t, from, 'merge', at);
++}
++
++/** Account deletion: revoke every active choice of the account and its aliases. Inside the deletion's transaction. */
++async function revokeForDeletion(t, subjects, { at }) {
++ const payloads = [];
++ for (const subject of [...new Set(subjects)]) payloads.push(...await revokeForSubject(t, subject, 'deletion', at));
++ return payloads;
++}
++
++/** Every row and every event of one subject, for the account export (ADR-033). */
++async function exportOf(db, subject) {
++ const consents = (await db.prepare('SELECT * FROM consents WHERE subject = ? ORDER BY granted_at, id').all(subject))
++ .map((r) => ({ ...consentOf(r, Date.now()), revoked_by: r.revoked_by }));
++ const events = (await db.prepare(`SELECT ${EVENT_SELECT} FROM consent_events WHERE subject = ? ORDER BY id`).all(subject))
++ .map((r) => ({ ...historyOf(r), id: Number(r.id), subject: r.subject }));
++ return { consents, events };
++}
++
++/**
++ * The registry for one boot.
++ * db the Network database
++ * emit async (payload) — called after each transaction commits; defaults to the outbox
++ * now () => epoch ms
++ */
++function createConsent({ db, emit, now = () => Date.now(), log = console } = {}) {
++ if (!db) throw new TypeError('createConsent: db is required');
++ const writes = new Map();
++ const send = typeof emit === 'function' ? emit : (payload) => publish(db, [payload]);
++
++ /** 30 writes a minute per person, like the Dock's limit: a runaway page must not fill the registry. */
++ function mayWrite(subject, at) {
++ const kept = (writes.get(subject) || []).filter((t) => at - t < WRITE_WINDOW_MS);
++ if (kept.length >= WRITE_LIMIT) { writes.set(subject, kept); fail(429, 'consent.rate_limited', 'too many consent changes; wait a minute'); }
++ kept.push(at);
++ writes.set(subject, kept);
++ if (writes.size > 5000) writes.delete(writes.keys().next().value);
++ }
++
++ function purposeOf(id) {
++ const purpose = PURPOSE_BY_ID.get(String(id || ''));
++ if (!purpose) fail(400, 'consent.unknown_purpose', `no consent purpose '${String(id || '').slice(0, 40)}'`);
++ return purpose;
++ }
++
++ /** The active choices and the history of one person → network.me-consent-result@1. */
++ async function mine(actor) {
++ const at = now();
++ const atText = atIso(at);
++ const consents = (await db.prepare(`SELECT * FROM consents
++ WHERE subject = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
++ ORDER BY purpose, client NULLS FIRST, granted_at DESC`).all(actor.subject, atText))
++ .map((r) => consentOf(r, at));
++ const history = (await db.prepare(`SELECT ${EVENT_SELECT} FROM consent_events WHERE subject = ? ORDER BY id DESC LIMIT ?`)
++ .all(actor.subject, HISTORY_LIMIT)).map(historyOf);
++ return { purposes: PURPOSES.map((p) => ({ ...p, kinds: [...p.kinds] })), consents, history };
++ }
++
++ /**
++ * Turn one purpose on or off. On: the old active row (if any) is revoked and a new row granted in one
++ * transaction, and the two events go out after it commits. Off: the active row is revoked.
++ */
++ async function choose(actor, purposeId, body = {}) {
++ const purpose = purposeOf(purposeId);
++ const at = now();
++ const atText = atIso(at);
++ mayWrite(actor.subject, at);
++ if (!body || typeof body !== 'object' || Array.isArray(body)) fail(422, 'consent.invalid', 'the body must be a consent choice');
++ const on = body.on === true;
++ const payloads = [];
++
++ if (on) {
++ // app.data belongs to an app's consent screen (C4), where the client is named; this page never names one.
++ if (purpose.id === 'app.data') fail(422, 'consent.app_data', "an app's choice is made on its consent screen, not here");
++ if (body.text_version !== purpose.text_version) {
++ fail(409, 'consent.stale_text', 'the wording changed; read the new words and choose again');
++ }
++ if (purpose.adults_only && body.adult !== true) {
++ fail(422, 'consent.adult_required', 'this purpose asks you to confirm that you are 18 or older');
++ }
++ const kinds = [...new Set(asArray(body.kinds))];
++ if (kinds.some((k) => !purpose.kinds.includes(k))) {
++ fail(422, 'consent.kind_not_allowed', 'that kind is not one this purpose takes');
++ }
++ const options = asJson(body.options);
++ const rowId = `cns_${ids.ulid(at)}`;
++
++ const fresh = await db.tx(async (t) => {
++ const active = await t.prepare('SELECT * FROM consents WHERE subject = ? AND purpose = ? AND client IS NULL AND revoked_at IS NULL FOR UPDATE')
++ .all(actor.subject, purpose.id);
++ const out = [];
++ for (const old of active) {
++ await t.prepare('UPDATE consents SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL').run(atText, 'person', old.id);
++ await t.prepare('INSERT INTO consent_events (consent_id, subject, purpose, client, action, "by", text_version, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(old.id, old.subject, old.purpose, old.client, 'revoked', 'person', old.text_version, atText);
++ out.push(eventPayload({ ...old, revoked_at: atText }, false, atText));
++ }
++ const row = { id: rowId, subject: actor.subject, purpose: purpose.id, kinds, client: null, options,
++ text_version: purpose.text_version, source: 'account', granted_at: atText, revoked_at: null, expires_at: null };
++ await t.prepare(`INSERT INTO consents (id, subject, purpose, kinds, client, options, text_version, source, granted_at, revoked_at, revoked_by, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)`)
++ .run(row.id, row.subject, row.purpose, kinds, null, JSON.stringify(options), row.text_version, row.source, atText);
++ await t.prepare('INSERT INTO consent_events (consent_id, subject, purpose, client, action, "by", text_version, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(row.id, row.subject, row.purpose, null, 'granted', 'person', row.text_version, atText);
++ out.push(eventPayload(row, true, atText));
++ return out;
++ });
++ payloads.push(...fresh);
++ } else {
++ // Off: every active row of this purpose goes (app.data can have one row per app; the page turns all off).
++ const revoked = await db.tx(async (t) => {
++ const active = await t.prepare('SELECT * FROM consents WHERE subject = ? AND purpose = ? AND revoked_at IS NULL FOR UPDATE')
++ .all(actor.subject, purpose.id);
++ const out = [];
++ for (const old of active) {
++ await t.prepare('UPDATE consents SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL').run(atText, 'person', old.id);
++ await t.prepare('INSERT INTO consent_events (consent_id, subject, purpose, client, action, "by", text_version, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(old.id, old.subject, old.purpose, old.client, 'revoked', 'person', old.text_version, atText);
++ out.push(eventPayload({ ...old, revoked_at: atText }, false, atText));
++ }
++ return out;
++ });
++ payloads.push(...revoked);
++ }
++
++ // After the commit, never inside it: a reader of the event that asks again sees the new state.
++ for (const payload of payloads) await send(payload);
++ return await mine(actor);
++ }
++
++ /** One app's grant ends (the person revoking it, or an app revoking itself through its consent screen). */
++ async function revokeApp(actor, purposeId, client) {
++ const purpose = purposeOf(purposeId);
++ const name = String(client || '');
++ if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/.test(name)) fail(400, 'consent.invalid_client', 'that is not an app id');
++ const at = now();
++ const atText = atIso(at);
++ mayWrite(actor.subject, at);
++ const revoked = await db.tx(async (t) => {
++ const active = await t.prepare('SELECT * FROM consents WHERE subject = ? AND purpose = ? AND client = ? AND revoked_at IS NULL FOR UPDATE')
++ .all(actor.subject, purpose.id, name);
++ const out = [];
++ for (const old of active) {
++ await t.prepare('UPDATE consents SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL').run(atText, 'app_revoked', old.id);
++ await t.prepare('INSERT INTO consent_events (consent_id, subject, purpose, client, action, "by", text_version, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(old.id, old.subject, old.purpose, old.client, 'revoked', 'app_revoked', old.text_version, atText);
++ out.push(eventPayload({ ...old, revoked_at: atText }, false, atText));
++ }
++ return out;
++ });
++ for (const payload of revoked) await send(payload);
++ return await mine(actor);
++ }
++
++ /**
++ * May this purpose be used for these people? → network.consent-check-result@1.
++ * ONE query: every subject's active rows for the purpose. Everything else is a false answer, never an error and
++ * never a sign of whether an account exists.
++ */
++ async function check(query = {}) {
++ const purpose = purposeOf(query.purpose);
++ const subjects = Array.isArray(query.subjects) ? [...new Set(query.subjects.map(String))] : [];
++ if (!subjects.length) fail(422, 'consent.invalid', 'subjects must name at least one person');
++ if (subjects.length > MAX_SUBJECTS) fail(422, 'consent.too_many_subjects', `at most ${MAX_SUBJECTS} subjects a call`);
++ if (purpose.id === 'app.data' && !query.client) fail(422, 'consent.client_required', 'app.data needs the app asking (client)');
++ // A recognition always names its feature: a face choice whose features are still empty allows none of them.
++ if (purpose.id === 'face.recognize' && !query.feature) fail(422, 'consent.feature_required', 'face.recognize needs the feature about to run (verify, find, cast or library)');
++ const kind = query.kind ? String(query.kind) : null;
++ if (kind && !KINDS.includes(kind)) fail(422, 'consent.invalid', 'unknown kind');
++ const feature = purpose.id === 'face.recognize' && query.feature ? String(query.feature) : null;
++ const client = query.client === undefined || query.client === null || query.client === '' ? null : String(query.client);
++ const at = now();
++ const atText = atIso(at);
++
++ const rows = await db.prepare(`SELECT subject, client, kinds, options FROM consents
++ WHERE subject = ANY(?) AND purpose = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`)
++ .all(subjects, purpose.id, atText);
++
++ const bySubject = new Map();
++ for (const row of rows) {
++ if ((row.client || null) !== client) continue;
++ const rowKinds = asArray(row.kinds);
++ if (kind ? !(rowKinds.length === 0 || rowKinds.includes(kind)) : rowKinds.length !== 0) continue;
++ if (feature) {
++ const features = asArray(asJson(row.options).features);
++ if (!features.includes(feature)) continue;
++ }
++ bySubject.set(row.subject, row);
++ }
++
++ const allowed = {};
++ const licence = {};
++ for (const subject of subjects) {
++ const row = bySubject.get(subject);
++ allowed[subject] = !!row;
++ if (row && purpose.id === 'data.public') {
++ const l = asJson(row.options).licence;
++ if (l) licence[subject] = l;
++ }
++ }
++
++ const result = { purpose: purpose.id, allowed, checked_at: atText };
++ if (query.kind) result.kind = kind;
++ if (client) result.client = client;
++ if (query.feature && purpose.id === 'face.recognize') result.feature = feature;
++ if (purpose.id === 'data.public') result.licence = licence;
++ const v = validate(RESULT_CONTRACT, result);
++ if (!v.valid) throw new Error(`consent: bad check result: ${JSON.stringify(v.errors).slice(0, 300)}`);
++ return result;
++ }
++
++ /**
++ * The expiry sweep: mark active rows whose end passed, with an 'expired' event, and emit. Bookkeeping only: every
++ * check already computes expiry itself, so a late sweep can never make a check wrong.
++ *
++ * The event's `by` is 'person': the contract's history has no "the clock did it" value, and a time-limited grant
++ * is the person's own choice at the moment they made it.
++ */
++ async function sweep({ limit = SWEEP_LIMIT } = {}) {
++ const at = now();
++ const atText = atIso(at);
++ const rows = await db.prepare(`SELECT * FROM consents
++ WHERE revoked_at IS NULL AND expires_at IS NOT NULL AND expires_at <= ? ORDER BY expires_at, id LIMIT ?`).all(atText, limit);
++ const payloads = [];
++ for (const row of rows) {
++ const changed = await db.tx(async (t) => {
++ const nowRow = await t.prepare('SELECT * FROM consents WHERE id = ? AND revoked_at IS NULL FOR UPDATE').get(row.id);
++ if (!nowRow || !nowRow.expires_at || Date.parse(String(nowRow.expires_at)) > at) return null;
++ await t.prepare("UPDATE consents SET revoked_at = expires_at, revoked_by = 'expired' WHERE id = ? AND revoked_at IS NULL").run(nowRow.id);
++ await t.prepare('INSERT INTO consent_events (consent_id, subject, purpose, client, action, "by", text_version, at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
++ .run(nowRow.id, nowRow.subject, nowRow.purpose, nowRow.client, 'expired', 'person', nowRow.text_version, atText);
++ return eventPayload({ ...nowRow, revoked_at: nowRow.expires_at }, false, atText);
++ });
++ if (changed) payloads.push(changed);
++ }
++ for (const payload of payloads) await send(payload);
++ return payloads.length;
++ }
++
++ return { mine, choose, revokeApp, check, sweep, _writes: writes };
++}
++
++/** The person's routes, behind Network's session check (like the Dock's), and never cached. */
++function router({ consent, requireAuth }) {
++ const r = express.Router();
++ r.use(http.middleware());
++ r.use(express.json({ limit: '16kb' }));
++ r.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
++ const actorOf = async (req) => {
++ const store = require('../developer/store');
++ return await store.actorOf(req.app.locals.db, req.user);
++ };
++ const problem = (res, err, req, fallback) => {
++ if (err && err.status) return http.sendProblem(res, err.status, err.code || fallback, { detail: err.message, errors: err.extra && err.extra.errors, ctx: req.ov });
++ console.error(`[Consent] ${fallback}:`, err);
++ return http.sendProblem(res, 500, fallback, { detail: 'could not read or save your data choices', ctx: req.ov });
++ };
++
++ r.get('/', requireAuth, async (req, res) => {
++ try {
++ res.json(await consent.mine(await actorOf(req)));
++ } catch (err) { problem(res, err, req, 'consent.error'); }
++ });
++
++ r.put('/:purpose', requireAuth, async (req, res) => {
++ try {
++ const v = validate(CHOICE_CONTRACT, req.body);
++ if (!v.valid) return http.sendProblem(res, 422, 'consent.invalid', { detail: 'that is not a consent choice', errors: v.errors, ctx: req.ov });
++ const actor = await actorOf(req);
++ res.json(await consent.choose(actor, req.params.purpose, req.body));
++ } catch (err) { problem(res, err, req, 'consent.error'); }
++ });
++
++ r.delete('/:purpose/:client', requireAuth, async (req, res) => {
++ try {
++ const actor = await actorOf(req);
++ res.json(await consent.revokeApp(actor, req.params.purpose, req.params.client));
++ } catch (err) { problem(res, err, req, 'consent.error'); }
++ });
++
++ return r;
++}
++
++/** The service check, behind network.consent.read (service token only). */
++function internalRouter({ consent }) {
++ const r = express.Router();
++ r.post('/check', async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ try {
++ const v = validate(REQUEST_CONTRACT, req.body);
++ if (!v.valid) return http.sendProblem(res, 400, 'consent.invalid', { detail: 'that is not a consent check', errors: v.errors, ctx: req.ov });
++ res.json(await consent.check(req.body));
++ } catch (err) {
++ if (err && err.status) return http.sendProblem(res, err.status, err.code || 'consent.invalid', { detail: err.message, errors: err.extra && err.extra.errors, ctx: req.ov });
++ console.error('[Consent] check:', err);
++ // The fail-closed rule belongs to the caller: a service that gets no answer treats every purpose as off.
++ return http.sendProblem(res, 500, 'consent.error', { detail: 'could not check consent', ctx: req.ov });
++ }
++ });
++ return r;
++}
++
++module.exports = {
++ PURPOSES, PURPOSE_BY_ID, TEXT_VERSION, KINDS, WRITE_LIMIT, WRITE_WINDOW_MS, MAX_SUBJECTS,
++ createConsent, router, internalRouter, eventEnvelope, publish, exportOf, revokeForMerge, revokeForDeletion,
++ ConsentError, CHOICE_CONTRACT, REQUEST_CONTRACT, RESULT_CONTRACT, MINE_CONTRACT,
++};
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 2c51047c..8e0832ca 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -190,6 +190,8 @@ async function networkPart(db, userId) {
// The Dock's state (the Dock plan 2026-10-30, K-D1): the little floating surface every OpenVibe site draws.
add('dock.json', await tableExists(db, 'dock_states')
? await db.prepare('SELECT subject, state, updated_at FROM dock_states WHERE subject = ?').all(u.subject_id) : []);
++ // The consent registry (L24): every choice and every change, the person's own rows (revoked ones included).
++ add('consents.json', await require('../consent').exportOf(db, u.subject_id));
add('username_history.json', (await rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] })).rows);
await byUser('profile_changes.json', 'user_profile_changes');
await byUser('sign_in_log.json', 'ip_log');
@@@ -327,6 +329,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
const at = iso(now);
const erased = {};
const count = (k, n) => { if (n) erased[k] = (erased[k] || 0) + n; };
++ let consentEvents = [];
const run = db.txFn(async () => {
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(deletion.user_id);
if (!user) throw new Error(`account-data: no user ${deletion.user_id}`);
@@@ -347,6 +350,9 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);
if (await tableExists(db, 'analytics_events')) await db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);
await del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');
++ // The consent registry (L24): the account's active choices are revoked (and its aliases' too), but the rows
++ // stay as proof of what was allowed and when; they hold no content. The events go out after this commit.
++ consentEvents = await require('../consent').revokeForDeletion(db, subjects, { at });
// The Dock's state (the Dock plan 2026-10-30, K-D1): the person's own row, gone with them.
await del('dock_states', `subject IN ${inS}`, subjects, 'dock_states');
await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
@@@ -407,6 +413,8 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
await eventRelay.writerFor(db).enqueue(envelope('network.account.deleted', payload, subject, at));
});
await run();
++ // Never inside the transaction: a service that reads the event and asks again must see the revoked rows.
++ await require('../consent').publish(db, consentEvents);
kick(db);
return erased;
}
ddiff --git a/server/identity/account-merge.js b/server/identity/account-merge.js
iindex cf212366..fa7ee8b8 100644
---- a/server/identity/account-merge.js
++++ b/server/identity/account-merge.js
@@@ -129,6 +129,7 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
const mergedAt = iso(now);
const splitUntil = iso(now + SPLIT_DAYS * 86400000);
const actor = actorSubject || into;
++ let consentEvents = [];
const run = db.txFn(async () => {
const pre = {
user: { ...fromUser, password_hash: undefined },
@@@ -162,6 +163,9 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
}
const mods = await modules.onSubjectMerged(db, { from, into });
moved.modules = mods.moved + (mods.filled || 0);
++ // Consent is the person's own and is never assumed: the folded-in account's active choices are revoked
++ // (events by 'merge'), never carried over. The survivor keeps its own choices. Published after this commit.
++ consentEvents = await require('../consent').revokeForMerge(db, from, { at: mergedAt });
// Follows (Network's since ADR-030), both directions, and blocks: the survivor's pair wins.
if (await tableExists(db, 'user_follows')) dropped.follows = follows.onSubjectMerged ? (await follows.onSubjectMerged(db, { from, into })).dropped : 0;
if (await tableExists(db, 'user_blocks')) {
@@@ -205,6 +209,8 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
return await db.prepare('SELECT * FROM account_merges WHERE id = ?').get(mergeId);
});
const row = await run();
++ // Never inside the transaction (L24): the revocations and their events are visible together once it commits.
++ await require('../consent').publish(db, consentEvents);
try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ }
return resultOf(row, false);
}
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 4fbcd90a..7fa7de5f 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -660,6 +660,12 @@ const DEFAULT_GRANTS = [
// Games submits each AI duel as a Bench task, two priced seat entries, and one automatic outcome.
...['bench.task.create', 'bench.round.create', 'bench.entry.create', 'bench.judgement.cast']
.map((cap) => ['games', cap, 'openvibe.bench', []]),
++ // The consent registry (L24, 2026-10-31; contracts 0.256.0): a service asks before it uses a person's content or
++ // likeness for AI or data, through POST /internal/consent/check. These are the first-party services that use
++ // content today; the answer is cached at most 60 s and dropped on network.consent.changed, and a service that
++ // cannot ask treats every purpose as off. None of these rows was ever granted or revoked before, so none needs a
++ // RESTORED_GRANTS entry (that list is only for a default that was once revoked).
++ ...['live', 'vision', 'ai', 'media-hub', 'cam', 'chat', 'actor'].map((svc) => [svc, 'network.consent.read', SELF_AUDIENCE, []]),
];
// Grants withdrawn by decision; applied at every boot so an old default can't come back.
ddiff --git a/server/index.js b/server/index.js
iindex 63f97a3e..18e67558 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -650,6 +650,14 @@ require('./developer/confirmations').start(db);
app.use('/api/v1/me/dock', require('./dock').router({ dock, requireAuth: requireDock('dock') }));
}
++// The consent registry (L24, 2026-10-31; contracts 0.256.0): every use of a person's content or likeness for AI or
++// data is off until that person turns it on. The person's choices sit behind the session (like the Dock's) and are
++// never cached; services ask the check with a service token holding network.consent.read.
++const consent = require('./consent').createConsent({ db });
++app.use('/api/v1/me/consent', require('./consent').router({ consent, requireAuth }));
++app.use('/internal/consent', require('./identity/principals').guard('network.consent.read'), require('./consent').internalRouter({ consent }));
++
++
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth, requireDock));
@@@ -992,6 +1000,12 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf
} catch (e) { console.warn('[AccountData] sweep failed:', e.message); }
};
timers.push(setTimeout(sweepAccountData, 60 * 1000), setInterval(sweepAccountData, 2 * 60 * 1000));
++ // Consents past their expires_at are marked and emitted (L24). Bookkeeping only: every check already computes
++ // expiry from the row, so a late sweep never changes an answer.
++ const sweepConsent = async () => {
++ try { const n = await consent.sweep(); if (n) console.log(`[Consent] expired ${n} choice(s)`); } catch (e) { console.warn('[Consent] expiry sweep failed:', e.message); }
++ };
++ timers.push(setTimeout(sweepConsent, 30 * 1000), setInterval(sweepConsent, 15 * 60 * 1000));
// Clean expired sessions daily
timers.push(setInterval(async () => {
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 17ecec6f..426eee51 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/consent.test.js b/test/consent.test.js
nnew file mode 100644
iindex 00000000..3594cb5b
---- /dev/null
++++ b/test/consent.test.js
@@@ -0,0 +1,337 @@
++'use strict';
++// The consent registry (L24, 2026-10-31; Contracts 0.256.0 network.consent@1): every use of a person's content or
++// likeness for AI or data is off until that person turns it on. What this proves:
++// - on, off, then on again with other kinds makes two rows and three append-only events;
++// - a stale text version, a missing 18+ confirmation and a kind outside the purpose are refused;
++// - the check is false for an unknown, revoked or expired subject, and for another kind, client or feature;
++// - 501 subjects are refused, and the service route needs a token with network.consent.read;
++// - a check's active rows come from one query;
++// - the change event is emitted after the commit, and the outbox carries the contract's fields only;
++// - export keeps every row and every event; deletion revokes and keeps; merge revokes and never carries over;
++// - the account page's "Your data choices" has kit fold rows and nothing pre-ticked.
++// node --import ./test/helpers/pg-preload.mjs test/consent.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const { ids, validate } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const principals = require('../server/identity/principals');
++const consentMod = require('../server/consent');
++const accountData = require('../server/identity/account-data');
++const accountMerge = require('../server/identity/account-merge');
++
++(async () => {
++const db = getDb();
++const log = console.log; console.log = () => {};
++await principals.ensureSchema(db);
++
++const mk = async (name, subjectId = null) => {
++ const subject = subjectId || `usr_${ids.ulid()}`;
++ const id = (await db.prepare('INSERT INTO users (username, email, password_hash, subject_id) VALUES (?, ?, ?, ?) RETURNING id')
++ .run(name, `${name}@example.test`, 'x', subject)).lastInsertRowid;
++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
++};
++const ana = await mk('consana');
++const bob = await mk('consbob');
++const unknown = `usr_${ids.ulid()}`;
++const current = '2026-10-31.1';
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++const authRoutes = require('../server/auth/routes');
++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
++const tokenOf = (u) => authRoutes.signToken(u, keys.privateKey, config);
++
++const emitted = [];
++const consent = consentMod.createConsent({ db, emit: async (payload) => {
++ const v = validate('network.consent.changed@1', payload);
++ assert.ok(v.valid, `the emitted payload is the contract's: ${JSON.stringify(v.errors)}`);
++ assert.deepStrictEqual(Object.keys(payload).sort(), ['changed_at', 'client', 'consent_id', 'kinds', 'on', 'purpose', 'subject', 'text_version'], 'only the contract fields');
++ emitted.push(payload);
++} });
++
++const app = express();
++app.use(express.json());
++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++app.use('/api/v1/me/consent', consentMod.router({ consent, requireAuth }));
++app.use('/internal/consent', principals.guard('network.consent.read'), consentMod.internalRouter({ consent }));
++const server = http.createServer(app);
++
++const outbox = async (subjectId) => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all())
++ .map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope))
++ .filter((e) => e.event_type === 'network.consent.changed' && (!subjectId || e.payload.subject === subjectId));
++
++(async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const as = (u) => ({ authorization: `Bearer ${tokenOf(u)}` });
++ const call = async (method, p, { user = ana, body, headers = {} } = {}) => {
++ const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(user ? as(user) : {}), ...headers }, body: body === undefined ? undefined : JSON.stringify(body) });
++ return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json().catch(() => ({})) };
++ };
++ const serviceToken = async (client) => {
++ await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(`${client}-secret`, client);
++ const out = await principals.issueToken(db, { clientId: client, clientSecret: `${client}-secret`, audience: 'openvibe.network', privateKey: keys.privateKey, issuer: config.jwt.issuer });
++ assert.strictEqual(out.status, 200, `${client} token: ${JSON.stringify(out.body)}`);
++ return out.body.access_token;
++ };
++
++ try {
++ // ── The words are the owner's: nothing is "never", it is off until turned on. ──
++ const ai = consentMod.PURPOSES.find((p) => p.id === 'ai.train');
++ const pub = consentMod.PURPOSES.find((p) => p.id === 'data.public');
++ assert.strictEqual(ai.allows, 'OpenVibe may train or fine-tune its models on the content you pick.');
++ assert.strictEqual(ai.cannot_undo, 'A model already trained keeps what it learned.');
++ assert.strictEqual(pub.allows, "Your content you pick goes into OpenVibe's open dataset, under the licence you choose.");
++ assert.strictEqual(pub.cannot_undo, 'Copies already downloaded stay out there; the next release leaves your content out.');
++ for (const p of consentMod.PURPOSES) assert.strictEqual(validate('network.me-consent-result@1', { purposes: [p], consents: [], history: [] }).valid, true, `${p.id} words fit the contract`);
++
++ // ── A person: nothing is on at first ──
++ let r = await call('GET', '/api/v1/me/consent');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.purposes.length, 4);
++ assert.deepStrictEqual(r.body.consents, [], 'off by default');
++ assert.deepStrictEqual(r.body.history, []);
++ assert.strictEqual(r.cache, 'private, no-store');
++ const m = validate('network.me-consent-result@1', r.body);
++ assert.ok(m.valid, `mine validates: ${JSON.stringify(m.errors)}`);
++
++ // The services that use content hold network.consent.read; none of these grants was ever revoked, so none
++ // belongs in RESTORED_GRANTS (the rule at the top of principals.js).
++ for (const svc of ['live', 'vision', 'ai', 'media-hub', 'cam', 'chat', 'actor']) {
++ assert.ok(principals.DEFAULT_GRANTS.some((g) => g[0] === svc && g[1] === 'network.consent.read' && g[2] === principals.SELF_AUDIENCE), `${svc} may ask the check`);
++ assert.ok(!principals.REVOKED_GRANTS.some((g) => g[0] === svc && g[1] === 'network.consent.read'), `${svc} is not a restored grant`);
++ }
++
++ // On.
++ r = await call('PUT', '/api/v1/me/consent/ai.train', { body: { on: true, text_version: current, adult: true, kinds: ['streams'] } });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.consents.length, 1);
++ assert.strictEqual(r.body.consents[0].on, true);
++ assert.deepStrictEqual(r.body.consents[0].kinds, ['streams']);
++ assert.strictEqual(r.body.history.length, 1);
++ assert.strictEqual(r.body.history[0].action, 'granted');
++ assert.strictEqual(emitted.length, 1);
++ assert.strictEqual(emitted[0].on, true);
++
++ // Off: the row is revoked, never deleted.
++ r = await call('PUT', '/api/v1/me/consent/ai.train', { body: { on: false } });
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.body.consents, []);
++ assert.strictEqual(r.body.history.length, 2);
++ assert.strictEqual(r.body.history[0].action, 'revoked');
++ assert.strictEqual(emitted.length, 2);
++ assert.strictEqual(emitted[1].on, false);
++
++ // On again with other kinds: a second row, three events in all (the first row is kept, revoked).
++ r = await call('PUT', '/api/v1/me/consent/ai.train', { body: { on: true, text_version: current, adult: true, kinds: ['vods'] } });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.consents.length, 1);
++ assert.deepStrictEqual(r.body.consents[0].kinds, ['vods']);
++ assert.strictEqual(r.body.history.length, 3);
++ const rows = await db.prepare("SELECT * FROM consents WHERE subject = ? AND purpose = 'ai.train' ORDER BY granted_at").all(ana.subject_id);
++ assert.strictEqual(rows.length, 2, 'two rows');
++ assert.strictEqual(rows.filter((x) => x.revoked_at).length, 1, 'the first row is revoked, kept');
++ const history = await db.prepare('SELECT action FROM consent_events WHERE subject = ? ORDER BY id').all(ana.subject_id);
++ assert.deepStrictEqual(history.map((h) => h.action), ['granted', 'revoked', 'granted'], 'three append-only events');
++
++ // The wording must be the one the page showed.
++ r = await call('PUT', '/api/v1/me/consent/ai.train', { body: { on: true, text_version: '2020-01-01.1', adult: true } });
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.body.code, 'consent.stale_text');
++
++ // 18+ only for ai.train, data.public and face.recognize.
++ r = await call('PUT', '/api/v1/me/consent/ai.train', { body: { on: true, text_version: current } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.body.code, 'consent.adult_required');
++
++ // face.recognize takes no kinds.
++ r = await call('PUT', '/api/v1/me/consent/face.recognize', { body: { on: true, text_version: current, adult: true, kinds: ['streams'] } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.body.code, 'consent.kind_not_allowed');
++
++ // ── The check: true only for the active row that covers purpose, kind, client and feature. ──
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'ai.train', kind: 'vods', subjects: [ana.subject_id, unknown] } });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual(r.body.allowed, { [ana.subject_id]: true, [unknown]: false }, 'unknown is false, never an error');
++ assert.strictEqual(r.cache, 'no-store');
++ const cv = validate('network.consent-check-result@1', r.body);
++ assert.ok(cv.valid, JSON.stringify(cv.errors));
++
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'ai.train', kind: 'chat', subjects: [ana.subject_id] } });
++ assert.deepStrictEqual(r.body.allowed, { [ana.subject_id]: false }, 'another kind is false');
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'ai.train', kind: 'vods', client: 'app-one', subjects: [ana.subject_id] } });
++ assert.deepStrictEqual(r.body.allowed, { [ana.subject_id]: false }, "OpenVibe's own grant does not cover an app");
++
++ // Revoked is false.
++ await consent.choose({ subject: bob.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['streams'] });
++ await consent.choose({ subject: bob.subject_id }, 'ai.train', { on: false });
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'ai.train', kind: 'streams', subjects: [bob.subject_id] } });
++ assert.deepStrictEqual(r.body.allowed, { [bob.subject_id]: false }, 'a revoked subject is false');
++
++ // Expired is false before the sweep runs: every check computes it.
++ const expired = await mk('consexp');
++ const at = Date.now();
++ await db.prepare(`INSERT INTO consents (id, subject, purpose, kinds, client, options, text_version, source, granted_at, expires_at)
++ VALUES (?, ?, 'ai.train', ?, NULL, '{}', ?, 'account', ?, ?)`)
++ .run(`cns_${ids.ulid(at)}`, expired.subject_id, ['streams'], current, new Date(at - 7200_000).toISOString(), new Date(at - 3600_000).toISOString());
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'ai.train', kind: 'streams', subjects: [expired.subject_id] } });
++ assert.deepStrictEqual(r.body.allowed, { [expired.subject_id]: false }, 'an expired subject is false');
++ assert.strictEqual(await consent.sweep(), 1, 'the sweep marks it');
++ const expiredRow = await db.prepare("SELECT revoked_at, revoked_by FROM consents WHERE subject = ? AND purpose = 'ai.train'").get(expired.subject_id);
++ assert.strictEqual(expiredRow.revoked_by, 'expired');
++ assert.ok(expiredRow.revoked_at);
++ const expiredEvent = await db.prepare("SELECT action FROM consent_events WHERE subject = ? AND action = 'expired'").get(expired.subject_id);
++ assert.ok(expiredEvent, 'an expired event');
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'ai.train', kind: 'streams', subjects: [expired.subject_id] } });
++ assert.deepStrictEqual(r.body.allowed, { [expired.subject_id]: false }, 'still false after the sweep');
++
++ // Another client, and a feature: app.data names its client; face.recognize needs the feature.
++ const appUser = await mk('consapp');
++ await db.prepare(`INSERT INTO consents (id, subject, purpose, kinds, client, options, text_version, source, granted_at)
++ VALUES (?, ?, 'app.data', ?, 'app-one', '{}', ?, 'oauth', ?)`)
++ .run(`cns_${ids.ulid()}`, appUser.subject_id, ['streams'], current, new Date(at).toISOString());
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'app.data', kind: 'streams', client: 'app-one', subjects: [appUser.subject_id] } });
++ assert.deepStrictEqual(r.body.allowed, { [appUser.subject_id]: true }, 'the named app is covered');
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'app.data', kind: 'streams', client: 'app-two', subjects: [appUser.subject_id] } });
++ assert.deepStrictEqual(r.body.allowed, { [appUser.subject_id]: false }, 'another client is false');
++
++ const faceUser = await mk('consface');
++ await db.prepare(`INSERT INTO consents (id, subject, purpose, kinds, client, options, text_version, source, granted_at)
++ VALUES (?, ?, 'face.recognize', '{}', NULL, ?, ?, 'account', ?)`)
++ .run(`cns_${ids.ulid()}`, faceUser.subject_id, JSON.stringify({ features: ['verify'] }), current, new Date(at).toISOString());
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'face.recognize', feature: 'find', subjects: [faceUser.subject_id] } });
++ assert.deepStrictEqual(r.body.allowed, { [faceUser.subject_id]: false }, 'another feature is false');
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'face.recognize', feature: 'verify', subjects: [faceUser.subject_id] } });
++ assert.deepStrictEqual(r.body.allowed, { [faceUser.subject_id]: true }, 'the chosen feature is true');
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'face.recognize', subjects: [faceUser.subject_id] } });
++ assert.strictEqual(r.status, 422, 'a recognition names its feature');
++ assert.strictEqual(r.body.code, 'consent.feature_required');
++
++ // data.public carries the licence of each allowed subject.
++ const pubUser = await mk('conspub');
++ await db.prepare(`INSERT INTO consents (id, subject, purpose, kinds, client, options, text_version, source, granted_at)
++ VALUES (?, ?, 'data.public', '{}', NULL, ?, ?, 'account', ?)`)
++ .run(`cns_${ids.ulid()}`, pubUser.subject_id, JSON.stringify({ licence: 'cc0-1.0' }), current, new Date(at).toISOString());
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'data.public', subjects: [pubUser.subject_id] } });
++ assert.strictEqual(r.body.allowed[pubUser.subject_id], true);
++ assert.strictEqual(r.body.licence[pubUser.subject_id], 'cc0-1.0');
++
++ // ── The guard: no token, a token without the capability. ──
++ const checkBody = { purpose: 'ai.train', kind: 'vods', subjects: [ana.subject_id] };
++ r = await call('POST', '/internal/consent/check', { user: null, body: checkBody });
++ assert.ok([401, 403].includes(r.status), `no token is refused (${r.status})`);
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('quest')}` }, body: checkBody });
++ assert.strictEqual(r.status, 403, 'a service without network.consent.read is refused');
++ assert.strictEqual(r.body.code, 'capability.denied');
++
++ // 501 subjects: the contract's limit, refused before any query.
++ const tooMany = Array.from({ length: 501 }, () => `usr_${ids.ulid()}`);
++ r = await call('POST', '/internal/consent/check', { user: null, headers: { authorization: `Bearer ${await serviceToken('live')}` }, body: { purpose: 'ai.train', subjects: tooMany } });
++ assert.strictEqual(r.status, 400, 'at most 500 subjects a call');
++ assert.strictEqual(r.body.code, 'consent.invalid');
++
++ // ── The account: export keeps every row and every event. ──
++ const part = await accountData.networkPart(db, ana.id);
++ const file = part.files.find((f) => f.name === 'consents.json');
++ assert.ok(file, 'consents.json is in the export');
++ assert.strictEqual(file.content.consents.length, 2, 'every row, revoked included');
++ assert.strictEqual(file.content.events.length, 3, 'every event');
++ assert.ok(file.content.consents.every((x) => x.subject === ana.subject_id), "only ana's rows");
++ const bobPart = await accountData.networkPart(db, bob.id);
++ assert.ok(bobPart.files.find((f) => f.name === 'consents.json').content.consents.every((x) => x.subject === bob.subject_id), "only bob's");
++
++ // ── Merge: the folded-in account's choices are revoked, never carried over. ──
++ const survivor = await mk('conskeep');
++ const folded = await mk('consfold');
++ await consent.choose({ subject: survivor.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['streams'] });
++ await consent.choose({ subject: folded.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['vods'] });
++ await accountMerge.merge(db, folded, survivor, { initiatedBy: 'person' });
++ const foldedActive = await db.prepare('SELECT COUNT(*) AS n FROM consents WHERE subject = ? AND revoked_at IS NULL').get(folded.subject_id);
++ assert.strictEqual(foldedActive.n, 0, "the folded-in account's choice is revoked");
++ const foldedRow = await db.prepare('SELECT revoked_by FROM consents WHERE subject = ?').get(folded.subject_id);
++ assert.strictEqual(foldedRow.revoked_by, 'merge');
++ const survivorActive = await db.prepare('SELECT kinds FROM consents WHERE subject = ? AND revoked_at IS NULL').get(survivor.subject_id);
++ assert.deepStrictEqual(survivorActive.kinds, ['streams'], 'the survivor keeps its own');
++ const mergeEvent = await db.prepare("SELECT action, \"by\" FROM consent_events WHERE subject = ? AND \"by\" = 'merge'").get(folded.subject_id);
++ assert.ok(mergeEvent, 'the merge event is recorded');
++ assert.ok((await outbox(folded.subject_id)).some((e) => e.payload.on === false), 'the merge change is emitted');
++
++ // ── Deletion: revoke every active row, keep them. ──
++ const gone = await mk('consgone');
++ await consent.choose({ subject: gone.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['clips'] });
++ const delId = `del_${ids.ulid()}`;
++ await db.prepare(`INSERT INTO account_deletions (id, user_id, subject, status, requested_at, delete_after) VALUES (?, ?, ?, 'scheduled', ?, ?)`)
++ .run(delId, gone.id, gone.subject_id, '2026-10-30T12:00:00.000Z', '2026-11-29T12:00:00.000Z');
++ await accountData.erase(db, await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(delId), { now: Date.parse('2026-11-29T12:00:00Z') });
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM consents WHERE subject = ? AND revoked_at IS NULL').get(gone.subject_id)).n, 0, 'no active row after deletion');
++ const kept = await db.prepare('SELECT revoked_by FROM consents WHERE subject = ?').get(gone.subject_id);
++ assert.ok(kept, 'the row is kept as proof');
++ assert.strictEqual(kept.revoked_by, 'deletion');
++ const delEvent = await db.prepare("SELECT action, \"by\" FROM consent_events WHERE subject = ? AND \"by\" = 'deletion'").get(gone.subject_id);
++ assert.ok(delEvent, 'the deletion event is recorded');
++ assert.ok((await outbox(gone.subject_id)).some((e) => e.payload.on === false), 'the deletion change is emitted');
++
++ // ── The event is emitted after the commit: if it throws, the choice stays committed. ──
++ const afterUser = await mk('consafter');
++ let attempts = 0;
++ const boom = consentMod.createConsent({ db, emit: async () => { attempts++; throw new Error('emit after commit'); } });
++ await assert.rejects(() => boom.choose({ subject: afterUser.subject_id }, 'ai.train', { on: true, text_version: current, adult: true }), /emit after commit/);
++ assert.strictEqual(attempts, 1, 'the emit ran');
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM consents WHERE subject = ? AND revoked_at IS NULL').get(afterUser.subject_id)).n, 1, 'the transaction had committed before the emit');
++
++ // The default path queues network.consent.changed in Network's outbox, contract-shaped.
++ const wire = consentMod.createConsent({ db });
++ const wireUser = await mk('conswire');
++ await wire.choose({ subject: wireUser.subject_id }, 'ai.train', { on: true, text_version: current, adult: true, kinds: ['code'] });
++ const wired = await outbox(wireUser.subject_id);
++ assert.strictEqual(wired.length, 1, 'one change event');
++ assert.strictEqual(validate('events.event-envelope@1', wired[0]).valid, true);
++ assert.strictEqual(validate('network.consent.changed@1', wired[0].payload).valid, true);
++ assert.deepStrictEqual(wired[0].payload.kinds, ['code']);
++ assert.strictEqual(wired[0].payload.on, true);
++ assert.strictEqual(wired[0].visibility, 'internal');
++
++ // ── 30 writes a minute per person. ──
++ const busy = await mk('consbusy');
++ for (let i = 0; i < 30; i++) await consent.choose({ subject: busy.subject_id }, 'ai.train', { on: false });
++ await assert.rejects(() => consent.choose({ subject: busy.subject_id }, 'ai.train', { on: false }), (e) => e.status === 429 && e.code === 'consent.rate_limited');
++
++ // ── The page: kit fold rows, nothing pre-ticked. ──
++ const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'utf8');
++ assert.match(html, /id="consent-card"/);
++ assert.match(html, /Your data choices/);
++ assert.match(html, /href="\/shared\/kit\.css"/, 'the kit fold rows use the shared kit');
++ const cardStart = html.indexOf('id="consent-card"');
++ const card = html.slice(cardStart, html.indexOf('<div class="card">', cardStart));
++ assert.ok(!/\bchecked\b/.test(card), 'nothing in the static card is pre-ticked');
++ const pageJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'consent.js'), 'utf8');
++ assert.match(pageJs, /class="ovk-fold consent-fold"/, 'one kit fold row per purpose');
++ assert.match(pageJs, /I am 18 or older/, 'the 18+ confirmation is inside the row');
++ assert.match(pageJs, /data-consent-on/);
++ assert.match(pageJs, /data-consent-off/);
++ assert.ok(!/checked\s*>/.test(pageJs), 'the checkbox markup itself is never pre-checked');
++ assert.match(pageJs, /purpose\.kinds\.map/, 'the kinds are checkboxes');
++ assert.match(pageJs, /consent-licence/, 'the licence select is there');
++ assert.match(pageJs, /features are chosen in OpenVibe\.Vision/, 'Vision keeps the feature choice');
++ assert.match(pageJs, /consentDoc\.history/, 'the history is listed');
++
++ // ── The migration is expand-only, with the partial unique index and the purpose index. ──
++ const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0033_consents.sql'), 'utf8');
++ assert.match(sql, /^-- phase: expand$/m);
++ assert.ok(!/\bDROP TABLE\b/i.test(sql), 'expand only');
++ assert.match(sql, /CREATE UNIQUE INDEX IF NOT EXISTS consents_active[\s\S]*COALESCE\(client, ''\)[\s\S]*WHERE revoked_at IS NULL/);
++ assert.match(sql, /CREATE INDEX IF NOT EXISTS consents_purpose_subject ON consents \(purpose, subject\)/);
++ assert.match(sql, /CREATE TABLE IF NOT EXISTS consent_events/);
++ } finally {
++ server.close();
++ console.log = log;
++ }
++ console.log('consent: ok');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/my-modules.test.js b/test/my-modules.test.js
iindex 2181d3a9..ef44784e 100644
---- a/test/my-modules.test.js
++++ b/test/my-modules.test.js
@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
--const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'security.js', 'approvals.js', 'boot.js'];
++const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'consent.js', 'security.js', 'approvals.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex 17cb2b54..4ba7b2eb 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -68,7 +68,7 @@ const server = http.createServer(app);
const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
assert.ok(claims.ok, claims.reason);
assert.strictEqual(claims.claims.sub, 'svc:live');
-- assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.analytics.creator.read', 'network.avatar.write', 'network.coins.credit', 'network.coins.debit', 'network.coins.read', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push', 'network.registry.read']);
++ assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.analytics.creator.read', 'network.avatar.write', 'network.coins.credit', 'network.coins.debit', 'network.coins.read', 'network.consent.read', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push', 'network.registry.read']);
assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);
assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
const full = t.body.access_token;
@@@ -109,7 +109,9 @@ const server = http.createServer(app);
for (const [client, secret] of [['vision', 'vision-secret'], ['exchange', 'exchange-secret']]) {
await db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(secret, client);
t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.network' });
-- assert.deepStrictEqual(t.body.scope.split(' '), ['network.account.deletion.confirm', 'network.account.export.contribute'], `${client} answers account export and deletion, and holds no identity.subject.resolve`);
++ // Vision also asks the consent check (L24): it may recognise only people who turned it on.
++ const networkCaps = ['network.account.deletion.confirm', 'network.account.export.contribute', ...(client === 'vision' ? ['network.consent.read'] : [])];
++ assert.deepStrictEqual(t.body.scope.split(' '), networkCaps, `${client} answers account export and deletion, and holds no identity.subject.resolve`);
t = await token({ client_id: client, client_secret: secret, audience: 'openvibe.events' });
assert.strictEqual(t.body.scope, 'events.subscription.manage', `${client} subscribes to the account events at boot`);
}
@@@ -142,7 +144,7 @@ const server = http.createServer(app);
// and Live calls Cam's POST /api/v1/stills with it, so Cam holds no such grant and registers no capability.
await db.prepare("UPDATE oauth_clients SET client_secret = 'cam-secret' WHERE client_id = 'cam'").run();
t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.network' });
-- assert.deepStrictEqual(t.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute'], 'Cam answers account export and deletion and resolves a grant\'s @username');
++ assert.deepStrictEqual(t.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.consent.read'], 'Cam answers account export and deletion, resolves a grant\'s @username, and asks the consent check before recognising a face (L24)');
t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.events' });
assert.strictEqual(t.body.scope, 'events.subscription.manage', 'Cam subscribes to the account events at boot');
t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.live' });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 021ce305..5073ab12 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');