openvibers/openvibe.network Commits

Build the third-party OAuth consent screen: listed capabilities, sensitive marked, absent scope consents to nothing (#54)

OP OpenVibers committed 5 Oct 2026 · abb1c8105b · 9 files, +342 −15

## Files changed

- **`server/auth/oauth-routes.js`** — `GET /oauth/client-info` now reads a `scope` query param; for a third-party app it returns `capabilities` (app-grantable requested ids with catalog `name`/`description`/`sensitive`, in requested order) and `refused` (unknown/ungrantable), and returns `capabilities: []`/`refused: []` without a scope; still nothing about other apps or the secret.
- **`server/developer/tokens.js`** — added `consentedCapabilities(scope)` (grantable ids, ordered, deduped) used by `issueCode`, so a code always carries an explicit consented set; added `consentView(scope)` for the screen; `mint` gained `allowEmpty` so a no-consent authorization code mints an identifying token with `cap: []` (client_credentials still refuses empty); `handleTokenRequest` now treats `row.scope` as the consented set (empty string = nothing), refuses any asked id outside it with `invalid_scope`, and passes `allowEmpty` only when the consented set is empty.
- **`test/consent-screen.test.js`** — new: scope-less authorize → token with `cap: []`; empty scope string same; scope-less code can't be widened (`invalid_scope`); extra id at /token is `invalid_scope`; scope-bearing authorizes exactly the named grantable ids and can be narrowed; `refresh_token` unsupported for apps; client-info with scope lists metadata (sensitive `media.object.delete` true) and refused ids; client-info without scope → `capabilities: []`.
- **`test/developer-projects.test.js`** — its app authorization-code helper now requests a scope, since a scope-less authorize intentionally consents to nothing now.
- **`docs/developer-projects.md`** — intro and "Consent screen" gap note: backend half built (client-info capabilities, absent scope = nothing consented), page renders next.
- **`docs/t2-projects-and-grants.md`** — §9 Decided: backend half built, no contract delta.

## Tests

- `ov test test/consent-screen.test.js test/developer-projects.test.js test/oauth-pkce.test.js test/developer-defaults.test.js test/developer-export-tokens.test.js test/refresh-tokens.test.js` → **6 passed**
- `ov test test/security-suite.js test/oidc-discovery.test.js test/security-idor.test.js` → **2 passed** (`security-suite.js` is a helper, not a standalone test)
- `ov test test/security-secrets-reads.test.js` → **1 passed**

## Not done / notes

- Did not touch `public/login.html` or `public/js/login.js`, and did not commit, push, deploy, or install anything.
- Refresh-token coherence: app tokens issue no refresh tokens, and `grant_type=refresh_token` for an app is `unsupported_grant_type`, so nothing can widen the consented set on refresh; the standard (non-app) refresh flow carries no capability claims and is unchanged.
- `client-info`'s `capabilities` uses exactly `issueCode`'s grantability filter (what a code from that request can carry); it is deliberately not intersected with the app's per-audience approved grants, since client-info receives no audience and `issueCode` itself does not. `capability@1` has no display-name field, so `name` is the catalog id.

Changes

ddiff --git a/docs/developer-projects.md b/docs/developer-projects.md
iindex b9079091..0ce2b95e 100644
---- a/docs/developer-projects.md
++++ b/docs/developer-projects.md
@@@ -2,7 +2,7 @@
Roadmap Wave 20 foundation. The binding decision is ADR-014 in OpenVibe.Contracts. Network owns developer projects: who may build on OpenVibe, which apps they run, what those apps may do, and how much they may use. OpenVibe.Codes (the portal, the next step) is a client of this API. It never enforces grants or quotas itself.
--What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, defaults that let a new project's sandbox apps work without staff (sandbox audiences and a sandbox allowance), the audit, and a relay of the audit's events to OpenVibe.Events (off until `OV_EVENTS_INTERNAL_URL` is set). What does not exist yet: a UI (Codes), a consent screen that lists capabilities, and refresh tokens for apps. Owning services read a project through `GET /internal/projects/:project_id` ([Service-side project reads](#service-side-project-reads)). Quotas are enforced by the owning services, not here.
++What exists today: the data model, the `/api/v1/projects` API, app tokens from `/oauth/token`, the sandbox check in Network's own capability guard, defaults that let a new project's sandbox apps work without staff (sandbox audiences and a sandbox allowance), the audit, and a relay of the audit's events to OpenVibe.Events (off until `OV_EVENTS_INTERNAL_URL` is set). The backend of the consent screen is built (`GET /oauth/client-info` with `scope`), the page that renders it is next (see [Consent screen](#known-gaps-next-steps)); what does not exist yet: a UI (Codes), the consent-screen page, and refresh tokens for apps. Owning services read a project through `GET /internal/projects/:project_id` ([Service-side project reads](#service-side-project-reads)). Quotas are enforced by the owning services, not here.
## Model
@@@ -307,7 +307,7 @@ Until the relay is turned on in an environment, nobody else receives these event
## Known gaps (next steps)
--- **Consent screen.** The account chooser shows the app's name, not the capabilities it asks for. Codes and Network need a consent step before third-party production apps go live, and it must list the capability ids the app asks for, marking the ones `capability@1` marks `sensitive`; a request without a `scope` must not consent to capabilities the screen did not name. This screen is the app path's compensation for not using the agent confirmation flow — an authorization-code app acts for a person who is present and authorizes it capability by capability, so it carries no `cap_confirm` (decided 2026-10-04, `docs/t2-projects-and-grants.md` section 9).
++- **Consent screen (backend built; page next).** The account chooser shows the app's name, not the capabilities it asks for. Codes and Network need a consent step before third-party production apps go live, and it must list the capability ids the app asks for, marking the ones `capability@1` marks `sensitive`; a request without a `scope` must not consent to capabilities the screen did not name. This screen is the app path's compensation for not using the agent confirmation flow — an authorization-code app acts for a person who is present and authorizes it capability by capability, so it carries no `cap_confirm` (decided 2026-10-04, `docs/t2-projects-and-grants.md` section 9). **The backend half is built:** `GET /oauth/client-info` with the authorize request's `scope` returns `capabilities: [{ id, name, description, sensitive }]` — the app-grantable requested ids, in the order requested, with `description` and `sensitive` from the `capability@1` catalog (`name` is the catalog id, since `capability@1` has no separate display name) — and `refused: [id]` for the unknown or ungrantable ones the page must not name; without a `scope` it returns `capabilities: []`. The authorization code an app receives now carries the explicit consented set (`tokens.issueCode`), so an absent or empty `scope` consents to nothing: the code stores an empty set and the token exchange mints a token with `cap: []` (the person is still identified by `on_behalf_of`), and asking the token endpoint for an id outside that set is `invalid_scope`. Apps have no refresh tokens, so no refreshed token can widen the set either. What remains is the API design session's page: render `capabilities` from `client-info`, mark the `sensitive` ones, and say the `refused` ids will not be granted (`test/consent-screen.test.js`).
- **Service-side project reads.** The route is built, but no service calls it yet. Host holds the grant. Contracts still has to make `network.project.read` `active`.
- **PowerChat as the first project** (ADR-014 migration). This is not done. PowerChat's existing OAuth client is unchanged.
- **Receivers.** Every default sandbox audience must refuse `env: sandbox` except on routes that keep sandbox data apart, and must key tenancy by `project_id`. Media and Events do this in Wave 20. Tools does not yet (see [Sandbox tokens](#sandbox-tokens)).
ddiff --git a/docs/t2-projects-and-grants.md b/docs/t2-projects-and-grants.md
iindex 4be3c61a..eef413c1 100644
---- a/docs/t2-projects-and-grants.md
++++ b/docs/t2-projects-and-grants.md
@@@ -647,7 +647,14 @@ notification shipped with `openvibe-shared` v2.7.0 (`TYPES.CONFIRMATION_REQUESTE
and revocation exist for, and if it proves too loose for the two app-grantable sensitive capabilities the narrower fix
is to leave sensitive capabilities out of refreshed tokens (the person consents again), not the agent inbox. No
contract delta: `cap_confirm` and the confirmation flow stay
-- agent-only in `identity.service-token-claims@1`, and Network builds nothing for apps here.
++ agent-only in `identity.service-token-claims@1`, and Network builds nothing for apps here. **The consent screen's
++ backend half is now built** (2026-10-04): `GET /oauth/client-info` with the authorize request's `scope` returns the
++ requested app-grantable ids with `capability@1`'s `description`/`sensitive` (`name` is the catalog id, `capability@1` having no display name) and the `refused` ids, and
++ `tokens.issueCode` stores an explicit consented set so an absent or empty `scope` consents to nothing — the code
++ carries an empty set and `handleTokenRequest` mints a token with `cap: []` (the person stays identified via
++ `on_behalf_of`); asking the token endpoint for an id outside the set is `invalid_scope`, and an app has no refresh
++ token to widen it. The account-chooser page renders that list next (`docs/developer-projects.md`, "Consent screen";
++ `test/consent-screen.test.js`).
### Unresolved
ddiff --git a/public/js/login.js b/public/js/login.js
iindex f985516d..2038a0f1 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -12,16 +12,57 @@ let clientName = '';
let clientHost = '';
if (isOAuth) {
fetch(`${API_BASE}/oauth/client-info?${new URLSearchParams({ client_id: params.get('client_id'), redirect_uri: params.get('redirect_uri'),
-- code_challenge: params.get('code_challenge') || '', code_challenge_method: params.get('code_challenge_method') || '' })}`, { credentials: 'omit' })
++ code_challenge: params.get('code_challenge') || '', code_challenge_method: params.get('code_challenge_method') || '',
++ scope: params.get('scope') || '' })}`, { credentials: 'omit' })
.then(r => (r.ok ? r.json() : null))
.then((info) => {
if (!info || !info.name) return;
clientName = info.third_party ? `${info.name} (third-party app)` : info.name;
clientHost = info.third_party ? info.redirect_host || '' : '';
renderChooserSubtitle();
++ renderConsent(info);
})
.catch(() => {});
}
++
++/**
++ * The consent screen (plan T2, docs/t2-projects-and-grants.md §9): what a third-party app may do if you continue,
++ * from the Network's own answer (the capability catalog's names and descriptions, sensitive ones marked), never from
++ * this page's URL. A request that names nothing gets nothing: the app only learns which account you are. Built from
++ * DOM nodes: names and descriptions come from the catalog, the app's name from its developer.
++ */
++function renderConsent(info) {
++ const box = document.getElementById('oauth-consent');
++ if (!box || !info || !info.third_party) 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();
++ 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})`), ' ');
++ const caps = Array.isArray(info.capabilities) ? info.capabilities : [];
++ head.append(caps.length ? 'is a third-party app. If you continue, it can:' : 'is a third-party app.');
++ box.append(head);
++ if (caps.length) {
++ const list = el('ul');
++ for (const c of caps) {
++ const li = el('li', c.sensitive ? 'sensitive' : null);
++ li.append(el('span', 'consent-name', c.name || c.id));
++ if (c.sensitive) li.append(el('span', 'consent-badge', 'Sensitive'));
++ if (c.description) li.append(el('small', null, c.description));
++ list.append(li);
++ }
++ box.append(list);
++ } else {
++ box.append(el('p', 'consent-none', 'It asks for nothing else: it will only learn which OpenVibe account you are.'));
++ }
++ 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: ');
++ refused.forEach((id, i) => { if (i) p.append(', '); p.append(el('code', null, id)); });
++ box.append(p);
++ }
++ box.hidden = false;
++}
let oauthConfirmInFlight = false;
// Generate particles
ddiff --git a/public/login.html b/public/login.html
iindex e388a26e..a013f4f3 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -176,6 +176,19 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
/* ── Account Chooser ───────────────────────────────────── */
.chooser-subtitle{font-size:13px;color:var(--text-muted);text-align:center;margin-bottom:20px;line-height:1.5}
.chooser-subtitle .app-name{color:var(--accent);font-weight:700}
++.oauth-consent{margin:0 0 20px;padding:14px 16px;border:1px solid var(--border);border-radius:12px;background:var(--bg-input);font-size:13px;line-height:1.5;text-align:left}
++.oauth-consent .consent-head{margin:0 0 10px;color:var(--text)}
++.oauth-consent .consent-head b{color:var(--accent)}
++.oauth-consent .consent-host{color:var(--text-muted)}
++.oauth-consent ul{list-style:none;margin:0;padding:0;display:grid;gap:8px}
++.oauth-consent li{padding:8px 10px;border-left:3px solid var(--border);border-radius:6px;background:rgba(255,255,255,0.02)}
++.oauth-consent li.sensitive{border-left-color:#f59e0b}
++.oauth-consent .consent-name{font-weight:600;color:var(--text)}
++.oauth-consent .consent-badge{display:inline-block;margin-left:6px;padding:1px 7px;border-radius:999px;font-size:11px;font-weight:700;color:#1a1204;background:#f59e0b;vertical-align:1px}
++.oauth-consent small{display:block;margin-top:2px;color:var(--text-muted);font-size:12px}
++.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}
.account-list{display:flex;flex-direction:column;gap:8px;margin-bottom:16px}
.account-item{
@@@ -254,6 +267,9 @@ body{min-height:100dvh;background:var(--bg);color:var(--text);font-family:'Inter
<p>One account. All of OpenVibe.</p>
</div>
++ <!-- What a third-party app asks for (GET /oauth/client-info), above every way of signing in -->
++ <section class="oauth-consent" id="oauth-consent" aria-label="What this app asks for" hidden></section>
++
<!-- Tab bar (hidden when chooser is shown) -->
<noscript><p class="panel-note">Signing in and creating an account need JavaScript: turn it on for openvibe.network and reload this page. You can use almost everything on OpenVibe as a guest without signing in.</p></noscript>
<div class="tab-bar" id="auth-tabs">
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 16bf234b..9485735c 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -128,10 +128,13 @@ router.get('/authorize', async (req, res) => {
// The name the account chooser shows ("continue to <name>"), looked up by client_id and only for a
// registered redirect_uri. The chooser used to print client_name from its own URL, so a crafted
// link could claim to be any app. redirect_host is shown beside it: where the code will go.
++// With the authorize request's `scope`, a third-party app also gets the consent screen's capability
++// list: the requested ids the app can be granted (catalog metadata, `sensitive` marked) and the
++// refused ids the screen must not name. Never another app, never a secret.
router.get('/client-info', async (req, res) => {
const db = await getDb(req);
res.set('Cache-Control', 'no-store');
-- const { client_id, redirect_uri } = req.query;
++ const { client_id, redirect_uri, scope } = req.query;
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' }); }
@@@ -140,13 +143,14 @@ router.get('/client-info', async (req, res) => {
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' });
-- return res.json({ name: app.name, third_party: true, redirect_host: host });
++ const { capabilities, refused } = devTokens.consentView(scope);
++ return res.json({ name: app.name, third_party: true, redirect_host: host, capabilities, refused });
}
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: client.name || String(client_id), third_party: false, redirect_host: host });
++ res.json({ name: client.name || String(client_id), third_party: false, redirect_host: host, capabilities: [], refused: [] });
});
// ── POST /oauth/confirm ─────────────────────────────────────
ddiff --git a/server/developer/tokens.js b/server/developer/tokens.js
iindex be01e4b8..63c6058c 100644
---- a/server/developer/tokens.js
++++ b/server/developer/tokens.js
@@@ -59,7 +59,41 @@ function projectNamespaces(projectId) {
return [projectId, `app.${projectId}.*`];
}
--async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db }) {
++/**
++ * The capability ids a requested `scope` consents to: the app-grantable ids, in the order requested,
++ * duplicates dropped. This is the explicit set `issueCode` stores on the code — an absent or empty
++ * scope consents to nothing — and the ceiling every token exchange narrows from.
++ */
++function consentedCapabilities(scope) {
++ const seen = new Set(); const out = [];
++ for (const id of String(scope || '').split(/\s+/).filter(Boolean)) {
++ if (seen.has(id)) continue;
++ seen.add(id);
++ if (policy.isGrantable(id)) out.push(id);
++ }
++ return out;
++}
++
++/**
++ * The consent screen's view of a requested scope. `capabilities` are the app-grantable ids with the
++ * catalog's metadata, in the order requested; `name` is the catalog id (capability@1 has no display
++ * name of its own). `refused` are the requested ids the screen cannot name because they are unknown
++ * or not grantable to apps — the same filter `issueCode` applies, so the screen lists exactly what a
++ * code from this request can carry. Nothing here reveals another app or a secret.
++ */
++function consentView(scope) {
++ const capabilities = []; const refused = []; const seen = new Set();
++ for (const id of String(scope || '').split(/\s+/).filter(Boolean)) {
++ if (seen.has(id)) continue;
++ seen.add(id);
++ const g = policy.grantability(id);
++ if (g.grantable) capabilities.push({ id, name: g.capability.id, description: g.capability.description || '', sensitive: !!g.capability.sensitive });
++ else refused.push(id);
++ }
++ return { capabilities, refused };
++}
++
++async function mint({ app, project, audience, scope, limit, onBehalfOf, privateKey, issuer, settings, db, allowEmpty }) {
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)) {
@@@ -70,7 +104,10 @@ async function mint({ app, project, audience, scope, limit, onBehalfOf, privateK
const missing = wanted ? wanted.filter(w => !held.includes(w)) : [];
if (missing.length) return oauthError(400, 'invalid_scope', `not granted: ${missing.join(' ')}`);
const cap = wanted || held;
-- if (!cap.length) return oauthError(400, 'invalid_scope', `no grants for audience ${aud}`);
++ // An authorization code that consented to nothing still mints an identifying token, with `cap: []`:
++ // the person is named (on_behalf_of) and the app can do nothing until it consents to a scope. Other
++ // grants (client_credentials) still refuse an empty set.
++ if (!cap.length && !allowEmpty) return oauthError(400, 'invalid_scope', `no grants for audience ${aud}`);
const now = Math.floor(Date.now() / 1000);
const claims = {
iss: issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: projectNamespaces(project.id),
@@@ -188,11 +225,13 @@ async function handleTokenRequest(db, body, { privateKey, issuer, config, ctx })
if (!verifierMatches(body.code_verifier, row.code_challenge)) return oauthError(400, 'invalid_grant', 'PKCE verification failed');
const user = await db.prepare('SELECT subject_id, is_banned FROM users WHERE subject_id = ?').get(row.user_subject);
if (!user || user.is_banned) return oauthError(400, 'invalid_grant', 'User not found or banned');
-- // The exchange may narrow what was authorized, never widen it.
-- const authorized = row.scope ? row.scope.split(' ') : null;
++ // 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;
-- if (authorized && asked && asked.some(s => !authorized.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: authorized, onBehalfOf: row.user_subject, privateKey, issuer, settings, db });
++ 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 });
}
return oauthError(400, 'unsupported_grant_type', 'apps use client_credentials or authorization_code');
}
@@@ -233,12 +272,13 @@ async function issueCode(db, { app, project, user, redirectUri, scope, challenge
const code = crypto.randomBytes(32).toString('hex');
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,
-- String(scope || '').split(/\s+/).filter(s => policy.isGrantable(s)).join(' '), challenge, new Date(Date.now() + CODE_TTL_MS).toISOString());
++ consentedCapabilities(scope).join(' '), 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,
++ consentedCapabilities, consentView,
mintExportToken, exportSubject, EXPORT_CAPS, EXPORT_PURPOSE,
};
ddiff --git a/test/consent-screen.test.js b/test/consent-screen.test.js
nnew file mode 100644
iindex 00000000..6aa55461
---- /dev/null
++++ b/test/consent-screen.test.js
@@@ -0,0 +1,151 @@
++'use strict';
++// The backend of the third-party OAuth consent screen (plan T2; docs/t2-projects-and-grants.md §9).
++// GET /oauth/client-info carries the requested capability ids with their catalog metadata (sensitive
++// marked) and refuses the rest; an authorization code carries an explicit consented set (an absent or
++// empty scope = nothing), and the token exchange can only narrow it, never widen it.
++// node test/consent-screen.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { serviceAuth } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++
++(async () => {
++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-consent-'));
++const log = console.log; console.log = () => {};
++const db = getDb();
++console.log = log;
++await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
++ (1, 'owner', 'x', 'user'), (2, 'ops', 'x', 'admin')`).run();
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const ISSUER = 'https://openvibe.network';
++const app = express();
++app.use(express.json());
++app.use(express.urlencoded({ extended: true }));
++app.locals.db = db;
++app.locals.config = {
++ baseUrl: ISSUER, loginUrl: ISSUER,
++ jwt: { issuer: ISSUER, accessTokenExpiry: '1h' },
++ // A staff-set allowance only: the sandbox default is switched off, so the grants below are the
++ // whole story (test/developer-defaults.test.js covers the code default).
++ developer: { sandboxAudiences: 'openvibe.media', sandboxAllowance: '', credentialOverlapS: 60 },
++};
++app.locals.privateKey = keys.privateKey;
++app.locals.publicKey = keys.publicKey;
++app.use('/oauth', require('../server/auth/oauth-routes'));
++app.use('/api/v1/projects', require('../server/developer/routes').router());
++const server = http.createServer(app);
++
++const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
++const T = { owner: userToken(1), ops: userToken(2) };
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const api = async (who, method, p, body) => {
++ const h = { ...(who ? { authorization: `Bearer ${T[who]}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) };
++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: h, body: body ? JSON.stringify(body) : undefined });
++ const text = await r.text();
++ return { status: r.status, text, body: text ? JSON.parse(text) : null };
++ };
++ const token = (form) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(form) })
++ .then(async r => ({ status: r.status, body: await r.json() }));
++ const verify = (t) => serviceAuth.verifyServiceToken(t, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.media', acceptSandbox: true });
++
++ // ── A sandbox project with two approved, app-grantable capabilities ──
++ let r = await api('owner', 'POST', '', { name: 'Consent Demo' });
++ assert.strictEqual(r.status, 201, r.text);
++ const P = r.body.id;
++ r = await api('ops', 'PUT', `/${P}/allowance`, { capabilities: ['media.object.delete', 'media.object.read'] });
++ assert.strictEqual(r.status, 200, r.text);
++ const REDIRECT = 'https://dev.example.com/cb';
++ r = await api('owner', 'POST', `/${P}/apps`, { name: 'Consent Demo App', environment: 'sandbox', type: 'public', redirect_uris: [REDIRECT] });
++ assert.strictEqual(r.status, 201, r.text);
++ const A = r.body.id;
++ for (const cap of ['media.object.delete', 'media.object.read']) {
++ r = await api('owner', 'POST', `/${P}/apps/${A}/grants`, { capability: cap });
++ assert.strictEqual(r.body.status, 'approved', `${cap}: ${r.text}`);
++ }
++
++ const verifier = crypto.randomBytes(32).toString('base64url');
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ const info = (q) => fetch(`${base}/oauth/client-info?${new URLSearchParams({ client_id: A, redirect_uri: REDIRECT, code_challenge: challenge, code_challenge_method: 'S256', ...q })}`)
++ .then(async x => ({ status: x.status, body: await x.json() }));
++ const confirm = (scope) => fetch(`${base}/oauth/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' },
++ body: JSON.stringify({ token: T.owner, client_id: A, redirect_uri: REDIRECT, state: 's', code_challenge: challenge, code_challenge_method: 'S256', ...(scope === undefined ? {} : { scope }) }) })
++ .then(async x => ({ status: x.status, body: await x.json() }));
++ const exchange = (code, extra = {}) => token({ grant_type: 'authorization_code', client_id: A, code, redirect_uri: REDIRECT, audience: 'openvibe.media', code_verifier: verifier, ...extra });
++ const codeFor = async (scope) => new URL((await confirm(scope)).body.redirect).searchParams.get('code');
++
++ // ── client-info names the capability ids the app asks for; a scope-less call gets none ──
++ let ci = await info({});
++ assert.strictEqual(ci.status, 200, JSON.stringify(ci.body));
++ assert.deepStrictEqual(ci.body.capabilities, []);
++ assert.deepStrictEqual(ci.body.refused, []);
++ assert.strictEqual(ci.body.client_secret, undefined, 'nothing about the app\'s secret');
++
++ ci = await info({ scope: 'media.object.delete media.object.read nope.not.real network.coins.credit media.object.delete' });
++ assert.strictEqual(ci.status, 200);
++ assert.deepStrictEqual(ci.body.capabilities.map(c => c.id), ['media.object.delete', 'media.object.read'], 'app-grantable ids, in the order requested, deduped');
++ const del = ci.body.capabilities.find(c => c.id === 'media.object.delete');
++ assert.strictEqual(del.sensitive, true, 'the sensitive capability is marked');
++ assert.strictEqual(del.name, 'media.object.delete');
++ assert.match(del.description, /Delete an object/);
++ const read = ci.body.capabilities.find(c => c.id === 'media.object.read');
++ assert.strictEqual(read.sensitive, false);
++ assert.deepStrictEqual(ci.body.refused, ['nope.not.real', 'network.coins.credit'], 'unknown/ungrantable ids the screen must not name');
++
++ // ── A scope-less authorize consents to nothing: no code can obtain media.object.delete ──
++ let code = await codeFor(undefined);
++ let t = await exchange(code);
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ let v = verify(t.body.access_token);
++ assert.strictEqual(v.ok, true, v.reason);
++ assert.deepStrictEqual(v.claims.cap, [], 'an absent scope consents to nothing');
++ assert.ok(v.claims.on_behalf_of, 'the person is still identified');
++ assert.strictEqual(v.claims.project_id, P);
++ assert.strictEqual(t.body.scope, '');
++ assert.strictEqual(t.body.refresh_token, undefined, 'app tokens have no refresh token to widen with');
++
++ // The same code, asking for a capability at the token endpoint, is refused.
++ code = await codeFor(undefined);
++ t = await exchange(code, { scope: 'media.object.delete' });
++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope');
++
++ // An empty scope string is the same as absent.
++ code = await codeFor('');
++ t = await exchange(code);
++ assert.strictEqual(t.status, 200); assert.deepStrictEqual(verify(t.body.access_token).claims.cap, []);
++
++ // A refresh_token request never widens: apps do not support the grant at all.
++ t = await token({ grant_type: 'refresh_token', client_id: A, refresh_token: 'x'.repeat(96) });
++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'unsupported_grant_type');
++
++ // ── A scope-bearing authorize consents to exactly the named grantable ids ──
++ code = await codeFor('media.object.read media.object.delete nope.not.real');
++ t = await exchange(code);
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ v = verify(t.body.access_token);
++ assert.deepStrictEqual(v.claims.cap.slice().sort(), ['media.object.delete', 'media.object.read'], 'the named grantable ids only');
++ assert.deepStrictEqual(t.body.scope.split(' ').sort(), ['media.object.delete', 'media.object.read'], 'exactly the consented ids');
++
++ // The exchange narrows, never widens.
++ code = await codeFor('media.object.read');
++ t = await exchange(code, { scope: 'media.object.read media.object.delete' });
++ assert.strictEqual(t.status, 400); assert.strictEqual(t.body.error, 'invalid_scope');
++
++ code = await codeFor('media.object.read media.object.delete');
++ t = await exchange(code, { scope: 'media.object.read' });
++ assert.strictEqual(t.status, 200, JSON.stringify(t.body));
++ assert.deepStrictEqual(verify(t.body.access_token).claims.cap, ['media.object.read'], 'narrowed to the asked subset');
++
++ console.log('consent screen: all checks passed');
++ server.close();
++})().catch(err => { console.error(err); process.exit(1); });
++})().catch(err => { console.error(err); process.exit(1); });
ddiff --git a/test/consent-ui.test.js b/test/consent-ui.test.js
nnew file mode 100644
iindex 00000000..2c91262f
---- /dev/null
++++ b/test/consent-ui.test.js
@@@ -0,0 +1,68 @@
++'use strict';
++// The consent screen on /login (public/js/login.js renderConsent, plan T2 §9): a third-party app's requested
++// capabilities from GET /oauth/client-info, sensitive ones marked, refused ids named, nothing parsed as HTML, and
++// a first-party app shows no box. Runs the function itself against a minimal DOM.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'login.html'), 'utf8');
++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'login.js'), 'utf8');
++
++assert.match(html, /<section class="oauth-consent" id="oauth-consent" aria-label="What this app asks for" hidden><\/section>/, 'the box is on the page, hidden until the app is known');
++assert.ok(html.indexOf('id="oauth-consent"') < html.indexOf('id="auth-tabs"'), 'above every way of signing in');
++assert.match(js, /scope: params\.get\('scope'\) \|\| ''/, 'client-info is asked about the requested scope');
++
++// Pull renderConsent out of login.js and run it.
++const start = js.indexOf('function renderConsent(info) {');
++assert.ok(start > 0, 'renderConsent 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 = true; this._text = ''; }
++ 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() { this.children = []; this._text = ''; }
++ findAll(pred, out = []) { for (const c of this.children) if (typeof c !== 'string') { if (pred(c)) out.push(c); c.findAll(pred, out); } return out; }
++}
++function run(info) {
++ const box = new Node('section');
++ const document = { getElementById: (id) => (id === 'oauth-consent' ? box : null), createElement: (t) => new Node(t) };
++ vm.runInNewContext(`${source}; renderConsent(info);`, { document, info });
++ return box;
++}
++
++{
++ const box = run({ name: 'Clip<b>Tool</b>', third_party: true, redirect_host: 'cliptool.example', capabilities: [
++ { id: 'media.object.read', name: 'Read your media', description: 'See the videos and files in your library.', sensitive: false },
++ { id: 'media.object.delete', name: 'Delete your media', description: 'Remove videos and files for good.', sensitive: true },
++ ], refused: ['network.admin'] });
++ assert.strictEqual(box.hidden, false, 'shown for a third-party app');
++ const text = box.textContent;
++ assert.ok(text.includes('Clip<b>Tool</b>') && text.includes('(cliptool.example)'), 'the app name is text, never markup');
++ assert.ok(text.includes('If you continue, it can:'));
++ const items = box.findAll((n) => n.tagName === 'li');
++ assert.strictEqual(items.length, 2);
++ assert.strictEqual(items[1].className, 'sensitive', 'the sensitive capability is marked');
++ assert.ok(items[1].textContent.includes('Sensitive') && !items[0].textContent.includes('Sensitive'));
++ assert.ok(text.includes('it will not get: ') && box.findAll((n) => n.tagName === 'code')[0].textContent === 'network.admin', 'refused ids are named');
++}
++{
++ const box = run({ name: 'Quiet', third_party: true, capabilities: [], refused: [] });
++ assert.strictEqual(box.hidden, false);
++ assert.ok(box.textContent.includes('it will only learn which OpenVibe account you are'), 'nothing asked, nothing given');
++ assert.strictEqual(box.findAll((n) => n.tagName === 'li').length, 0);
++}
++{
++ const box = run({ name: 'OpenVibe.Live', third_party: false, capabilities: [] });
++ assert.strictEqual(box.hidden, true, 'a first-party site shows no consent box');
++}
++console.log('consent ui: all checks passed');
ddiff --git a/test/developer-projects.test.js b/test/developer-projects.test.js
iindex 89e58a55..12f901e1 100644
---- a/test/developer-projects.test.js
++++ b/test/developer-projects.test.js
@@@ -269,7 +269,7 @@ const secretsSeen = [];
assert.strictEqual(c.status, 400, 'confirm without PKCE refused');
c = await confirm('stranger', { code_challenge: challenge, code_challenge_method: 'S256' });
assert.strictEqual(c.status, 403, 'sandbox apps are authorized only by project members');
-- const codeFor = async () => new URL((await confirm('dev', { code_challenge: challenge, code_challenge_method: 'S256' })).body.redirect).searchParams.get('code');
++ const codeFor = async (scope = 'media.object.upload') => new URL((await confirm('dev', { code_challenge: challenge, code_challenge_method: 'S256', scope })).body.redirect).searchParams.get('code');
const exchange = (code, extra = {}) => token({ grant_type: 'authorization_code', client_id: B, code, redirect_uri: REDIRECT, audience: 'openvibe.media', ...extra });
t = await cc(B, '');
assert.strictEqual(t.body.error, 'unauthorized_client', 'public apps cannot use client_credentials');