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.
Changes
@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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",@@@ -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"@@@ -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 }); }});@@@ -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",@@@ -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",@@@ -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 }); }});@@@ -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 }); }});@@@ -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",@@@ -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"}@@@ -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; }nnew file mode 100644@@@ -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); });@@@ -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",@@@ -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",@@@ -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 }); }});nnew file mode 100644@@@ -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); });@@@ -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 }); }});@@@ -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 }); }});@@@ -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 }); }});
@@ -7,16 +7,19 @@
@@ -7,16 +7,19 @@
// X-Real-IP, service-to-service calls on loopback never do.
// X-Real-IP, service-to-service calls on loopback never do.
const crypto = require('crypto');
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) {
function internalOk(req) {
const want = String(process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '');
const want = String(process.env.INTERNAL_API_KEY || process.env.OV_INTERNAL_KEY || '');
const got = String(req.headers['x-internal-key'] || '');
const got = String(req.headers['x-internal-key'] || '');
if (want.length < 16 || got.length !== want.length) return false;
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));
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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 };
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
@@ -1159,9 +1159,9 @@
@@ -1159,9 +1159,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
@@ -37,7 +37,7 @@ const sdk = require('openvibe-sdk');
@@ -37,7 +37,7 @@ const sdk = require('openvibe-sdk');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
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 { 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');
const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
const analyticsDb = new Database(analyticsDbPath);
@@ -348,13 +348,12 @@ app.get('/api/preview/:id', (req, res) => {
@@ -348,13 +348,12 @@ app.get('/api/preview/:id', (req, res) => {
});
});
// ── Internal Analytics API ────────────────────────────────────
// ── 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 }) }); }
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 }); }
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"pdf-lib": "^1.17.1",
"pdf-lib": "^1.17.1",
@@ -1238,9 +1238,9 @@
@@ -1238,9 +1238,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"pdf-lib": "^1.17.1",
"pdf-lib": "^1.17.1",
@@ -37,7 +37,7 @@ const sdk = require('openvibe-sdk');
@@ -37,7 +37,7 @@ const sdk = require('openvibe-sdk');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
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 { 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');
const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
const analyticsDb = new Database(analyticsDbPath);
@@ -368,13 +368,12 @@ app.get('/api/download/:id', (req, res) => {
@@ -368,13 +368,12 @@ app.get('/api/download/:id', (req, res) => {
});
});
// ── Internal Analytics API ────────────────────────────────────
// ── 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 }) }); }
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 }); }
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -15,7 +15,7 @@ const cookieParser = require('cookie-parser');
@@ -15,7 +15,7 @@ const cookieParser = require('cookie-parser');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
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 { 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 { hostGuard, stampedPage } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
@@ -145,13 +145,12 @@ app.get('/api/meal-plan', proxyToMaps('/api/meal-plan'));
@@ -145,13 +145,12 @@ app.get('/api/meal-plan', proxyToMaps('/api/meal-plan'));
app.get('/api/geocode', proxyToMaps('/api/geocode'));
app.get('/api/geocode', proxyToMaps('/api/geocode'));
// ── Internal Analytics API ────────────────────────────────────
// ── 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 }) }); }
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 }); }
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2"
}
}
@@ -1036,9 +1036,9 @@
@@ -1036,9 +1036,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2"
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2"
}
}
@@ -402,17 +402,19 @@ function sendTool(req, res, file) {
@@ -402,17 +402,19 @@ function sendTool(req, res, file) {
}
}
// ── Internal analytics (Network admin + ranking) ─────────────
// ── 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 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 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]) => {
const parts = await Promise.all(Object.entries(SATELLITES).map(async ([name, port]) => {
try {
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;
const j = r.ok ? await r.json() : null;
return j && j.analytics ? { name, analytics: j.analytics } : null;
return j && j.analytics ? { name, analytics: j.analytics } : null;
} catch { return null; }
} catch { return null; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -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); });
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
@@ -1729,9 +1729,9 @@
@@ -1729,9 +1729,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.29.2",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
@@ -37,7 +37,7 @@ const sdk = require('openvibe-sdk');
@@ -37,7 +37,7 @@ const sdk = require('openvibe-sdk');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
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 { 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');
const analyticsDbPath = path.resolve(__dirname, '..', config.dataDir, 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
const analyticsDb = new Database(analyticsDbPath);
@@ -310,13 +310,12 @@ app.get('/api/download/:id', (req, res) => {
@@ -310,13 +310,12 @@ app.get('/api/download/:id', (req, res) => {
});
});
// ── Internal Analytics API ────────────────────────────────────
// ── 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 }) }); }
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 }); }
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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); });
@@ -20,7 +20,7 @@ const config = require('./config');
@@ -20,7 +20,7 @@ const config = require('./config');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
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 { 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 { hostGuard, stampedPage } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
@@ -475,13 +475,12 @@ app.get('/api/meal-plan', (req, res) => {
@@ -475,13 +475,12 @@ app.get('/api/meal-plan', (req, res) => {
});
});
// ── Internal Analytics API ────────────────────────────────────
// ── 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 }) }); }
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 }); }
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -13,7 +13,7 @@ const config = require('./config');
@@ -13,7 +13,7 @@ const config = require('./config');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
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 { 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 { hostGuard } = require('../../_shared/host-role');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createToolsApi, exceptRegistry } = require('../../_shared/tools/http');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
const { createLocalRegistry, requiresStatus } = require('../../_shared/tools/local');
@@ -117,13 +117,12 @@ const { HOSTNAME_MAP } = require('./hosts');
@@ -117,13 +117,12 @@ const { HOSTNAME_MAP } = require('./hosts');
app.use(hostGuard({ knows: (h) => Object.prototype.hasOwnProperty.call(HOSTNAME_MAP, h) }));
app.use(hostGuard({ knows: (h) => Object.prototype.hasOwnProperty.call(HOSTNAME_MAP, h) }));
// ── Internal Analytics API ────────────────────────────────────
// ── 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 }) }); }
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 }); }
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});
@@ -24,7 +24,7 @@ const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
@@ -24,7 +24,7 @@ const { createGuard, TRUST_PROXY } = require('../../_shared/guard');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
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 { 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');
const analyticsDbPath = path.join(__dirname, '..', 'data', 'analytics.db');
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
fs.mkdirSync(path.dirname(analyticsDbPath), { recursive: true });
const analyticsDb = new Database(analyticsDbPath);
const analyticsDb = new Database(analyticsDbPath);
@@ -283,13 +283,12 @@ app.get('/api/download/:id', (req, res) => {
@@ -283,13 +283,12 @@ app.get('/api/download/:id', (req, res) => {
});
});
// ── Internal Analytics API ────────────────────────────────────
// ── 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 }) }); }
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 }); }
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)) }); }
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 }); }
catch (err) { res.status(500).json({ ok: false, error: err.message }); }
});
});