openvibers/openvibe.network Commits

Apps ask for consent per purpose at sign-in (consent:<purpose> scopes) (#608)

OP OpenVibers committed 6 days ago · 26e16bec21 · 18 files, +1249 −75

* Apps ask for consent per purpose at sign-in (consent:<purpose> scopes)

An app adds one scope consent:<purpose> per purpose to /oauth/authorize; a purpose the
Contracts consent family does not define is refused with invalid_scope. After the person
picks an account, the sign-in page lists each purpose not answered yet as its own
checkbox, none ticked, and Continue works with none ticked. A ticked purpose is a
consents row (the app as client, source oauth, the screen's wording version); an
unticked one is a refusal in consent_refusals (migration 0044, expand only), not asked
again by that app for 30 days. Every token from the sign-in carries consent: the
purposes granted to that client and active now, worked out again on each refresh. The
account page lists each app's grants under Apps you allowed, with Turn off and Ask me
next time. One app's grant never counts for another app or for OpenVibe itself.

* Account page: a purpose row names the apps that still hold it

Off on a purpose row is OpenVibe's own choice since apps ask per purpose at sign-in; an app's grant is turned
off under Apps you allowed. The row read "Off by default" while an app still had the purpose, so a person
could believe every app had stopped. It now reads "Off for OpenVibe · N apps allowed" and names them, with
a link to Apps you allowed; re-rendering keeps open folds.

* Sign-in purposes step: say where an app's grant is turned off (Apps you allowed)

Changes

ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex 6bc19f76..b93d80d6 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -75,6 +75,7 @@ Tokens are RS256 and last 5 minutes. They are signed with Network's key (JWKS at
| `project_id` | `prj_<ULID>` (optional in the contract today; proposed as required for apps) |
| `env` | `sandbox` or `production` (same) |
| `on_behalf_of` | `usr_<ULID>` of the person who authorized the app (authorization code only) |
++| `consent` | the purposes the person granted the app when it asked with `consent:<purpose>` scopes, active at issue (`network.consent-claim@1`; authorization code only; `[]` when none, absent when none was asked). See [sign-in.md](sign-in.md#asking-for-consent-per-purpose-consentpurpose). |
| `iat`, `exp`, `jti` | 300-second lifetime |
No refresh tokens are issued to apps. A confidential app asks again with its secret, and a public app goes through authorization again.
ddiff --git a/docs/sign-in.md b/docs/sign-in.md
iindex 97af552b..21118f3d 100644
---- a/docs/sign-in.md
++++ b/docs/sign-in.md
@@@ -41,6 +41,39 @@ or as an anonymous account.
| `login_hint` | The account to continue as: a username (`@` optional) or an account id. Saved in this browser: continues as it. Not saved: the sign-in form with the username filled in. |
| `nonce` | Echoed in the `id_token` (scope `openid`). |
++## Asking for consent per purpose (`consent:<purpose>`)
++
++Every AI or data use of a person is off until that person turns it on (the consent plan, L24). An app, an OpenVibe
++site or a developer's app alike, asks for a purpose in the same sign-in by adding one scope per purpose beside its
++own scopes, for example `scope=profile consent:ai.train consent:app.data` (Contracts 0.307.0,
++`network.consent-claim@1`).
++
++| Scope | What the screen asks |
++|---|---|
++| `consent:ai.train` | **Train AI on your content** (18+) |
++| `consent:data.public` | **Put your content in an open dataset** (18+) |
++| `consent:face.recognize` | **Recognise your face** (18+) |
++| `consent:app.data` | **Use your content** |
++
++- **Only those.** A purpose the Contracts consent family does not define (the retired `ai.looks` included) is refused:
++ `/oauth/authorize` sends the app back to its `redirect_uri` with `error=invalid_scope`, and `/oauth/client-info` and
++ `/oauth/confirm` answer 400 `invalid_scope`.
++- **The screen.** After the person picks an account, the sign-in page lists each purpose the app asked for that the
++ person has not answered yet, in plain words (`server/consent/apps.js`, wording version `2026-11-07.1`). Each one is
++ its own checkbox, none ticked, and **Continue** works with none ticked. The 18+ purposes ask for "I am 18 or older"
++ unless the account already confirmed it. `prompt=none` never shows the screen: it asks nothing.
++- **Granted.** A ticked purpose is a `consents` row: the person, the app's client id, the purpose, when, the wording
++ version and `source` `oauth`, with a `network.consent.changed` event. The person turns it off under **Apps you
++ allowed** on the account page (`DELETE /api/v1/me/consent/:purpose/:client`).
++- **Refused.** A purpose left unticked is kept in `consent_refusals`, and that app does not ask for it again for 30 days.
++ The person can end that early with **Ask me next time** (`DELETE /api/v1/me/consent/:purpose/:client/refusal`).
++- **The token.** Every token from that sign-in carries the claim `consent`: the purposes it asked for that the person
++ granted to that app and that are active when the token is issued, `[]` when none. The claim is absent when no consent
++ scope was asked. A refresh works it out again, so a purpose turned off is gone from the next token. The token
++ response's `scope` names `consent:<purpose>` only for granted ones. An app never learns more than "not granted",
++ and another app's grants never count: not on the screen, not in the claim, not in `POST /internal/consent/check`
++ (which answers an app's grant only when the check names that app as `client`).
++
## Switching and adding accounts from a site
The shared navbar's account menu works this way on every site, whatever session the site keeps, and any site or app
ddiff --git a/migrations/0044_consent_refusals.sql b/migrations/0044_consent_refusals.sql
nnew file mode 100644
iindex 00000000..9dea1094
---- /dev/null
++++ b/migrations/0044_consent_refusals.sql
@@@ -0,0 +1,24 @@
++-- phase: expand
++-- Apps ask for consent per purpose at sign-in (the consent plan's C4, 2026-11-07; Contracts 0.307.0
++-- network.consent-claim@1). A purpose the person grants an app is a `consents` row (client = the app, source
++-- oauth) and needs nothing new. A purpose the person leaves unticked is a refusal, kept here so that app does not
++-- ask for it again for 30 days. One row per person, app and purpose, replaced when the screen asks again. Granting
++-- the purpose, or the person asking for it again on their account page, removes the row.
++--
++-- It holds no content and no choice: only the end of a quiet period. Account deletion and merge remove the
++-- subject's rows, and the account export carries them.
++--
++-- Expand only: an older release never reads this table, and nothing here changes an existing one.
++CREATE TABLE IF NOT EXISTS consent_refusals (
++ subject text COLLATE "C" NOT NULL,
++ client text COLLATE "C" NOT NULL,
++ purpose text NOT NULL CHECK (purpose IN ('ai.train', 'data.public', 'face.recognize', 'app.data')),
++ text_version text NOT NULL,
++ refused_at timestamptz NOT NULL,
++ quiet_until timestamptz NOT NULL,
++ PRIMARY KEY (subject, client, purpose),
++ CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CHECK (client ~ '^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$'),
++ CHECK (text_version ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}\.[0-9]{1,3}$'),
++ CHECK (quiet_until > refused_at)
++);
ddiff --git a/public/css/login.css b/public/css/login.css
iindex 1c5a782c..0b1e0974 100644
---- a/public/css/login.css
++++ b/public/css/login.css
@@@ -176,6 +176,30 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
.oauth-consent .consent-none,.oauth-consent .consent-refused{margin:0;color:var(--text-muted)}
.oauth-consent .consent-refused{margin-top:10px;font-size:12px}
.oauth-consent code{font-size:11.5px}
++.oauth-consent .consent-purposes{margin:10px 0 0;color:var(--text-muted)}
++.oauth-consent .consent-purposes b{color:var(--text);font-weight:600}
++
++/* The purposes step: an app asks for your permission, one purpose per row (consent:<purpose>). A whole row is one tap
++ target of at least 44 px, nothing is ticked until the person ticks it, and the page's own tokens colour it. */
++.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
++.purposes-head{font-size:14px;color:var(--text);line-height:1.5;margin-bottom:14px;outline:none}
++.purposes-head b{color:var(--accent);font-weight:700}
++.purposes-list{border:0;margin:0 0 12px;padding:0;min-width:0;display:grid;grid-template-columns:minmax(0,1fr);gap:8px}
++.purpose-item{display:flex;align-items:flex-start;gap:12px;min-height:44px;padding:12px 14px;border:1px solid var(--border);border-radius:12px;background:var(--bg-input);cursor:pointer;line-height:1.45;transition:border-color var(--ov-dur-2, .2s)}
++.purpose-item:hover{border-color:var(--accent-dark)}
++.purpose-item:has(.purpose-box:checked){border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-glow)}
++.purpose-item:focus-within{outline:2px solid var(--accent);outline-offset:2px}
++.purpose-box,.purpose-adult input{flex:none;width:22px;height:22px;margin:1px 0 0;accent-color:var(--accent);cursor:pointer}
++.purpose-text{display:grid;gap:3px;min-width:0;overflow-wrap:anywhere}
++.purpose-title{font-size:14px;font-weight:600;color:var(--text)}
++.purpose-text small{font-size:12.5px;color:var(--text-muted)}
++.purpose-badge{display:inline-block;margin-left:2px;padding:0 7px;border:1px solid var(--border);border-radius:999px;font-size:12px;font-weight:700;color:var(--text);vertical-align:1px}
++.purpose-adult{display:flex;align-items:center;gap:12px;min-height:44px;padding:10px 14px;margin-bottom:12px;border:1px dashed var(--border);border-radius:12px;font-size:13px;color:var(--text);cursor:pointer}
++.purpose-adult[hidden],.purposes-already[hidden]{display:none}
++.purposes-already b{color:var(--text);font-weight:600}
++#purposes-continue{min-height:48px}
++#purposes-continue:disabled{opacity:.7;cursor:progress}
++.purposes-foot{margin:14px 0 0}
.account-list{display:flex;flex-direction:column;gap:8px;margin-bottom:16px}
.account-item{
ddiff --git a/public/css/my.css b/public/css/my.css
iindex 6ba26d9c..b9353ffa 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -395,6 +395,16 @@ button.td-stat:hover span { color: var(--accent-light); }
.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: var(--danger-text, #ef4444); }
++/* Apps you allowed (C4): one block per app, one line per purpose with its own 44 px button. */
++.consent-apps-title { margin: 18px 0 8px; font-size: 14px; font-weight: 700; color: var(--text); }
++.consent-app { margin: 0 0 10px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 12px; }
++.consent-app-name { margin: 0 0 6px; overflow-wrap: anywhere; }
++.consent-app-list { list-style: none; margin: 0; padding: 0; }
++.consent-app-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 12px; padding: 8px 0; border-top: 1px solid var(--border); }
++.consent-app-row:first-child { border-top: 0; }
++.consent-app-text { display: grid; gap: 2px; min-width: 0; flex: 1 1 200px; font-size: 13px; overflow-wrap: anywhere; }
++.consent-app-text .ovk-muted { font-size: 12px; }
++.consent-app-btn { min-height: 44px; margin: 0; flex: none; }
@media (min-width: 600px) {
.consent-kind-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.consent-actions { grid-template-columns: 1fr 1fr; }
ddiff --git a/public/js/login.js b/public/js/login.js
iindex 64025c7e..687b91a6 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -46,9 +46,27 @@ if (isOAuth) {
*/
function renderConsent(info) {
const box = document.getElementById('oauth-consent');
-- if (!box || !info || !info.third_party) return;
++ const purposes = info && Array.isArray(info.purposes) ? info.purposes : [];
++ if (!box || !info || (!info.third_party && !purposes.length)) return;
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
box.replaceChildren();
++ // An app's permission questions (consent:<purpose>): named here, asked one by one after the account is chosen.
++ const asks = () => {
++ if (!purposes.length) return;
++ const p = el('p', 'consent-purposes', 'It will also ask your permission for: ');
++ purposes.forEach((x, i) => { if (i) p.append(', '); p.append(el('b', null, x.title || x.id)); });
++ p.append('. Each one stays off unless you tick it.');
++ box.append(p);
++ };
++ if (!info.third_party) {
++ const head = el('p', 'consent-head');
++ head.append(el('b', null, info.name), ' asks for your permission after you choose an account.');
++ box.append(head);
++ asks();
++ box.dataset.onlyPurposes = '1';
++ box.hidden = false;
++ return;
++ }
const head = el('p', 'consent-head');
head.append(el('b', null, info.name), ' ');
if (info.redirect_host) head.append(el('span', 'consent-host', `(${info.redirect_host})`), ' ');
@@@ -65,9 +83,10 @@ function renderConsent(info) {
list.append(li);
}
box.append(list);
-- } else {
++ } else if (!purposes.length) {
box.append(el('p', 'consent-none', 'It asks for nothing else: it will only learn which OpenVibe account you are.'));
}
++ asks();
const refused = Array.isArray(info.refused) ? info.refused : [];
if (refused.length) {
const p = el('p', 'consent-refused', 'It also asked for what it cannot be given, so it will not get: ');
@@@ -340,6 +359,117 @@ function escapeHtml(str) {
return d.innerHTML;
}
++// ── /oauth/confirm, and the purposes step ───────────────────
++/** POST /oauth/confirm for this sign-in. `consent` is the person's answer on the purposes step, when there was one. */
++async function postConfirm(token, consent) {
++ const res = await fetch(API_BASE + '/oauth/confirm', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({
++ token,
++ client_id: params.get('client_id'),
++ redirect_uri: params.get('redirect_uri'),
++ scope: params.get('scope') || 'profile theme',
++ state: params.get('state') || '',
++ code_challenge: params.get('code_challenge') || undefined,
++ code_challenge_method: params.get('code_challenge_method') || undefined,
++ nonce: params.get('nonce') || undefined,
++ ...(consent ? { consent } : {}),
++ }),
++ });
++ let data = {};
++ try { data = await res.json(); } catch { /* an empty answer is an error below */ }
++ return { res, data };
++}
++
++/** Confirm the sign-in; when the app asks for permission (consent:<purpose>), the person answers first. */
++async function confirmWithPurposes(token) {
++ let out = await postConfirm(token);
++ if (out.res.ok && out.data && out.data.consent) {
++ const before = (document.querySelector('.form-panel.active') || {}).id || 'panel-chooser';
++ oauthConfirmInFlight = false;
++ const answer = await askPurposes(out.data.consent);
++ oauthConfirmInFlight = true;
++ out = await postConfirm(token, answer);
++ // A refused answer goes back where the person was, so the caller's message shows there.
++ if (!out.res.ok) setActivePanel(before, { showTabs: before === 'panel-login' || before === 'panel-register', showExtras: true });
++ }
++ return out;
++}
++
++/**
++ * The purposes step (the consent plan's C4): the app asks for your permission, one purpose at a time, in the Network's
++ * own words. Every box starts unticked, and Continue works with none ticked: the app then learns only "not granted".
++ * A purpose marked 18+ asks for "I am 18 or older" unless the account already confirmed it. Built from DOM nodes.
++ * Resolves with the answer /oauth/confirm records: { shown, allow, adult, text_version }.
++ */
++function askPurposes(step) {
++ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
++ const ask = Array.isArray(step.ask) ? step.ask : [];
++ const granted = Array.isArray(step.granted) ? step.granted : [];
++ const head = document.getElementById('purposes-head');
++ head.replaceChildren(el('b', null, clientName || 'This app'), ' asks for your permission. Tick only what you want to allow.');
++ const list = document.getElementById('purposes-list');
++ list.replaceChildren(el('legend', 'sr-only', 'What this app may do'));
++ for (const p of ask) {
++ const item = el('label', 'purpose-item');
++ const box = el('input', 'purpose-box');
++ box.type = 'checkbox';
++ box.value = p.id;
++ box.checked = false;
++ const text = el('span', 'purpose-text');
++ const title = el('span', 'purpose-title', p.title || p.id);
++ if (p.adults_only) title.append(' ', el('span', 'purpose-badge', '18+'));
++ text.append(title, el('small', null, p.allows || ''));
++ if (p.cannot_undo) text.append(el('small', 'purpose-undo', `If you turn it off later: ${p.cannot_undo}`));
++ item.append(box, text);
++ list.append(item);
++ }
++ const needsAdult = !step.adult && ask.some((p) => p.adults_only);
++ const adultRow = document.getElementById('purposes-adult');
++ const adultBox = document.getElementById('purposes-adult-box');
++ adultBox.checked = false;
++ adultRow.hidden = !needsAdult;
++ const already = document.getElementById('purposes-already');
++ already.replaceChildren();
++ if (granted.length) {
++ already.append('You already allowed: ');
++ granted.forEach((g, i) => { if (i) already.append(', '); already.append(el('b', null, g.title || g.id)); });
++ already.append('.');
++ }
++ already.hidden = !granted.length;
++ const btn = document.getElementById('purposes-continue');
++ const label = () => {
++ const n = list.querySelectorAll('.purpose-box:checked').length;
++ btn.textContent = n ? `Allow ${n === 1 ? 'this' : `these ${n}`} and continue` : 'Continue without allowing any';
++ };
++ btn.disabled = false;
++ label();
++ list.onchange = label;
++ // The box above said "it will also ask"; now it is asking, so that line (or a site's whole box) steps aside.
++ const intro = document.getElementById('oauth-consent');
++ if (intro && intro.dataset && intro.dataset.onlyPurposes) intro.hidden = true;
++ else if (intro && intro.querySelector) { const line = intro.querySelector('.consent-purposes'); if (line) line.hidden = true; }
++ setActivePanel('panel-purposes', { showTabs: false, showExtras: false });
++ head.focus();
++ return new Promise((resolve) => {
++ document.getElementById('purposes-form').onsubmit = (e) => {
++ e.preventDefault();
++ const allow = [...list.querySelectorAll('.purpose-box:checked')].map((b) => b.value);
++ const adultOnly = allow.some((id) => (ask.find((p) => p.id === id) || {}).adults_only);
++ if (needsAdult && adultOnly && !adultBox.checked) {
++ showMsg('purposes-msg', 'Tick "I am 18 or older" to allow the ones marked 18+, or untick them.', 'error');
++ adultBox.focus();
++ return;
++ }
++ e.target.onsubmit = (ev) => ev.preventDefault();
++ btn.disabled = true;
++ btn.textContent = 'Continuing…';
++ resolve({ shown: ask.map((p) => p.id), allow, adult: adultBox.checked, text_version: step.text_version });
++ };
++ });
++}
++
// ── Account Selection ───────────────────────────────────────
async function selectAccount(account) {
if (oauthConfirmInFlight) return;
@@@ -435,24 +565,10 @@ async function guardScheduledDeletion(token, next) {
/** The part of a saved-account sign-in that leaves this page (an OAuth app, or back to the site). */
async function continueAsAccount(account, validToken) {
if (isOAuth) {
-- // Call /oauth/confirm to get the redirect URL
++ // Call /oauth/confirm to get the redirect URL (after the purposes step, when the app asks for permission)
try {
oauthConfirmInFlight = true;
-- const res = await fetch(API_BASE + '/oauth/confirm', {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify({
-- token: validToken,
-- client_id: params.get('client_id'),
-- redirect_uri: params.get('redirect_uri'),
-- scope: params.get('scope') || 'profile theme',
-- state: params.get('state') || '',
-- code_challenge: params.get('code_challenge') || undefined,
-- code_challenge_method: params.get('code_challenge_method') || undefined,
-- nonce: params.get('nonce') || undefined,
-- }),
-- });
-- const data = await res.json();
++ const { res, data } = await confirmWithPurposes(validToken);
if (!res.ok) {
if (res.status === 401) {
prefillAccount(account, 'Welcome back, ' + (account.display_name || account.username) + ' — enter your password to continue.');
@@@ -464,7 +580,7 @@ async function continueAsAccount(account, validToken) {
removeAccount(account.id);
return;
}
-- throw new Error(data.error || 'Authorization failed');
++ throw new Error(data.error_description || data.error || 'Authorization failed');
}
if (!data.redirect || isLoginLikeRedirect(data.redirect)) {
throw new Error('Invalid redirect target returned by OAuth server');
@@@ -632,28 +748,14 @@ async function finishSignIn(data) {
try {
if (isOAuth) {
oauthConfirmInFlight = true;
-- // Confirm OAuth with the new token
-- const confirmRes = await fetch(API_BASE + '/oauth/confirm', {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify({
-- token: data.token,
-- client_id: params.get('client_id'),
-- redirect_uri: params.get('redirect_uri'),
-- scope: params.get('scope') || 'profile theme',
-- state: params.get('state') || '',
-- code_challenge: params.get('code_challenge') || undefined,
-- code_challenge_method: params.get('code_challenge_method') || undefined,
-- nonce: params.get('nonce') || undefined,
-- }),
-- });
-- const confirmData = await confirmRes.json();
++ // Confirm OAuth with the new token (after the purposes step, when the app asks for permission)
++ const { res: confirmRes, data: confirmData } = await confirmWithPurposes(data.token);
if (!confirmRes.ok) {
if (confirmRes.status === 401 || confirmRes.status === 403) {
clearLocalAuthState();
throw new Error('Session invalid during redirect. Please sign in again.');
}
-- throw new Error(confirmData.error || 'Authorization failed');
++ throw new Error(confirmData.error_description || confirmData.error || 'Authorization failed');
}
if (!confirmData.redirect || isLoginLikeRedirect(confirmData.redirect)) {
throw new Error('Invalid redirect target returned by OAuth server');
ddiff --git a/public/js/my/consent.js b/public/js/my/consent.js
iindex 462e232e..e7f7e072 100644
---- a/public/js/my/consent.js
++++ b/public/js/my/consent.js
@@@ -5,6 +5,7 @@
modal. Classic script, global scope: it uses my/core.js's apiFetch/API and my/boot.js starts the page.
═══════════════════════════════════════════════════════════════ */
let consentDoc = null;
++let consentAppsDoc = null;
const CONSENT_KIND_LABEL = {
streams: 'Streams', vods: 'VODs', clips: 'Clips', voice: 'Voice', chat: 'Chat',
@@@ -24,9 +25,13 @@ function consentWhen(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. */
++/**
++ * The active rows of one purpose, newest first: OpenVibe's own choice. An app's grant of the purpose (C4) is under
++ * "Apps you allowed"; app.data is every app's row, since this row is where every app is turned off.
++ */
function consentActive(purposeId) {
-- return (consentDoc && consentDoc.consents ? consentDoc.consents : []).filter((c) => c.purpose === purposeId && c.on);
++ return (consentDoc && consentDoc.consents ? consentDoc.consents : [])
++ .filter((c) => c.purpose === purposeId && c.on && (purposeId === 'app.data' || !c.client));
}
/** One line of history, never HTML from the server. */
@@@ -41,11 +46,27 @@ function consentHistoryList(purposeId) {
}).join('')}</ul>`;
}
++/**
++ * The apps that still hold this purpose from a sign-in (C4). This row's Off is OpenVibe's own choice only, so the row
++ * must say when an app still has it: nobody should read "Off" and believe every app stopped.
++ */
++function consentAppHolders(purposeId) {
++ if (purposeId === 'app.data') return [];
++ const clients = [...new Set((consentDoc && consentDoc.consents ? consentDoc.consents : [])
++ .filter((c) => c.purpose === purposeId && c.on && c.client).map((c) => c.client))];
++ const named = (consentAppsDoc && consentAppsDoc.apps) || [];
++ return clients.map((client) => { const a = named.find((x) => x.client === client); return a ? a.name : client; });
++}
++
/** 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 holders = consentAppHolders(purpose.id);
++ const appsLabel = holders.length ? ` · ${holders.length === 1 ? '1 app' : `${holders.length} apps`} allowed` : '';
++ const state = (active.length ? 'On for OpenVibe' : holders.length ? 'Off for OpenVibe' : 'Off by default') + appsLabel;
++ const holdersNote = holders.length ? `<p class="consent-note consent-holders"><b>Still allowed for ${consentEsc(holders.join(', '))}.</b>
++ These buttons are OpenVibe's own choice. Turn an app off under <a href="#consent-apps">Apps you allowed</a>.</p>` : '';
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;
@@@ -69,7 +90,7 @@ function consentRow(purpose) {
<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}
++ ${holdersNote}${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>
@@@ -83,7 +104,10 @@ function renderConsent() {
const box = document.getElementById('consent-choices');
if (!box) return;
if (!consentDoc) { box.innerHTML = '<div class="loading-spinner">Loading your choices…</div>'; return; }
++ // A re-render (a save, or the apps arriving) keeps the folds the person had open.
++ const open = new Set([...box.querySelectorAll('details[open][data-consent-row]')].map((d) => d.getAttribute('data-consent-row')));
box.innerHTML = (consentDoc.purposes || []).map(consentRow).join('');
++ open.forEach((id) => { const d = box.querySelector(`details[data-consent-row="${CSS.escape(id)}"]`); if (d) d.open = true; });
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)));
}
@@@ -129,6 +153,93 @@ async function saveConsent(btn, on) {
}
}
++// ── Apps you allowed (C4) ───────────────────────────────────
++// What each app asked for when you signed in to it (consent:<purpose>): the purposes it was given, each with its own
++// Turn off, and the ones you said no to in the last 30 days, which that app does not ask for again until then unless
++// you press "Ask me next time". Built from DOM nodes; the app's name comes from its developer.
++
++function consentNode(tag, cls, text) {
++ const n = document.createElement(tag);
++ if (cls) n.className = cls;
++ if (text !== null && text !== undefined) n.textContent = text;
++ return n;
++}
++
++function consentDay(at) {
++ const d = new Date(at);
++ return Number.isNaN(d.getTime()) ? String(at) : d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
++}
++
++/** One purpose line of one app: its title, what it means, and its one button. */
++function consentAppRow(app, purposeId, line, buttonText, onPress) {
++ const words = ((consentAppsDoc && consentAppsDoc.purposes) || []).find((p) => p.id === purposeId) || { title: purposeId, allows: '' };
++ const row = consentNode('li', 'consent-app-row');
++ const text = consentNode('div', 'consent-app-text');
++ text.append(consentNode('b', null, words.title), consentNode('span', 'ovk-muted', line));
++ const btn = consentNode('button', 'btn btn-outline consent-app-btn', buttonText);
++ btn.type = 'button';
++ btn.setAttribute('aria-label', `${buttonText}: ${words.title}, ${app.name}`);
++ btn.addEventListener('click', () => onPress(btn));
++ row.append(text, btn);
++ return row;
++}
++
++function renderConsentApps() {
++ const box = document.getElementById('consent-apps');
++ if (!box) return;
++ box.replaceChildren();
++ if (!consentAppsDoc) { box.append(consentNode('div', 'loading-spinner', 'Loading your apps…')); return; }
++ const apps = consentAppsDoc.apps || [];
++ if (!apps.length) {
++ box.append(consentNode('p', 'ovk-muted consent-empty', 'No app has asked you for anything yet. An app asks when you sign in to it, and what you allow shows here.'));
++ return;
++ }
++ for (const app of apps) {
++ const card = consentNode('div', 'consent-app');
++ const head = consentNode('p', 'consent-app-name');
++ head.append(consentNode('b', null, app.name));
++ if (app.third_party) head.append(consentNode('span', 'ovk-muted', ' · third-party app'));
++ const list = consentNode('ul', 'consent-app-list');
++ for (const g of app.granted || []) {
++ list.append(consentAppRow(app, g.purpose, `Allowed on ${consentDay(g.granted_at)}`, 'Turn off',
++ (btn) => saveConsentApp(btn, `/api/v1/me/consent/${encodeURIComponent(g.purpose)}/${encodeURIComponent(app.client)}`, `${app.name} can no longer do that.`)));
++ }
++ for (const q of app.quiet || []) {
++ list.append(consentAppRow(app, q.purpose, `You said no. It won't ask again until ${consentDay(q.quiet_until)}.`, 'Ask me next time',
++ (btn) => saveConsentApp(btn, `/api/v1/me/consent/${encodeURIComponent(q.purpose)}/${encodeURIComponent(app.client)}/refusal`, `${app.name} will ask the next time you sign in to it.`)));
++ }
++ card.append(head, list);
++ box.append(card);
++ }
++}
++
++/** Turn one app's purpose off, or end a refusal's quiet days. The answer is the new "Apps you allowed". */
++async function saveConsentApp(btn, path, done) {
++ btn.disabled = true;
++ consentStatus('Saving…', false);
++ try {
++ consentAppsDoc = await apiFetch(path, { method: 'DELETE' });
++ renderConsentApps();
++ consentStatus(done, false);
++ loadConsent(); // the history shows the change
++ } catch (err) {
++ btn.disabled = false;
++ consentStatus('Could not save that (' + (err && err.status ? 'HTTP ' + err.status : 'network error') + '). Nothing changed.', true);
++ }
++}
++
++async function loadConsentApps() {
++ try {
++ consentAppsDoc = await apiFetch('/api/v1/me/consent/apps');
++ renderConsentApps();
++ if (consentDoc) renderConsent(); // the rows name the apps that still hold a purpose
++ } catch (err) {
++ consentAppsDoc = null;
++ const box = document.getElementById('consent-apps');
++ if (box) box.replaceChildren(consentNode('p', 'muted', 'Your apps could not be loaded. Nothing an app asked for is on unless you allowed it.'));
++ }
++}
++
async function loadConsent() {
const box = document.getElementById('consent-choices');
if (!box) return;
@@@ -136,6 +247,7 @@ async function loadConsent() {
try {
consentDoc = await apiFetch('/api/v1/me/consent');
renderConsent();
++ if (!consentAppsDoc) loadConsentApps();
} 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>';
ddiff --git a/public/login.html b/public/login.html
iindex 88c4d1ae..689fe508 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -98,6 +98,20 @@
<a class="inline-link" id="keep-continue" href="#" onclick="return false">Continue signing in</a>
</div>
++ <!-- An app asks for your permission per purpose (consent:<purpose>, js/login.js askPurposes): nothing is
++ ticked, and Continue works with nothing ticked. -->
++ <div class="form-panel" id="panel-purposes">
++ <p class="purposes-head" id="purposes-head" tabindex="-1"></p>
++ <div class="message" id="purposes-msg" role="alert"></div>
++ <form id="purposes-form" onsubmit="return false">
++ <fieldset class="purposes-list" id="purposes-list"></fieldset>
++ <label class="purpose-adult" id="purposes-adult" hidden><input type="checkbox" id="purposes-adult-box"> I am 18 or older</label>
++ <p class="panel-note purposes-already" id="purposes-already" hidden></p>
++ <button type="submit" class="submit-btn" id="purposes-continue">Continue without allowing any</button>
++ </form>
++ <p class="panel-note purposes-foot">Nothing is allowed unless you tick it. You can turn any of these off later under Apps you allowed on your account page.</p>
++ </div>
++
<!-- Register Form -->
<div class="form-panel" id="panel-register">
<div class="message" id="register-msg"></div>
ddiff --git a/public/my.html b/public/my.html
iindex 26a71290..215e9c38 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -329,6 +329,11 @@
<h3><span class="icon"><i class="fa-solid fa-circle-check"></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>
++ <!-- Apps you allowed (C4, 2026-11-07): what each app asked for when you signed in to it (consent:<purpose>)
++ and was given, one Turn off per purpose, and what you said no to in the last 30 days
++ (GET /api/v1/me/consent/apps). -->
++ <h4 class="consent-apps-title">Apps you allowed</h4>
++ <div id="consent-apps"><div class="loading-spinner">Loading your apps…</div></div>
<p class="muted consent-status" id="consent-status" role="status"></p>
</div>
<!-- What you see (L1, 2026-11-02): one row per content label, three equal choices, saved at once. The web default
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 5f648771..746f4b0f 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -17,6 +17,7 @@ const oidc = require('./oidc');
const refreshTokens = require('./refresh-tokens');
const dock = require('./dock-tokens');
const { isExtensionOrigin } = require('./extension-origin');
++const consentApps = require('../consent/apps');
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
@@@ -79,8 +80,26 @@ function cleanLoginHint(raw) {
return v && v.length <= 64 && /^@?[A-Za-z0-9_.-]+$/.test(v) ? v : '';
}
const withCode = (redirectUri, code, state) => `${redirectUri}${redirectUri.includes('?') ? '&' : '?'}code=${code}&state=${encodeURIComponent(state || '')}`;
++const withError = (redirectUri, error, description, state) => `${redirectUri}${redirectUri.includes('?') ? '&' : '?'}error=${error}`
++ + `&error_description=${encodeURIComponent(description)}&state=${encodeURIComponent(state || '')}`;
function getConfig(req) { return req.app.locals.config; }
++/**
++ * Consent per purpose at sign-in (the consent plan's C4; server/consent/apps.js). An app asks with consent:<purpose>
++ * scopes beside its own; a purpose the Contracts consent family does not define is refused with invalid_scope.
++ */
++const unknownConsent = (asked) => `unknown consent purpose: ${asked.unknown.join(' ')}`;
++/** The scope a code carries: the client's own scope (the first-party default when it has none) and the purposes asked. */
++function scopeWithConsent(scope, purposes) {
++ return purposes.length ? consentApps.joinScope(scope || 'profile theme', purposes) : scope;
++}
++/** The consent registry: the server's own (server/index.js), or one for this database when the routes run alone. */
++function consentOf(req) {
++ const locals = req.app.locals;
++ if (!locals.consent) locals.consent = require('../consent').createConsent({ db: locals.db });
++ return locals.consent;
++}
++
// ── GET /authorize ───────────────────────────────────────────
// OAuth2 authorization endpoint. Always shows the account chooser
// so the user can pick which account to continue with, add a new
@@@ -101,6 +120,8 @@ router.get('/authorize', async (req, res) => {
if (devTokens.isAppClient(client_id)) {
const found = await devTokens.checkAuthorizeRequest(db, req.query);
if (found.error) return res.status(400).json({ error: found.pkce ? 'invalid_request' : found.error, error_description: found.error });
++ const askedConsent = consentApps.parseScope(scope);
++ if (askedConsent.unknown.length) return res.redirect(withError(redirect_uri, 'invalid_scope', unknownConsent(askedConsent), state));
if (String(req.query.prompt || '') === 'none') {
return res.redirect(`${redirect_uri}${redirect_uri.includes('?') ? '&' : '?'}error=interaction_required&state=${encodeURIComponent(state || '')}`);
}
@@@ -120,10 +141,13 @@ router.get('/authorize', async (req, res) => {
if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
return res.status(400).json({ error: 'Invalid redirect_uri' });
}
-- const allowed = publicAuthorizeParams(client, req.query);
++ // The consent purposes are the person's to answer, never the client's allowed_scopes: split them off first.
++ const askedConsent = consentApps.parseScope(scope);
++ if (askedConsent.unknown.length) return res.redirect(withError(redirect_uri, 'invalid_scope', unknownConsent(askedConsent), state));
++ const allowed = publicAuthorizeParams(client, { ...req.query, scope: askedConsent.rest });
if (allowed.error) return res.status(400).json({ error: 'invalid_request', error_description: allowed.error });
const { pkce } = allowed;
-- const requestScope = allowed.scope;
++ const requestScope = scopeWithConsent(allowed.scope, askedConsent.purposes);
const requestState = allowed.state;
const nonce = oidc.cleanNonce(req.query.nonce);
if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
@@@ -193,19 +217,24 @@ router.get('/client-info', async (req, res) => {
if (!client_id || !redirect_uri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });
let host = '';
try { host = new URL(String(redirect_uri)).host; } catch { return res.status(400).json({ error: 'Invalid redirect_uri' }); }
++ // The purposes the app asks for, in the screen's words. Who the person is, and what they already answered, is
++ // /oauth/confirm's: this answer is the same for everyone.
++ const askedConsent = consentApps.parseScope(scope);
++ if (askedConsent.unknown.length) return res.status(400).json({ error: 'invalid_scope', error_description: unknownConsent(askedConsent) });
++ const purposes = consentApps.wordsFor(askedConsent.purposes);
if (devTokens.isAppClient(client_id)) {
const found = await devTokens.checkAuthorizeRequest(db, req.query);
if (found.error && !found.pkce) return res.status(404).json({ error: found.error });
const app = found.app || null;
if (!app) return res.status(404).json({ error: 'Unknown client_id' });
-- const { capabilities, refused } = devTokens.consentView(scope);
-- return res.json({ name: app.name, third_party: true, redirect_host: host, capabilities, refused });
++ const { capabilities, refused } = devTokens.consentView(askedConsent.rest);
++ return res.json({ name: app.name, third_party: true, redirect_host: host, capabilities, refused, purposes });
}
const client = await db.prepare('SELECT name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(client_id));
if (!client) return res.status(404).json({ error: 'Unknown client_id' });
const allowed = JSON.parse(client.redirect_uris || '[]').map(u => String(u).toLowerCase());
if (!allowed.includes(String(redirect_uri).toLowerCase())) return res.status(404).json({ error: 'Invalid redirect_uri' });
-- res.json({ name: siteNameForHost(host) || client.name || String(client_id), third_party: false, redirect_host: host, capabilities: [], refused: [] });
++ res.json({ name: siteNameForHost(host) || client.name || String(client_id), third_party: false, redirect_host: host, capabilities: [], refused: [], purposes });
});
// ── POST /oauth/confirm ─────────────────────────────────────
@@@ -220,6 +249,8 @@ router.post('/confirm', async (req, res) => {
if (!token || !client_id || !redirect_uri) {
return res.status(400).json({ error: 'token, client_id, and redirect_uri are required' });
}
++ const askedConsent = consentApps.parseScope(scope);
++ if (askedConsent.unknown.length) return res.status(400).json({ error: 'invalid_scope', error_description: unknownConsent(askedConsent) });
const devApp = devTokens.isAppClient(client_id) ? await devTokens.checkAuthorizeRequest(db, req.body) : null;
if (devApp && devApp.error) return res.status(400).json({ error: devApp.pkce ? 'invalid_request' : devApp.error, error_description: devApp.error });
@@@ -236,10 +267,10 @@ router.post('/confirm', async (req, res) => {
if (!allowedUris.some(u => u.toLowerCase() === uriLower)) {
return res.status(400).json({ error: 'Invalid redirect_uri' });
}
-- const allowed = publicAuthorizeParams(client, req.body);
++ const allowed = publicAuthorizeParams(client, { ...req.body, scope: askedConsent.rest });
if (allowed.error) return res.status(400).json({ error: 'invalid_request', error_description: allowed.error });
pkce = allowed.pkce;
-- requestScope = allowed.scope;
++ requestScope = scopeWithConsent(allowed.scope, askedConsent.purposes);
requestState = allowed.state;
}
const nonce = oidc.cleanNonce(req.body.nonce);
@@@ -257,6 +288,42 @@ router.post('/confirm', async (req, res) => {
}
const user = out.user;
++ // Consent per purpose (C4): before any code, the purposes this app asks for that the person has not answered yet.
++ // Without `consent` in the body the answer is the screen to show ({ consent: … }, no redirect); with it, the person's
++ // choice is recorded and the sign-in goes on, whatever they chose. Granted ones and those refused in the last 30
++ // days are not asked again. The app only ever sees the claim on its token, which names granted purposes alone.
++ if (askedConsent.purposes.length) {
++ if (devApp) {
++ const may = await devTokens.mayAuthorize(db, { app: devApp.app, project: devApp.project, user });
++ if (may.error) return res.status(may.status).json({ error: may.error });
++ }
++ const subject = await subjects.ensureUserSubject(db, user);
++ if (!subject) return res.status(403).json({ error: 'account has no subject id' });
++ const choice = req.body.consent;
++ if (choice === undefined || choice === null) {
++ const step = await consentApps.stepFor(db, { subject, client: String(client_id), purposes: askedConsent.purposes });
++ if (step.ask.length) {
++ return res.json({ consent: {
++ text_version: consentApps.APP_TEXT_VERSION,
++ ask: consentApps.wordsFor(step.ask),
++ granted: consentApps.wordsFor(step.granted),
++ adult: Boolean(user.adult_confirmed_at),
++ } });
++ }
++ } else {
++ if (typeof choice !== 'object' || Array.isArray(choice)) return res.status(400).json({ error: 'invalid_request', error_description: 'consent must be an object' });
++ try {
++ await consentOf(req).decideApp({ subject }, {
++ client: String(client_id), asked: askedConsent.purposes, shown: choice.shown, allow: choice.allow,
++ adult: choice.adult === true, adultConfirmed: Boolean(user.adult_confirmed_at), textVersion: choice.text_version,
++ });
++ } catch (err) {
++ if (err && err.status) return res.status(err.status).json({ error: err.code, error_description: err.message });
++ throw err;
++ }
++ }
++ }
++
if (devApp) {
const out = await devTokens.issueCode(db, { app: devApp.app, project: devApp.project, user, redirectUri: redirect_uri, scope, challenge: String(req.body.code_challenge) });
if (out.error) return res.status(out.status).json({ error: out.error });
@@@ -415,7 +482,7 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectU
refresh_token: refreshToken,
token_type: 'Bearer',
expires_in: issued.expiresIn,
-- scope: authCode.scope,
++ scope: issued.scope,
...(idToken ? { id_token: idToken } : {}),
...(publicClient
? { user: { id: user.id, username: user.username, display_name: user.display_name, avatar_url: user.avatar_url } }
@@@ -479,7 +546,7 @@ async function handleRefreshGrant(db, config, req, res, client, refreshToken) {
refresh_token: issued.refreshToken,
token_type: 'Bearer',
expires_in: issued.expiresIn,
-- scope: stored.scope,
++ scope: issued.scope,
});
}
@@@ -490,6 +557,13 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope
const ttl = publicClient ? Math.max(60, Math.min(86400, Number(client.access_token_ttl_s) || 3600)) : null;
const familyId = family.familyId || refreshTokens.newFamily();
const subjectId = await subjects.ensureUserSubject(db, user) || undefined;
++ // The consent claim (network.consent-claim@1): of the purposes this sign-in asked for, the ones the person granted to
++ // THIS client and that are active now. Worked out for every token, so a refresh after a revoke leaves it out.
++ const askedConsent = consentApps.parseScope(scope);
++ const consent = askedConsent.purposes.length
++ ? await consentApps.grantedFor(db, { subject: subjectId, client: client.client_id, purposes: askedConsent.purposes })
++ : null;
++ const consentClaim = consent ? { consent } : {};
const base = {
sub: user.id,
@@@ -503,8 +577,8 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope
// A Dock token is a read credential: no role and no staff caps, and a private issuer so no other
// service's normal user-token check accepts it. The family id lets Network end it on revoke.
const claims = publicClient
-- ? { ...base, client_id: client.client_id, scope: String(scope || ''), fam: familyId }
-- : { ...base, role: user.role, ...staffClaims(user) };
++ ? { ...base, client_id: client.client_id, scope: String(scope || ''), fam: familyId, ...consentClaim }
++ : { ...base, role: user.role, ...staffClaims(user), ...consentClaim };
const accessToken = jwt.sign(claims, privateKey, {
algorithm,
@@@ -513,13 +587,17 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope
expiresIn: publicClient ? ttl : config.jwt.accessTokenExpiry,
});
-- // 30 days; only its SHA-256 is stored. A sign-in starts a family; a refresh continues it.
++ // 30 days; only its SHA-256 is stored. A sign-in starts a family; a refresh continues it. The consent purposes asked
++ // for stay on it, so every refresh works the claim out again.
const issued = await refreshTokens.issue(db, {
-- clientId: client.client_id, userId: user.id, scope: publicClient ? String(scope || '') : 'profile theme',
++ clientId: client.client_id, userId: user.id,
++ scope: publicClient ? String(scope || '') : scopeWithConsent('profile theme', askedConsent.purposes),
familyId, generation: family.generation || 0,
});
-- return { accessToken, refreshToken: issued.token, expiresIn: publicClient ? ttl : 86400, familyId: issued.familyId };
++ // The token response's scope names a consent purpose only when it was granted (the app learns "not granted", no more).
++ const grantedScope = consent ? consentApps.joinScope(askedConsent.rest, consent) : scope;
++ return { accessToken, refreshToken: issued.token, expiresIn: publicClient ? ttl : 86400, familyId: issued.familyId, consent, scope: grantedScope };
}
/**
ddiff --git a/server/consent/apps.js b/server/consent/apps.js
nnew file mode 100644
iindex 00000000..de3dc0e9
---- /dev/null
++++ b/server/consent/apps.js
@@@ -0,0 +1,190 @@
++'use strict';
++/**
++ * Apps ask for consent per purpose at sign-in (the consent plan's C4, 2026-11-07; Contracts 0.307.0
++ * network.consent-claim@1).
++ *
++ * An app adds one scope `consent:<purpose>` per purpose to /oauth/authorize, beside its other scopes. It may ask only
++ * for a purpose the Contracts consent family defines (network.consent@1) AND that has words for the app screen here;
++ * the retired ai.looks has none. Any other `consent:` scope is refused with invalid_scope.
++ *
++ * The sign-in screen lists each purpose the person has not answered yet, nothing ticked. A ticked purpose becomes a
++ * `consents` row for (person, purpose, app) with source oauth (server/consent/index.js decideApp). A purpose left
++ * unticked is a refusal in `consent_refusals`, and that app does not ask for it again for 30 days, unless the person
++ * asks for it again on their account page ("Apps you allowed").
++ *
++ * The token claim (`consent`) holds the purposes the sign-in asked for that the person granted to THAT app and that are
++ * active when the token is issued. It is worked out again for every token, so a purpose turned off is gone from the
++ * next one. An app's grant never counts for OpenVibe itself or for another app: every query here names the client.
++ */
++const contracts = require('openvibe-contracts');
++
++const SCOPE_PREFIX = 'consent:';
++const APP_TEXT_VERSION = '2026-11-07.1';
++const QUIET_DAYS = 30;
++const QUIET_MS = QUIET_DAYS * 24 * 60 * 60 * 1000;
++const CLIENT_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,79}$/;
++
++/**
++ * The words the app consent screen shows, in network.consent@1's order. "It" is the app, named in the screen's heading.
++ * Plain words for anyone: what ticking it lets the app do, and what turning it off later cannot undo.
++ */
++const WORDS = [
++ {
++ id: 'ai.train',
++ title: 'Train AI on your content',
++ allows: 'It may use your streams, videos, voice, chat, posts, pictures and code to train or improve its AI.',
++ cannot_undo: 'A model it already trained keeps what it learned.',
++ adults_only: true,
++ },
++ {
++ id: 'data.public',
++ title: 'Put your content in an open dataset',
++ allows: 'It may publish your content in a dataset that anyone can download and use.',
++ cannot_undo: 'Copies people already downloaded stay out there.',
++ adults_only: true,
++ },
++ {
++ id: 'face.recognize',
++ title: 'Recognise your face',
++ allows: 'It may learn your face and recognise you in pictures and videos.',
++ cannot_undo: 'A match it already made cannot be unmade.',
++ adults_only: true,
++ },
++ {
++ id: 'app.data',
++ title: 'Use your content',
++ allows: 'It may read and use your streams, videos, posts and other content for its own features.',
++ cannot_undo: 'A copy it already made stays with the app.',
++ adults_only: false,
++ },
++];
++
++/** The purposes the pinned Contracts consent family defines. Nothing when the contract cannot be read (fail closed). */
++function familyPurposes() {
++ try {
++ const s = contracts.schema('network.consent@1');
++ return new Set((s && s.properties && s.properties.purpose && s.properties.purpose.enum) || []);
++ } catch { return new Set(); }
++}
++
++const FAMILY = familyPurposes();
++const APP_PURPOSES = Object.freeze(WORDS.filter((w) => FAMILY.has(w.id)).map((w) => Object.freeze({ ...w, text_version: APP_TEXT_VERSION })));
++const APP_PURPOSE_IDS = Object.freeze(APP_PURPOSES.map((p) => p.id));
++const BY_ID = new Map(APP_PURPOSES.map((p) => [p.id, p]));
++
++const inOrder = (ids) => APP_PURPOSE_IDS.filter((id) => ids.includes(id));
++const iso = (v) => (v instanceof Date ? v.toISOString() : new Date(String(v)).toISOString());
++
++/**
++ * Split a scope string into the consent purposes it asks for (known, deduplicated, in the family's order), the
++ * `consent:` scopes nobody defines (unknown: the request is refused with invalid_scope), and the rest, untouched.
++ */
++function parseScope(scope) {
++ const purposes = []; const unknown = []; const rest = [];
++ for (const token of String(scope == null ? '' : scope).split(/\s+/).filter(Boolean)) {
++ if (token.slice(0, SCOPE_PREFIX.length).toLowerCase() !== SCOPE_PREFIX) { rest.push(token); continue; }
++ const id = token.slice(SCOPE_PREFIX.length);
++ if (BY_ID.has(id)) { if (!purposes.includes(id)) purposes.push(id); } else unknown.push(token.slice(0, 64));
++ }
++ return { purposes: inOrder(purposes), unknown, rest: rest.join(' ') };
++}
++
++/** `base` plus one consent:<purpose> per purpose. */
++function joinScope(base, purposes) {
++ return [String(base || '').trim(), ...inOrder(purposes || []).map((id) => SCOPE_PREFIX + id)].filter(Boolean).join(' ');
++}
++
++/** The screen's words for these purposes (copies, in the family's order). */
++function wordsFor(ids) {
++ return inOrder(ids || []).map((id) => ({ ...BY_ID.get(id) }));
++}
++
++/**
++ * The claim: of `purposes`, the ones this person has granted to this app and that are active at `at`. One query, and
++ * it always names the client: another app's grant, or OpenVibe's own (client null), never counts.
++ */
++async function grantedFor(db, { subject, client, purposes, at = Date.now() }) {
++ const wanted = inOrder(purposes || []);
++ if (!subject || !client || !wanted.length) return [];
++ const rows = await db.prepare(`SELECT DISTINCT purpose FROM consents
++ WHERE subject = ? AND client = ? AND purpose = ANY(?) AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`)
++ .all(String(subject), String(client), wanted, new Date(at).toISOString());
++ return inOrder(rows.map((r) => r.purpose));
++}
++
++/**
++ * Where this person stands with this app for each purpose it asks for: granted, quiet (refused in the last 30 days)
++ * or to ask. Only the person's own sign-in sees it; the app sees the claim, which names granted purposes only.
++ */
++async function stepFor(db, { subject, client, purposes, at = Date.now() }) {
++ const wanted = inOrder(purposes || []);
++ if (!wanted.length) return { granted: [], quiet: [], ask: [] };
++ const granted = await grantedFor(db, { subject, client, purposes: wanted, at });
++ const quietRows = await db.prepare(`SELECT purpose FROM consent_refusals
++ WHERE subject = ? AND client = ? AND purpose = ANY(?) AND quiet_until > ?`)
++ .all(String(subject), String(client), wanted, new Date(at).toISOString());
++ const quiet = inOrder(quietRows.map((r) => r.purpose).filter((id) => !granted.includes(id)));
++ const ask = wanted.filter((id) => !granted.includes(id) && !quiet.includes(id));
++ return { granted, quiet, ask };
++}
++
++/** The apps' names for "Apps you allowed": a developer's app by its client id, an OpenVibe site by its OAuth client. */
++async function appNames(db, clients) {
++ const names = new Map();
++ if (!clients.length) return names;
++ const dev = clients.filter((c) => /^app_/.test(c));
++ if (dev.length) {
++ for (const r of await db.prepare('SELECT oauth_client_id, name FROM dev_apps WHERE oauth_client_id = ANY(?)').all(dev)) {
++ names.set(r.oauth_client_id, { name: r.name, third_party: true });
++ }
++ }
++ const firstParty = clients.filter((c) => !names.has(c));
++ if (firstParty.length) {
++ for (const r of await db.prepare('SELECT client_id, name FROM oauth_clients WHERE client_id = ANY(?)').all(firstParty)) {
++ names.set(r.client_id, { name: r.name, third_party: false });
++ }
++ }
++ return names;
++}
++
++/**
++ * "Apps you allowed" → network.me-consent-apps-result@1: every app with an active grant or a quiet refusal, apps with
++ * a grant first, then by name. Only the person's own session reaches it.
++ */
++async function appsOf(db, subject, { at = Date.now() } = {}) {
++ const atText = new Date(at).toISOString();
++ const grants = await db.prepare(`SELECT id, purpose, client, granted_at, text_version FROM consents
++ WHERE subject = ? AND client IS NOT NULL AND purpose = ANY(?) AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
++ ORDER BY granted_at, id`).all(String(subject), [...APP_PURPOSE_IDS], atText);
++ const quiet = await db.prepare(`SELECT client, purpose, refused_at, quiet_until FROM consent_refusals
++ WHERE subject = ? AND quiet_until > ? ORDER BY refused_at, purpose`).all(String(subject), atText);
++ const byClient = new Map();
++ const entry = (client) => {
++ if (!byClient.has(client)) byClient.set(client, { client, name: client, third_party: /^app_/.test(client), granted: [], quiet: [] });
++ return byClient.get(client);
++ };
++ for (const g of grants) {
++ if (!CLIENT_RE.test(String(g.client)) || !BY_ID.has(g.purpose)) continue;
++ entry(g.client).granted.push({ consent_id: g.id, purpose: g.purpose, granted_at: iso(g.granted_at), text_version: g.text_version });
++ }
++ for (const q of quiet) {
++ if (!CLIENT_RE.test(String(q.client)) || !BY_ID.has(q.purpose)) continue;
++ const e = entry(q.client);
++ if (e.granted.some((g) => g.purpose === q.purpose)) continue;
++ e.quiet.push({ purpose: q.purpose, refused_at: iso(q.refused_at), quiet_until: iso(q.quiet_until) });
++ }
++ const names = await appNames(db, [...byClient.keys()]);
++ const apps = [...byClient.values()].map((e) => {
++ const known = names.get(e.client);
++ const name = String((known && known.name) || e.client).trim().slice(0, 120) || e.client;
++ const sort = (list) => list.sort((a, b) => APP_PURPOSE_IDS.indexOf(a.purpose) - APP_PURPOSE_IDS.indexOf(b.purpose));
++ return { ...e, name, third_party: known ? known.third_party : e.third_party, granted: sort(e.granted).slice(0, 4), quiet: sort(e.quiet).slice(0, 4) };
++ });
++ apps.sort((a, b) => (b.granted.length > 0) - (a.granted.length > 0) || a.name.localeCompare(b.name) || a.client.localeCompare(b.client));
++ return { purposes: APP_PURPOSES.map((p) => ({ ...p })), apps: apps.slice(0, 200) };
++}
++
++module.exports = {
++ SCOPE_PREFIX, APP_TEXT_VERSION, QUIET_DAYS, QUIET_MS, CLIENT_RE, APP_PURPOSES, APP_PURPOSE_IDS, BY_ID,
++ parseScope, joinScope, wordsFor, grantedFor, stepFor, appsOf,
++};
ddiff --git a/server/consent/index.js b/server/consent/index.js
iindex 503de21c..0be94748 100644
---- a/server/consent/index.js
++++ b/server/consent/index.js
@@@ -11,6 +11,9 @@
* 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
++ * decideApp(actor, { client, asked, shown, allow, ... }) the person's answer on an app's sign-in screen (C4, ./apps.js)
++ * appsOf(actor) → network.me-consent-apps-result@1: "Apps you allowed"
++ * forgetRefusal(actor, purpose, client) ends a refusal's quiet days, so the app's next sign-in asks again
*
* 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
@@@ -25,6 +28,7 @@
const express = require('express');
const { ids, validate, http } = require('openvibe-contracts');
const eventRelay = require('../developer/event-relay');
++const apps = require('./apps');
const TEXT_VERSION = '2026-10-31.1';
const KINDS = ['streams', 'vods', 'clips', 'voice', 'chat', 'posts', 'pictures', 'code'];
@@@ -204,6 +208,8 @@ async function revokeForSubject(t, subject, by, at) {
.run(row.id, row.subject, row.purpose, row.client, 'revoked', by, row.text_version, at);
payloads.push(eventPayload({ ...row, revoked_at: at }, false, at));
}
++ // A refusal only keeps an app quiet; it is the account's, so it goes with the account.
++ await t.prepare('DELETE FROM consent_refusals WHERE subject = ?').run(subject);
return payloads;
}
@@@ -225,7 +231,10 @@ async function exportOf(db, 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 purposes the person refused an app at sign-in (C4), and when each quiet period ends.
++ const refusals = (await db.prepare('SELECT client, purpose, text_version, refused_at, quiet_until FROM consent_refusals WHERE subject = ? ORDER BY refused_at, client, purpose').all(subject))
++ .map((r) => ({ client: r.client, purpose: r.purpose, text_version: r.text_version, refused_at: iso(r.refused_at), quiet_until: iso(r.quiet_until) }));
++ return { consents, events, refusals };
}
/**
@@@ -318,9 +327,11 @@ function createConsent({ db, emit, now = () => Date.now(), log = console } = {})
});
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).
++ // Off: OpenVibe's own row goes. For app.data every app's row goes too (the page's row is where every app is
++ // turned off). An app's grant of another purpose (C4) is turned off under "Apps you allowed", one at a time.
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')
++ const active = await t.prepare(`SELECT * FROM consents WHERE subject = ? AND purpose = ? AND revoked_at IS NULL
++ ${purpose.id === 'app.data' ? '' : 'AND client IS NULL '}FOR UPDATE`)
.all(actor.subject, purpose.id);
const out = [];
for (const old of active) {
@@@ -339,8 +350,12 @@ function createConsent({ db, emit, now = () => Date.now(), log = console } = {})
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) {
++ /**
++ * One app's grant of one purpose ends: the person turning it off under "Apps you allowed" (by 'person'), or the app
++ * being revoked (by 'app_revoked').
++ */
++ async function revokeApp(actor, purposeId, client, { by = 'app_revoked' } = {}) {
++ if (!['person', 'app_revoked'].includes(by)) throw new TypeError('revokeApp: by is person or app_revoked');
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');
@@@ -352,9 +367,9 @@ function createConsent({ db, emit, now = () => Date.now(), log = console } = {})
.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('UPDATE consents SET revoked_at = ?, revoked_by = ? WHERE id = ? AND revoked_at IS NULL').run(atText, by, 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);
++ .run(old.id, old.subject, old.purpose, old.client, 'revoked', by, old.text_version, atText);
out.push(eventPayload({ ...old, revoked_at: atText }, false, atText));
}
return out;
@@@ -448,7 +463,82 @@ function createConsent({ db, emit, now = () => Date.now(), log = console } = {})
return payloads.length;
}
-- return { mine, choose, revokeApp, check, sweep, _writes: writes };
++ /**
++ * The person's answer on an app's sign-in screen (C4). `asked` are the purposes the app's scope asked for, `shown`
++ * the ones the screen listed, `allow` the ones the person ticked (nothing is ticked until they do). Each allowed
++ * purpose becomes an active row for (person, purpose, app) with source oauth, unless one is already active. Each
++ * shown purpose left unticked is a refusal: the app does not ask for it again for 30 days. A purpose the screen did
++ * not show is neither. Allowing none at all is a valid answer, and the sign-in goes on.
++ * adult the person ticked "I am 18 or older" on the screen
++ * adultConfirmed the account already confirmed it (users.adult_confirmed_at)
++ */
++ async function decideApp(actor, { client, asked = [], shown = [], allow = [], adult = false, adultConfirmed = false, textVersion } = {}) {
++ const name = String(client || '');
++ if (!apps.CLIENT_RE.test(name)) fail(400, 'consent.invalid_client', 'that is not an app id');
++ const list = (v) => (Array.isArray(v) ? [...new Set(v.map(String))] : []);
++ const askedIds = list(asked);
++ if (askedIds.some((id) => !apps.BY_ID.has(id))) fail(400, 'invalid_scope', 'the app asked for a purpose that does not exist');
++ const shownIds = list(shown);
++ const allowIds = list(allow);
++ if (shownIds.some((id) => !askedIds.includes(id))) fail(422, 'consent.not_asked', 'the app did not ask for that');
++ if (allowIds.some((id) => !shownIds.includes(id))) fail(422, 'consent.not_shown', 'only a purpose the screen showed can be allowed');
++ if (shownIds.length && textVersion !== apps.APP_TEXT_VERSION) {
++ fail(409, 'consent.stale_text', 'the wording changed; read the new words and choose again');
++ }
++ if (allowIds.some((id) => apps.BY_ID.get(id).adults_only) && adult !== true && !adultConfirmed) {
++ fail(422, 'consent.adult_required', 'tick "I am 18 or older" to allow that, or leave it unticked');
++ }
++ const at = now();
++ const atText = atIso(at);
++ const quietUntil = atIso(at + apps.QUIET_MS);
++ if (shownIds.length) mayWrite(actor.subject, at);
++
++ const payloads = await db.tx(async (t) => {
++ const out = [];
++ const activeRow = async (id) => await t.prepare('SELECT id FROM consents WHERE subject = ? AND purpose = ? AND client = ? AND revoked_at IS NULL FOR UPDATE')
++ .get(actor.subject, id, name);
++ for (const id of shownIds) {
++ if (await activeRow(id)) continue; // already allowed: the screen changes nothing (turning off is on the account page)
++ if (allowIds.includes(id)) {
++ const row = { id: `cns_${ids.ulid(at)}`, subject: actor.subject, purpose: id, kinds: [], client: name, options: {},
++ text_version: apps.APP_TEXT_VERSION, source: 'oauth', granted_at: atText, revoked_at: null, expires_at: null };
++ const added = 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)
++ ON CONFLICT (subject, purpose, COALESCE(client, '')) WHERE revoked_at IS NULL DO NOTHING`)
++ .run(row.id, row.subject, row.purpose, row.kinds, name, JSON.stringify(row.options), row.text_version, row.source, atText);
++ await t.prepare('DELETE FROM consent_refusals WHERE subject = ? AND client = ? AND purpose = ?').run(actor.subject, name, id);
++ if (added.changes !== 1) continue; // a second tab granted it a moment ago
++ 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, name, 'granted', 'person', row.text_version, atText);
++ out.push(eventPayload(row, true, atText));
++ } else {
++ await t.prepare(`INSERT INTO consent_refusals (subject, client, purpose, text_version, refused_at, quiet_until) VALUES (?, ?, ?, ?, ?, ?)
++ ON CONFLICT (subject, client, purpose) DO UPDATE SET text_version = excluded.text_version, refused_at = excluded.refused_at, quiet_until = excluded.quiet_until`)
++ .run(actor.subject, name, id, apps.APP_TEXT_VERSION, atText, quietUntil);
++ }
++ }
++ return out;
++ });
++ for (const payload of payloads) await send(payload);
++ return { granted: await apps.grantedFor(db, { subject: actor.subject, client: name, purposes: askedIds, at: now() }) };
++ }
++
++ /** "Apps you allowed" for the signed-in person → network.me-consent-apps-result@1. */
++ async function appsOf(actor) {
++ return await apps.appsOf(db, actor.subject, { at: now() });
++ }
++
++ /** The person asks for a refused purpose again: the quiet days end now, so the app's next sign-in asks. */
++ async function forgetRefusal(actor, purposeId, client) {
++ if (!apps.BY_ID.has(String(purposeId || ''))) fail(400, 'consent.unknown_purpose', `no app consent purpose '${String(purposeId || '').slice(0, 40)}'`);
++ const name = String(client || '');
++ if (!apps.CLIENT_RE.test(name)) fail(400, 'consent.invalid_client', 'that is not an app id');
++ mayWrite(actor.subject, now());
++ await db.prepare('DELETE FROM consent_refusals WHERE subject = ? AND client = ? AND purpose = ?').run(actor.subject, name, String(purposeId));
++ return await appsOf(actor);
++ }
++
++ return { mine, choose, revokeApp, check, sweep, decideApp, appsOf, forgetRefusal, _writes: writes };
}
/** The person's routes, behind Network's session check (like the Dock's), and never cached. */
@@@ -482,10 +572,26 @@ function router({ consent, requireAuth }) {
} catch (err) { problem(res, err, req, 'consent.error'); }
});
++ // "Apps you allowed" (C4): what each app was granted or refused at sign-in. Before /:purpose so 'apps' is never a purpose.
++ r.get('/apps', requireAuth, async (req, res) => {
++ try {
++ res.json(await consent.appsOf(await actorOf(req)));
++ } catch (err) { problem(res, err, req, 'consent.error'); }
++ });
++
++ // The person turns one app's purpose off. The account page's answer is "Apps you allowed".
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));
++ await consent.revokeApp(actor, req.params.purpose, req.params.client, { by: 'person' });
++ res.json(await consent.appsOf(actor));
++ } catch (err) { problem(res, err, req, 'consent.error'); }
++ });
++
++ // The person asks for a refused purpose again: that app's next sign-in asks.
++ r.delete('/:purpose/:client/refusal', requireAuth, async (req, res) => {
++ try {
++ res.json(await consent.forgetRefusal(await actorOf(req), req.params.purpose, req.params.client));
} catch (err) { problem(res, err, req, 'consent.error'); }
});
@@@ -512,6 +618,7 @@ function internalRouter({ consent }) {
}
module.exports = {
++ apps,
PURPOSES, PURPOSE_BY_ID, TEXT_VERSION, KINDS, WRITE_LIMIT, WRITE_WINDOW_MS, MAX_SUBJECTS,
createConsent, router, internalRouter, eventEnvelope, publish, exportOf, revokeForMerge, revokeForDeletion,
ConsentError, CHOICE_CONTRACT, REQUEST_CONTRACT, RESULT_CONTRACT, MINE_CONTRACT,
ddiff --git a/server/developer/tokens.js b/server/developer/tokens.js
iindex 63c6058c..d5695901 100644
---- a/server/developer/tokens.js
++++ b/server/developer/tokens.js
@@@ -21,11 +21,15 @@
* within their 5-minute lifetime.
* With `agent=agt_…` (client_credentials only) the app asks for a token of an agent it hosts instead
* (./agent-tokens.js, plan T2 WS-Z2 slice 8); without it, app tokens are unchanged.
++ * Consent per purpose (the consent plan's C4, Contracts 0.307.0): `consent:<purpose>` scopes ride on the code beside
++ * the capability ids, and the authorization-code token carries `consent`, the purposes the person granted this app
++ * that are active at mint (network.consent-claim@1). They are never capabilities and never widen `cap`.
*/
const crypto = require('crypto');
const { serviceAuth, assertValid } = require('openvibe-contracts');
const policy = require('./policy');
const store = require('./store');
++const consentApps = require('../consent/apps');
const TOKEN_TTL_S = 300;
const CODE_TTL_MS = 5 * 60 * 1000;
@@@ -93,7 +97,7 @@ function consentView(scope) {
return { capabilities, refused };
}
--async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db, allowEmpty }) {
++async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db, allowEmpty, consent }) {
const aud = String(audience || '').trim();
if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauthError(400, 'invalid_request', 'audience is required');
if (app.environment === 'sandbox' && !settings.sandboxAudiences.has(aud)) {
@@@ -113,10 +117,12 @@ async function mint({ app, project, audience, scope, limit, onBehalfOf, privateK
iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: projectNamespaces(project.id),
project_id: project.id, env: app.environment,
...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),
++ ...(consent ? { consent } : {}),
iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
};
assertValid('identity.service-token-claims@1', claims);
-- return { status: 200, body: { access_token: serviceAuth.signServiceToken(claims, privateKey), token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: cap.join(' ') } };
++ const scopeOut = consent ? consentApps.joinScope(cap.join(' '), consent) : cap.join(' ');
++ return { status: 200, body: { access_token: serviceAuth.signServiceToken(claims, privateKey), token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: scopeOut } };
}
// ── Export tokens (roadmap WS-N task 9) ─────────────────────────
@@@ -228,10 +234,20 @@ async function handleTokenRequest(db, body, { privateKey, issuer, config, ctx })
// The consent the code carries is explicit: `row.scope` is exactly the grantable ids the
// authorize requested, and an empty set means the person consented to nothing. The exchange
// may narrow it, never widen it, and a no-consent code still yields an identifying token.
-- const consented = String(row.scope || '').split(/\s+/).filter(Boolean);
-- const asked = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : null;
++ // The consent:<purpose> scopes beside them are the purposes the app asked the person for (C4).
++ const onCode = consentApps.parseScope(row.scope);
++ const consented = onCode.rest.split(/\s+/).filter(Boolean);
++ const wanted = body.scope ? consentApps.parseScope(body.scope) : null;
++ if (wanted && wanted.unknown.length) return oauthError(400, 'invalid_scope', 'unknown consent purpose');
++ const asked = wanted ? wanted.rest.split(/\s+/).filter(Boolean) : null;
if (asked && asked.some(s => !consented.includes(s))) return oauthError(400, 'invalid_scope', 'scope exceeds what the user authorized');
-- return await mint({ app, project, audience: body.audience, scope: body.scope, limit: consented, allowEmpty: consented.length === 0, onBehalfOf: row.user_subject, privateKey, issuer, settings, db });
++ if (wanted && wanted.purposes.some(p => !onCode.purposes.includes(p))) return oauthError(400, 'invalid_scope', 'scope exceeds what the user authorized');
++ const purposes = wanted && wanted.purposes.length ? wanted.purposes : onCode.purposes;
++ const consent = purposes.length
++ ? await consentApps.grantedFor(db, { subject: row.user_subject, client: app.oauth_client_id, purposes })
++ : null;
++ return await mint({ app, project, audience: body.audience, scope: asked && asked.length ? asked.join(' ') : undefined, limit: consented,
++ allowEmpty: consented.length === 0, onBehalfOf: row.user_subject, privateKey, issuer, settings, db, consent });
}
return oauthError(400, 'unsupported_grant_type', 'apps use client_credentials or authorization_code');
}
@@@ -259,26 +275,39 @@ async function checkAuthorizeRequest(db, { client_id, redirect_uri, code_challen
}
/**
-- * Issue an authorization code to an app for a signed-in user. A sandbox app may only be authorized
-- * by members of its project. Returns { code } or { status, error }.
++ * May this signed-in user authorize this app? A sandbox app may only be authorized by members of
++ * its project. Returns { subject } or { status, error }.
*/
--async function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {
++async function mayAuthorize(db, { app, project, user }) {
const subjects = require('../identity/subjects');
const subject = await subjects.ensureUserSubject(db, user);
if (!subject) return { status: 403, error: 'account has no subject id' };
if (app.environment === 'sandbox' && !await store.memberRole(db, project.id, subject)) {
return { status: 403, error: 'sandbox apps can only be authorized by members of their project' };
}
++ return { subject };
++}
++
++/**
++ * Issue an authorization code to an app for a signed-in user (see mayAuthorize). The code carries
++ * the consented capability ids and the consent:<purpose> scopes the app asked for (C4).
++ * Returns { code } or { status, error }.
++ */
++async function issueCode(db, { app, project, user, redirectUri, scope, challenge }) {
++ const may = await mayAuthorize(db, { app, project, user });
++ if (may.error) return may;
++ const { subject } = may;
const code = crypto.randomBytes(32).toString('hex');
++ const onCode = consentApps.joinScope(consentedCapabilities(scope).join(' '), consentApps.parseScope(scope).purposes);
await db.prepare(`INSERT INTO dev_auth_codes (code_hash, app_id, user_subject, redirect_uri, scope, code_challenge, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(store.hashSecret(code), app.id, subject, redirectUri,
-- consentedCapabilities(scope).join(' '), challenge, new Date(Date.now() + CODE_TTL_MS).toISOString());
++ onCode, challenge, new Date(Date.now() + CODE_TTL_MS).toISOString());
await db.prepare('DELETE FROM dev_auth_codes WHERE expires_at < ?').run(new Date(Date.now() - 3600 * 1000).toISOString());
return { code };
}
module.exports = {
-- isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, effectiveGrants, projectNamespaces, TOKEN_TTL_S,
++ isAppClient, handleTokenRequest, checkAuthorizeRequest, issueCode, mayAuthorize, effectiveGrants, projectNamespaces, TOKEN_TTL_S,
consentedCapabilities, consentView,
mintExportToken, exportSubject, EXPORT_CAPS, EXPORT_PURPOSE,
};
ddiff --git a/server/index.js b/server/index.js
iindex 7af8e588..67beda8b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -793,6 +793,7 @@ require('./developer/confirmations').start(db);
// 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.locals.consent = consent; // /oauth/confirm records an app's sign-in answer through it (C4)
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 }));
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 56c204b1..de5a203d 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', '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'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/consent-account-holders.test.js b/test/consent-account-holders.test.js
nnew file mode 100644
iindex 00000000..eb545c65
---- /dev/null
++++ b/test/consent-account-holders.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++// The account page's purpose rows (public/js/my/consent.js) and app grants from sign-in (C4, 2026-11-07).
++// A row's On/Off is OpenVibe's own choice; an app's grant of the same purpose is turned off under "Apps you allowed".
++// So a row must never read plain "Off" while an app still holds the purpose. What this proves:
++// - with only an app's grant, the row reads "Off for OpenVibe · 1 app allowed" and names the app (escaped);
++// - with no grant at all it still reads "Off by default" and carries no app note;
++// - app.data is unchanged: its row is every app's, so it never shows the note;
++// - a revoked (off) app row does not count.
++// node test/consent-account-holders.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'consent.js'), 'utf8');
++const ctx = { document: { getElementById: () => null }, CSS: { escape: (s) => s }, apiFetch: async () => ({}), console };
++vm.createContext(ctx);
++vm.runInContext(`${source}
++;globalThis.__t = { consentRow, consentAppHolders, set(doc, apps) { consentDoc = doc; consentAppsDoc = apps; } };`, ctx);
++const t = ctx.__t;
++
++const purpose = (id, extra = {}) => ({ id, title: id, allows: 'a', off: 'o', cannot_undo: '', kinds: [], adults_only: false, text_version: 'v', ...extra });
++const row = (p, client, on = true) => ({ purpose: p, client, on, kinds: [], options: {} });
++
++let n = 0;
++const check = (name, fn) => { fn(); n++; console.log(` ✓ ${name}`); };
++
++check('an app still holding a purpose is named on the row, and the row does not read plain Off', () => {
++ t.set({ purposes: [], consents: [row('ai.train', 'app_01')], history: [] },
++ { apps: [{ client: 'app_01', name: 'Clip <Bot>' }] });
++ assert.deepStrictEqual([...t.consentAppHolders('ai.train')], ['Clip <Bot>']);
++ const html = t.consentRow(purpose('ai.train'));
++ assert.match(html, /Off for OpenVibe · 1 app allowed/);
++ assert.doesNotMatch(html, /Off by default/);
++ assert.match(html, /Still allowed for Clip &lt;Bot&gt;\./);
++ assert.match(html, /href="#consent-apps"/);
++});
++
++check('OpenVibe on plus two apps says both', () => {
++ t.set({ purposes: [], consents: [row('ai.train', null), row('ai.train', 'app_01'), row('ai.train', 'app_02')], history: [] }, null);
++ const html = t.consentRow(purpose('ai.train'));
++ assert.match(html, /On for OpenVibe · 2 apps allowed/);
++ assert.match(html, /Still allowed for app_01, app_02\./, 'before the app names load, the client ids stand in');
++});
++
++check('no grant anywhere still reads "Off by default" with no app note', () => {
++ t.set({ purposes: [], consents: [row('ai.train', 'app_01', false)], history: [] }, null);
++ const html = t.consentRow(purpose('ai.train'));
++ assert.match(html, /Off by default/);
++ assert.doesNotMatch(html, /Still allowed/);
++});
++
++check('app.data keeps its own row for every app and never shows the note', () => {
++ t.set({ purposes: [], consents: [row('app.data', 'app_01')], history: [] }, null);
++ assert.deepStrictEqual([...t.consentAppHolders('app.data')], []);
++ assert.doesNotMatch(t.consentRow(purpose('app.data')), /Still allowed/);
++});
++
++console.log(`consent-account-holders: ${n} passed`);
ddiff --git a/test/consent-purpose-scopes.test.js b/test/consent-purpose-scopes.test.js
nnew file mode 100644
iindex 00000000..86e59d8d
---- /dev/null
++++ b/test/consent-purpose-scopes.test.js
@@@ -0,0 +1,385 @@
++'use strict';
++// Apps ask for consent per purpose at sign-in (the consent plan's C4, 2026-11-07; Contracts 0.307.0
++// network.consent-claim@1). What this proves:
++// - an unknown purpose (or the retired ai.looks) is refused with invalid_scope at authorize, client-info and confirm;
++// - nothing is pre-ticked: the screen's words carry no default, the step asks, and the page builds unticked boxes;
++// - signing in with every purpose refused works, and the token's claim is [];
++// - the token carries exactly the purposes granted (a first-party user token, its silent sign-in, and an app token);
++// - turning a purpose off on the account page removes it from the next token (a refresh);
++// - a refused purpose is not asked again for 30 days, is asked once they pass, and the person can ask for it sooner;
++// - one app never reads another app's grants: not on its screen, not in its token, not in the check.
++// node --import ./test/helpers/pg-preload.mjs test/consent-purpose-scopes.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const http = require('http');
++const vm = require('vm');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const contracts = require('openvibe-contracts');
++const { ids, serviceAuth } = contracts;
++const { getDb } = require('../server/db/database');
++const consentMod = require('../server/consent');
++const consentApps = require('../server/consent/apps');
++
++(async () => {
++const log = console.log; console.log = () => {};
++const db = getDb();
++
++const mk = async (name) => {
++ const id = (await db.prepare('INSERT INTO users (username, email, password_hash, subject_id) VALUES (?, ?, ?, ?) RETURNING id')
++ .run(name, `${name}@example.test`, 'x', `usr_${ids.ulid()}`)).lastInsertRowid;
++ return await db.prepare('SELECT * FROM users WHERE id = ?').get(id);
++};
++const ana = await mk('scopeana');
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++const ISSUER = 'https://openvibe.network';
++const config = {
++ baseUrl: ISSUER, loginUrl: ISSUER,
++ jwt: { issuer: ISSUER, accessTokenExpiry: '1h' },
++ developer: { sandboxAudiences: 'openvibe.media', sandboxAllowance: '', credentialOverlapS: 60 },
++};
++const authRoutes = require('../server/auth/routes');
++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
++const session = (u) => authRoutes.signToken(u, keys.privateKey, config);
++
++const emitted = [];
++const consent = consentMod.createConsent({ db, emit: async (payload) => { emitted.push(payload); } });
++
++const app = express();
++app.use(express.json());
++app.use(express.urlencoded({ extended: true }));
++app.use(require('cookie-parser')());
++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey, consent });
++app.use('/oauth', require('../server/auth/oauth-routes'));
++app.use('/api/v1/projects', require('../server/developer/routes').router());
++app.use('/api/v1/me/consent', consentMod.router({ consent, requireAuth }));
++const server = http.createServer(app);
++
++// Two OpenVibe sites (confidential first-party clients) and, below, two developer apps.
++const SITE1 = 'scope-site-one';
++const SITE2 = 'scope-site-two';
++const SITE_URI = (c) => `https://${c}.example/auth/callback`;
++for (const c of [SITE1, SITE2]) {
++ await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)')
++ .run(c, `${c}-secret`, `Site ${c}`, JSON.stringify([SITE_URI(c)]));
++}
++const ALL = 'profile consent:ai.train consent:data.public consent:app.data';
++const TEXT = consentApps.APP_TEXT_VERSION;
++
++(async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const json = async (r) => ({ status: r.status, body: await r.json().catch(() => ({})), location: r.headers.get('location') });
++ const post = (p, body, headers = {}) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) }).then(json);
++ const form = (p, body) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(body) }).then(json);
++ const get = (p, headers = {}) => fetch(base + p, { headers, redirect: 'manual' }).then(json);
++ const me = (method, p) => fetch(base + '/api/v1/me/consent' + p, { method, headers: { authorization: `Bearer ${session(ana)}` } }).then(json);
++
++ // A first-party site's sign-in: /oauth/confirm, then /oauth/token.
++ const siteConfirm = (client, scope, consentAnswer) => post('/oauth/confirm', {
++ token: session(ana), client_id: client, redirect_uri: SITE_URI(client), scope, state: 'st',
++ ...(consentAnswer === undefined ? {} : { consent: consentAnswer }),
++ });
++ const codeOf = (r) => new URL(r.body.redirect).searchParams.get('code');
++ const siteToken = async (client, code) => {
++ const t = await form('/oauth/token', { grant_type: 'authorization_code', client_id: client, client_secret: `${client}-secret`, code, redirect_uri: SITE_URI(client) });
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ return t.body;
++ };
++ const claimsOf = (token) => jwt.verify(token, keys.publicKey, { algorithms: ['RS256'] });
++ const rows = async (client) => await db.prepare('SELECT * FROM consents WHERE subject = ? AND client = ? ORDER BY granted_at, id').all(ana.subject_id, client);
++ const refusals = async (client) => await db.prepare('SELECT * FROM consent_refusals WHERE subject = ? AND client = ? ORDER BY purpose').all(ana.subject_id, client);
++
++ try {
++ // ── The words: the family's purposes minus the retired ai.looks, read from the pinned Contracts ──
++ const family = contracts.schema('network.consent@1').properties.purpose.enum;
++ assert.deepStrictEqual([...consentApps.APP_PURPOSE_IDS], family.filter((p) => p !== 'ai.looks'), 'every purpose the family defines, except the retired one');
++ for (const p of consentApps.APP_PURPOSES) {
++ assert.ok(p.title && p.allows && p.text_version === TEXT, `${p.id} has plain words and the screen's version`);
++ assert.ok(!('on' in p) && !('checked' in p) && !('default' in p), `${p.id} carries no default`);
++ }
++
++ // ══ 1. An unknown purpose is refused with invalid_scope ══
++ let r = await get(`/oauth/authorize?${new URLSearchParams({ client_id: SITE1, redirect_uri: SITE_URI(SITE1), response_type: 'code', scope: 'profile consent:data.sell', state: 'xyz' })}`);
++ assert.strictEqual(r.status, 302);
++ let loc = new URL(r.location);
++ assert.strictEqual(loc.origin + loc.pathname, SITE_URI(SITE1), 'the error goes back to the app');
++ assert.strictEqual(loc.searchParams.get('error'), 'invalid_scope');
++ assert.strictEqual(loc.searchParams.get('state'), 'xyz', 'with its state');
++ r = await get(`/oauth/authorize?${new URLSearchParams({ client_id: SITE1, redirect_uri: SITE_URI(SITE1), response_type: 'code', scope: 'consent:ai.looks', state: 's' })}`);
++ assert.strictEqual(new URL(r.location).searchParams.get('error'), 'invalid_scope', 'the retired ai.looks is not a purpose an app may ask for');
++ r = await get(`/oauth/authorize?${new URLSearchParams({ client_id: SITE1, redirect_uri: SITE_URI(SITE1), response_type: 'code', scope: ALL, state: 's' })}`);
++ assert.strictEqual(r.status, 302);
++ loc = new URL(r.location);
++ assert.strictEqual(loc.pathname, '/login', 'known purposes go on to the sign-in page');
++ assert.strictEqual(loc.searchParams.get('scope'), ALL, 'with the purposes on the scope');
++ r = await get(`/oauth/client-info?${new URLSearchParams({ client_id: SITE1, redirect_uri: SITE_URI(SITE1), scope: 'consent:nope' })}`);
++ assert.strictEqual(r.status, 400); assert.strictEqual(r.body.error, 'invalid_scope');
++ r = await siteConfirm(SITE1, 'profile consent:nope');
++ assert.strictEqual(r.status, 400); assert.strictEqual(r.body.error, 'invalid_scope');
++ r = await siteConfirm(SITE1, 'profile consent:ai.train consent:sell.it', { shown: ['ai.train'], allow: ['ai.train'], adult: true, text_version: TEXT });
++ assert.strictEqual(r.status, 400, 'one unknown purpose refuses the whole request, even with an answer');
++ assert.strictEqual(r.body.error, 'invalid_scope');
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM oauth_codes').get()).n, 0, 'no code for a refused request');
++ assert.deepStrictEqual(await rows(SITE1), [], 'and nothing granted');
++
++ // ══ 2. Nothing is pre-ticked ══
++ r = await get(`/oauth/client-info?${new URLSearchParams({ client_id: SITE1, redirect_uri: SITE_URI(SITE1), scope: ALL })}`);
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.body.purposes.map((p) => p.id), ['ai.train', 'data.public', 'app.data'], 'each purpose, in the family\'s order');
++ assert.ok(r.body.purposes.every((p) => p.title && p.allows && !('on' in p) && !('checked' in p)), 'plain words, no default');
++ r = await siteConfirm(SITE1, ALL);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.redirect, undefined, 'no code before the person answers');
++ assert.deepStrictEqual(r.body.consent.ask.map((p) => p.id), ['ai.train', 'data.public', 'app.data'], 'every purpose is asked, separately');
++ assert.deepStrictEqual(r.body.consent.granted, []);
++ assert.strictEqual(r.body.consent.adult, false, 'the page asks for 18+ itself');
++ assert.strictEqual(r.body.consent.text_version, TEXT);
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM oauth_codes').get()).n, 0);
++ assert.deepStrictEqual(await rows(SITE1), [], 'showing the screen grants nothing');
++ // The page itself: login.js askPurposes builds one unticked box per purpose.
++ const page = runAskPurposes(r.body.consent);
++ assert.strictEqual(page.boxes.length, 3);
++ assert.ok(page.boxes.every((b) => b.type === 'checkbox' && b.checked === false), 'nothing is pre-ticked');
++ assert.strictEqual(page.button.textContent, 'Continue without allowing any', 'continuing with nothing ticked is the plain default');
++ assert.strictEqual(page.adultRow.hidden, false, 'the 18+ line shows for the 18+ purposes');
++ assert.ok(!/\bchecked\b/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'login.html'), 'utf8').split('id="panel-purposes"')[1].split('<!-- Register Form')[0]), 'the panel\'s markup ticks nothing');
++
++ // ══ 3. Signing in with every purpose refused works ══
++ r = await siteConfirm(SITE1, ALL, { shown: ['ai.train', 'data.public', 'app.data'], allow: [], text_version: TEXT });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.ok(r.body.redirect && codeOf(r), 'a code with nothing allowed');
++ let t = await siteToken(SITE1, codeOf(r));
++ assert.deepStrictEqual(claimsOf(t.access_token).consent, [], 'the claim says none granted');
++ assert.strictEqual(t.scope, 'profile', 'the token response names no consent scope that was not granted');
++ assert.deepStrictEqual(await rows(SITE1), [], 'nothing granted');
++ const refused = await refusals(SITE1);
++ assert.deepStrictEqual(refused.map((x) => x.purpose), ['ai.train', 'app.data', 'data.public']);
++ for (const x of refused) {
++ const days = (new Date(x.quiet_until).getTime() - new Date(x.refused_at).getTime()) / 86400000;
++ assert.strictEqual(days, 30, 'quiet for 30 days');
++ assert.strictEqual(x.text_version, TEXT);
++ }
++
++ // ══ 4. The 30-day quiet period ══
++ r = await siteConfirm(SITE1, ALL);
++ assert.ok(r.body.redirect, 'a refused purpose is not asked again: the sign-in goes straight on');
++ assert.strictEqual(r.body.consent, undefined);
++ t = await siteToken(SITE1, codeOf(r));
++ assert.deepStrictEqual(claimsOf(t.access_token).consent, [], 'and still grants nothing');
++ // Day 29: still quiet. Day 31: asked again.
++ const dayMs = 86400000;
++ const shift = async (days) => await db.prepare('UPDATE consent_refusals SET refused_at = ?, quiet_until = ? WHERE subject = ? AND client = ?')
++ .run(new Date(Date.now() - days * dayMs).toISOString(), new Date(Date.now() - days * dayMs + consentApps.QUIET_MS).toISOString(), ana.subject_id, SITE1);
++ await shift(29);
++ r = await siteConfirm(SITE1, ALL);
++ assert.ok(r.body.redirect, 'still quiet on day 29');
++ await shift(31);
++ r = await siteConfirm(SITE1, ALL);
++ assert.deepStrictEqual(r.body.consent.ask.map((p) => p.id), ['ai.train', 'data.public', 'app.data'], 'asked again once 30 days passed');
++ // Refuse again; the person asks for one of them sooner on their account page ("Ask me next time").
++ r = await siteConfirm(SITE1, ALL, { shown: ['ai.train', 'data.public', 'app.data'], allow: [], text_version: TEXT });
++ assert.ok(r.body.redirect);
++ let apps = await me('GET', '/apps');
++ assert.strictEqual(apps.status, 200);
++ const site1 = apps.body.apps.find((a) => a.client === SITE1);
++ assert.deepStrictEqual(site1.quiet.map((q) => q.purpose), ['ai.train', 'data.public', 'app.data'], 'the account page lists the refusals');
++ assert.strictEqual(site1.name, `Site ${SITE1}`);
++ apps = await me('DELETE', `/app.data/${SITE1}/refusal`);
++ assert.strictEqual(apps.status, 200);
++ assert.deepStrictEqual(apps.body.apps.find((a) => a.client === SITE1).quiet.map((q) => q.purpose), ['ai.train', 'data.public']);
++ r = await siteConfirm(SITE1, ALL);
++ assert.deepStrictEqual(r.body.consent.ask.map((p) => p.id), ['app.data'], 'only the one the person opened again is asked');
++
++ // ══ 5. The token carries exactly the purposes granted ══
++ r = await siteConfirm(SITE2, ALL);
++ assert.deepStrictEqual(r.body.consent.ask.map((p) => p.id), ['ai.train', 'data.public', 'app.data'], 'SITE1\'s answers do not count for SITE2');
++ r = await siteConfirm(SITE2, ALL, { shown: ['ai.train', 'data.public', 'app.data'], allow: ['ai.train', 'app.data'], text_version: TEXT });
++ assert.strictEqual(r.status, 422, '18+ purposes need the confirmation');
++ assert.strictEqual(r.body.error, 'consent.adult_required');
++ r = await siteConfirm(SITE2, ALL, { shown: ['ai.train', 'data.public', 'app.data'], allow: ['ai.train', 'app.data'], adult: true, text_version: '2026-10-31.1' });
++ assert.strictEqual(r.status, 409, 'old words are refused');
++ assert.deepStrictEqual(await rows(SITE2), [], 'neither refusal wrote anything');
++ r = await siteConfirm(SITE2, ALL, { shown: ['ai.train', 'data.public', 'app.data'], allow: ['ai.train', 'app.data'], adult: true, text_version: TEXT });
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ t = await siteToken(SITE2, codeOf(r));
++ assert.deepStrictEqual(claimsOf(t.access_token).consent, ['ai.train', 'app.data'], 'exactly the granted purposes');
++ assert.strictEqual(t.scope, 'profile consent:ai.train consent:app.data', 'the response scope names the granted ones only');
++ const granted = await rows(SITE2);
++ // One sign-in grants both at the same moment, so their order is the ids' random part: compare by purpose.
++ assert.deepStrictEqual(granted.map((x) => [x.purpose, x.source, x.text_version, x.revoked_at]).sort((a, b) => a[0].localeCompare(b[0])),
++ [['ai.train', 'oauth', TEXT, null], ['app.data', 'oauth', TEXT, null]], 'each grant is a row: who, which app, which purpose, when, the wording');
++ assert.ok(granted.every((x) => x.subject === ana.subject_id && x.client === SITE2 && x.granted_at));
++ assert.deepStrictEqual(emitted.filter((e) => e.client === SITE2 && e.on).map((e) => e.purpose).sort(), ['ai.train', 'app.data'], 'network.consent.changed for each grant');
++ assert.deepStrictEqual((await refusals(SITE2)).map((x) => x.purpose), ['data.public'], 'the unticked one is a refusal');
++ // A token that asked for fewer purposes carries fewer: the claim follows the request.
++ r = await siteConfirm(SITE2, 'profile consent:app.data');
++ t = await siteToken(SITE2, codeOf(r));
++ assert.deepStrictEqual(claimsOf(t.access_token).consent, ['app.data'], 'only what this sign-in asked for');
++ // A sign-in that asked for no purpose carries no claim at all.
++ r = await siteConfirm(SITE2, 'profile');
++ t = await siteToken(SITE2, codeOf(r));
++ assert.strictEqual(claimsOf(t.access_token).consent, undefined, 'no consent scope, no claim');
++ // The silent sign-in (prompt=none) asks nothing and carries the granted purposes.
++ r = await get(`/oauth/authorize?${new URLSearchParams({ client_id: SITE2, redirect_uri: SITE_URI(SITE2), response_type: 'code', scope: ALL, state: 'q', prompt: 'none' })}`,
++ { cookie: `ov_token=${session(ana)}` });
++ assert.strictEqual(r.status, 302);
++ const silentCode = new URL(r.location).searchParams.get('code');
++ assert.ok(silentCode, r.location);
++ t = await siteToken(SITE2, silentCode);
++ assert.deepStrictEqual(claimsOf(t.access_token).consent, ['ai.train', 'app.data'], 'silent sign-in: the grants, nothing asked');
++
++ // ══ 6. Revoking removes it from the next token ══
++ r = await siteConfirm(SITE2, ALL);
++ const first = await siteToken(SITE2, codeOf(r));
++ assert.deepStrictEqual(claimsOf(first.access_token).consent, ['ai.train', 'app.data']);
++ apps = await me('DELETE', `/ai.train/${SITE2}`);
++ assert.strictEqual(apps.status, 200);
++ assert.deepStrictEqual(apps.body.apps.find((a) => a.client === SITE2).granted.map((g) => g.purpose), ['app.data'], 'the account page shows it off');
++ const history = await db.prepare("SELECT \"by\" FROM consent_events WHERE subject = ? AND client = ? AND purpose = 'ai.train' AND action = 'revoked'").all(ana.subject_id, SITE2);
++ assert.deepStrictEqual(history.map((h) => h.by), ['person'], 'the person turned it off');
++ const next = await form('/oauth/token', { grant_type: 'refresh_token', client_id: SITE2, client_secret: `${SITE2}-secret`, refresh_token: first.refresh_token });
++ assert.strictEqual(next.status, 200, JSON.stringify(next.body));
++ assert.deepStrictEqual(claimsOf(next.body.access_token).consent, ['app.data'], 'the next token (a refresh) leaves it out');
++ assert.strictEqual(next.body.scope, 'profile theme consent:app.data');
++ const checked = await consent.check({ purpose: 'ai.train', client: SITE2, subjects: [ana.subject_id] });
++ assert.strictEqual(checked.allowed[ana.subject_id], false, 'and the check says no');
++ // The account page's own ai.train row turned off never touches an app's grant, and the reverse.
++ let mine = await me('GET', '');
++ assert.ok(mine.body.consents.every((c) => c.client === SITE2), 'only app rows are active');
++
++ // ══ 7. One app never reads another app's grants (developer apps) ══
++ const projects = (method, p, body) => fetch(`${base}/api/v1/projects${p}`, { method, headers: { authorization: `Bearer ${session(ana)}`, 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then(json);
++ let pr = await projects('POST', '', { name: 'Scope Demo' });
++ assert.strictEqual(pr.status, 201, JSON.stringify(pr.body));
++ const P = pr.body.id;
++ const DEV_URI = 'https://dev.example.com/cb';
++ pr = await projects('POST', `/${P}/apps`, { name: 'App A', environment: 'sandbox', type: 'public', redirect_uris: [DEV_URI] });
++ assert.strictEqual(pr.status, 201, JSON.stringify(pr.body));
++ const A = pr.body.id;
++ pr = await projects('POST', `/${P}/apps`, { name: 'App B', environment: 'sandbox', type: 'public', redirect_uris: [DEV_URI] });
++ const B = pr.body.id;
++ const verifier = crypto.randomBytes(32).toString('base64url');
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ const devConfirm = (client, scope, consentAnswer) => post('/oauth/confirm', {
++ token: session(ana), client_id: client, redirect_uri: DEV_URI, scope, state: 's', code_challenge: challenge, code_challenge_method: 'S256',
++ ...(consentAnswer === undefined ? {} : { consent: consentAnswer }),
++ });
++ const devToken = async (client, code) => {
++ const x = await form('/oauth/token', { grant_type: 'authorization_code', client_id: client, code, redirect_uri: DEV_URI, audience: 'openvibe.media', code_verifier: verifier });
++ assert.strictEqual(x.status, 200, JSON.stringify(x.body));
++ const v = serviceAuth.verifyServiceToken(x.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.media', acceptSandbox: true });
++ assert.strictEqual(v.ok, true, v.reason);
++ return { body: x.body, claims: v.claims };
++ };
++ r = await get(`/oauth/authorize?${new URLSearchParams({ client_id: A, redirect_uri: DEV_URI, response_type: 'code', scope: 'consent:face.looks', state: 'd', code_challenge: challenge, code_challenge_method: 'S256' })}`);
++ assert.strictEqual(new URL(r.location).searchParams.get('error'), 'invalid_scope', 'a developer app is refused an unknown purpose too');
++ r = await get(`/oauth/client-info?${new URLSearchParams({ client_id: A, redirect_uri: DEV_URI, code_challenge: challenge, code_challenge_method: 'S256', scope: 'consent:ai.train consent:app.data' })}`);
++ assert.deepStrictEqual(r.body.purposes.map((p) => p.id), ['ai.train', 'app.data']);
++ assert.deepStrictEqual(r.body.refused, [], 'consent scopes are not capabilities the screen refuses');
++ // Ana grants App A ai.train (and refuses app.data).
++ r = await devConfirm(A, 'consent:ai.train consent:app.data');
++ assert.deepStrictEqual(r.body.consent.ask.map((p) => p.id), ['ai.train', 'app.data']);
++ r = await devConfirm(A, 'consent:ai.train consent:app.data', { shown: ['ai.train', 'app.data'], allow: ['ai.train'], adult: true, text_version: TEXT });
++ let dt = await devToken(A, codeOf(r));
++ assert.deepStrictEqual(dt.claims.consent, ['ai.train'], 'the app token carries exactly the granted purpose');
++ assert.deepStrictEqual(dt.claims.cap, [], 'a purpose is never a capability');
++ assert.strictEqual(dt.claims.on_behalf_of, ana.subject_id);
++ assert.strictEqual(dt.body.scope, 'consent:ai.train');
++ assert.strictEqual(contracts.validate('identity.service-token-claims@1', dt.claims).valid, true, 'the claims still fit the token contract');
++ // App B asks for the same purpose: it sees no grant, is asked fresh, and its token has none.
++ r = await devConfirm(B, 'consent:ai.train');
++ assert.deepStrictEqual(r.body.consent.ask.map((p) => p.id), ['ai.train'], 'App A\'s grant is not shown to App B');
++ assert.deepStrictEqual(r.body.consent.granted, []);
++ r = await devConfirm(B, 'consent:ai.train', { shown: ['ai.train'], allow: [], text_version: TEXT });
++ dt = await devToken(B, codeOf(r));
++ assert.deepStrictEqual(dt.claims.consent, [], 'App B\'s token carries nothing of App A\'s');
++ assert.strictEqual(dt.body.scope, '');
++ // B cannot narrow its way to A's grant at the token endpoint either.
++ r = await devConfirm(B, 'consent:ai.train');
++ assert.ok(r.body.redirect, 'B\'s refusal keeps it quiet');
++ const widen = await form('/oauth/token', { grant_type: 'authorization_code', client_id: B, code: codeOf(r), redirect_uri: DEV_URI, audience: 'openvibe.media', code_verifier: verifier, scope: 'consent:face.recognize' });
++ assert.strictEqual(widen.status, 400); assert.strictEqual(widen.body.error, 'invalid_scope', 'the exchange never widens the purposes');
++ // A's refusal of app.data does not silence B; B's refusal of ai.train does not touch A.
++ r = await devConfirm(A, 'consent:ai.train');
++ assert.ok(r.body.redirect, 'A is not asked again for what it holds');
++ dt = await devToken(A, codeOf(r));
++ assert.deepStrictEqual(dt.claims.consent, ['ai.train']);
++ // The service check: each app's grant answers for that app alone, never for OpenVibe or another app.
++ const ask = async (client) => (await consent.check({ purpose: 'ai.train', ...(client ? { client } : {}), subjects: [ana.subject_id] })).allowed[ana.subject_id];
++ assert.strictEqual(await ask(A), true);
++ assert.strictEqual(await ask(B), false);
++ assert.strictEqual(await ask(SITE1), false);
++ assert.strictEqual(await ask(null), false, 'an app\'s grant is never OpenVibe\'s own');
++ // The account page names both apps, A with its grant first.
++ apps = await me('GET', '/apps');
++ const names = apps.body.apps.map((a) => a.name);
++ assert.ok(names.indexOf('App A') < names.indexOf('App B'), 'apps with a grant first');
++ const appA = apps.body.apps.find((a) => a.client === A);
++ assert.strictEqual(appA.third_party, true);
++ assert.deepStrictEqual(appA.granted.map((g) => g.purpose), ['ai.train']);
++ assert.deepStrictEqual(appA.quiet.map((q) => q.purpose), ['app.data']);
++ let known = true;
++ try { contracts.schema('network.me-consent-apps-result@1'); } catch { known = false; }
++ if (known) assert.strictEqual(contracts.validate('network.me-consent-apps-result@1', apps.body).valid, true, 'the answer fits the contract');
++
++ // ══ The record: export carries the refusals; deletion drops them ══
++ const exported = await consentMod.exportOf(db, ana.subject_id);
++ assert.ok(exported.refusals.length >= 3 && exported.refusals.every((x) => x.client && x.purpose && x.quiet_until), 'the export holds the refusals');
++ await db.tx(async (tx) => { await consentMod.revokeForDeletion(tx, [ana.subject_id], { at: new Date().toISOString() }); });
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM consent_refusals WHERE subject = ?').get(ana.subject_id)).n, 0, 'deletion drops the refusals');
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM consents WHERE subject = ? AND revoked_at IS NULL').get(ana.subject_id)).n, 0, 'and revokes the grants');
++
++ // ══ The migration is expand-only ══
++ const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0044_consent_refusals.sql'), 'utf8');
++ assert.match(sql, /^-- phase: expand$/m);
++ assert.ok(!/\b(DROP|ALTER)\b/i.test(sql.replace(/^--.*$/gm, '')), 'expand only');
++ } finally {
++ server.close();
++ console.log = log;
++ }
++ console.log('consent purpose scopes: ok');
++ process.exit(0);
++})().catch((e) => { console.log = log; console.error(e); process.exit(1); });
++
++/** Run login.js's askPurposes against a small DOM and return what it built. */
++function runAskPurposes(step) {
++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'login.js'), 'utf8');
++ const start = js.indexOf('function askPurposes(step) {');
++ assert.ok(start > 0, 'askPurposes exists');
++ let depth = 0, end = start;
++ for (let i = js.indexOf('{', start); i < js.length; i++) {
++ if (js[i] === '{') depth++;
++ else if (js[i] === '}' && --depth === 0) { end = i + 1; break; }
++ }
++ const source = js.slice(start, end);
++ assert.ok(!/innerHTML/.test(source), 'built from DOM nodes only');
++ class Node {
++ constructor(tag) { this.tagName = tag; this.children = []; this.className = ''; this.hidden = false; this._text = ''; this.attrs = {}; }
++ set textContent(v) { this._text = String(v); this.children = []; }
++ get textContent() { return this._text + this.children.map((c) => (typeof c === 'string' ? c : c.textContent)).join(''); }
++ append(...kids) { this.children.push(...kids); }
++ replaceChildren(...kids) { this.children = [...kids]; this._text = ''; }
++ focus() {}
++ all(out = []) { for (const c of this.children) if (typeof c !== 'string') { out.push(c); c.all(out); } return out; }
++ querySelectorAll(sel) {
++ const cls = sel.replace(/:checked$/, '').replace(/^\./, '');
++ return this.all().filter((n) => n.className.split(' ').includes(cls) && (!sel.endsWith(':checked') || n.checked));
++ }
++ }
++ const nodes = {};
++ for (const id of ['purposes-head', 'purposes-list', 'purposes-adult', 'purposes-adult-box', 'purposes-already', 'purposes-continue', 'purposes-form']) nodes[id] = new Node(id);
++ const document = { getElementById: (id) => nodes[id] || null, createElement: (t) => new Node(t) };
++ vm.runInNewContext(`${source}; askPurposes(step);`, {
++ document, step, clientName: 'Site', setActivePanel: () => {}, showMsg: () => {}, Promise,
++ });
++ return {
++ boxes: nodes['purposes-list'].all().filter((n) => n.tagName === 'input'),
++ button: nodes['purposes-continue'],
++ adultRow: nodes['purposes-adult'],
++ };
++}
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 859987ed..8d85dae2 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', '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'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');