openvibers/openvibe.trade Commits
Sign in with a passkey on Trade's own origin (#284)
Changes
@@@ -2,6 +2,12 @@## Unreleased++- Sign in with a passkey on Trade's own origin (WebAuthn Related Origin Requests). The signed-out account line offers++ "Use a passkey" only after `PublicKeyCredential.getClientCapabilities()` reports `relatedOrigins`; the browser++ ceremony is brokered by `POST /auth/passkey/options` and `/verify` (SDK 0.151.0) with Trade's OAuth client, and++ Network verifies the exact origin and RP ID. Browsers without support and people without a key keep the ordinary++ OpenVibe sign-in. `server/app.js` (`relatedPasskeys`), `server/render/layout.js`, `public/js/passkey-signin.js`.++- Worker-written AI briefs ask OpenVibe.AI for the `batch` latency class: the pending run id and the exact inputs live in `trade_ai_brief_jobs` (migration `0009`), the worker collects the answer on a later tick, a run id is written once, and a refused or expired batch falls back to `background` once; a reader's ask stays synchronous. `test/ai-briefs.test.js`.- The instrument page carries the market-research outcomes the data backs: every observation shows the source URL and the source's own reference (`source_ref`), when the value was true, when it was fetched, and a stale verdict with the value's `max_age_sec` and a plain reason; a "Free, always" line; one-tap watchlist (new `POST /watchlists/watch`) and new-filing alert actions, with the configured limits stated instead of a no-cap claim; price rows are labelled source-stated, not live, and no delay is invented. Observation rows are compact on phones, with the long provenance in a closed "Source details" fold; the source table uses the OpenVibe Kit and stacks on phones. `test/market-outcomes.test.js`.- The front page is curated (`server/render/home.js`): a company search and the numbers beside the hero (companies, filings in 24 hours, filings recorded, companies with figures, AI briefs, currencies), then AI briefs as cards, euro reference rates as cards with the change from the same source, new 10-K/10-Q reports, the week's filings by form in plain words, the newest filings, companies with something new, what is here and six questions (FAQPage JSON-LD). The instruments list moved to `/instruments` (301 from `/?page=N`); `/filings[?form=]` is new; `/sitemaps/pages.xml` lists `/`, `/instruments`, `/filings` and `/sources` (the sitemap listed nothing before: instrument pages stay noindex until a person reviews their context). The navbar reads Companies, Filings, Sources, Watchlists.@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.151.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0","pg": "^8.23.0"},@@@ -1263,9 +1263,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.150.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",-- "integrity": "sha512-5k2PovDQ2zhYXxLHtNNm1Q3rzPMXLpdh0lIYdl9kfa6D2/X2gYozT5W0CHRCIMAK4UhjbsZaXOSMM8GbkJWsNw==",++ "version": "0.151.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.151.0",++ "integrity": "sha512-iHtOPvww2qiaYN4gVlrVca4eoo/cWvW9nZP5fXcZeLxLXguFrUMEI8Au1xn3GlMIx+b7I55nwaHLSewG98qIhA==","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -29,7 +29,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.151.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0","pg": "^8.23.0"},nnew file mode 100644@@@ -0,0 +1,61 @@++/* Sign in on this site with a passkey. The site's own server brokers the ceremony and Network verifies it++ (WebAuthn Related Origin Requests). A browser without them, or a person without a key, keeps the ordinary++ OpenVibe sign-in: this only takes over the Sign In control after the browser says it supports related origins. */++(function () {++ 'use strict';++ if (!window.PublicKeyCredential || !PublicKeyCredential.getClientCapabilities) return;++ var b64 = function (buf) {++ var s = String.fromCharCode.apply(null, new Uint8Array(buf));++ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');++ };++ var bytes = function (value) {++ var raw = atob(String(value).replace(/-/g, '+').replace(/_/g, '/'));++ var out = new Uint8Array(raw.length);++ for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);++ return out;++ };++ var post = function (url, body) {++ return fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })++ .then(function (res) {++ return res.json().then(function (data) { if (!res.ok) throw new Error(data.error_description || data.error || 'Sign-in failed'); return data; });++ });++ };++ var nextOf = function (href) {++ try { var next = new URL(href, location.href).searchParams.get('next'); return next && next.charAt(0) === '/' ? next : '/'; } catch (e) { return '/'; }++ };++ var signIn = async function (login) {++ try {++ var start = await post('/auth/passkey/options', {});++ var options = start.options;++ options.challenge = bytes(options.challenge);++ if (options.allowCredentials) options.allowCredentials = options.allowCredentials.map(function (c) { return Object.assign({}, c, { id: bytes(c.id) }); });++ var credential = await navigator.credentials.get({ publicKey: options });++ var done = await post('/auth/passkey/verify', {++ state: start.state,++ response: {++ id: credential.id, rawId: b64(credential.rawId), type: credential.type,++ clientExtensionResults: credential.getClientExtensionResults(),++ response: {++ clientDataJSON: b64(credential.response.clientDataJSON),++ authenticatorData: b64(credential.response.authenticatorData),++ signature: b64(credential.response.signature),++ userHandle: credential.response.userHandle ? b64(credential.response.userHandle) : null,++ },++ },++ });++ if (done.ok) location.href = nextOf(login.href);++ } catch (err) {++ // No key on this device, a cancelled prompt or a site not ready: the ordinary sign-in still works.++ location.href = login.href;++ }++ };++ PublicKeyCredential.getClientCapabilities().then(function (caps) {++ if (!caps || !caps.relatedOrigins) return;++ document.addEventListener('click', function (event) {++ var login = event.target.closest && event.target.closest('#openvibe-login-btn');++ if (!login || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;++ event.preventDefault();++ signIn(login);++ }, true);++ }).catch(function () { /* keep the ordinary sign-in */ });++})();@@@ -117,7 +117,7 @@ async function createApp(opts = {}) {clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,redirectUri: config.oauth.redirectUri, scope: config.oauth.scope,networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl,-- issuer: config.issuer || config.networkUrl, secureCookies: config.cookies.secure, log,++ issuer: config.issuer || config.networkUrl, secureCookies: config.cookies.secure, relatedPasskeys: true, log,});ctx.jwks = jwksClient(ctx.jwksUrl, { log });ctx.jwks.keys().catch(() => {}); // warm the key cache at boot (non-fatal if the Network is down)@@@ -91,7 +91,7 @@ function renderPage(o) {canonical: o.canonical,// The head names the page's own Markdown twin (openvibe-shared/agent-layer).// The rules that show the picked pane of a pick block are written for the page, because there is one per pane.-- head: [o.head, agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),++ head: [o.head, agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`), signedIn ? '' : `<script src="${asset('js/passkey-signin.js')}" defer></script>`].filter(Boolean).join('\n'),decision: o.decision,type: o.type || 'website',image: o.image,@@@ -47,6 +47,9 @@ const count = (html, re) => (html.match(re) || []).length;assert.ok(home.includes('Recently shipped on OpenVibe.Trade'), 'the shipped line stays on the home page');const page = (await t.get('/i/ACME')).text;assert.ok(count(page.slice(0, page.indexOf('</head>')), /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD on the instrument page');++ assert.ok(/<script src="\/js\/passkey-signin\.js\?v=[0-9a-f]+" defer><\/script>/.test(home), 'the feature-detecting script');++ const signedIn = (await t.get('/watchlists', { as: t.alice })).text;++ assert.ok(!signedIn.includes('/js/passkey-signin.js'), 'no passkey script once signed in');});await t.close();nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++// public/js/passkey-signin.js — the Sign In control only changes when the browser reports Related Origin++// Requests; a failed ceremony falls back to the ordinary OpenVibe sign-in.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++const SCRIPT = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'passkey-signin.js'), 'utf8');++++function load({ getClientCapabilities }) {++ const listeners = [], posted = [];++ const ctx = {++ console, setTimeout, clearTimeout,++ btoa: (s) => Buffer.from(s, 'binary').toString('base64'),++ atob: (s) => Buffer.from(s, 'base64').toString('binary'),++ URL, fetch: async (url, opts) => { posted.push({ url, body: opts.body }); return { ok: false, json: async () => ({ error: 'unavailable' }) }; },++ document: { addEventListener: (type, fn, capture) => listeners.push({ type, fn, capture }) },++ location: { origin: 'https://openvibe.deals', hostname: 'openvibe.deals', href: 'https://openvibe.deals/' },++ PublicKeyCredential: getClientCapabilities ? { getClientCapabilities } : {},++ };++ ctx.window = ctx; ctx.globalThis = ctx; ctx.self = ctx;++ vm.createContext(ctx);++ vm.runInContext(SCRIPT, ctx);++ return { ctx, listeners, posted };++}++++(async () => {++ {++ const { listeners } = load({});++ await new Promise((r) => setImmediate(r));++ assert.equal(listeners.length, 0, 'no PublicKeyCredential.getClientCapabilities: the sign-in is untouched');++ }++ {++ const { listeners } = load({ getClientCapabilities: async () => ({ conditionalGet: true }) });++ await new Promise((r) => setImmediate(r));++ assert.equal(listeners.length, 0, 'no relatedOrigins: the sign-in is untouched');++ }++ {++ const { ctx, listeners, posted } = load({ getClientCapabilities: async () => ({ relatedOrigins: true }) });++ await new Promise((r) => setImmediate(r));++ assert.equal(listeners.length, 1, 'one delegated click listener');++ assert.deepEqual([listeners[0].type, listeners[0].capture], ['click', true]);++ const login = { href: 'https://openvibe.deals/auth/login?next=%2Fdeals', closest: (sel) => (sel === '#openvibe-login-btn' ? login : null) };++ let prevented = 0;++ listeners[0].fn({ target: login, button: 0, preventDefault: () => { prevented += 1; }, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false });++ await new Promise((r) => setImmediate(r));++ await new Promise((r) => setImmediate(r));++ assert.equal(prevented, 1, 'the ordinary navigation waits for the ceremony');++ assert.equal(posted[0].url, '/auth/passkey/options', 'the site broker is asked for a ceremony');++ assert.equal(ctx.location.href, login.href, 'a site without the ceremony falls back to the ordinary sign-in');++ }++ console.log('passkey-signin: gated on relatedOrigins, takes the Sign In click, and falls back to the ordinary sign-in');++})().catch((err) => {++ console.error('passkey-signin test failed');++ console.error(String(err.stack).split('\n').slice(0, 5).join('\n'));++ process.exitCode = 1;++});@@@ -14,7 +14,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {htmlRawKB: 44, // 44: measured 43.7 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 36.2 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); ~32.5 with the curated front page (numbers, sections, six questions and their JSON-LD; 2026-10-22); 27.4 with the AI briefs (2026-10-15)htmlBrotliKB: 10, // 10: measured 9.8 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 9.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 8.9 with the phone pick block (the chips and the fold script, 2026-10-30); 8.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 7.7 with the curated front page; 6.2 before (4.7 before the showcase)-- jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)++ jsFiles: 6, // 6: the signed-out page's passkey enhancement (2026-11-11, related-origin passkeys); 5 before it (theme-loader, web-runtime, navbar, footer, boost, 2026-10-04)jsRawKB: 394.5, // 394.5: measured 394.1 KB raw with openvibe-shared 4.197.0 (2026-11-10: the menu's row of places, the You tab's row, the Sign in mark, the tooltip); 393: measured 392.5 KB raw with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 376.4: measured 376.3 KB raw with openvibe-shared 4.186.0 (2026-11-10: the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls styled by the loader); 375.1: measured 367.1 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09jsBrotliKB: 94, // 94: measured 93.7 KB brotli with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 89.8: measured 89.7 KB brotli with openvibe-shared 4.186.0 (2026-11-10: the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls styled by the loader); 89.5: measured 87.5 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09cssFiles: 2, // 2 (trade.css, openvibe-shared showcase.css)
@@ -2,6 +2,12 @@
@@ -2,6 +2,12 @@
## Unreleased
## Unreleased
+- Sign in with a passkey on Trade's own origin (WebAuthn Related Origin Requests). The signed-out account line offers
+ "Use a passkey" only after `PublicKeyCredential.getClientCapabilities()` reports `relatedOrigins`; the browser
+ ceremony is brokered by `POST /auth/passkey/options` and `/verify` (SDK 0.151.0) with Trade's OAuth client, and
+ Network verifies the exact origin and RP ID. Browsers without support and people without a key keep the ordinary
+ OpenVibe sign-in. `server/app.js` (`relatedPasskeys`), `server/render/layout.js`, `public/js/passkey-signin.js`.
+
- Worker-written AI briefs ask OpenVibe.AI for the `batch` latency class: the pending run id and the exact inputs live in `trade_ai_brief_jobs` (migration `0009`), the worker collects the answer on a later tick, a run id is written once, and a refused or expired batch falls back to `background` once; a reader's ask stays synchronous. `test/ai-briefs.test.js`.
- Worker-written AI briefs ask OpenVibe.AI for the `batch` latency class: the pending run id and the exact inputs live in `trade_ai_brief_jobs` (migration `0009`), the worker collects the answer on a later tick, a run id is written once, and a refused or expired batch falls back to `background` once; a reader's ask stays synchronous. `test/ai-briefs.test.js`.
- The instrument page carries the market-research outcomes the data backs: every observation shows the source URL and the source's own reference (`source_ref`), when the value was true, when it was fetched, and a stale verdict with the value's `max_age_sec` and a plain reason; a "Free, always" line; one-tap watchlist (new `POST /watchlists/watch`) and new-filing alert actions, with the configured limits stated instead of a no-cap claim; price rows are labelled source-stated, not live, and no delay is invented. Observation rows are compact on phones, with the long provenance in a closed "Source details" fold; the source table uses the OpenVibe Kit and stacks on phones. `test/market-outcomes.test.js`.
- The instrument page carries the market-research outcomes the data backs: every observation shows the source URL and the source's own reference (`source_ref`), when the value was true, when it was fetched, and a stale verdict with the value's `max_age_sec` and a plain reason; a "Free, always" line; one-tap watchlist (new `POST /watchlists/watch`) and new-filing alert actions, with the configured limits stated instead of a no-cap claim; price rows are labelled source-stated, not live, and no delay is invented. Observation rows are compact on phones, with the long provenance in a closed "Source details" fold; the source table uses the OpenVibe Kit and stacks on phones. `test/market-outcomes.test.js`.
- The front page is curated (`server/render/home.js`): a company search and the numbers beside the hero (companies, filings in 24 hours, filings recorded, companies with figures, AI briefs, currencies), then AI briefs as cards, euro reference rates as cards with the change from the same source, new 10-K/10-Q reports, the week's filings by form in plain words, the newest filings, companies with something new, what is here and six questions (FAQPage JSON-LD). The instruments list moved to `/instruments` (301 from `/?page=N`); `/filings[?form=]` is new; `/sitemaps/pages.xml` lists `/`, `/instruments`, `/filings` and `/sources` (the sitemap listed nothing before: instrument pages stay noindex until a person reviews their context). The navbar reads Companies, Filings, Sources, Watchlists.
- The front page is curated (`server/render/home.js`): a company search and the numbers beside the hero (companies, filings in 24 hours, filings recorded, companies with figures, AI briefs, currencies), then AI briefs as cards, euro reference rates as cards with the change from the same source, new 10-K/10-Q reports, the week's filings by form in plain words, the newest filings, companies with something new, what is here and six questions (FAQPage JSON-LD). The instruments list moved to `/instruments` (301 from `/?page=N`); `/filings[?form=]` is new; `/sitemaps/pages.xml` lists `/`, `/instruments`, `/filings` and `/sources` (the sitemap listed nothing before: instrument pages stay noindex until a person reviews their context). The navbar reads Companies, Filings, Sources, Watchlists.
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.151.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1263,9 +1263,9 @@
@@ -1263,9 +1263,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.150.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
- "integrity": "sha512-5k2PovDQ2zhYXxLHtNNm1Q3rzPMXLpdh0lIYdl9kfa6D2/X2gYozT5W0CHRCIMAK4UhjbsZaXOSMM8GbkJWsNw==",
+ "version": "0.151.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.151.0",
+ "integrity": "sha512-iHtOPvww2qiaYN4gVlrVca4eoo/cWvW9nZP5fXcZeLxLXguFrUMEI8Au1xn3GlMIx+b7I55nwaHLSewG98qIhA==",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.408.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.150.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.151.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.211.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+/* Sign in on this site with a passkey. The site's own server brokers the ceremony and Network verifies it
+ (WebAuthn Related Origin Requests). A browser without them, or a person without a key, keeps the ordinary
+ OpenVibe sign-in: this only takes over the Sign In control after the browser says it supports related origins. */
+(function () {
+ 'use strict';
+ if (!window.PublicKeyCredential || !PublicKeyCredential.getClientCapabilities) return;
+ var b64 = function (buf) {
+ var s = String.fromCharCode.apply(null, new Uint8Array(buf));
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+ };
+ var bytes = function (value) {
+ var raw = atob(String(value).replace(/-/g, '+').replace(/_/g, '/'));
+ var out = new Uint8Array(raw.length);
+ for (var i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
+ return out;
+ };
+ var post = function (url, body) {
+ return fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
+ .then(function (res) {
+ return res.json().then(function (data) { if (!res.ok) throw new Error(data.error_description || data.error || 'Sign-in failed'); return data; });
+ });
+ };
+ var nextOf = function (href) {
+ try { var next = new URL(href, location.href).searchParams.get('next'); return next && next.charAt(0) === '/' ? next : '/'; } catch (e) { return '/'; }
+ };
+ var signIn = async function (login) {
+ try {
+ var start = await post('/auth/passkey/options', {});
+ var options = start.options;
+ options.challenge = bytes(options.challenge);
+ if (options.allowCredentials) options.allowCredentials = options.allowCredentials.map(function (c) { return Object.assign({}, c, { id: bytes(c.id) }); });
+ var credential = await navigator.credentials.get({ publicKey: options });
+ var done = await post('/auth/passkey/verify', {
+ state: start.state,
+ response: {
+ id: credential.id, rawId: b64(credential.rawId), type: credential.type,
+ clientExtensionResults: credential.getClientExtensionResults(),
+ response: {
+ clientDataJSON: b64(credential.response.clientDataJSON),
+ authenticatorData: b64(credential.response.authenticatorData),
+ signature: b64(credential.response.signature),
+ userHandle: credential.response.userHandle ? b64(credential.response.userHandle) : null,
+ },
+ },
+ });
+ if (done.ok) location.href = nextOf(login.href);
+ } catch (err) {
+ // No key on this device, a cancelled prompt or a site not ready: the ordinary sign-in still works.
+ location.href = login.href;
+ }
+ };
+ PublicKeyCredential.getClientCapabilities().then(function (caps) {
+ if (!caps || !caps.relatedOrigins) return;
+ document.addEventListener('click', function (event) {
+ var login = event.target.closest && event.target.closest('#openvibe-login-btn');
+ if (!login || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+ event.preventDefault();
+ signIn(login);
+ }, true);
+ }).catch(function () { /* keep the ordinary sign-in */ });
+})();
@@ -117,7 +117,7 @@ async function createApp(opts = {}) {
@@ -117,7 +117,7 @@ async function createApp(opts = {}) {
clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
redirectUri: config.oauth.redirectUri, scope: config.oauth.scope,
redirectUri: config.oauth.redirectUri, scope: config.oauth.scope,
networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl,
networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl,
- issuer: config.issuer || config.networkUrl, secureCookies: config.cookies.secure, log,
+ issuer: config.issuer || config.networkUrl, secureCookies: config.cookies.secure, relatedPasskeys: true, log,
});
});
ctx.jwks = jwksClient(ctx.jwksUrl, { log });
ctx.jwks = jwksClient(ctx.jwksUrl, { log });
ctx.jwks.keys().catch(() => {}); // warm the key cache at boot (non-fatal if the Network is down)
ctx.jwks.keys().catch(() => {}); // warm the key cache at boot (non-fatal if the Network is down)
@@ -91,7 +91,7 @@ function renderPage(o) {
@@ -91,7 +91,7 @@ function renderPage(o) {
canonical: o.canonical,
canonical: o.canonical,
// The head names the page's own Markdown twin (openvibe-shared/agent-layer).
// The head names the page's own Markdown twin (openvibe-shared/agent-layer).
// The rules that show the picked pane of a pick block are written for the page, because there is one per pane.
// The rules that show the picked pane of a pick block are written for the page, because there is one per pane.
- head: [o.head, agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
+ head: [o.head, agentHeadTags(o), ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`), signedIn ? '' : `<script src="${asset('js/passkey-signin.js')}" defer></script>`].filter(Boolean).join('\n'),
decision: o.decision,
decision: o.decision,
type: o.type || 'website',
type: o.type || 'website',
image: o.image,
image: o.image,
@@ -47,6 +47,9 @@ const count = (html, re) => (html.match(re) || []).length;
@@ -47,6 +47,9 @@ const count = (html, re) => (html.match(re) || []).length;
assert.ok(home.includes('Recently shipped on OpenVibe.Trade'), 'the shipped line stays on the home page');
assert.ok(home.includes('Recently shipped on OpenVibe.Trade'), 'the shipped line stays on the home page');
const page = (await t.get('/i/ACME')).text;
const page = (await t.get('/i/ACME')).text;
assert.ok(count(page.slice(0, page.indexOf('</head>')), /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD on the instrument page');
assert.ok(count(page.slice(0, page.indexOf('</head>')), /<script type="application\/ld\+json">/g) >= 1, 'JSON-LD on the instrument page');
+ assert.ok(/<script src="\/js\/passkey-signin\.js\?v=[0-9a-f]+" defer><\/script>/.test(home), 'the feature-detecting script');
+ const signedIn = (await t.get('/watchlists', { as: t.alice })).text;
+ assert.ok(!signedIn.includes('/js/passkey-signin.js'), 'no passkey script once signed in');
});
});
await t.close();
await t.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+// public/js/passkey-signin.js — the Sign In control only changes when the browser reports Related Origin
+// Requests; a failed ceremony falls back to the ordinary OpenVibe sign-in.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const SCRIPT = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'passkey-signin.js'), 'utf8');
+
+function load({ getClientCapabilities }) {
+ const listeners = [], posted = [];
+ const ctx = {
+ console, setTimeout, clearTimeout,
+ btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
+ atob: (s) => Buffer.from(s, 'base64').toString('binary'),
+ URL, fetch: async (url, opts) => { posted.push({ url, body: opts.body }); return { ok: false, json: async () => ({ error: 'unavailable' }) }; },
+ document: { addEventListener: (type, fn, capture) => listeners.push({ type, fn, capture }) },
+ location: { origin: 'https://openvibe.deals', hostname: 'openvibe.deals', href: 'https://openvibe.deals/' },
+ PublicKeyCredential: getClientCapabilities ? { getClientCapabilities } : {},
+ };
+ ctx.window = ctx; ctx.globalThis = ctx; ctx.self = ctx;
+ vm.createContext(ctx);
+ vm.runInContext(SCRIPT, ctx);
+ return { ctx, listeners, posted };
+}
+
+(async () => {
+ {
+ const { listeners } = load({});
+ await new Promise((r) => setImmediate(r));
+ assert.equal(listeners.length, 0, 'no PublicKeyCredential.getClientCapabilities: the sign-in is untouched');
+ }
+ {
+ const { listeners } = load({ getClientCapabilities: async () => ({ conditionalGet: true }) });
+ await new Promise((r) => setImmediate(r));
+ assert.equal(listeners.length, 0, 'no relatedOrigins: the sign-in is untouched');
+ }
+ {
+ const { ctx, listeners, posted } = load({ getClientCapabilities: async () => ({ relatedOrigins: true }) });
+ await new Promise((r) => setImmediate(r));
+ assert.equal(listeners.length, 1, 'one delegated click listener');
+ assert.deepEqual([listeners[0].type, listeners[0].capture], ['click', true]);
+ const login = { href: 'https://openvibe.deals/auth/login?next=%2Fdeals', closest: (sel) => (sel === '#openvibe-login-btn' ? login : null) };
+ let prevented = 0;
+ listeners[0].fn({ target: login, button: 0, preventDefault: () => { prevented += 1; }, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false });
+ await new Promise((r) => setImmediate(r));
+ await new Promise((r) => setImmediate(r));
+ assert.equal(prevented, 1, 'the ordinary navigation waits for the ceremony');
+ assert.equal(posted[0].url, '/auth/passkey/options', 'the site broker is asked for a ceremony');
+ assert.equal(ctx.location.href, login.href, 'a site without the ceremony falls back to the ordinary sign-in');
+ }
+ console.log('passkey-signin: gated on relatedOrigins, takes the Sign In click, and falls back to the ordinary sign-in');
+})().catch((err) => {
+ console.error('passkey-signin test failed');
+ console.error(String(err.stack).split('\n').slice(0, 5).join('\n'));
+ process.exitCode = 1;
+});
@@ -14,7 +14,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -14,7 +14,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
htmlRawKB: 44, // 44: measured 43.7 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 36.2 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); ~32.5 with the curated front page (numbers, sections, six questions and their JSON-LD; 2026-10-22); 27.4 with the AI briefs (2026-10-15)
htmlRawKB: 44, // 44: measured 43.7 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 36.2 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); ~32.5 with the curated front page (numbers, sections, six questions and their JSON-LD; 2026-10-22); 27.4 with the AI briefs (2026-10-15)
htmlBrotliKB: 10, // 10: measured 9.8 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 9.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 8.9 with the phone pick block (the chips and the fold script, 2026-10-30); 8.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 7.7 with the curated front page; 6.2 before (4.7 before the showcase)
htmlBrotliKB: 10, // 10: measured 9.8 with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 9.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 8.9 with the phone pick block (the chips and the fold script, 2026-10-30); 8.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 7.7 with the curated front page; 6.2 before (4.7 before the showcase)
- jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
+ jsFiles: 6, // 6: the signed-out page's passkey enhancement (2026-11-11, related-origin passkeys); 5 before it (theme-loader, web-runtime, navbar, footer, boost, 2026-10-04)
jsRawKB: 394.5, // 394.5: measured 394.1 KB raw with openvibe-shared 4.197.0 (2026-11-10: the menu's row of places, the You tab's row, the Sign in mark, the tooltip); 393: measured 392.5 KB raw with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 376.4: measured 376.3 KB raw with openvibe-shared 4.186.0 (2026-11-10: the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls styled by the loader); 375.1: measured 367.1 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09
jsRawKB: 394.5, // 394.5: measured 394.1 KB raw with openvibe-shared 4.197.0 (2026-11-10: the menu's row of places, the You tab's row, the Sign in mark, the tooltip); 393: measured 392.5 KB raw with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 376.4: measured 376.3 KB raw with openvibe-shared 4.186.0 (2026-11-10: the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls styled by the loader); 375.1: measured 367.1 with openvibe-shared 4.161.0, plus room for 4.162.0–4.163.0, 2026-11-09
jsBrotliKB: 94, // 94: measured 93.7 KB brotli with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 89.8: measured 89.7 KB brotli with openvibe-shared 4.186.0 (2026-11-10: the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls styled by the loader); 89.5: measured 87.5 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
jsBrotliKB: 94, // 94: measured 93.7 KB brotli with openvibe-shared 4.190.0 (2026-11-10: the site name's dropdown, the footer's online strip, the balances pill); 89.8: measured 89.7 KB brotli with openvibe-shared 4.186.0 (2026-11-10: the mark's Working and Thinking states, the Dock and the menu as one sheet, bare controls styled by the loader); 89.5: measured 87.5 with openvibe-shared 4.163.0, plus room for 4.162.0–4.163.0, 2026-11-09
cssFiles: 2, // 2 (trade.css, openvibe-shared showcase.css)
cssFiles: 2, // 2 (trade.css, openvibe-shared showcase.css)