openvibers/openvibe.network Commits
Pair device lands on Machines signed in (#809)
Changes
@@@ -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; }++}@@@ -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);}@@@ -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>@@@ -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 };@@@ -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;@@@ -745,7 +745,7 @@ homePopularity.refresh().catch(() => {}); // floating-ok: the first reader getconst 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, {@@@ -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`);}@@@ -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, pagePathconst 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, pagePathrouter.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();@@@ -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.nnew file mode 100644@@@ -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); });++})();nnew file mode 100644@@@ -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');nnew file mode 100644@@@ -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); });++})();nnew file mode 100644@@@ -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');
@@ -1019,6 +1019,17 @@ async function continueAnonymous() {
@@ -1019,6 +1019,17 @@ async function continueAnonymous() {
hasAccounts = accounts.length > 0;
hasAccounts = accounts.length > 0;
const resetToken = params.get('token');
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') {
if (window.location.pathname === '/forgot-password') {
showForgotForm();
showForgotForm();
return;
return;
@@ -1156,8 +1167,20 @@ async function signInWithPasskey(conditional = false) {
@@ -1156,8 +1167,20 @@ async function signInWithPasskey(conditional = false) {
}
}
}
}
if (window.PublicKeyCredential) {
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();
button.onclick = () => signInWithPasskey();
document.getElementById('login-form').after(button);
document.getElementById('login-form').after(button);
PublicKeyCredential.isConditionalMediationAvailable?.().then(available => { if (available) signInWithPasskey(true); }).catch(() => {});
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; }
+}
@@ -178,6 +178,16 @@ function openInitialSection() {
@@ -178,6 +178,16 @@ function openInitialSection() {
if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines','apps'].includes(routeSection)) {
if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines','apps'].includes(routeSection)) {
return showSection('anon');
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)) {
if (document.getElementById('sec-' + routeSection)) {
showSection(routeSection);
showSection(routeSection);
}
}
@@ -185,7 +185,7 @@
@@ -185,7 +185,7 @@
</div>
</div>
<div class="divider"><span>or</span></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
<i class="fa-solid fa-user-secret"></i> Continue Anonymously
</button>
</button>
@@ -85,6 +85,61 @@ function registeredRedirect(allowedUris, redirectUri) {
@@ -85,6 +85,61 @@ function registeredRedirect(allowedUris, redirectUri) {
return Array.isArray(allowedUris) && allowedUris.some(u => String(u).toLowerCase() === want);
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). */
/** The redeem router. `requireAuth` is the session guard (user Bearer token or first-party cookie). */
function router({ db, requireAuth }) {
function router({ db, requireAuth }) {
const r = express.Router();
const r = express.Router();
@@ -97,21 +152,13 @@ function router({ db, requireAuth }) {
@@ -97,21 +152,13 @@ function router({ db, requireAuth }) {
const database = await getDb();
const database = await getDb();
const user = req.user;
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' });
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) {
} 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' });
res.status(500).json({ error: 'handoff issue failed' });
}
}
});
});
@@ -129,36 +176,11 @@ function router({ db, requireAuth }) {
@@ -129,36 +176,11 @@ function router({ db, requireAuth }) {
if (!client || !sameSecret(client.client_secret, clientSecret)) {
if (!client || !sameSecret(client.client_secret, clientSecret)) {
return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' });
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 });
res.json({ code, expires_in: CODE_TTL_S });
} catch (err) {
} 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' });
res.status(500).json({ error: 'handoff redeem failed' });
}
}
});
});
@@ -166,4 +188,4 @@ function router({ db, requireAuth }) {
@@ -166,4 +188,4 @@ function router({ db, requireAuth }) {
return r;
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 };
@@ -713,3 +713,6 @@ router.post('/userinfo', oidc.userinfo);
@@ -713,3 +713,6 @@ router.post('/userinfo', oidc.userinfo);
module.exports = router;
module.exports = router;
module.exports.cleanLoginHint = cleanLoginHint;
module.exports.cleanLoginHint = cleanLoginHint;
module.exports.siteNameForHost = siteNameForHost;
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;
@@ -745,7 +745,7 @@ homePopularity.refresh().catch(() => {}); // floating-ok: the first reader get
@@ -745,7 +745,7 @@ homePopularity.refresh().catch(() => {}); // floating-ok: the first reader get
const tryNext = require('./home/try-next').createTryNext({ db, popularity: homePopularity });
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
// 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.
// 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
// 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.
// 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, {
require('openvibe-shared/agent-layer').mount(app, {
@@ -12,11 +12,21 @@ const fs = require('node:fs');
@@ -12,11 +12,21 @@ const fs = require('node:fs');
const pathLib = require('node:path');
const pathLib = require('node:path');
const page = require('./page');
const page = require('./page');
const release = require('./release');
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 HOST = page.HOST;
const WWW_HOST = page.WWW_HOST;
const WWW_HOST = page.WWW_HOST;
const SITE = page.SITE;
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. */
/** The request's host, lowercased, without the port. */
function hostOf(req) {
function hostOf(req) {
return String((req.headers && req.headers.host) || '').split(':')[0].toLowerCase();
return String((req.headers && req.headers.host) || '').split(':')[0].toLowerCase();
@@ -66,11 +76,84 @@ function llmsTxt() {
@@ -66,11 +76,84 @@ function llmsTxt() {
].join('\n');
].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
* 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();
const router = require('express').Router();
router.use(require('./egress').publicRouter());
router.use(require('./egress').publicRouter());
@@ -109,15 +192,22 @@ function createLinkRoutes({ releaseId, releaseApi = release } = {}) {
@@ -109,15 +192,22 @@ function createLinkRoutes({ releaseId, releaseApi = release } = {}) {
// The machine hub lives on Link's own origin. It uses Network's same-origin
// 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.
// 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') {
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
// 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.
// 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/')) {
if (path.startsWith('/machines/')) {
return res.set('Cache-Control', 'no-store').redirect(301, `${SITE}/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`);
return res.set('Cache-Control', 'no-store').redirect(302, `${SITE}/machines`);
}
}
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
const { createSsoClient, COOKIES } = require('openvibe-sdk/sso');
const { createSsoClient, COOKIES } = require('openvibe-sdk/sso');
+const { issueTicketForUser } = require('../auth/handoff');
const MY_HOST = 'openvibe.my';
const MY_HOST = 'openvibe.my';
const WWW_HOST = 'www.openvibe.my';
const WWW_HOST = 'www.openvibe.my';
@@ -172,6 +173,26 @@ async function createMyRoutes({ db, config = require('../config'), sso, pagePath
@@ -172,6 +173,26 @@ async function createMyRoutes({ db, config = require('../config'), sso, pagePath
const auth = client.router(express);
const auth = client.router(express);
const page = path.resolve(pagePath);
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) {
function serveApp(req, res) {
const quiet = String(req.query.sso || '') === 'none' || readCookie(req, COOKIES.hint) === 'guest';
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'] || ''))) {
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
@@ -205,9 +226,10 @@ async function createMyRoutes({ db, config = require('../config'), sso, pagePath
router.use((req, res, next) => {
router.use((req, res, next) => {
const host = hostOf(req);
const host = hostOf(req);
if (host === WWW_HOST) return res.redirect(301, `https://${MY_HOST}${req.originalUrl}`);
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))) {
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 (NETWORK_HOSTS.has(host)) {
if (!isHubPath(req.path) || !['GET', 'HEAD'].includes(req.method)) return next();
if (!isHubPath(req.path) || !['GET', 'HEAD'].includes(req.method)) return next();
@@ -220,12 +220,18 @@ const SHAPED = {
@@ -220,12 +220,18 @@ const SHAPED = {
assert.ok(String(linkInstall.headers.location || '').endsWith('/install.sh'));
assert.ok(String(linkInstall.headers.location || '').endsWith('/install.sh'));
const machines = await get('/machines', 'openvibe.link');
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');
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');
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.
// Bot's old sub-paths (and Actor's Watch links) land on the hub rather than the catch-all's Network 404.
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -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); });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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); });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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');