openvibers/openvibe.network Commits
X-Internal-Key retirement, Network's step (plan T2, register C-50–C-58), on openvibe-contracts v0.79.0 (via v0.78.0: the realtime manifest is gone, so the registry tests assert its absence). The 13 key-only internal routes nothing in the estate calls are deleted (verify-token, users/:id, users/by-username, users/bulk, users/:id/theme, users/:id/linked-accounts, audit, stats, notifications/unread/:userId, notifications/resolve-users, issue-token, anon-admin, anon-list). The two Live still called with the key take its token: POST /internal/user-avatar (network.avatar.write) and POST /internal/notifications/mark-read (network.notifications.push); Live is granted network.avatar.write, network.registry.read and network.coins.read. Network's own calls carry its self-signed service token (identity/self-token.js, one cache per audience and capability set): the avatar push to Live (live.avatar.write), avatar import on Media (media.avatar.ingest), Live's analytics totals (live.analytics.read), Live's registry refresh (live.url_registry.refresh), the Tools analytics (tools.analytics.read); the key rides along only while those receivers move. Games and Media never had /api/internal/analytics: the dashboard uses the navbar page-view count for them without asking. npm test green.
Changes
@@@ -20,7 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2","uuid": "^11.1.0",@@@ -1465,9 +1465,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",@@@ -23,7 +23,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2","uuid": "^11.1.0",@@@ -11,18 +11,20 @@const express = require('express');const router = express.Router();--module.exports = function createAnalyticsRoutes(analytics, requireAuth, config) {++module.exports = function createAnalyticsRoutes(analytics, requireAuth, config, { selfToken = () => null } = {}) {-- // Service-to-service calls carry the deployment's INTERNAL_API_KEY. (This used to be a constant in-- // the source, which any reader of the repository could replay against the public tool hosts.)++ // Tools is asked with a Network service token (tools.analytics.read); the deployment's INTERNAL_API_KEY rides++ // along only while Tools moves to tokens (plan T2), then goes.const INTERNAL_KEY = config.internalKey;// All remote services with their internal URLs and fetch strategyconst REMOTE_SERVICES = [{ name: 'live', label: 'OpenVibe.Live', url: config.services?.live?.internalUrl || 'http://127.0.0.1:3000', path: '/api/admin/analytics', auth: 'bearer' },{ name: 'tools', label: 'OpenVibe.Tools', url: config.services?.tools?.internalUrl || 'http://127.0.0.1:4001', path: '/api/internal/analytics', auth: 'internal' },-- { name: 'games', label: 'OpenVibe.Games', url: config.services?.games?.internalUrl || 'http://127.0.0.1:8000', path: '/api/internal/analytics', auth: 'internal' },-- { name: 'media', label: 'OpenVibe.Media', url: config.services?.media?.internalUrl || 'http://127.0.0.1:4100', path: '/api/internal/analytics', auth: 'internal' },++ // Games and Media keep no analytics of their own and never had this route: the navbar's page-view count is++ // their number (beaconFallback), without asking them.++ { name: 'games', label: 'OpenVibe.Games', url: config.services?.games?.internalUrl || 'http://127.0.0.1:8000', path: null, auth: 'beacon' },++ { name: 'media', label: 'OpenVibe.Media', url: config.services?.media?.internalUrl || 'http://127.0.0.1:4100', path: null, auth: 'beacon' },];function requireAdmin(req, res, next) {@@@ -52,9 +54,12 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config)const url = `${svc.url}${svc.path}${subPath}?${qs}`;const headers = { 'Content-Type': 'application/json' };++ if (svc.auth === 'beacon') return subPath ? null : beaconFallback(svc, days);if (svc.auth === 'internal') {-- if (!INTERNAL_KEY || INTERNAL_KEY === 'change-me-in-production') return null;-- headers['X-Internal-Key'] = INTERNAL_KEY;++ const t = selfToken(`openvibe.${svc.name}`, [`${svc.name}.analytics.read`]);++ if (t) headers['Authorization'] = `Bearer ${t}`;++ if (INTERNAL_KEY && INTERNAL_KEY !== 'change-me-in-production') headers['X-Internal-Key'] = INTERNAL_KEY;++ if (!headers['Authorization'] && !headers['X-Internal-Key']) return null;} else {headers['Authorization'] = `Bearer ${token}`;}@@@ -118,9 +118,13 @@ async function refreshService(req, serviceName) {const controller = new AbortController();const timer = setTimeout(() => controller.abort(), 3000);try {++ // Live's refresh takes live.url_registry.refresh; the key rides along only while services move to tokens (T2).++ const cap = `${serviceName}.url_registry.refresh`;++ const t = serviceName === 'live' && req.app.locals.selfToken ? req.app.locals.selfToken('openvibe.live', [cap]) : null;++ const key = req.app.locals.config?.internalKey;const r = await fetch(info.target, {method: 'POST',-- headers: { 'X-Internal-Key': req.app.locals.config?.internalKey || '' },++ headers: { ...(key && key !== 'change-me-in-production' ? { 'X-Internal-Key': key } : {}), ...(t ? { Authorization: `Bearer ${t}` } : {}) },signal: controller.signal,});const baseResult = {@@@ -58,7 +58,7 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {};}--function createFrameService(db, config, analytics, { privateKey = null, issuer = null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' } = {}) {++function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' } = {}) {const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;// Renamed from chrome_cache/chrome_hits (2026-09-24): carry the old tables over once.for (const [from, to] of [['chrome_cache', 'frame_cache'], ['chrome_hits', 'frame_hits']]) {@@@ -85,8 +85,13 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =async function refreshRank() {const scores = Object.assign({}, rank.scores);-- // Live runs its own navbar and its own analytics: ask it for totals (internal key, totals only).-- const live = await getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/analytics-summary?days=7`, { 'X-Internal-Key': config.internalKey });++ // Live runs its own navbar and its own analytics: ask it for totals (live.analytics.read, totals only). The key++ // rides along only while Live moves to tokens (plan T2).++ const liveToken = selfToken('openvibe.live', ['live.analytics.read']);++ const live = await getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/analytics-summary?days=7`, {++ ...(config.internalKey && config.internalKey !== 'change-me-in-production' ? { 'X-Internal-Key': config.internalKey } : {}),++ ...(liveToken ? { Authorization: `Bearer ${liveToken}` } : {}),++ });if (live && live.summary) scores.live = Math.round(Number(live.summary.total_pageviews) || 0);// Everything else: the navbar's page-view beacon, summed per site over 7 days.let tools = rank.tools || [];@@@ -179,6 +179,12 @@ const DEFAULT_GRANTS = [['billing', 'identity.subject.resolve', SELF_AUDIENCE, []],['chat', 'identity.subject.resolve', SELF_AUDIENCE, []],['live', 'identity.subject.resolve', SELF_AUDIENCE, []],++ // X-Internal-Key retirement (plan T2): Live's avatar picker reports to Network, and Live reads the URL registry and++ // the OpenCoins totals with capabilities of their own (the registry read used identity.subject.resolve, the coin++ // totals network.coins.credit).++ ['live', 'network.avatar.write', SELF_AUDIENCE, []],++ ['live', 'network.registry.read', SELF_AUDIENCE, []],++ ['live', 'network.coins.read', SELF_AUDIENCE, []],// Media records each object's owner as a canonical subject (roadmap D01/D20): it resolves the app-local// owner ids it is given (X-OV-User-Id) through resolve-batch, in its backfill and its reconcile job.['media', 'identity.subject.resolve', SELF_AUDIENCE, []],nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++/**++ * Network's own service tokens for its calls to other services. Network is the token issuer, so it signs them itself++ * (sub svc:network, 5 minutes, one audience, the capabilities the call performs), the way the receivers verify every++ * other service's token (identity.service-token-claims@1). One cached token per audience + capability set.++ *++ * const token = createSelfTokens({ privateKey, issuer });++ * token('openvibe.live', ['live.avatar.write']) → 'eyJ…' | null (no RS256 key: a development HS256 setup)++ */++const crypto = require('crypto');++++const TTL_S = 300;++++function createSelfTokens({ privateKey, issuer, now = () => Date.now() } = {}) {++ const cache = new Map();++ return function token(audience, caps, { ns = null } = {}) {++ if (!privateKey || !String(privateKey).includes('BEGIN') || !issuer || !audience) return null;++ const list = [...caps].sort();++ const key = `${audience}|${list.join(',')}|${ns ? [...ns].sort().join(',') : ''}`;++ const t = Math.floor(now() / 1000);++ const hit = cache.get(key);++ if (hit && hit.exp - 60 > t) return hit.token;++ const claims = { iss: issuer, sub: 'svc:network', actor_type: 'service', aud: [audience], cap: list, ...(ns ? { ns: [...ns] } : {}),++ iat: t, exp: t + TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}` };++ const signed = require('openvibe-contracts').serviceAuth.signServiceToken(claims, privateKey);++ cache.set(key, { token: signed, exp: claims.exp });++ return signed;++ };++}++++module.exports = { createSelfTokens };@@@ -356,6 +356,9 @@ try {// Make keys available to route modulesapp.locals.db = db;app.locals.privateKey = privateKey;++// Network's own service tokens for its calls to Live, Media and Tools (identity/self-token.js).++const selfToken = require('./identity/self-token').createSelfTokens({ privateKey, issuer: config.jwt.issuer });++app.locals.selfToken = selfToken;app.locals.publicKey = publicKey;app.locals.config = config;@@@ -605,14 +608,14 @@ app.get('/api/catalog.json', async (req, res) => {});// The avatar: one picture per account, hosted on openvibe.media, used by every site (server/profile/avatar.js).--const avatarService = require('./profile/avatar').createAvatarService({ db, config, requireAuth });++const avatarService = require('./profile/avatar').createAvatarService({ db, config, requireAuth, selfToken });app.use('/api/profile/avatar', rateLimit({ windowMs: 60_000, max: 20 }), avatarService.api);app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub);app.locals.avatarService = avatarService;// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.--const frameService = require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer });++const frameService = require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken });const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });app.use('/api/frame', frameLimit, frameService.router);app.use('/api/chrome', frameLimit, frameService.router);@@@ -660,7 +663,7 @@ app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, r// Analytics admin APIconst createAnalyticsRoutes = require('./admin/analytics-routes');--app.use('/api/admin/analytics', createAnalyticsRoutes(analytics, requireAuth, config));++app.use('/api/admin/analytics', createAnalyticsRoutes(analytics, requireAuth, config, { selfToken }));// ── Admin Proxy to OpenVibe.Live ──────────────────────────────// Proxies /api/admin/streamer/* → openvibe.live /api/admin/*@@@ -1,11 +1,11 @@'use strict';// ═══════════════════════════════════════════════════════════════--// openvibe.network — Internal Server-to-Server API--// Used by OpenVibe.Live (3000), OpenVibe.Tools (4001),--// OpenVibe.Games (8000), and OpenVibe.Media (4100) to verify--// tokens, sync users, move OpenCoins, and fetch shared data.--// Protected by X-Internal-Key header.++// openvibe.network — Internal Server-to-Server API (loopback only)++// First-party services resolve identities, move OpenCoins, push notifications and read++// shared data here. Every route checks the one capability it performs on a service token++// (identity/principals.js guard); X-Internal-Key is being retired (plan T2) and is still++// accepted only where a route's guard allows the legacy path.// ═══════════════════════════════════════════════════════════════const express = require('express');@@@ -59,6 +59,8 @@ const principals = require('../identity/principals');const TOKEN_ROUTES = new Set(['GET /identity/resolve', 'POST /identity/resolve-batch', 'POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk', 'POST /events/stream-live',// Internal-key retirement (register C-50/C-52): the routes Live still calls with the key also take a token.'GET /url-registry/resolved', 'GET /coins/stats', 'POST /resolve-anon', 'POST /identity/legacy-map', 'POST /link-account',++ // The last two Live called with the key only (C-50): Live's avatar picker and its mark-read-by-type.++ 'POST /user-avatar', 'POST /notifications/mark-read',// Token only (legacy: false): Host's relay of the alerts firing on the production host (WS-H task 11).'POST /operator/alerts']);const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT|DELETE) \/modules\/[a-z0-9_.]+\/[A-Za-z0-9_]+$/];@@@ -86,81 +88,6 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolvrouter.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);router.use('/identity', require('../identity/internal-routes'));--// ── Verify Token ─────────────────────────────────────────────--// Other services call this to validate an access token and get user data.--// Avoids each service needing the public key locally (though they can — this is a convenience).--const jwt = require('jsonwebtoken');----router.post('/verify-token', (req, res) => {-- const { token } = req.body;-- if (!token) return res.status(400).json({ error: 'token required' });---- const publicKey = req.app.locals.publicKey;-- const config = getConfig(req);-- const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';---- try {-- const decoded = jwt.verify(token, publicKey, {-- algorithms: [algorithm],-- issuer: config.jwt.issuer-- });-- // A FedCM assertion or a service/app token (same signing key) is not a user token.-- if (!require('../auth/session').isUserSessionClaims(decoded)) return res.json({ valid: false, error: 'not a user token' });-- const db = getDb(req);-- const user = db.prepare('SELECT id, username, display_name, role, avatar_url, profile_color AS color FROM users WHERE id = ?').get(decoded.sub || decoded.id);-- res.json({ valid: true, decoded, user: user || null });-- } catch (err) {-- res.json({ valid: false, error: err.message });-- }--});----// ── Get User by ID ───────────────────────────────────────────--router.get('/users/:id', (req, res) => {-- const db = getDb(req);-- const user = db.prepare(`-- SELECT id, username, display_name, role, avatar_url, profile_color AS color, bio, created_at-- FROM users WHERE id = ?-- `).get(req.params.id);-- if (!user) return res.status(404).json({ error: 'User not found' });-- res.json({ user });--});----// ── Lookup User by Username ──────────────────────────────────--router.get('/users/by-username/:username', (req, res) => {-- const db = getDb(req);-- const user = db.prepare(`-- SELECT id, username, display_name, role, avatar_url, profile_color AS color, bio, created_at-- FROM users WHERE username = ?-- `).get(req.params.username.toLowerCase());-- if (!user) return res.status(404).json({ error: 'User not found' });-- res.json({ user });--});----// ── Bulk User Lookup ─────────────────────────────────────────--router.post('/users/bulk', (req, res) => {-- const { ids } = req.body;-- if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' });-- if (ids.length > 200) return res.status(400).json({ error: 'Max 200 ids per request' });---- const db = getDb(req);-- const placeholders = ids.map(() => '?').join(',');-- const users = db.prepare(`-- SELECT id, username, display_name, role, avatar_url, profile_color AS color-- FROM users WHERE id IN (${placeholders})-- `).all(...ids);-- res.json({ users });--});----// ── Get User Theme Preference ────────────────────────────────--router.get('/users/:id/theme', (req, res) => {-- const db = getDb(req);-- const prefs = db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(req.params.id);-- if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null });-- let custom = null;-- try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}-- res.json({ theme_id: prefs.theme_id, custom_variables: custom });--});--// ── Sync Linked Account ──────────────────────────────────────// When a user connects their OpenVibe.Live or OpenVibe.Games account,// the service reports the link here.@@@ -208,39 +135,13 @@ router.post('/link-account', principals.guard('identity.subject.resolve', { ownA});// ── A site changed someone's avatar (Live's avatar picker) ───--router.post('/user-avatar', (req, res) => {++router.post('/user-avatar', principals.guard('network.avatar.write'), (req, res) => {const svc = req.app.locals.avatarService;if (!svc) return res.status(503).json({ error: 'avatar service unavailable' });const r = svc.fromSite(req.body || {});res.status(r.status).json(r.error ? { error: r.error } : { ok: true, changed: r.changed });});--// ── Get Linked Accounts ──────────────────────────────────────--router.get('/users/:id/linked-accounts', (req, res) => {-- const db = getDb(req);-- const accounts = db.prepare('SELECT service, service_user_id, service_username, linked_at FROM linked_accounts WHERE user_id = ?').all(req.params.id);-- res.json({ accounts });--});----// ── Audit Log ────────────────────────────────────────────────--router.post('/audit', (req, res) => {-- const { user_id, action, details, ip } = req.body;-- if (!action) return res.status(400).json({ error: 'action required' });-- const db = getDb(req);-- db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(user_id || null, action, details || null, ip || null);-- res.json({ success: true });--});----// ── Health / Stats ───────────────────────────────────────────--router.get('/stats', (req, res) => {-- const db = getDb(req);-- const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;-- const themeCount = db.prepare('SELECT COUNT(*) as count FROM themes').get().count;-- const linkedCount = db.prepare('SELECT COUNT(*) as count FROM linked_accounts').get().count;-- const notifCount = db.prepare('SELECT COUNT(*) as count FROM notifications').get().count;-- res.json({ users: userCount, themes: themeCount, linked_accounts: linkedCount, notifications: notifCount });--});--// Service URLs only (no secrets); any first-party service that resolves identities may read them.// Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the owner's, not the services'.// TODO(contracts): a network.registry.read capability would say it more exactly.@@@ -517,24 +418,10 @@ router.post('/operator/alerts', principals.guard('network.operator.alert', { leg}});--// ── Get Unread Count for User ────────────────────────────────--// GET /internal/notifications/unread/:userId--router.get('/notifications/unread/:userId', (req, res) => {-- const notifService = req.app.locals.notificationService;-- if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });---- try {-- const count = notifService.getUnreadCount(parseInt(req.params.userId));-- res.json({ ok: true, count });-- } catch (err) {-- res.status(500).json({ error: err.message });-- }--});--// ── Mark Notifications Read by Type ──────────────────────────// POST /internal/notifications/mark-read// Body: { user_id, type, url_pattern? }--router.post('/notifications/mark-read', (req, res) => {++router.post('/notifications/mark-read', principals.guard('network.notifications.push'), (req, res) => {const notifService = req.app.locals.notificationService;if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });@@@ -550,51 +437,6 @@ router.post('/notifications/mark-read', (req, res) => {}});--// ── Resolve User for Notification Context ────────────────────--// POST /internal/notifications/resolve-users--// Body: { usernames: [] } → returns user IDs + display info--router.post('/notifications/resolve-users', (req, res) => {-- const { usernames } = req.body;-- if (!Array.isArray(usernames) || usernames.length === 0) {-- return res.status(400).json({ error: 'usernames array required' });-- }-- const db = getDb(req);-- const placeholders = usernames.map(() => '?').join(',');-- const users = db.prepare(`-- SELECT id, username, display_name, avatar_url, name_effect, particle_effect-- FROM users WHERE LOWER(username) IN (${placeholders})-- `).all(...usernames.map(u => u.toLowerCase()));-- res.json({ ok: true, users });--});----// ── Issue Token for Linked User ──────────────────────────────--// POST /internal/issue-token--// Body: { user_id }--// Used by first-party services to get an openvibe.network JWT for--// users who logged in via password but have a linked account.--// This enables cross-service features (notifications, themes).--router.post('/issue-token', (req, res) => {-- const { user_id } = req.body;-- if (!user_id) return res.status(400).json({ error: 'user_id required' });---- const db = getDb(req);-- const config = getConfig(req);-- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(user_id);-- if (!user) return res.status(404).json({ error: 'User not found' });-- if (user.is_banned) return res.status(403).json({ error: 'User is banned' });---- const privateKey = req.app.locals.privateKey;-- const algorithm = privateKey === req.app.locals.publicKey ? 'HS256' : 'RS256';---- const token = jwt.sign(-- { sub: user.id, username: user.username, role: user.role },-- privateKey,-- { algorithm, expiresIn: config.jwt.accessTokenExpiry, issuer: config.jwt.issuer }-- );---- res.json({ token, user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role, avatar_url: user.avatar_url } });--});--// ═══════════════════════════════════════════════════════════════// Unified Anon Identity Resolution (cross-service)// ═══════════════════════════════════════════════════════════════@@@ -679,82 +521,4 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req,}});--// ── Admin: IP → Anon/Account Lookup ──────────────────────────--// GET /internal/anon-admin?ip=X--// Returns all anonymous identities AND registered accounts--// associated with a given IP address.--router.get('/anon-admin', (req, res) => {-- const { ip } = req.query;-- if (!ip) return res.status(400).json({ error: 'ip query param required' });---- const db = getDb(req);---- try {-- // Get all anons seen from this IP-- const anons = db.prepare(`-- SELECT a.id, a.anon_number, a.display_name, a.ip AS creating_ip,-- a.total_messages, a.total_commands, a.first_seen, a.last_seen,-- l.first_seen AS ip_first_seen, l.last_seen AS ip_last_seen-- FROM anon_users a-- INNER JOIN anon_ip_log l ON l.anon_id = a.id-- WHERE l.ip = ?-- ORDER BY a.anon_number ASC-- `).all(ip);---- // Get all registered users who have logged in from this IP-- const users = db.prepare(`-- SELECT DISTINCT u.id, u.username, u.display_name, u.role, u.is_banned,-- u.anon_number, u.created_at-- FROM users u-- INNER JOIN ip_log il ON il.user_id = u.id-- WHERE il.ip = ?-- ORDER BY u.created_at ASC-- `).all(ip);---- // Get all IPs for each anon (cross-reference)-- const anonIps = {};-- for (const a of anons) {-- const ips = db.prepare('SELECT ip, first_seen, last_seen FROM anon_ip_log WHERE anon_id = ?').all(a.id);-- anonIps[a.anon_number] = ips;-- }---- res.json({-- ip,-- anonymous_identities: anons,-- registered_accounts: users,-- anon_ip_map: anonIps,-- });-- } catch (err) {-- console.error('[Internal] anon-admin error:', err);-- res.status(500).json({ error: 'Failed to lookup IP data' });-- }--});----// ── Admin: List All Anon Identities ──────────────────────────--// GET /internal/anon-list?limit=100&offset=0--router.get('/anon-list', (req, res) => {-- const db = getDb(req);-- const limit = Math.min(parseInt(req.query.limit) || 100, 500);-- const offset = parseInt(req.query.offset) || 0;---- try {-- const total = db.prepare('SELECT COUNT(*) as cnt FROM anon_users').get().cnt;-- const anons = db.prepare(`-- SELECT a.id, a.anon_number, a.display_name, a.ip AS creating_ip,-- a.total_messages, a.total_commands, a.first_seen, a.last_seen-- FROM anon_users a ORDER BY a.anon_number DESC LIMIT ? OFFSET ?-- `).all(limit, offset);---- // Attach IP list to each anon-- for (const a of anons) {-- a.ips = db.prepare('SELECT ip, last_seen FROM anon_ip_log WHERE anon_id = ?').all(a.id);-- }---- res.json({ total, anons, limit, offset });-- } catch (err) {-- console.error('[Internal] anon-list error:', err);-- res.status(500).json({ error: 'Failed to list anons' });-- }--});--module.exports = router;@@@ -54,7 +54,14 @@ const initialSvg = (name, size) => {return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 96 96"><rect width="96" height="96" fill="hsl(${h} 45% 42%)"/><text x="48" y="50" font-family="system-ui,sans-serif" font-size="46" font-weight="700" fill="#fff" text-anchor="middle" dominant-baseline="middle">${ch}</text></svg>`;};--function createAvatarService({ db, config, requireAuth, log = console }) {++function createAvatarService({ db, config, requireAuth, selfToken = () => null, log = console }) {++ // A service token for the call (live.avatar.write / media.avatar.ingest); X-Internal-Key rides along only while the++ // receivers move to tokens (plan T2), then goes.++ const legacyKey = () => (config.internalKey && config.internalKey !== 'change-me-in-production' ? { 'X-Internal-Key': config.internalKey } : {});++ function authHeaders(audience, cap) {++ const t = selfToken(audience, [cap]);++ return { ...legacyKey(), ...(t ? { Authorization: `Bearer ${t}` } : {}) };++ }const getUrl = db.prepare('SELECT avatar_url FROM users WHERE id = ?');const byName = db.prepare('SELECT username, avatar_url FROM users WHERE lower(username) = lower(?)');const setUrl = db.prepare('UPDATE users SET avatar_url = ? WHERE id = ?');@@@ -62,17 +69,19 @@ function createAvatarService({ db, config, requireAuth, log = console }) {/** Tell the sites that keep their own copy. Fire and forget: they also pick it up at the next sign-in. */function pushToSites(userId, username, url) {const live = config.services && config.services.live && config.services.live.internalUrl || 'http://127.0.0.1:3000';-- if (!config.internalKey || config.internalKey === 'change-me-in-production') return;-- fetch(`${live.replace(/\/$/, '')}/internal/user-avatar`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Internal-Key': config.internalKey },++ const auth = authHeaders('openvibe.live', 'live.avatar.write');++ if (!Object.keys(auth).length) return;++ fetch(`${live.replace(/\/$/, '')}/internal/user-avatar`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...auth },body: JSON.stringify({ openvibenetwork_id: userId, username, avatar_url: url }), signal: AbortSignal.timeout(5000) }).catch(() => {});}/** A picture from elsewhere on the web: openvibe.media fetches it safely, re-encodes it and keeps the copy. */async function ingest(url, user) {const media = (config.services && config.services.media && config.services.media.internalUrl) || 'http://127.0.0.1:4100';-- if (!config.internalKey || config.internalKey === 'change-me-in-production') return { ok: false, status: 503, error: 'Picture import is not configured on this server' };++ const auth = authHeaders('openvibe.media', 'media.avatar.ingest');++ if (!Object.keys(auth).length) return { ok: false, status: 503, error: 'Picture import is not configured on this server' };try {-- const r = await fetch(`${media.replace(/\/$/, '')}/internal/avatar-ingest`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Internal-Key': config.internalKey },++ const r = await fetch(`${media.replace(/\/$/, '')}/internal/avatar-ingest`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...auth },body: JSON.stringify({ url, user_id: user.id, username: user.username }), signal: AbortSignal.timeout(20000) });const j = await r.json().catch(() => ({}));if (!r.ok || !j.ok) return { ok: false, status: r.status === 404 ? 503 : 422, error: j.error || 'That picture could not be imported' };@@@ -23,11 +23,17 @@ for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https:const db = new Database(':memory:');db.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT, avatar_url TEXT); CREATE TABLE audit_log(id INTEGER PRIMARY KEY, user_id INT, action TEXT, details TEXT, ip TEXT); INSERT INTO users VALUES (1,'Goosely',NULL),(2,'legacy','https://evil.example/old.png')");-- const pushes = []; const realFetch = global.fetch; global.fetch = async (u, o) => { pushes.push(JSON.parse(o.body)); return { ok: true }; };-- const svc = createAvatarService({ db, config: { internalKey: 'k'.repeat(24), services: {} }, requireAuth: (req, _res, next) => { req.user = { id: 1, username: 'Goosely' }; next(); } });++ const pushes = []; const pushHeaders = []; const realFetch = global.fetch; global.fetch = async (u, o) => { pushes.push(JSON.parse(o.body)); pushHeaders.push(o.headers); return { ok: true }; };++ // Network's own service token for the push (plan T2): live.avatar.write, audience openvibe.live.++ const { privateKey, publicKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ const selfToken = require('../server/identity/self-token').createSelfTokens({ privateKey, issuer: 'https://openvibe.network' });++ const svc = createAvatarService({ db, config: { internalKey: 'k'.repeat(24), services: {} }, selfToken, requireAuth: (req, _res, next) => { req.user = { id: 1, username: 'Goosely' }; next(); } });assert.equal(svc.fromSite({ user_id: 1, avatar_url: `${M}/f/a.png`, origin: 'live' }).changed, true);assert.equal(pushes.length, 0, 'a change that came from Live is not echoed back to Live');svc.apply(1, 'Goosely', `${M}/f/b.png`, 'network'); assert.equal(pushes.length, 1); assert.equal(pushes[0].avatar_url, `${M}/f/b.png`, 'a change made here is pushed to the sites');++ const bearer = String(pushHeaders[0].Authorization || '').replace(/^Bearer /, '');++ const v = require('openvibe-contracts').serviceAuth.verifyServiceToken(bearer, { publicKey, issuer: 'https://openvibe.network', audience: 'openvibe.live' });++ assert.ok(v.ok && v.claims.sub === 'svc:network' && v.claims.cap.includes('live.avatar.write'), `the push carries Network's token for live.avatar.write: ${v.reason || ''}`);assert.equal(svc.fromSite({ user_id: 1, avatar_url: 'https://evil.example/x.png' }).status, 422);global.fetch = realFetch;@@@ -53,7 +53,7 @@ const server = http.createServer(app);const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });assert.ok(claims.ok, claims.reason);assert.strictEqual(claims.claims.sub, 'svc:live');-- assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.analytics.creator.read', 'network.coins.credit', 'network.coins.debit', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);++ assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.analytics.creator.read', 'network.avatar.write', 'network.coins.credit', 'network.coins.debit', 'network.coins.read', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push', 'network.registry.read']);assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');const full = t.body.access_token;@@@ -105,6 +105,14 @@ const server = http.createServer(app);assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.denied', 'narrowed token cannot debit');r = await post('/internal/coins/transfer', { from_user_id: 7, to_user_id: 8, app_id: 'live', amount: 2, reason: 't', idempotency_key: 'tr-1' }, { authorization: `Bearer ${full}` });assert.strictEqual(r.status, 403, 'ADR-012: loyalty is not transferable between people'); assert.strictEqual(r.body.code, 'capability.denied');++ // The two routes Live called with the key only (plan T2): its token opens them, a narrower one does not.++ r = await post('/internal/user-avatar', { user_id: 7, avatar_url: null, origin: 'live' }, { authorization: `Bearer ${full}` });++ assert.notStrictEqual(r.status, 403, `network.avatar.write opens /internal/user-avatar: ${JSON.stringify(r.body)}`);++ assert.notStrictEqual(r.status, 401);++ r = await post('/internal/notifications/mark-read', { user_id: 7, type: 'follow' }, { authorization: `Bearer ${full}` });++ assert.notStrictEqual(r.status, 403, `network.notifications.push opens mark-read: ${JSON.stringify(r.body)}`);++ r = await post('/internal/user-avatar', { user_id: 7, avatar_url: null }, { authorization: `Bearer ${creditOnly}` });++ assert.strictEqual(r.status, 403, 'a token without network.avatar.write');r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.malformed');r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt', 'x-internal-key': 'legacy-key' });@@@ -36,13 +36,13 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');assert.strictEqual(byId.network.runtime.status, 'up');assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');-- assert.strictEqual(byId.realtime.runtime.status, 'not-running', 'placeholders are never shown as running');++ assert.ok(!('realtime' in byId), 'openvibe-contracts 0.78.0 removed the realtime manifest (ADR-005 closed it, never built)');assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => !['runtime', 'exposure', 'observed'].includes(k)))).valid));r = await get('/api/v1/registry/services?status=placeholder');assert.ok(r.body.services.every(s => s.status === 'placeholder'), 'the status filter only returns that status');-- // openvibe-contracts 0.34: realtime is retired (ADR-005 folded it into Events).++ // openvibe-contracts 0.78.0: the realtime manifest is gone, so nothing is retired by it any morer = await get('/api/v1/registry/services?status=retired');-- assert.ok(r.body.services.some(s => s.id === 'realtime') && r.body.services.every(s => s.status === 'retired'));++ assert.ok(!r.body.services.some(s => s.id === 'realtime') && r.body.services.every(s => s.status === 'retired'));r = await get('/api/v1/registry/services/network');assert.ok(r.body.capability_details.some(c => c.id === 'network.coins.credit'));@@@ -76,7 +76,9 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');assert.strictEqual(inCat('live'), 'site'); assert.strictEqual(inCat('network'), 'site'); assert.strictEqual(inCat('tips'), 'site', 'a site whose domain is still a placeholder is a site, not open yet');assert.strictEqual(inCat('events'), 'platform'); assert.strictEqual(inCat('ai'), 'platform'); assert.strictEqual(inCat('sources'), 'platform');assert.strictEqual(inCat('sdk'), 'library'); assert.strictEqual(inCat('contracts'), 'library');-- assert.strictEqual(inCat('examples'), 'repository'); assert.strictEqual(inCat('realtime'), 'planned');++ assert.strictEqual(inCat('examples'), 'repository');++ // 'planned' = exposure placeholder, and no manifest has that state since realtime's was removed++ assert.deepStrictEqual(cats.planned.services, []);const liveRow = cats.site.services.find(x => x.id === 'live');assert.strictEqual(liveRow.runtime, 'down'); assert.ok(liveRow.checked_at, 'rows carry the last check');r = await get('/api/v1/registry/categories/library');@@@ -21,7 +21,6 @@ const PINNED = {tips: 'internal', vip: 'internal', openre: 'internal', search: 'live', sources: 'internal', chat: 'live', ai: 'internal',sdk: 'library', shared: 'library', contracts: 'library', publishing: 'library',examples: 'repository',-- realtime: 'placeholder',};const PLACEHOLDER_DOMAINS = ['news', 'reviews', 'deals', 'coupons', 'trade', 'host', 'tips', 'vip', 'openre'];@@@ -86,7 +85,8 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], chassert.strictEqual(by.sdk.runtime.status, 'not-running');assert.strictEqual(by.sdk.runtime.reason, `library, released v${require('openvibe-sdk/package.json').version}`);assert.strictEqual(by.examples.runtime.reason, 'repository, nothing to run');-- assert.strictEqual(by.realtime.runtime.reason, 'placeholder');++ // openvibe-contracts 0.78.0 removed the realtime manifest (ADR-005 closed OpenVibe.Realtime, never built)++ assert.ok(!('realtime' in by), 'realtime is gone from the exposure map, not a stale row');assert.strictEqual(by.live.runtime.scope, 'public');r = await json('/api/v1/registry/services?state=internal');assert.deepStrictEqual(r.services.map(s => s.id).sort(), Object.keys(PINNED).filter(k => PINNED[k] === 'internal').sort());@@@ -101,7 +101,8 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], chassert.strictEqual(sBy.live.label, 'up');assert.strictEqual(sBy.live.origin, 'https://openvibe.live');assert.strictEqual(sBy.shared.label, `not running (library, released v${require('openvibe-shared/package.json').version})`);-- assert.deepStrictEqual(st.exposure_summary, { live: 14, internal: 11, library: 4, repository: 1, placeholder: 1, retired: 0 });++ // no manifest has exposure 'placeholder' or 'retired' since realtime's was removed in 0.78.0++ assert.deepStrictEqual(st.exposure_summary, { live: 14, internal: 11, library: 4, repository: 1, placeholder: 0, retired: 0 });// The page: no row for a service whose domain serves a placeholder shows a bare "Up".const html = await fetch(base + '/status').then(x => x.text());@@@ -113,11 +113,12 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcdassert.strictEqual(rel2.network.drift['openvibe-contracts'].state, 'current');assert.ok(r.body.behind.includes('live') && !r.body.behind.includes('network'));assert.strictEqual(rel2.media.release, null); assert.ok(rel2.media.error);-- assert.strictEqual(rel2.realtime.release, null, 'a placeholder has no release');++ assert.ok(!('realtime' in rel2), 'a removed manifest has no release row');r = await get('/api/v1/registry/health');const h = Object.fromEntries(r.body.services.map(s => [s.id, s]));-- assert.strictEqual(h.network.status, 'up'); assert.strictEqual(h.media.status, 'down'); assert.strictEqual(h.realtime.status, 'not-running');++ assert.strictEqual(h.network.status, 'up'); assert.strictEqual(h.media.status, 'down');++ assert.ok(!('realtime' in h), 'health is per manifest, and realtime has none');assert.ok(h.network.checked_at);assert.strictEqual(Object.values(r.body.summary).reduce((a, b) => a + b, 0), contracts.services.manifests.length);@@@ -92,7 +92,8 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');await mine('alice', '/api/history', 'alice-private-history');await mine('alice', `/api/v1/projects/${dev.PA.id}`, 'alice-private-project');await mine('alice', `/api/v1/projects/${dev.PA.id}/apps`, 'alice-private-app');-- await mine({ 'x-internal-key': w.sentinels.INTERNAL_API_KEY }, `/internal/users/${users.alice.id}/linked-accounts`, 'alice-games-771');++ // (The linked-account needle's positive control was GET /internal/users/:id/linked-accounts, deleted with the key++ // in plan T2; the needle stays in the crawl so any route that exposes it to the wrong caller still fails.)const pub = await w.call(null, 'GET', '/api/v1/status/incidents');assert.strictEqual(pub.status, 200);assert.ok(!pub.text.includes('long-closed-incident-title'), 'an incident closed 40 days ago is not listed');@@@ -120,8 +121,10 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');}assert.deepStrictEqual(bad, [], `private reads answered:\n${bad.join('\n')}`);// A service principal reaches only the /internal routes its capabilities name.-- for (const p of ['/internal/stats', `/internal/users/${users.alice.id}`, `/internal/users/${users.alice.id}/linked-accounts`, '/internal/anon-list', '/internal/integrations/github-token', `/internal/notifications/unread/${users.alice.id}`]) {-- assert.strictEqual(st[`service GET ${p}`], 403, `${p} needs the legacy key or its own capability`);++ assert.strictEqual(st['service GET /internal/integrations/github-token'], 403, 'github-token needs the legacy key or its own capability');++ // Deleted with the X-Internal-Key retirement (plan T2, no caller left): no longer routes at all.++ for (const p of ['/internal/stats', `/internal/users/${users.alice.id}`, `/internal/users/${users.alice.id}/linked-accounts`, '/internal/anon-list', `/internal/notifications/unread/${users.alice.id}`]) {++ assert.strictEqual(st[`service GET ${p}`], undefined, `${p} is gone`);}// Lists and search: bob's view of projects never includes alice's, even asking for all.@@@ -60,18 +60,16 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.assert.strictEqual(r.status, 401, 'a FedCM assertion cannot sign in to another client through /oauth/confirm');r = await post('/api/auth/refresh', {}, { authorization: `Bearer ${assertion}` });assert.strictEqual(r.status, 401, 'a FedCM assertion cannot be refreshed into a full session');-- r = await post('/internal/verify-token', { token: assertion }, { 'x-internal-key': config.internalKey });-- assert.strictEqual(r.body.valid, false, 'internal verify-token does not call a FedCM assertion a user token');++ // POST /internal/verify-token (key-only, no caller left) was deleted with the X-Internal-Key retirement (plan T2):++ // services verify tokens offline against the JWKS.r = await post('/internal/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });-- assert.strictEqual(r.body.valid, true);++ assert.strictEqual(r.status, 404, 'internal verify-token is gone');// nginx only lets loopback reach `location /internal/` (case-sensitive); Express mounts are not,// so another spelling must not reach the internal router from outside.-- r = await post('/INTERNAL/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });-- assert.strictEqual(r.status, 404, '/INTERNAL/... is not the internal API');-- r = await post('/Internal/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });-- assert.strictEqual(r.status, 404);-- r = await post('/internal/verify-token', { token: sign(7) }, { 'x-internal-key': 'k'.repeat(31) + 'x' });-- assert.strictEqual(r.status, 403, 'wrong internal key');++ const get = (p, headers) => fetch(base + p, { headers }).then((x) => x.status);++ assert.strictEqual(await get('/INTERNAL/coins/stats', { 'x-internal-key': config.internalKey }), 404, '/INTERNAL/... is not the internal API');++ assert.strictEqual(await get('/Internal/coins/stats', { 'x-internal-key': config.internalKey }), 404);++ assert.strictEqual(await get('/internal/coins/stats', { 'x-internal-key': 'k'.repeat(31) + 'x' }), 403, 'wrong internal key');// ── Revoked tokens (password changed after iat) ──────────────────const old = sign(8, { expiresIn: '1h' });@@@ -86,8 +86,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22assert.strictEqual(statusFromReady(okRes, { ready: true, status: 'ready', failed: [], degraded: [], checks: { a: skippedCheck } }).status, 'degraded', 'all skipped: not green');assert.strictEqual(statusFromReady(okRes, { ready: true, status: 'ready', failed: [], degraded: [], checks: {} }).status, 'degraded', 'no checks: not green');assert.strictEqual(statusFromReady({ ok: false }, { status: 'x' }).status, 'down');-- assert.strictEqual(by.realtime.status, 'not-running');-- assert.strictEqual(by.realtime.label, 'not running (placeholder)');++ assert.ok(!('realtime' in by), 'openvibe-contracts 0.78.0 removed the realtime manifest');assert.strictEqual(by.contracts.status, 'not-running');assert.strictEqual(by.contracts.label, `not running (library, released v${require('openvibe-contracts/package.json').version})`);for (const s of r.body.services) assert.ok(s.checked_at, `${s.id} says when it was checked`);@@@ -121,7 +120,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22assert.ok(html.includes('<meta name="robots" content="noindex, nofollow">'));assert.ok(html.includes('id="svc-media"') && /id="svc-media"[\s\S]*?Degraded/.test(html));assert.ok(/id="svc-community"[\s\S]*?Down[\s\S]*?SQLITE_CANTOPEN/.test(html));-- assert.ok(/id="svc-realtime"[\s\S]*?not running \(placeholder\)/.test(html));++ assert.ok(!html.includes('id="svc-realtime"'), 'the page lists no realtime row, the manifest is gone');assert.ok(/id="svc-tools"[\s\S]*?liveness only/.test(html));assert.ok(html.includes('<noscript>'), 'navigation without JavaScript');assert.ok(html.includes('SLO categories (proposals)'));
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,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",
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -11,18 +11,20 @@
@@ -11,18 +11,20 @@
const express = require('express');
const express = require('express');
const router = express.Router();
const router = express.Router();
-module.exports = function createAnalyticsRoutes(analytics, requireAuth, config) {
+module.exports = function createAnalyticsRoutes(analytics, requireAuth, config, { selfToken = () => null } = {}) {
- // Service-to-service calls carry the deployment's INTERNAL_API_KEY. (This used to be a constant in
- // the source, which any reader of the repository could replay against the public tool hosts.)
+ // Tools is asked with a Network service token (tools.analytics.read); the deployment's INTERNAL_API_KEY rides
+ // along only while Tools moves to tokens (plan T2), then goes.
const INTERNAL_KEY = config.internalKey;
const INTERNAL_KEY = config.internalKey;
// All remote services with their internal URLs and fetch strategy
// All remote services with their internal URLs and fetch strategy
const REMOTE_SERVICES = [
const REMOTE_SERVICES = [
{ name: 'live', label: 'OpenVibe.Live', url: config.services?.live?.internalUrl || 'http://127.0.0.1:3000', path: '/api/admin/analytics', auth: 'bearer' },
{ name: 'live', label: 'OpenVibe.Live', url: config.services?.live?.internalUrl || 'http://127.0.0.1:3000', path: '/api/admin/analytics', auth: 'bearer' },
{ name: 'tools', label: 'OpenVibe.Tools', url: config.services?.tools?.internalUrl || 'http://127.0.0.1:4001', path: '/api/internal/analytics', auth: 'internal' },
{ name: 'tools', label: 'OpenVibe.Tools', url: config.services?.tools?.internalUrl || 'http://127.0.0.1:4001', path: '/api/internal/analytics', auth: 'internal' },
- { name: 'games', label: 'OpenVibe.Games', url: config.services?.games?.internalUrl || 'http://127.0.0.1:8000', path: '/api/internal/analytics', auth: 'internal' },
- { name: 'media', label: 'OpenVibe.Media', url: config.services?.media?.internalUrl || 'http://127.0.0.1:4100', path: '/api/internal/analytics', auth: 'internal' },
+ // Games and Media keep no analytics of their own and never had this route: the navbar's page-view count is
+ // their number (beaconFallback), without asking them.
+ { name: 'games', label: 'OpenVibe.Games', url: config.services?.games?.internalUrl || 'http://127.0.0.1:8000', path: null, auth: 'beacon' },
+ { name: 'media', label: 'OpenVibe.Media', url: config.services?.media?.internalUrl || 'http://127.0.0.1:4100', path: null, auth: 'beacon' },
];
];
function requireAdmin(req, res, next) {
function requireAdmin(req, res, next) {
@@ -52,9 +54,12 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config)
@@ -52,9 +54,12 @@ module.exports = function createAnalyticsRoutes(analytics, requireAuth, config)
const url = `${svc.url}${svc.path}${subPath}?${qs}`;
const url = `${svc.url}${svc.path}${subPath}?${qs}`;
const headers = { 'Content-Type': 'application/json' };
const headers = { 'Content-Type': 'application/json' };
+ if (svc.auth === 'beacon') return subPath ? null : beaconFallback(svc, days);
if (svc.auth === 'internal') {
if (svc.auth === 'internal') {
- if (!INTERNAL_KEY || INTERNAL_KEY === 'change-me-in-production') return null;
- headers['X-Internal-Key'] = INTERNAL_KEY;
+ const t = selfToken(`openvibe.${svc.name}`, [`${svc.name}.analytics.read`]);
+ if (t) headers['Authorization'] = `Bearer ${t}`;
+ if (INTERNAL_KEY && INTERNAL_KEY !== 'change-me-in-production') headers['X-Internal-Key'] = INTERNAL_KEY;
+ if (!headers['Authorization'] && !headers['X-Internal-Key']) return null;
} else {
} else {
headers['Authorization'] = `Bearer ${token}`;
headers['Authorization'] = `Bearer ${token}`;
}
}
@@ -118,9 +118,13 @@ async function refreshService(req, serviceName) {
@@ -118,9 +118,13 @@ async function refreshService(req, serviceName) {
const controller = new AbortController();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
const timer = setTimeout(() => controller.abort(), 3000);
try {
try {
+ // Live's refresh takes live.url_registry.refresh; the key rides along only while services move to tokens (T2).
+ const cap = `${serviceName}.url_registry.refresh`;
+ const t = serviceName === 'live' && req.app.locals.selfToken ? req.app.locals.selfToken('openvibe.live', [cap]) : null;
+ const key = req.app.locals.config?.internalKey;
const r = await fetch(info.target, {
const r = await fetch(info.target, {
method: 'POST',
method: 'POST',
- headers: { 'X-Internal-Key': req.app.locals.config?.internalKey || '' },
+ headers: { ...(key && key !== 'change-me-in-production' ? { 'X-Internal-Key': key } : {}), ...(t ? { Authorization: `Bearer ${t}` } : {}) },
signal: controller.signal,
signal: controller.signal,
});
});
const baseResult = {
const baseResult = {
@@ -58,7 +58,7 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {
@@ -58,7 +58,7 @@ function aiSiteCopy({ privateKey, issuer, aiUrl }) {
};
};
}
}
-function createFrameService(db, config, analytics, { privateKey = null, issuer = null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' } = {}) {
+function createFrameService(db, config, analytics, { privateKey = null, issuer = null, selfToken = () => null, aiUrl = process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700' } = {}) {
const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;
const fromAi = privateKey && issuer ? aiSiteCopy({ privateKey, issuer, aiUrl: String(aiUrl).replace(/\/+$/, '') }) : null;
// Renamed from chrome_cache/chrome_hits (2026-09-24): carry the old tables over once.
// Renamed from chrome_cache/chrome_hits (2026-09-24): carry the old tables over once.
for (const [from, to] of [['chrome_cache', 'frame_cache'], ['chrome_hits', 'frame_hits']]) {
for (const [from, to] of [['chrome_cache', 'frame_cache'], ['chrome_hits', 'frame_hits']]) {
@@ -85,8 +85,13 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
@@ -85,8 +85,13 @@ function createFrameService(db, config, analytics, { privateKey = null, issuer =
async function refreshRank() {
async function refreshRank() {
const scores = Object.assign({}, rank.scores);
const scores = Object.assign({}, rank.scores);
- // Live runs its own navbar and its own analytics: ask it for totals (internal key, totals only).
- const live = await getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/analytics-summary?days=7`, { 'X-Internal-Key': config.internalKey });
+ // Live runs its own navbar and its own analytics: ask it for totals (live.analytics.read, totals only). The key
+ // rides along only while Live moves to tokens (plan T2).
+ const liveToken = selfToken('openvibe.live', ['live.analytics.read']);
+ const live = await getJson(`${svcUrl('live', 'http://127.0.0.1:3000')}/internal/analytics-summary?days=7`, {
+ ...(config.internalKey && config.internalKey !== 'change-me-in-production' ? { 'X-Internal-Key': config.internalKey } : {}),
+ ...(liveToken ? { Authorization: `Bearer ${liveToken}` } : {}),
+ });
if (live && live.summary) scores.live = Math.round(Number(live.summary.total_pageviews) || 0);
if (live && live.summary) scores.live = Math.round(Number(live.summary.total_pageviews) || 0);
// Everything else: the navbar's page-view beacon, summed per site over 7 days.
// Everything else: the navbar's page-view beacon, summed per site over 7 days.
let tools = rank.tools || [];
let tools = rank.tools || [];
@@ -179,6 +179,12 @@ const DEFAULT_GRANTS = [
@@ -179,6 +179,12 @@ const DEFAULT_GRANTS = [
['billing', 'identity.subject.resolve', SELF_AUDIENCE, []],
['billing', 'identity.subject.resolve', SELF_AUDIENCE, []],
['chat', 'identity.subject.resolve', SELF_AUDIENCE, []],
['chat', 'identity.subject.resolve', SELF_AUDIENCE, []],
['live', 'identity.subject.resolve', SELF_AUDIENCE, []],
['live', 'identity.subject.resolve', SELF_AUDIENCE, []],
+ // X-Internal-Key retirement (plan T2): Live's avatar picker reports to Network, and Live reads the URL registry and
+ // the OpenCoins totals with capabilities of their own (the registry read used identity.subject.resolve, the coin
+ // totals network.coins.credit).
+ ['live', 'network.avatar.write', SELF_AUDIENCE, []],
+ ['live', 'network.registry.read', SELF_AUDIENCE, []],
+ ['live', 'network.coins.read', SELF_AUDIENCE, []],
// Media records each object's owner as a canonical subject (roadmap D01/D20): it resolves the app-local
// Media records each object's owner as a canonical subject (roadmap D01/D20): it resolves the app-local
// owner ids it is given (X-OV-User-Id) through resolve-batch, in its backfill and its reconcile job.
// owner ids it is given (X-OV-User-Id) through resolve-batch, in its backfill and its reconcile job.
['media', 'identity.subject.resolve', SELF_AUDIENCE, []],
['media', 'identity.subject.resolve', SELF_AUDIENCE, []],
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+/**
+ * Network's own service tokens for its calls to other services. Network is the token issuer, so it signs them itself
+ * (sub svc:network, 5 minutes, one audience, the capabilities the call performs), the way the receivers verify every
+ * other service's token (identity.service-token-claims@1). One cached token per audience + capability set.
+ *
+ * const token = createSelfTokens({ privateKey, issuer });
+ * token('openvibe.live', ['live.avatar.write']) → 'eyJ…' | null (no RS256 key: a development HS256 setup)
+ */
+const crypto = require('crypto');
+
+const TTL_S = 300;
+
+function createSelfTokens({ privateKey, issuer, now = () => Date.now() } = {}) {
+ const cache = new Map();
+ return function token(audience, caps, { ns = null } = {}) {
+ if (!privateKey || !String(privateKey).includes('BEGIN') || !issuer || !audience) return null;
+ const list = [...caps].sort();
+ const key = `${audience}|${list.join(',')}|${ns ? [...ns].sort().join(',') : ''}`;
+ const t = Math.floor(now() / 1000);
+ const hit = cache.get(key);
+ if (hit && hit.exp - 60 > t) return hit.token;
+ const claims = { iss: issuer, sub: 'svc:network', actor_type: 'service', aud: [audience], cap: list, ...(ns ? { ns: [...ns] } : {}),
+ iat: t, exp: t + TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}` };
+ const signed = require('openvibe-contracts').serviceAuth.signServiceToken(claims, privateKey);
+ cache.set(key, { token: signed, exp: claims.exp });
+ return signed;
+ };
+}
+
+module.exports = { createSelfTokens };
@@ -356,6 +356,9 @@ try {
@@ -356,6 +356,9 @@ try {
// Make keys available to route modules
// Make keys available to route modules
app.locals.db = db;
app.locals.db = db;
app.locals.privateKey = privateKey;
app.locals.privateKey = privateKey;
+// Network's own service tokens for its calls to Live, Media and Tools (identity/self-token.js).
+const selfToken = require('./identity/self-token').createSelfTokens({ privateKey, issuer: config.jwt.issuer });
+app.locals.selfToken = selfToken;
app.locals.publicKey = publicKey;
app.locals.publicKey = publicKey;
app.locals.config = config;
app.locals.config = config;
@@ -605,14 +608,14 @@ app.get('/api/catalog.json', async (req, res) => {
@@ -605,14 +608,14 @@ app.get('/api/catalog.json', async (req, res) => {
});
});
// The avatar: one picture per account, hosted on openvibe.media, used by every site (server/profile/avatar.js).
// The avatar: one picture per account, hosted on openvibe.media, used by every site (server/profile/avatar.js).
-const avatarService = require('./profile/avatar').createAvatarService({ db, config, requireAuth });
+const avatarService = require('./profile/avatar').createAvatarService({ db, config, requireAuth, selfToken });
app.use('/api/profile/avatar', rateLimit({ windowMs: 60_000, max: 20 }), avatarService.api);
app.use('/api/profile/avatar', rateLimit({ windowMs: 60_000, max: 20 }), avatarService.api);
app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub);
app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub);
app.locals.avatarService = avatarService;
app.locals.avatarService = avatarService;
// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).
// The OpenVibe Frame: analytics-ranked navigation + footer copy for every site (server/frame).
// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.
// /api/chrome is the old name, kept for copies of openvibe-shared older than 1.11.0.
-const frameService = require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer });
+const frameService = require('./frame/service').createFrameService(db, config, analytics, { privateKey, issuer: config.jwt.issuer, selfToken });
const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });
const frameLimit = rateLimit({ windowMs: 60_000, max: 240 });
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/frame', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
app.use('/api/chrome', frameLimit, frameService.router);
@@ -660,7 +663,7 @@ app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, r
@@ -660,7 +663,7 @@ app.use('/api/admin', createAdminRoutes(db, notificationService, emailService, r
// Analytics admin API
// Analytics admin API
const createAnalyticsRoutes = require('./admin/analytics-routes');
const createAnalyticsRoutes = require('./admin/analytics-routes');
-app.use('/api/admin/analytics', createAnalyticsRoutes(analytics, requireAuth, config));
+app.use('/api/admin/analytics', createAnalyticsRoutes(analytics, requireAuth, config, { selfToken }));
// ── Admin Proxy to OpenVibe.Live ──────────────────────────────
// ── Admin Proxy to OpenVibe.Live ──────────────────────────────
// Proxies /api/admin/streamer/* → openvibe.live /api/admin/*
// Proxies /api/admin/streamer/* → openvibe.live /api/admin/*
@@ -1,11 +1,11 @@
@@ -1,11 +1,11 @@
'use strict';
'use strict';
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-// openvibe.network — Internal Server-to-Server API
-// Used by OpenVibe.Live (3000), OpenVibe.Tools (4001),
-// OpenVibe.Games (8000), and OpenVibe.Media (4100) to verify
-// tokens, sync users, move OpenCoins, and fetch shared data.
-// Protected by X-Internal-Key header.
+// openvibe.network — Internal Server-to-Server API (loopback only)
+// First-party services resolve identities, move OpenCoins, push notifications and read
+// shared data here. Every route checks the one capability it performs on a service token
+// (identity/principals.js guard); X-Internal-Key is being retired (plan T2) and is still
+// accepted only where a route's guard allows the legacy path.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
const express = require('express');
const express = require('express');
@@ -59,6 +59,8 @@ const principals = require('../identity/principals');
@@ -59,6 +59,8 @@ const principals = require('../identity/principals');
const TOKEN_ROUTES = new Set(['GET /identity/resolve', 'POST /identity/resolve-batch', 'POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk', 'POST /events/stream-live',
const TOKEN_ROUTES = new Set(['GET /identity/resolve', 'POST /identity/resolve-batch', 'POST /coins/credit', 'POST /coins/debit', 'POST /coins/transfer', 'POST /notifications/push', 'POST /notifications/push-bulk', 'POST /events/stream-live',
// Internal-key retirement (register C-50/C-52): the routes Live still calls with the key also take a token.
// Internal-key retirement (register C-50/C-52): the routes Live still calls with the key also take a token.
'GET /url-registry/resolved', 'GET /coins/stats', 'POST /resolve-anon', 'POST /identity/legacy-map', 'POST /link-account',
'GET /url-registry/resolved', 'GET /coins/stats', 'POST /resolve-anon', 'POST /identity/legacy-map', 'POST /link-account',
+ // The last two Live called with the key only (C-50): Live's avatar picker and its mark-read-by-type.
+ 'POST /user-avatar', 'POST /notifications/mark-read',
// Token only (legacy: false): Host's relay of the alerts firing on the production host (WS-H task 11).
// Token only (legacy: false): Host's relay of the alerts firing on the production host (WS-H task 11).
'POST /operator/alerts']);
'POST /operator/alerts']);
const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT|DELETE) \/modules\/[a-z0-9_.]+\/[A-Za-z0-9_]+$/];
const TOKEN_ROUTE_PATTERNS = [/^(GET|PUT|DELETE) \/modules\/[a-z0-9_.]+\/[A-Za-z0-9_]+$/];
@@ -86,81 +88,6 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolv
@@ -86,81 +88,6 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolv
router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
router.use('/identity', require('../identity/internal-routes'));
router.use('/identity', require('../identity/internal-routes'));
-// ── Verify Token ─────────────────────────────────────────────
-// Other services call this to validate an access token and get user data.
-// Avoids each service needing the public key locally (though they can — this is a convenience).
-const jwt = require('jsonwebtoken');
-
-router.post('/verify-token', (req, res) => {
- const { token } = req.body;
- if (!token) return res.status(400).json({ error: 'token required' });
-
- const publicKey = req.app.locals.publicKey;
- const config = getConfig(req);
- const algorithm = publicKey.includes('BEGIN') ? 'RS256' : 'HS256';
-
- try {
- const decoded = jwt.verify(token, publicKey, {
- algorithms: [algorithm],
- issuer: config.jwt.issuer
- });
- // A FedCM assertion or a service/app token (same signing key) is not a user token.
- if (!require('../auth/session').isUserSessionClaims(decoded)) return res.json({ valid: false, error: 'not a user token' });
- const db = getDb(req);
- const user = db.prepare('SELECT id, username, display_name, role, avatar_url, profile_color AS color FROM users WHERE id = ?').get(decoded.sub || decoded.id);
- res.json({ valid: true, decoded, user: user || null });
- } catch (err) {
- res.json({ valid: false, error: err.message });
- }
-});
-
-// ── Get User by ID ───────────────────────────────────────────
-router.get('/users/:id', (req, res) => {
- const db = getDb(req);
- const user = db.prepare(`
- SELECT id, username, display_name, role, avatar_url, profile_color AS color, bio, created_at
- FROM users WHERE id = ?
- `).get(req.params.id);
- if (!user) return res.status(404).json({ error: 'User not found' });
- res.json({ user });
-});
-
-// ── Lookup User by Username ──────────────────────────────────
-router.get('/users/by-username/:username', (req, res) => {
- const db = getDb(req);
- const user = db.prepare(`
- SELECT id, username, display_name, role, avatar_url, profile_color AS color, bio, created_at
- FROM users WHERE username = ?
- `).get(req.params.username.toLowerCase());
- if (!user) return res.status(404).json({ error: 'User not found' });
- res.json({ user });
-});
-
-// ── Bulk User Lookup ─────────────────────────────────────────
-router.post('/users/bulk', (req, res) => {
- const { ids } = req.body;
- if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' });
- if (ids.length > 200) return res.status(400).json({ error: 'Max 200 ids per request' });
-
- const db = getDb(req);
- const placeholders = ids.map(() => '?').join(',');
- const users = db.prepare(`
- SELECT id, username, display_name, role, avatar_url, profile_color AS color
- FROM users WHERE id IN (${placeholders})
- `).all(...ids);
- res.json({ users });
-});
-
-// ── Get User Theme Preference ────────────────────────────────
-router.get('/users/:id/theme', (req, res) => {
- const db = getDb(req);
- const prefs = db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(req.params.id);
- if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null });
- let custom = null;
- try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}
- res.json({ theme_id: prefs.theme_id, custom_variables: custom });
-});
-
// ── Sync Linked Account ──────────────────────────────────────
// ── Sync Linked Account ──────────────────────────────────────
// When a user connects their OpenVibe.Live or OpenVibe.Games account,
// When a user connects their OpenVibe.Live or OpenVibe.Games account,
// the service reports the link here.
// the service reports the link here.
@@ -208,39 +135,13 @@ router.post('/link-account', principals.guard('identity.subject.resolve', { ownA
@@ -208,39 +135,13 @@ router.post('/link-account', principals.guard('identity.subject.resolve', { ownA
});
});
// ── A site changed someone's avatar (Live's avatar picker) ───
// ── A site changed someone's avatar (Live's avatar picker) ───
-router.post('/user-avatar', (req, res) => {
+router.post('/user-avatar', principals.guard('network.avatar.write'), (req, res) => {
const svc = req.app.locals.avatarService;
const svc = req.app.locals.avatarService;
if (!svc) return res.status(503).json({ error: 'avatar service unavailable' });
if (!svc) return res.status(503).json({ error: 'avatar service unavailable' });
const r = svc.fromSite(req.body || {});
const r = svc.fromSite(req.body || {});
res.status(r.status).json(r.error ? { error: r.error } : { ok: true, changed: r.changed });
res.status(r.status).json(r.error ? { error: r.error } : { ok: true, changed: r.changed });
});
});
-// ── Get Linked Accounts ──────────────────────────────────────
-router.get('/users/:id/linked-accounts', (req, res) => {
- const db = getDb(req);
- const accounts = db.prepare('SELECT service, service_user_id, service_username, linked_at FROM linked_accounts WHERE user_id = ?').all(req.params.id);
- res.json({ accounts });
-});
-
-// ── Audit Log ────────────────────────────────────────────────
-router.post('/audit', (req, res) => {
- const { user_id, action, details, ip } = req.body;
- if (!action) return res.status(400).json({ error: 'action required' });
- const db = getDb(req);
- db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(user_id || null, action, details || null, ip || null);
- res.json({ success: true });
-});
-
-// ── Health / Stats ───────────────────────────────────────────
-router.get('/stats', (req, res) => {
- const db = getDb(req);
- const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
- const themeCount = db.prepare('SELECT COUNT(*) as count FROM themes').get().count;
- const linkedCount = db.prepare('SELECT COUNT(*) as count FROM linked_accounts').get().count;
- const notifCount = db.prepare('SELECT COUNT(*) as count FROM notifications').get().count;
- res.json({ users: userCount, themes: themeCount, linked_accounts: linkedCount, notifications: notifCount });
-});
-
// Service URLs only (no secrets); any first-party service that resolves identities may read them.
// Service URLs only (no secrets); any first-party service that resolves identities may read them.
// Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the owner's, not the services'.
// Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the owner's, not the services'.
// TODO(contracts): a network.registry.read capability would say it more exactly.
// TODO(contracts): a network.registry.read capability would say it more exactly.
@@ -517,24 +418,10 @@ router.post('/operator/alerts', principals.guard('network.operator.alert', { leg
@@ -517,24 +418,10 @@ router.post('/operator/alerts', principals.guard('network.operator.alert', { leg
}
}
});
});
-// ── Get Unread Count for User ────────────────────────────────
-// GET /internal/notifications/unread/:userId
-router.get('/notifications/unread/:userId', (req, res) => {
- const notifService = req.app.locals.notificationService;
- if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
-
- try {
- const count = notifService.getUnreadCount(parseInt(req.params.userId));
- res.json({ ok: true, count });
- } catch (err) {
- res.status(500).json({ error: err.message });
- }
-});
-
// ── Mark Notifications Read by Type ──────────────────────────
// ── Mark Notifications Read by Type ──────────────────────────
// POST /internal/notifications/mark-read
// POST /internal/notifications/mark-read
// Body: { user_id, type, url_pattern? }
// Body: { user_id, type, url_pattern? }
-router.post('/notifications/mark-read', (req, res) => {
+router.post('/notifications/mark-read', principals.guard('network.notifications.push'), (req, res) => {
const notifService = req.app.locals.notificationService;
const notifService = req.app.locals.notificationService;
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
if (!notifService) return res.status(503).json({ error: 'Notification service unavailable' });
@@ -550,51 +437,6 @@ router.post('/notifications/mark-read', (req, res) => {
@@ -550,51 +437,6 @@ router.post('/notifications/mark-read', (req, res) => {
}
}
});
});
-// ── Resolve User for Notification Context ────────────────────
-// POST /internal/notifications/resolve-users
-// Body: { usernames: [] } → returns user IDs + display info
-router.post('/notifications/resolve-users', (req, res) => {
- const { usernames } = req.body;
- if (!Array.isArray(usernames) || usernames.length === 0) {
- return res.status(400).json({ error: 'usernames array required' });
- }
- const db = getDb(req);
- const placeholders = usernames.map(() => '?').join(',');
- const users = db.prepare(`
- SELECT id, username, display_name, avatar_url, name_effect, particle_effect
- FROM users WHERE LOWER(username) IN (${placeholders})
- `).all(...usernames.map(u => u.toLowerCase()));
- res.json({ ok: true, users });
-});
-
-// ── Issue Token for Linked User ──────────────────────────────
-// POST /internal/issue-token
-// Body: { user_id }
-// Used by first-party services to get an openvibe.network JWT for
-// users who logged in via password but have a linked account.
-// This enables cross-service features (notifications, themes).
-router.post('/issue-token', (req, res) => {
- const { user_id } = req.body;
- if (!user_id) return res.status(400).json({ error: 'user_id required' });
-
- const db = getDb(req);
- const config = getConfig(req);
- const user = db.prepare('SELECT * FROM users WHERE id = ?').get(user_id);
- if (!user) return res.status(404).json({ error: 'User not found' });
- if (user.is_banned) return res.status(403).json({ error: 'User is banned' });
-
- const privateKey = req.app.locals.privateKey;
- const algorithm = privateKey === req.app.locals.publicKey ? 'HS256' : 'RS256';
-
- const token = jwt.sign(
- { sub: user.id, username: user.username, role: user.role },
- privateKey,
- { algorithm, expiresIn: config.jwt.accessTokenExpiry, issuer: config.jwt.issuer }
- );
-
- res.json({ token, user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role, avatar_url: user.avatar_url } });
-});
-
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Unified Anon Identity Resolution (cross-service)
// Unified Anon Identity Resolution (cross-service)
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
@@ -679,82 +521,4 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req,
@@ -679,82 +521,4 @@ router.post('/resolve-anon', principals.guard('identity.subject.resolve'), (req,
}
}
});
});
-// ── Admin: IP → Anon/Account Lookup ──────────────────────────
-// GET /internal/anon-admin?ip=X
-// Returns all anonymous identities AND registered accounts
-// associated with a given IP address.
-router.get('/anon-admin', (req, res) => {
- const { ip } = req.query;
- if (!ip) return res.status(400).json({ error: 'ip query param required' });
-
- const db = getDb(req);
-
- try {
- // Get all anons seen from this IP
- const anons = db.prepare(`
- SELECT a.id, a.anon_number, a.display_name, a.ip AS creating_ip,
- a.total_messages, a.total_commands, a.first_seen, a.last_seen,
- l.first_seen AS ip_first_seen, l.last_seen AS ip_last_seen
- FROM anon_users a
- INNER JOIN anon_ip_log l ON l.anon_id = a.id
- WHERE l.ip = ?
- ORDER BY a.anon_number ASC
- `).all(ip);
-
- // Get all registered users who have logged in from this IP
- const users = db.prepare(`
- SELECT DISTINCT u.id, u.username, u.display_name, u.role, u.is_banned,
- u.anon_number, u.created_at
- FROM users u
- INNER JOIN ip_log il ON il.user_id = u.id
- WHERE il.ip = ?
- ORDER BY u.created_at ASC
- `).all(ip);
-
- // Get all IPs for each anon (cross-reference)
- const anonIps = {};
- for (const a of anons) {
- const ips = db.prepare('SELECT ip, first_seen, last_seen FROM anon_ip_log WHERE anon_id = ?').all(a.id);
- anonIps[a.anon_number] = ips;
- }
-
- res.json({
- ip,
- anonymous_identities: anons,
- registered_accounts: users,
- anon_ip_map: anonIps,
- });
- } catch (err) {
- console.error('[Internal] anon-admin error:', err);
- res.status(500).json({ error: 'Failed to lookup IP data' });
- }
-});
-
-// ── Admin: List All Anon Identities ──────────────────────────
-// GET /internal/anon-list?limit=100&offset=0
-router.get('/anon-list', (req, res) => {
- const db = getDb(req);
- const limit = Math.min(parseInt(req.query.limit) || 100, 500);
- const offset = parseInt(req.query.offset) || 0;
-
- try {
- const total = db.prepare('SELECT COUNT(*) as cnt FROM anon_users').get().cnt;
- const anons = db.prepare(`
- SELECT a.id, a.anon_number, a.display_name, a.ip AS creating_ip,
- a.total_messages, a.total_commands, a.first_seen, a.last_seen
- FROM anon_users a ORDER BY a.anon_number DESC LIMIT ? OFFSET ?
- `).all(limit, offset);
-
- // Attach IP list to each anon
- for (const a of anons) {
- a.ips = db.prepare('SELECT ip, last_seen FROM anon_ip_log WHERE anon_id = ?').all(a.id);
- }
-
- res.json({ total, anons, limit, offset });
- } catch (err) {
- console.error('[Internal] anon-list error:', err);
- res.status(500).json({ error: 'Failed to list anons' });
- }
-});
-
module.exports = router;
module.exports = router;
@@ -54,7 +54,14 @@ const initialSvg = (name, size) => {
@@ -54,7 +54,14 @@ const initialSvg = (name, size) => {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 96 96"><rect width="96" height="96" fill="hsl(${h} 45% 42%)"/><text x="48" y="50" font-family="system-ui,sans-serif" font-size="46" font-weight="700" fill="#fff" text-anchor="middle" dominant-baseline="middle">${ch}</text></svg>`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 96 96"><rect width="96" height="96" fill="hsl(${h} 45% 42%)"/><text x="48" y="50" font-family="system-ui,sans-serif" font-size="46" font-weight="700" fill="#fff" text-anchor="middle" dominant-baseline="middle">${ch}</text></svg>`;
};
};
-function createAvatarService({ db, config, requireAuth, log = console }) {
+function createAvatarService({ db, config, requireAuth, selfToken = () => null, log = console }) {
+ // A service token for the call (live.avatar.write / media.avatar.ingest); X-Internal-Key rides along only while the
+ // receivers move to tokens (plan T2), then goes.
+ const legacyKey = () => (config.internalKey && config.internalKey !== 'change-me-in-production' ? { 'X-Internal-Key': config.internalKey } : {});
+ function authHeaders(audience, cap) {
+ const t = selfToken(audience, [cap]);
+ return { ...legacyKey(), ...(t ? { Authorization: `Bearer ${t}` } : {}) };
+ }
const getUrl = db.prepare('SELECT avatar_url FROM users WHERE id = ?');
const getUrl = db.prepare('SELECT avatar_url FROM users WHERE id = ?');
const byName = db.prepare('SELECT username, avatar_url FROM users WHERE lower(username) = lower(?)');
const byName = db.prepare('SELECT username, avatar_url FROM users WHERE lower(username) = lower(?)');
const setUrl = db.prepare('UPDATE users SET avatar_url = ? WHERE id = ?');
const setUrl = db.prepare('UPDATE users SET avatar_url = ? WHERE id = ?');
@@ -62,17 +69,19 @@ function createAvatarService({ db, config, requireAuth, log = console }) {
@@ -62,17 +69,19 @@ function createAvatarService({ db, config, requireAuth, log = console }) {
/** Tell the sites that keep their own copy. Fire and forget: they also pick it up at the next sign-in. */
/** Tell the sites that keep their own copy. Fire and forget: they also pick it up at the next sign-in. */
function pushToSites(userId, username, url) {
function pushToSites(userId, username, url) {
const live = config.services && config.services.live && config.services.live.internalUrl || 'http://127.0.0.1:3000';
const live = config.services && config.services.live && config.services.live.internalUrl || 'http://127.0.0.1:3000';
- if (!config.internalKey || config.internalKey === 'change-me-in-production') return;
- fetch(`${live.replace(/\/$/, '')}/internal/user-avatar`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Internal-Key': config.internalKey },
+ const auth = authHeaders('openvibe.live', 'live.avatar.write');
+ if (!Object.keys(auth).length) return;
+ fetch(`${live.replace(/\/$/, '')}/internal/user-avatar`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...auth },
body: JSON.stringify({ openvibenetwork_id: userId, username, avatar_url: url }), signal: AbortSignal.timeout(5000) }).catch(() => {});
body: JSON.stringify({ openvibenetwork_id: userId, username, avatar_url: url }), signal: AbortSignal.timeout(5000) }).catch(() => {});
}
}
/** A picture from elsewhere on the web: openvibe.media fetches it safely, re-encodes it and keeps the copy. */
/** A picture from elsewhere on the web: openvibe.media fetches it safely, re-encodes it and keeps the copy. */
async function ingest(url, user) {
async function ingest(url, user) {
const media = (config.services && config.services.media && config.services.media.internalUrl) || 'http://127.0.0.1:4100';
const media = (config.services && config.services.media && config.services.media.internalUrl) || 'http://127.0.0.1:4100';
- if (!config.internalKey || config.internalKey === 'change-me-in-production') return { ok: false, status: 503, error: 'Picture import is not configured on this server' };
+ const auth = authHeaders('openvibe.media', 'media.avatar.ingest');
+ if (!Object.keys(auth).length) return { ok: false, status: 503, error: 'Picture import is not configured on this server' };
try {
try {
- const r = await fetch(`${media.replace(/\/$/, '')}/internal/avatar-ingest`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Internal-Key': config.internalKey },
+ const r = await fetch(`${media.replace(/\/$/, '')}/internal/avatar-ingest`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...auth },
body: JSON.stringify({ url, user_id: user.id, username: user.username }), signal: AbortSignal.timeout(20000) });
body: JSON.stringify({ url, user_id: user.id, username: user.username }), signal: AbortSignal.timeout(20000) });
const j = await r.json().catch(() => ({}));
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.ok) return { ok: false, status: r.status === 404 ? 503 : 422, error: j.error || 'That picture could not be imported' };
if (!r.ok || !j.ok) return { ok: false, status: r.status === 404 ? 503 : 422, error: j.error || 'That picture could not be imported' };
@@ -23,11 +23,17 @@ for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https:
@@ -23,11 +23,17 @@ for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https:
const db = new Database(':memory:');
const db = new Database(':memory:');
db.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT, avatar_url TEXT); CREATE TABLE audit_log(id INTEGER PRIMARY KEY, user_id INT, action TEXT, details TEXT, ip TEXT); INSERT INTO users VALUES (1,'Goosely',NULL),(2,'legacy','https://evil.example/old.png')");
db.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT, avatar_url TEXT); CREATE TABLE audit_log(id INTEGER PRIMARY KEY, user_id INT, action TEXT, details TEXT, ip TEXT); INSERT INTO users VALUES (1,'Goosely',NULL),(2,'legacy','https://evil.example/old.png')");
- const pushes = []; const realFetch = global.fetch; global.fetch = async (u, o) => { pushes.push(JSON.parse(o.body)); return { ok: true }; };
- const svc = createAvatarService({ db, config: { internalKey: 'k'.repeat(24), services: {} }, requireAuth: (req, _res, next) => { req.user = { id: 1, username: 'Goosely' }; next(); } });
+ const pushes = []; const pushHeaders = []; const realFetch = global.fetch; global.fetch = async (u, o) => { pushes.push(JSON.parse(o.body)); pushHeaders.push(o.headers); return { ok: true }; };
+ // Network's own service token for the push (plan T2): live.avatar.write, audience openvibe.live.
+ const { privateKey, publicKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ const selfToken = require('../server/identity/self-token').createSelfTokens({ privateKey, issuer: 'https://openvibe.network' });
+ const svc = createAvatarService({ db, config: { internalKey: 'k'.repeat(24), services: {} }, selfToken, requireAuth: (req, _res, next) => { req.user = { id: 1, username: 'Goosely' }; next(); } });
assert.equal(svc.fromSite({ user_id: 1, avatar_url: `${M}/f/a.png`, origin: 'live' }).changed, true);
assert.equal(svc.fromSite({ user_id: 1, avatar_url: `${M}/f/a.png`, origin: 'live' }).changed, true);
assert.equal(pushes.length, 0, 'a change that came from Live is not echoed back to Live');
assert.equal(pushes.length, 0, 'a change that came from Live is not echoed back to Live');
svc.apply(1, 'Goosely', `${M}/f/b.png`, 'network'); assert.equal(pushes.length, 1); assert.equal(pushes[0].avatar_url, `${M}/f/b.png`, 'a change made here is pushed to the sites');
svc.apply(1, 'Goosely', `${M}/f/b.png`, 'network'); assert.equal(pushes.length, 1); assert.equal(pushes[0].avatar_url, `${M}/f/b.png`, 'a change made here is pushed to the sites');
+ const bearer = String(pushHeaders[0].Authorization || '').replace(/^Bearer /, '');
+ const v = require('openvibe-contracts').serviceAuth.verifyServiceToken(bearer, { publicKey, issuer: 'https://openvibe.network', audience: 'openvibe.live' });
+ assert.ok(v.ok && v.claims.sub === 'svc:network' && v.claims.cap.includes('live.avatar.write'), `the push carries Network's token for live.avatar.write: ${v.reason || ''}`);
assert.equal(svc.fromSite({ user_id: 1, avatar_url: 'https://evil.example/x.png' }).status, 422);
assert.equal(svc.fromSite({ user_id: 1, avatar_url: 'https://evil.example/x.png' }).status, 422);
global.fetch = realFetch;
global.fetch = realFetch;
@@ -53,7 +53,7 @@ const server = http.createServer(app);
@@ -53,7 +53,7 @@ const server = http.createServer(app);
const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
assert.ok(claims.ok, claims.reason);
assert.ok(claims.ok, claims.reason);
assert.strictEqual(claims.claims.sub, 'svc:live');
assert.strictEqual(claims.claims.sub, 'svc:live');
- assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.analytics.creator.read', 'network.coins.credit', 'network.coins.debit', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);
+ assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.analytics.creator.read', 'network.avatar.write', 'network.coins.credit', 'network.coins.debit', 'network.coins.read', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push', 'network.registry.read']);
assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);
assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);
assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
const full = t.body.access_token;
const full = t.body.access_token;
@@ -105,6 +105,14 @@ const server = http.createServer(app);
@@ -105,6 +105,14 @@ const server = http.createServer(app);
assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.denied', 'narrowed token cannot debit');
assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.denied', 'narrowed token cannot debit');
r = await post('/internal/coins/transfer', { from_user_id: 7, to_user_id: 8, app_id: 'live', amount: 2, reason: 't', idempotency_key: 'tr-1' }, { authorization: `Bearer ${full}` });
r = await post('/internal/coins/transfer', { from_user_id: 7, to_user_id: 8, app_id: 'live', amount: 2, reason: 't', idempotency_key: 'tr-1' }, { authorization: `Bearer ${full}` });
assert.strictEqual(r.status, 403, 'ADR-012: loyalty is not transferable between people'); assert.strictEqual(r.body.code, 'capability.denied');
assert.strictEqual(r.status, 403, 'ADR-012: loyalty is not transferable between people'); assert.strictEqual(r.body.code, 'capability.denied');
+ // The two routes Live called with the key only (plan T2): its token opens them, a narrower one does not.
+ r = await post('/internal/user-avatar', { user_id: 7, avatar_url: null, origin: 'live' }, { authorization: `Bearer ${full}` });
+ assert.notStrictEqual(r.status, 403, `network.avatar.write opens /internal/user-avatar: ${JSON.stringify(r.body)}`);
+ assert.notStrictEqual(r.status, 401);
+ r = await post('/internal/notifications/mark-read', { user_id: 7, type: 'follow' }, { authorization: `Bearer ${full}` });
+ assert.notStrictEqual(r.status, 403, `network.notifications.push opens mark-read: ${JSON.stringify(r.body)}`);
+ r = await post('/internal/user-avatar', { user_id: 7, avatar_url: null }, { authorization: `Bearer ${creditOnly}` });
+ assert.strictEqual(r.status, 403, 'a token without network.avatar.write');
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt' });
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.malformed');
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.malformed');
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt', 'x-internal-key': 'legacy-key' });
r = await post('/internal/coins/credit', credit(), { authorization: 'Bearer not-a-jwt', 'x-internal-key': 'legacy-key' });
@@ -36,13 +36,13 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
@@ -36,13 +36,13 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
assert.strictEqual(byId.network.runtime.status, 'up');
assert.strictEqual(byId.network.runtime.status, 'up');
assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');
assert.ok(byId.network.runtime.checked_at, 'every health value says when it was checked');
assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');
assert.strictEqual(byId.live.runtime.status, 'down', 'unreachable is reported, not hidden');
- assert.strictEqual(byId.realtime.runtime.status, 'not-running', 'placeholders are never shown as running');
+ assert.ok(!('realtime' in byId), 'openvibe-contracts 0.78.0 removed the realtime manifest (ADR-005 closed it, never built)');
assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => !['runtime', 'exposure', 'observed'].includes(k)))).valid));
assert.ok(r.body.services.every(s => contracts.validate('registry.service-manifest@1', Object.fromEntries(Object.entries(s).filter(([k]) => !['runtime', 'exposure', 'observed'].includes(k)))).valid));
r = await get('/api/v1/registry/services?status=placeholder');
r = await get('/api/v1/registry/services?status=placeholder');
assert.ok(r.body.services.every(s => s.status === 'placeholder'), 'the status filter only returns that status');
assert.ok(r.body.services.every(s => s.status === 'placeholder'), 'the status filter only returns that status');
- // openvibe-contracts 0.34: realtime is retired (ADR-005 folded it into Events).
+ // openvibe-contracts 0.78.0: the realtime manifest is gone, so nothing is retired by it any more
r = await get('/api/v1/registry/services?status=retired');
r = await get('/api/v1/registry/services?status=retired');
- assert.ok(r.body.services.some(s => s.id === 'realtime') && r.body.services.every(s => s.status === 'retired'));
+ assert.ok(!r.body.services.some(s => s.id === 'realtime') && r.body.services.every(s => s.status === 'retired'));
r = await get('/api/v1/registry/services/network');
r = await get('/api/v1/registry/services/network');
assert.ok(r.body.capability_details.some(c => c.id === 'network.coins.credit'));
assert.ok(r.body.capability_details.some(c => c.id === 'network.coins.credit'));
@@ -76,7 +76,9 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
@@ -76,7 +76,9 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
assert.strictEqual(inCat('live'), 'site'); assert.strictEqual(inCat('network'), 'site'); assert.strictEqual(inCat('tips'), 'site', 'a site whose domain is still a placeholder is a site, not open yet');
assert.strictEqual(inCat('live'), 'site'); assert.strictEqual(inCat('network'), 'site'); assert.strictEqual(inCat('tips'), 'site', 'a site whose domain is still a placeholder is a site, not open yet');
assert.strictEqual(inCat('events'), 'platform'); assert.strictEqual(inCat('ai'), 'platform'); assert.strictEqual(inCat('sources'), 'platform');
assert.strictEqual(inCat('events'), 'platform'); assert.strictEqual(inCat('ai'), 'platform'); assert.strictEqual(inCat('sources'), 'platform');
assert.strictEqual(inCat('sdk'), 'library'); assert.strictEqual(inCat('contracts'), 'library');
assert.strictEqual(inCat('sdk'), 'library'); assert.strictEqual(inCat('contracts'), 'library');
- assert.strictEqual(inCat('examples'), 'repository'); assert.strictEqual(inCat('realtime'), 'planned');
+ assert.strictEqual(inCat('examples'), 'repository');
+ // 'planned' = exposure placeholder, and no manifest has that state since realtime's was removed
+ assert.deepStrictEqual(cats.planned.services, []);
const liveRow = cats.site.services.find(x => x.id === 'live');
const liveRow = cats.site.services.find(x => x.id === 'live');
assert.strictEqual(liveRow.runtime, 'down'); assert.ok(liveRow.checked_at, 'rows carry the last check');
assert.strictEqual(liveRow.runtime, 'down'); assert.ok(liveRow.checked_at, 'rows carry the last check');
r = await get('/api/v1/registry/categories/library');
r = await get('/api/v1/registry/categories/library');
@@ -21,7 +21,6 @@ const PINNED = {
@@ -21,7 +21,6 @@ const PINNED = {
tips: 'internal', vip: 'internal', openre: 'internal', search: 'live', sources: 'internal', chat: 'live', ai: 'internal',
tips: 'internal', vip: 'internal', openre: 'internal', search: 'live', sources: 'internal', chat: 'live', ai: 'internal',
sdk: 'library', shared: 'library', contracts: 'library', publishing: 'library',
sdk: 'library', shared: 'library', contracts: 'library', publishing: 'library',
examples: 'repository',
examples: 'repository',
- realtime: 'placeholder',
};
};
const PLACEHOLDER_DOMAINS = ['news', 'reviews', 'deals', 'coupons', 'trade', 'host', 'tips', 'vip', 'openre'];
const PLACEHOLDER_DOMAINS = ['news', 'reviews', 'deals', 'coupons', 'trade', 'host', 'tips', 'vip', 'openre'];
@@ -86,7 +85,8 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
@@ -86,7 +85,8 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
assert.strictEqual(by.sdk.runtime.status, 'not-running');
assert.strictEqual(by.sdk.runtime.status, 'not-running');
assert.strictEqual(by.sdk.runtime.reason, `library, released v${require('openvibe-sdk/package.json').version}`);
assert.strictEqual(by.sdk.runtime.reason, `library, released v${require('openvibe-sdk/package.json').version}`);
assert.strictEqual(by.examples.runtime.reason, 'repository, nothing to run');
assert.strictEqual(by.examples.runtime.reason, 'repository, nothing to run');
- assert.strictEqual(by.realtime.runtime.reason, 'placeholder');
+ // openvibe-contracts 0.78.0 removed the realtime manifest (ADR-005 closed OpenVibe.Realtime, never built)
+ assert.ok(!('realtime' in by), 'realtime is gone from the exposure map, not a stale row');
assert.strictEqual(by.live.runtime.scope, 'public');
assert.strictEqual(by.live.runtime.scope, 'public');
r = await json('/api/v1/registry/services?state=internal');
r = await json('/api/v1/registry/services?state=internal');
assert.deepStrictEqual(r.services.map(s => s.id).sort(), Object.keys(PINNED).filter(k => PINNED[k] === 'internal').sort());
assert.deepStrictEqual(r.services.map(s => s.id).sort(), Object.keys(PINNED).filter(k => PINNED[k] === 'internal').sort());
@@ -101,7 +101,8 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
@@ -101,7 +101,8 @@ const ready = [200, { ready: true, status: 'ready', failed: [], degraded: [], ch
assert.strictEqual(sBy.live.label, 'up');
assert.strictEqual(sBy.live.label, 'up');
assert.strictEqual(sBy.live.origin, 'https://openvibe.live');
assert.strictEqual(sBy.live.origin, 'https://openvibe.live');
assert.strictEqual(sBy.shared.label, `not running (library, released v${require('openvibe-shared/package.json').version})`);
assert.strictEqual(sBy.shared.label, `not running (library, released v${require('openvibe-shared/package.json').version})`);
- assert.deepStrictEqual(st.exposure_summary, { live: 14, internal: 11, library: 4, repository: 1, placeholder: 1, retired: 0 });
+ // no manifest has exposure 'placeholder' or 'retired' since realtime's was removed in 0.78.0
+ assert.deepStrictEqual(st.exposure_summary, { live: 14, internal: 11, library: 4, repository: 1, placeholder: 0, retired: 0 });
// The page: no row for a service whose domain serves a placeholder shows a bare "Up".
// The page: no row for a service whose domain serves a placeholder shows a bare "Up".
const html = await fetch(base + '/status').then(x => x.text());
const html = await fetch(base + '/status').then(x => x.text());
@@ -113,11 +113,12 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
@@ -113,11 +113,12 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
assert.strictEqual(rel2.network.drift['openvibe-contracts'].state, 'current');
assert.strictEqual(rel2.network.drift['openvibe-contracts'].state, 'current');
assert.ok(r.body.behind.includes('live') && !r.body.behind.includes('network'));
assert.ok(r.body.behind.includes('live') && !r.body.behind.includes('network'));
assert.strictEqual(rel2.media.release, null); assert.ok(rel2.media.error);
assert.strictEqual(rel2.media.release, null); assert.ok(rel2.media.error);
- assert.strictEqual(rel2.realtime.release, null, 'a placeholder has no release');
+ assert.ok(!('realtime' in rel2), 'a removed manifest has no release row');
r = await get('/api/v1/registry/health');
r = await get('/api/v1/registry/health');
const h = Object.fromEntries(r.body.services.map(s => [s.id, s]));
const h = Object.fromEntries(r.body.services.map(s => [s.id, s]));
- assert.strictEqual(h.network.status, 'up'); assert.strictEqual(h.media.status, 'down'); assert.strictEqual(h.realtime.status, 'not-running');
+ assert.strictEqual(h.network.status, 'up'); assert.strictEqual(h.media.status, 'down');
+ assert.ok(!('realtime' in h), 'health is per manifest, and realtime has none');
assert.ok(h.network.checked_at);
assert.ok(h.network.checked_at);
assert.strictEqual(Object.values(r.body.summary).reduce((a, b) => a + b, 0), contracts.services.manifests.length);
assert.strictEqual(Object.values(r.body.summary).reduce((a, b) => a + b, 0), contracts.services.manifests.length);
@@ -92,7 +92,8 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -92,7 +92,8 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
await mine('alice', '/api/history', 'alice-private-history');
await mine('alice', '/api/history', 'alice-private-history');
await mine('alice', `/api/v1/projects/${dev.PA.id}`, 'alice-private-project');
await mine('alice', `/api/v1/projects/${dev.PA.id}`, 'alice-private-project');
await mine('alice', `/api/v1/projects/${dev.PA.id}/apps`, 'alice-private-app');
await mine('alice', `/api/v1/projects/${dev.PA.id}/apps`, 'alice-private-app');
- await mine({ 'x-internal-key': w.sentinels.INTERNAL_API_KEY }, `/internal/users/${users.alice.id}/linked-accounts`, 'alice-games-771');
+ // (The linked-account needle's positive control was GET /internal/users/:id/linked-accounts, deleted with the key
+ // in plan T2; the needle stays in the crawl so any route that exposes it to the wrong caller still fails.)
const pub = await w.call(null, 'GET', '/api/v1/status/incidents');
const pub = await w.call(null, 'GET', '/api/v1/status/incidents');
assert.strictEqual(pub.status, 200);
assert.strictEqual(pub.status, 200);
assert.ok(!pub.text.includes('long-closed-incident-title'), 'an incident closed 40 days ago is not listed');
assert.ok(!pub.text.includes('long-closed-incident-title'), 'an incident closed 40 days ago is not listed');
@@ -120,8 +121,10 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
@@ -120,8 +121,10 @@ const out = (...a) => process.stdout.write(a.join(' ') + '\n');
}
}
assert.deepStrictEqual(bad, [], `private reads answered:\n${bad.join('\n')}`);
assert.deepStrictEqual(bad, [], `private reads answered:\n${bad.join('\n')}`);
// A service principal reaches only the /internal routes its capabilities name.
// A service principal reaches only the /internal routes its capabilities name.
- for (const p of ['/internal/stats', `/internal/users/${users.alice.id}`, `/internal/users/${users.alice.id}/linked-accounts`, '/internal/anon-list', '/internal/integrations/github-token', `/internal/notifications/unread/${users.alice.id}`]) {
- assert.strictEqual(st[`service GET ${p}`], 403, `${p} needs the legacy key or its own capability`);
+ assert.strictEqual(st['service GET /internal/integrations/github-token'], 403, 'github-token needs the legacy key or its own capability');
+ // Deleted with the X-Internal-Key retirement (plan T2, no caller left): no longer routes at all.
+ for (const p of ['/internal/stats', `/internal/users/${users.alice.id}`, `/internal/users/${users.alice.id}/linked-accounts`, '/internal/anon-list', `/internal/notifications/unread/${users.alice.id}`]) {
+ assert.strictEqual(st[`service GET ${p}`], undefined, `${p} is gone`);
}
}
// Lists and search: bob's view of projects never includes alice's, even asking for all.
// Lists and search: bob's view of projects never includes alice's, even asking for all.
@@ -60,18 +60,16 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
@@ -60,18 +60,16 @@ const sign = (sub, opts = {}) => jwt.sign({ sub, id: sub, username: 'u' }, keys.
assert.strictEqual(r.status, 401, 'a FedCM assertion cannot sign in to another client through /oauth/confirm');
assert.strictEqual(r.status, 401, 'a FedCM assertion cannot sign in to another client through /oauth/confirm');
r = await post('/api/auth/refresh', {}, { authorization: `Bearer ${assertion}` });
r = await post('/api/auth/refresh', {}, { authorization: `Bearer ${assertion}` });
assert.strictEqual(r.status, 401, 'a FedCM assertion cannot be refreshed into a full session');
assert.strictEqual(r.status, 401, 'a FedCM assertion cannot be refreshed into a full session');
- r = await post('/internal/verify-token', { token: assertion }, { 'x-internal-key': config.internalKey });
- assert.strictEqual(r.body.valid, false, 'internal verify-token does not call a FedCM assertion a user token');
+ // POST /internal/verify-token (key-only, no caller left) was deleted with the X-Internal-Key retirement (plan T2):
+ // services verify tokens offline against the JWKS.
r = await post('/internal/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });
r = await post('/internal/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });
- assert.strictEqual(r.body.valid, true);
+ assert.strictEqual(r.status, 404, 'internal verify-token is gone');
// nginx only lets loopback reach `location /internal/` (case-sensitive); Express mounts are not,
// nginx only lets loopback reach `location /internal/` (case-sensitive); Express mounts are not,
// so another spelling must not reach the internal router from outside.
// so another spelling must not reach the internal router from outside.
- r = await post('/INTERNAL/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });
- assert.strictEqual(r.status, 404, '/INTERNAL/... is not the internal API');
- r = await post('/Internal/verify-token', { token: sign(7) }, { 'x-internal-key': config.internalKey });
- assert.strictEqual(r.status, 404);
- r = await post('/internal/verify-token', { token: sign(7) }, { 'x-internal-key': 'k'.repeat(31) + 'x' });
- assert.strictEqual(r.status, 403, 'wrong internal key');
+ const get = (p, headers) => fetch(base + p, { headers }).then((x) => x.status);
+ assert.strictEqual(await get('/INTERNAL/coins/stats', { 'x-internal-key': config.internalKey }), 404, '/INTERNAL/... is not the internal API');
+ assert.strictEqual(await get('/Internal/coins/stats', { 'x-internal-key': config.internalKey }), 404);
+ assert.strictEqual(await get('/internal/coins/stats', { 'x-internal-key': 'k'.repeat(31) + 'x' }), 403, 'wrong internal key');
// ── Revoked tokens (password changed after iat) ──────────────────
// ── Revoked tokens (password changed after iat) ──────────────────
const old = sign(8, { expiresIn: '1h' });
const old = sign(8, { expiresIn: '1h' });
@@ -86,8 +86,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22
@@ -86,8 +86,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22
assert.strictEqual(statusFromReady(okRes, { ready: true, status: 'ready', failed: [], degraded: [], checks: { a: skippedCheck } }).status, 'degraded', 'all skipped: not green');
assert.strictEqual(statusFromReady(okRes, { ready: true, status: 'ready', failed: [], degraded: [], checks: { a: skippedCheck } }).status, 'degraded', 'all skipped: not green');
assert.strictEqual(statusFromReady(okRes, { ready: true, status: 'ready', failed: [], degraded: [], checks: {} }).status, 'degraded', 'no checks: not green');
assert.strictEqual(statusFromReady(okRes, { ready: true, status: 'ready', failed: [], degraded: [], checks: {} }).status, 'degraded', 'no checks: not green');
assert.strictEqual(statusFromReady({ ok: false }, { status: 'x' }).status, 'down');
assert.strictEqual(statusFromReady({ ok: false }, { status: 'x' }).status, 'down');
- assert.strictEqual(by.realtime.status, 'not-running');
- assert.strictEqual(by.realtime.label, 'not running (placeholder)');
+ assert.ok(!('realtime' in by), 'openvibe-contracts 0.78.0 removed the realtime manifest');
assert.strictEqual(by.contracts.status, 'not-running');
assert.strictEqual(by.contracts.status, 'not-running');
assert.strictEqual(by.contracts.label, `not running (library, released v${require('openvibe-contracts/package.json').version})`);
assert.strictEqual(by.contracts.label, `not running (library, released v${require('openvibe-contracts/package.json').version})`);
for (const s of r.body.services) assert.ok(s.checked_at, `${s.id} says when it was checked`);
for (const s of r.body.services) assert.ok(s.checked_at, `${s.id} says when it was checked`);
@@ -121,7 +120,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22
@@ -121,7 +120,7 @@ const rel = (release) => [200, { service: 'x', release, released_at: '2026-09-22
assert.ok(html.includes('<meta name="robots" content="noindex, nofollow">'));
assert.ok(html.includes('<meta name="robots" content="noindex, nofollow">'));
assert.ok(html.includes('id="svc-media"') && /id="svc-media"[\s\S]*?Degraded/.test(html));
assert.ok(html.includes('id="svc-media"') && /id="svc-media"[\s\S]*?Degraded/.test(html));
assert.ok(/id="svc-community"[\s\S]*?Down[\s\S]*?SQLITE_CANTOPEN/.test(html));
assert.ok(/id="svc-community"[\s\S]*?Down[\s\S]*?SQLITE_CANTOPEN/.test(html));
- assert.ok(/id="svc-realtime"[\s\S]*?not running \(placeholder\)/.test(html));
+ assert.ok(!html.includes('id="svc-realtime"'), 'the page lists no realtime row, the manifest is gone');
assert.ok(/id="svc-tools"[\s\S]*?liveness only/.test(html));
assert.ok(/id="svc-tools"[\s\S]*?liveness only/.test(html));
assert.ok(html.includes('<noscript>'), 'navigation without JavaScript');
assert.ok(html.includes('<noscript>'), 'navigation without JavaScript');
assert.ok(html.includes('SLO categories (proposals)'));
assert.ok(html.includes('SLO categories (proposals)'));