openvibers/openvibe.network Commits

Pair device lands on Machines signed in (#809)

OP OpenVibers committed 2 days ago · ac2726d899 · 13 files, +497 −57

* Pair device lands on Machines signed in: link handoff arrival, pair intent, login fixes

* Keep the old-brand guard green on the hub hop

Changes

ddiff --git a/public/js/login.js b/public/js/login.js
iindex cbd6d50e..3a17db1d 100644
---- a/public/js/login.js
++++ b/public/js/login.js
@@@ -1019,6 +1019,17 @@ async function continueAnonymous() {
hasAccounts = accounts.length > 0;
const resetToken = params.get('token');
++ // A return into a page that only makes sense signed in (the machines hub): anonymous mode would
++ // only land on a sign-in wall, so Continue Anonymously — and the divider above it — stays hidden.
++ if (isSignedInOnlyReturn(params.get('return'))) {
++ const anon = document.getElementById('anon-btn');
++ if (anon) {
++ anon.style.display = 'none';
++ const prev = anon.previousElementSibling;
++ if (prev && prev.classList && prev.classList.contains('divider')) prev.style.display = 'none';
++ }
++ }
++
if (window.location.pathname === '/forgot-password') {
showForgotForm();
return;
@@@ -1156,8 +1167,20 @@ async function signInWithPasskey(conditional = false) {
}
}
if (window.PublicKeyCredential) {
-- const button = document.createElement('button'); button.type = 'button'; button.className = 'ovk-btn ovk-btn--quiet'; button.textContent = 'Sign in with a passkey';
++ const button = document.createElement('button'); button.type = 'button'; button.id = 'passkey-btn'; button.className = 'anon-btn'; button.textContent = 'Sign in with a passkey';
button.onclick = () => signInWithPasskey();
document.getElementById('login-form').after(button);
PublicKeyCredential.isConditionalMediationAvailable?.().then(available => { if (available) signInWithPasskey(true); }).catch(() => {});
}
++
++// Pages that only make sense signed in (the machines hub): Continue Anonymously would only land on a
++// sign-in wall, so the button (and its divider) stays hidden when the return targets one of them.
++function isSignedInOnlyReturn(raw) {
++ const s = String(raw || '');
++ if (!s) return false;
++ if (s.startsWith('/') && !s.startsWith('//')) return /^\/machines(\/|\?|#|$)/.test(s);
++ try {
++ const u = new URL(s);
++ return u.protocol === 'https:' && u.hostname.toLowerCase() === 'openvibe.link' && /^\/machines(\/|$)/.test(u.pathname);
++ } catch { return false; }
++}
ddiff --git a/public/js/my/core.js b/public/js/my/core.js
iindex dd5cfdcb..a3ef20b6 100644
---- a/public/js/my/core.js
++++ b/public/js/my/core.js
@@@ -178,6 +178,16 @@ function openInitialSection() {
if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines','apps'].includes(routeSection)) {
return showSection('anon');
}
++ // Pairing intent: openvibe.link/machines#pair (and /pair, which lands on ?pair=1 so the intent survives
++ // a sign-in round trip). An account session opens Machines and starts pairing at once; anywhere else the
++ // hash or query is just ignored.
++ let pairWanted = hash === 'pair';
++ try { pairWanted = pairWanted || new URLSearchParams(window.location.search).get('pair') === '1'; } catch { /* keep the hash answer */ }
++ if (pairWanted && !isAnonSession() && document.getElementById('sec-machines')) {
++ showSection('machines');
++ if (typeof mcPair === 'function') mcPair();
++ return;
++ }
if (document.getElementById('sec-' + routeSection)) {
showSection(routeSection);
}
ddiff --git a/public/login.html b/public/login.html
iindex 2897294a..4c02ea98 100644
---- a/public/login.html
++++ b/public/login.html
@@@ -185,7 +185,7 @@
</div>
<div class="divider"><span>or</span></div>
-- <button class="anon-btn" onclick="continueAnonymous()">
++ <button class="anon-btn" id="anon-btn" onclick="continueAnonymous()">
<i class="fa-solid fa-user-secret"></i> Continue Anonymously
</button>
ddiff --git a/server/auth/handoff.js b/server/auth/handoff.js
iindex 6e8d3514..8f002264 100644
---- a/server/auth/handoff.js
++++ b/server/auth/handoff.js
@@@ -85,6 +85,61 @@ function registeredRedirect(allowedUris, redirectUri) {
return Array.isArray(allowedUris) && allowedUris.some(u => String(u).toLowerCase() === want);
}
++/**
++ * Mint a ticket for one destination, storing only its hash. Throws { status, code, error } when the
++ * destination client is unknown or the redirect URI is not registered for it. Same-service arrivals
++ * (openvibe.link's hub) mint through this instead of the HTTP route below.
++ */
++async function issueTicketForUser(database, { userId, clientId, redirectUri, scope = 'profile theme', source = null }) {
++ if (!Number.isInteger(userId)) throw { status: 401, code: 'handoff.no_user', error: 'Sign in required' };
++ if (!clientId || !redirectUri) throw { status: 400, code: 'invalid_request', error: 'client_id and redirect_uri are required' };
++ const client = await database.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(String(clientId));
++ if (!client) throw { status: 400, code: 'invalid_request', error: 'Unknown destination client' };
++ if (!registeredRedirect(JSON.parse(client.redirect_uris || '[]'), String(redirectUri))) {
++ throw { status: 400, code: 'invalid_request', error: 'Unregistered redirect_uri' };
++ }
++ const { ticket, ticketHash, issuedAt, expiresAt } = mintTicket({ userId });
++ // Expired tickets never redeem; sweep them where a fresh one is minted so the table stays small.
++ try { await database.prepare('DELETE FROM handoff_tickets WHERE expires_at < ?').run(new Date(Date.now() - 3600e3).toISOString()); } catch { /* sweep is best-effort */ }
++ await database.prepare(`INSERT INTO handoff_tickets (ticket_hash, user_id, client_id, redirect_uri, scope, source, issued_at, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(ticketHash, userId, String(clientId), String(redirectUri), String(scope).slice(0, 200), source ? String(source).slice(0, 200) : null, issuedAt, expiresAt);
++ return { ticket, expiresAt, expiresIn: TTL_S, clientId: String(clientId) };
++}
++
++/**
++ * Consume a ticket for one destination, atomically single-use. → { user, scope }. Throws
++ * { status, code, error } for an unknown, used, expired, mismatched or revoked ticket, or when the
++ * account is banned, merged, deleted, or its tokens were revoked after issuance. Same-service
++ * arrivals use this and then sign their own session; cross-service arrivals go through /redeem.
++ */
++async function consumeTicket(database, { ticket, clientId, redirectUri }) {
++ const fail = (status, code, error) => { throw { status, code, error }; };
++ const presented = String(ticket || '');
++ if (!presented.startsWith(TICKET_PREFIX) || presented.length > 200) fail(400, 'invalid_grant', 'Unknown or used handoff ticket');
++ const row = await database.prepare('SELECT * FROM handoff_tickets WHERE ticket_hash = ?').get(hashTicket(presented));
++ if (!row || row.used) fail(400, 'invalid_grant', 'Unknown or used handoff ticket');
++ if (row.client_id !== String(clientId)) fail(400, 'invalid_grant', 'Ticket is for another client');
++ if (String(row.redirect_uri).toLowerCase() !== String(redirectUri).toLowerCase()) fail(400, 'invalid_grant', 'Redirect URI mismatch');
++ if (Date.now() > parseTime(row.expires_at)) {
++ await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);
++ fail(400, 'invalid_grant', 'Handoff ticket expired');
++ }
++ const user = await database.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
++ if (!user || user.is_banned || user.merged_into || user.deleted_at) {
++ await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);
++ fail(400, 'invalid_grant', 'Account is no longer valid');
++ }
++ // A password change or sign-out-everywhere after issuance revokes outstanding tickets.
++ if (user.token_valid_after && parseTime(row.issued_at) < parseTime(user.token_valid_after)) {
++ await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);
++ fail(400, 'invalid_grant', 'Handoff ticket revoked');
++ }
++ // Atomic single-use: two concurrent redeems cannot both succeed.
++ const consumed = await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ? AND used = 0').run(row.ticket_hash);
++ if (consumed.changes !== 1) fail(400, 'invalid_grant', 'Unknown or used handoff ticket');
++ return { user, scope: row.scope };
++}
++
/** The redeem router. `requireAuth` is the session guard (user Bearer token or first-party cookie). */
function router({ db, requireAuth }) {
const r = express.Router();
@@@ -97,21 +152,13 @@ function router({ db, requireAuth }) {
const database = await getDb();
const user = req.user;
if (!user || user.is_anon) return res.status(403).json({ code: 'handoff.guest', error: 'sign in with an account to mint a handoff ticket' });
-- const clientId = String(req.body?.client_id || '');
-- const redirectUri = String(req.body?.redirect_uri || '');
-- const scope = String(req.body?.scope || 'profile theme').slice(0, 200);
-- const source = String(req.body?.source || '').slice(0, 200) || null;
-- if (!clientId || !redirectUri) return res.status(400).json({ error: 'client_id and redirect_uri are required' });
-- const client = await database.prepare('SELECT client_id, redirect_uris FROM oauth_clients WHERE client_id = ?').get(clientId);
-- if (!client) return res.status(400).json({ error: 'invalid_request', error_description: 'Unknown destination client' });
-- if (!registeredRedirect(JSON.parse(client.redirect_uris || '[]'), redirectUri)) {
-- return res.status(400).json({ error: 'invalid_request', error_description: 'Unregistered redirect_uri' });
-- }
-- const { ticket, ticketHash, issuedAt, expiresAt } = mintTicket({ userId: user.id });
-- await database.prepare(`INSERT INTO handoff_tickets (ticket_hash, user_id, client_id, redirect_uri, scope, source, issued_at, expires_at)
-- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(ticketHash, user.id, clientId, redirectUri, scope, source, issuedAt, expiresAt);
-- res.json({ ticket, expires_at: expiresAt, expires_in: TTL_S, client_id: clientId });
++ const { ticket, expiresAt, expiresIn, clientId } = await issueTicketForUser(database, {
++ userId: user.id, clientId: String(req.body?.client_id || ''), redirectUri: String(req.body?.redirect_uri || ''),
++ scope: req.body?.scope, source: req.body?.source,
++ });
++ res.json({ ticket, expires_at: expiresAt, expires_in: expiresIn, client_id: clientId });
} catch (err) {
++ if (err && err.status) return res.status(err.status).json({ code: err.code, error: err.error });
res.status(500).json({ error: 'handoff issue failed' });
}
});
@@@ -129,36 +176,11 @@ function router({ db, requireAuth }) {
if (!client || !sameSecret(client.client_secret, clientSecret)) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
}
-- const presented = String(ticket || '');
-- if (!presented.startsWith(TICKET_PREFIX) || presented.length > 200) {
-- return res.status(400).json({ error: 'invalid_grant', error_description: 'Unknown or used handoff ticket' });
-- }
-- const row = await database.prepare('SELECT * FROM handoff_tickets WHERE ticket_hash = ?').get(hashTicket(presented));
-- if (!row || row.used) return res.status(400).json({ error: 'invalid_grant', error_description: 'Unknown or used handoff ticket' });
-- if (row.client_id !== String(clientId)) return res.status(400).json({ error: 'invalid_grant', error_description: 'Ticket is for another client' });
-- if (String(row.redirect_uri).toLowerCase() !== String(redirectUri).toLowerCase()) {
-- return res.status(400).json({ error: 'invalid_grant', error_description: 'Redirect URI mismatch' });
-- }
-- if (Date.now() > parseTime(row.expires_at)) {
-- await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);
-- return res.status(400).json({ error: 'invalid_grant', error_description: 'Handoff ticket expired' });
-- }
-- const user = await database.prepare('SELECT * FROM users WHERE id = ?').get(row.user_id);
-- if (!user || user.is_banned || user.merged_into || user.deleted_at) {
-- await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);
-- return res.status(400).json({ error: 'invalid_grant', error_description: 'Account is no longer valid' });
-- }
-- // A password change or sign-out-everywhere after issuance revokes outstanding tickets.
-- if (user.token_valid_after && parseTime(row.issued_at) < parseTime(user.token_valid_after)) {
-- await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ?').run(row.ticket_hash);
-- return res.status(400).json({ error: 'invalid_grant', error_description: 'Handoff ticket revoked' });
-- }
-- // Atomic single-use: two concurrent redeems cannot both succeed.
-- const consumed = await database.prepare('UPDATE handoff_tickets SET used = 1 WHERE ticket_hash = ? AND used = 0').run(row.ticket_hash);
-- if (consumed.changes !== 1) return res.status(400).json({ error: 'invalid_grant', error_description: 'Unknown or used handoff ticket' });
-- const { code } = await issueHandoffCode(database, { clientId: client.client_id, userId: user.id, redirectUri: String(redirectUri), scope: row.scope });
++ const { user, scope } = await consumeTicket(database, { ticket, clientId, redirectUri });
++ const { code } = await issueHandoffCode(database, { clientId: client.client_id, userId: user.id, redirectUri: String(redirectUri), scope });
res.json({ code, expires_in: CODE_TTL_S });
} catch (err) {
++ if (err && err.status) return res.status(err.status).json({ error: err.code || 'invalid_grant', error_description: err.error });
res.status(500).json({ error: 'handoff redeem failed' });
}
});
@@@ -166,4 +188,4 @@ function router({ db, requireAuth }) {
return r;
}
--module.exports = { router, mintTicket, issueHandoffCode, hashTicket, TTL_S, CODE_TTL_S, TICKET_PREFIX };
++module.exports = { router, mintTicket, issueTicketForUser, consumeTicket, issueHandoffCode, hashTicket, TTL_S, CODE_TTL_S, TICKET_PREFIX };
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 2f85ed21..f527ad2e 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -713,3 +713,6 @@ router.post('/userinfo', oidc.userinfo);
module.exports = router;
module.exports.cleanLoginHint = cleanLoginHint;
module.exports.siteNameForHost = siteNameForHost;
++// Same-service arrivals (openvibe.link's hub) sign their session through these instead of the HTTP grant.
++module.exports.issueTokenPair = issueTokenPair;
++module.exports.recordLinkedService = recordLinkedService;
ddiff --git a/server/index.js b/server/index.js
iindex 1efdd4e5..585c6803 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -745,7 +745,7 @@ homePopularity.refresh().catch(() => {}); // floating-ok: the first reader get
const tryNext = require('./home/try-next').createTryNext({ db, popularity: homePopularity });
// The Node's own front door on its own subdomain (openvibe.link): the Host header decides. It mounts
// before the agent layer, so the Node's page is not answered by Network's home renderer.
--app.use(require('./link/routes').createLinkRoutes({ release: release.release }));
++app.use(require('./link/routes').createLinkRoutes({ release: release.release, db, config, publicKey }));
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the public pages as Markdown twins. It
// answers their HTML as well, with the discovery tags in the head, so it mounts before the routes for those pages.
require('openvibe-shared/agent-layer').mount(app, {
ddiff --git a/server/link/routes.js b/server/link/routes.js
iindex 03d22c3c..dc2ab34b 100644
---- a/server/link/routes.js
++++ b/server/link/routes.js
@@@ -12,11 +12,21 @@ const fs = require('node:fs');
const pathLib = require('node:path');
const page = require('./page');
const release = require('./release');
++const { verifySession } = require('../auth/session');
++const { consumeTicket } = require('../auth/handoff');
++const { issueTokenPair, recordLinkedService } = require('../auth/oauth-routes');
const HOST = page.HOST;
const WWW_HOST = page.WWW_HOST;
const SITE = page.SITE;
++// The hub's sign-in: the link-web OAuth client, and the callback the tickets are bound to.
++const LINK_CLIENT_ID = 'link-web';
++const LINK_CALLBACK = `${SITE}/auth/callback`;
++const HUB_PATH = '/machines';
++const HUB_PAGE = pathLib.join(__dirname, '..', '..', 'public', 'my.html');
++const BOT_RE = /bot|crawl|spider|slurp|headless/i;
++
/** The request's host, lowercased, without the port. */
function hostOf(req) {
return String((req.headers && req.headers.host) || '').split(':')[0].toLowerCase();
@@@ -66,11 +76,84 @@ function llmsTxt() {
].join('\n');
}
++/** This request's cookies as an object (no cookie-parser needed). */
++function readCookies(req) {
++ const out = {};
++ for (const part of String((req.headers && req.headers.cookie) || '').split(';')) {
++ const i = part.indexOf('=');
++ if (i <= 0) continue;
++ const k = part.slice(0, i).trim();
++ if (!(k in out)) { try { out[k] = decodeURIComponent(part.slice(i + 1).trim()); } catch { out[k] = part.slice(i + 1).trim(); } }
++ }
++ return out;
++}
++
++/** The hub page's session cookies, matching the SDK sign-in on /auth (link/auth.js). */
++function setLinkSession(res, accessToken, refreshToken, secure) {
++ res.cookie('ov_token', accessToken, { sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600e3 });
++ if (refreshToken) res.cookie('ov_refresh', refreshToken, { sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600e3 });
++ res.cookie('ov_sso_hint', 'account', { sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600e3 });
++}
++
/**
* Link's routes. Returns null for a request that is not for this host, so the router can be mounted early
-- * and every other host falls through to Network's own pages.
++ * and every other host falls through to Network's own pages. With db, config and publicKey the machines hub
++ * signs arrivals in (a handoff ticket, then the silent login); without them it serves the page as before.
*/
--function createLinkRoutes({ releaseId, releaseApi = release } = {}) {
++function createLinkRoutes({ releaseId, releaseApi = release, db = null, config = null, publicKey = null, log = console } = {}) {
++ /** A live link-web session for this request, or null. Never throws. */
++ async function linkSession(req) {
++ try {
++ if (!db || !config || !publicKey) return null;
++ const cookies = readCookies(req);
++ const token = (req.headers && req.headers.authorization || '').startsWith('Bearer ')
++ ? String(req.headers.authorization).slice(7).trim() : cookies.ov_token;
++ if (!token) return null;
++ const out = await verifySession(token, { db, publicKey, config });
++ return out && !out.error ? out.user : null;
++ } catch { return null; }
++ }
++
++ /** The machines hub arrival: ticket → session, session → hub, nobody → silent sign-in. */
++ async function machinesHub(req, res) {
++ const noStore = () => res.set('Cache-Control', 'no-store');
++ const canSign = !!(db && config && publicKey);
++ const userAgent = String((req.headers && req.headers['user-agent']) || '');
++ let params;
++ try { params = new URL(req.url, SITE).searchParams; } catch { params = new URLSearchParams(); }
++ const ticket = params.get('ov_handoff');
++ const cookies = readCookies(req);
++ const sessionUser = canSign && ['GET', 'HEAD'].includes(req.method) ? await linkSession(req) : null;
++ // A ticket from another OpenVibe site (openvibe.my mints one on its way here): redeem it for this
++ // host's session and scrub it from the address bar. Never for a browser that already has a session
++ // here (no silent account switch), and never for a browser this host signed out (the guest hint).
++ if (ticket && /^ovh_[A-Za-z0-9_-]{20,200}$/.test(ticket) && !sessionUser && cookies.ov_sso_hint !== 'guest'
++ && canSign && ['GET', 'HEAD'].includes(req.method)) {
++ try {
++ const { user, scope } = await consumeTicket(db, { ticket, clientId: LINK_CLIENT_ID, redirectUri: LINK_CALLBACK });
++ const client = await db.prepare('SELECT * FROM oauth_clients WHERE client_id = ?').get(LINK_CLIENT_ID);
++ if (!client) throw new Error('the link-web OAuth client is not registered');
++ const pair = await issueTokenPair(db, config, req, user, client, {}, scope);
++ await recordLinkedService(db, user, client);
++ setLinkSession(res, pair.accessToken, pair.refreshToken, config.nodeEnv === 'production');
++ params.delete('ov_handoff');
++ const clean = `${HUB_PATH}${params.toString() ? `?${params.toString()}` : ''}`;
++ return res.redirect(302, clean);
++ } catch (err) { (log.warn || log.log || (() => {}))(`[link] handoff arrival failed: ${err && err.message ? err.message : err}`); }
++ }
++ // Signed in, a crawler, or back from a silent sign-in that found nobody: the hub itself.
++ if (sessionUser || BOT_RE.test(userAgent) || params.get('sso') === 'none') {
++ noStore();
++ return res.sendFile(HUB_PAGE);
++ }
++ // Nobody home: the silent sign-in (prompt=none) signs a Network-signed-in person in with no click;
++ // the login page only shows when they are truly signed out. Pair intent survives in ?pair=1.
++ params.delete('ov_handoff');
++ const next = `${HUB_PATH}${params.toString() ? `?${params.toString()}` : ''}`;
++ noStore();
++ return res.redirect(302, `/auth/login?silent=1&next=${encodeURIComponent(next)}`);
++ }
++
const router = require('express').Router();
router.use(require('./egress').publicRouter());
@@@ -109,15 +192,22 @@ function createLinkRoutes({ releaseId, releaseApi = release } = {}) {
// The machine hub lives on Link's own origin. It uses Network's same-origin
// session and machine API, and keeps the existing roles and pairing controls.
++ // Arrival signs in first: a handoff ticket, then the silent login; the login
++ // page only shows when the person is truly signed out.
if (path === '/machines') {
-- return res.set('Cache-Control', 'no-store').sendFile(pathLib.join(__dirname, '..', '..', 'public', 'my.html'));
++ return machinesHub(req, res).catch(next);
}
// Older links name a machine's screen, desks or pairing below /machines (Actor's Watch links, Bot's old
// pages). The hub is the one page this host has, so they land on it instead of the catch-all's Network 404.
if (path.startsWith('/machines/')) {
return res.set('Cache-Control', 'no-store').redirect(301, `${SITE}/machines`);
}
-- if (path === '/pair' || path === '/panel') {
++ // Pairing starts on the hub: /pair lands there with the intent in ?pair=1 (a query survives the
++ // sign-in round trip; #pair works too once the page loads). /panel is the old name for the hub.
++ if (path === '/pair') {
++ return res.set('Cache-Control', 'no-store').redirect(302, `${SITE}/machines?pair=1`);
++ }
++ if (path === '/panel') {
return res.set('Cache-Control', 'no-store').redirect(302, `${SITE}/machines`);
}
ddiff --git a/server/my/routes.js b/server/my/routes.js
iindex 91a316cd..150a62dd 100644
---- a/server/my/routes.js
++++ b/server/my/routes.js
@@@ -22,6 +22,7 @@
const path = require('path');
const express = require('express');
const { createSsoClient, COOKIES } = require('openvibe-sdk/sso');
++const { issueTicketForUser } = require('../auth/handoff');
const MY_HOST = 'openvibe.my';
const WWW_HOST = 'www.openvibe.my';
@@@ -172,6 +173,26 @@ async function createMyRoutes({ db, config = require('../config'), sso, pagePath
const auth = client.router(express);
const page = path.resolve(pagePath);
++ /**
++ * The hub's old paths here and on the apex 302 to the Link hub. When the browser carries
++ * this host's session, mint a one-time handoff ticket for link-web so openvibe.link signs them in
++ * with no round trip; without a session the plain hop lands on the hub's silent sign-in instead.
++ */
++ async function machinesRedirect(req, res, host) {
++ const plain = `https://openvibe.link/machines${queryOf(req)}`;
++ try {
++ const token = readCookie(req, COOKIES.access);
++ const claims = token ? await client.verify(token).catch(() => null) : null;
++ if (!claims || !Number.isInteger(claims.id) || claims.is_anon) return res.redirect(302, plain);
++ const { ticket } = await issueTicketForUser(db, {
++ userId: claims.id, clientId: 'link-web', redirectUri: 'https://openvibe.link/auth/callback', source: host,
++ });
++ return res.redirect(302, `${plain}${plain.includes('?') ? '&' : '?'}ov_handoff=${encodeURIComponent(ticket)}`);
++ } catch {
++ return res.redirect(302, plain);
++ }
++ }
++
function serveApp(req, res) {
const quiet = String(req.query.sso || '') === 'none' || readCookie(req, COOKIES.hint) === 'guest';
if (!readCookie(req, COOKIES.access) && !quiet && ['GET', 'HEAD'].includes(req.method) && !BOT_RE.test(String(req.headers['user-agent'] || ''))) {
@@@ -205,9 +226,10 @@ async function createMyRoutes({ db, config = require('../config'), sso, pagePath
router.use((req, res, next) => {
const host = hostOf(req);
if (host === WWW_HOST) return res.redirect(301, `https://${MY_HOST}${req.originalUrl}`);
-- // The machines hub moved to the Link product: /machines goes there from both the apex and the hub.
++ // The machines hub moved to the Link product: /machines goes there from both the apex and the hub,
++ // carrying a handoff ticket when the browser is signed in here.
if (req.path === '/machines' && ['GET', 'HEAD'].includes(req.method) && (host === MY_HOST || NETWORK_HOSTS.has(host))) {
-- return res.redirect(302, `https://openvibe.link/machines${queryOf(req)}`);
++ return machinesRedirect(req, res, host).catch(() => res.redirect(302, `https://openvibe.link/machines${queryOf(req)}`));
}
if (NETWORK_HOSTS.has(host)) {
if (!isHubPath(req.path) || !['GET', 'HEAD'].includes(req.method)) return next();
ddiff --git a/test/link-home.test.js b/test/link-home.test.js
iindex 9fc12493..44284cfa 100644
---- a/test/link-home.test.js
++++ b/test/link-home.test.js
@@@ -220,12 +220,18 @@ const SHAPED = {
assert.ok(String(linkInstall.headers.location || '').endsWith('/install.sh'));
const machines = await get('/machines', 'openvibe.link');
-- assert.strictEqual(machines.status, 200, 'the Link host serves the machine hub');
-- assert.ok(machines.body.includes('sec-machines'), 'pairing and machine controls render on Link');
++ assert.strictEqual(machines.status, 302, 'a signed-out arrival starts the silent sign-in, not the hub');
++ assert.strictEqual(machines.headers.location, '/auth/login?silent=1&next=%2Fmachines');
assert.strictEqual(machines.headers['cache-control'], 'no-store');
-- for (const p of ['/pair', '/panel']) {
-- const hub = await get(p, 'openvibe.link');
-- assert.strictEqual(hub.status, 302, `${p} opens the Link machine hub`);
++ const guest = await get('/machines?sso=none', 'openvibe.link');
++ assert.strictEqual(guest.status, 200, 'back from a silent sign-in that found nobody, the hub itself');
++ assert.ok(guest.body.includes('sec-machines'), 'pairing and machine controls render on Link');
++ const pair = await get('/pair', 'openvibe.link');
++ assert.strictEqual(pair.status, 302, '/pair opens the hub with the pairing intent');
++ assert.strictEqual(pair.headers.location, 'https://openvibe.link/machines?pair=1');
++ {
++ const hub = await get('/panel', 'openvibe.link');
++ assert.strictEqual(hub.status, 302, '/panel opens the Link machine hub');
assert.strictEqual(hub.headers.location, 'https://openvibe.link/machines');
}
// Bot's old sub-paths (and Actor's Watch links) land on the hub rather than the catch-all's Network 404.
ddiff --git a/test/link-machines-signin.test.js b/test/link-machines-signin.test.js
nnew file mode 100644
iindex 00000000..8c8bc152
---- /dev/null
++++ b/test/link-machines-signin.test.js
@@@ -0,0 +1,96 @@
++'use strict';
++// openvibe.link/machines arrival: a handoff ticket signs the browser in with no round trip, a session
++// serves the hub, nobody home starts the silent sign-in, and the login page only shows the truly signed out.
++// ov test test/link-machines-signin.test.js (or: node test/link-machines-signin.test.js)
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { getDb } = require('../server/db/database');
++const { issueTicketForUser } = require('../server/auth/handoff');
++const { createLinkRoutes } = require('../server/link/routes');
++
++(async () => {
++const db = getDb();
++await db.prepare("UPDATE oauth_clients SET client_secret = 'link-secret' WHERE client_id = 'link-web'").run();
++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (11, 'linkarr', 'x')").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.locals.db = db;
++app.locals.config = { baseUrl: ISSUER, loginUrl: ISSUER, nodeEnv: 'test', jwt: { issuer: ISSUER, accessTokenExpiry: '1h', refreshTokenExpiry: '30d' } };
++app.locals.privateKey = keys.privateKey;
++app.locals.publicKey = keys.publicKey;
++app.use(createLinkRoutes({ db, config: app.locals.config, publicKey: keys.publicKey, log: { warn() {}, log() {} } }));
++app.use((req, res) => res.status(404).end());
++const server = http.createServer(app);
++
++const DEST = 'https://openvibe.link/auth/callback';
++const tokenFor = (id, name) => jwt.sign({ sub: id, id, username: name }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '5m' });
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const get = (path, cookie = '', host = 'openvibe.link') => new Promise((resolve, reject) => {
++ const req = http.get({ hostname: '127.0.0.1', port: server.address().port, path, headers: { Host: host, ...(cookie ? { Cookie: cookie } : {}) } }, res => {
++ let body = ''; res.on('data', c => body += c); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
++ });
++ req.on('error', reject);
++ });
++ const cookiesOf = (res) => (res.headers['set-cookie'] || []).map(c => c.split(';')[0]);
++ const session = tokenFor(11, 'linkarr');
++
++ // A ticket arrival signs in with no round trip and scrubs the ticket from the address bar.
++ const { hashTicket } = require('../server/auth/handoff');
++ const { ticket } = await issueTicketForUser(db, { userId: 11, clientId: 'link-web', redirectUri: DEST, source: 'openvibe.my' });
++ let r = await get(`/machines?ov_handoff=${encodeURIComponent(ticket)}`);
++ assert.strictEqual(r.status, 302, 'ticket arrival redirects');
++ assert.strictEqual(r.headers.location, '/machines', 'the ticket leaves the address bar');
++ const jar = cookiesOf(r).join(';');
++ assert.ok(jar.includes('ov_token=') && jar.includes('ov_sso_hint=account'), 'a link-web session is set');
++ const used = await db.prepare('SELECT used FROM handoff_tickets WHERE ticket_hash = ?').get(hashTicket(ticket));
++ assert.strictEqual(used.used, 1, 'the ticket is consumed');
++
++ // A signed-in arrival serves the hub; the ticket is never touched.
++ const { ticket: spare } = await issueTicketForUser(db, { userId: 11, clientId: 'link-web', redirectUri: DEST });
++ r = await get(`/machines?ov_handoff=${encodeURIComponent(spare)}`, `ov_token=${session}`);
++ assert.strictEqual(r.status, 200, 'a session serves the hub');
++ assert.ok(r.body.includes('sec-machines'), 'the hub page');
++ const spareRow = await db.prepare('SELECT used FROM handoff_tickets WHERE ticket_hash = ?').get(hashTicket(spare));
++ assert.strictEqual(spareRow.used, 0, 'no silent account switch: the ticket stays unused');
++
++ // Nobody home starts the silent sign-in, keeping pair intent; back as a guest lands on the hub.
++ r = await get('/machines?pair=1');
++ assert.strictEqual(r.status, 302, 'no session starts the silent sign-in');
++ assert.ok(r.headers.location.startsWith('/auth/login?silent=1&next=%2Fmachines%3Fpair%3D1'), `silent login keeps pair intent: ${r.headers.location}`);
++ r = await get('/machines?sso=none');
++ assert.strictEqual(r.status, 200, 'a guest (sso=none) lands on the hub, not a loop');
++
++ // A guest hint is never signed in, even with a fresh ticket.
++ const { ticket: guestTicket } = await issueTicketForUser(db, { userId: 11, clientId: 'link-web', redirectUri: DEST });
++ r = await get(`/machines?ov_handoff=${encodeURIComponent(guestTicket)}`, 'ov_sso_hint=guest');
++ assert.strictEqual(r.status, 302, 'a guest stays a guest');
++ assert.ok(r.headers.location.startsWith('/auth/login?'), 'guests go quiet, not into an account');
++ assert.ok(!cookiesOf(r).join(';').includes('ov_token='), 'no session for a guest');
++ const guestRow = await db.prepare('SELECT used FROM handoff_tickets WHERE ticket_hash = ?').get(hashTicket(guestTicket));
++ assert.strictEqual(guestRow.used, 0, 'the guest ticket stays unused');
++
++ // A forged ticket breaks nothing: the silent sign-in starts instead.
++ r = await get('/machines?ov_handoff=ovh_forged');
++ assert.strictEqual(r.status, 302);
++ assert.ok(r.headers.location.startsWith('/auth/login?silent=1'), 'a bad ticket falls through to silent sign-in');
++
++ // /pair lands on the hub with the intent in the query; other hosts fall through.
++ r = await get('/pair');
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, 'https://openvibe.link/machines?pair=1', '/pair keeps pairing intent');
++ r = await get('/machines', '', 'openvibe.network');
++ assert.strictEqual(r.status, 404, 'other hosts fall through');
++
++ console.log('link-machines-signin.test.js: all assertions passed');
++ server.close();
++ process.exit(0);
++})().catch(e => { console.error(e); server.close(); process.exit(1); });
++})();
ddiff --git a/test/login-passkey-anon.test.js b/test/login-passkey-anon.test.js
nnew file mode 100644
iindex 00000000..eadfa0c1
---- /dev/null
++++ b/test/login-passkey-anon.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++// The login page's passkey button wears the page's own secondary-button look, and Continue Anonymously
++// hides when the return address is a page that only makes sense signed in (the machines hub).
++// ov test test/login-passkey-anon.test.js (or: node test/login-passkey-anon.test.js)
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const LOGIN = fs.readFileSync(path.join(__dirname, '..', 'public/js/login.js'), 'utf8');
++const HTML = fs.readFileSync(path.join(__dirname, '..', 'public/login.html'), 'utf8');
++
++function block(from, fnName) {
++ const start = LOGIN.indexOf(from);
++ const fnStart = LOGIN.indexOf(`function ${fnName}(`, start);
++ assert.ok(start >= 0 && fnStart > start, `${fnName} exists`);
++ let depth = 0;
++ for (let i = LOGIN.indexOf('{', fnStart); i < LOGIN.length; i++) {
++ if (LOGIN[i] === '{') depth++;
++ else if (LOGIN[i] === '}' && --depth === 0) return LOGIN.slice(start, i + 1);
++ }
++ throw new Error(`unbalanced ${fnName}`);
++}
++
++// The passkey button: the page's secondary look, addressable, and no kit classes the page never loads.
++const at = LOGIN.indexOf('Sign in with a passkey');
++assert.ok(at >= 0, 'the passkey button exists');
++const passkeyBlock = LOGIN.slice(Math.max(0, at - 600), at + 100);
++assert.match(passkeyBlock, /button\.className = '[^']*\banon-btn\b[^']*'/, 'the passkey button wears anon-btn');
++assert.ok(!/ovk-btn/.test(passkeyBlock), 'no kit classes the login page never loads');
++assert.match(passkeyBlock, /button\.id = 'passkey-btn'/, 'the passkey button has an id');
++assert.ok(HTML.includes('id="anon-btn"'), 'Continue Anonymously has an id');
++
++// The signed-in-only rule, run for real.
++const sandbox = { URL };
++vm.createContext(sandbox);
++const src = block('// Pages that only make sense signed in', 'isSignedInOnlyReturn');
++const isSignedInOnlyReturn = vm.runInContext(`${src}\nisSignedInOnlyReturn;`, sandbox);
++for (const yes of ['https://openvibe.link/machines', 'https://openvibe.link/machines#pair', 'https://openvibe.link/machines?pair=1', '/machines', '/machines?pair=1', '/machines#pair']) {
++ assert.strictEqual(isSignedInOnlyReturn(yes), true, `signed-in-only: ${yes}`);
++}
++for (const no of ['', '/', '/login', 'https://openvibe.link/', 'https://openvibe.link/pair', 'https://openvibe.my/', 'https://evil.example/machines', '//evil.example/machines', 'javascript:alert(1)']) {
++ assert.strictEqual(isSignedInOnlyReturn(no), false, `not signed-in-only: ${no}`);
++}
++
++// init hides the button and its divider on a machines return.
++assert.match(LOGIN, /isSignedInOnlyReturn\(params\.get\('return'\)\)/, 'init consults the rule');
++assert.match(LOGIN, /getElementById\('anon-btn'\)/, 'init hides Continue Anonymously');
++
++console.log('login-passkey-anon.test.js: all assertions passed');
ddiff --git a/test/my-machines-handoff.test.js b/test/my-machines-handoff.test.js
nnew file mode 100644
iindex 00000000..97b00247
---- /dev/null
++++ b/test/my-machines-handoff.test.js
@@@ -0,0 +1,56 @@
++'use strict';
++// The hub's old paths here mint a one-time handoff ticket for link-web when the browser is signed in here,
++// so openvibe.link signs them in with no round trip. Signed out, the plain hop lands on silent sign-in.
++// ov test test/my-machines-handoff.test.js (or: node test/my-machines-handoff.test.js)
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++const { createMyRoutes } = require('../server/my/routes');
++
++(async () => {
++const db = getDb();
++await db.prepare("UPDATE oauth_clients SET client_secret = 'my-secret' WHERE client_id = 'my'").run();
++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (12, 'mymint', 'x')").run();
++
++const stubSso = {
++ router: () => express.Router(),
++ verify: async (token) => (token === 'good-token' ? { id: 12, username: 'mymint' } : null),
++};
++const app = express();
++app.use(await createMyRoutes({ db, config: {}, sso: stubSso }));
++app.use((req, res) => res.status(404).end());
++const server = http.createServer(app);
++
++(async () => {
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const get = (path, cookie = '', host = 'openvibe.my') => new Promise((resolve, reject) => {
++ const req = http.get({ hostname: '127.0.0.1', port: server.address().port, path, headers: { Host: host, ...(cookie ? { Cookie: cookie } : {}) } }, res => {
++ let body = ''; res.on('data', c => body += c); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
++ });
++ req.on('error', reject);
++ });
++
++ // Signed in here: the hop carries a ticket minted for link-web.
++ let r = await get('/machines', 'ov_token=good-token');
++ assert.strictEqual(r.status, 302, 'signed in 302s to the hub');
++ const to = new URL(r.headers.location);
++ assert.strictEqual(to.origin + to.pathname, 'https://openvibe.link/machines');
++ assert.ok(/^ovh_[A-Za-z0-9_-]{20,200}$/.test(to.searchParams.get('ov_handoff')), 'a ticket rides along');
++
++ // Signed out (or a bad cookie): the plain hop, which lands on the hub's silent sign-in.
++ r = await get('/machines');
++ assert.strictEqual(r.status, 302);
++ assert.strictEqual(r.headers.location, 'https://openvibe.link/machines', 'no ticket when signed out');
++ r = await get('/machines', 'ov_token=nope');
++ assert.strictEqual(r.headers.location, 'https://openvibe.link/machines', 'no ticket on a bad session');
++
++ // The old Network path mints the same way.
++ r = await get('/machines', 'ov_token=good-token', 'openvibe.network');
++ assert.ok(new URL(r.headers.location).searchParams.get('ov_handoff'), 'apex arrivals mint too');
++
++ console.log('my-machines-handoff.test.js: all assertions passed');
++ server.close();
++ process.exit(0);
++})().catch(e => { console.error(e); server.close(); process.exit(1); });
++})();
ddiff --git a/test/my-pair-intent.test.js b/test/my-pair-intent.test.js
nnew file mode 100644
iindex 00000000..9f791cd8
---- /dev/null
++++ b/test/my-pair-intent.test.js
@@@ -0,0 +1,62 @@
++'use strict';
++// Pairing intent on the hub: openvibe.link/machines#pair (and /pair, which lands on ?pair=1 so the
++// intent survives a sign-in round trip) opens Machines and starts pairing at once.
++// ov test test/my-pair-intent.test.js (or: node test/my-pair-intent.test.js)
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const CORE = fs.readFileSync(path.join(__dirname, '..', 'public/js/my/core.js'), 'utf8');
++function block(from, fnName) {
++ const start = CORE.indexOf(from);
++ const fnStart = CORE.indexOf(`function ${fnName}(`, start);
++ assert.ok(start >= 0 && fnStart > start, `${fnName} exists`);
++ let depth = 0;
++ for (let i = CORE.indexOf('{', fnStart); i < CORE.length; i++) {
++ if (CORE[i] === '{') depth++;
++ else if (CORE[i] === '}' && --depth === 0) return CORE.slice(fnStart, i + 1);
++ }
++ throw new Error(`unbalanced ${fnName}`);
++}
++const src = block('function showSection(', 'openInitialSection');
++assert.match(src, /mcPair\(\)/, 'pairing intent starts pairing');
++
++function runSection({ pathname = '/machines', hash = '', search = '', anon = false }) {
++ const shown = [], paired = [];
++ const sandbox = {
++ window: { location: { pathname, hash, search } },
++ document: { getElementById: (id) => (id === 'sec-machines' || id === 'sec-anon' ? {} : null) },
++ isAnonSession: () => anon,
++ showSection: (id) => shown.push(id),
++ mcPair: () => paired.push(true),
++ handleVerifyEmailToken: () => {},
++ setTimeout: () => {},
++ URLSearchParams,
++ };
++ vm.createContext(sandbox);
++ vm.runInContext(`${src}\nopenInitialSection();`, sandbox);
++ return { shown, paired };
++}
++
++// #pair opens Machines and starts pairing for an account session.
++let r = runSection({ hash: '#pair' });
++assert.deepStrictEqual(r.shown, ['machines'], '#pair opens Machines');
++assert.strictEqual(r.paired.length, 1, '#pair starts pairing');
++
++// ?pair=1 does the same after a sign-in round trip ate the fragment.
++r = runSection({ search: '?pair=1' });
++assert.deepStrictEqual(r.shown, ['machines'], '?pair=1 opens Machines');
++assert.strictEqual(r.paired.length, 1, '?pair=1 starts pairing');
++
++// Anonymous mode never pairs: the anon section shows instead.
++r = runSection({ hash: '#pair', anon: true });
++assert.deepStrictEqual(r.shown, ['anon'], 'anon keeps the anon section');
++assert.strictEqual(r.paired.length, 0, 'anon never starts pairing');
++
++// Anywhere else the intent is ignored, and a plain visit pairs nothing.
++r = runSection({ hash: '#pair', pathname: '/profile' });
++assert.deepStrictEqual(r.shown, ['machines'], 'pair intent lands on Machines from any path');
++assert.strictEqual(runSection({}).paired.length, 0, 'no intent starts nothing');
++
++console.log('my-pair-intent.test.js: all assertions passed');