openvibers/openvibe.network Commits

A public first-party client that signs the person in for real (the phone app) (#641)

OP OpenVibers committed 6 days ago · 106b5cd6ab · 14 files, +449 −24

* A public first-party app that signs the person in for real: the link client

OpenVibe Link on the phone (plan 2026-11-07 §13.2) is a native Android app. It cannot keep a client
secret, so it signs in as a public OAuth client with PKCE S256 and its own scheme as the redirect.

Until now every public client's token was the browser extension's read-only Dock token: a private
issuer, its own audiences and no role, so no service's normal user-token check would accept it. The
phone answers approvals and uploads files as the person, which needs their own session.

oauth_clients gains session_token (default 0, so every existing client is unchanged). A public client
with it gets the normal issuer, the normal audiences and the person's role; the extension keeps its
Dock token. The `link` client is seeded public + session, scopes `profile theme`, redirect
openvibe-link://auth, 24h access tokens.

Checked: test/oauth-link.test.js (exact redirect, state, PKCE S256, scope ceiling, code redemption
with no secret, normal issuer/audience/role, the token reads and answers /api/v1/me/actions and passes
a plain requireAuth route, refresh rotates); the extension, dock, actions and session tests still pass.

* The migration manifests in the agent and trust tests learn about 0049

* Only a verified App Link earns a phone a session

A custom scheme belongs to whoever registers it first: any app could answer openvibe-link://auth, catch a
redirect meant for us and receive a full session (role, staff claims, every audience, a refresh). The
sign-in now returns to https://openvibe.network/link/auth, a claimed App Link: server/auth/app-links.js
publishes /.well-known/assetlinks.json for network.openvibe.link with the release fingerprints in
LINK_ANDROID_CERT_SHA256, and serves /link/auth as the page a browser shows when no app claimed it.

A session token is issued only when the authorization used that https redirect AND a fingerprint is
published; every other path (the custom scheme a debug build uses, or no release key at all) gets the
read-only Dock token. The link client always shows the chooser's sentence — it acts as the person — and
prompt=none is refused with interaction_required. A session token carries the person's non-staff role and
no staff claims, with client_id, scope and fam kept. The family's kind is fixed at issue
(migrations/0050) so a refresh can never promote a Dock family to a session.

Tests: test/oauth-link.test.js (scheme and no-fingerprint Dock tokens, the labelled session, an admin's
phone as user, prompt=none, refresh by family kind, assetlinks.json, the page). The two migration
manifests name 0050.

* The staff-claims check knows a phone's session carries no staff claims

Changes

ddiff --git a/.env.example b/.env.example
iindex 7f2f21bf..6b5530d2 100644
---- a/.env.example
++++ b/.env.example
@@@ -141,3 +141,14 @@ NOTIFICATION_EMAIL_CRITICAL_ONLY=true
# The private key stays on openvibe.network only. Other services verify
# tokens OFFLINE via the JWKS endpoint (/api/.well-known/jwks).
# ═══════════════════════════════════════════════════════════════
++
++# ═══════════════════════════════════════════════════════════════
++# OpenVibe Link on Android (server/auth/app-links.js)
++# ═══════════════════════════════════════════════════════════════
++# The SHA-256 fingerprints of the certificate that signs the RELEASE build of network.openvibe.link, comma-separated,
++# colon-hex (AA:BB:…). They are what /.well-known/assetlinks.json publishes so Android hands the https://
++# openvibe.network/link/auth sign-in return to the app alone; the certificates are public values, not secrets.
++#
++# Leave it unset until the owner's release key exists. With no fingerprint, Android verifies nothing, /link/auth opens
++# this site's page, and OpenVibe Link gets the extension's read-only Dock token instead of a session.
++# LINK_ANDROID_CERT_SHA256=AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99
ddiff --git a/migrations/0049_oauth_session_public_clients.sql b/migrations/0049_oauth_session_public_clients.sql
nnew file mode 100644
iindex 00000000..6c8463d1
---- /dev/null
++++ b/migrations/0049_oauth_session_public_clients.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- A public first-party app that signs the person in for real (OpenVibe Link on the phone, plan 2026-11-07 §13.2).
++-- Until now every public client's token was the extension's read-only Dock token: a private issuer, its own audiences
++-- and no role, so no service's normal user-token check would take it. An app that answers approvals and uploads files
++-- needs the person's own session; its proof is PKCE, not a secret an APK cannot keep. 0 keeps every client as it is.
++ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS session_token bigint DEFAULT 0;
ddiff --git a/migrations/0050_oauth_token_session_families.sql b/migrations/0050_oauth_token_session_families.sql
nnew file mode 100644
iindex 00000000..1bd5689e
---- /dev/null
++++ b/migrations/0050_oauth_token_session_families.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- Whether a refresh family is a session family (OpenVibe Link's sign-in through the https App Link, plan 2026-11-07
++-- §13.2). The kind is decided once, when the code is exchanged: a family started through openvibe-link://auth — a
++-- scheme any app can claim — must never become a session on a later refresh. 0 keeps every family a Dock family,
++-- which is what every row written before this column is.
++ALTER TABLE oauth_tokens ADD COLUMN IF NOT EXISTS session_family bigint DEFAULT 0;
ddiff --git a/public/js/login.js b/public/js/login.js
iindex 93a930d1..3389fa7b 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -47,7 +47,7 @@ if (isOAuth) {
function renderConsent(info) {
const box = document.getElementById('oauth-consent');
const purposes = info && Array.isArray(info.purposes) ? info.purposes : [];
-- if (!box || !info || (!info.third_party && !purposes.length)) return;
++ if (!box || !info || (!info.third_party && !info.act_as_you && !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.
@@@ -58,6 +58,17 @@ function renderConsent(info) {
p.append('. Each one stays off unless you tick it.');
box.append(p);
};
++ // An app that acts AS the person (OpenVibe Link on the phone) says so in the Network's own words, every time:
++ // it is never signed in silently and is never shown as a plain first-party site.
++ if (info.act_as_you) {
++ const head = el('p', 'consent-head');
++ head.append(el('b', null, info.name), ' ');
++ head.append(el('span', null, info.consent_text || 'will act as you.'));
++ box.append(head);
++ asks();
++ box.hidden = false;
++ return;
++ }
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.');
@@@ -283,8 +294,9 @@ function takeChoice() {
let autoContinued = false;
function maybeContinue(info) {
if (autoContinued || !isOAuth || !info) return;
++ // An app that acts as the person is chosen by the person, in this tab: its sentence is read before any code.
++ if (info.third_party || info.act_as_you) return;
const chosen = takeChoice();
-- if (info.third_party) return;
if (['select_account', 'login'].includes(params.get('prompt') || '')) return;
const hint = String(params.get('login_hint') || '').trim().replace(/^@/, '').toLowerCase();
const accounts = getSavedAccounts().filter(a => !a.is_anon);
ddiff --git a/server/auth/app-links.js b/server/auth/app-links.js
nnew file mode 100644
iindex 00000000..524deba6
---- /dev/null
++++ b/server/auth/app-links.js
@@@ -0,0 +1,88 @@
++'use strict';
++/**
++ * Android App Links for OpenVibe Link (plan 2026-11-07 §13.2, RFC 8252 §7.2).
++ *
++ * A custom scheme belongs to whoever registers it first: any app may answer openvibe-link://auth, run its own
++ * authorization request, keep the redirect and receive a code it redeems with its own PKCE pair. An https link is
++ * claimed instead: Android hands https://openvibe.network/link/auth to the one app named in
++ * /.well-known/assetlinks.json for the certificate it was signed with, and to the browser only when none matches.
++ *
++ * So a session token — the person's own role and audiences — is issued only when BOTH hold: the authorization returned
++ * to that https link, and at least one release fingerprint is published. Without fingerprints (the owner has not made
++ * the release key yet) nothing is verified, the https link opens this page in the browser, and the link client gets the
++ * extension's read-only Dock token, as every other public client does.
++ */
++const PACKAGE = 'network.openvibe.link';
++const APP_LINK = 'https://openvibe.network/link/auth';
++// A debug build's return: still a valid redirect, but a scheme any app can claim, so a Dock token only.
++const CUSTOM_SCHEME = 'openvibe-link://auth';
++const FINGERPRINT_RE = /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/;
++
++/** The sentence the account chooser says before OpenVibe Link signs in: it acts as the person, never as staff. */
++const CONSENT_TEXT = "OpenVibe Link on your phone will act as you: answer your agents' approvals and send files to your Download.";
++
++/** The release certificate fingerprints published today; none until LINK_ANDROID_CERT_SHA256 is set. */
++function fingerprints() {
++ return String(process.env.LINK_ANDROID_CERT_SHA256 || '')
++ .split(',')
++ .map((s) => s.trim().toUpperCase())
++ .filter((s) => FINGERPRINT_RE.test(s));
++}
++
++/** The authorization returned to the claimed https link (an exact match, ignoring case). */
++function isAppLink(uri) {
++ return typeof uri === 'string' && uri.toLowerCase() === APP_LINK;
++}
++
++/** /.well-known/assetlinks.json: the app allowed to claim /link/auth, for the certificates in the env. */
++function assetLinks() {
++ return [{
++ relation: ['delegate_permission/common.handle_all_urls'],
++ target: { namespace: 'android_app', package_name: PACKAGE, sha256_cert_fingerprints: fingerprints() },
++ }];
++}
++
++/**
++ * The page /link/auth answers when the browser got the link because no installed app could claim it (the fingerprints
++ * are unpublished, the app is missing, or the phone could not verify). It says what to do and never echoes the code:
++ * the code is single-use and belongs to the app, not to a page anyone could bookmark.
++ */
++function page() {
++ return `<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<meta name="robots" content="noindex, nofollow">
++<title>Open this in OpenVibe Link</title>
++<style>
++body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0b1120;color:#e6ecff;font:16px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
++main{max-width:34rem;padding:2rem}
++h1{font-size:1.6rem;margin:0 0 1rem}
++p{margin:0 0 .8rem;color:#a9b6d6}
++</style>
++</head>
++<body>
++<main>
++<h1>Open this in OpenVibe Link</h1>
++<p>This is where signing in to OpenVibe Link on your phone returns. If you can read this page, the app did not open it.</p>
++<p>Make sure OpenVibe Link is installed on this phone, then start the sign-in again from the app.</p>
++</main>
++</body>
++</html>`;
++}
++
++/** The two routes, mounted by server/index.js and by its tests. */
++function mount(app) {
++ app.get('/.well-known/assetlinks.json', (_req, res) => {
++ res.set('Cache-Control', 'public, max-age=300');
++ res.type('application/json').send(JSON.stringify(assetLinks()));
++ });
++ app.get('/link/auth', (_req, res) => {
++ res.set('X-Robots-Tag', 'noindex, nofollow').set('Cache-Control', 'no-store');
++ res.type('html').send(page());
++ });
++ return app;
++}
++
++module.exports = { PACKAGE, APP_LINK, CUSTOM_SCHEME, CONSENT_TEXT, fingerprints, isAppLink, assetLinks, page, mount };
ddiff --git a/server/auth/dock-tokens.js b/server/auth/dock-tokens.js
iindex e889ff3a..5f7ac26c 100644
---- a/server/auth/dock-tokens.js
++++ b/server/auth/dock-tokens.js
@@@ -33,6 +33,11 @@ function isDockClient(client) {
return isPublicClient(client) && client.client_id === CLIENT_ID;
}
++/** A public client that signs the person in for real (OpenVibe Link), when its authorization earns it (oauth-routes). */
++function isSessionClient(client) {
++ return isPublicClient(client) && Number(client.session_token) === 1;
++}
++
function dockIssuer(config) {
return `${String(config.jwt.issuer || '').replace(/\/+$/, '')}/dock`;
}
@@@ -116,6 +121,6 @@ async function verifyDockSession(token, { db, publicKey, config }) {
module.exports = {
CLIENT_ID, CLIENT_NAME, SCOPES, AUDIENCES, FAMILY_RE,
-- isPublicClient, isDockClient, dockIssuer, scopesOf, hasScope, allowedScopes,
++ isPublicClient, isDockClient, isSessionClient, dockIssuer, scopesOf, hasScope, allowedScopes,
readScopeForClient, readState, verifyDockSession,
};
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 746f4b0f..51d3ad43 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -18,6 +18,8 @@ const refreshTokens = require('./refresh-tokens');
const dock = require('./dock-tokens');
const { isExtensionOrigin } = require('./extension-origin');
const consentApps = require('../consent/apps');
++const appLinks = require('./app-links');
++const { staff } = require('openvibe-contracts');
const router = express.Router();
function getDb(req) { return req.app.locals.db; }
@@@ -152,6 +154,11 @@ router.get('/authorize', async (req, res) => {
const nonce = oidc.cleanNonce(req.query.nonce);
if (nonce === undefined) return res.status(400).json({ error: 'invalid_request', error_description: 'malformed nonce' });
++ // A session client is never signed in silently: prompt=none would return a code with no chooser and the sentence
++ // about acting as the person never said. RFC 6749 §3.1.2.6 answers interaction_required instead.
++ if (dock.isSessionClient(client) && String(req.query.prompt || '') === 'none') {
++ return res.redirect(withError(redirect_uri, 'interaction_required', 'consent is always shown for this app', requestState));
++ }
// prompt=none (silent SSO from an app whose own session lapsed): if this browser still has a
// live/renewable openvibe.network session, continue as that account with no chooser. With no
// session, bounce straight back with error=login_required so the app can stay quiet. From an
@@@ -230,11 +237,17 @@ router.get('/client-info', async (req, res) => {
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));
++ const client = await db.prepare('SELECT name, redirect_uris, is_public, session_token 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: [], purposes });
++ // A session client acts as the person after they choose an account: the chooser says so, always, and never
++ // continues by itself (public/js/login.js). third_party stays false: it is ours, and it is not a developer app.
++ res.json({
++ name: siteNameForHost(host) || client.name || String(client_id), third_party: false, redirect_host: host,
++ capabilities: [], refused: [], purposes,
++ ...(dock.isSessionClient(client) ? { act_as_you: true, consent_text: appLinks.CONSENT_TEXT } : {}),
++ });
});
// ── POST /oauth/confirm ─────────────────────────────────────
@@@ -464,8 +477,13 @@ async function handleAuthCodeGrant(db, config, req, res, client, code, redirectU
const user = await db.prepare('SELECT * FROM users WHERE id = ?').get(authCode.user_id);
if (!user || user.is_banned) return res.status(400).json({ error: 'invalid_grant', error_description: 'User not found or banned' });
++ // A session token only through the claimed https App Link and only with a published release certificate: a custom
++ // scheme can be answered by any app, and no fingerprint means Android verified nothing. Everything else is the
++ // read-only Dock token, as for every other public client.
++ const session = dock.isSessionClient(client) && appLinks.isAppLink(authCode.redirect_uri) && appLinks.fingerprints().length > 0;
++
// Issue tokens
-- const issued = await issueTokenPair(db, config, req, user, client, {}, authCode.scope);
++ const issued = await issueTokenPair(db, config, req, user, client, {}, authCode.scope, { session });
const { accessToken, refreshToken } = issued;
await recordLinkedService(db, user, client);
@@@ -537,8 +555,11 @@ async function handleRefreshGrant(db, config, req, res, client, refreshToken) {
}
}
-- // Issue new token pair: the next generation of the same family
-- const issued = await issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation }, stored.scope);
++ // Issue new token pair: the next generation of the same family, never a different kind. A session family stays a
++ // session family while a release certificate is published; withdrawing LINK_ANDROID_CERT_SHA256 stops sessions at
++ // the next refresh, and every rotation of a Dock family is still a Dock token.
++ const session = dock.isSessionClient(client) && Number(stored.session_family) === 1 && appLinks.fingerprints().length > 0;
++ const issued = await issueTokenPair(db, config, req, user, client, { familyId: r.familyId, generation: r.generation }, stored.scope, { session });
await recordLinkedService(db, user, client);
res.json({
@@@ -550,10 +571,25 @@ async function handleRefreshGrant(db, config, req, res, client, refreshToken) {
});
}
--async function issueTokenPair(db, config, req, user, client, family = {}, scope = null) {
++/**
++ * The role a phone's token may carry: the person's own when it is not a staff role, and "user" when it is. A staff
++ * member's phone answers their own work; it is not a staff credential (openvibe-contracts' staff map says which roles
++ * carry capabilities).
++ */
++function nonStaffRole(role) {
++ const known = staff.map.roles.find((r) => r.id === role);
++ return !known || known.staff ? 'user' : known.id;
++}
++
++async function issueTokenPair(db, config, req, user, client, family = {}, scope = null, { session = false } = {}) {
const privateKey = req.app.locals.privateKey;
const algorithm = privateKey.includes('BEGIN') ? 'RS256' : 'HS256';
const publicClient = dock.isPublicClient(client);
++ // A public first-party app that signs a person in for real (OpenVibe Link on the phone) gets the person's own
++ // session: the normal issuer and audiences, so every service that takes a user token takes it, and the person's
++ // role. Its proof is PKCE, since an APK cannot keep a client secret. Every other public client keeps the
++ // extension's read-only Dock token (private issuer, its own audiences, no role).
++ const sessionClient = dock.isSessionClient(client) && session === true;
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;
@@@ -576,14 +612,22 @@ 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
++ // A session token carries the person's non-staff role and NO staff claims: the phone acts as them, not as their
++ // office. client_id, scope and fam stay, so the account page can end the family and the app knows what it holds.
++ const claims = publicClient && !sessionClient
? { ...base, client_id: client.client_id, scope: String(scope || ''), fam: familyId, ...consentClaim }
-- : { ...base, role: user.role, ...staffClaims(user), ...consentClaim };
++ : {
++ ...base,
++ role: sessionClient ? nonStaffRole(user.role) : user.role,
++ ...(sessionClient ? {} : staffClaims(user)),
++ ...(sessionClient ? { client_id: client.client_id, scope: String(scope || ''), fam: familyId } : {}),
++ ...consentClaim,
++ };
const accessToken = jwt.sign(claims, privateKey, {
algorithm,
-- issuer: publicClient ? dock.dockIssuer(config) : config.jwt.issuer,
-- audience: publicClient ? [...dock.AUDIENCES] : ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'],
++ issuer: publicClient && !sessionClient ? dock.dockIssuer(config) : config.jwt.issuer,
++ audience: publicClient && !sessionClient ? [...dock.AUDIENCES] : ['openvibe.live', 'openvibe.tools', 'openvibe.games', 'openvibe.media', 'openvibe.network'],
expiresIn: publicClient ? ttl : config.jwt.accessTokenExpiry,
});
@@@ -592,7 +636,7 @@ async function issueTokenPair(db, config, req, user, client, family = {}, scope
const issued = await refreshTokens.issue(db, {
clientId: client.client_id, userId: user.id,
scope: publicClient ? String(scope || '') : scopeWithConsent('profile theme', askedConsent.purposes),
-- familyId, generation: family.generation || 0,
++ familyId, generation: family.generation || 0, session: sessionClient ? 1 : 0,
});
// The token response's scope names a consent purpose only when it was granted (the app learns "not granted", no more).
ddiff --git a/server/auth/refresh-tokens.js b/server/auth/refresh-tokens.js
iindex 184798b8..53a22b1e 100644
---- a/server/auth/refresh-tokens.js
++++ b/server/auth/refresh-tokens.js
@@@ -38,11 +38,13 @@ async function ensureSchema(db) { /* the schema is migrations/NNNN_*.sql (plan T
* clear). The family id is also signed into a Dock access token, so the caller must know it before
* the pair is minted.
*/
--async function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, now = Date.now() }) {
++async function issue(db, { clientId, userId, scope = 'profile theme', familyId = null, generation = 0, session = 0, now = Date.now() }) {
const token = crypto.randomBytes(48).toString('hex');
const fam = familyId || newFamily();
-- await db.prepare(`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation)
-- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(hash(token), clientId, userId, scope, new Date(now + TTL_MS).toISOString(), fam, generation);
++ // The family's kind (session or Dock) is fixed when it starts; a rotation carries it, so a refresh can never
++ // promote a family that came in through a scheme any app can claim (migrations/0050).
++ await db.prepare(`INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at, family_id, generation, session_family)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(hash(token), clientId, userId, scope, new Date(now + TTL_MS).toISOString(), fam, generation, session ? 1 : 0);
return { token, familyId: fam };
}
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 2f8d89fc..6646b9c5 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -191,14 +191,23 @@ async function seedDb(db, { log = console } = {}) {
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
] },
++ // Link on the phone (plan 2026-11-07 §13.2): a public first-party client with no secret — the Android app
++ // cannot keep one — PKCE S256 only. Its redirect is the claimed https App Link (server/auth/app-links.js:
++ // only the app named in /.well-known/assetlinks.json for a published release certificate gets it); the
++ // custom scheme stays for debug builds. `session: true` gives it the person's own session token instead of
++ // the extension's read-only Dock token, but only when the authorization returned to the https link and a
++ // fingerprint is published. The scope ceiling is the profile and theme a sign-in asks for.
++ { client_id: 'link', name: 'OpenVibe Link', public: true, session: true,
++ allowed_scopes: 'profile theme', access_token_ttl_s: 86400,
++ redirect_uris: ['https://openvibe.network/link/auth', 'openvibe-link://auth'] },
];
let seededAny = false;
for (const c of contractClients) {
const existing = await db.prepare('SELECT client_id, name, redirect_uris FROM oauth_clients WHERE client_id = ?').get(c.client_id);
if (!existing) {
if (c.public) {
-- await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party, is_public, allowed_scopes, access_token_ttl_s) VALUES (?, NULL, ?, ?, 1, 1, ?, ?)')
-- .run(c.client_id, c.name, JSON.stringify(c.redirect_uris), c.allowed_scopes, c.access_token_ttl_s);
++ await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party, is_public, allowed_scopes, access_token_ttl_s, session_token) VALUES (?, NULL, ?, ?, 1, 1, ?, ?, ?)')
++ .run(c.client_id, c.name, JSON.stringify(c.redirect_uris), c.allowed_scopes, c.access_token_ttl_s, c.session ? 1 : 0);
console.log(`[DB] Seeded public OAuth2 client: ${c.client_id} (no secret)`);
} else {
await db.prepare('INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, ?, ?, ?, 1)')
@@@ -210,8 +219,8 @@ async function seedDb(db, { log = console } = {}) {
}
await mergeRedirectUris(db, c.client_id, c.redirect_uris, { log });
if (c.public) {
-- await db.prepare('UPDATE oauth_clients SET name = ?, is_public = 1, allowed_scopes = ?, access_token_ttl_s = ?, client_secret = NULL, redirect_uris = ? WHERE client_id = ?')
-- .run(c.name, c.allowed_scopes, c.access_token_ttl_s, JSON.stringify(c.redirect_uris), c.client_id);
++ await db.prepare('UPDATE oauth_clients SET name = ?, is_public = 1, allowed_scopes = ?, access_token_ttl_s = ?, session_token = ?, client_secret = NULL, redirect_uris = ? WHERE client_id = ?')
++ .run(c.name, c.allowed_scopes, c.access_token_ttl_s, c.session ? 1 : 0, JSON.stringify(c.redirect_uris), c.client_id);
}
// A client created first as a service principal carries the placeholder name "OpenVibe.<id> (service)",
// which the sign-in page shows people ("Sign in to OpenVibe.food (service)"). Give it the site's name; a
ddiff --git a/server/index.js b/server/index.js
iindex 2d1e19c7..cae48fa2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1047,6 +1047,10 @@ app.get('/verify-email', (req, res) => {
lookBoot.send(res, 'verify-email.html');
});
++// Android App Links for OpenVibe Link (plan 2026-11-07 §13.2): /.well-known/assetlinks.json claims /link/auth for the
++// release certificate in LINK_ANDROID_CERT_SHA256, and /link/auth is the plain page a browser shows when no app did.
++require('./auth/app-links').mount(app);
++
// FedCM identity provider (browser-native sign-in for the other OpenVibe sites) — server/auth/fedcm.js
{
const fedcm = require('./auth/fedcm');
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 5036327d..c55136a0 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'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', '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'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', '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/oauth-link.test.js b/test/oauth-link.test.js
nnew file mode 100644
iindex 00000000..3216583b
---- /dev/null
++++ b/test/oauth-link.test.js
@@@ -0,0 +1,234 @@
++'use strict';
++// OpenVibe Link on the phone (plan 2026-11-07 §13.2, security round 2): a public first-party OAuth client (`link`) with
++// no secret and PKCE S256. Its session — the person's own token, so Actor and the drive accept it — is issued only when
++// BOTH hold: the authorization returned to the claimed https App Link (https://openvibe.network/link/auth, claimed in
++// /.well-known/assetlinks.json for the release certificate) and LINK_ANDROID_CERT_SHA256 publishes at least one
++// fingerprint. A custom scheme any app can answer, and an unpublished certificate, get the extension's read-only Dock
++// token instead. The chooser always says what the app does, and prompt=none is refused.
++//
++// node --import ./test/helpers/pg-preload.mjs test/oauth-link.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { getDb } = require('../server/db/database');
++const subjects = require('../server/identity/subjects');
++const oauthRoutes = require('../server/auth/oauth-routes');
++const authRoutes = require('../server/auth/routes');
++const appLinks = require('../server/auth/app-links');
++const { makeRequireAuth } = require('../server/auth/session');
++
++const ISSUER = 'https://openvibe.network';
++const APP_LINK = 'https://openvibe.network/link/auth';
++const SCHEME = 'openvibe-link://auth';
++const PRINTS = ['AA:'.repeat(31) + 'AA', 'BB:'.repeat(31) + 'BB'];
++const b64 = (buf) => Buffer.from(buf).toString('base64url');
++const verifier = () => b64(crypto.randomBytes(32));
++const challenge = (v) => b64(crypto.createHash('sha256').update(v).digest());
++
++(async () => {
++ const db = getDb();
++ const quiet = console.log; console.log = () => {};
++ const keys = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048,
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++ });
++ const config = { baseUrl: ISSUER, loginUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '7d' } };
++ await db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'phoneana', '[email protected]', 'x', 'user')").run();
++ await db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (2, 'phoneadmin', '[email protected]', 'x', 'admin')").run();
++ await db.prepare("INSERT INTO users (id, username, email, password_hash, role) VALUES (3, 'phonestreamer', '[email protected]', 'x', 'streamer')").run();
++ const user = await db.prepare('SELECT * FROM users WHERE id = 1').get();
++ const admin = await db.prepare('SELECT * FROM users WHERE id = 2').get();
++ const streamer = await db.prepare('SELECT * FROM users WHERE id = 3').get();
++ for (const u of [user, admin, streamer]) await subjects.ensureUserSubject(db, u);
++
++ const requireAuth = makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
++ const requireDock = (scope) => makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken, { dockScope: scope });
++ const app = express();
++ app.use(express.json());
++ app.use(express.urlencoded({ extended: true }));
++ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
++ app.use('/oauth', oauthRoutes);
++ appLinks.mount(app);
++ app.get('/api/v1/me/private', requireAuth, (req, res) => res.json({ ok: true, user: req.user.username }));
++ app.get('/api/v1/me/actions', requireDock('activity'), (req, res) => res.json({ ok: true }));
++ app.post('/api/v1/me/actions', requireAuth, (req, res) => res.json({ ok: true }));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++
++ const authorize = async (query) => {
++ const r = await fetch(`${base}/oauth/authorize?${new URLSearchParams(query)}`, { redirect: 'manual' });
++ return { status: r.status, location: r.headers.get('location') || null };
++ };
++ const confirm = async (token, body) => {
++ const r = await fetch(`${base}/oauth/confirm`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token, ...body }) });
++ return { status: r.status, body: await r.json().catch(() => null) };
++ };
++ const token = async (body) => {
++ const r = await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(body) });
++ return { status: r.status, body: await r.json().catch(() => null) };
++ };
++ const api = async (method, route, tok) => {
++ const r = await fetch(`${base}${route}`, { method, headers: { authorization: `Bearer ${tok}` } });
++ return { status: r.status, body: await r.json().catch(() => null) };
++ };
++ const info = async (query) => {
++ const r = await fetch(`${base}/oauth/client-info?${new URLSearchParams(query)}`);
++ return { status: r.status, body: await r.json().catch(() => null) };
++ };
++ /** A full code grant for one redirect and one person, with the verifier it must be redeemed with. */
++ const grant = async (redirectUri, who = user) => {
++ const v = verifier();
++ const state = 'state_' + crypto.randomBytes(12).toString('hex');
++ const start = await authorize({ client_id: 'link', redirect_uri: redirectUri, response_type: 'code', scope: 'profile theme', state, code_challenge: challenge(v), code_challenge_method: 'S256' });
++ assert.strictEqual(start.status, 302);
++ assert.match(start.location, /\/login\?/);
++ const cx = await confirm(authRoutes.signToken(who, keys.privateKey, config), { client_id: 'link', redirect_uri: redirectUri, response_type: 'code', scope: 'profile theme', state, code_challenge: challenge(v), code_challenge_method: 'S256' });
++ assert.strictEqual(cx.status, 200, JSON.stringify(cx.body));
++ const redirect = new URL(cx.body.redirect);
++ assert.strictEqual(redirect.searchParams.get('state'), state);
++ const code = redirect.searchParams.get('code');
++ assert.ok(code);
++ const issued = await token({ grant_type: 'authorization_code', client_id: 'link', code, redirect_uri: redirectUri, code_verifier: v });
++ assert.strictEqual(issued.status, 200, JSON.stringify(issued.body));
++ return issued.body;
++ };
++ const claimsOf = (body) => jwt.decode(body.access_token);
++
++ try {
++ // ── The migration is expand only, and the client is seeded with the claimed link and the debug scheme.
++ const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0050_oauth_token_session_families.sql'), 'utf8');
++ assert.match(sql, /^-- phase: expand/m);
++ assert.match(sql, /ADD COLUMN IF NOT EXISTS session_family/);
++ assert.doesNotMatch(sql, /ADD COLUMN[^;]*NOT NULL/i);
++
++ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get('link');
++ assert.ok(client, 'the link client is seeded');
++ assert.strictEqual(Number(client.is_public), 1);
++ assert.strictEqual(Number(client.session_token), 1);
++ assert.strictEqual(client.allowed_scopes, 'profile theme');
++ assert.deepStrictEqual(JSON.parse(client.redirect_uris), [APP_LINK, SCHEME]);
++ assert.strictEqual(client.client_secret, null, 'a public client keeps no secret');
++
++ // ── Claimed https redirect (RFC 8252 §7.2): assetlinks.json names the package and the env's certificates.
++ delete process.env.LINK_ANDROID_CERT_SHA256;
++ let links = await (await fetch(`${base}/.well-known/assetlinks.json`)).json();
++ assert.deepStrictEqual(links[0].target.package_name, 'network.openvibe.link');
++ assert.deepStrictEqual(links[0].target.sha256_cert_fingerprints, [], 'no release key, no claim');
++ assert.deepStrictEqual(links[0].relation, ['delegate_permission/common.handle_all_urls']);
++ process.env.LINK_ANDROID_CERT_SHA256 = `${PRINTS[0].toLowerCase()}, not-a-fingerprint,${PRINTS[1]}`;
++ links = await (await fetch(`${base}/.well-known/assetlinks.json`)).json();
++ assert.deepStrictEqual(links[0].target.sha256_cert_fingerprints, PRINTS, 'the env fingerprints, upper-cased, junk dropped');
++
++ // ── /link/auth is the plain page a browser gets when no installed app could claim it.
++ const page = await fetch(`${base}/link/auth?code=SECRET_CODE&state=s`);
++ assert.strictEqual(page.status, 200);
++ assert.match(page.headers.get('x-robots-tag') || '', /noindex/);
++ const html = await page.text();
++ assert.match(html, /Open this in OpenVibe Link/);
++ assert.doesNotMatch(html, /SECRET_CODE/, 'the page never echoes the code');
++
++ // ── Consent is always shown: client-info says what it does, and prompt=none is refused, never silent.
++ const linkInfo = await info({ client_id: 'link', redirect_uri: APP_LINK, scope: 'profile theme' });
++ assert.strictEqual(linkInfo.status, 200);
++ assert.strictEqual(linkInfo.body.act_as_you, true);
++ assert.match(linkInfo.body.consent_text, /act as you/);
++ assert.match(linkInfo.body.consent_text, /approvals/);
++ const companionInfo = await info({ client_id: 'companion', redirect_uri: 'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/', scope: 'dock' });
++ assert.strictEqual(companionInfo.status, 200);
++ assert.strictEqual(companionInfo.body.act_as_you, undefined, 'the extension reads; it does not act as you');
++ const silent = await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme', state: 's0', prompt: 'none', code_challenge: challenge(verifier()), code_challenge_method: 'S256' });
++ assert.strictEqual(silent.status, 302);
++ assert.match(silent.location, /^https:\/\/openvibe\.network\/link\/auth\?/);
++ assert.match(silent.location, /error=interaction_required/);
++ assert.doesNotMatch(silent.location, /code=/, 'no code without the chooser');
++
++ // ── Public client rules still hold: exact redirect, state, PKCE S256, a scope inside the ceiling.
++ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme' })).status, 400, 'state required');
++ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', state: 's', scope: 'profile theme' })).status, 400, 'PKCE required');
++ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', state: 's', scope: 'dock', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'scope ceiling');
++ assert.strictEqual((await authorize({ client_id: 'link', redirect_uri: 'openvibe-link://other', response_type: 'code', state: 's', scope: 'theme', code_challenge: challenge(verifier()), code_challenge_method: 'S256' })).status, 400, 'exact redirect');
++
++ // ── The custom scheme gets the read-only Dock token, even with certificates published: any app can claim it.
++ process.env.LINK_ANDROID_CERT_SHA256 = PRINTS.join(',');
++ const viaScheme = await grant(SCHEME);
++ let claims = claimsOf(viaScheme);
++ assert.strictEqual(claims.iss, `${ISSUER}/dock`, 'the private issuer: no normal user-token check takes it');
++ assert.ok(claims.aud.includes('openvibe.chat'));
++ assert.strictEqual(claims.role, undefined, 'a Dock token carries no role');
++ assert.strictEqual(claims.staff_caps, undefined);
++ assert.strictEqual(claims.client_id, 'link');
++ assert.ok(claims.fam);
++ assert.strictEqual((await api('GET', '/api/v1/me/private', viaScheme.access_token)).status, 401, 'not a session');
++
++ // ── The claimed https link with NO published certificate: Dock too. Android verified nothing.
++ delete process.env.LINK_ANDROID_CERT_SHA256;
++ const unverified = await grant(APP_LINK);
++ assert.strictEqual(claimsOf(unverified).iss, `${ISSUER}/dock`);
++ assert.strictEqual(claimsOf(unverified).role, undefined);
++
++ // ── The claimed https link WITH a certificate: the person's own session.
++ process.env.LINK_ANDROID_CERT_SHA256 = PRINTS.join(',');
++ const session = await grant(APP_LINK);
++ claims = claimsOf(session);
++ assert.strictEqual(session.expires_in, 86400);
++ assert.strictEqual(claims.iss, ISSUER, 'the normal issuer');
++ assert.ok(claims.aud.includes('openvibe.network'), 'the normal audiences');
++ assert.strictEqual(claims.role, 'user');
++ assert.strictEqual(claims.client_id, 'link');
++ assert.strictEqual(claims.scope, 'profile theme');
++ assert.ok(claims.fam, 'the family, so the account page can end it');
++ assert.strictEqual(claims.is_owner, undefined);
++ assert.strictEqual(claims.staff_caps, undefined, 'a session token carries no staff claims');
++ assert.strictEqual(claims.typ, undefined);
++ assert.strictEqual((await api('GET', '/api/v1/me/actions', session.access_token)).status, 200);
++ assert.strictEqual((await api('POST', '/api/v1/me/actions', session.access_token)).status, 200, 'answering an approval');
++ assert.strictEqual((await api('GET', '/api/v1/me/private', session.access_token)).status, 200, 'a plain user token');
++ assert.strictEqual((await api('GET', '/api/v1/me/private', 'nope')).status, 401);
++
++ // ── A staff member's phone acts as a user: role capped, no staff claims. A non-staff role is kept.
++ const adminSession = claimsOf(await grant(APP_LINK, admin));
++ assert.strictEqual(adminSession.role, 'user', "an admin's phone is not an admin");
++ assert.strictEqual(adminSession.staff_caps, undefined);
++ assert.strictEqual(adminSession.is_owner, undefined);
++ assert.strictEqual(claimsOf(await grant(APP_LINK, streamer)).role, 'streamer', 'a streamer is not staff');
++ assert.strictEqual(claimsOf(await grant(APP_LINK, streamer)).staff_caps, undefined);
++
++ // ── A refresh keeps its family's kind: Dock stays Dock, session stays session (while a certificate is published).
++ const dockRefresh = await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: viaScheme.refresh_token });
++ assert.strictEqual(dockRefresh.status, 200, JSON.stringify(dockRefresh.body));
++ assert.strictEqual(claimsOf(dockRefresh.body).iss, `${ISSUER}/dock`, 'a scheme family never becomes a session');
++ assert.notStrictEqual(dockRefresh.body.refresh_token, viaScheme.refresh_token, 'rotated');
++
++ const sessionRefresh = await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: session.refresh_token });
++ assert.strictEqual(sessionRefresh.status, 200, JSON.stringify(sessionRefresh.body));
++ assert.strictEqual(claimsOf(sessionRefresh.body).iss, ISSUER);
++ assert.strictEqual(claimsOf(sessionRefresh.body).role, 'user');
++ assert.strictEqual(claimsOf(sessionRefresh.body).staff_caps, undefined);
++ assert.strictEqual((await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: session.refresh_token })).status, 400, 'the old refresh token is spent');
++
++ // Withdrawing the certificate stops sessions at the next refresh; the family cannot promote itself back.
++ delete process.env.LINK_ANDROID_CERT_SHA256;
++ const downgraded = await token({ grant_type: 'refresh_token', client_id: 'link', refresh_token: sessionRefresh.body.refresh_token });
++ assert.strictEqual(downgraded.status, 200, JSON.stringify(downgraded.body));
++ assert.strictEqual(claimsOf(downgraded.body).iss, `${ISSUER}/dock`, 'no certificate, no session');
++
++ // ── A wrong verifier is refused (and spent, as the flow requires).
++ const v2 = verifier();
++ await authorize({ client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme', state: 's2', code_challenge: challenge(v2), code_challenge_method: 'S256' });
++ const cx2 = await confirm(authRoutes.signToken(user, keys.privateKey, config), { client_id: 'link', redirect_uri: APP_LINK, response_type: 'code', scope: 'profile theme', state: 's2', code_challenge: challenge(v2), code_challenge_method: 'S256' });
++ const code2 = new URL(cx2.body.redirect).searchParams.get('code');
++ assert.strictEqual((await token({ grant_type: 'authorization_code', client_id: 'link', code: code2, redirect_uri: APP_LINK, code_verifier: verifier() })).status, 400, 'a wrong verifier is refused');
++ } finally {
++ delete process.env.LINK_ANDROID_CERT_SHA256;
++ await new Promise((r) => server.close(r));
++ console.log = quiet;
++ }
++ console.log('oauth link: all passed');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/staff-claims.test.js b/test/staff-claims.test.js
iindex ed663a2b..d140a397 100644
---- a/test/staff-claims.test.js
++++ b/test/staff-claims.test.js
@@@ -20,5 +20,9 @@ assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', us
assert.strictEqual(staff.can({ role: 'admin', ...staffClaims({ role: 'admin', username: 'boss' }) }, 'staff.secrets.manage'), false);
// Both user-token signers add them.
const fs = require('fs');
--for (const f of ['server/auth/routes.js', 'server/auth/oauth-routes.js']) assert.ok(fs.readFileSync(require('path').join(__dirname, '..', f), 'utf8').includes('...staffClaims(user)'), f);
++for (const f of ['server/auth/routes.js', 'server/auth/oauth-routes.js']) assert.ok(fs.readFileSync(require('path').join(__dirname, '..', f), 'utf8').includes('staffClaims(user)'), f);
++// A phone's session token (OpenVibe Link) is not staff authority: oauth-routes leaves the claims out and caps the role.
++const oauth = fs.readFileSync(require('path').join(__dirname, '..', 'server/auth/oauth-routes.js'), 'utf8');
++assert.ok(oauth.includes('sessionClient ? {} : staffClaims(user)'), 'a session client gets no staff claims');
++assert.ok(oauth.includes('nonStaffRole(user.role)'), 'a session client keeps only a non-staff role');
console.log('staff claims: all checks passed');
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex b5cae27e..8b1194b6 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'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', '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'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', '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');