openvibers/openvibe.news Commits

News 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), the outbox is createServiceOutbox; server/auth/sso.js and server/events/outbox.js are deleted. Pins: openvibe-sdk v0.25.0, openvibe-contracts v0.79.0, openvibe-shared v2.0.0. Both suites green (verified).

OP OpenVibers committed 29 Sept 2026 · 6023be4e08 · 12 files, +71 −538


Changes

ddiff --git a/README.md b/README.md
iindex f5d593e..e0b4727 100644
---- a/README.md
++++ b/README.md
@@@ -225,10 +225,10 @@ under [Grants the Network must hold](#grants-the-network-must-hold).
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
- **Packages** (pinned by release tarball): `openvibe-publishing` v1.0.0 (async PostgreSQL stores: revisions,
-- citations, authorship, seo, index-hooks, ssr, taxonomy slugify), `openvibe-contracts` v0.76.0,
-- `openvibe-shared` v1.27.0 (Frame, app icon, footer, legal, release, metrics, ready, seo),
-- `openvibe-sdk` v0.20.0 (db with after-commit hooks, PostgreSQL events outbox and inbox, webhook signatures v2,
-- service tokens, per-actor limits, testing).
++ citations, authorship, seo, index-hooks, ssr, taxonomy slugify), `openvibe-contracts` v0.79.0,
++ `openvibe-shared` v2.0.0 (Frame, app icon, footer, legal, release, metrics, ready, seo),
++ `openvibe-sdk` v0.25.0 (db with after-commit hooks, PostgreSQL events outbox and inbox, webhook signatures v2,
++ service tokens, Network sign-in (`openvibe-sdk/sso`) and the service outbox (`openvibe-sdk/events`), per-actor limits, testing).
- **OpenVibe.Sources** (4720): `sources.item.read`; optionally `sources.source.read` (outlet
names and source health; without it the outlet is the URL's host).
- **OpenVibe.Events** (4300): `events.event.publish`; `events.subscription.manage` to create the
ddiff --git a/STATUS.json b/STATUS.json
iindex d0cd05c..03166ab 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,12 +7,12 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4820,
"domain": "openvibe.news",
-- "contracts": "openvibe-contracts v0.76.0",
++ "contracts": "openvibe-contracts v0.79.0",
"packages": [
"openvibe-publishing v1.0.0",
-- "openvibe-contracts v0.76.0",
-- "openvibe-shared v1.27.0",
-- "openvibe-sdk v0.20.0"
++ "openvibe-contracts v0.79.0",
++ "openvibe-shared v2.0.0",
++ "openvibe-sdk v0.25.0"
],
"capabilities": [
"news.story.create",
@@@ -35,7 +35,7 @@
"deployed": true,
"deployment": "Deployed with `sudo ovhost deploy news`: unit openvibe-news, 127.0.0.1:4820 loopback only; on 2026-09-23 /api/ready answered 200 and the production database held 12 topics and 0 stories. Deployed internally, not launched: openvibe.news still serves the OpenVibe.Sites placeholder until a person verifies a news source's terms, enables it in Sources, and an editor publishes a reviewed story",
"placeholderInSites": true,
-- "updated": "2026-09-28",
++ "updated": "2026-09-29",
"features": [
"Sources ingestion by Events subscription and cursor pull, with dedupe",
"failed ingestion recorded, never fabricated",
ddiff --git a/package-lock.json b/package-lock.json
iindex cdf0399..7d558e7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,10 +15,10 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.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"
},
"devDependencies": {
@@@ -1147,9 +1147,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",
@@@ -1181,9 +1181,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.20.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
-- "integrity": "sha512-CqZFfVuSv3ZPoyG1LuWCAdvo8TG2ZuUCeGaqTsxDKS3gsymSFV2QXPq/5QUotTd82WShTGUKKhTgcyp9ggUMfQ==",
++ "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"
@@@ -1210,9 +1210,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"
ddiff --git a/package.json b/package.json
iindex 3a83483..26dbafe 100644
---- a/package.json
++++ b/package.json
@@@ -29,10 +29,10 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.0.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.27.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"
},
"devDependencies": {
ddiff --git a/server/app.js b/server/app.js
iindex 278e80e..7d3f5a7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -15,15 +15,16 @@ const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
++const { createSsoClient } = require('openvibe-sdk/sso');
++const { createServiceOutbox } = require('openvibe-sdk/events');
++
const configLib = require('./config');
const { openStore } = require('./db');
--const { createAuthClient, createAuthRoutes } = require('./auth/sso');
const { createViewerResolver } = require('./auth/viewer');
const { createPeople } = require('./clients/network');
const { createSourcesClient } = require('./clients/sources');
const { createAi } = require('./clients/ai');
const { createCommunity } = require('./clients/community');
--const { createNewsOutbox } = require('./events/outbox');
const { createPublication } = require('./domain/publication');
const { createDiscussion } = require('./domain/discussion');
const { createClusters } = require('./domain/clusters');
@@@ -44,7 +45,7 @@ const { assetVersion } = require('./render/layout');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
--/** opts: config, store, now (clock), fetchImpl, auth (a createAuthClient-like object), log,
++/** opts: config, store, now (clock), fetchImpl, auth (an openvibe-sdk/sso client), log,
* limitsNow (the per-actor limiter's clock, tests; default the wall clock) */
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
@@@ -53,7 +54,14 @@ async function createApp(opts = {}) {
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test, a script) hands in a store.
const store = opts.store || await openStore(config, { now: opts.now, log });
-- const outbox = createNewsOutbox({ db: store.db, config, fetchImpl, now: store.now, log });
++ const outbox = createServiceOutbox({
++ db: store.db, source: 'news',
++ eventsUrl: config.events.url,
++ networkInternalUrl: config.networkInternalUrl,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ intervalMs: config.events.intervalMs, log, now: store.now,
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ });
const publication = createPublication({ store, config, outbox });
const clusters = createClusters({ store, config, outbox });
const sources = createSourcesClient({ config, fetchImpl });
@@@ -68,7 +76,18 @@ async function createApp(opts = {}) {
if (opts.seedTopics !== false) await topics.seed();
const people = createPeople({ store, config, fetchImpl });
const reading = createReading({ store, config, stories, publication, people, topics });
-- const auth = opts.auth || createAuthClient(config);
++ const auth = opts.auth || createSsoClient({
++ site: 'news',
++ 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.issuer || config.networkUrl,
++ secureCookies: config.cookies.secure,
++ });
const viewers = createViewerResolver({ auth, config, people });
const worker = createWorker({ config, ingest, outbox, log });
@@@ -116,7 +135,7 @@ async function createApp(opts = {}) {
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-news', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
release.mount(app, { registry: metrics.registry });
-- const readiness = createNewsReadiness({ store, auth, outbox, ingest, config, release: release.release, valkey: ctx.valkey });
++ const readiness = createNewsReadiness({ store, outbox, ingest, config, release: release.release, valkey: ctx.valkey });
app.get('/api/ready', readiness.handler);
// ── Events webhook (raw body; before any other body parser) ─
@@@ -126,7 +145,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: 'news', service: 'news', host: 'openvibe.news', name: 'OpenVibe.News' })); }
// ── Static assets (content-hashed ?v= → immutable) ──────
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
ddeleted file mode 100644
iindex 8bcaf9d..0000000
---- a/server/auth/sso.js
++++ /dev/null
@@@ -1,423 +0,0 @@
--'use strict';
--
--// ═══════════════════════════════════════════════════════════════
--// openvibe.news — OAuth2 CLIENT session layer (sign-in via OpenVibe.Network SSO)
--//
--// The same session layer OpenVibe.Community uses (server/auth/routes.js there, MIT), with
--// News' client id: registered in the Network as OAuth client `news`, redirect
--// https://openvibe.news/auth/callback.
--//
--// GET /auth/login → redirect to Network /oauth/authorize (?silent=1: prompt=none;
--// ?next=… same-site path or an https://openvibe.network/… URL)
--// 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 ov_token, return profile
--// POST /auth/refresh → rotate tokens via refresh_token grant
--//
--// Cookies (host-only for openvibe.news):
--// ov_token access JWT, SameSite=Lax, Secure, JS-readable (navbar.js reads it)
--// ov_refresh opaque refresh token, httpOnly, Path=/auth
--// ov_sso_hint 'account' after a sign-in, 'guest' after a sign-out (navbar silent-login hint)
--// Pages never trust a body or query for identity: only the verified ov_token (or a Bearer token).
--// ═══════════════════════════════════════════════════════════════
--
--const express = require('express');
--const crypto = require('crypto');
--const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
--
--const ACCESS_COOKIE = 'ov_token';
--const REFRESH_COOKIE = 'ov_refresh';
--const HINT_COOKIE = 'ov_sso_hint';
--const STATE_COOKIE = 'ov_oauth_state';
--const NEXT_COOKIE = 'ov_oauth_next';
--const SILENT_COOKIE = 'ov_oauth_silent';
--
--/**
-- * 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(`[News] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
-- return client.publicKey;
-- }
-- } catch (err) {
-- console.warn(`[News 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 };
--}
--
--/** Token from Authorization header or the ov_token cookie. */
--function extractToken(req) {
-- const h = req.headers.authorization;
-- if (h && h.startsWith('Bearer ')) return h.slice(7);
-- return req.cookies?.[ACCESS_COOKIE] || 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 accessCookieOpts = () => ({
-- sameSite: 'lax',
-- secure: config.cookies.secure,
-- httpOnly: false, // JS-readable by design — the shared navbar reads it client-side
-- path: '/',
-- maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT
-- });
--
-- const refreshCookieOpts = () => ({
-- sameSite: 'lax',
-- secure: config.cookies.secure,
-- httpOnly: true,
-- path: '/auth', // only ever sent back to this session layer
-- maxAge: 30 * 24 * 60 * 60 * 1000,
-- });
--
-- const flowCookieOpts = () => ({
-- sameSite: 'lax', secure: config.cookies.secure, httpOnly: true,
-- path: '/auth', 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(ACCESS_COOKIE, accessToken, accessCookieOpts());
-- if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());
-- res.cookie(HINT_COOKIE, 'account', hintCookieOpts());
-- }
--
-- function clearSessionCookies(res) {
-- 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, { path: '/auth' });
-- }
--
-- /** 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?.[ACCESS_COOKIE];
-- 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, { path: '/auth' });
-- if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());
-- else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
-- 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('[News 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('[News 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 = extractToken(req);
-- 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('[News auth] Refresh failed:', err.message);
-- return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
-- }
-- });
--
-- return router;
--}
--
--/** Attaches req.user (JWT claims) and req.token when a valid ov_token is presented; never blocks. */
--function optionalAuth(auth) {
-- return async (req, _res, next) => {
-- const token = extractToken(req);
-- if (token) {
-- const claims = await auth.verify(token);
-- if (claims) { req.user = claimsToUser(claims); req.token = token; }
-- }
-- next();
-- };
--}
--
--module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };
ddiff --git a/server/auth/viewer.js b/server/auth/viewer.js
iindex c9814a5..b9f4124 100644
---- a/server/auth/viewer.js
++++ b/server/auth/viewer.js
@@@ -19,10 +19,11 @@
* on that token alone: a bad one is refused (problem+json), never downgraded to anonymous.
*/
const contracts = require('openvibe-contracts');
--const { extractToken, claimsToUser, decodeJwtPayload } = require('./sso');
++const { verifyServiceToken } = require('openvibe-sdk/auth');
++const { claimsToUser, decodeJwtPayload } = require('openvibe-sdk/sso');
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.news';
@@@ -34,10 +35,14 @@ const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false
function createViewerResolver({ auth, config, people }) {
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');
-- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
-- 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,
++ });
++ 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);
++ }
// Developer apps (app:…) and modules (mod:…) are third parties: they act only for the person
// who authorized them (on_behalf_of), never for whoever X-OV-Subject names. Only first-party
// service principals (svc:…) are trusted to name the acting person.
@@@ -81,7 +86,7 @@ function createViewerResolver({ auth, config, people }) {
return await fromServiceToken(req, token);
}
}
-- const token = extractToken(req);
++ const token = auth.extractToken(req);
if (!token) return ANONYMOUS;
return (await fromUserToken(token)) || ANONYMOUS;
}
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
ddeleted file mode 100644
iindex c505e9c..0000000
---- a/server/events/outbox.js
++++ /dev/null
@@@ -1,71 +0,0 @@
--'use strict';
--
--/**
-- * News → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
-- *
-- * news.source.ingested a Sources item was ingested (created, revised or removed
-- * upstream), with its dedupe and cluster outcome
-- * news.source.failed ingestion failed: Sources reported a failed fetch of a news
-- * source, or News could not read an item (no story text is
-- * ever made in its place)
-- * news.cluster.updated a cluster was created, grew, merged, split or a merge/split
-- * was reversed
-- * news.story.created|flagged internal editorial events
-- * news.story.published|updated|unpublished|retracted
-- * publication changes (canonical URL, state, indexability; no
-- * body; public only for public, listable stories)
-- * news.index_document.upserted|deleted the OpenVibe.Search document or tombstone (index-hooks)
-- *
-- * emit() runs inside the PostgreSQL transaction that makes the change, so an event exists if and only
-- * if its change committed. The relay publishes with News' 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.
-- */
--const { createClient } = require('openvibe-sdk/core');
--const { createServiceTokenClient } = require('openvibe-sdk/auth');
--const { createEventsClient, createPgOutbox } = require('openvibe-sdk/events');
--
--function createNewsOutbox({ 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 / OV_OAUTH_CLIENT_SECRET unset)'); };
-- }
-- const events = createEventsClient(createClient(clientOpts), { source: 'news' });
-- 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,
-- intervalMs: config.events.intervalMs,
-- now,
-- onError: (err) => {
-- const msg = err && err.message;
-- if (msg !== lastError) log.warn('[News] event publish failed (will retry):', msg);
-- lastError = msg;
-- },
-- });
--
-- /** Inside the caller's transaction. Returns the complete envelope (with its event_id). */
-- async function emit(envelope, { traceparent } = {}) {
-- return await outbox.enqueue(db, envelope, { traceparent });
-- }
--
-- return {
-- emit,
-- outbox,
-- enabled,
-- start() { if (enabled) outbox.start(); },
-- stop: () => outbox.stop(),
-- async kick() { if (enabled) await outbox.kick(); },
-- status: async () => ({ enabled, pending: await outbox.pending(), rejected: await outbox.rejected(), last_error: lastError }),
-- };
--}
--
--module.exports = { createNewsOutbox };
ddiff --git a/server/observability.js b/server/observability.js
iindex 6d39bc9..95fbd6c 100644
---- a/server/observability.js
++++ b/server/observability.js
@@@ -14,9 +14,10 @@
* Request metrics come from openvibe-shared/metrics in app.js; content counts are not metrics.
*/
const { createReadiness, skip } = require('openvibe-shared/ready');
++const { jwksClient } = require('openvibe-sdk/auth');
const { CHARTER_TABLES } = require('./db');
--function createNewsReadiness({ store, auth, outbox, ingest, config, valkey = null, release = null }) {
++function createNewsReadiness({ store, outbox, ingest, config, valkey = null, release = null }) {
const { db } = store;
return createReadiness({
service: 'news',
@@@ -37,8 +38,10 @@ function createNewsReadiness({ store, auth, outbox, ingest, config, valkey = nul
{
name: 'network_jwks', required: false,
check: () => {
-- if (auth.client.publicKey) return true;
-- auth.ensureKey().catch(() => {});
++ // The shared JWKS client: sign-in and service tokens verify through it offline.
++ const client = jwksClient(`${config.networkInternalUrl}/api/.well-known/jwks`);
++ if (client.status().ready) return true;
++ client.keys().catch(() => { /* logged by the client */ });
return 'Network signing key not loaded yet: sign-in and service calls are unavailable';
},
},
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex ef8962e..a87f7a4 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -57,7 +57,7 @@ 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: opts.log || quiet, limitsNow: opts.limitsNow });
-- await built.ctx.auth.ensureKey();
++ await require('openvibe-sdk/auth').jwksClient(`${config.networkInternalUrl}/api/.well-known/jwks`).keys();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
t.app = built.app;
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex be96372..f34aeb9 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -80,7 +80,7 @@ async function startNetwork() {
return 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.news'], expiresIn: '1h' });
}
function serviceToken(client, cap) {
return signService({ sub: `svc:${client}`, aud: ['openvibe.news'], cap });
ddiff --git a/test/open-redirect.test.js b/test/open-redirect.test.js
iindex 175c4fe..2f44086 100644
---- a/test/open-redirect.test.js
++++ b/test/open-redirect.test.js
@@@ -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) => {