openvibers/openvibe.tools Commits
X-Internal-Key is gone from Tools (plan T2): GET /api/internal/analytics[/bots] on the gateway and the seven tool sites take only a Network service token with tools.analytics.read (401 token.missing without one), loopback only; the gateway forwards only the caller's Bearer. apps/_shared/internal-auth.js is deleted (loopbackOnly moved into internal-token.js); INTERNAL_API_KEY/OV_INTERNAL_KEY leave .env.example and the README. Tests: the key alone and a bad Bearer beside the key are 401, the token passes and is forwarded, and no server file reads the key. npm test green (verified).
Changes
@@@ -398,7 +398,7 @@ address). Past a limit: `429` problem+json `rate_limited` with `Retry-After`, on|---|---|---|| every app | Registry reads `GET`/`HEAD /api/v1/tools[/:id[/schema]]` by a signed-in person or a third-party principal (`app:…`, `mod:…`) | `TOOLS_LIMITS_MINUTE` / `TOOLS_LIMITS_HOUR` (120 / 3000) || gateway, img, audio, docs | Backstop on job submits and retries (`POST /api/v1/jobs`, `…/:id/retry`) and runs (`POST /api/v1/tools/:id/run`) | a person or an address 600 / 20 000; a principal 3000 / 100 000 |--| gateway | `GET /api/internal/analytics` (the Network admin's analytics page, after the internal key) | 30 / 300 |++| gateway | `GET /api/internal/analytics` (the Network admin's analytics page, with a Network service token) | 30 / 300 |Signed-out registry reads keep only the per-address `/api/` limit (many visitors share a carrier or campusaddress); a first-party service (`svc:…`) reading for itself is not counted. The backstop sits above the@@@ -601,7 +601,7 @@ sniffed against the descriptor; every tool that reaches a visitor-chosen host go(`apps/_shared/egress.js`: public addresses only, checked after DNS, redirect hops re-checked); `trust proxy`is one loopback hop; sign-in `next` accepts only relative paths, `*.openvibe.tools` and `openvibe.network`.Secrets (`OV_OAUTH_CLIENT_SECRET`, `TOOLS_EVENTS_SECRET`, `NET_IPINFO_TOKEN`, `RIDB_API_KEY`, `NPS_API_KEY`,--`OPEN_CHARGE_MAP_KEY`, `YT_COOKIES_FILE`, the fallback `INTERNAL_API_KEY`) live in `/etc/openvibe/tools.env`++`OPEN_CHARGE_MAP_KEY`, `YT_COOKIES_FILE`) live in `/etc/openvibe/tools.env`(0600), by name only.## Deployddeleted file mode 100644@@@ -1,25 +0,0 @@--'use strict';--// Internal-only routes (analytics totals for the Network's admin and ranking).--// Two independent conditions, both required:--// 1. X-Internal-Key equals INTERNAL_API_KEY from the unit's environment (never a value in the repo;--// with no key configured every internal route is closed).--// 2. The request did not come through the public proxy: nginx always adds X-Forwarded-For and--// 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 (!loopbackOnly(req)) return false;-- return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));--}----module.exports = { internalOk, loopbackOnly };@@@ -1,20 +1,24 @@'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.++// satellite. The X-Internal-Key is retired (plan T2): a Network service token alone opens the route++// (contracts v0.79.0, capability tools.analytics.read, ADR-014 service tokens).++// One way in, loopback-only (loopbackOnly below refuses anything that came through the proxy):++// Authorization: Bearer <Network service token>: aud openvibe.tools, cap tools.analytics.read,++// never a sandbox token. 401 for a bad or malformed token, 403 without the capability, and 401++// token.missing when no Bearer is presented at all.++// → { ok: true, via: 'token', bearer, claims } | { ok: false, status, body }++// The gateway forwards the caller's Bearer to the satellites unchanged (same audience).// ═══════════════════════════════════════════════════════════════--const { internalOk, loopbackOnly } = require('./internal-auth');const tokens = require('./guard/tokens');++/** 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);++}++const ANALYTICS_CAPABILITY = 'tools.analytics.read';const authHeader = (req) => String((req.headers && req.headers.authorization) || '');@@@ -32,21 +36,18 @@ const authHeader = (req) => String((req.headers && req.headers.authorization) ||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' } };++ if (!header) return { ok: false, status: 401, body: { error: 'Unauthorized', code: 'token.missing' } };++ 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 };}/**@@@ -27,9 +27,6 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4001/auth/callbackMEDIA_URL=http://127.0.0.1:4100MEDIA_PUBLIC_URL=https://openvibe.media--# Internal API key for Network /internal/* calls (if ever needed)--INTERNAL_API_KEY=change-me-to-a-random-64-char-string--# Net tools (optional). With an ipinfo.io token, IP lookups use ipinfo over HTTPS instead of ip-api.com's# free tier (plain HTTP). Answers are cached either way. The Globalping token is read but no tool uses it yet.#NET_IPINFO_TOKEN=@@@ -403,15 +403,15 @@ function sendTool(req, res, file) {// ── Internal analytics (Network admin + ranking) ─────────────// 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).++// service token (capability tools.analytics.read, apps/_shared/internal-token.js).{const { requireInternalAccess } = require('../../_shared/internal-token');-- // After the token-or-key check, before the fan-out: per-actor limit (actorLimits.admin, 30 a minute).++ // After the token 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;-- // 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']) };++ // The caller's Bearer is what the satellites get (the same audience, no key any more).++ const forward = { Authorization: req.ovInternal.bearer };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: forward, signal: AbortSignal.timeout(5000) });@@@ -1,10 +1,9 @@'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.++// a Network service token with tools.analytics.read (aud openvibe.tools) opens it and the Bearer is++// forwarded unchanged to all seven satellites, a token without the capability is 403, a wrong audience++// or a sandbox token is 401, the retired X-Internal-Key alone is 401 (token.missing), a bad Bearer beside++// the key is still 401, and a proxied request is refused. A stub satellite records what is sent.const assert = require('assert');const crypto = require('crypto');const fs = require('fs');@@@ -68,26 +67,26 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-gw-internal-'));// 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.++ // 5. A bad Bearer is 401 whatever else is sent beside it (the retired key 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.++ // 7. The retired X-Internal-Key alone is 401 token.missing, with or without the env var set.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');++ assert.strictEqual(r.status, 401, JSON.stringify(body));++ assert.strictEqual(body.code, 'token.missing');++ assert.strictEqual(fanout().length, 0, 'nothing is fanned out without a token');-- // 8. No credentials is 404, as before.-- assert.strictEqual((await get()).status, 404);++ // 8. No credentials is 401 too.++ r = await get();++ assert.strictEqual(r.status, 401, JSON.stringify(await r.json()));-- 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');++ console.log('gateway internal analytics: token (tools.analytics.read) opens and is forwarded, 403 without it, 401 for wrong audience/sandbox/bad Bearer, the key alone is 401, proxied refused');} finally {if (gw) await gw.kill();await new Promise(r => stub.close(r));@@@ -1,7 +1,9 @@'use strict';/*** The gateway's secrets never reach a visitor (roadmap WS-R task 5, the internal-secret class). The-- * gateway starts with a sentinel as its Network OAuth client secret, its internal API keys and its++ * first check is static: the retired internal key (X-Internal-Key, plan T2) is read nowhere in the++ * serving code. Then the++ * gateway starts with a sentinel as its Network OAuth client secret and its* Events subscription secret, and with no network (the no-network preload: every third-party call* and lookup fails at once, so the net tools and the sign-in, registry and search calls all take* their error paths). Then its pages, APIs, every tool in its registry (description and schema) and@@@ -17,8 +19,6 @@ const { startApp } = require('../../_shared/test/spawn');const SECRETS = {OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-tools-oauth-client',-- INTERNAL_API_KEY: 'sentinel-not-a-secret-tools-internal',-- OV_INTERNAL_KEY: 'sentinel-not-a-secret-tools-ov-internal',TOOLS_EVENTS_SECRET: 'sentinel-not-a-secret-tools-events-subscription',};const NET_TOOLS = ['blacklist', 'dnsprop', 'dns', 'headers', 'ip', 'ipv4', 'ipv6', 'lookup', 'ping', 'port', 'rdap', 'rdns', 'redirects', 'robots', 'sitemap', 'smtp', 'ssl', 'uptime', 'whois'];@@@ -34,6 +34,30 @@ const client = () => { seq++; return { 'x-forwarded-for': `198.18.${(seq >> 8) &});let failures = 0;const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 8).join('\n ')}`); } };++ const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/.*$/gm, '$1');++ const RETIRED_KEY = /\bINTERNAL_API_KEY\b|\bOV_INTERNAL_KEY\b|x-internal-key/i;++ await check('no server file or _shared helper reads the retired internal key', () => {++ const roots = [path.join(__dirname, '..', '..'), path.join(__dirname, '..', '..', '_shared')];++ const files = [];++ const walk = (dir) => {++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {++ const full = path.join(dir, e.name);++ if (e.isDirectory()) { if (e.name === 'node_modules' || e.name === 'test') continue; walk(full); }++ else if (/\.(js|cjs|mjs|ts)$/.test(e.name)) files.push(full);++ }++ };++ for (const root of roots) {++ for (const e of fs.readdirSync(root, { withFileTypes: true })) {++ const full = path.join(root, e.name);++ if (!e.isDirectory() || e.name !== 'server') continue;++ for (const f of fs.readdirSync(full)) if (/\.js$/.test(f)) files.push(path.join(full, f));++ }++ }++ walk(roots[1]);++ assert.ok(files.length > 10, `found ${files.length} files to scan`);++ const hits = files.filter((f) => RETIRED_KEY.test(stripComments(fs.readFileSync(f, 'utf8'))));++ assert.deepStrictEqual(hits.map((f) => path.relative(path.join(__dirname, '..', '..'), f)), []);++ });const get = async (p, opts = {}) => {const ac = new AbortController();const timer = setTimeout(() => ac.abort(), 15000);@@@ -2,8 +2,8 @@// 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).++// bad Bearer beside the retired key (401), a proxied request (404), and the retired key alone or++// nothing at all (401 token.missing).const assert = require('assert');const crypto = require('crypto');const fs = require('fs');@@@ -33,7 +33,7 @@ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-internal-'));// 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}` });++ let 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);@@@ -42,17 +42,20 @@ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-internal-'));// 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.++ // A bad Bearer is 401 whatever else is sent beside it (the retired key 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);++ // The retired key alone is 401 token.missing, and so is no credentials at all.++ r = await get(p, { 'X-Internal-Key': KEY });++ assert.strictEqual(r.status, 401, p);++ assert.strictEqual((await r.json()).code, 'token.missing', p);++ r = await get(p);++ assert.strictEqual(r.status, 401, p);++ assert.strictEqual((await r.json()).code, 'token.missing', 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');++ console.log('img internal analytics: token (tools.analytics.read) passes, 403 without it, 401 wrong audience/sandbox/bad Bearer, the retired key alone is 401, proxied refused');} finally {if (app) await app.kill();await net.close();@@@ -15,8 +15,6 @@ const KEYS = {RIDB_API_KEY: 'sentinel-not-a-secret-ridb',NPS_API_KEY: 'sentinel-not-a-secret-nps',OPEN_CHARGE_MAP_KEY: 'sentinel-not-a-secret-ocm',-- INTERNAL_API_KEY: 'sentinel-not-a-secret-internal',-- OV_INTERNAL_KEY: 'sentinel-not-a-secret-ov-internal',};// One loopback proxy hop is trusted (guard/ip.js): a fresh client address per request keeps the
@@ -398,7 +398,7 @@ address). Past a limit: `429` problem+json `rate_limited` with `Retry-After`, on
@@ -398,7 +398,7 @@ address). Past a limit: `429` problem+json `rate_limited` with `Retry-After`, on
|---|---|---|
|---|---|---|
| every app | Registry reads `GET`/`HEAD /api/v1/tools[/:id[/schema]]` by a signed-in person or a third-party principal (`app:…`, `mod:…`) | `TOOLS_LIMITS_MINUTE` / `TOOLS_LIMITS_HOUR` (120 / 3000) |
| every app | Registry reads `GET`/`HEAD /api/v1/tools[/:id[/schema]]` by a signed-in person or a third-party principal (`app:…`, `mod:…`) | `TOOLS_LIMITS_MINUTE` / `TOOLS_LIMITS_HOUR` (120 / 3000) |
| gateway, img, audio, docs | Backstop on job submits and retries (`POST /api/v1/jobs`, `…/:id/retry`) and runs (`POST /api/v1/tools/:id/run`) | a person or an address 600 / 20 000; a principal 3000 / 100 000 |
| gateway, img, audio, docs | Backstop on job submits and retries (`POST /api/v1/jobs`, `…/:id/retry`) and runs (`POST /api/v1/tools/:id/run`) | a person or an address 600 / 20 000; a principal 3000 / 100 000 |
-| gateway | `GET /api/internal/analytics` (the Network admin's analytics page, after the internal key) | 30 / 300 |
+| gateway | `GET /api/internal/analytics` (the Network admin's analytics page, with a Network service token) | 30 / 300 |
Signed-out registry reads keep only the per-address `/api/` limit (many visitors share a carrier or campus
Signed-out registry reads keep only the per-address `/api/` limit (many visitors share a carrier or campus
address); a first-party service (`svc:…`) reading for itself is not counted. The backstop sits above the
address); a first-party service (`svc:…`) reading for itself is not counted. The backstop sits above the
@@ -601,7 +601,7 @@ sniffed against the descriptor; every tool that reaches a visitor-chosen host go
@@ -601,7 +601,7 @@ sniffed against the descriptor; every tool that reaches a visitor-chosen host go
(`apps/_shared/egress.js`: public addresses only, checked after DNS, redirect hops re-checked); `trust proxy`
(`apps/_shared/egress.js`: public addresses only, checked after DNS, redirect hops re-checked); `trust proxy`
is one loopback hop; sign-in `next` accepts only relative paths, `*.openvibe.tools` and `openvibe.network`.
is one loopback hop; sign-in `next` accepts only relative paths, `*.openvibe.tools` and `openvibe.network`.
Secrets (`OV_OAUTH_CLIENT_SECRET`, `TOOLS_EVENTS_SECRET`, `NET_IPINFO_TOKEN`, `RIDB_API_KEY`, `NPS_API_KEY`,
Secrets (`OV_OAUTH_CLIENT_SECRET`, `TOOLS_EVENTS_SECRET`, `NET_IPINFO_TOKEN`, `RIDB_API_KEY`, `NPS_API_KEY`,
-`OPEN_CHARGE_MAP_KEY`, `YT_COOKIES_FILE`, the fallback `INTERNAL_API_KEY`) live in `/etc/openvibe/tools.env`
+`OPEN_CHARGE_MAP_KEY`, `YT_COOKIES_FILE`) live in `/etc/openvibe/tools.env`
(0600), by name only.
(0600), by name only.
## Deploy
## Deploy
deleted file mode 100644
deleted file mode 100644
@@ -1,25 +0,0 @@
@@ -1,25 +0,0 @@
-'use strict';
-// Internal-only routes (analytics totals for the Network's admin and ranking).
-// Two independent conditions, both required:
-// 1. X-Internal-Key equals INTERNAL_API_KEY from the unit's environment (never a value in the repo;
-// with no key configured every internal route is closed).
-// 2. The request did not come through the public proxy: nginx always adds X-Forwarded-For and
-// 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 (!loopbackOnly(req)) return false;
- return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));
-}
-
-module.exports = { internalOk, loopbackOnly };
@@ -1,20 +1,24 @@
@@ -1,20 +1,24 @@
'use strict';
'use strict';
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Internal analytics access (GET /api/internal/analytics[/bots]), shared by the gateway and every
// 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.
+// satellite. The X-Internal-Key is retired (plan T2): a Network service token alone opens the route
+// (contracts v0.79.0, capability tools.analytics.read, ADR-014 service tokens).
+// One way in, loopback-only (loopbackOnly below refuses anything that came through the proxy):
+// Authorization: Bearer <Network service token>: aud openvibe.tools, cap tools.analytics.read,
+// never a sandbox token. 401 for a bad or malformed token, 403 without the capability, and 401
+// token.missing when no Bearer is presented at all.
+// → { ok: true, via: 'token', bearer, claims } | { ok: false, status, body }
+// The gateway forwards the caller's Bearer to the satellites unchanged (same audience).
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
-const { internalOk, loopbackOnly } = require('./internal-auth');
const tokens = require('./guard/tokens');
const tokens = require('./guard/tokens');
+/** 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);
+}
+
const ANALYTICS_CAPABILITY = 'tools.analytics.read';
const ANALYTICS_CAPABILITY = 'tools.analytics.read';
const authHeader = (req) => String((req.headers && req.headers.authorization) || '');
const authHeader = (req) => String((req.headers && req.headers.authorization) || '');
@@ -32,21 +36,18 @@ const authHeader = (req) => String((req.headers && req.headers.authorization) ||
@@ -32,21 +36,18 @@ const authHeader = (req) => String((req.headers && req.headers.authorization) ||
function checkAccess(req, o = {}) {
function checkAccess(req, o = {}) {
if (!loopbackOnly(req)) return { ok: false, status: 404, body: { error: 'Not found' } };
if (!loopbackOnly(req)) return { ok: false, status: 404, body: { error: 'Not found' } };
const header = authHeader(req);
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' } };
+ if (!header) return { ok: false, status: 401, body: { error: 'Unauthorized', code: 'token.missing' } };
+ 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 };
}
}
/**
/**
@@ -27,9 +27,6 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4001/auth/callback
@@ -27,9 +27,6 @@ OV_OAUTH_REDIRECT_URI=http://localhost:4001/auth/callback
MEDIA_URL=http://127.0.0.1:4100
MEDIA_URL=http://127.0.0.1:4100
MEDIA_PUBLIC_URL=https://openvibe.media
MEDIA_PUBLIC_URL=https://openvibe.media
-# Internal API key for Network /internal/* calls (if ever needed)
-INTERNAL_API_KEY=change-me-to-a-random-64-char-string
-
# Net tools (optional). With an ipinfo.io token, IP lookups use ipinfo over HTTPS instead of ip-api.com's
# Net tools (optional). With an ipinfo.io token, IP lookups use ipinfo over HTTPS instead of ip-api.com's
# free tier (plain HTTP). Answers are cached either way. The Globalping token is read but no tool uses it yet.
# free tier (plain HTTP). Answers are cached either way. The Globalping token is read but no tool uses it yet.
#NET_IPINFO_TOKEN=
#NET_IPINFO_TOKEN=
@@ -403,15 +403,15 @@ function sendTool(req, res, file) {
@@ -403,15 +403,15 @@ 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'. Loopback only, with a Network
// 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).
+// service token (capability tools.analytics.read, apps/_shared/internal-token.js).
{
{
const { requireInternalAccess } = require('../../_shared/internal-token');
const { requireInternalAccess } = require('../../_shared/internal-token');
- // After the token-or-key check, before the fan-out: per-actor limit (actorLimits.admin, 30 a minute).
+ // After the token 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) => {
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;
- // 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']) };
+ // The caller's Bearer is what the satellites get (the same audience, no key any more).
+ const forward = { Authorization: req.ovInternal.bearer };
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: forward, 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) });
@@ -1,10 +1,9 @@
@@ -1,10 +1,9 @@
'use strict';
'use strict';
// The internal analytics route on the real gateway (apps/_shared/internal-token.js, contracts v0.79.0):
// 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.
+// a Network service token with tools.analytics.read (aud openvibe.tools) opens it and the Bearer is
+// forwarded unchanged to all seven satellites, a token without the capability is 403, a wrong audience
+// or a sandbox token is 401, the retired X-Internal-Key alone is 401 (token.missing), a bad Bearer beside
+// the key is still 401, and a proxied request is refused. A stub satellite records what is sent.
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
const fs = require('fs');
const fs = require('fs');
@@ -68,26 +67,26 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-gw-internal-'));
@@ -68,26 +67,26 @@ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-gw-internal-'));
// 4. A sandbox token is 401.
// 4. A sandbox token is 401.
assert.strictEqual((await get({ Authorization: `Bearer ${sandbox}` })).status, 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.
+ // 5. A bad Bearer is 401 whatever else is sent beside it (the retired key does not help).
assert.strictEqual((await get({ Authorization: 'Bearer not-a-jwt', 'X-Internal-Key': KEY })).status, 401);
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).
// 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({ '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);
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.
+ // 7. The retired X-Internal-Key alone is 401 token.missing, with or without the env var set.
seen.length = 0;
seen.length = 0;
r = await get({ 'X-Internal-Key': KEY });
r = await get({ 'X-Internal-Key': KEY });
body = await r.json();
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');
+ assert.strictEqual(r.status, 401, JSON.stringify(body));
+ assert.strictEqual(body.code, 'token.missing');
+ assert.strictEqual(fanout().length, 0, 'nothing is fanned out without a token');
- // 8. No credentials is 404, as before.
- assert.strictEqual((await get()).status, 404);
+ // 8. No credentials is 401 too.
+ r = await get();
+ assert.strictEqual(r.status, 401, JSON.stringify(await r.json()));
- 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');
+ console.log('gateway internal analytics: token (tools.analytics.read) opens and is forwarded, 403 without it, 401 for wrong audience/sandbox/bad Bearer, the key alone is 401, proxied refused');
} finally {
} finally {
if (gw) await gw.kill();
if (gw) await gw.kill();
await new Promise(r => stub.close(r));
await new Promise(r => stub.close(r));
@@ -1,7 +1,9 @@
@@ -1,7 +1,9 @@
'use strict';
'use strict';
/**
/**
* The gateway's secrets never reach a visitor (roadmap WS-R task 5, the internal-secret class). The
* The gateway's secrets never reach a visitor (roadmap WS-R task 5, the internal-secret class). The
- * gateway starts with a sentinel as its Network OAuth client secret, its internal API keys and its
+ * first check is static: the retired internal key (X-Internal-Key, plan T2) is read nowhere in the
+ * serving code. Then the
+ * gateway starts with a sentinel as its Network OAuth client secret and its
* Events subscription secret, and with no network (the no-network preload: every third-party call
* Events subscription secret, and with no network (the no-network preload: every third-party call
* and lookup fails at once, so the net tools and the sign-in, registry and search calls all take
* and lookup fails at once, so the net tools and the sign-in, registry and search calls all take
* their error paths). Then its pages, APIs, every tool in its registry (description and schema) and
* their error paths). Then its pages, APIs, every tool in its registry (description and schema) and
@@ -17,8 +19,6 @@ const { startApp } = require('../../_shared/test/spawn');
@@ -17,8 +19,6 @@ const { startApp } = require('../../_shared/test/spawn');
const SECRETS = {
const SECRETS = {
OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-tools-oauth-client',
OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-tools-oauth-client',
- INTERNAL_API_KEY: 'sentinel-not-a-secret-tools-internal',
- OV_INTERNAL_KEY: 'sentinel-not-a-secret-tools-ov-internal',
TOOLS_EVENTS_SECRET: 'sentinel-not-a-secret-tools-events-subscription',
TOOLS_EVENTS_SECRET: 'sentinel-not-a-secret-tools-events-subscription',
};
};
const NET_TOOLS = ['blacklist', 'dnsprop', 'dns', 'headers', 'ip', 'ipv4', 'ipv6', 'lookup', 'ping', 'port', 'rdap', 'rdns', 'redirects', 'robots', 'sitemap', 'smtp', 'ssl', 'uptime', 'whois'];
const NET_TOOLS = ['blacklist', 'dnsprop', 'dns', 'headers', 'ip', 'ipv4', 'ipv6', 'lookup', 'ping', 'port', 'rdap', 'rdns', 'redirects', 'robots', 'sitemap', 'smtp', 'ssl', 'uptime', 'whois'];
@@ -34,6 +34,30 @@ const client = () => { seq++; return { 'x-forwarded-for': `198.18.${(seq >> 8) &
@@ -34,6 +34,30 @@ const client = () => { seq++; return { 'x-forwarded-for': `198.18.${(seq >> 8) &
});
});
let failures = 0;
let failures = 0;
const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 8).join('\n ')}`); } };
const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${String(e.stack || e.message).split('\n').slice(0, 8).join('\n ')}`); } };
+ const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/.*$/gm, '$1');
+ const RETIRED_KEY = /\bINTERNAL_API_KEY\b|\bOV_INTERNAL_KEY\b|x-internal-key/i;
+ await check('no server file or _shared helper reads the retired internal key', () => {
+ const roots = [path.join(__dirname, '..', '..'), path.join(__dirname, '..', '..', '_shared')];
+ const files = [];
+ const walk = (dir) => {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, e.name);
+ if (e.isDirectory()) { if (e.name === 'node_modules' || e.name === 'test') continue; walk(full); }
+ else if (/\.(js|cjs|mjs|ts)$/.test(e.name)) files.push(full);
+ }
+ };
+ for (const root of roots) {
+ for (const e of fs.readdirSync(root, { withFileTypes: true })) {
+ const full = path.join(root, e.name);
+ if (!e.isDirectory() || e.name !== 'server') continue;
+ for (const f of fs.readdirSync(full)) if (/\.js$/.test(f)) files.push(path.join(full, f));
+ }
+ }
+ walk(roots[1]);
+ assert.ok(files.length > 10, `found ${files.length} files to scan`);
+ const hits = files.filter((f) => RETIRED_KEY.test(stripComments(fs.readFileSync(f, 'utf8'))));
+ assert.deepStrictEqual(hits.map((f) => path.relative(path.join(__dirname, '..', '..'), f)), []);
+ });
const get = async (p, opts = {}) => {
const get = async (p, opts = {}) => {
const ac = new AbortController();
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 15000);
const timer = setTimeout(() => ac.abort(), 15000);
@@ -2,8 +2,8 @@
@@ -2,8 +2,8 @@
// The internal analytics routes on a satellite (apps/_shared/internal-token.js): the image service
// 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
// 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
// /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).
+// bad Bearer beside the retired key (401), a proxied request (404), and the retired key alone or
+// nothing at all (401 token.missing).
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
const fs = require('fs');
const fs = require('fs');
@@ -33,7 +33,7 @@ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-internal-'));
@@ -33,7 +33,7 @@ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-internal-'));
// A token with the capability passes both routes.
// A token with the capability passes both routes.
for (const p of ['/api/internal/analytics', '/api/internal/analytics/bots']) {
for (const p of ['/api/internal/analytics', '/api/internal/analytics/bots']) {
- const r = await get(p, { Authorization: `Bearer ${good}` });
+ let r = await get(p, { Authorization: `Bearer ${good}` });
const body = await r.json();
const body = await r.json();
assert.strictEqual(r.status, 200, `${p}: ${JSON.stringify(body)}`);
assert.strictEqual(r.status, 200, `${p}: ${JSON.stringify(body)}`);
assert.ok(body.ok, p);
assert.ok(body.ok, p);
@@ -42,17 +42,20 @@ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-internal-'));
@@ -42,17 +42,20 @@ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-img-internal-'));
// Another audience, and a sandbox token: 401.
// 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 ${wrongAud}` })).status, 401, p);
assert.strictEqual((await get(p, { Authorization: `Bearer ${sandbox}` })).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.
+ // A bad Bearer is 401 whatever else is sent beside it (the retired key does not help).
assert.strictEqual((await get(p, { Authorization: 'Bearer not-a-jwt', 'X-Internal-Key': KEY })).status, 401, p);
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);
+ // The retired key alone is 401 token.missing, and so is no credentials at all.
+ r = await get(p, { 'X-Internal-Key': KEY });
+ assert.strictEqual(r.status, 401, p);
+ assert.strictEqual((await r.json()).code, 'token.missing', p);
+ r = await get(p);
+ assert.strictEqual(r.status, 401, p);
+ assert.strictEqual((await r.json()).code, 'token.missing', p);
// A proxied request is refused with key or token.
// 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, { '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);
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');
+ console.log('img internal analytics: token (tools.analytics.read) passes, 403 without it, 401 wrong audience/sandbox/bad Bearer, the retired key alone is 401, proxied refused');
} finally {
} finally {
if (app) await app.kill();
if (app) await app.kill();
await net.close();
await net.close();
@@ -15,8 +15,6 @@ const KEYS = {
@@ -15,8 +15,6 @@ const KEYS = {
RIDB_API_KEY: 'sentinel-not-a-secret-ridb',
RIDB_API_KEY: 'sentinel-not-a-secret-ridb',
NPS_API_KEY: 'sentinel-not-a-secret-nps',
NPS_API_KEY: 'sentinel-not-a-secret-nps',
OPEN_CHARGE_MAP_KEY: 'sentinel-not-a-secret-ocm',
OPEN_CHARGE_MAP_KEY: 'sentinel-not-a-secret-ocm',
- INTERNAL_API_KEY: 'sentinel-not-a-secret-internal',
- OV_INTERNAL_KEY: 'sentinel-not-a-secret-ov-internal',
};
};
// One loopback proxy hop is trusted (guard/ip.js): a fresh client address per request keeps the
// One loopback proxy hop is trusted (guard/ip.js): a fresh client address per request keeps the