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.

OP OpenVibers committed 29 Sept 2026 · 79a4eea28f · 18 files, +141 −296


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex c02bf738..12765ab3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 7a73a268..013ecb5f 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/admin/analytics-routes.js b/server/admin/analytics-routes.js
iindex 15a69093..a6c92b30 100644
---- a/server/admin/analytics-routes.js
++++ b/server/admin/analytics-routes.js
@@@ -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 strategy
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: '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}`;
}
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 875b0514..d382979a 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -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 = {
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 59dcf576..362c1af6 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -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 || [];
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex f03eafd1..10feebdc 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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, []],
ddiff --git a/server/identity/self-token.js b/server/identity/self-token.js
nnew file mode 100644
iindex 00000000..6bd2407a
---- /dev/null
++++ b/server/identity/self-token.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
iindex 448ee5f8..a25c526c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -356,6 +356,9 @@ try {
// Make keys available to route modules
app.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 API
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 ──────────────────────────────
// Proxies /api/admin/streamer/* → openvibe.live /api/admin/*
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 6af6125a..838a0fd3 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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.resolv
router.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;
ddiff --git a/server/profile/avatar.js b/server/profile/avatar.js
iindex 21f84a19..8a095e37 100644
---- a/server/profile/avatar.js
++++ b/server/profile/avatar.js
@@@ -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' };
ddiff --git a/test/avatar.test.js b/test/avatar.test.js
iindex 8c300fb6..88d6e64d 100644
---- a/test/avatar.test.js
++++ b/test/avatar.test.js
@@@ -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;
ddiff --git a/test/principals.test.js b/test/principals.test.js
iindex b813b689..8f0be088 100644
---- a/test/principals.test.js
++++ b/test/principals.test.js
@@@ -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' });
ddiff --git a/test/registry-ecosystem.test.js b/test/registry-ecosystem.test.js
iindex 00b0643a..99891f41 100644
---- a/test/registry-ecosystem.test.js
++++ b/test/registry-ecosystem.test.js
@@@ -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 more
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');
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');
ddiff --git a/test/registry-exposure.test.js b/test/registry-exposure.test.js
iindex cbe5020f..2bdcddb1 100644
---- a/test/registry-exposure.test.js
++++ b/test/registry-exposure.test.js
@@@ -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: [], ch
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.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: [], ch
assert.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());
ddiff --git a/test/registry-topics.test.js b/test/registry-topics.test.js
iindex edc82a4f..bd151fac 100644
---- a/test/registry-topics.test.js
++++ b/test/registry-topics.test.js
@@@ -113,11 +113,12 @@ const rel = (contractsVersion, packages) => [200, { service: 'x', release: 'abcd
assert.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);
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex ea88e5a8..6e0759b2 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -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.
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
iindex f84c2a32..bcfdd4ce 100644
---- a/test/security-session.test.js
++++ b/test/security-session.test.js
@@@ -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' });
ddiff --git a/test/status.test.js b/test/status.test.js
iindex e60c3689..3156025d 100644
---- a/test/status.test.js
++++ b/test/status.test.js
@@@ -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: {} }).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-22
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(/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)'));