openvibers/openvibe.deals Commits
Sign in with a passkey on Deals' own origin (#305)
Changes
@@@ -2,6 +2,12 @@## Unreleased++- Sign in with a passkey on Deals' 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 Deals' 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`.++- The sitemap cache holds index entries, not page views (memory on the host): `/sitemap.xml`, `/sitemap-<n>.xml` and `/llms-full.txt` kept every offer's and product's full view for `DEALS_SITEMAP_TTL_MS` — observations, sources, vote tallies and merge members — at ~6.7 KB per offer. On 2026-11-07 that was 667 MB resident (923 MB at measurement, 2 GB peak) for 50,000 offers, the most of any Node service on a 15 GB host that ran out of memory at 02:34. The cache now stores `publication.indexEntry` / `productIndexEntry`: the URL, lastmod and indexability decision, plus an `/llms-full.txt` summary for the first 10,000 indexable offers (the file truncates at 512 KB), ~0.6 KB an offer. Each offer needs two reads (its merge group and the group's latest observation) instead of ten, and `listings.indexable()` joins the store so no per-offer store lookup is needed; a product's lastmod and freshest observation come from one query. Output is unchanged (same URLs, same order, same lastmods, same gate). `test/memory-budget.test.js` boots the app on a 2,500-offer catalog, builds the sitemaps and runs one Sources import: the heap grows at most 12 MB past boot (measured 3.5 MB; the old full-view cache measured 12.8 MB at 2,000 offers) and stays under 200 MB overall.- Imported deals become searchable (the automatic review of imports): an offer imported from a configured feed source is checked when it is imported and passes when its link is https and points at that source's retailer domain, its title is not empty and at most 300 characters, its price is a positive number with a currency, it has not expired and it states none of the blocked/adult terms. A pass sets `review_state = auto_approved` (published, indexable, `reviewed_by = system:import-checks`); a fail stays `pending` with the failed check's name in `review_failed_check`. Offers people submit by hand keep today's review flow and a moderator can still hide any offer. `server/domain/import-review.js`, migration `0009_import_auto_review.sql`, `scripts/backfill-import-review.js` (batches of 500, `--dry-run`, counts only), `test/import-review.test.js`.@@@ -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"@@@ -28,7 +28,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 */ });++})();@@@ -104,7 +104,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,});const auth = opts.auth || sso;const jwksUrl = `${config.networkInternalUrl}/api/.well-known/jwks`;@@@ -93,7 +93,7 @@ function renderPage(o) {return layout.renderDocument({// The head names the page's own Markdown twin, and carries the rules that show the picked pane of a pick// block (there is one per pane, written for the page).-- head: [o.head, agentHead, o.path === '/' ? '' : `<link rel="stylesheet" href="${asset('css/deal-cards.css')}">`, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),++ head: [o.head, agentHead, o.path === '/' ? '' : `<link rel="stylesheet" href="${asset('css/deal-cards.css')}">`, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`), signedIn ? '' : `<script src="${asset('js/passkey-signin.js')}" defer></script>`].filter(Boolean).join('\n'),site: 'deals',siteName: SITE_NAME,lang: o.lang,@@@ -44,6 +44,13 @@ const split = (html) => ({ head: html.slice(0, html.indexOf('</head>')), body: hassert.ok(body.includes('"loginUrl":"/auth/login?next={path}"'), 'the navbar sign-in returns to the current page');});++ await check('a signed-out page loads the passkey enhancement, which no signed-in page needs', async () => {++ const guest = await t.get('/');++ assert.ok(/<script src="\/js\/passkey-signin\.js\?v=[0-9a-f]+" defer><\/script>/.test(guest.text), 'the feature-detecting script');++ const signedIn = await t.get('/watches', { as: alice });++ assert.ok(!signedIn.text.includes('/js/passkey-signin.js'), 'no passkey script once signed in');++ });++await check('the home page carries the feed links, one title and robots from the decision', async () => {const r = await t.get('/');const { head } = split(r.text);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;++});@@@ -17,7 +17,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');const BUDGETS = {htmlRawKB: 47, // 47 openvibe-shared v4.202.0 (2026-11-11: measured 46.7, raised by pin-bump from 46); 2026-11-11: openvibe-shared 4.190.0 (the site name's dropdown, the footer's online strip, the balances pill); 42.4 measured 2026-11-10: the empty home's kit empty state (the measured database is fresh); 41.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 40.1 with the short home's pick blocks and folds (2026-10-30); 33.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 29.1 with the curated front page's search, questions (FAQ + JSON-LD) and closing call (2026-10-22); 23.5 beforehtmlBrotliKB: 10.5, // 2026-11-11: openvibe-shared 4.190.0 (the site name's dropdown, the footer's online strip, the balances pill); 8.3 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 7.1 with the curated front page (6.0 before; 4.5 before the showcase)-- jsFiles: 5, // 5 (openvibe-shared 2.10's shell adds web-runtime.js)++ jsFiles: 6, // 6: the signed-out page's passkey enhancement (2026-11-11, related-origin passkeys); 5 before it, with openvibe-shared 2.10's web-runtime.jsjsRawKB: 394.5, // 394.5: measured 394.1 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); 2026-11-11: openvibe-shared 4.190.0 (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, // 2026-11-11: openvibe-shared 4.190.0 (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: 3, // 3 on the empty home (deals.css, shared showcase.css and kit.css for the empty state); 2 on every other home
@@ -2,6 +2,12 @@
@@ -2,6 +2,12 @@
## Unreleased
## Unreleased
+- Sign in with a passkey on Deals' 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 Deals' 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`.
+
- The sitemap cache holds index entries, not page views (memory on the host): `/sitemap.xml`, `/sitemap-<n>.xml` and `/llms-full.txt` kept every offer's and product's full view for `DEALS_SITEMAP_TTL_MS` — observations, sources, vote tallies and merge members — at ~6.7 KB per offer. On 2026-11-07 that was 667 MB resident (923 MB at measurement, 2 GB peak) for 50,000 offers, the most of any Node service on a 15 GB host that ran out of memory at 02:34. The cache now stores `publication.indexEntry` / `productIndexEntry`: the URL, lastmod and indexability decision, plus an `/llms-full.txt` summary for the first 10,000 indexable offers (the file truncates at 512 KB), ~0.6 KB an offer. Each offer needs two reads (its merge group and the group's latest observation) instead of ten, and `listings.indexable()` joins the store so no per-offer store lookup is needed; a product's lastmod and freshest observation come from one query. Output is unchanged (same URLs, same order, same lastmods, same gate). `test/memory-budget.test.js` boots the app on a 2,500-offer catalog, builds the sitemaps and runs one Sources import: the heap grows at most 12 MB past boot (measured 3.5 MB; the old full-view cache measured 12.8 MB at 2,000 offers) and stays under 200 MB overall.
- The sitemap cache holds index entries, not page views (memory on the host): `/sitemap.xml`, `/sitemap-<n>.xml` and `/llms-full.txt` kept every offer's and product's full view for `DEALS_SITEMAP_TTL_MS` — observations, sources, vote tallies and merge members — at ~6.7 KB per offer. On 2026-11-07 that was 667 MB resident (923 MB at measurement, 2 GB peak) for 50,000 offers, the most of any Node service on a 15 GB host that ran out of memory at 02:34. The cache now stores `publication.indexEntry` / `productIndexEntry`: the URL, lastmod and indexability decision, plus an `/llms-full.txt` summary for the first 10,000 indexable offers (the file truncates at 512 KB), ~0.6 KB an offer. Each offer needs two reads (its merge group and the group's latest observation) instead of ten, and `listings.indexable()` joins the store so no per-offer store lookup is needed; a product's lastmod and freshest observation come from one query. Output is unchanged (same URLs, same order, same lastmods, same gate). `test/memory-budget.test.js` boots the app on a 2,500-offer catalog, builds the sitemaps and runs one Sources import: the heap grows at most 12 MB past boot (measured 3.5 MB; the old full-view cache measured 12.8 MB at 2,000 offers) and stays under 200 MB overall.
- Imported deals become searchable (the automatic review of imports): an offer imported from a configured feed source is checked when it is imported and passes when its link is https and points at that source's retailer domain, its title is not empty and at most 300 characters, its price is a positive number with a currency, it has not expired and it states none of the blocked/adult terms. A pass sets `review_state = auto_approved` (published, indexable, `reviewed_by = system:import-checks`); a fail stays `pending` with the failed check's name in `review_failed_check`. Offers people submit by hand keep today's review flow and a moderator can still hide any offer. `server/domain/import-review.js`, migration `0009_import_auto_review.sql`, `scripts/backfill-import-review.js` (batches of 500, `--dry-run`, counts only), `test/import-review.test.js`.
- Imported deals become searchable (the automatic review of imports): an offer imported from a configured feed source is checked when it is imported and passes when its link is https and points at that source's retailer domain, its title is not empty and at most 300 characters, its price is a positive number with a currency, it has not expired and it states none of the blocked/adult terms. A pass sets `review_state = auto_approved` (published, indexable, `reviewed_by = system:import-checks`); a fail stays `pending` with the failed check's name in `review_failed_check`. Offers people submit by hand keep today's review flow and a moderator can still hide any offer. `server/domain/import-review.js`, migration `0009_import_auto_review.sql`, `scripts/backfill-import-review.js` (batches of 500, `--dry-run`, counts only), `test/import-review.test.js`.
@@ -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"
@@ -28,7 +28,7 @@
@@ -28,7 +28,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 */ });
+})();
@@ -104,7 +104,7 @@ async function createApp(opts = {}) {
@@ -104,7 +104,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,
});
});
const auth = opts.auth || sso;
const auth = opts.auth || sso;
const jwksUrl = `${config.networkInternalUrl}/api/.well-known/jwks`;
const jwksUrl = `${config.networkInternalUrl}/api/.well-known/jwks`;
@@ -93,7 +93,7 @@ function renderPage(o) {
@@ -93,7 +93,7 @@ function renderPage(o) {
return layout.renderDocument({
return layout.renderDocument({
// The head names the page's own Markdown twin, and carries the rules that show the picked pane of a pick
// The head names the page's own Markdown twin, and carries the rules that show the picked pane of a pick
// block (there is one per pane, written for the page).
// block (there is one per pane, written for the page).
- head: [o.head, agentHead, o.path === '/' ? '' : `<link rel="stylesheet" href="${asset('css/deal-cards.css')}">`, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`)].filter(Boolean).join('\n'),
+ head: [o.head, agentHead, o.path === '/' ? '' : `<link rel="stylesheet" href="${asset('css/deal-cards.css')}">`, ...(o.inlineStyles || []).map((css) => `<style>${css}</style>`), signedIn ? '' : `<script src="${asset('js/passkey-signin.js')}" defer></script>`].filter(Boolean).join('\n'),
site: 'deals',
site: 'deals',
siteName: SITE_NAME,
siteName: SITE_NAME,
lang: o.lang,
lang: o.lang,
@@ -44,6 +44,13 @@ const split = (html) => ({ head: html.slice(0, html.indexOf('</head>')), body: h
@@ -44,6 +44,13 @@ const split = (html) => ({ head: html.slice(0, html.indexOf('</head>')), body: h
assert.ok(body.includes('"loginUrl":"/auth/login?next={path}"'), 'the navbar sign-in returns to the current page');
assert.ok(body.includes('"loginUrl":"/auth/login?next={path}"'), 'the navbar sign-in returns to the current page');
});
});
+ await check('a signed-out page loads the passkey enhancement, which no signed-in page needs', async () => {
+ const guest = await t.get('/');
+ assert.ok(/<script src="\/js\/passkey-signin\.js\?v=[0-9a-f]+" defer><\/script>/.test(guest.text), 'the feature-detecting script');
+ const signedIn = await t.get('/watches', { as: alice });
+ assert.ok(!signedIn.text.includes('/js/passkey-signin.js'), 'no passkey script once signed in');
+ });
+
await check('the home page carries the feed links, one title and robots from the decision', async () => {
await check('the home page carries the feed links, one title and robots from the decision', async () => {
const r = await t.get('/');
const r = await t.get('/');
const { head } = split(r.text);
const { head } = split(r.text);
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;
+});
@@ -17,7 +17,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -17,7 +17,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
const BUDGETS = {
const BUDGETS = {
htmlRawKB: 47, // 47 openvibe-shared v4.202.0 (2026-11-11: measured 46.7, raised by pin-bump from 46); 2026-11-11: openvibe-shared 4.190.0 (the site name's dropdown, the footer's online strip, the balances pill); 42.4 measured 2026-11-10: the empty home's kit empty state (the measured database is fresh); 41.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 40.1 with the short home's pick blocks and folds (2026-10-30); 33.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 29.1 with the curated front page's search, questions (FAQ + JSON-LD) and closing call (2026-10-22); 23.5 before
htmlRawKB: 47, // 47 openvibe-shared v4.202.0 (2026-11-11: measured 46.7, raised by pin-bump from 46); 2026-11-11: openvibe-shared 4.190.0 (the site name's dropdown, the footer's online strip, the balances pill); 42.4 measured 2026-11-10: the empty home's kit empty state (the measured database is fresh); 41.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 40.1 with the short home's pick blocks and folds (2026-10-30); 33.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 29.1 with the curated front page's search, questions (FAQ + JSON-LD) and closing call (2026-10-22); 23.5 before
htmlBrotliKB: 10.5, // 2026-11-11: openvibe-shared 4.190.0 (the site name's dropdown, the footer's online strip, the balances pill); 8.3 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 7.1 with the curated front page (6.0 before; 4.5 before the showcase)
htmlBrotliKB: 10.5, // 2026-11-11: openvibe-shared 4.190.0 (the site name's dropdown, the footer's online strip, the balances pill); 8.3 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 7.1 with the curated front page (6.0 before; 4.5 before the showcase)
- jsFiles: 5, // 5 (openvibe-shared 2.10's shell adds web-runtime.js)
+ jsFiles: 6, // 6: the signed-out page's passkey enhancement (2026-11-11, related-origin passkeys); 5 before it, with openvibe-shared 2.10's web-runtime.js
jsRawKB: 394.5, // 394.5: measured 394.1 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); 2026-11-11: openvibe-shared 4.190.0 (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 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); 2026-11-11: openvibe-shared 4.190.0 (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, // 2026-11-11: openvibe-shared 4.190.0 (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, // 2026-11-11: openvibe-shared 4.190.0 (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: 3, // 3 on the empty home (deals.css, shared showcase.css and kit.css for the empty state); 2 on every other home
cssFiles: 3, // 3 on the empty home (deals.css, shared showcase.css and kit.css for the empty state); 2 on every other home