openvibers/openvibe.deals Commits

Sign in with a passkey on Deals' own origin (#305)

OP OpenVibers committed 2 days ago · 9e3bafb96f · 9 files, +139 −8

* Sign in with a passkey on Deals' own origin

The SDK's related-origin passkey routes are enabled and a signed-out page
loads a small script that takes over the existing Sign In control only when
the browser reports relatedOrigins. A person with a key signs in on
openvibe.deals with no Network redirect; no key, a cancelled prompt or an
older browser falls back to the ordinary OpenVibe sign-in.

* Budget the passkey script as the sixth guest-page file

* Test the passkey Sign In enhancement and its fallback

* Merge remote-tracking branch 'origin/main' into signin-passkeys-deals-1111

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3ae5656..0a5259d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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`.
ddiff --git a/package-lock.json b/package-lock.json
iindex fabb495..497412b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 4840639..f89dd68 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/public/js/passkey-signin.js b/public/js/passkey-signin.js
nnew file mode 100644
iindex 0000000..19029fd
---- /dev/null
++++ b/public/js/passkey-signin.js
@@@ -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 */ });
++})();
ddiff --git a/server/app.js b/server/app.js
iindex a22d9e2..dd083cd 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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`;
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex a0df426..ac8ca01 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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,
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex 99f811e..f04bfcc 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -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');
});
++ 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);
ddiff --git a/test/passkey-signin.test.js b/test/passkey-signin.test.js
nnew file mode 100644
iindex 0000000..502ccf2
---- /dev/null
++++ b/test/passkey-signin.test.js
@@@ -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;
++});
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 073bb4d..763e536 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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 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)
-- 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
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