openvibers/openvibe.sdk Commits

SDK 0.25.0 (plan T1 kits): openvibe-sdk/sso createSsoClient() is a product site's sign-in with OpenVibe.Network in one call (router with /login state + PKCE S256, /callback server-side exchange, /fedcm, /logout, /me, /refresh; optionalAuth/requireAuth; offline verification through the shared JWKS client; next limited to this site and the Network), replacing the copies in Blog, Coupons, Deals, Host, News and Trade. openvibe-sdk/events createServiceOutbox() is a service's outbox in one call around createPgOutbox (emit joins the change's transaction, emitIn, moderationAction, start/stop/kick/status; the relay is off and says so without the events URL or client secret), replacing the wrapper nine services copied. Types, ESM entries, README and CHANGELOG. 47/47 test files with the test services.

OP OpenVibers committed 29 Sept 2026 · 9b5bbec26e · 18 files, +836 −12


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 08a1226..d0c0a93 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -8,6 +8,20 @@ release may change an API and says so here.
++## 0.25.0 (2026-09-29)
++
++- `openvibe-sdk/sso` (new, server): `createSsoClient({ site, baseUrl, clientId, clientSecret, networkUrl, networkInternalUrl,
++ secureCookies })` → `router(express)` (`/login`, `/callback`, `/fedcm`, `/logout`, `/me`, `/refresh`), `optionalAuth()`,
++ `requireAuth()`, `verify()`, `extractToken()`. A product site's sign-in with OpenVibe.Network in one call: state and PKCE
++ S256 on every sign-in, the code exchanged server-side, `next` limited to this site and the Network, offline verification
++ through the shared JWKS client. Replaces the copies in Blog, Coupons, Deals, Host, News and Trade (plan T1).
++- `openvibe-sdk/events` `createServiceOutbox({ db, source, eventsUrl, clientId, clientSecret, eventTypes, validate })`: a
++ service's events outbox in one call around `createPgOutbox` (`emit` joins the change's transaction, `emitIn(t, …)`,
++ `moderationAction`, `start`/`stop`/`kick`/`status`); the relay is off, and says so, without the events URL or the
++ client secret. Replaces the wrapper nine services copied (plan T1).
++- No floating promises (plan T0): the Valkey queue logs a job whose run rejects outside its handler (a malformed stream
++ entry was an unhandled rejection), and the mock jobs service fails a job whose background run throws.
++
## 0.24.0 (2026-09-29)
- `openvibe-sdk/auth` `verifyServiceToken(token, { jwks | publicKey, issuer, audience, contracts, acceptSandbox, log })`:
ddiff --git a/README.md b/README.md
iindex 9884364..fbff524 100644
---- a/README.md
++++ b/README.md
@@@ -166,7 +166,8 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
--| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `parseDelivery` (v1 and v2, `requireV2`), `verifyDeliveryV2`, `signDeliveryV2`, `signDeliveryHeaders`, `verifyDelivery`, `signDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` |
++| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `parseDelivery` (v1 and v2, `requireV2`), `verifyDeliveryV2`, `signDeliveryV2`, `signDeliveryHeaders`, `verifyDelivery`, `signDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox`, `createPgOutbox`, `createPgInbox`; `createServiceOutbox({db, source, eventsUrl, clientId, clientSecret, eventTypes?, validate?})` (a service's outbox in one call: `emit`/`emitIn` inside the change's transaction, `moderationAction`, `start`/`stop`/`kick`/`status`) |
++| `openvibe-sdk/sso` | server | `createSsoClient({site, baseUrl, clientId, clientSecret, networkUrl, networkInternalUrl, secureCookies})`: a product site's sign-in with OpenVibe.Network: `router(express)` (`/login` with state + PKCE S256, `/callback`, `/fedcm`, `/logout`, `/me`, `/refresh`), `optionalAuth()`, `requireAuth()`, `verify(token)` (offline, shared JWKS), `extractToken(req)`; `sanitizeNext`, `COOKIES` |
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers; `createObjectsClient({app, baseUrl?, tokenClient \| apiKey \| client})`: object API v2 `upload` (single or multipart by size, resumable), `resume`, `get`, `signedUrl`, `delete`, `list`, `iterate`, and `jobs.create/get/list/approve/cancel/wait`; Media's origin from the platform descriptor when `baseUrl` is omitted |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
@@@ -224,6 +225,8 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app.
- `createRevocationStore(db?)` keeps each person's token cutoff from Network's `network.user.token_valid_after` event (Contracts 0.39.0: sign out everywhere, a password change or reset, a ban, staff ending someone's sessions). Subscribe to the event, pass each delivery to `store.apply(event)` (`'revoked'` means the cutoff moved: close that person's sockets, drop their cached sessions), and after `verifyUserToken()` refuse the token when `store.isRevoked(claims)`. That is Network's rule, `iat * 1000 < valid_after`. With a better-sqlite3 handle the cutoffs survive restarts; the store only ever moves a cutoff forward and ignores anything not from `network`.
++- `createSsoClient()` (`openvibe-sdk/sso`) is the site side of first-party sign-in, the layer Blog, Coupons, Deals, Host, News and Trade each carried a copy of. `/auth/login` sends the browser to the Network with a random `state` and a PKCE S256 challenge kept in short-lived httpOnly cookies on `/auth`; `/auth/callback` requires the state (constant-time compare) and exchanges the code server-side with the client secret and the verifier. `next` is a same-site path, this site's https origin or the Network's; anything else, including control characters and backslashes, goes home. Sessions are verified offline with `verifyUserToken` through the shared JWKS client (the last good keys survive a Network outage), so a service principal or a typed token (a realtime ticket, a FedCM assertion) is never a session. Cookies are host-only: `ov_token` (the access JWT, readable by the shared navbar), `ov_refresh` (httpOnly, `Path=/auth`) and `ov_sso_hint`.
++
### Receiving event webhooks
OpenVibe.Events POSTs each delivery as `{ event, seq }` with two signatures:
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 5440db5..3b1921a 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.24.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.25.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -41,7 +41,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.24.0';
++const SDK_VERSION = '0.25.0';
module.exports = {
createClient,
ddiff --git a/esm/events.mjs b/esm/events.mjs
iindex 9a89ccc..0e95268 100644
---- a/esm/events.mjs
++++ b/esm/events.mjs
@@@ -4,6 +4,6 @@ import cjs from '../src/events.js';
export const {
createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
createPgOutbox, createPgInbox, outboxSchema, inboxSchema, projectKey, appSource,
-- createAppEvents, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders,
++ createAppEvents, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders, createServiceOutbox,
} = cjs;
export default cjs;
ddiff --git a/esm/sso.mjs b/esm/sso.mjs
nnew file mode 100644
iindex 0000000..1d8e1c2
---- /dev/null
++++ b/esm/sso.mjs
@@@ -0,0 +1,8 @@
++// Generated by scripts/esm.js from src/sso/index.js. Do not edit.
++import cjs from '../src/sso/index.js';
++
++export const {
++ createSsoClient, sanitizeNext, withParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser,
++ parseCookies, COOKIES,
++} = cjs;
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 8852264..2d6d9ad 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.24.0",
++ "version": "0.25.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.24.0",
++ "version": "0.25.0",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex ad9fe4f..31df80f 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.24.0",
++ "version": "0.25.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -50,6 +50,12 @@
"import": "./esm/events.mjs",
"default": "./src/events.js"
},
++ "./sso": {
++ "types": "./types/sso.d.ts",
++ "browser": null,
++ "import": "./esm/sso.mjs",
++ "default": "./src/sso/index.js"
++ },
"./realtime": {
"types": "./types/realtime.d.ts",
"import": "./esm/realtime.mjs",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 780e275..826fcd2 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -20,6 +20,7 @@ const ENTRIES = {
'identity.mjs': '../src/identity.js',
'modules.mjs': '../src/modules.js',
'events.mjs': '../src/events.js',
++ 'sso.mjs': '../src/sso/index.js',
'realtime.mjs': '../src/realtime.js',
'media.mjs': '../src/media.js',
'community.mjs': '../src/community.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex fc64979..a8d4bc7 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.24.0';
++const SDK_VERSION = '0.25.0';
module.exports = {
createClient,
ddiff --git a/src/events.js b/src/events.js
iindex ecb54e9..d5e308d 100644
---- a/src/events.js
++++ b/src/events.js
@@@ -356,9 +356,10 @@ function createAppEvents(client, { projectId, appId, onBehalfOf, baseUrl } = {})
}
const { createOutbox, createInbox, createPgOutbox, createPgInbox, outboxSchema, inboxSchema } = require('./outbox');
++const { createServiceOutbox } = require('./service-outbox');
module.exports = {
createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
createPgOutbox, createPgInbox, outboxSchema, inboxSchema,
-- projectKey, appSource, createAppEvents, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders,
++ projectKey, appSource, createAppEvents, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders, createServiceOutbox,
};
ddiff --git a/src/service-outbox.js b/src/service-outbox.js
nnew file mode 100644
iindex 0000000..b9ca504
---- /dev/null
++++ b/src/service-outbox.js
@@@ -0,0 +1,107 @@
++'use strict';
++/**
++ * createServiceOutbox: a service's events outbox in one call (the wrapper nine services copied around createPgOutbox:
++ * Trade, News, Host, Codes, Blog, Deals, Coupons, VIP, Tips). Rows are written inside the change's own transaction
++ * (emit joins the ambient openvibe-sdk/db transaction), so an event exists if and only if its change committed; the
++ * relay publishes with the service's token (events.event.publish, audience openvibe.events) when the events URL and the
++ * OAuth client secret are set, and otherwise rows wait and status() says the relay is off.
++ *
++ * const { createServiceOutbox } = require('openvibe-sdk/events');
++ * const out = createServiceOutbox({
++ * db, source: 'trade',
++ * eventsUrl: config.events.url, networkInternalUrl: config.networkInternalUrl,
++ * clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ * intervalMs: config.events.intervalMs, log,
++ * eventTypes: ['trade.observation.created', …], // optional: emit refuses an undeclared type
++ * validate: (env) => contracts.validate('events.event-envelope@1', env), // optional: refuse a malformed envelope
++ * });
++ * await out.emit(envelope, { traceparent }); // inside the change's transaction
++ * await out.emitIn(t, envelope, { traceparent }); // or on an explicit transaction handle
++ * await out.moderationAction({ action, target, actorSubject, reason, details }); // <source>.moderation.action
++ * out.start(); out.stop(); await out.kick(); await out.status(); // → { enabled, pending, rejected, last_error }
++ */
++const { createClient } = require('./core');
++const { createServiceTokenClient } = require('./auth/tokens');
++
++function createServiceOutbox({
++ db, source, eventsUrl = null, networkInternalUrl = 'http://127.0.0.1:4000', clientId = null, clientSecret = null,
++ table = 'event_outbox', intervalMs, now, fetch: fetchImpl, log = console, eventTypes = null, validate = null,
++ autoDiscover, moderationOwnerSubject = true,
++} = {}) {
++ if (!db) throw new TypeError('createServiceOutbox: db is required');
++ if (!source || !/^[a-z][a-z0-9_-]*$/.test(source)) throw new TypeError('createServiceOutbox: source is the service id (e.g. "trade")');
++ // Required lazily: ./events requires ./outbox, which stays independent of this helper.
++ const { createEventsClient, createPgOutbox } = require('./events');
++ const enabled = Boolean(eventsUrl && clientSecret);
++ const clientOpts = { baseUrls: { events: eventsUrl || 'http://127.0.0.1:4300' }, retries: 0 };
++ if (autoDiscover !== undefined) clientOpts.autoDiscover = autoDiscover;
++ if (fetchImpl) clientOpts.fetch = fetchImpl;
++ if (enabled) {
++ clientOpts.tokenProvider = createServiceTokenClient({
++ tokenUrl: `${String(networkInternalUrl).replace(/\/+$/, '')}/oauth/token`, clientId: clientId || source, clientSecret,
++ scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ });
++ } else {
++ clientOpts.getToken = async () => { throw new Error('events relay disabled (the events URL or the OAuth client secret is unset)'); };
++ }
++ const events = createEventsClient(createClient(clientOpts), { source });
++ const tag = `[${source.charAt(0).toUpperCase()}${source.slice(1)}]`;
++ let lastError = null;
++ const outbox = createPgOutbox(db, {
++ events, table, ...(intervalMs ? { intervalMs } : {}), ...(now ? { now } : {}),
++ onError: (err) => {
++ const msg = err && err.message;
++ if (msg !== lastError) (log.warn || log.log).call(log, `${tag} event publish failed (will retry): ${msg}`);
++ lastError = msg;
++ },
++ });
++ const declared = eventTypes ? new Set(eventTypes) : null;
++
++ function check(envelope) {
++ if (!envelope || typeof envelope !== 'object') throw new TypeError('emit: pass an envelope object');
++ if (declared && !declared.has(envelope.event_type)) throw new Error(`undeclared event type ${envelope.event_type}`);
++ }
++ function checkValid(prepared) {
++ if (!validate) return;
++ const v = validate(prepared);
++ if (v && v.valid === false) {
++ const why = (v.errors || []).map((e) => `${e.path || ''} ${e.message || e}`.trim()).join('; ');
++ throw new Error(`outbox: invalid envelope for ${prepared.event_type}: ${why}`);
++ }
++ }
++
++ /** On an explicit transaction handle (or db). Returns the complete envelope (with its event_id). */
++ async function emitIn(t, envelope, { traceparent } = {}) {
++ check(envelope);
++ if (validate) {
++ const prepared = events.prepare(envelope, now ? { now: now() } : {});
++ checkValid(prepared);
++ return await outbox.enqueue(t, prepared, { traceparent });
++ }
++ return await outbox.enqueue(t, envelope, { traceparent });
++ }
++ /** Inside the caller's (ambient) transaction. */
++ const emit = (envelope, opts) => emitIn(db, envelope, opts);
++
++ /** <source>.moderation.action inside the caller's transaction. target: { type, id, owner_subject? }. Never the content. */
++ async function moderationAction({ action, target, actorSubject = null, reason = null, details = {} }, { traceparent } = {}) {
++ const t = { type: target.type, id: String(target.id).slice(0, 200), owner_subject: moderationOwnerSubject ? (target.owner_subject || null) : null };
++ return await emit({
++ event_type: `${source}.moderation.action`,
++ actor: actorSubject ? { type: 'user', id: actorSubject } : { type: 'service', id: source },
++ subject: { type: 'moderation_action', id: `${t.type}:${t.id}`.slice(0, 200) },
++ visibility: 'internal',
++ payload: { action, target: t, actor_subject: actorSubject || null, reason: reason ? String(reason).slice(0, 500) : null, details: details || {} },
++ }, { traceparent });
++ }
++
++ return {
++ emit, emitIn, moderationAction, outbox, events, enabled,
++ start() { if (enabled) outbox.start(); },
++ stop: () => outbox.stop(),
++ async kick() { if (enabled) await outbox.kick(); },
++ status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),
++ };
++}
++
++module.exports = { createServiceOutbox };
ddiff --git a/src/sso/index.js b/src/sso/index.js
nnew file mode 100644
iindex 0000000..884c83a
---- /dev/null
++++ b/src/sso/index.js
@@@ -0,0 +1,351 @@
++'use strict';
++/**
++ * openvibe-sdk/sso: a site's sign-in with OpenVibe.Network (the OAuth2 client session layer every product site used to
++ * copy: Blog, Coupons, Deals, Host, News, Trade). One call gives the routes, the middleware and offline verification.
++ *
++ * const { createSsoClient } = require('openvibe-sdk/sso');
++ * const sso = createSsoClient({
++ * site: 'trade', // log prefix and the default extra audience openvibe.trade
++ * baseUrl: 'https://openvibe.trade', // this site's origin (post-sign-in targets, redirect URI)
++ * clientId, clientSecret, // the site's OAuth client in the Network
++ * networkUrl: 'https://openvibe.network', networkInternalUrl: 'http://127.0.0.1:4000',
++ * secureCookies: true,
++ * });
++ * app.use('/auth', sso.router(express)); // /login /callback /fedcm /logout /me /refresh
++ * app.use(sso.optionalAuth()); // req.user, req.token when a valid session is presented
++ * app.get('/private', sso.requireAuth(), handler); // 401 JSON without a session
++ *
++ * Routes (mounted at /auth; the redirect URI is <baseUrl>/auth/callback unless `redirectUri` says otherwise):
++ * GET /login → Network /oauth/authorize with state and PKCE (S256); ?silent=1: prompt=none; ?next= a same-site
++ * path, this site's https origin or the Network's (control characters and backslashes go home)
++ * GET /callback → the state is required and compared in constant time; the code is exchanged server-side with the
++ * client secret and the PKCE verifier; cookies are set; back to `next`
++ * POST /fedcm → the shared navbar's FedCM assertion (nonce pre-checked) swapped for tokens (jwt-bearer grant)
++ * GET /logout → cookies cleared, the refresh token revoked (best effort), hint=guest
++ * GET /me → the verified session's profile
++ * POST /refresh → rotate the tokens with the refresh token
++ * Cookies (host-only): ov_token (the access JWT, SameSite=Lax, readable by the shared navbar), ov_refresh (httpOnly,
++ * Path=/auth), ov_sso_hint ('account' / 'guest'), and the short-lived flow cookies on /auth.
++ *
++ * Verification is offline through the SDK's shared JWKS client (./auth/jwks.js: the last good keys through a Network
++ * outage, rotations honoured) and verifyUserToken (issuer, audience, expiry; service principals and typed tokens such as
++ * a FedCM assertion are never a session). No express dependency: pass your express module to router().
++ */
++const crypto = require('node:crypto');
++const { verifyUserToken } = require('../auth/jwt');
++
++const ACCESS_COOKIE = 'ov_token';
++const REFRESH_COOKIE = 'ov_refresh';
++const HINT_COOKIE = 'ov_sso_hint';
++const STATE_COOKIE = 'ov_oauth_state';
++const NEXT_COOKIE = 'ov_oauth_next';
++const SILENT_COOKIE = 'ov_oauth_silent';
++const VERIFIER_COOKIE = 'ov_oauth_verifier';
++const FLOW_COOKIES = [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE, VERIFIER_COOKIE];
++
++const trim = (u) => String(u || '').replace(/\/+$/, '');
++
++/** The Cookie header as an object (no cookie-parser needed). */
++function parseCookies(req) {
++ if (req.cookies && typeof req.cookies === 'object') return req.cookies;
++ 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;
++}
++
++/**
++ * Only a same-site relative path, this site's own https origin, or the Network's https origin is a post-auth target.
++ * Browsers drop tabs and newlines from a URL and read a backslash as "/": "/<TAB>/evil.com" would leave the site, so a
++ * next with any control character or backslash goes home.
++ */
++function sanitizeNext(next, { baseUrl, networkUrl } = {}) {
++ if (typeof next !== 'string' || !next) return '/';
++ if (/[\u0000-\u001f\u007f\\]/.test(next)) return '/';
++ if (/^\/(?!\/)/.test(next)) return next;
++ try {
++ const u = new URL(next);
++ if (u.protocol !== 'https:') return '/';
++ const allowed = [baseUrl, networkUrl].map((b) => { try { return new URL(b).hostname; } catch { return null; } }).filter(Boolean);
++ if (allowed.includes(u.hostname)) return u.toString();
++ } catch { /* fall through */ }
++ return '/';
++}
++
++/** Append ?key=value to a same-site path or absolute URL, keeping any existing query and hash. */
++function withParam(target, key, value) {
++ const hashAt = target.indexOf('#');
++ const hash = hashAt >= 0 ? target.slice(hashAt) : '';
++ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;
++ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
++}
++
++/** A JWT's payload WITHOUT checking its signature: only to pre-check a FedCM nonce before the Network verifies it. */
++function decodeJwtPayload(token) {
++ if (typeof token !== 'string') return null;
++ const parts = token.split('.');
++ if (parts.length !== 3) return null;
++ try {
++ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
++ return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;
++ } catch { return null; }
++}
++
++/** True when the assertion's `nonce` claim is exactly the nonce the page posted (constant time). */
++function fedcmNonceMatches(token, nonce) {
++ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;
++ const claims = decodeJwtPayload(token);
++ if (!claims || typeof claims.nonce !== 'string') return false;
++ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);
++ return a.length === b.length && crypto.timingSafeEqual(a, b);
++}
++
++/** The claims a site shows as "the user" (registered claims removed). */
++function claimsToUser(claims) {
++ if (!claims) return null;
++ const { iat, exp, aud, iss, nbf, jti, ...user } = claims; // eslint-disable-line no-unused-vars
++ return user;
++}
++
++function sameString(a, b) {
++ const x = Buffer.from(String(a || '')), y = Buffer.from(String(b || ''));
++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
++}
++
++function createSsoClient(opts = {}) {
++ const site = String(opts.site || 'site');
++ const baseUrl = trim(opts.baseUrl);
++ const networkUrl = trim(opts.networkUrl || 'https://openvibe.network');
++ const networkInternalUrl = trim(opts.networkInternalUrl || networkUrl);
++ const issuer = trim(opts.issuer || networkUrl);
++ const redirectUri = opts.redirectUri || `${baseUrl}/auth/callback`;
++ const scope = opts.scope || 'profile';
++ const audience = opts.audience || ['openvibe.network', `openvibe.${site}`];
++ const jwks = opts.jwks || (opts.publicKey ? null : `${networkInternalUrl}/api/.well-known/jwks`);
++ const secure = opts.secureCookies !== false;
++ const fetchImpl = opts.fetch || globalThis.fetch;
++ const log = opts.log || console;
++ const accessMaxAgeMs = opts.accessMaxAgeMs || 24 * 3600e3;
++ if (!baseUrl || !opts.clientId) throw new TypeError('createSsoClient: baseUrl and clientId are required');
++ const next = (value) => sanitizeNext(value, { baseUrl, networkUrl });
++
++ /** The verified claims of a session token, or null. Never throws. */
++ async function verify(token) {
++ if (!token) return null;
++ try {
++ return await verifyUserToken(token, { jwks, publicKey: opts.publicKey, issuer, audience, log, fetch: fetchImpl });
++ } catch (err) {
++ if (err && err.code === 'token.no_key') log.warn(`[${site} auth] Network keys unavailable: ${err.message}`);
++ return null;
++ }
++ }
++
++ /** The session token: a Bearer header, else the ov_token cookie. */
++ function extractToken(req) {
++ const h = String((req.headers && req.headers.authorization) || '');
++ if (h.startsWith('Bearer ')) return h.slice(7).trim() || null;
++ return parseCookies(req)[ACCESS_COOKIE] || null;
++ }
++
++ const cookie = (maxAge, { httpOnly = true, path = '/' } = {}) => ({ sameSite: 'lax', secure, httpOnly, path, ...(maxAge ? { maxAge } : {}) });
++ const accessOpts = () => cookie(accessMaxAgeMs, { httpOnly: false }); // the shared navbar reads it
++ const refreshOpts = () => cookie(30 * 24 * 3600e3, { path: '/auth' });
++ const flowOpts = () => cookie(10 * 60e3, { path: '/auth' });
++ const hintOpts = () => cookie(365 * 24 * 3600e3, { httpOnly: false });
++
++ function setSession(res, accessToken, refreshToken) {
++ res.cookie(ACCESS_COOKIE, accessToken, accessOpts());
++ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshOpts());
++ res.cookie(HINT_COOKIE, 'account', hintOpts());
++ }
++ function clearSession(res) {
++ res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });
++ res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });
++ }
++ const clearFlow = (res) => { for (const c of FLOW_COOKIES) res.clearCookie(c, { path: '/auth' }); };
++
++ /** A token grant at the Network: the internal URL first, the public one if it cannot be reached. */
++ async function tokenGrant(body) {
++ let lastErr = null;
++ for (const base of [...new Set([networkInternalUrl, networkUrl])]) {
++ try {
++ const res = await fetchImpl(`${base}/oauth/token`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: opts.clientId, client_secret: opts.clientSecret, ...body }),
++ signal: AbortSignal.timeout(10_000),
++ });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) {
++ throw Object.assign(new Error(data.error_description || data.error || `token grant failed (${res.status})`), {
++ status: res.status, error: data.error || 'invalid_grant', error_description: data.error_description || null,
++ });
++ }
++ return data;
++ } catch (err) {
++ lastErr = err;
++ if (err.status && err.status < 500) throw err; // the grant itself is bad: another base will not help
++ }
++ }
++ throw lastErr || new Error('Network unreachable');
++ }
++
++ /** The authorize URL for a sign-in (pure; exported for tests). → { url, state, verifier } */
++ function authorizeUrl({ silent = false } = {}) {
++ const verifier = crypto.randomBytes(32).toString('base64url');
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ const state = crypto.randomBytes(24).toString('base64url');
++ const u = new URL(`${networkUrl}/oauth/authorize`);
++ u.searchParams.set('response_type', 'code');
++ u.searchParams.set('client_id', opts.clientId);
++ u.searchParams.set('redirect_uri', redirectUri);
++ u.searchParams.set('scope', scope);
++ u.searchParams.set('state', state);
++ u.searchParams.set('code_challenge', challenge);
++ u.searchParams.set('code_challenge_method', 'S256');
++ // silent: continue as the account openvibe.network already knows (no chooser); with no Network session either it
++ // bounces back with error=login_required and the site stays quiet.
++ if (silent) u.searchParams.set('prompt', 'none');
++ return { url: u.toString(), state, verifier };
++ }
++
++ const handlers = {
++ async login(req, res) {
++ const q = req.query || {};
++ const silent = !!q.silent && q.silent !== '0';
++ // Already signed in here? A silent round trip would only hand back the session we have.
++ if (silent && await verify(parseCookies(req)[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(next(q.next)); }
++ const { url, state, verifier } = authorizeUrl({ silent });
++ res.cookie(STATE_COOKIE, state, flowOpts());
++ res.cookie(VERIFIER_COOKIE, verifier, flowOpts());
++ const target = next(q.next);
++ if (target !== '/') res.cookie(NEXT_COOKIE, target, flowOpts()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
++ if (silent) res.cookie(SILENT_COOKIE, '1', flowOpts()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
++ return res.redirect(url);
++ },
++
++ async callback(req, res) {
++ const q = req.query || {};
++ const c = parseCookies(req);
++ const target = next(c[NEXT_COOKIE]);
++ const silent = c[SILENT_COOKIE] === '1';
++ if (q.error) {
++ clearFlow(res);
++ // Silent sign-in found no Network session: back quietly as a guest (?sso=none stops the navbar retrying).
++ if (silent || q.error === 'login_required') return res.redirect(withParam(target, 'sso', 'none'));
++ return res.redirect(withParam('/', 'auth_error', String(q.error)));
++ }
++ if (!q.code) return res.status(400).send('Missing authorization code');
++ const expected = c[STATE_COOKIE], verifier = c[VERIFIER_COOKIE];
++ clearFlow(res);
++ // The state cookie is required, not only compared when present: skipping the check without it would let a
++ // crafted link sign a visitor into someone else's account.
++ if (!sameString(q.state, expected) || !verifier) return res.status(400).send('OAuth state mismatch. Please try signing in again.');
++ try {
++ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: redirectUri, code: String(q.code), code_verifier: verifier });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.redirect(target);
++ } catch (err) {
++ log.error(`[${site} auth] code exchange failed: ${err.message}`);
++ if (err.status && err.status < 500) return res.redirect(withParam('/', 'auth_error', String(err.error || 'invalid_grant')));
++ return res.status(502).send('Sign-in failed: OpenVibe.Network could not be reached. Please try again.');
++ }
++ },
++
++ async fedcm(req, res) {
++ const { token, nonce } = req.body || {};
++ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {
++ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });
++ }
++ if (!fedcmNonceMatches(token, nonce)) return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
++ try {
++ const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.json({ ok: true, user: data.user || claimsToUser(await verify(data.access_token)) });
++ } catch (err) {
++ if (err.status && err.status < 500) return res.status(401).json({ error: err.error || 'invalid_grant', error_description: err.error_description || err.message });
++ log.error(`[${site} auth] FedCM exchange failed: ${err.message}`);
++ return res.status(502).json({ error: 'server_error', error_description: 'OpenVibe.Network could not be reached' });
++ }
++ },
++
++ async logout(req, res) {
++ // Best-effort revocation: the Network's rotating refresh tokens invalidate themselves anyway.
++ const refresh = parseCookies(req)[REFRESH_COOKIE];
++ if (refresh) {
++ try {
++ await fetchImpl(`${networkInternalUrl}/oauth/revoke`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: opts.clientId, client_secret: opts.clientSecret, token: refresh }),
++ signal: AbortSignal.timeout(3000),
++ });
++ } catch { /* optional */ }
++ }
++ clearSession(res);
++ res.cookie(HINT_COOKIE, 'guest', hintOpts());
++ return res.redirect(next((req.query || {}).next));
++ },
++
++ async me(req, res) {
++ const claims = await verify(extractToken(req));
++ if (!claims) return res.status(401).json({ error: 'Not signed in' });
++ return res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
++ },
++
++ async refresh(req, res) {
++ const refresh = parseCookies(req)[REFRESH_COOKIE];
++ if (!refresh) return res.status(401).json({ error: 'No refresh token' });
++ try {
++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.json({ token: data.access_token, user: claimsToUser(await verify(data.access_token)) });
++ } catch (err) {
++ if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'The session ended. Please sign in again.' }); }
++ log.error(`[${site} auth] refresh failed: ${err.message}`);
++ return res.status(502).json({ error: 'OpenVibe.Network could not be reached' });
++ }
++ },
++ };
++
++ /** The /auth router. Pass your express module (the SDK does not depend on it). */
++ function router(express) {
++ if (!express || typeof express.Router !== 'function') throw new TypeError('sso.router(express): pass the express module');
++ const r = express.Router();
++ const wrap = (fn) => (req, res, nextFn) => { Promise.resolve(fn(req, res)).catch(nextFn); };
++ const json = express.json({ limit: '16kb', type: 'application/json' });
++ const badJson = (err, _req, res, nextFn) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : nextFn());
++ r.get('/login', wrap(handlers.login));
++ r.get('/callback', wrap(handlers.callback));
++ r.post('/fedcm', (req, res, nextFn) => (req.is && !req.is('application/json') ? res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' }) : nextFn()), json, badJson, wrap(handlers.fedcm));
++ r.get('/logout', wrap(handlers.logout));
++ r.get('/me', wrap(handlers.me));
++ r.post('/refresh', wrap(handlers.refresh));
++ return r;
++ }
++
++ /** req.user (claims without the registered ones) and req.token when a valid session is presented; never blocks. */
++ function optionalAuth() {
++ return (req, _res, nextFn) => {
++ const token = extractToken(req);
++ if (!token) return nextFn();
++ verify(token).then((claims) => { if (claims) { req.user = claimsToUser(claims); req.token = token; } nextFn(); }, () => nextFn());
++ };
++ }
++
++ /** 401 JSON without a valid session; otherwise req.user and req.token as optionalAuth sets them. */
++ function requireAuth() {
++ const opt = optionalAuth();
++ return (req, res, nextFn) => opt(req, res, () => (req.user ? nextFn() : res.status(401).json({ error: 'Sign in required' })));
++ }
++
++ return { router, handlers, optionalAuth, requireAuth, verify, extractToken, authorizeUrl, tokenGrant, redirectUri, audience };
++}
++
++module.exports = {
++ createSsoClient, sanitizeNext, withParam, decodeJwtPayload, fedcmNonceMatches, claimsToUser, parseCookies,
++ COOKIES: Object.freeze({ access: ACCESS_COOKIE, refresh: REFRESH_COOKIE, hint: HINT_COOKIE }),
++};
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 919b10d..894f663 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,12 +76,12 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./cache', './db', './events', './govern', './identity', './limits', './openre', './placement', './pubsub', './queue', './testing', './valkey', './vip']);
++ assert.deepEqual(blocked.sort(), ['./cache', './db', './events', './govern', './identity', './limits', './openre', './placement', './pubsub', './queue', './sso', './testing', './valkey', './vip']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
}
-- for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/testing/index.js']) {
++ for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/sso/index.js', 'src/service-outbox.js', 'src/testing/index.js']) {
assert.ok(!rel.includes(never), `${never} is not reachable from a browser entry`);
}
console.log(` scanned ${scanned.size} files`);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 3138d6b..badc9e7 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 30);
++ assert.equal(subpaths.length, 31);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/service-outbox.test.js b/test/service-outbox.test.js
nnew file mode 100644
iindex 0000000..59289b6
---- /dev/null
++++ b/test/service-outbox.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++/** createServiceOutbox: the per-service outbox wrapper, against PGlite and the mock platform. */
++const assert = require('node:assert/strict');
++const { run } = require('./helpers');
++const { createServiceOutbox } = require('../src/events');
++const { outboxSchema } = require('../src/events');
++const { createMockPlatform } = require('../src/testing');
++const { createDb, sql } = require('../src/db');
++
++const actor = { type: 'service', id: 'trade' };
++const subject = { type: 'instrument', id: 'AAPL' };
++const platform = () => createMockPlatform({ clients: { trade: { secret: 'trade-secret', grants: [['events.event.publish', 'openvibe.events']] } } });
++
++async function open(table = 'event_outbox') {
++ const db = createDb({ pglite: true });
++ await db.query(`CREATE TABLE changes (id INTEGER PRIMARY KEY); ${outboxSchema(table)}`);
++ return db;
++}
++const make = (db, p, over = {}) => createServiceOutbox({
++ db, source: 'trade', eventsUrl: 'https://events.openvibe.network', networkInternalUrl: 'https://openvibe.network',
++ clientId: 'trade', clientSecret: 'trade-secret', fetch: p.fetch, log: { warn() {}, log() {} }, ...over,
++});
++
++run([
++ ['emit joins the change\'s transaction: a rollback leaves nothing, a commit publishes once', async () => {
++ const db = await open(); const p = platform();
++ try {
++ const out = make(db, p);
++ assert.equal(out.enabled, true);
++ await assert.rejects(db.tx(async (t) => { await t.exec(sql`INSERT INTO changes (id) VALUES (1)`); await out.emitIn(t, { event_type: 'trade.observation.created', actor, subject }); throw new Error('boom'); }), /boom/);
++ assert.equal((await out.status()).pending, 0);
++ const env = await db.tx(async (t) => { await t.exec(sql`INSERT INTO changes (id) VALUES (2)`); return out.emitIn(t, { event_type: 'trade.observation.created', actor, subject }); });
++ assert.ok(env.event_id);
++ assert.deepEqual(await out.outbox.flush(), { sent: 1, failed: 0, rejected: 0 });
++ assert.equal(p.state.events.length, 1);
++ assert.equal(p.state.events[0].event.source, 'trade');
++ const s = await out.status();
++ assert.deepEqual([s.enabled, s.pending, s.rejected, s.last_error], [true, 0, 0, null]);
++ } finally { await db.close(); }
++ }],
++ ['without the events URL or the client secret the relay is off and rows wait', async () => {
++ const db = await open(); const p = platform();
++ try {
++ const out = make(db, p, { clientSecret: null });
++ assert.equal(out.enabled, false);
++ await db.tx((t) => out.emitIn(t, { event_type: 'trade.observation.created', actor, subject }));
++ out.start(); await out.kick(); out.stop();
++ const s = await out.status();
++ assert.deepEqual([s.enabled, s.pending], [false, 1]);
++ assert.equal(p.state.events.length, 0);
++ } finally { await db.close(); }
++ }],
++ ['eventTypes refuses an undeclared type; validate refuses a malformed envelope', async () => {
++ const db = await open(); const p = platform();
++ try {
++ const out = make(db, p, { eventTypes: ['trade.observation.created'] });
++ await assert.rejects(db.tx((t) => out.emitIn(t, { event_type: 'trade.other', actor, subject })), /undeclared event type trade.other/);
++ const strict = make(db, p, { validate: (env) => (env.subject && env.subject.id ? { valid: true } : { valid: false, errors: [{ path: '/subject/id', message: 'is required' }] }) });
++ await assert.rejects(db.tx((t) => strict.emitIn(t, { event_type: 'trade.observation.created', actor, subject: { type: 'instrument' } })), /invalid envelope for trade.observation.created: \/subject\/id is required/);
++ await db.tx((t) => strict.emitIn(t, { event_type: 'trade.observation.created', actor, subject }));
++ assert.equal((await strict.status()).pending, 1);
++ } finally { await db.close(); }
++ }],
++ ['moderationAction is <source>.moderation.action; owner_subject can be withheld', async () => {
++ const db = await open(); const p = platform();
++ try {
++ const out = make(db, p);
++ await db.tx(async () => out.moderationAction({ action: 'hide', target: { type: 'post', id: 9, owner_subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' }, actorSubject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR', reason: 'spam' }));
++ const noOwner = make(db, p, { moderationOwnerSubject: false });
++ await db.tx(async () => noOwner.moderationAction({ action: 'hide', target: { type: 'post', id: 10, owner_subject: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' } }));
++ await out.outbox.flush();
++ const [a, b] = p.state.events.map((e) => e.event);
++ assert.equal(a.event_type, 'trade.moderation.action');
++ assert.deepEqual([a.payload.target.owner_subject, a.actor.type, a.subject.id], ['usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', 'user', 'post:9']);
++ assert.deepEqual([b.payload.target.owner_subject, b.actor], [null, { type: 'service', id: 'trade' }]);
++ } finally { await db.close(); }
++ }],
++ ['a service with its own table name; misuse is a TypeError', async () => {
++ const db = await open('tips_event_outbox'); const p = platform();
++ try {
++ const out = make(db, p, { table: 'tips_event_outbox' });
++ await db.tx((t) => out.emitIn(t, { event_type: 'trade.observation.created', actor, subject }));
++ assert.equal(await db.value(sql`SELECT count(*)::int FROM tips_event_outbox`), 1);
++ } finally { await db.close(); }
++ assert.throws(() => createServiceOutbox({ source: 'x' }), TypeError);
++ assert.throws(() => createServiceOutbox({ db: {}, source: 'Bad Name' }), TypeError);
++ }],
++]);
ddiff --git a/test/sso.test.js b/test/sso.test.js
nnew file mode 100644
iindex 0000000..a65f94e
---- /dev/null
++++ b/test/sso.test.js
@@@ -0,0 +1,144 @@
++'use strict';
++/** openvibe-sdk/sso: the site sign-in layer against a stub Network (token grants, revoke, JWKS). */
++const assert = require('node:assert/strict');
++const crypto = require('node:crypto');
++const http = require('node:http');
++const { run } = require('./helpers');
++const { createSsoClient, sanitizeNext, withParam, fedcmNonceMatches } = require('../src/sso');
++
++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++const ISS = 'https://openvibe.network';
++const now = () => Math.floor(Date.now() / 1000);
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++const sign = (claims) => { const input = `${b64({ alg: 'RS256', typ: 'JWT', kid: 'k1' })}.${b64(claims)}`; return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), keys.privateKey).toString('base64url')}`; };
++const session = (over = {}) => sign({ sub: 7, id: 7, username: 'ana', iss: ISS, aud: ['openvibe.network', 'openvibe.live'], iat: now(), exp: now() + 600, ...over });
++
++// A stub Network: /oauth/token (code, refresh, jwt-bearer), /oauth/revoke, the JWKS.
++const net = { grants: [], revoked: [], refuseRefresh: false };
++const server = http.createServer((req, res) => {
++ let raw = ''; req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const send = (status, body) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(body)); };
++ if (req.url === '/api/.well-known/jwks') return send(200, { keys: [{ ...keys.publicKey.export({ format: 'jwk' }), kid: 'k1', use: 'sig', alg: 'RS256' }] });
++ const body = raw ? JSON.parse(raw) : {};
++ if (req.url === '/oauth/revoke') { net.revoked.push(body.token); return send(200, { revoked: true }); }
++ if (req.url === '/oauth/token') {
++ net.grants.push(body);
++ if (body.client_secret !== 'secret') return send(401, { error: 'invalid_client' });
++ if (body.grant_type === 'refresh_token' && net.refuseRefresh) return send(400, { error: 'invalid_grant' });
++ return send(200, { access_token: session(), refresh_token: `rt_${net.grants.length}`, token_type: 'Bearer' });
++ }
++ return send(404, {});
++ });
++});
++
++function fakeRes() {
++ const r = { cookies: {}, cleared: [], statusCode: 200 };
++ r.cookie = (k, v, o) => { r.cookies[k] = { v, o }; return r; };
++ r.clearCookie = (k) => { r.cleared.push(k); return r; };
++ r.redirect = (u) => { r.statusCode = 302; r.location = u; return r; };
++ r.status = (s) => { r.statusCode = s; return r; };
++ r.json = (b) => { r.body = b; return r; };
++ r.send = (b) => { r.body = b; return r; };
++ return r;
++}
++const cookieHeader = (o) => Object.entries(o).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ');
++
++let sso, base;
++run([
++ ['next: same-site paths, this site and the Network only; control characters and backslashes go home', async () => {
++ const o = { baseUrl: 'https://openvibe.trade', networkUrl: ISS };
++ assert.equal(sanitizeNext('/a/b?c=1', o), '/a/b?c=1');
++ for (const bad of ['//evil.example', '/\\evil.example', '/\t/evil.example', 'https://evil.example/', 'http://openvibe.trade/x', 'javascript:alert(1)', '', null]) assert.equal(sanitizeNext(bad, o), '/', String(bad));
++ assert.equal(sanitizeNext('https://openvibe.trade/x', o), 'https://openvibe.trade/x');
++ assert.equal(sanitizeNext('https://openvibe.network/oauth/authorize?x=1', o), 'https://openvibe.network/oauth/authorize?x=1');
++ assert.equal(withParam('/a?b=1#h', 'sso', 'none'), '/a?b=1&sso=none#h');
++ }],
++
++ ['login: state + PKCE (S256), silent is prompt=none, next is sanitized', async () => {
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ base = `http://127.0.0.1:${server.address().port}`;
++ sso = createSsoClient({ site: 'trade', baseUrl: 'https://openvibe.trade', clientId: 'trade', clientSecret: 'secret', networkUrl: ISS, networkInternalUrl: base, issuer: ISS, log: { warn() {}, error() {}, info() {} } });
++ const res = fakeRes();
++ await sso.handlers.login({ query: { next: '//evil.example' }, headers: {} }, res);
++ const u = new URL(res.location);
++ assert.equal(u.origin + u.pathname, `${ISS}/oauth/authorize`);
++ assert.deepEqual([u.searchParams.get('client_id'), u.searchParams.get('redirect_uri'), u.searchParams.get('code_challenge_method')], ['trade', 'https://openvibe.trade/auth/callback', 'S256']);
++ assert.equal(u.searchParams.get('state'), res.cookies.ov_oauth_state.v);
++ assert.equal(u.searchParams.get('code_challenge'), crypto.createHash('sha256').update(res.cookies.ov_oauth_verifier.v).digest('base64url'), 'the challenge is S256 of the stored verifier');
++ assert.equal(res.cookies.ov_oauth_state.o.httpOnly, true); assert.equal(res.cookies.ov_oauth_state.o.path, '/auth');
++ assert.ok(!res.cookies.ov_oauth_next, 'an unsafe next is not kept');
++ const silent = fakeRes();
++ await sso.handlers.login({ query: { silent: '1', next: '/deals' }, headers: {} }, silent);
++ assert.equal(new URL(silent.location).searchParams.get('prompt'), 'none');
++ assert.equal(silent.cookies.ov_oauth_next.v, '/deals');
++ }],
++
++ ['callback: the state cookie is required and must match; the code is exchanged with the secret and the PKCE verifier', async () => {
++ const noCookie = fakeRes();
++ await sso.handlers.callback({ query: { code: 'c', state: 's' }, headers: {} }, noCookie);
++ assert.equal(noCookie.statusCode, 400, 'no state cookie');
++ const mismatch = fakeRes();
++ await sso.handlers.callback({ query: { code: 'c', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 't', ov_oauth_verifier: 'v' }) } }, mismatch);
++ assert.equal(mismatch.statusCode, 400, 'another state');
++ const ok = fakeRes();
++ await sso.handlers.callback({ query: { code: 'c1', state: 's' }, headers: { cookie: cookieHeader({ ov_oauth_state: 's', ov_oauth_verifier: 'ver', ov_oauth_next: '/deals' }) } }, ok);
++ assert.equal(ok.location, '/deals');
++ const g = net.grants.at(-1);
++ assert.deepEqual([g.grant_type, g.code, g.code_verifier, g.client_secret, g.redirect_uri], ['authorization_code', 'c1', 'ver', 'secret', 'https://openvibe.trade/auth/callback']);
++ assert.ok(ok.cookies.ov_token && ok.cookies.ov_refresh && ok.cookies.ov_sso_hint.v === 'account');
++ assert.equal(ok.cookies.ov_token.o.httpOnly, false, 'the shared navbar reads the access token');
++ assert.equal(ok.cookies.ov_refresh.o.httpOnly, true); assert.equal(ok.cookies.ov_refresh.o.path, '/auth');
++ const quiet = fakeRes();
++ await sso.handlers.callback({ query: { error: 'login_required' }, headers: { cookie: cookieHeader({ ov_oauth_silent: '1', ov_oauth_next: '/x' }) } }, quiet);
++ assert.equal(quiet.location, '/x?sso=none', 'a silent sign-in with no Network session goes back quietly');
++ }],
++
++ ['me and the middleware: a session verifies offline; a FedCM assertion, a service token, an expired token do not', async () => {
++ const me = fakeRes();
++ await sso.handlers.me({ headers: { cookie: cookieHeader({ ov_token: session() }) } }, me);
++ assert.equal(me.statusCode, 200); assert.equal(me.body.user.username, 'ana'); assert.equal(me.body.user.iss, undefined);
++ for (const [what, tok] of [['a FedCM assertion', session({ typ: 'fedcm', nonce: 'n' })], ['a service token', session({ sub: 'svc:live', actor_type: 'service' })], ['expired', session({ exp: now() - 600 })], ['another audience', session({ aud: ['openvibe.elsewhere'] })]]) {
++ const r = fakeRes();
++ await sso.handlers.me({ headers: { authorization: `Bearer ${tok}` } }, r);
++ assert.equal(r.statusCode, 401, what);
++ }
++ const req = { headers: { authorization: `Bearer ${session()}` } };
++ await new Promise((done) => sso.optionalAuth()(req, {}, done));
++ assert.equal(req.user.username, 'ana');
++ const denied = fakeRes();
++ await new Promise((done) => { sso.requireAuth()({ headers: {} }, denied, done); setTimeout(done, 50); });
++ assert.equal(denied.statusCode, 401);
++ }],
++
++ ['fedcm: the nonce is pre-checked; a matching assertion is swapped for a session', async () => {
++ const assertion = session({ typ: 'fedcm', nonce: 'n-123' });
++ assert.equal(fedcmNonceMatches(assertion, 'n-123'), true);
++ const bad = fakeRes();
++ await sso.handlers.fedcm({ body: { token: assertion, nonce: 'other' }, headers: {} }, bad);
++ assert.equal(bad.statusCode, 400);
++ const ok = fakeRes();
++ await sso.handlers.fedcm({ body: { token: assertion, nonce: 'n-123' }, headers: {} }, ok);
++ assert.equal(ok.body.ok, true); assert.equal(net.grants.at(-1).grant_type, 'urn:ietf:params:oauth:grant-type:jwt-bearer');
++ assert.ok(ok.cookies.ov_token);
++ }],
++
++ ['refresh rotates; a refused refresh ends the session; logout revokes and hints guest', async () => {
++ const r = fakeRes();
++ await sso.handlers.refresh({ headers: { cookie: cookieHeader({ ov_refresh: 'rt_a' }) } }, r);
++ assert.equal(r.statusCode, 200); assert.ok(r.body.token); assert.equal(net.grants.at(-1).refresh_token, 'rt_a');
++ net.refuseRefresh = true;
++ const refused = fakeRes();
++ await sso.handlers.refresh({ headers: { cookie: cookieHeader({ ov_refresh: 'rt_b' }) } }, refused);
++ assert.equal(refused.statusCode, 401); assert.ok(refused.cleared.includes('ov_token') && refused.cleared.includes('ov_refresh'));
++ const out = fakeRes();
++ await sso.handlers.logout({ query: { next: 'https://evil.example' }, headers: { cookie: cookieHeader({ ov_refresh: 'rt_c' }) } }, out);
++ assert.deepEqual(net.revoked, ['rt_c']); assert.equal(out.cookies.ov_sso_hint.v, 'guest'); assert.equal(out.location, '/');
++ server.close();
++ }],
++
++ ['misuse: baseUrl and clientId are required; router() needs the express module', async () => {
++ assert.throws(() => createSsoClient({ clientId: 'x' }), TypeError);
++ assert.throws(() => sso.router(null), TypeError);
++ }],
++]);
ddiff --git a/types/events.d.ts b/types/events.d.ts
iindex fe3193e..72231b4 100644
---- a/types/events.d.ts
++++ b/types/events.d.ts
@@@ -173,4 +173,41 @@ export interface PgInbox {
export declare function createPgInbox(db: import('./db').Db, opts?: { table?: string; now?: () => number }): PgInbox;
/** DDL for a service migration. */
export declare function outboxSchema(table?: string): string;
++export interface ServiceOutboxOptions {
++ db: import('./db').Db;
++ /** The service id and event source, e.g. 'trade'. */
++ source: string;
++ /** The relay runs only with eventsUrl and clientSecret; otherwise rows wait and status().enabled is false. */
++ eventsUrl?: string | null;
++ networkInternalUrl?: string;
++ clientId?: string | null;
++ clientSecret?: string | null;
++ table?: string;
++ intervalMs?: number;
++ now?: () => number;
++ fetch?: import('./core').FetchLike;
++ log?: Pick<Console, 'warn' | 'log'>;
++ /** emit refuses a type not in this list. */
++ eventTypes?: string[] | null;
++ /** emit refuses an envelope this rejects (e.g. openvibe-contracts validate). */
++ validate?: ((envelope: EventEnvelope) => { valid: boolean; errors?: Array<{ path?: string; message?: string }> }) | null;
++ autoDiscover?: boolean;
++ /** false: <source>.moderation.action withholds target.owner_subject. */
++ moderationOwnerSubject?: boolean;
++}
++export interface ServiceOutbox {
++ /** Inside the change's ambient openvibe-sdk/db transaction. */
++ emit(envelope: EventInput, opts?: { traceparent?: string }): Promise<EventEnvelope>;
++ emitIn(t: import('./db').Tx, envelope: EventInput, opts?: { traceparent?: string }): Promise<EventEnvelope>;
++ /** <source>.moderation.action. */
++ moderationAction(input: { action: string; target: { type: string; id: string | number; owner_subject?: string | null; [k: string]: unknown }; actorSubject?: string | null; reason?: string | null; details?: Record<string, unknown> }, opts?: { traceparent?: string }): Promise<EventEnvelope>;
++ outbox: PgOutbox;
++ events: EventsClient;
++ enabled: boolean;
++ start(): void;
++ stop(): Promise<unknown>;
++ kick(): Promise<void>;
++ status(): Promise<{ enabled: boolean; pending: number | null; rejected: number | null; last_error: string | null }>;
++}
++export declare function createServiceOutbox(opts: ServiceOutboxOptions): ServiceOutbox;
export declare function inboxSchema(table?: string): string;
ddiff --git a/types/sso.d.ts b/types/sso.d.ts
nnew file mode 100644
iindex 0000000..713a1fd
---- /dev/null
++++ b/types/sso.d.ts
@@@ -0,0 +1,64 @@
++import type { IncomingMessage, ServerResponse } from 'node:http';
++import type { FetchLike } from './core';
++import type { UserTokenClaims } from './auth';
++
++type Req = IncomingMessage & Record<string, any>;
++type Res = ServerResponse & Record<string, any>;
++type Next = (err?: unknown) => void;
++type Handler = (req: Req, res: Res, next?: Next) => unknown;
++
++export interface SsoClientOptions {
++ /** Log prefix and the default extra audience openvibe.<site>. */
++ site: string;
++ /** This site's origin (post-sign-in targets, the redirect URI). */
++ baseUrl: string;
++ clientId: string;
++ clientSecret: string;
++ networkUrl?: string;
++ /** Token grants try this first, then networkUrl. */
++ networkInternalUrl?: string;
++ issuer?: string;
++ /** Default <baseUrl>/auth/callback. */
++ redirectUri?: string;
++ scope?: string;
++ /** Default ['openvibe.network', 'openvibe.<site>']. */
++ audience?: string | string[];
++ /** A pinned PEM instead of the JWKS. */
++ publicKey?: string;
++ secureCookies?: boolean;
++ accessMaxAgeMs?: number;
++ fetch?: FetchLike;
++ log?: Pick<Console, 'warn' | 'log'>;
++}
++
++/** The verified session's claims without iat/exp/aud/iss/nbf/jti. */
++export type SsoUser = Omit<UserTokenClaims, 'iat' | 'exp' | 'aud' | 'iss' | 'nbf' | 'jti'> & Record<string, unknown>;
++
++export interface SsoClient {
++ /** GET /login /callback /logout /me, POST /fedcm /refresh; mount at /auth. */
++ router(express: any): any;
++ handlers: { login: Handler; callback: Handler; fedcm: Handler; logout: Handler; me: Handler; refresh: Handler };
++ /** Sets req.user and req.token when a valid session is presented. */
++ optionalAuth(): (req: Req, res: Res, next: Next) => void;
++ /** 401 JSON without a valid session. */
++ requireAuth(): (req: Req, res: Res, next: Next) => void;
++ /** The verified claims, or null. */
++ verify(token: string | null | undefined): Promise<UserTokenClaims | null>;
++ /** A Bearer header, else the ov_token cookie. */
++ extractToken(req: Req): string | null;
++ authorizeUrl(opts?: { silent?: boolean }): { url: string; state: string; verifier: string };
++ tokenGrant(body: Record<string, unknown>): Promise<Record<string, any>>;
++ redirectUri: string;
++ audience: string | string[];
++}
++
++export declare function createSsoClient(opts: SsoClientOptions): SsoClient;
++/** A same-site path, this site's https origin or the Network's; anything else (control characters, backslashes) is '/'. */
++export declare function sanitizeNext(next: unknown, opts?: { baseUrl?: string; networkUrl?: string }): string;
++export declare function withParam(target: string, key: string, value: string): string;
++/** The payload of a JWT, unverified; null when it does not parse. */
++export declare function decodeJwtPayload(token: string): Record<string, any> | null;
++export declare function fedcmNonceMatches(token: string, nonce: string): boolean;
++export declare function claimsToUser(claims: Record<string, any> | null): SsoUser | null;
++export declare function parseCookies(req: { headers?: Record<string, any> }): Record<string, string>;
++export declare const COOKIES: Readonly<{ access: string; refresh: string; hint: string }>;