openvibers/openvibe.network Commits
L24 C1: the consent registry — data and AI by consent (#448)
Changes
nnew file mode 100644@@@ -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();@@@ -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",@@@ -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",@@@ -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; }++}++nnew file mode 100644@@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');++}++++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);++ }++}@@@ -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();@@@ -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>nnew file mode 100644@@@ -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,++};@@@ -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;}@@@ -129,6 +129,7 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubjconst 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', actorSubjreturn 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);}@@@ -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.@@@ -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 dailytimers.push(setInterval(async () => {@@@ -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.nnew file mode 100644@@@ -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); });@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst 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]);@@@ -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' });@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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();
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.57.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1595,9 +1595,8 @@
@@ -1595,9 +1595,8 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.57.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -264,3 +264,34 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -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 { 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[aria-pressed="true"] { color: var(--accent-light); }
.recent-tool-star:hover, .recent-tool-star:focus-visible { 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; }
+}
+
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
+}
+
+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);
+ }
+}
@@ -39,6 +39,7 @@ async function loadUser() {
@@ -39,6 +39,7 @@ async function loadUser() {
loadAccounts();
loadAccounts();
renderMerge();
renderMerge();
renderExport(); renderDeletion();
renderExport(); renderDeletion();
+ loadConsent();
if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
loadNotifPrefs(); loadQuietHours();
loadNotifPrefs(); loadQuietHours();
loadLinked();
loadLinked();
@@ -31,6 +31,8 @@
@@ -31,6 +31,8 @@
</script>
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<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">
<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>
</head>
<body>
<body>
@@ -266,6 +268,15 @@
@@ -266,6 +268,15 @@
<!-- AI & Data Section: the ai.preferences module and every module record kept with this account -->
<!-- AI & Data Section: the ai.preferences module and every module record kept with this account -->
<div class="section" id="sec-data">
<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">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AI preferences</h3>
<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>
<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 @@
@@ -385,6 +396,7 @@
<script src="/js/my/tools.js" defer></script>
<script src="/js/my/tools.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/profile.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/security.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/boot.js" defer></script>
<script src="/js/my/boot.js" defer></script>
new file mode 100644
new file mode 100644
@@ -0,0 +1,518 @@
@@ -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,
+};
@@ -190,6 +190,8 @@ async function networkPart(db, userId) {
@@ -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.
// 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')
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) : []);
? 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);
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('profile_changes.json', 'user_profile_changes');
await byUser('sign_in_log.json', 'ip_log');
await byUser('sign_in_log.json', 'ip_log');
@@ -327,6 +329,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -327,6 +329,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
const at = iso(now);
const at = iso(now);
const erased = {};
const erased = {};
const count = (k, n) => { if (n) erased[k] = (erased[k] || 0) + n; };
const count = (k, n) => { if (n) erased[k] = (erased[k] || 0) + n; };
+ let consentEvents = [];
const run = db.txFn(async () => {
const run = db.txFn(async () => {
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(deletion.user_id);
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}`);
if (!user) throw new Error(`account-data: no user ${deletion.user_id}`);
@@ -347,6 +350,9 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -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, '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);
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');
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.
// 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('dock_states', `subject IN ${inS}`, subjects, 'dock_states');
await del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
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() } = {}) {
@@ -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 eventRelay.writerFor(db).enqueue(envelope('network.account.deleted', payload, subject, at));
});
});
await run();
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);
kick(db);
return erased;
return erased;
}
}
@@ -129,6 +129,7 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
@@ -129,6 +129,7 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
const mergedAt = iso(now);
const mergedAt = iso(now);
const splitUntil = iso(now + SPLIT_DAYS * 86400000);
const splitUntil = iso(now + SPLIT_DAYS * 86400000);
const actor = actorSubject || into;
const actor = actorSubject || into;
+ let consentEvents = [];
const run = db.txFn(async () => {
const run = db.txFn(async () => {
const pre = {
const pre = {
user: { ...fromUser, password_hash: undefined },
user: { ...fromUser, password_hash: undefined },
@@ -162,6 +163,9 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
@@ -162,6 +163,9 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
}
}
const mods = await modules.onSubjectMerged(db, { from, into });
const mods = await modules.onSubjectMerged(db, { from, into });
moved.modules = mods.moved + (mods.filled || 0);
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.
// 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_follows')) dropped.follows = follows.onSubjectMerged ? (await follows.onSubjectMerged(db, { from, into })).dropped : 0;
if (await tableExists(db, 'user_blocks')) {
if (await tableExists(db, 'user_blocks')) {
@@ -205,6 +209,8 @@ async function merge(db, fromUser, intoUser, { initiatedBy = 'person', actorSubj
@@ -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);
return await db.prepare('SELECT * FROM account_merges WHERE id = ?').get(mergeId);
});
});
const row = await run();
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 */ }
try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ }
return resultOf(row, false);
return resultOf(row, false);
}
}
@@ -660,6 +660,12 @@ const DEFAULT_GRANTS = [
@@ -660,6 +660,12 @@ const DEFAULT_GRANTS = [
// Games submits each AI duel as a Bench task, two priced seat entries, and one automatic outcome.
// 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']
...['bench.task.create', 'bench.round.create', 'bench.entry.create', 'bench.judgement.cast']
.map((cap) => ['games', cap, 'openvibe.bench', []]),
.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.
// Grants withdrawn by decision; applied at every boot so an old default can't come back.
@@ -650,6 +650,14 @@ require('./developer/confirmations').start(db);
@@ -650,6 +650,14 @@ require('./developer/confirmations').start(db);
app.use('/api/v1/me/dock', require('./dock').router({ dock, requireAuth: requireDock('dock') }));
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)
// Notification API (authenticated users)
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth, requireDock));
app.use('/api/notifications', createNotificationRoutes(db, notificationService, requireAuth, requireDock));
@@ -992,6 +1000,12 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf
@@ -992,6 +1000,12 @@ const server = app.listen(...(socketActivated ? [{ fd: 3 }] : [config.port, conf
} catch (e) { console.warn('[AccountData] sweep failed:', e.message); }
} catch (e) { console.warn('[AccountData] sweep failed:', e.message); }
};
};
timers.push(setTimeout(sweepAccountData, 60 * 1000), setInterval(sweepAccountData, 2 * 60 * 1000));
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
// Clean expired sessions daily
timers.push(setInterval(async () => {
timers.push(setInterval(async () => {
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
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.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
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.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,337 @@
@@ -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); });
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -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();
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
// 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');
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 tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);
@@ -68,7 +68,7 @@ const server = http.createServer(app);
@@ -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' });
const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
assert.ok(claims.ok, claims.reason);
assert.ok(claims.ok, claims.reason);
assert.strictEqual(claims.claims.sub, 'svc:live');
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(validate('identity.service-token-claims@1', claims.claims).valid);
assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
const full = t.body.access_token;
const full = t.body.access_token;
@@ -109,7 +109,9 @@ const server = http.createServer(app);
@@ -109,7 +109,9 @@ const server = http.createServer(app);
for (const [client, secret] of [['vision', 'vision-secret'], ['exchange', 'exchange-secret']]) {
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);
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' });
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' });
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`);
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);
@@ -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.
// 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();
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' });
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' });
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');
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' });
t = await token({ client_id: 'cam', client_secret: 'cam-secret', audience: 'openvibe.live' });
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
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');
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
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');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');