openvibers/openvibe.tools Commits

Internal analytics take Network service tokens (plan T2, X-Internal-Key retirement): GET /api/internal/analytics (and /bots) on the gateway and all seven tool sites accept a token holding tools.analytics.read (audience openvibe.tools, never sandbox) through one shared helper (apps/_shared/internal-token.js), loopback only. A Bearer is judged on the token alone, never downgraded to the key; without one the key still passes until Network stops sending it. The gateway forwards the caller's proof to the satellites: the Bearer with a token, the key otherwise. openvibe-contracts v0.79.0 in the four apps that pin it. Tests: capability, audience, sandbox, a bad Bearer plus the right key refused, the public edge refused, the key alone, the Bearer forwarded. npm test 57/57.

OP OpenVibers committed 29 Sept 2026 · 55e4b84fcf · 20 files, +295 −67


Changes

ddiff --git a/apps/_shared/internal-auth.js b/apps/_shared/internal-auth.js
iindex f9bb294..7d918f8 100644
---- a/apps/_shared/internal-auth.js
++++ b/apps/_shared/internal-auth.js
@@@ -7,16 +7,19 @@
// X-Real-IP, service-to-service calls on loopback never do.
const crypto = require('crypto');
++/** The request came straight from loopback, not through the public proxy (nginx adds the headers). */
++function loopbackOnly(req) {
++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return false;
++ const ip = String(req.socket && req.socket.remoteAddress || '');
++ return /^(::1|::ffff:127\.|127\.)/.test(ip);
++}
++
function internalOk(req) {
const want = String(process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '');
const got = String(req.headers['x-internal-key'] || '');
if (want.length < 16 || got.length !== want.length) return false;
-- if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return false;
-- const ip = String(req.socket && req.socket.remoteAddress || '');
-- if (!/^(::1|::ffff:127\.|127\.)/.test(ip)) return false;
++ if (!loopbackOnly(req)) return false;
return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
}
--function requireInternal(req, res, next) { return internalOk(req) ? next() : res.status(404).json({ error: 'Not found' }); }
--
--module.exports = { internalOk, requireInternal };
++module.exports = { internalOk, loopbackOnly };
ddiff --git a/apps/_shared/internal-token.js b/apps/_shared/internal-token.js
nnew file mode 100644
iindex 0000000..07a02e9
---- /dev/null
++++ b/apps/_shared/internal-token.js
@@@ -0,0 +1,72 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// Internal analytics access (GET /api/internal/analytics[/bots]), shared by the gateway and every
++// satellite. Replaces the X-Internal-Key alone with a token-or-key check (contracts v0.79.0,
++// capability tools.analytics.read, ADR-014 service tokens) while the key is retired.
++// Two ways in, both loopback-only (./internal-auth.js refuses anything that came through the proxy):
++// • Authorization: Bearer <Network service token>: aud openvibe.tools, cap tools.analytics.read,
++// never a sandbox token. A request that presents a Bearer is judged on the token alone and is
++// never downgraded to the key: 401 for a bad or missing token, 403 without the capability.
++// • X-Internal-Key, exactly as before, when no Bearer is presented (the key goes in a later step).
++// → { ok: true, via: 'token'|'key', bearer, claims } | { ok: false, status, body }
++// The gateway forwards `bearer` to the satellites when the caller used a token (same audience), and
++// keeps forwarding the key when the caller did not.
++// ═══════════════════════════════════════════════════════════════
++const { internalOk, loopbackOnly } = require('./internal-auth');
++const tokens = require('./guard/tokens');
++
++const ANALYTICS_CAPABILITY = 'tools.analytics.read';
++
++const authHeader = (req) => String((req.headers && req.headers.authorization) || '');
++
++/**
++ * @param {object} req
++ * @param {object} [o]
++ * @param {string} [o.publicKey] the Network's RS256 public key
++ * @param {() => string|null} [o.getPublicKey]
++ * @param {string} [o.issuer] the Network issuer URL
++ * @param {string} [o.audience] default openvibe.tools
++ * @param {object} [o.contracts] openvibe-contracts, when the app has it
++ * @param {string} [o.capability] default tools.analytics.read
++ */
++function checkAccess(req, o = {}) {
++ if (!loopbackOnly(req)) return { ok: false, status: 404, body: { error: 'Not found' } };
++ const header = authHeader(req);
++ if (header) {
++ const m = /^Bearer\s+(\S+)$/i.exec(header.trim());
++ if (!m) return { ok: false, status: 401, body: { error: 'Unauthorized', code: 'token.malformed' } };
++ const publicKey = o.getPublicKey ? o.getPublicKey() : (o.publicKey || null);
++ const r = tokens.verifyServiceToken(m[1], {
++ publicKey, issuer: o.issuer, audience: o.audience || tokens.AUDIENCE, acceptSandbox: false, contracts: o.contracts || null,
++ });
++ if (!r.ok) return { ok: false, status: 401, body: { error: 'Unauthorized', code: r.code } };
++ const cap = o.capability || ANALYTICS_CAPABILITY;
++ const denied = tokens.capabilityDenied(r.claims, cap, o.contracts || null);
++ if (denied) return { ok: false, status: 403, body: { error: 'Forbidden', code: denied, detail: `${cap} not granted` } };
++ return { ok: true, via: 'token', bearer: header, claims: r.claims };
++ }
++ if (internalOk(req)) return { ok: true, via: 'key', bearer: null, claims: null };
++ return { ok: false, status: 404, body: { error: 'Not found' } };
++}
++
++/**
++ * Express middleware. `o.keys` is the app's key source ({ get(), ensure() } — the guard's), so a
++ * Bearer request can wait once for the Network key to load before the token is judged.
++ */
++function requireInternalAccess(o = {}) {
++ const getKey = o.getPublicKey || (o.keys && typeof o.keys.get === 'function' ? () => o.keys.get() : () => null);
++ const ensure = o.ensureKey || (o.keys && typeof o.keys.ensure === 'function' ? o.keys.ensure : null);
++ return function internalAccess(req, res, next) {
++ const answer = (key) => {
++ const r = checkAccess(req, { ...o, getPublicKey: () => key });
++ if (!r.ok) return res.status(r.status).json(r.body);
++ req.ovInternal = r;
++ return next();
++ };
++ const key = getKey();
++ if (key || !/^\s*Bearer\s/i.test(authHeader(req)) || !ensure) return answer(key);
++ Promise.resolve(ensure()).then(answer, () => answer(null));
++ };
++}
++
++module.exports = { checkAccess, requireInternalAccess, ANALYTICS_CAPABILITY };
ddiff --git a/apps/audio/package-lock.json b/apps/audio/package-lock.json
iindex 07d8430..daa6933 100644
---- a/apps/audio/package-lock.json
++++ b/apps/audio/package-lock.json
@@@ -19,7 +19,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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"
@@@ -1159,9 +1159,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.63.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
-- "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==",
++ "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/apps/audio/package.json b/apps/audio/package.json
iindex f61d69c..73471be 100644
---- a/apps/audio/package.json
++++ b/apps/audio/package.json
@@@ -21,7 +21,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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/apps/audio/server/index.js b/apps/audio/server/index.js
iindex beb2c01..f6f5789 100644
---- a/apps/audio/server/index.js
++++ b/apps/audio/server/index.js
@@@ -37,7 +37,7 @@ const sdk = require('openvibe-sdk');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
--const { internalOk } = require('../../_shared/internal-auth');
++const { requireInternalAccess } = require('../../_shared/internal-token');
const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -348,13 +348,12 @@ app.get('/api/preview/:id', (req, res) => {
});
// ── Internal Analytics API ────────────────────────────────────
--app.get('/api/internal/analytics', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });
++app.get('/api/internal/analytics', internalAccess, (req, res) => {
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
--app.get('/api/internal/analytics/bots', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/docs/package-lock.json b/apps/docs/package-lock.json
iindex da8f873..a952fb5 100644
---- a/apps/docs/package-lock.json
++++ b/apps/docs/package-lock.json
@@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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",
"pdf-lib": "^1.17.1",
@@@ -1238,9 +1238,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.63.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
-- "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==",
++ "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/apps/docs/package.json b/apps/docs/package.json
iindex b59f10f..d0f5b84 100644
---- a/apps/docs/package.json
++++ b/apps/docs/package.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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",
"pdf-lib": "^1.17.1",
ddiff --git a/apps/docs/server/index.js b/apps/docs/server/index.js
iindex 71f5fe2..53ad8f7 100644
---- a/apps/docs/server/index.js
++++ b/apps/docs/server/index.js
@@@ -37,7 +37,7 @@ const sdk = require('openvibe-sdk');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
--const { internalOk } = require('../../_shared/internal-auth');
++const { requireInternalAccess } = require('../../_shared/internal-token');
const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -368,13 +368,12 @@ app.get('/api/download/:id', (req, res) => {
});
// ── Internal Analytics API ────────────────────────────────────
--app.get('/api/internal/analytics', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });
++app.get('/api/internal/analytics', internalAccess, (req, res) => {
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
--app.get('/api/internal/analytics/bots', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/food/server/index.js b/apps/food/server/index.js
iindex 3918614..f912251 100644
---- a/apps/food/server/index.js
++++ b/apps/food/server/index.js
@@@ -15,7 +15,7 @@ const cookieParser = require('cookie-parser');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
--const { internalOk } = require('../../_shared/internal-auth');
++const { requireInternalAccess } = require('../../_shared/internal-token');
const { hostGuard, stampedPage } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
@@@ -145,13 +145,12 @@ app.get('/api/meal-plan', proxyToMaps('/api/meal-plan'));
app.get('/api/geocode', proxyToMaps('/api/geocode'));
// ── Internal Analytics API ────────────────────────────────────
--app.get('/api/internal/analytics', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
++app.get('/api/internal/analytics', internalAccess, (req, res) => {
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
--app.get('/api/internal/analytics/bots', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/gateway/package-lock.json b/apps/gateway/package-lock.json
iindex b9f6a0c..130e3d6 100644
---- a/apps/gateway/package-lock.json
++++ b/apps/gateway/package-lock.json
@@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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"
}
@@@ -1036,9 +1036,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.63.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
-- "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==",
++ "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/apps/gateway/package.json b/apps/gateway/package.json
iindex 0facb52..3003e17 100644
---- a/apps/gateway/package.json
++++ b/apps/gateway/package.json
@@@ -19,7 +19,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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"
}
ddiff --git a/apps/gateway/server/index.js b/apps/gateway/server/index.js
iindex 2bc9ee7..2ddef3b 100644
---- a/apps/gateway/server/index.js
++++ b/apps/gateway/server/index.js
@@@ -402,17 +402,19 @@ function sendTool(req, res, file) {
}
// ── Internal analytics (Network admin + ranking) ─────────────
--// The gateway keeps no analytics of its own; it adds up the satellites'. Internal key + loopback only.
++// The gateway keeps no analytics of its own; it adds up the satellites'. Loopback only, with a Network
++// service token (capability tools.analytics.read) or the internal key (apps/_shared/internal-token.js).
{
-- const { requireInternal } = require('../../_shared/internal-auth');
-- // After the internal key, before the fan-out: per-actor limit (actorLimits.admin, 30 a minute).
-- app.get('/api/internal/analytics', requireInternal, actorLimits.admin, async (req, res) => {
++ const { requireInternalAccess } = require('../../_shared/internal-token');
++ // After the token-or-key check, before the fan-out: per-actor limit (actorLimits.admin, 30 a minute).
++ app.get('/api/internal/analytics', requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts: require('openvibe-contracts') }), actorLimits.admin, async (req, res) => {
const days = Math.min(parseInt(req.query.days, 10) || 30, 365);
const hours = req.query.hours ? Math.min(parseInt(req.query.hours, 10), 8760) : null;
-- const key = String(req.headers['x-internal-key']);
++ // The same proof the caller used goes to the satellites: the Bearer with a token, the key otherwise.
++ const forward = req.ovInternal.via === 'token' ? { Authorization: req.ovInternal.bearer } : { 'X-Internal-Key': String(req.headers['x-internal-key']) };
const parts = await Promise.all(Object.entries(SATELLITES).map(async ([name, port]) => {
try {
-- const r = await fetch(`http://127.0.0.1:${port}/api/internal/analytics?days=${days}${hours ? '&hours=' + hours : ''}`, { headers: { 'X-Internal-Key': key }, signal: AbortSignal.timeout(5000) });
++ const r = await fetch(`http://127.0.0.1:${port}/api/internal/analytics?days=${days}${hours ? '&hours=' + hours : ''}`, { headers: forward, signal: AbortSignal.timeout(5000) });
const j = r.ok ? await r.json() : null;
return j && j.analytics ? { name, analytics: j.analytics } : null;
} catch { return null; }
ddiff --git a/apps/gateway/test/internal-analytics.test.js b/apps/gateway/test/internal-analytics.test.js
nnew file mode 100644
iindex 0000000..43cb534
---- /dev/null
++++ b/apps/gateway/test/internal-analytics.test.js
@@@ -0,0 +1,97 @@
++'use strict';
++// The internal analytics route on the real gateway (apps/_shared/internal-token.js, contracts v0.79.0):
++// a Network service token with tools.analytics.read (aud openvibe.tools) opens it, a token without the
++// capability is 403, a wrong audience or a sandbox token is 401, a bad Bearer beside the right key is
++// still 401 (never downgraded to the key), the key alone still works, a proxied request is refused, and
++// the incoming Bearer is forwarded unchanged to the satellites while the key stays forwarded for a key
++// caller. A stub satellite records what the gateway sends.
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const http = require('http');
++const os = require('os');
++const path = require('path');
++const { startApp } = require('../../_shared/test/spawn');
++const { startNetwork } = require('../../_shared/test/network');
++
++const KEY = 'k'.repeat(40); // a fake key, built at runtime so no literal looks like a secret
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-gw-internal-'));
++
++(async () => {
++ const net = await startNetwork();
++ const seen = [];
++ const stub = http.createServer((req, res) => {
++ seen.push({ url: req.url, authorization: req.headers.authorization || null, key: req.headers['x-internal-key'] || null });
++ res.setHeader('Content-Type', 'application/json');
++ res.end(JSON.stringify({ ok: true, analytics: { summary: { total_pageviews: 1 }, realtime: {} } }));
++ });
++ await new Promise(r => stub.listen(0, '127.0.0.1', r));
++ const stubPort = stub.address().port;
++ const fanout = () => seen.filter(s => s.url.startsWith('/api/internal/analytics'));
++
++ let gw = null;
++ try {
++ gw = await startApp('gateway', {
++ OV_NETWORK_URL: net.url, OV_NETWORK_INTERNAL_URL: net.url, INTERNAL_API_KEY: KEY,
++ DATA_DIR: path.join(tmp, 'gw'),
++ OV_DOMAINS_URL: 'http://127.0.0.1:9/api/domains', OV_REGISTRY_URL: 'http://127.0.0.1:9/registry',
++ OV_COMMUNITY_INTERNAL_URL: 'http://127.0.0.1:9',
++ TOOLS_SATELLITE_PORTS: ['img', 'audio', 'docs', 'yt', 'text', 'maps', 'food'].map(n => `${n}=${stubPort}`).join(','),
++ });
++ const now = () => Math.floor(Date.now() / 1000);
++ const claims = (extra) => ({ iss: net.url, sub: 'svc:network-admin', actor_type: 'service', aud: ['openvibe.tools'], cap: [], ns: [], iat: now(), exp: now() + 900, jti: `tok_${crypto.randomBytes(12).toString('hex')}`, ...extra });
++ const good = net.sign(claims({ cap: ['tools.analytics.read'] }));
++ const noCap = net.sign(claims({ cap: ['tools.tool.read'] }));
++ const wrongAud = net.sign(claims({ cap: ['tools.analytics.read'], aud: ['openvibe.live'] }));
++ const sandbox = net.sign(claims({ sub: 'app:app_01JCCCCCCCCCCCCCCCCCCCCCCC', actor_type: 'app', project_id: 'prj_01JCCCCCCCCCCCCCCCCCCCCCCC', env: 'sandbox', ns: ['prj_01JCCCCCCCCCCCCCCCCCCCCCCC'], cap: ['tools.analytics.read'] }));
++ const get = (headers = {}) => fetch(`${gw.base}/api/internal/analytics`, { headers });
++
++ // 1. A service token with the capability opens it, and the Bearer reaches every satellite.
++ seen.length = 0;
++ let r = await get({ Authorization: `Bearer ${good}` });
++ let body = await r.json();
++ assert.strictEqual(r.status, 200, JSON.stringify(body));
++ assert.ok(body.ok && body.analytics, JSON.stringify(body));
++ assert.strictEqual(fanout().length, 7, 'the gateway fanned out to all seven satellites');
++ assert.ok(fanout().every(s => s.authorization === `Bearer ${good}`), 'the Bearer is forwarded unchanged');
++ assert.ok(fanout().every(s => s.key === null), 'no key is sent with a token');
++
++ // 2. A token without the capability is 403.
++ r = await get({ Authorization: `Bearer ${noCap}` });
++ body = await r.json();
++ assert.strictEqual(r.status, 403, JSON.stringify(body));
++ assert.strictEqual(body.code, 'capability.denied');
++
++ // 3. Another audience is 401.
++ assert.strictEqual((await get({ Authorization: `Bearer ${wrongAud}` })).status, 401);
++
++ // 4. A sandbox token is 401.
++ assert.strictEqual((await get({ Authorization: `Bearer ${sandbox}` })).status, 401);
++
++ // 5. A Bearer request is judged on the token alone: the right key beside it does not help.
++ assert.strictEqual((await get({ Authorization: 'Bearer not-a-jwt', 'X-Internal-Key': KEY })).status, 401);
++ assert.strictEqual((await get({ Authorization: `Bearer ${noCap}`, 'X-Internal-Key': KEY })).status, 403);
++
++ // 6. A proxied request is refused with key or token (loopback only, as before).
++ assert.strictEqual((await get({ 'X-Internal-Key': KEY, 'X-Forwarded-For': '203.0.113.9' })).status, 404);
++ assert.strictEqual((await get({ Authorization: `Bearer ${good}`, 'X-Forwarded-For': '203.0.113.9' })).status, 404);
++
++ // 7. The key alone still works, and the key is what the gateway forwards.
++ seen.length = 0;
++ r = await get({ 'X-Internal-Key': KEY });
++ body = await r.json();
++ assert.strictEqual(r.status, 200, JSON.stringify(body));
++ assert.strictEqual(fanout().length, 7);
++ assert.ok(fanout().every(s => s.key === KEY && s.authorization === null), 'the key is forwarded when the caller used the key');
++
++ // 8. No credentials is 404, as before.
++ assert.strictEqual((await get()).status, 404);
++
++ console.log('gateway internal analytics: token (tools.analytics.read) opens and is forwarded, 403 without it, 401 for wrong audience/sandbox/bad Bearer, key alone still works, proxied refused');
++ } finally {
++ if (gw) await gw.kill();
++ await new Promise(r => stub.close(r));
++ await net.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/apps/img/package-lock.json b/apps/img/package-lock.json
iindex 039526d..b7e86a0 100644
---- a/apps/img/package-lock.json
++++ b/apps/img/package-lock.json
@@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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",
"sharp": "^0.33.5",
@@@ -1729,9 +1729,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.63.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.0",
-- "integrity": "sha512-vmc1b1vWg9M5fm57X50MlZEZHkP2IshHwBXt68+kHDV85nyg6FxaYyUWa4ibi1eeFqzMcECPlILSRTOgg2Xbgw==",
++ "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/apps/img/package.json b/apps/img/package.json
iindex baa333e..de16b02 100644
---- a/apps/img/package.json
++++ b/apps/img/package.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.63.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",
"sharp": "^0.33.5",
ddiff --git a/apps/img/server/index.js b/apps/img/server/index.js
iindex 6c45a1c..06a1ea4 100644
---- a/apps/img/server/index.js
++++ b/apps/img/server/index.js
@@@ -37,7 +37,7 @@ const sdk = require('openvibe-sdk');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
--const { internalOk } = require('../../_shared/internal-auth');
++const { requireInternalAccess } = require('../../_shared/internal-token');
const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -310,13 +310,12 @@ app.get('/api/download/:id', (req, res) => {
});
// ── Internal Analytics API ────────────────────────────────────
--app.get('/api/internal/analytics', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience, contracts });
++app.get('/api/internal/analytics', internalAccess, (req, res) => {
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
--app.get('/api/internal/analytics/bots', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/img/test/internal-analytics.test.js b/apps/img/test/internal-analytics.test.js
nnew file mode 100644
iindex 0000000..781fe6e
---- /dev/null
++++ b/apps/img/test/internal-analytics.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++// The internal analytics routes on a satellite (apps/_shared/internal-token.js): the image service
++// accepts a Network service token with tools.analytics.read (aud openvibe.tools) on /analytics and
++// /bots, refuses a token without the capability (403), a wrong audience or a sandbox token (401), a
++// bad Bearer beside the right key (401, never downgraded to the key), a proxied request (404), and
++// still accepts the internal key alone (404 with nothing, as before).
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { startApp } = require('../../_shared/test/spawn');
++const { startNetwork } = require('../../_shared/test/network');
++
++const KEY = 'k'.repeat(40); // a fake key, built at runtime so no literal looks like a secret
++const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-internal-'));
++
++(async () => {
++ const net = await startNetwork();
++ let app = null;
++ try {
++ app = await startApp('img', {
++ DATA_DIR: data, UPLOADS_DIR: path.join(data, 'uploads'), OUTPUT_DIR: path.join(data, 'output'),
++ OV_NETWORK_URL: net.url, OV_NETWORK_INTERNAL_URL: net.url, INTERNAL_API_KEY: KEY,
++ });
++ const now = () => Math.floor(Date.now() / 1000);
++ const claims = (extra) => ({ iss: net.url, sub: 'svc:network-admin', actor_type: 'service', aud: ['openvibe.tools'], cap: [], ns: [], iat: now(), exp: now() + 900, jti: `tok_${crypto.randomBytes(12).toString('hex')}`, ...extra });
++ const good = net.sign(claims({ cap: ['tools.analytics.read'] }));
++ const noCap = net.sign(claims({ cap: ['tools.tool.read'] }));
++ const wrongAud = net.sign(claims({ cap: ['tools.analytics.read'], aud: ['openvibe.live'] }));
++ const sandbox = net.sign(claims({ sub: 'app:app_01JCCCCCCCCCCCCCCCCCCCCCCC', actor_type: 'app', project_id: 'prj_01JCCCCCCCCCCCCCCCCCCCCCCC', env: 'sandbox', ns: ['prj_01JCCCCCCCCCCCCCCCCCCCCCCC'], cap: ['tools.analytics.read'] }));
++ const get = (p, headers = {}) => fetch(`${app.base}${p}`, { headers });
++
++ // A token with the capability passes both routes.
++ for (const p of ['/api/internal/analytics', '/api/internal/analytics/bots']) {
++ const r = await get(p, { Authorization: `Bearer ${good}` });
++ const body = await r.json();
++ assert.strictEqual(r.status, 200, `${p}: ${JSON.stringify(body)}`);
++ assert.ok(body.ok, p);
++ // Without the capability: 403.
++ assert.strictEqual((await get(p, { Authorization: `Bearer ${noCap}` })).status, 403, p);
++ // Another audience, and a sandbox token: 401.
++ assert.strictEqual((await get(p, { Authorization: `Bearer ${wrongAud}` })).status, 401, p);
++ assert.strictEqual((await get(p, { Authorization: `Bearer ${sandbox}` })).status, 401, p);
++ // A Bearer request is judged on the token alone: the key beside a bad Bearer does not help.
++ assert.strictEqual((await get(p, { Authorization: 'Bearer not-a-jwt', 'X-Internal-Key': KEY })).status, 401, p);
++ // The key alone still passes.
++ assert.strictEqual((await get(p, { 'X-Internal-Key': KEY })).status, 200, p);
++ // A proxied request is refused with key or token.
++ assert.strictEqual((await get(p, { 'X-Internal-Key': KEY, 'X-Forwarded-For': '203.0.113.9' })).status, 404, p);
++ assert.strictEqual((await get(p, { Authorization: `Bearer ${good}`, 'X-Forwarded-For': '203.0.113.9' })).status, 404, p);
++ // No credentials: 404, as before.
++ assert.strictEqual((await get(p)).status, 404, p);
++ }
++ console.log('img internal analytics: token (tools.analytics.read) passes, 403 without it, 401 wrong audience/sandbox/bad Bearer, key alone passes, proxied refused');
++ } finally {
++ if (app) await app.kill();
++ await net.close();
++ fs.rmSync(data, { recursive: true, force: true });
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/apps/maps/server/index.js b/apps/maps/server/index.js
iindex 2755a4b..9a4437d 100644
---- a/apps/maps/server/index.js
++++ b/apps/maps/server/index.js
@@@ -20,7 +20,7 @@ const config = require('./config');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
--const { internalOk } = require('../../_shared/internal-auth');
++const { requireInternalAccess } = require('../../_shared/internal-token');
const { hostGuard, stampedPage } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
@@@ -475,13 +475,12 @@ app.get('/api/meal-plan', (req, res) => {
});
// ── Internal Analytics API ────────────────────────────────────
--app.get('/api/internal/analytics', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
++app.get('/api/internal/analytics', internalAccess, (req, res) => {
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
--app.get('/api/internal/analytics/bots', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/text/server/index.js b/apps/text/server/index.js
iindex 4e20496..1361003 100644
---- a/apps/text/server/index.js
++++ b/apps/text/server/index.js
@@@ -13,7 +13,7 @@ const config = require('./config');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
--const { internalOk } = require('../../_shared/internal-auth');
++const { requireInternalAccess } = require('../../_shared/internal-token');
const { hostGuard } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
@@@ -117,13 +117,12 @@ const { HOSTNAME_MAP } = require('./hosts');
app.use(hostGuard({ knows: (h) => Object.prototype.hasOwnProperty.call(HOSTNAME_MAP, h) }));
// ── Internal Analytics API ────────────────────────────────────
--app.get('/api/internal/analytics', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
++app.get('/api/internal/analytics', internalAccess, (req, res) => {
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
--app.get('/api/internal/analytics/bots', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
ddiff --git a/apps/yt/server/index.js b/apps/yt/server/index.js
iindex ab15c68..ebe87c6 100644
---- a/apps/yt/server/index.js
++++ b/apps/yt/server/index.js
@@@ -24,7 +24,7 @@ const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const { AnalyticsTracker } = require('openvibe-shared/analytics'); // ADR-021: no IP/user id, route templates, raw rows pruned after 30 days, Sec-GPC/DNT not recorded
--const { internalOk } = require('../../_shared/internal-auth');
++const { requireInternalAccess } = require('../../_shared/internal-token');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
@@@ -283,13 +283,12 @@ app.get('/api/download/:id', (req, res) => {
});
// ── Internal Analytics API ────────────────────────────────────
--app.get('/api/internal/analytics', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++const internalAccess = requireInternalAccess({ keys: guard.keys, issuer: guard.issuer, audience: guard.audience });
++app.get('/api/internal/analytics', internalAccess, (req, res) => {
try { const d = Math.min(parseInt(req.query.days) || 30, 365); const h = req.query.hours ? Math.min(parseInt(req.query.hours), 8760) : null; res.json({ ok: true, analytics: analytics.getStats({ days: d, hours: h }) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
--app.get('/api/internal/analytics/bots', (req, res) => {
-- if (!internalOk(req)) return res.status(404).json({ error: 'Not found' });
++app.get('/api/internal/analytics/bots', internalAccess, (req, res) => {
try { res.json({ ok: true, bots: analytics.getBotAnalysis(Math.min(parseInt(req.query.days) || 30, 365)) }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});