openvibers/openvibe.host Commits
Host on the service kit (plan T1): sign-in is openvibe-sdk/sso createSsoClient (state + PKCE S256, server-side exchange, next limited to this site and the Network), service tokens are checked by openvibe-sdk/auth verifyServiceToken (the key their kid names; sandbox tokens still confined to sandbox projects), the outbox is createServiceOutbox with Host's event-type list; server/auth/sso.js is deleted. Pins: openvibe-sdk v0.25.0, openvibe-contracts v0.79.0, openvibe-shared v2.0.0. Both suites green (verified).
Changes
@@@ -49,10 +49,10 @@ Stage A adds a few safety rules of its own:## Depends on--- OpenVibe.Contracts (`openvibe-contracts` v0.76.0; `host.*` capabilities and the `host` manifest were released in v0.24.0, and v0.32.0 adds the takedown routes to `host.site.manage`): service manifests (vhost rendering, snapshots, the first-party domain list), ids, problem+json, service-token verification, capability checks.++- OpenVibe.Contracts (`openvibe-contracts` v0.79.0; `host.*` capabilities and the `host` manifest were released in v0.24.0, and v0.32.0 adds the takedown routes to `host.site.manage`): service manifests (vhost rendering, snapshots, the first-party domain list), ids, problem+json, service-token verification, capability checks.- OpenVibe.Network (Stage B): SSO for the dashboard, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay.--- OpenVibe.Events (Stage B): `host.*` events through the `openvibe-sdk` v0.20.3 transactional outbox (openvibe-sdk/limits for the per-actor limits).--- `openvibe-shared` v1.27.0 (Stage B): shared chrome, legal pages, `/release.json`, `/metrics`, `/api/ready`.++- OpenVibe.Events (Stage B): `host.*` events through the `openvibe-sdk` v0.25.0 transactional outbox (openvibe-sdk/limits for the per-actor limits).++- `openvibe-shared` v2.0.0 (Stage B): shared chrome, legal pages, `/release.json`, `/metrics`, `/api/ready`.- OpenVibe.Media: not yet. The roadmap stores artifacts "through Media where practical"; Stage B keeps them on local disk for now (see [Not done yet](#not-done-yet-stage-b)).## Capabilities@@@ -309,7 +309,7 @@ Activation is one PostgreSQL transaction: a compare-and-set on the site's `activ### Events--Through the `openvibe-sdk` v0.20.3 transactional outbox (`event_outbox`), inside the transaction that makes the change:++Through the `openvibe-sdk` v0.25.0 transactional outbox (`event_outbox`), inside the transaction that makes the change:| Event | When | Payload ||---|---|---|@@@ -324,7 +324,7 @@ These tenant events have subject `deploy` and visibility `internal`; `host.deplo### Dashboard--Server-rendered pages with the shared chrome (`openvibe-shared` v1.27.0: navbar and theme loader from the Network, `<noscript>` navigation, SSR footer, app icon, legal pages). Every action is a plain form, so it works without JavaScript: projects, quotas and usage, members, sites, folder or archive upload, deploys with activate/rollback/delete, activation history, upload logs and file lists, domains with their DNS records and a "check DNS now" button. Pages are `private, no-store`, `noindex`, `frame-ancestors 'none'`.++Server-rendered pages with the shared chrome (`openvibe-shared` v2.0.0: navbar and theme loader from the Network, `<noscript>` navigation, SSR footer, app icon, legal pages). Every action is a plain form, so it works without JavaScript: projects, quotas and usage, members, sites, folder or archive upload, deploys with activate/rollback/delete, activation history, upload logs and file lists, domains with their DNS records and a "check DNS now" button. Pages are `private, no-store`, `noindex`, `frame-ancestors 'none'`.### Observability@@@ -335,7 +335,7 @@ Server-rendered pages with the shared chrome (`openvibe-shared` v1.27.0: navbar- OpenVibe.Network OAuth client **`host`**, redirect `https://openvibe.host/auth/callback`, scope `profile theme`. The same client is the service principal `svc:host`.- Grant `[host, events.event.publish, openvibe.events]`.- Callers of Host get `[<client>, host.site.manage | host.deploy.create | host.domain.manage, openvibe.host]` as needed. None exist yet (Codes, the expected first caller, is not built).--- Released in `openvibe-contracts` v0.76.0 (CI contract check blocking); v0.32.0 adds the takedown routes to `host.site.manage.implementedBy`.++- Released in `openvibe-contracts` v0.79.0 (CI contract check blocking); v0.32.0 adds the takedown routes to `host.site.manage.implementedBy`.### Deploying Stage B (for the operator)@@@ -8,11 +8,11 @@"port": 4910,"domain": "openvibe.host","tenantDomain": "*.openvibe.host",-- "contracts": "openvibe-contracts v0.76.0",++ "contracts": "openvibe-contracts v0.79.0","packages": [-- "openvibe-contracts v0.76.0",-- "openvibe-sdk v0.20.3",-- "openvibe-shared v1.27.0"++ "openvibe-contracts v0.79.0",++ "openvibe-sdk v0.25.0",++ "openvibe-shared v2.0.0"],"capabilities": ["host.site.manage",@@@ -17,9 +17,9 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.3",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0","pg": "^8.23.0"},"bin": {@@@ -1501,9 +1501,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.76.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",-- "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",++ "version": "0.79.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",++ "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1517,9 +1517,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.20.3",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.3",-- "integrity": "sha512-+g+ySveItzOBnUsw9aZZIJVKRrGuj0aO328W4N25T0Z8mY4fE6++ImvLnZ55LH2BRh0WYozC1KAVIShACm2KGQ==",++ "version": "0.25.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",++ "integrity": "sha512-8eurZLyFUtB8XN16hLUuYkah+qwS3tkvdzul5qdZkJTi0aZ4q8dq2QGWSq6pN5mtVPdJyuJ8Mo53r36lixmP1g==","license": "MIT","engines": {"node": ">=20"@@@ -1546,9 +1546,9 @@}},"node_modules/openvibe-shared": {-- "version": "1.27.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",-- "integrity": "sha512-/H7dyFw+cURcdjyfbpyY7mNc6F+Pcb9TwkVwZHR+9d3M62q70KDhZLo/gZi71eaYxUjD8lO8tmbXu70EAa9paQ==",++ "version": "2.0.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",++ "integrity": "sha512-n5CHC0hfxOo0jo/3kS4O3KiwhirhZDlx5zZTIuPyIf3Dq0yI/R6R64btx7NYwZe5dI0NjvqzEtnv19m8unwLMg==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -33,9 +33,9 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.3",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0","pg": "^8.23.0"},"engines": {@@@ -30,7 +30,7 @@ const { createProjects } = require('./domain/projects');const { createSites } = require('./domain/sites');const { createDeploys } = require('./domain/deploys');const { createDomains } = require('./domain/domains');--const { createAuthClient, createAuthRoutes } = require('./auth/sso');++const { createSsoClient } = require('openvibe-sdk/sso');const { createViewerResolver } = require('./auth/viewer');const { createTenantServer } = require('./http/tenant');const { createUploadGate } = require('./http/upload');@@@ -62,7 +62,7 @@ const DASHBOARD_CSP = {const DASHBOARD_CSP_HEADER = Object.entries(DASHBOARD_CSP).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ');/**-- * opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object),++ * opts: config, store | dbPath, now (clock), fetchImpl, auth (an openvibe-sdk/sso client),* resolver ({ resolveTxt }), log*/async function createApp(opts = {}) {@@@ -80,8 +80,21 @@ async function createApp(opts = {}) {const sites = createSites({ store, config, access, projects, takedowns });const deploys = createDeploys({ store, config, access, projects, sites, blobs, outbox, takedowns, log });const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log });-- const auth = opts.auth || createAuthClient(config);-- const viewers = createViewerResolver({ auth, config });++ const auth = opts.auth || createSsoClient({++ site: 'host',++ baseUrl: config.baseUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ redirectUri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ networkUrl: config.networkUrl,++ networkInternalUrl: config.networkInternalUrl,++ issuer: config.networkUrl,++ secureCookies: config.cookies.secure,++ log,++ ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}),++ });++ const viewers = createViewerResolver({ auth, config, log });const tenant = createTenantServer({ store, config, blobs, takedowns, log });const uploadGate = createUploadGate(config.uploads.maxConcurrent);const worker = createWorker({ config, store, domains, blobs, outbox, log });@@@ -146,7 +159,7 @@ async function createApp(opts = {}) {release.mount(app, { registry: registry });// GET /limits.json: the developer limits enforced here, from config (WS-N task 7; Codes renders them).require('./limits').mountLimits(app, config);-- const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release, minFreeBytes: () => config.uploads.minFreeBytes, valkey: ctx.valkey });++ const readiness = createHostReadiness({ store, blobs, outbox, jwksUrl: `${config.networkInternalUrl}/api/.well-known/jwks`, release: release.release, minFreeBytes: () => config.uploads.minFreeBytes, valkey: ctx.valkey });app.get('/api/ready', readiness.handler);app.get('/metrics', sharedMetrics.metricsHandler(registry));// The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites@@@ -159,7 +172,7 @@ async function createApp(opts = {}) {// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));-- app.use('/auth', createAuthRoutes(config, auth));++ app.use('/auth', auth.router(express));{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'host', service: 'host', host: 'openvibe.host', name: 'OpenVibe.Host', profile: 'ugc' })); }// ── Static assets (content-hashed ?v= → immutable) ──────ddeleted file mode 100644@@@ -1,430 +0,0 @@--'use strict';----// ═══════════════════════════════════════════════════════════════--// openvibe.host dashboard — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)--//--// The session layer OpenVibe.Community and OpenVibe.Blog use, with Host's client id (OAuth--// client `host`, redirect https://openvibe.host/auth/callback), and one Host-specific change:--//--// Tenant sites are served on SUBDOMAINS of openvibe.host (<site>.openvibe.host). Any tenant page--// can set a cookie for Domain=openvibe.host, so a plain cookie on the dashboard could be planted--// by a tenant (session fixation, login CSRF). Every cookie Host trusts therefore uses the--// `__Host-` prefix when cookies are Secure: the browser only accepts such a cookie from--// openvibe.host itself, with Path=/ and no Domain. The plain `ov_token` cookie is still written--// because the shared navbar reads it to show who is signed in, but the server never trusts it.--// The API (/api/v1) ignores cookies entirely: Bearer tokens only.--//--// GET /auth/login → redirect to Network /oauth/authorize (?silent=1: prompt=none)--// GET /auth/callback → server-side code exchange, set cookies--// POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)--// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest--// GET /auth/me → offline-verify the session, return profile--// POST /auth/refresh → rotate tokens via refresh_token grant--// ═══════════════════════════════════════════════════════════════----const express = require('express');--const crypto = require('crypto');--const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');----const ACCESS_COOKIE = 'ov_token'; // display only (navbar.js); never trusted by the server--const HINT_COOKIE = 'ov_sso_hint';----/** Cookies the server trusts: `__Host-` prefixed whenever cookies are Secure. */--function cookieNames(secure) {-- const p = secure ? '__Host-' : '';-- return {-- session: `${p}ov_host_session`,-- refresh: `${p}ov_host_refresh`,-- state: `${p}ov_host_oauth_state`,-- next: `${p}ov_host_oauth_next`,-- silent: `${p}ov_host_oauth_silent`,-- };--}----/**-- * Create the auth client + JWKS fetcher shared by the whole app.-- * Verification is OFFLINE: we cache the Network's RS256 public key from-- * GET /api/.well-known/jwks and verify JWTs locally on every request.-- */--function createAuthClient(config) {-- const client = new OpenVibeAuthClient({-- clientId: config.oauth.clientId,-- clientSecret: config.oauth.clientSecret,-- redirectUri: config.oauth.redirectUri,-- publicKey: null, // filled by ensureKey()-- authBase: config.networkUrl,-- internalBase: config.networkInternalUrl,-- });---- let lastFetch = 0;-- let inflight = null;-- async function ensureKey() {-- if (client.publicKey) return client.publicKey;-- // A request that arrives while the key is being fetched waits for that fetch rather than-- // being treated as signed out.-- if (inflight) return inflight;-- // Don't hammer the Network if it's down — retry at most every 30s-- if (Date.now() - lastFetch < 30_000) return null;-- lastFetch = Date.now();-- inflight = fetchKey().finally(() => { inflight = null; });-- return inflight;-- }-- async function fetchKey() {-- for (const base of [config.networkInternalUrl, config.networkUrl]) {-- if (!base) continue;-- try {-- const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });-- if (!res.ok) continue;-- const jwks = await res.json();-- if (jwks.public_key) {-- client.publicKey = jwks.public_key;-- console.log(`[Host] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);-- return client.publicKey;-- }-- } catch (err) {-- console.warn(`[Host auth] JWKS fetch failed from ${base}: ${err.message}`);-- }-- }-- return null;-- }---- /** Offline JWT verification. Returns decoded claims or null. */-- async function verify(token) {-- if (!token) return null;-- await ensureKey();-- return client.verifyToken(token);-- }---- // Warm the key cache at boot (non-fatal if the Network is down)-- ensureKey().catch(() => {});---- return { client, ensureKey, verify };--}----/** The dashboard session: only the (prefixed) session cookie, never ov_token. */--function sessionToken(req, config) {-- return req.cookies?.[cookieNames(config.cookies.secure).session] || null;--}----/** API callers: only the Authorization header. */--function bearerToken(req) {-- const h = String(req.headers.authorization || '');-- return h.startsWith('Bearer ') ? h.slice(7).trim() || null : null;--}----/** Strip registered claims — what the site shows as "the user". */--function claimsToUser(claims) {-- if (!claims) return null;-- const { iat, exp, aud, iss, nbf, jti, ...user } = claims;-- return user;--}----/**-- * Decode a JWT's payload WITHOUT checking its signature. Only for reading claims-- * we cross-check locally (the FedCM nonce) before the Network — which does verify-- * the signature — sees the assertion. Never use it to trust an identity.-- */--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. */--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);--}----/**-- * Only allow same-site relative paths, this site's own https origin, or an-- * https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.-- */--function sanitizeNext(next, config) {-- // Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would-- // leave the site. A next with any control character or backslash goes home.-- if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';-- if (!next || typeof next !== 'string') return '/';-- if (/^\/(?!\/|\\)/.test(next)) return next; // relative path, not protocol-relative-- try {-- const u = new URL(next);-- if (u.protocol !== 'https:') return '/';-- const allowed = [config.baseUrl, config.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/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;-- const sep = base.includes('?') ? '&' : '?';-- return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;--}----/**-- * Build the Network authorize URL for a login. Exported (and pure) so tests can-- * cover the silent-login shape without a browser.-- */--function buildAuthorizeUrl(config, auth, { silent = false } = {}) {-- const { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);-- if (!silent) return { url, state };-- // ?silent=1: continue as the account openvibe.network already knows (no chooser); if the-- // network has no session either, it bounces back with error=login_required and we stay quiet.-- const u = new URL(url);-- u.searchParams.set('prompt', 'none');-- return { url: u.toString(), state };--}----function createAuthRoutes(config, auth) {-- const router = express.Router();---- const C = cookieNames(config.cookies.secure);-- const STATE_COOKIE = C.state;-- const NEXT_COOKIE = C.next;-- const SILENT_COOKIE = C.silent;-- const REFRESH_COOKIE = C.refresh;---- const accessCookieOpts = () => ({-- sameSite: 'lax',-- secure: config.cookies.secure,-- httpOnly: false, // JS-readable by design: the shared navbar reads it (display only)-- path: '/',-- maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT-- });---- const sessionCookieOpts = () => ({-- sameSite: 'lax',-- secure: config.cookies.secure,-- httpOnly: true,-- path: '/', // __Host- requires Path=/ and no Domain-- maxAge: 24 * 60 * 60 * 1000,-- });---- const refreshCookieOpts = () => ({ ...sessionCookieOpts(), maxAge: 30 * 24 * 60 * 60 * 1000 });---- const flowCookieOpts = () => ({ ...sessionCookieOpts(), maxAge: 10 * 60 * 1000 });---- const hintCookieOpts = () => ({-- sameSite: 'lax',-- secure: config.cookies.secure,-- httpOnly: false, // navbar.js reads it to decide whether a silent login is worth trying-- path: '/',-- maxAge: 365 * 24 * 60 * 60 * 1000,-- });---- function setSessionCookies(res, accessToken, refreshToken) {-- res.cookie(C.session, accessToken, sessionCookieOpts());-- res.cookie(ACCESS_COOKIE, accessToken, accessCookieOpts());-- if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());-- res.cookie(HINT_COOKIE, 'account', hintCookieOpts());-- }---- function clearSessionCookies(res) {-- res.clearCookie(C.session, { ...sessionCookieOpts(), maxAge: undefined });-- res.clearCookie(ACCESS_COOKIE, { ...accessCookieOpts(), maxAge: undefined });-- res.clearCookie(REFRESH_COOKIE, { ...refreshCookieOpts(), maxAge: undefined });-- }---- function clearFlowCookies(res) {-- for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { ...flowCookieOpts(), maxAge: undefined });-- }---- /** Exchange at the Network — internal URL first, public as fallback. */-- async function tokenGrant(body) {-- let lastErr = null;-- for (const base of [config.networkInternalUrl, config.networkUrl]) {-- if (!base) continue;-- try {-- const res = await fetch(`${base}/oauth/token`, {-- method: 'POST',-- headers: { 'Content-Type': 'application/json' },-- body: JSON.stringify({-- client_id: config.oauth.clientId,-- client_secret: config.oauth.clientSecret,-- ...body,-- }),-- signal: AbortSignal.timeout(10_000),-- });-- const data = await res.json().catch(() => ({}));-- if (!res.ok) {-- const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);-- err.status = res.status;-- err.error = data.error || 'invalid_grant';-- err.error_description = data.error_description || null;-- throw err;-- }-- return data;-- } catch (err) {-- lastErr = err;-- // 4xx = the grant itself is bad; retrying against another base won't help-- if (err.status && err.status < 500) throw err;-- }-- }-- throw lastErr || new Error('Network unreachable');-- }---- // ── GET /auth/login ──────────────────────────────────────-- router.get('/login', async (req, res) => {-- const silent = !!req.query.silent && req.query.silent !== '0';-- if (silent) {-- // Already signed in here? Then the silent round trip through the Network would-- // only hand back the session we have: go straight to where the caller wanted.-- const existing = req.cookies?.[C.session];-- if (existing && await auth.verify(existing)) {-- clearFlowCookies(res);-- return res.redirect(sanitizeNext(req.query.next, config));-- }-- }-- const { url, state } = buildAuthorizeUrl(config, auth, { silent });-- res.cookie(STATE_COOKIE, state, flowCookieOpts());-- const next = sanitizeNext(req.query.next, config);-- if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());-- else res.clearCookie(NEXT_COOKIE, { ...flowCookieOpts(), maxAge: undefined });-- if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());-- else res.clearCookie(SILENT_COOKIE, { ...flowCookieOpts(), maxAge: undefined });-- res.redirect(url);-- });---- // ── GET /auth/callback ───────────────────────────────────-- router.get('/callback', async (req, res) => {-- const { code, state, error } = req.query;-- const next = sanitizeNext(req.cookies?.[NEXT_COOKIE], config);-- const silent = req.cookies?.[SILENT_COOKIE] === '1';---- if (error) {-- clearFlowCookies(res);-- // Silent sign-in found no network session — go back quietly as a guest. The-- // ?sso=none marker stops navbar.js from trying again on that page.-- if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));-- return res.redirect(withParam('/', 'auth_error', String(error)));-- }-- if (!code) return res.status(400).send('Missing authorization code');---- const expectedState = req.cookies?.[STATE_COOKIE];-- clearFlowCookies(res);-- // The state cookie is required, not just compared when present: skipping the check when-- // the cookie is missing would let a crafted link sign a visitor into someone else's account.-- if (!expectedState || !state || !crypto.timingSafeEqual(-- Buffer.from(String(state).padEnd(64).slice(0, 64)),-- Buffer.from(String(expectedState).padEnd(64).slice(0, 64))-- )) {-- return res.status(400).send('OAuth state mismatch — please try signing in again.');-- }---- try {-- const data = await tokenGrant({-- grant_type: 'authorization_code',-- redirect_uri: config.oauth.redirectUri,-- code,-- });-- setSessionCookies(res, data.access_token, data.refresh_token);-- return res.redirect(next);-- } catch (err) {-- console.error('[Host auth] Code exchange failed:', err.message);-- return res.status(502).send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');-- }-- });---- // ── POST /auth/fedcm ─────────────────────────────────────-- // The shared navbar obtained a FedCM assertion from openvibe.network (the-- // browser's own account chooser) and posts it same-origin with the nonce it-- // put in the FedCM request. We only pre-check the nonce; the Network verifies-- // the assertion's signature when it swaps it for tokens.-- const fedcmBody = express.json({ limit: '16kb', type: 'application/json' });-- const fedcmBodyError = (err, _req, res, next) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : next());-- router.post('/fedcm', fedcmBody, fedcmBodyError, async (req, res) => {-- if (!req.is('application/json')) return res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' });-- 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' });-- setSessionCookies(res, data.access_token, data.refresh_token);-- const user = data.user || claimsToUser(await auth.verify(data.access_token));-- return res.json({ ok: true, user });-- } 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 });-- }-- console.error('[Host auth] FedCM exchange failed:', err.message);-- return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });-- }-- });---- // ── GET /auth/logout ─────────────────────────────────────-- router.get('/logout', async (req, res) => {-- // Best-effort refresh revocation. The Network's rotating refresh tokens-- // self-invalidate, so a failure here is harmless.-- const refresh = req.cookies?.[REFRESH_COOKIE];-- if (refresh) {-- try {-- await fetch(`${config.networkInternalUrl}/oauth/revoke`, {-- method: 'POST',-- headers: { 'Content-Type': 'application/json' },-- body: JSON.stringify({-- client_id: config.oauth.clientId,-- client_secret: config.oauth.clientSecret,-- token: refresh,-- }),-- signal: AbortSignal.timeout(3000),-- });-- } catch { /* optional */ }-- }-- clearSessionCookies(res);-- res.cookie(HINT_COOKIE, 'guest', hintCookieOpts());-- res.redirect(sanitizeNext(req.query.next, config));-- });---- // ── GET /auth/me ─────────────────────────────────────────-- router.get('/me', async (req, res) => {-- const token = sessionToken(req, config);-- if (!token) return res.status(401).json({ error: 'Not authenticated' });-- const claims = await auth.verify(token);-- if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });-- res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });-- });---- // ── POST /auth/refresh ───────────────────────────────────-- // Small endpoint the shared navbar can call when ov_token expires.-- router.post('/refresh', async (req, res) => {-- const refresh = req.cookies?.[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 });-- setSessionCookies(res, data.access_token, data.refresh_token);-- const claims = await auth.verify(data.access_token);-- return res.json({ token: data.access_token, user: claimsToUser(claims) });-- } catch (err) {-- if (err.status && err.status < 500) {-- clearSessionCookies(res);-- return res.status(401).json({ error: 'Refresh token rejected — please sign in again' });-- }-- console.error('[Host auth] Refresh failed:', err.message);-- return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });-- }-- });---- return router;--}----module.exports = { createAuthClient, createAuthRoutes, sessionToken, bearerToken, cookieNames, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };@@@ -6,8 +6,8 @@* { kind: 'anonymous' }* { kind: 'user', subject: 'usr_…', staff, user }* API: a Network user JWT in `Authorization: Bearer` (cookies are ignored on /api/v1).-- * Dashboard: the __Host- session cookie (never the navbar's ov_token, which a tenant-- * subdomain could plant). staff = the contracts staff map's staff.site.configure (ADR-022):++ * Dashboard: the ov_token session cookie (openvibe-sdk/sso; the shared navbar reads the same cookie).++ * staff = the contracts staff map's staff.site.configure (ADR-022):* quotas, takedowns, and maintaining or deleting any project (never deploying to it).* { kind: 'service', service: 'svc:codes' | 'app:app_…', claims, subject, env }* A Network client-credentials token for audience openvibe.host. A first-party service@@@ -19,28 +19,45 @@* never downgraded to anonymous.*/const contracts = require('openvibe-contracts');--const { sessionToken, bearerToken, claimsToUser, decodeJwtPayload } = require('./sso');++const { claimsToUser, decodeJwtPayload, parseCookies, COOKIES } = require('openvibe-sdk/sso');++const { verifyServiceToken } = require('openvibe-sdk/auth');const { checkCapability } = require('./capabilities');--const { ids, serviceAuth, http, staff: staffMap } = contracts;++const { ids, http, staff: staffMap } = contracts;const PRINCIPAL_SUB = /^(svc|app|mod):/;const AUDIENCE = 'openvibe.host';++/** API callers: only the Authorization header (cookies are never read on /api/v1). */++const bearerToken = (req) => {++ const h = String(req.headers.authorization || '');++ return h.startsWith('Bearer ') ? h.slice(7).trim() || null : null;++};++class ViewerError extends Error {constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }}const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false });--function createViewerResolver({ auth, config }) {++function createViewerResolver({ auth, config, log = console }) {async function fromServiceToken(req, token) {-- const publicKey = await auth.ensureKey();-- if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');++ // The SDK supplies the key (the shared JWKS client, through a Network outage); every token rule is the++ // service's own pinned openvibe-contracts serviceAuth.verifyServiceToken.// acceptSandbox: Host takes sandbox tokens itself and confines them to sandbox projects// (projects.js/access.js answer 403 environment.sandbox_token elsewhere); openvibe-contracts// >= 0.26.0 refuses them before that unless the receiver opts in.-- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE, acceptSandbox: true });-- if (!r.ok) throw new ViewerError(401, r.code, r.reason);++ const r = await verifyServiceToken(token, {++ jwks: `${config.networkInternalUrl}/api/.well-known/jwks`,++ issuer: config.networkUrl,++ audience: AUDIENCE,++ contracts,++ acceptSandbox: true,++ log,++ });++ if (!r.ok) {++ if (r.code === 'token.unavailable') throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');++ throw new ViewerError(401, r.code, r.reason);++ }const sub = r.claims.sub;if (sub.startsWith('mod:')) throw new ViewerError(403, 'principal.not_allowed', 'mods cannot manage hosted sites');const subjectHeader = req.get('x-ov-subject');@@@ -78,7 +95,7 @@ function createViewerResolver({ auth, config }) {}async function resolveDashboard(req) {-- const token = sessionToken(req, config);++ const token = parseCookies(req)[COOKIES.access] || null;if (!token) return ANONYMOUS;return (await fromUserToken(token)) || ANONYMOUS;}@@@ -1,66 +1,37 @@'use strict';/**-- * Host → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).++ * Host → OpenVibe.Events through the openvibe-sdk service outbox (ADR-004).** host.deploy.created an upload became an immutable, ready deploy* host.deploy.activated a site's active deploy changed (payload.rollback marks a rollback)* host.deploy.failed an upload was refused (payload.code, problem codes; no file contents)* host.domain.verified a custom domain's TXT record was found; the domain is now served*-- * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only-- * if its change committed. The relay publishes with Host's service token (events.event.publish,-- * audience openvibe.events) only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise-- * rows wait in event_outbox and /api/ready reports the relay as off.++ * The SDK writes each row 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++ * Host's service token (events.event.publish, audience openvibe.events) only when EVENTS_URL and++ * OV_OAUTH_CLIENT_SECRET are set; otherwise rows wait in event_outbox and /api/ready reports the++ * relay as off. The undeclared event types below are refused by emit().*/--const { createClient } = require('openvibe-sdk/core');--const { createServiceTokenClient } = require('openvibe-sdk/auth');--const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');++const { createServiceOutbox } = require('openvibe-sdk/events');const EVENT_TYPES = Object.freeze(['host.deploy.created', 'host.deploy.activated', 'host.deploy.failed', 'host.domain.verified']);function createHostOutbox({ db, config, fetchImpl, now, log = console }) {-- const enabled = Boolean(config.events.url && config.oauth.clientSecret);-- const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0 };-- if (fetchImpl) clientOpts.fetch = fetchImpl;-- if (enabled) {-- clientOpts.tokenProvider = createServiceTokenClient({-- tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,-- scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),-- });-- } else {-- clientOpts.getToken = async () => { throw new Error('events relay disabled (Events URL or service credentials not configured)'); };-- }-- const events = createEventsClient(createClient(clientOpts), { source: 'host' });-- let lastError = null;-- // The PostgreSQL outbox: rows are written in the change's own transaction (enqueue(db, …) joins the ambient-- // transaction); several processes relay one table safely (leases).-- const outbox = createPgOutbox(db, {-- events,++ return createServiceOutbox({++ db,++ source: 'host',++ eventsUrl: config.events.url,++ networkInternalUrl: config.networkInternalUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,intervalMs: config.events.intervalMs,now,-- onError: (err) => {-- const msg = err && err.message;-- if (msg !== lastError) log.warn('[Host] event publish failed (will retry):', msg);-- lastError = msg;-- },++ log,++ eventTypes: EVENT_TYPES,++ ...(fetchImpl ? { fetch: fetchImpl } : {}),});---- /** Inside the caller's transaction. Returns the complete envelope (with its event_id). */-- async function emit(envelope, { traceparent } = {}) {-- if (!EVENT_TYPES.includes(envelope.event_type)) throw new Error(`undeclared event type ${envelope.event_type}`);-- return await outbox.enqueue(db, envelope, { traceparent });-- }---- return {-- emit,-- outbox,-- enabled,-- start() { if (enabled) outbox.start(); },-- stop: () => outbox.stop(),-- kick() { if (enabled) outbox.kick(); },-- status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),-- };}module.exports = { createHostOutbox, EVENT_TYPES };@@@ -10,9 +10,10 @@* domain_checks optional no custom domain is stuck: pending checks are running*/const { createReadiness } = require('openvibe-shared/ready');++const { jwksClient } = require('openvibe-sdk/auth');const { CHARTER_TABLES } = require('./db');--function createHostReadiness({ store, blobs, auth, outbox, release = null, minFreeBytes = () => 0, valkey = null }) {++function createHostReadiness({ store, blobs, outbox, jwksUrl = null, release = null, minFreeBytes = () => 0, valkey = null }) {const { db } = store;return createReadiness({service: 'host',@@@ -43,8 +44,10 @@ function createHostReadiness({ store, blobs, auth, outbox, release = null, minFr{name: 'network_jwks', required: false,check: () => {-- if (auth.client.publicKey) return true;-- auth.ensureKey().catch(() => {});++ if (!jwksUrl) return { skipped: 'no Network JWKS URL configured' };++ const client = jwksClient(jwksUrl);++ if (client.status().ready) return true;++ client.refresh().catch(() => {});return 'Network signing key not loaded yet: sign-in and service calls are unavailable';},},@@@ -194,7 +194,7 @@ const DAY = 24 * 3600 * 1000;assert.strictEqual(list.status, 401, list.text);const del = await t.api('DELETE', `/api/v1/projects/${project.id}`, { as: assertion });assert.strictEqual(del.status, 401, del.text);-- const dash = await t.request({ method: 'GET', host: 'openvibe.host', path: '/', headers: { cookie: `ov_host_session=${assertion}` } });++ const dash = await t.request({ method: 'GET', host: 'openvibe.host', path: '/', headers: { cookie: `ov_token=${assertion}` } });assert.ok(!/Your projects/.test(dash.text), 'the dashboard does not accept it as a session either');});@@@ -81,11 +81,16 @@ const ORIGIN = 'https://openvibe.host';for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);});-- await check('a planted ov_token cookie (e.g. set by a tenant subdomain) is not a session', async () => {-- const r = await t.api('GET', '/', { headers: { cookie: `ov_token=${t.network.userToken(alice)}` } });++ await check('the dashboard session is the verified ov_token cookie; a forged ov_token is not a session', async () => {++ // openvibe-sdk/sso keeps the session in the ov_token cookie (the shared navbar reads the same one).++ const forged = 'not.a.jwt';++ const r = await t.api('GET', '/', { headers: { cookie: `ov_token=${forged}` } });assert.match(r.text, /Sign in with OpenVibe/);-- const me = await t.api('GET', '/auth/me', { headers: { cookie: `ov_token=${t.network.userToken(alice)}` } });++ const me = await t.api('GET', '/auth/me', { headers: { cookie: `ov_token=${forged}` } });assert.strictEqual(me.status, 401);++ // /api/v1 never reads a cookie: only a Bearer token authenticates.++ const api = await t.api('GET', '/api/v1/projects', { headers: { cookie: `ov_token=${t.network.userToken(alice)}` } });++ assert.strictEqual(api.status, 401);});let projectId, siteId;@@@ -3,7 +3,7 @@// newline characters from a URL and read a backslash as "/", so "/<TAB>/evil.com" is "//evil.com" to them;// until 2026-09-26 it got through here.const assert = require('assert');--const { sanitizeNext } = require('../server/auth/sso');++const { sanitizeNext } = require('openvibe-sdk/sso');const config = { baseUrl: 'https://site.openvibe.test', networkUrl: 'https://openvibe.network' };const home = (v) => {@@@ -63,10 +63,7 @@ const { boot, check, done } = require('./stageb/boot');assert.deepStrictEqual(odd, [], 'a connection went to a tenant-named host');});-- await check('ratchet: every file in server/ that makes an outbound request itself is reviewed', () => {-- const REVIEWED = {-- 'server/auth/sso.js': 'Network JWKS, OAuth token and revoke (configured)',-- };++ await check('ratchet: no file in server/ makes an outbound request of its own (they go through openvibe-sdk)', () => {const root = path.join(__dirname, '..');const found = [];const walk = (dir) => {@@@ -80,8 +77,7 @@ const { boot, check, done } = require('./stageb/boot');}};walk(path.join(root, 'server'));-- assert.ok(found.length >= 1, `the scan finds the known site (${found.join(', ')})`);-- assert.deepStrictEqual(found.filter((f) => !REVIEWED[f]).sort(), [], 'a new outbound request site: a tenant-chosen host goes through openvibe-shared/egress; then add the file here with where it goes');++ assert.deepStrictEqual(found.sort(), [], 'a new outbound request site: a tenant-chosen host goes through openvibe-shared/egress; then add the file here with where it goes');});await t.close();@@@ -65,7 +65,6 @@ async function boot(opts = {}) {async function start() {const config = configLib.load(env);built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl });-- await built.ctx.auth.ensureKey();server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });t.port = server.address().port;t.app = built.app;@@@ -85,7 +84,7 @@ async function boot(opts = {}) {if (o.host !== undefined) headers.host = o.host;if (o.as && typeof o.as === 'object') headers.authorization = `Bearer ${network.userToken(o.as)}`;if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;-- if (o.session) headers.cookie = [headers.cookie, `ov_host_session=${network.userToken(o.session)}`].filter(Boolean).join('; ');++ if (o.session) headers.cookie = [headers.cookie, `ov_token=${network.userToken(o.session)}`].filter(Boolean).join('; ');let body = o.body;if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }@@@ -54,7 +54,7 @@ async function startNetwork() {return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };}function userToken(u) {-- return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });++ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, audience: ['openvibe.network', 'openvibe.host'], expiresIn: '1h' });}/** A FedCM ID assertion as Network's /fedcm/assertion signs it (same key and issuer, aud = the RP origin). */function fedcmAssertion(u, origin, nonce = 'n0nce') {
@@ -49,10 +49,10 @@ Stage A adds a few safety rules of its own:
@@ -49,10 +49,10 @@ Stage A adds a few safety rules of its own:
## Depends on
## Depends on
-- OpenVibe.Contracts (`openvibe-contracts` v0.76.0; `host.*` capabilities and the `host` manifest were released in v0.24.0, and v0.32.0 adds the takedown routes to `host.site.manage`): service manifests (vhost rendering, snapshots, the first-party domain list), ids, problem+json, service-token verification, capability checks.
+- OpenVibe.Contracts (`openvibe-contracts` v0.79.0; `host.*` capabilities and the `host` manifest were released in v0.24.0, and v0.32.0 adds the takedown routes to `host.site.manage`): service manifests (vhost rendering, snapshots, the first-party domain list), ids, problem+json, service-token verification, capability checks.
- OpenVibe.Network (Stage B): SSO for the dashboard, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay.
- OpenVibe.Network (Stage B): SSO for the dashboard, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay.
-- OpenVibe.Events (Stage B): `host.*` events through the `openvibe-sdk` v0.20.3 transactional outbox (openvibe-sdk/limits for the per-actor limits).
-- `openvibe-shared` v1.27.0 (Stage B): shared chrome, legal pages, `/release.json`, `/metrics`, `/api/ready`.
+- OpenVibe.Events (Stage B): `host.*` events through the `openvibe-sdk` v0.25.0 transactional outbox (openvibe-sdk/limits for the per-actor limits).
+- `openvibe-shared` v2.0.0 (Stage B): shared chrome, legal pages, `/release.json`, `/metrics`, `/api/ready`.
- OpenVibe.Media: not yet. The roadmap stores artifacts "through Media where practical"; Stage B keeps them on local disk for now (see [Not done yet](#not-done-yet-stage-b)).
- OpenVibe.Media: not yet. The roadmap stores artifacts "through Media where practical"; Stage B keeps them on local disk for now (see [Not done yet](#not-done-yet-stage-b)).
## Capabilities
## Capabilities
@@ -309,7 +309,7 @@ Activation is one PostgreSQL transaction: a compare-and-set on the site's `activ
@@ -309,7 +309,7 @@ Activation is one PostgreSQL transaction: a compare-and-set on the site's `activ
### Events
### Events
-Through the `openvibe-sdk` v0.20.3 transactional outbox (`event_outbox`), inside the transaction that makes the change:
+Through the `openvibe-sdk` v0.25.0 transactional outbox (`event_outbox`), inside the transaction that makes the change:
| Event | When | Payload |
| Event | When | Payload |
|---|---|---|
|---|---|---|
@@ -324,7 +324,7 @@ These tenant events have subject `deploy` and visibility `internal`; `host.deplo
@@ -324,7 +324,7 @@ These tenant events have subject `deploy` and visibility `internal`; `host.deplo
### Dashboard
### Dashboard
-Server-rendered pages with the shared chrome (`openvibe-shared` v1.27.0: navbar and theme loader from the Network, `<noscript>` navigation, SSR footer, app icon, legal pages). Every action is a plain form, so it works without JavaScript: projects, quotas and usage, members, sites, folder or archive upload, deploys with activate/rollback/delete, activation history, upload logs and file lists, domains with their DNS records and a "check DNS now" button. Pages are `private, no-store`, `noindex`, `frame-ancestors 'none'`.
+Server-rendered pages with the shared chrome (`openvibe-shared` v2.0.0: navbar and theme loader from the Network, `<noscript>` navigation, SSR footer, app icon, legal pages). Every action is a plain form, so it works without JavaScript: projects, quotas and usage, members, sites, folder or archive upload, deploys with activate/rollback/delete, activation history, upload logs and file lists, domains with their DNS records and a "check DNS now" button. Pages are `private, no-store`, `noindex`, `frame-ancestors 'none'`.
### Observability
### Observability
@@ -335,7 +335,7 @@ Server-rendered pages with the shared chrome (`openvibe-shared` v1.27.0: navbar
@@ -335,7 +335,7 @@ Server-rendered pages with the shared chrome (`openvibe-shared` v1.27.0: navbar
- OpenVibe.Network OAuth client **`host`**, redirect `https://openvibe.host/auth/callback`, scope `profile theme`. The same client is the service principal `svc:host`.
- OpenVibe.Network OAuth client **`host`**, redirect `https://openvibe.host/auth/callback`, scope `profile theme`. The same client is the service principal `svc:host`.
- Grant `[host, events.event.publish, openvibe.events]`.
- Grant `[host, events.event.publish, openvibe.events]`.
- Callers of Host get `[<client>, host.site.manage | host.deploy.create | host.domain.manage, openvibe.host]` as needed. None exist yet (Codes, the expected first caller, is not built).
- Callers of Host get `[<client>, host.site.manage | host.deploy.create | host.domain.manage, openvibe.host]` as needed. None exist yet (Codes, the expected first caller, is not built).
-- Released in `openvibe-contracts` v0.76.0 (CI contract check blocking); v0.32.0 adds the takedown routes to `host.site.manage.implementedBy`.
+- Released in `openvibe-contracts` v0.79.0 (CI contract check blocking); v0.32.0 adds the takedown routes to `host.site.manage.implementedBy`.
### Deploying Stage B (for the operator)
### Deploying Stage B (for the operator)
@@ -8,11 +8,11 @@
@@ -8,11 +8,11 @@
"port": 4910,
"port": 4910,
"domain": "openvibe.host",
"domain": "openvibe.host",
"tenantDomain": "*.openvibe.host",
"tenantDomain": "*.openvibe.host",
- "contracts": "openvibe-contracts v0.76.0",
+ "contracts": "openvibe-contracts v0.79.0",
"packages": [
"packages": [
- "openvibe-contracts v0.76.0",
- "openvibe-sdk v0.20.3",
- "openvibe-shared v1.27.0"
+ "openvibe-contracts v0.79.0",
+ "openvibe-sdk v0.25.0",
+ "openvibe-shared v2.0.0"
],
],
"capabilities": [
"capabilities": [
"host.site.manage",
"host.site.manage",
@@ -17,9 +17,9 @@
@@ -17,9 +17,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.3",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"bin": {
"bin": {
@@ -1501,9 +1501,9 @@
@@ -1501,9 +1501,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.76.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
- "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
+ "version": "0.79.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
+ "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1517,9 +1517,9 @@
@@ -1517,9 +1517,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.20.3",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.3",
- "integrity": "sha512-+g+ySveItzOBnUsw9aZZIJVKRrGuj0aO328W4N25T0Z8mY4fE6++ImvLnZ55LH2BRh0WYozC1KAVIShACm2KGQ==",
+ "version": "0.25.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
+ "integrity": "sha512-8eurZLyFUtB8XN16hLUuYkah+qwS3tkvdzul5qdZkJTi0aZ4q8dq2QGWSq6pN5mtVPdJyuJ8Mo53r36lixmP1g==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -1546,9 +1546,9 @@
@@ -1546,9 +1546,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.27.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
- "integrity": "sha512-/H7dyFw+cURcdjyfbpyY7mNc6F+Pcb9TwkVwZHR+9d3M62q70KDhZLo/gZi71eaYxUjD8lO8tmbXu70EAa9paQ==",
+ "version": "2.0.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
+ "integrity": "sha512-n5CHC0hfxOo0jo/3kS4O3KiwhirhZDlx5zZTIuPyIf3Dq0yI/R6R64btx7NYwZe5dI0NjvqzEtnv19m8unwLMg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -33,9 +33,9 @@
@@ -33,9 +33,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.3",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -30,7 +30,7 @@ const { createProjects } = require('./domain/projects');
@@ -30,7 +30,7 @@ const { createProjects } = require('./domain/projects');
const { createSites } = require('./domain/sites');
const { createSites } = require('./domain/sites');
const { createDeploys } = require('./domain/deploys');
const { createDeploys } = require('./domain/deploys');
const { createDomains } = require('./domain/domains');
const { createDomains } = require('./domain/domains');
-const { createAuthClient, createAuthRoutes } = require('./auth/sso');
+const { createSsoClient } = require('openvibe-sdk/sso');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
const { createTenantServer } = require('./http/tenant');
const { createTenantServer } = require('./http/tenant');
const { createUploadGate } = require('./http/upload');
const { createUploadGate } = require('./http/upload');
@@ -62,7 +62,7 @@ const DASHBOARD_CSP = {
@@ -62,7 +62,7 @@ const DASHBOARD_CSP = {
const DASHBOARD_CSP_HEADER = Object.entries(DASHBOARD_CSP).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ');
const DASHBOARD_CSP_HEADER = Object.entries(DASHBOARD_CSP).map(([k, v]) => `${k} ${v.join(' ')}`).join('; ');
/**
/**
- * opts: config, store | dbPath, now (clock), fetchImpl, auth (a createAuthClient-like object),
+ * opts: config, store | dbPath, now (clock), fetchImpl, auth (an openvibe-sdk/sso client),
* resolver ({ resolveTxt }), log
* resolver ({ resolveTxt }), log
*/
*/
async function createApp(opts = {}) {
async function createApp(opts = {}) {
@@ -80,8 +80,21 @@ async function createApp(opts = {}) {
@@ -80,8 +80,21 @@ async function createApp(opts = {}) {
const sites = createSites({ store, config, access, projects, takedowns });
const sites = createSites({ store, config, access, projects, takedowns });
const deploys = createDeploys({ store, config, access, projects, sites, blobs, outbox, takedowns, log });
const deploys = createDeploys({ store, config, access, projects, sites, blobs, outbox, takedowns, log });
const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log });
const domains = createDomains({ store, config, access, projects, sites, outbox, resolver: opts.resolver, log });
- const auth = opts.auth || createAuthClient(config);
- const viewers = createViewerResolver({ auth, config });
+ const auth = opts.auth || createSsoClient({
+ site: 'host',
+ baseUrl: config.baseUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ redirectUri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ networkUrl: config.networkUrl,
+ networkInternalUrl: config.networkInternalUrl,
+ issuer: config.networkUrl,
+ secureCookies: config.cookies.secure,
+ log,
+ ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}),
+ });
+ const viewers = createViewerResolver({ auth, config, log });
const tenant = createTenantServer({ store, config, blobs, takedowns, log });
const tenant = createTenantServer({ store, config, blobs, takedowns, log });
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
const worker = createWorker({ config, store, domains, blobs, outbox, log });
const worker = createWorker({ config, store, domains, blobs, outbox, log });
@@ -146,7 +159,7 @@ async function createApp(opts = {}) {
@@ -146,7 +159,7 @@ async function createApp(opts = {}) {
release.mount(app, { registry: registry });
release.mount(app, { registry: registry });
// GET /limits.json: the developer limits enforced here, from config (WS-N task 7; Codes renders them).
// GET /limits.json: the developer limits enforced here, from config (WS-N task 7; Codes renders them).
require('./limits').mountLimits(app, config);
require('./limits').mountLimits(app, config);
- const readiness = createHostReadiness({ store, blobs, auth, outbox, release: release.release, minFreeBytes: () => config.uploads.minFreeBytes, valkey: ctx.valkey });
+ const readiness = createHostReadiness({ store, blobs, outbox, jwksUrl: `${config.networkInternalUrl}/api/.well-known/jwks`, release: release.release, minFreeBytes: () => config.uploads.minFreeBytes, valkey: ctx.valkey });
app.get('/api/ready', readiness.handler);
app.get('/api/ready', readiness.handler);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
app.get('/metrics', sharedMetrics.metricsHandler(registry));
// The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites
// The dashboard host: only the public front page and the legal pages are crawlable. Tenant sites
@@ -159,7 +172,7 @@ async function createApp(opts = {}) {
@@ -159,7 +172,7 @@ async function createApp(opts = {}) {
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
- app.use('/auth', createAuthRoutes(config, auth));
+ app.use('/auth', auth.router(express));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'host', service: 'host', host: 'openvibe.host', name: 'OpenVibe.Host', profile: 'ugc' })); }
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'host', service: 'host', host: 'openvibe.host', name: 'OpenVibe.Host', profile: 'ugc' })); }
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
deleted file mode 100644
deleted file mode 100644
@@ -1,430 +0,0 @@
@@ -1,430 +0,0 @@
-'use strict';
-
-// ═══════════════════════════════════════════════════════════════
-// openvibe.host dashboard — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)
-//
-// The session layer OpenVibe.Community and OpenVibe.Blog use, with Host's client id (OAuth
-// client `host`, redirect https://openvibe.host/auth/callback), and one Host-specific change:
-//
-// Tenant sites are served on SUBDOMAINS of openvibe.host (<site>.openvibe.host). Any tenant page
-// can set a cookie for Domain=openvibe.host, so a plain cookie on the dashboard could be planted
-// by a tenant (session fixation, login CSRF). Every cookie Host trusts therefore uses the
-// `__Host-` prefix when cookies are Secure: the browser only accepts such a cookie from
-// openvibe.host itself, with Path=/ and no Domain. The plain `ov_token` cookie is still written
-// because the shared navbar reads it to show who is signed in, but the server never trusts it.
-// The API (/api/v1) ignores cookies entirely: Bearer tokens only.
-//
-// GET /auth/login → redirect to Network /oauth/authorize (?silent=1: prompt=none)
-// GET /auth/callback → server-side code exchange, set cookies
-// POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)
-// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest
-// GET /auth/me → offline-verify the session, return profile
-// POST /auth/refresh → rotate tokens via refresh_token grant
-// ═══════════════════════════════════════════════════════════════
-
-const express = require('express');
-const crypto = require('crypto');
-const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
-
-const ACCESS_COOKIE = 'ov_token'; // display only (navbar.js); never trusted by the server
-const HINT_COOKIE = 'ov_sso_hint';
-
-/** Cookies the server trusts: `__Host-` prefixed whenever cookies are Secure. */
-function cookieNames(secure) {
- const p = secure ? '__Host-' : '';
- return {
- session: `${p}ov_host_session`,
- refresh: `${p}ov_host_refresh`,
- state: `${p}ov_host_oauth_state`,
- next: `${p}ov_host_oauth_next`,
- silent: `${p}ov_host_oauth_silent`,
- };
-}
-
-/**
- * Create the auth client + JWKS fetcher shared by the whole app.
- * Verification is OFFLINE: we cache the Network's RS256 public key from
- * GET /api/.well-known/jwks and verify JWTs locally on every request.
- */
-function createAuthClient(config) {
- const client = new OpenVibeAuthClient({
- clientId: config.oauth.clientId,
- clientSecret: config.oauth.clientSecret,
- redirectUri: config.oauth.redirectUri,
- publicKey: null, // filled by ensureKey()
- authBase: config.networkUrl,
- internalBase: config.networkInternalUrl,
- });
-
- let lastFetch = 0;
- let inflight = null;
- async function ensureKey() {
- if (client.publicKey) return client.publicKey;
- // A request that arrives while the key is being fetched waits for that fetch rather than
- // being treated as signed out.
- if (inflight) return inflight;
- // Don't hammer the Network if it's down — retry at most every 30s
- if (Date.now() - lastFetch < 30_000) return null;
- lastFetch = Date.now();
- inflight = fetchKey().finally(() => { inflight = null; });
- return inflight;
- }
- async function fetchKey() {
- for (const base of [config.networkInternalUrl, config.networkUrl]) {
- if (!base) continue;
- try {
- const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
- if (!res.ok) continue;
- const jwks = await res.json();
- if (jwks.public_key) {
- client.publicKey = jwks.public_key;
- console.log(`[Host] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
- return client.publicKey;
- }
- } catch (err) {
- console.warn(`[Host auth] JWKS fetch failed from ${base}: ${err.message}`);
- }
- }
- return null;
- }
-
- /** Offline JWT verification. Returns decoded claims or null. */
- async function verify(token) {
- if (!token) return null;
- await ensureKey();
- return client.verifyToken(token);
- }
-
- // Warm the key cache at boot (non-fatal if the Network is down)
- ensureKey().catch(() => {});
-
- return { client, ensureKey, verify };
-}
-
-/** The dashboard session: only the (prefixed) session cookie, never ov_token. */
-function sessionToken(req, config) {
- return req.cookies?.[cookieNames(config.cookies.secure).session] || null;
-}
-
-/** API callers: only the Authorization header. */
-function bearerToken(req) {
- const h = String(req.headers.authorization || '');
- return h.startsWith('Bearer ') ? h.slice(7).trim() || null : null;
-}
-
-/** Strip registered claims — what the site shows as "the user". */
-function claimsToUser(claims) {
- if (!claims) return null;
- const { iat, exp, aud, iss, nbf, jti, ...user } = claims;
- return user;
-}
-
-/**
- * Decode a JWT's payload WITHOUT checking its signature. Only for reading claims
- * we cross-check locally (the FedCM nonce) before the Network — which does verify
- * the signature — sees the assertion. Never use it to trust an identity.
- */
-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. */
-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);
-}
-
-/**
- * Only allow same-site relative paths, this site's own https origin, or an
- * https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.
- */
-function sanitizeNext(next, config) {
- // Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would
- // leave the site. A next with any control character or backslash goes home.
- if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
- if (!next || typeof next !== 'string') return '/';
- if (/^\/(?!\/|\\)/.test(next)) return next; // relative path, not protocol-relative
- try {
- const u = new URL(next);
- if (u.protocol !== 'https:') return '/';
- const allowed = [config.baseUrl, config.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/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;
- const sep = base.includes('?') ? '&' : '?';
- return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
-}
-
-/**
- * Build the Network authorize URL for a login. Exported (and pure) so tests can
- * cover the silent-login shape without a browser.
- */
-function buildAuthorizeUrl(config, auth, { silent = false } = {}) {
- const { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);
- if (!silent) return { url, state };
- // ?silent=1: continue as the account openvibe.network already knows (no chooser); if the
- // network has no session either, it bounces back with error=login_required and we stay quiet.
- const u = new URL(url);
- u.searchParams.set('prompt', 'none');
- return { url: u.toString(), state };
-}
-
-function createAuthRoutes(config, auth) {
- const router = express.Router();
-
- const C = cookieNames(config.cookies.secure);
- const STATE_COOKIE = C.state;
- const NEXT_COOKIE = C.next;
- const SILENT_COOKIE = C.silent;
- const REFRESH_COOKIE = C.refresh;
-
- const accessCookieOpts = () => ({
- sameSite: 'lax',
- secure: config.cookies.secure,
- httpOnly: false, // JS-readable by design: the shared navbar reads it (display only)
- path: '/',
- maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT
- });
-
- const sessionCookieOpts = () => ({
- sameSite: 'lax',
- secure: config.cookies.secure,
- httpOnly: true,
- path: '/', // __Host- requires Path=/ and no Domain
- maxAge: 24 * 60 * 60 * 1000,
- });
-
- const refreshCookieOpts = () => ({ ...sessionCookieOpts(), maxAge: 30 * 24 * 60 * 60 * 1000 });
-
- const flowCookieOpts = () => ({ ...sessionCookieOpts(), maxAge: 10 * 60 * 1000 });
-
- const hintCookieOpts = () => ({
- sameSite: 'lax',
- secure: config.cookies.secure,
- httpOnly: false, // navbar.js reads it to decide whether a silent login is worth trying
- path: '/',
- maxAge: 365 * 24 * 60 * 60 * 1000,
- });
-
- function setSessionCookies(res, accessToken, refreshToken) {
- res.cookie(C.session, accessToken, sessionCookieOpts());
- res.cookie(ACCESS_COOKIE, accessToken, accessCookieOpts());
- if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());
- res.cookie(HINT_COOKIE, 'account', hintCookieOpts());
- }
-
- function clearSessionCookies(res) {
- res.clearCookie(C.session, { ...sessionCookieOpts(), maxAge: undefined });
- res.clearCookie(ACCESS_COOKIE, { ...accessCookieOpts(), maxAge: undefined });
- res.clearCookie(REFRESH_COOKIE, { ...refreshCookieOpts(), maxAge: undefined });
- }
-
- function clearFlowCookies(res) {
- for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { ...flowCookieOpts(), maxAge: undefined });
- }
-
- /** Exchange at the Network — internal URL first, public as fallback. */
- async function tokenGrant(body) {
- let lastErr = null;
- for (const base of [config.networkInternalUrl, config.networkUrl]) {
- if (!base) continue;
- try {
- const res = await fetch(`${base}/oauth/token`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- client_id: config.oauth.clientId,
- client_secret: config.oauth.clientSecret,
- ...body,
- }),
- signal: AbortSignal.timeout(10_000),
- });
- const data = await res.json().catch(() => ({}));
- if (!res.ok) {
- const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
- err.status = res.status;
- err.error = data.error || 'invalid_grant';
- err.error_description = data.error_description || null;
- throw err;
- }
- return data;
- } catch (err) {
- lastErr = err;
- // 4xx = the grant itself is bad; retrying against another base won't help
- if (err.status && err.status < 500) throw err;
- }
- }
- throw lastErr || new Error('Network unreachable');
- }
-
- // ── GET /auth/login ──────────────────────────────────────
- router.get('/login', async (req, res) => {
- const silent = !!req.query.silent && req.query.silent !== '0';
- if (silent) {
- // Already signed in here? Then the silent round trip through the Network would
- // only hand back the session we have: go straight to where the caller wanted.
- const existing = req.cookies?.[C.session];
- if (existing && await auth.verify(existing)) {
- clearFlowCookies(res);
- return res.redirect(sanitizeNext(req.query.next, config));
- }
- }
- const { url, state } = buildAuthorizeUrl(config, auth, { silent });
- res.cookie(STATE_COOKIE, state, flowCookieOpts());
- const next = sanitizeNext(req.query.next, config);
- if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());
- else res.clearCookie(NEXT_COOKIE, { ...flowCookieOpts(), maxAge: undefined });
- if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());
- else res.clearCookie(SILENT_COOKIE, { ...flowCookieOpts(), maxAge: undefined });
- res.redirect(url);
- });
-
- // ── GET /auth/callback ───────────────────────────────────
- router.get('/callback', async (req, res) => {
- const { code, state, error } = req.query;
- const next = sanitizeNext(req.cookies?.[NEXT_COOKIE], config);
- const silent = req.cookies?.[SILENT_COOKIE] === '1';
-
- if (error) {
- clearFlowCookies(res);
- // Silent sign-in found no network session — go back quietly as a guest. The
- // ?sso=none marker stops navbar.js from trying again on that page.
- if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
- return res.redirect(withParam('/', 'auth_error', String(error)));
- }
- if (!code) return res.status(400).send('Missing authorization code');
-
- const expectedState = req.cookies?.[STATE_COOKIE];
- clearFlowCookies(res);
- // The state cookie is required, not just compared when present: skipping the check when
- // the cookie is missing would let a crafted link sign a visitor into someone else's account.
- if (!expectedState || !state || !crypto.timingSafeEqual(
- Buffer.from(String(state).padEnd(64).slice(0, 64)),
- Buffer.from(String(expectedState).padEnd(64).slice(0, 64))
- )) {
- return res.status(400).send('OAuth state mismatch — please try signing in again.');
- }
-
- try {
- const data = await tokenGrant({
- grant_type: 'authorization_code',
- redirect_uri: config.oauth.redirectUri,
- code,
- });
- setSessionCookies(res, data.access_token, data.refresh_token);
- return res.redirect(next);
- } catch (err) {
- console.error('[Host auth] Code exchange failed:', err.message);
- return res.status(502).send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
- }
- });
-
- // ── POST /auth/fedcm ─────────────────────────────────────
- // The shared navbar obtained a FedCM assertion from openvibe.network (the
- // browser's own account chooser) and posts it same-origin with the nonce it
- // put in the FedCM request. We only pre-check the nonce; the Network verifies
- // the assertion's signature when it swaps it for tokens.
- const fedcmBody = express.json({ limit: '16kb', type: 'application/json' });
- const fedcmBodyError = (err, _req, res, next) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : next());
- router.post('/fedcm', fedcmBody, fedcmBodyError, async (req, res) => {
- if (!req.is('application/json')) return res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' });
- 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' });
- setSessionCookies(res, data.access_token, data.refresh_token);
- const user = data.user || claimsToUser(await auth.verify(data.access_token));
- return res.json({ ok: true, user });
- } 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 });
- }
- console.error('[Host auth] FedCM exchange failed:', err.message);
- return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
- }
- });
-
- // ── GET /auth/logout ─────────────────────────────────────
- router.get('/logout', async (req, res) => {
- // Best-effort refresh revocation. The Network's rotating refresh tokens
- // self-invalidate, so a failure here is harmless.
- const refresh = req.cookies?.[REFRESH_COOKIE];
- if (refresh) {
- try {
- await fetch(`${config.networkInternalUrl}/oauth/revoke`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- client_id: config.oauth.clientId,
- client_secret: config.oauth.clientSecret,
- token: refresh,
- }),
- signal: AbortSignal.timeout(3000),
- });
- } catch { /* optional */ }
- }
- clearSessionCookies(res);
- res.cookie(HINT_COOKIE, 'guest', hintCookieOpts());
- res.redirect(sanitizeNext(req.query.next, config));
- });
-
- // ── GET /auth/me ─────────────────────────────────────────
- router.get('/me', async (req, res) => {
- const token = sessionToken(req, config);
- if (!token) return res.status(401).json({ error: 'Not authenticated' });
- const claims = await auth.verify(token);
- if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });
- res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
- });
-
- // ── POST /auth/refresh ───────────────────────────────────
- // Small endpoint the shared navbar can call when ov_token expires.
- router.post('/refresh', async (req, res) => {
- const refresh = req.cookies?.[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 });
- setSessionCookies(res, data.access_token, data.refresh_token);
- const claims = await auth.verify(data.access_token);
- return res.json({ token: data.access_token, user: claimsToUser(claims) });
- } catch (err) {
- if (err.status && err.status < 500) {
- clearSessionCookies(res);
- return res.status(401).json({ error: 'Refresh token rejected — please sign in again' });
- }
- console.error('[Host auth] Refresh failed:', err.message);
- return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
- }
- });
-
- return router;
-}
-
-module.exports = { createAuthClient, createAuthRoutes, sessionToken, bearerToken, cookieNames, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };
@@ -6,8 +6,8 @@
@@ -6,8 +6,8 @@
* { kind: 'anonymous' }
* { kind: 'anonymous' }
* { kind: 'user', subject: 'usr_…', staff, user }
* { kind: 'user', subject: 'usr_…', staff, user }
* API: a Network user JWT in `Authorization: Bearer` (cookies are ignored on /api/v1).
* API: a Network user JWT in `Authorization: Bearer` (cookies are ignored on /api/v1).
- * Dashboard: the __Host- session cookie (never the navbar's ov_token, which a tenant
- * subdomain could plant). staff = the contracts staff map's staff.site.configure (ADR-022):
+ * Dashboard: the ov_token session cookie (openvibe-sdk/sso; the shared navbar reads the same cookie).
+ * staff = the contracts staff map's staff.site.configure (ADR-022):
* quotas, takedowns, and maintaining or deleting any project (never deploying to it).
* quotas, takedowns, and maintaining or deleting any project (never deploying to it).
* { kind: 'service', service: 'svc:codes' | 'app:app_…', claims, subject, env }
* { kind: 'service', service: 'svc:codes' | 'app:app_…', claims, subject, env }
* A Network client-credentials token for audience openvibe.host. A first-party service
* A Network client-credentials token for audience openvibe.host. A first-party service
@@ -19,28 +19,45 @@
@@ -19,28 +19,45 @@
* never downgraded to anonymous.
* never downgraded to anonymous.
*/
*/
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const { sessionToken, bearerToken, claimsToUser, decodeJwtPayload } = require('./sso');
+const { claimsToUser, decodeJwtPayload, parseCookies, COOKIES } = require('openvibe-sdk/sso');
+const { verifyServiceToken } = require('openvibe-sdk/auth');
const { checkCapability } = require('./capabilities');
const { checkCapability } = require('./capabilities');
-const { ids, serviceAuth, http, staff: staffMap } = contracts;
+const { ids, http, staff: staffMap } = contracts;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const PRINCIPAL_SUB = /^(svc|app|mod):/;
const AUDIENCE = 'openvibe.host';
const AUDIENCE = 'openvibe.host';
+/** API callers: only the Authorization header (cookies are never read on /api/v1). */
+const bearerToken = (req) => {
+ const h = String(req.headers.authorization || '');
+ return h.startsWith('Bearer ') ? h.slice(7).trim() || null : null;
+};
+
class ViewerError extends Error {
class ViewerError extends Error {
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
}
}
const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false });
const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false });
-function createViewerResolver({ auth, config }) {
+function createViewerResolver({ auth, config, log = console }) {
async function fromServiceToken(req, token) {
async function fromServiceToken(req, token) {
- const publicKey = await auth.ensureKey();
- if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
+ // The SDK supplies the key (the shared JWKS client, through a Network outage); every token rule is the
+ // service's own pinned openvibe-contracts serviceAuth.verifyServiceToken.
// acceptSandbox: Host takes sandbox tokens itself and confines them to sandbox projects
// acceptSandbox: Host takes sandbox tokens itself and confines them to sandbox projects
// (projects.js/access.js answer 403 environment.sandbox_token elsewhere); openvibe-contracts
// (projects.js/access.js answer 403 environment.sandbox_token elsewhere); openvibe-contracts
// >= 0.26.0 refuses them before that unless the receiver opts in.
// >= 0.26.0 refuses them before that unless the receiver opts in.
- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE, acceptSandbox: true });
- if (!r.ok) throw new ViewerError(401, r.code, r.reason);
+ const r = await verifyServiceToken(token, {
+ jwks: `${config.networkInternalUrl}/api/.well-known/jwks`,
+ issuer: config.networkUrl,
+ audience: AUDIENCE,
+ contracts,
+ acceptSandbox: true,
+ log,
+ });
+ if (!r.ok) {
+ if (r.code === 'token.unavailable') throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
+ throw new ViewerError(401, r.code, r.reason);
+ }
const sub = r.claims.sub;
const sub = r.claims.sub;
if (sub.startsWith('mod:')) throw new ViewerError(403, 'principal.not_allowed', 'mods cannot manage hosted sites');
if (sub.startsWith('mod:')) throw new ViewerError(403, 'principal.not_allowed', 'mods cannot manage hosted sites');
const subjectHeader = req.get('x-ov-subject');
const subjectHeader = req.get('x-ov-subject');
@@ -78,7 +95,7 @@ function createViewerResolver({ auth, config }) {
@@ -78,7 +95,7 @@ function createViewerResolver({ auth, config }) {
}
}
async function resolveDashboard(req) {
async function resolveDashboard(req) {
- const token = sessionToken(req, config);
+ const token = parseCookies(req)[COOKIES.access] || null;
if (!token) return ANONYMOUS;
if (!token) return ANONYMOUS;
return (await fromUserToken(token)) || ANONYMOUS;
return (await fromUserToken(token)) || ANONYMOUS;
}
}
@@ -1,66 +1,37 @@
@@ -1,66 +1,37 @@
'use strict';
'use strict';
/**
/**
- * Host → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
+ * Host → OpenVibe.Events through the openvibe-sdk service outbox (ADR-004).
*
*
* host.deploy.created an upload became an immutable, ready deploy
* host.deploy.created an upload became an immutable, ready deploy
* host.deploy.activated a site's active deploy changed (payload.rollback marks a rollback)
* host.deploy.activated a site's active deploy changed (payload.rollback marks a rollback)
* host.deploy.failed an upload was refused (payload.code, problem codes; no file contents)
* host.deploy.failed an upload was refused (payload.code, problem codes; no file contents)
* host.domain.verified a custom domain's TXT record was found; the domain is now served
* host.domain.verified a custom domain's TXT record was found; the domain is now served
*
*
- * emit() runs inside the SQLite transaction that makes the change, so an event exists if and only
- * if its change committed. The relay publishes with Host's service token (events.event.publish,
- * audience openvibe.events) only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise
- * rows wait in event_outbox and /api/ready reports the relay as off.
+ * The SDK writes each row 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
+ * Host's service token (events.event.publish, audience openvibe.events) only when EVENTS_URL and
+ * OV_OAUTH_CLIENT_SECRET are set; otherwise rows wait in event_outbox and /api/ready reports the
+ * relay as off. The undeclared event types below are refused by emit().
*/
*/
-const { createClient } = require('openvibe-sdk/core');
-const { createServiceTokenClient } = require('openvibe-sdk/auth');
-const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
+const { createServiceOutbox } = require('openvibe-sdk/events');
const EVENT_TYPES = Object.freeze(['host.deploy.created', 'host.deploy.activated', 'host.deploy.failed', 'host.domain.verified']);
const EVENT_TYPES = Object.freeze(['host.deploy.created', 'host.deploy.activated', 'host.deploy.failed', 'host.domain.verified']);
function createHostOutbox({ db, config, fetchImpl, now, log = console }) {
function createHostOutbox({ db, config, fetchImpl, now, log = console }) {
- const enabled = Boolean(config.events.url && config.oauth.clientSecret);
- const clientOpts = { baseUrls: { events: config.events.url || 'http://127.0.0.1:4300' }, retries: 0 };
- if (fetchImpl) clientOpts.fetch = fetchImpl;
- if (enabled) {
- clientOpts.tokenProvider = createServiceTokenClient({
- tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
- scope: { 'openvibe.events': 'events.event.publish' }, ...(fetchImpl ? { fetch: fetchImpl } : {}),
- });
- } else {
- clientOpts.getToken = async () => { throw new Error('events relay disabled (Events URL or service credentials not configured)'); };
- }
- const events = createEventsClient(createClient(clientOpts), { source: 'host' });
- let lastError = null;
- // The PostgreSQL outbox: rows are written in the change's own transaction (enqueue(db, …) joins the ambient
- // transaction); several processes relay one table safely (leases).
- const outbox = createPgOutbox(db, {
- events,
+ return createServiceOutbox({
+ db,
+ source: 'host',
+ eventsUrl: config.events.url,
+ networkInternalUrl: config.networkInternalUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
intervalMs: config.events.intervalMs,
intervalMs: config.events.intervalMs,
now,
now,
- onError: (err) => {
- const msg = err && err.message;
- if (msg !== lastError) log.warn('[Host] event publish failed (will retry):', msg);
- lastError = msg;
- },
+ log,
+ eventTypes: EVENT_TYPES,
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
});
});
-
- /** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
- async function emit(envelope, { traceparent } = {}) {
- if (!EVENT_TYPES.includes(envelope.event_type)) throw new Error(`undeclared event type ${envelope.event_type}`);
- return await outbox.enqueue(db, envelope, { traceparent });
- }
-
- return {
- emit,
- outbox,
- enabled,
- start() { if (enabled) outbox.start(); },
- stop: () => outbox.stop(),
- kick() { if (enabled) outbox.kick(); },
- status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),
- };
}
}
module.exports = { createHostOutbox, EVENT_TYPES };
module.exports = { createHostOutbox, EVENT_TYPES };
@@ -10,9 +10,10 @@
@@ -10,9 +10,10 @@
* domain_checks optional no custom domain is stuck: pending checks are running
* domain_checks optional no custom domain is stuck: pending checks are running
*/
*/
const { createReadiness } = require('openvibe-shared/ready');
const { createReadiness } = require('openvibe-shared/ready');
+const { jwksClient } = require('openvibe-sdk/auth');
const { CHARTER_TABLES } = require('./db');
const { CHARTER_TABLES } = require('./db');
-function createHostReadiness({ store, blobs, auth, outbox, release = null, minFreeBytes = () => 0, valkey = null }) {
+function createHostReadiness({ store, blobs, outbox, jwksUrl = null, release = null, minFreeBytes = () => 0, valkey = null }) {
const { db } = store;
const { db } = store;
return createReadiness({
return createReadiness({
service: 'host',
service: 'host',
@@ -43,8 +44,10 @@ function createHostReadiness({ store, blobs, auth, outbox, release = null, minFr
@@ -43,8 +44,10 @@ function createHostReadiness({ store, blobs, auth, outbox, release = null, minFr
{
{
name: 'network_jwks', required: false,
name: 'network_jwks', required: false,
check: () => {
check: () => {
- if (auth.client.publicKey) return true;
- auth.ensureKey().catch(() => {});
+ if (!jwksUrl) return { skipped: 'no Network JWKS URL configured' };
+ const client = jwksClient(jwksUrl);
+ if (client.status().ready) return true;
+ client.refresh().catch(() => {});
return 'Network signing key not loaded yet: sign-in and service calls are unavailable';
return 'Network signing key not loaded yet: sign-in and service calls are unavailable';
},
},
},
},
@@ -194,7 +194,7 @@ const DAY = 24 * 3600 * 1000;
@@ -194,7 +194,7 @@ const DAY = 24 * 3600 * 1000;
assert.strictEqual(list.status, 401, list.text);
assert.strictEqual(list.status, 401, list.text);
const del = await t.api('DELETE', `/api/v1/projects/${project.id}`, { as: assertion });
const del = await t.api('DELETE', `/api/v1/projects/${project.id}`, { as: assertion });
assert.strictEqual(del.status, 401, del.text);
assert.strictEqual(del.status, 401, del.text);
- const dash = await t.request({ method: 'GET', host: 'openvibe.host', path: '/', headers: { cookie: `ov_host_session=${assertion}` } });
+ const dash = await t.request({ method: 'GET', host: 'openvibe.host', path: '/', headers: { cookie: `ov_token=${assertion}` } });
assert.ok(!/Your projects/.test(dash.text), 'the dashboard does not accept it as a session either');
assert.ok(!/Your projects/.test(dash.text), 'the dashboard does not accept it as a session either');
});
});
@@ -81,11 +81,16 @@ const ORIGIN = 'https://openvibe.host';
@@ -81,11 +81,16 @@ const ORIGIN = 'https://openvibe.host';
for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);
for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);
});
});
- await check('a planted ov_token cookie (e.g. set by a tenant subdomain) is not a session', async () => {
- const r = await t.api('GET', '/', { headers: { cookie: `ov_token=${t.network.userToken(alice)}` } });
+ await check('the dashboard session is the verified ov_token cookie; a forged ov_token is not a session', async () => {
+ // openvibe-sdk/sso keeps the session in the ov_token cookie (the shared navbar reads the same one).
+ const forged = 'not.a.jwt';
+ const r = await t.api('GET', '/', { headers: { cookie: `ov_token=${forged}` } });
assert.match(r.text, /Sign in with OpenVibe/);
assert.match(r.text, /Sign in with OpenVibe/);
- const me = await t.api('GET', '/auth/me', { headers: { cookie: `ov_token=${t.network.userToken(alice)}` } });
+ const me = await t.api('GET', '/auth/me', { headers: { cookie: `ov_token=${forged}` } });
assert.strictEqual(me.status, 401);
assert.strictEqual(me.status, 401);
+ // /api/v1 never reads a cookie: only a Bearer token authenticates.
+ const api = await t.api('GET', '/api/v1/projects', { headers: { cookie: `ov_token=${t.network.userToken(alice)}` } });
+ assert.strictEqual(api.status, 401);
});
});
let projectId, siteId;
let projectId, siteId;
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
// newline characters from a URL and read a backslash as "/", so "/<TAB>/evil.com" is "//evil.com" to them;
// newline characters from a URL and read a backslash as "/", so "/<TAB>/evil.com" is "//evil.com" to them;
// until 2026-09-26 it got through here.
// until 2026-09-26 it got through here.
const assert = require('assert');
const assert = require('assert');
-const { sanitizeNext } = require('../server/auth/sso');
+const { sanitizeNext } = require('openvibe-sdk/sso');
const config = { baseUrl: 'https://site.openvibe.test', networkUrl: 'https://openvibe.network' };
const config = { baseUrl: 'https://site.openvibe.test', networkUrl: 'https://openvibe.network' };
const home = (v) => {
const home = (v) => {
@@ -63,10 +63,7 @@ const { boot, check, done } = require('./stageb/boot');
@@ -63,10 +63,7 @@ const { boot, check, done } = require('./stageb/boot');
assert.deepStrictEqual(odd, [], 'a connection went to a tenant-named host');
assert.deepStrictEqual(odd, [], 'a connection went to a tenant-named host');
});
});
- await check('ratchet: every file in server/ that makes an outbound request itself is reviewed', () => {
- const REVIEWED = {
- 'server/auth/sso.js': 'Network JWKS, OAuth token and revoke (configured)',
- };
+ await check('ratchet: no file in server/ makes an outbound request of its own (they go through openvibe-sdk)', () => {
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
const found = [];
const found = [];
const walk = (dir) => {
const walk = (dir) => {
@@ -80,8 +77,7 @@ const { boot, check, done } = require('./stageb/boot');
@@ -80,8 +77,7 @@ const { boot, check, done } = require('./stageb/boot');
}
}
};
};
walk(path.join(root, 'server'));
walk(path.join(root, 'server'));
- assert.ok(found.length >= 1, `the scan finds the known site (${found.join(', ')})`);
- assert.deepStrictEqual(found.filter((f) => !REVIEWED[f]).sort(), [], 'a new outbound request site: a tenant-chosen host goes through openvibe-shared/egress; then add the file here with where it goes');
+ assert.deepStrictEqual(found.sort(), [], 'a new outbound request site: a tenant-chosen host goes through openvibe-shared/egress; then add the file here with where it goes');
});
});
await t.close();
await t.close();
@@ -65,7 +65,6 @@ async function boot(opts = {}) {
@@ -65,7 +65,6 @@ async function boot(opts = {}) {
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl });
built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl });
- await built.ctx.auth.ensureKey();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.port = server.address().port;
t.port = server.address().port;
t.app = built.app;
t.app = built.app;
@@ -85,7 +84,7 @@ async function boot(opts = {}) {
@@ -85,7 +84,7 @@ async function boot(opts = {}) {
if (o.host !== undefined) headers.host = o.host;
if (o.host !== undefined) headers.host = o.host;
if (o.as && typeof o.as === 'object') headers.authorization = `Bearer ${network.userToken(o.as)}`;
if (o.as && typeof o.as === 'object') headers.authorization = `Bearer ${network.userToken(o.as)}`;
if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;
if (typeof o.as === 'string') headers.authorization = `Bearer ${o.as}`;
- if (o.session) headers.cookie = [headers.cookie, `ov_host_session=${network.userToken(o.session)}`].filter(Boolean).join('; ');
+ if (o.session) headers.cookie = [headers.cookie, `ov_token=${network.userToken(o.session)}`].filter(Boolean).join('; ');
let body = o.body;
let body = o.body;
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
if (o.form) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
@@ -54,7 +54,7 @@ async function startNetwork() {
@@ -54,7 +54,7 @@ async function startNetwork() {
return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
return { subject: ids.newId('user'), username, display_name: extra.display_name || username, role: extra.role || 'user' };
}
}
function userToken(u) {
function userToken(u) {
- return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
+ return jwt.sign({ sub: String(Math.floor(Math.random() * 1e6)), subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role || 'user' }, privatePem, { algorithm: 'RS256', issuer, audience: ['openvibe.network', 'openvibe.host'], expiresIn: '1h' });
}
}
/** A FedCM ID assertion as Network's /fedcm/assertion signs it (same key and issuer, aud = the RP origin). */
/** A FedCM ID assertion as Network's /fedcm/assertion signs it (same key and issuer, aud = the RP origin). */
function fedcmAssertion(u, origin, nonce = 'n0nce') {
function fedcmAssertion(u, origin, nonce = 'n0nce') {