openvibers/openvibe.sources Commits
Sources: the read-only GitHub token is an owner setting (sources.data_sources) (#219)
Changes
@@@ -84,6 +84,10 @@ VALKEY_PREFIX=#SOURCES_ALLOW_PRIVATE_HOSTS=#SOURCES_MAX_ITEMS_PER_FETCH=12000++# Data-source keys (D11 owner settings): the admin panel's sources.data_sources namespace wins; this++# is the fallback when no value was entered there.++#SOURCES_GITHUB_TOKEN=++# Source credentials: a registry entry names the variable (auth.env), never the value. Names# must start with SOURCES_CRED_. Example (none of the seeds needs one):#SOURCES_CRED_EXAMPLE_API_KEY=@@@ -601,7 +601,7 @@ in [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites); thPart of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.295.0++- openvibe-contracts: v0.311.0- openvibe-sdk: v0.93.0- openvibe-shared: v4.124.0<!-- versions:end -->@@@ -7,7 +7,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser","port": 4720,"domain": "sources.openvibe.network (vhost in deploy/nginx: an internal-service page, the merchant opt-in pages, health and JSON 404s; the JSON API is loopback-only; README \"Public host\")",-- "contracts": "openvibe-contracts v0.295.0",++ "contracts": "openvibe-contracts v0.311.0","sdk": "openvibe-sdk v0.93.0","shared": "openvibe-shared v4.124.0","capabilities": [nnew file mode 100644@@@ -0,0 +1,30 @@++-- phase: expand++-- D11 owner settings (openvibe-shared/config): the configuration snapshots and the per-namespace++-- fingerprint key. Sources' data-source keys live in sources.data_sources; the store seals++-- secret-class values under OV_CONFIG_KEKS and keeps only markers in snapshots. Every table here is++-- new, and an older release ignores all of it.++CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL,++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0,++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL,++ created_at text COLLATE "C" NOT NULL++);@@@ -12,7 +12,7 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.295.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0","pg": "^8.23.0"@@@ -1044,9 +1044,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.295.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.295.0",-- "integrity": "sha512-GLE0+RK6gqeLxoMCVG9IG1sIS50+kDbI1BTo56jypcSNNojAibOxTdNfbsKDyNe1rWHM01AOkZ6dGcHH7FIGOw==",++ "version": "0.311.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",++ "integrity": "sha512-s0CubY2CA281mADLTwfnHSlpcC+fXbdmTEHFd0gExXrE6P2DGZlf+l+p6GyyOTF0k4CJZeCutDzwSjfSU/ZJRg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -30,7 +30,7 @@"dotenv": "^16.4.7","express": "^4.21.2","fast-xml-parser": "^4.5.3",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.295.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0","pg": "^8.23.0"@@@ -3,7 +3,7 @@const path = require('path');const express = require('express');const { homePage } = require('./views/home');--const { http } = require('openvibe-contracts');++const { http, capabilities } = require('openvibe-contracts');const { instrument } = require('openvibe-shared/metrics');const { createRelease } = require('openvibe-shared/release');const { createSourcesReadiness, registerSourcesGauges } = require('./observability');@@@ -13,6 +13,7 @@ const { merchantRouter } = require('./api/merchant');const { merchantPages } = require('./merchant-pages');const { createActorLimits } = require('./api/actor-limits');const pkg = require('../package.json');++const CONFIG_CAPABILITY = 'sources.config.manage';const TEXT_INDEX = ['OpenVibe.Sources: the source registry and ingestion workers for the network\'s publication products.',@@@ -34,7 +35,7 @@ const TEXT_INDEX = [].join('\n');--function createApp({ config, db, registry, items, ingest, scheduler, auth, outbox, now, log = console, limitsNow = null, merchant = null, merchantAuth = null, accountData = null, accountSend = null }) {++function createApp({ config, db, registry, items, ingest, scheduler, auth, outbox, now, log = console, limitsNow = null, merchant = null, merchantAuth = null, accountData = null, accountSend = null, settings = null }) {const app = express();// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts// (openvibe-contracts); the operations they mark internal-only are left out.@@@ -76,6 +77,28 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo});app.use('/api', express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] }));++ // ── D11 owner settings (openvibe-shared/config): Network's admin proxy, acting for the owner, reads and++ // writes sources.data_sources (the read-only GitHub token). Loopback only, guarded by++ // sources.config.manage; a secret change needs X-OV-Fresh: 1.++ if (settings) {++ const configGuard = async (req, res, next) => {++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {++ return res.status(404).json({ code: 'sources.not_found', error: 'no such route' });++ }++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ')) return res.status(401).json({ code: 'token.missing', error: 'a service token is required' });++ const r = await auth.verifyService(header.slice(7).trim());++ if (!r.ok) return res.status(r.code === 'token.unavailable' ? 503 : 401).json({ code: r.code, error: r.reason || 'the token does not verify' });++ const decision = capabilities.check(r.claims, CONFIG_CAPABILITY);++ const granted = decision.allowed || capabilities.grants(r.claims.cap, CONFIG_CAPABILITY);++ if (!granted) return res.status(403).json({ code: 'capability.denied', error: 'sources.config.manage is not granted' });++ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };++ return next();++ };++ app.use('/internal/config', express.json({ limit: '64kb' }));++ settings.mount(app, { guard: configGuard, log });++ }++app.get('/api/health', (_req, res) => {res.json({ status: 'ok', service: 'openvibe-sources', version: pkg.version });});@@@ -29,6 +29,7 @@ const { createAuth } = require('./auth');const { createMerchant } = require('./merchant');const { createMerchantAuth } = require('./merchant-auth');const { createApp } = require('./app');++const settingsLib = require('./settings');const accountDataLib = require('./account-data');const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');@@@ -64,7 +65,17 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetchconst merchantAuth = createMerchantAuth({ config, auth, log, fetchImpl });const accountData = accountDataLib.create({ db, log });const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }) : null;-- const app = createApp({ config, db, registry, items, ingest, scheduler, auth, outbox, now, log, limitsNow, merchant, merchantAuth, accountData, accountSend });++ // D11 owner settings (sources.data_sources): created before the app mounts /internal/config. A failure++ // leaves Sources serving (the GitHub token falls back to the environment) and says why in the log.++ let settings = null;++ try {++ const made = settingsLib.createSettings({ db, env: process.env, log });++ await made.ready;++ settings = settingsLib.activate(made);++ } catch (err) {++ log.warn('[OpenVibe.Sources] owner settings unavailable, the data-source keys fall back to the environment:', err && err.message);++ }++ const app = createApp({ config, db, registry, items, ingest, scheduler, auth, outbox, now, log, limitsNow, merchant, merchantAuth, accountData, accountSend, settings });// One JWKS client for the process (the SDK shares it with verifyUserToken): refresh in the background// on an unref'd timer, keeping the last good keys through a Network outage.keys.start();@@@ -119,7 +130,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetchif (!givenDb) await db.close();}-- return { config, db, registry, items, guard, fetcher, robots, ingest, scheduler, jobs, outbox, keys, keyLoaded, auth, merchant, merchantAuth, app, server, close };++ return { config, db, settings, registry, items, guard, fetcher, robots, ingest, scheduler, jobs, outbox, keys, keyLoaded, auth, merchant, merchantAuth, app, server, close };}if (require.main === module) {nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++++/**++ * Owner settings (D11) for OpenVibe.Sources: the data-source keys in `sources.data_sources`.++ *++ * The read-only GitHub token is read through githubToken() when a GitHub read is made: the value++ * the owner entered in the admin panel wins, SOURCES_GITHUB_TOKEN is the fallback. The token is++ * sealed by the shared store under OV_CONFIG_KEKS and is write-only through++ * /internal/config/sources.data_sources/secrets/github_read_only_token; summaries, history and logs++ * carry markers. Network's admin proxy reaches this namespace with `sources.config.manage`, acting++ * for the owner (X-OV-Subject); a secret change needs X-OV-Fresh: 1. The test route makes one cheap++ * authenticated GitHub read and answers ok or failed in one line, never echoing the token.++ */++const { createConfigStore, adminRoutes } = require('openvibe-shared/config');++++const SERVICE = 'sources';++const NAMESPACE = 'sources.data_sources';++const BASE_PATH = '/internal/config';++const GROUP = 'Data sources';++++const SCHEMA = Object.freeze({++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'Sources data sources',++ description: 'The accounts Sources reads its sources with. A source with no key falls back to anonymous reads; the other sources keep working.',++ type: 'object',++ additionalProperties: false,++ properties: {++ github_read_only_token: {++ type: 'string', title: 'GitHub read-only token', 'x-group': GROUP,++ description: 'A GitHub token with read-only access to public repositories. It raises GitHub\'s rate limit for Sources\' reads and lets it read files from private repositories it was given access to; without it those reads are anonymous and limited.',++ 'x-owner-input': {++ group: 'data-sources',++ unlocks: 'GitHub reads for Sources run with the token\'s higher rate limit and can see the private repositories it is allowed to read.',++ get_it_at: 'https://github.com/settings/tokens',++ get_it_how: 'Create a fine-grained token with only read access to public repositories (or the repositories Sources may read), then paste it here.',++ test: true,++ },++ 'x-required-for': 'GitHub source reads',++ },++ },++});++++const SECRETS = new Set(['github_read_only_token']);++const ACTIVE = { settings: null };++++function createSettings({ db, env = process.env, log = console, fetchImpl = globalThis.fetch, probes = null } = {}) {++ let store = null;++ const ready = createConfigStore({++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA,++ classify: (key) => (SECRETS.has(key) ? 'secret' : 'internal'),++ env, log,++ }).then((s) => { store = s; return s; });++++ const values = () => (store ? store.get() : {});++++ function valueOf(key, envName, fallback = '') {++ const v = values()[key];++ if (v !== undefined && v !== null && v !== '') return v;++ const e = envName ? env[envName] : undefined;++ return e !== undefined && e !== null && e !== '' ? e : fallback;++ }++++ /** The read-only GitHub token, read when a GitHub read is made. */++ function githubToken() { return valueOf('github_read_only_token', 'SOURCES_GITHUB_TOKEN', ''); }++++ async function test(key) {++ if (key !== 'github_read_only_token') return { ok: false, detail: 'That setting has no test.' };++ const token = githubToken();++ if (!token) return { ok: false, detail: 'GitHub is not configured: paste the read-only token first.' };++ const probe = probes && typeof probes.github === 'function'++ ? probes.github++ : async ({ token: value }) => {++ const r = await fetchImpl('https://api.github.com/rate_limit', {++ headers: { accept: 'application/vnd.github+json', authorization: `Bearer ${value}`, 'user-agent': 'OpenVibe.Sources', 'x-github-api-version': '2022-11-28' },++ });++ let body = {};++ try { body = await r.json(); } catch { /* not JSON */ }++ if (!r.ok) throw new Error(`HTTP ${r.status}${body && body.message ? `: ${body.message}` : ''}`);++ };++ try {++ await probe({ token });++ return { ok: true, detail: 'ok — GitHub answered the rate-limit read with this token.' };++ } catch (err) {++ const words = String((err && err.message) || 'the request failed').split(/\s+/).join(' ').slice(0, 160);++ return { ok: false, detail: `failed — GitHub said: ${words.split(token).join('[redacted]')}` };++ }++ }++++ function mount(app, { guard, log: appLog = log } = {}) {++ if (!store) throw new Error('createSettings: await ready before mount()');++ const routes = adminRoutes([store], {++ basePath: BASE_PATH,++ requireAdmin: guard,++ actor: (req) => {++ const id = String(req.get('x-ov-subject') || '').trim();++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? { type: 'user', id } : null;++ },++ requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',++ log: appLog,++ });++ app.use(routes.handle);++ app.post(`${BASE_PATH}/:namespace/test/:key`, guard, async (req, res) => {++ if (req.params.namespace !== NAMESPACE) return res.status(404).json({ error: 'no such configuration namespace' });++ let key = String(req.params.key || '');++ try { key = decodeURIComponent(key); } catch { /* keep it as it is */ }++ if (!SCHEMA.properties[key]) return res.status(404).json({ error: 'no such setting' });++ try {++ const out = await test(key);++ return res.status(200).json({ ok: Boolean(out.ok), detail: String(out.detail).replace(/\s+/g, ' ').slice(0, 200) });++ } catch (err) {++ appLog.warn(`[config] ${NAMESPACE}: the test for ${key} failed: ${String((err && err.message) || err).slice(0, 160)}`);++ return res.status(200).json({ ok: false, detail: 'failed — the test did not finish. The service log has the reason.' });++ }++ });++ return routes;++ }++++ return { namespace: NAMESPACE, schema: SCHEMA, ready, value: valueOf, get: values, githubToken, test, mount };++}++++function activate(settings) { ACTIVE.settings = settings; return settings; }++function githubToken() {++ if (ACTIVE.settings) return ACTIVE.settings.githubToken();++ return process.env.SOURCES_GITHUB_TOKEN || '';++}++++module.exports = { SERVICE, NAMESPACE, SCHEMA, SECRETS, createSettings, activate, githubToken };@@@ -19,7 +19,7 @@ const B = 'usr_01JZ0000000000000000000BBB';const accountData = create({ db: d.db });const missing = await uncovered(d.db, {tables: [...TABLES, 'merchant_sources'], // the merchant's claim: exported and erased by this module-- exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Sources applied' },++ exempt: { "config_snapshots":"D11 owner settings: created_by/activated_by is the acting administrator (usr_...) for the config audit trail; the values are network configuration, not the person's account data, and are kept for rollback/history", account_data_events: 'the ledger of requests itself: which export and deletion Sources applied' },});assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);nnew file mode 100644@@@ -0,0 +1,92 @@++'use strict';++/**++ * Owner settings (D11) for Sources' data sources (server/settings.js, sources.data_sources):++ * - the read-only GitHub token has a plain title, a 30+ character description, x-owner-input++ * words and a test;++ * - /internal/config is loopback only and needs sources.config.manage;++ * - the token is write-only and marked in summaries, githubToken() reads the owner's value and++ * falls back to SOURCES_GITHUB_TOKEN, and the test hook never echoes or logs the token.++ * node test/owner-settings.test.js++ */++const assert = require('assert');++const http = require('http');++const express = require('express');++const { boot, request, serviceToken } = require('./helpers');++const { createSettings } = require('../server/settings');++++const CAP = 'sources.config.manage';++const NS = 'sources.data_sources';++const USR = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';++const TOKEN = 'github-owner-token-sentinel';++++(async () => {++ const lines = [];++ const log = { log: (...a) => lines.push(a.join(' ')), info: (...a) => lines.push(a.join(' ')), warn: (...a) => lines.push(a.join(' ')), error: (...a) => lines.push(a.join(' ')) };++ const svc = await boot({ log });++ const bearer = serviceToken('network', [CAP]);++ const checks = [];++ const check = async (name, fn) => {++ try { await fn(); checks.push(name); console.log(` ✓ ${name}`); } catch (e) { console.log(` ✗ ${name}\n${e.stack}`); await svc.stop(); process.exit(1); }++ };++++ await check('the GitHub token has owner words and a test', async () => {++ const r = await request(svc.base, 'GET', `/internal/config/${NS}`, { token: bearer });++ assert.strictEqual(r.status, 200, r.text);++ const f = r.body.schema.properties.github_read_only_token;++ assert.ok(f);++ assert.ok(f.title && f.title.length >= 3);++ assert.ok(f.description && f.description.length >= 30);++ assert.strictEqual(f['x-owner-input'].group, 'data-sources');++ assert.ok(f['x-owner-input'].unlocks.length > 20);++ assert.strictEqual(f['x-owner-input'].get_it_at, 'https://github.com/settings/tokens');++ assert.strictEqual(f['x-owner-input'].test, true);++ assert.strictEqual(f['x-required-for'], 'GitHub source reads');++ });++++ await check('the routes are loopback only and need the capability', async () => {++ assert.strictEqual((await request(svc.base, 'GET', `/internal/config/${NS}`)).status, 401);++ assert.strictEqual((await request(svc.base, 'GET', `/internal/config/${NS}`, { token: serviceToken('news', ['sources.source.read']) })).status, 403);++ assert.strictEqual((await request(svc.base, 'GET', `/internal/config/${NS}`, { token: bearer, headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 404);++ });++++ await check('the token is write-only and the environment is the fallback', async () => {++ const envOnly = createSettings({ db: svc.db, env: { SOURCES_GITHUB_TOKEN: 'github-env-token' } });++ await envOnly.ready;++ assert.strictEqual(envOnly.githubToken(), 'github-env-token', 'the env is the fallback');++ const put = await request(svc.base, 'PUT', `/internal/config/${NS}/secrets/github_read_only_token`, {++ token: bearer, body: { value: TOKEN, reason: 'owner test' }, headers: { 'x-ov-subject': USR, 'x-ov-fresh': '1' },++ });++ assert.strictEqual(put.status, 200, put.text);++ assert.ok(put.body.fingerprint && put.body.hint);++ assert.ok(!put.text.includes(TOKEN));++ const summary = await request(svc.base, 'GET', `/internal/config/${NS}`, { token: bearer });++ assert.ok(summary.body.values.github_read_only_token.redacted);++ assert.ok(!summary.text.includes(TOKEN));++ assert.strictEqual(svc.settings.githubToken(), TOKEN, 'the code reads the owner value when set');++ });++++ await check('the test hook says ok/failed in one line and never echoes a token', async () => {++ const fake = createSettings({++ db: svc.db, env: {},++ probes: { github: async ({ token }) => { throw new Error(`refused ${token}`); } },++ });++ await fake.ready;++ const app = express();++ app.use(express.json());++ fake.mount(app, { guard: (_req, _res, next) => next(), log });++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ try {++ const r = await fetch(`http://127.0.0.1:${server.address().port}/internal/config/${NS}/test/github_read_only_token`, { method: 'POST' });++ const body = await r.json();++ assert.strictEqual(r.status, 200);++ assert.strictEqual(body.ok, false);++ assert.ok(/^failed — GitHub said: /.test(body.detail), body.detail);++ assert.ok(!body.detail.includes(TOKEN));++ assert.ok(!body.detail.includes('\n'));++ } finally { server.close(); }++ assert.ok(!lines.join('\n').includes(TOKEN), 'no token in a captured log line');++ });++++ await svc.stop();++ console.log(`\n${checks.length} checks passed`);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -152,6 +152,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>const REVIEWED = {'server/net/fetcher.js': 'every source fetch; the guard (checkUrl up front, lookup at connect, on every redirect hop)','server/merchant-auth.js': 'OAuth code exchange only, to the operator-configured Network URLs (never a merchant-supplied URL)',++ 'server/settings.js': 'the owner-settings test hook: one fixed GitHub URL (api.github.com/rate_limit), never a caller-supplied URL; the token only goes to GitHub',};const root = path.join(__dirname, '..');const found = [];
@@ -84,6 +84,10 @@ VALKEY_PREFIX=
@@ -84,6 +84,10 @@ VALKEY_PREFIX=
#SOURCES_ALLOW_PRIVATE_HOSTS=
#SOURCES_ALLOW_PRIVATE_HOSTS=
#SOURCES_MAX_ITEMS_PER_FETCH=12000
#SOURCES_MAX_ITEMS_PER_FETCH=12000
+# Data-source keys (D11 owner settings): the admin panel's sources.data_sources namespace wins; this
+# is the fallback when no value was entered there.
+#SOURCES_GITHUB_TOKEN=
+
# Source credentials: a registry entry names the variable (auth.env), never the value. Names
# Source credentials: a registry entry names the variable (auth.env), never the value. Names
# must start with SOURCES_CRED_. Example (none of the seeds needs one):
# must start with SOURCES_CRED_. Example (none of the seeds needs one):
#SOURCES_CRED_EXAMPLE_API_KEY=
#SOURCES_CRED_EXAMPLE_API_KEY=
@@ -601,7 +601,7 @@ in [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites); th
@@ -601,7 +601,7 @@ in [OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites); th
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.295.0
+- openvibe-contracts: v0.311.0
- openvibe-sdk: v0.93.0
- openvibe-sdk: v0.93.0
- openvibe-shared: v4.124.0
- openvibe-shared: v4.124.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"port": 4720,
"port": 4720,
"domain": "sources.openvibe.network (vhost in deploy/nginx: an internal-service page, the merchant opt-in pages, health and JSON 404s; the JSON API is loopback-only; README \"Public host\")",
"domain": "sources.openvibe.network (vhost in deploy/nginx: an internal-service page, the merchant opt-in pages, health and JSON 404s; the JSON API is loopback-only; README \"Public host\")",
- "contracts": "openvibe-contracts v0.295.0",
+ "contracts": "openvibe-contracts v0.311.0",
"sdk": "openvibe-sdk v0.93.0",
"sdk": "openvibe-sdk v0.93.0",
"shared": "openvibe-shared v4.124.0",
"shared": "openvibe-shared v4.124.0",
"capabilities": [
"capabilities": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+-- phase: expand
+-- D11 owner settings (openvibe-shared/config): the configuration snapshots and the per-namespace
+-- fingerprint key. Sources' data-source keys live in sources.data_sources; the store seals
+-- secret-class values under OV_CONFIG_KEKS and keeps only markers in snapshots. Every table here is
+-- new, and an older release ignores all of it.
+CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL,
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0,
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL,
+ created_at text COLLATE "C" NOT NULL
+);
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.295.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1044,9 +1044,9 @@
@@ -1044,9 +1044,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.295.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.295.0",
- "integrity": "sha512-GLE0+RK6gqeLxoMCVG9IG1sIS50+kDbI1BTo56jypcSNNojAibOxTdNfbsKDyNe1rWHM01AOkZ6dGcHH7FIGOw==",
+ "version": "0.311.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
+ "integrity": "sha512-s0CubY2CA281mADLTwfnHSlpcC+fXbdmTEHFd0gExXrE6P2DGZlf+l+p6GyyOTF0k4CJZeCutDzwSjfSU/ZJRg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"fast-xml-parser": "^4.5.3",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.295.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
const path = require('path');
const path = require('path');
const express = require('express');
const express = require('express');
const { homePage } = require('./views/home');
const { homePage } = require('./views/home');
-const { http } = require('openvibe-contracts');
+const { http, capabilities } = require('openvibe-contracts');
const { instrument } = require('openvibe-shared/metrics');
const { instrument } = require('openvibe-shared/metrics');
const { createRelease } = require('openvibe-shared/release');
const { createRelease } = require('openvibe-shared/release');
const { createSourcesReadiness, registerSourcesGauges } = require('./observability');
const { createSourcesReadiness, registerSourcesGauges } = require('./observability');
@@ -13,6 +13,7 @@ const { merchantRouter } = require('./api/merchant');
@@ -13,6 +13,7 @@ const { merchantRouter } = require('./api/merchant');
const { merchantPages } = require('./merchant-pages');
const { merchantPages } = require('./merchant-pages');
const { createActorLimits } = require('./api/actor-limits');
const { createActorLimits } = require('./api/actor-limits');
const pkg = require('../package.json');
const pkg = require('../package.json');
+const CONFIG_CAPABILITY = 'sources.config.manage';
const TEXT_INDEX = [
const TEXT_INDEX = [
'OpenVibe.Sources: the source registry and ingestion workers for the network\'s publication products.',
'OpenVibe.Sources: the source registry and ingestion workers for the network\'s publication products.',
@@ -34,7 +35,7 @@ const TEXT_INDEX = [
@@ -34,7 +35,7 @@ const TEXT_INDEX = [
].join('\n');
].join('\n');
-function createApp({ config, db, registry, items, ingest, scheduler, auth, outbox, now, log = console, limitsNow = null, merchant = null, merchantAuth = null, accountData = null, accountSend = null }) {
+function createApp({ config, db, registry, items, ingest, scheduler, auth, outbox, now, log = console, limitsNow = null, merchant = null, merchantAuth = null, accountData = null, accountSend = null, settings = null }) {
const app = express();
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
// (openvibe-contracts); the operations they mark internal-only are left out.
@@ -76,6 +77,28 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo
@@ -76,6 +77,28 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo
});
});
app.use('/api', express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] }));
app.use('/api', express.json({ limit: '256kb', type: ['application/json', 'application/*+json'] }));
+ // ── D11 owner settings (openvibe-shared/config): Network's admin proxy, acting for the owner, reads and
+ // writes sources.data_sources (the read-only GitHub token). Loopback only, guarded by
+ // sources.config.manage; a secret change needs X-OV-Fresh: 1.
+ if (settings) {
+ const configGuard = async (req, res, next) => {
+ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {
+ return res.status(404).json({ code: 'sources.not_found', error: 'no such route' });
+ }
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ')) return res.status(401).json({ code: 'token.missing', error: 'a service token is required' });
+ const r = await auth.verifyService(header.slice(7).trim());
+ if (!r.ok) return res.status(r.code === 'token.unavailable' ? 503 : 401).json({ code: r.code, error: r.reason || 'the token does not verify' });
+ const decision = capabilities.check(r.claims, CONFIG_CAPABILITY);
+ const granted = decision.allowed || capabilities.grants(r.claims.cap, CONFIG_CAPABILITY);
+ if (!granted) return res.status(403).json({ code: 'capability.denied', error: 'sources.config.manage is not granted' });
+ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
+ return next();
+ };
+ app.use('/internal/config', express.json({ limit: '64kb' }));
+ settings.mount(app, { guard: configGuard, log });
+ }
+
app.get('/api/health', (_req, res) => {
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', service: 'openvibe-sources', version: pkg.version });
res.json({ status: 'ok', service: 'openvibe-sources', version: pkg.version });
});
});
@@ -29,6 +29,7 @@ const { createAuth } = require('./auth');
@@ -29,6 +29,7 @@ const { createAuth } = require('./auth');
const { createMerchant } = require('./merchant');
const { createMerchant } = require('./merchant');
const { createMerchantAuth } = require('./merchant-auth');
const { createMerchantAuth } = require('./merchant-auth');
const { createApp } = require('./app');
const { createApp } = require('./app');
+const settingsLib = require('./settings');
const accountDataLib = require('./account-data');
const accountDataLib = require('./account-data');
const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');
const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');
@@ -64,7 +65,17 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -64,7 +65,17 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
const merchantAuth = createMerchantAuth({ config, auth, log, fetchImpl });
const merchantAuth = createMerchantAuth({ config, auth, log, fetchImpl });
const accountData = accountDataLib.create({ db, log });
const accountData = accountDataLib.create({ db, log });
const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }) : null;
const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }) : null;
- const app = createApp({ config, db, registry, items, ingest, scheduler, auth, outbox, now, log, limitsNow, merchant, merchantAuth, accountData, accountSend });
+ // D11 owner settings (sources.data_sources): created before the app mounts /internal/config. A failure
+ // leaves Sources serving (the GitHub token falls back to the environment) and says why in the log.
+ let settings = null;
+ try {
+ const made = settingsLib.createSettings({ db, env: process.env, log });
+ await made.ready;
+ settings = settingsLib.activate(made);
+ } catch (err) {
+ log.warn('[OpenVibe.Sources] owner settings unavailable, the data-source keys fall back to the environment:', err && err.message);
+ }
+ const app = createApp({ config, db, registry, items, ingest, scheduler, auth, outbox, now, log, limitsNow, merchant, merchantAuth, accountData, accountSend, settings });
// One JWKS client for the process (the SDK shares it with verifyUserToken): refresh in the background
// One JWKS client for the process (the SDK shares it with verifyUserToken): refresh in the background
// on an unref'd timer, keeping the last good keys through a Network outage.
// on an unref'd timer, keeping the last good keys through a Network outage.
keys.start();
keys.start();
@@ -119,7 +130,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -119,7 +130,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
if (!givenDb) await db.close();
if (!givenDb) await db.close();
}
}
- return { config, db, registry, items, guard, fetcher, robots, ingest, scheduler, jobs, outbox, keys, keyLoaded, auth, merchant, merchantAuth, app, server, close };
+ return { config, db, settings, registry, items, guard, fetcher, robots, ingest, scheduler, jobs, outbox, keys, keyLoaded, auth, merchant, merchantAuth, app, server, close };
}
}
if (require.main === module) {
if (require.main === module) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+
+/**
+ * Owner settings (D11) for OpenVibe.Sources: the data-source keys in `sources.data_sources`.
+ *
+ * The read-only GitHub token is read through githubToken() when a GitHub read is made: the value
+ * the owner entered in the admin panel wins, SOURCES_GITHUB_TOKEN is the fallback. The token is
+ * sealed by the shared store under OV_CONFIG_KEKS and is write-only through
+ * /internal/config/sources.data_sources/secrets/github_read_only_token; summaries, history and logs
+ * carry markers. Network's admin proxy reaches this namespace with `sources.config.manage`, acting
+ * for the owner (X-OV-Subject); a secret change needs X-OV-Fresh: 1. The test route makes one cheap
+ * authenticated GitHub read and answers ok or failed in one line, never echoing the token.
+ */
+const { createConfigStore, adminRoutes } = require('openvibe-shared/config');
+
+const SERVICE = 'sources';
+const NAMESPACE = 'sources.data_sources';
+const BASE_PATH = '/internal/config';
+const GROUP = 'Data sources';
+
+const SCHEMA = Object.freeze({
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Sources data sources',
+ description: 'The accounts Sources reads its sources with. A source with no key falls back to anonymous reads; the other sources keep working.',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ github_read_only_token: {
+ type: 'string', title: 'GitHub read-only token', 'x-group': GROUP,
+ description: 'A GitHub token with read-only access to public repositories. It raises GitHub\'s rate limit for Sources\' reads and lets it read files from private repositories it was given access to; without it those reads are anonymous and limited.',
+ 'x-owner-input': {
+ group: 'data-sources',
+ unlocks: 'GitHub reads for Sources run with the token\'s higher rate limit and can see the private repositories it is allowed to read.',
+ get_it_at: 'https://github.com/settings/tokens',
+ get_it_how: 'Create a fine-grained token with only read access to public repositories (or the repositories Sources may read), then paste it here.',
+ test: true,
+ },
+ 'x-required-for': 'GitHub source reads',
+ },
+ },
+});
+
+const SECRETS = new Set(['github_read_only_token']);
+const ACTIVE = { settings: null };
+
+function createSettings({ db, env = process.env, log = console, fetchImpl = globalThis.fetch, probes = null } = {}) {
+ let store = null;
+ const ready = createConfigStore({
+ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA,
+ classify: (key) => (SECRETS.has(key) ? 'secret' : 'internal'),
+ env, log,
+ }).then((s) => { store = s; return s; });
+
+ const values = () => (store ? store.get() : {});
+
+ function valueOf(key, envName, fallback = '') {
+ const v = values()[key];
+ if (v !== undefined && v !== null && v !== '') return v;
+ const e = envName ? env[envName] : undefined;
+ return e !== undefined && e !== null && e !== '' ? e : fallback;
+ }
+
+ /** The read-only GitHub token, read when a GitHub read is made. */
+ function githubToken() { return valueOf('github_read_only_token', 'SOURCES_GITHUB_TOKEN', ''); }
+
+ async function test(key) {
+ if (key !== 'github_read_only_token') return { ok: false, detail: 'That setting has no test.' };
+ const token = githubToken();
+ if (!token) return { ok: false, detail: 'GitHub is not configured: paste the read-only token first.' };
+ const probe = probes && typeof probes.github === 'function'
+ ? probes.github
+ : async ({ token: value }) => {
+ const r = await fetchImpl('https://api.github.com/rate_limit', {
+ headers: { accept: 'application/vnd.github+json', authorization: `Bearer ${value}`, 'user-agent': 'OpenVibe.Sources', 'x-github-api-version': '2022-11-28' },
+ });
+ let body = {};
+ try { body = await r.json(); } catch { /* not JSON */ }
+ if (!r.ok) throw new Error(`HTTP ${r.status}${body && body.message ? `: ${body.message}` : ''}`);
+ };
+ try {
+ await probe({ token });
+ return { ok: true, detail: 'ok — GitHub answered the rate-limit read with this token.' };
+ } catch (err) {
+ const words = String((err && err.message) || 'the request failed').split(/\s+/).join(' ').slice(0, 160);
+ return { ok: false, detail: `failed — GitHub said: ${words.split(token).join('[redacted]')}` };
+ }
+ }
+
+ function mount(app, { guard, log: appLog = log } = {}) {
+ if (!store) throw new Error('createSettings: await ready before mount()');
+ const routes = adminRoutes([store], {
+ basePath: BASE_PATH,
+ requireAdmin: guard,
+ actor: (req) => {
+ const id = String(req.get('x-ov-subject') || '').trim();
+ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? { type: 'user', id } : null;
+ },
+ requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',
+ log: appLog,
+ });
+ app.use(routes.handle);
+ app.post(`${BASE_PATH}/:namespace/test/:key`, guard, async (req, res) => {
+ if (req.params.namespace !== NAMESPACE) return res.status(404).json({ error: 'no such configuration namespace' });
+ let key = String(req.params.key || '');
+ try { key = decodeURIComponent(key); } catch { /* keep it as it is */ }
+ if (!SCHEMA.properties[key]) return res.status(404).json({ error: 'no such setting' });
+ try {
+ const out = await test(key);
+ return res.status(200).json({ ok: Boolean(out.ok), detail: String(out.detail).replace(/\s+/g, ' ').slice(0, 200) });
+ } catch (err) {
+ appLog.warn(`[config] ${NAMESPACE}: the test for ${key} failed: ${String((err && err.message) || err).slice(0, 160)}`);
+ return res.status(200).json({ ok: false, detail: 'failed — the test did not finish. The service log has the reason.' });
+ }
+ });
+ return routes;
+ }
+
+ return { namespace: NAMESPACE, schema: SCHEMA, ready, value: valueOf, get: values, githubToken, test, mount };
+}
+
+function activate(settings) { ACTIVE.settings = settings; return settings; }
+function githubToken() {
+ if (ACTIVE.settings) return ACTIVE.settings.githubToken();
+ return process.env.SOURCES_GITHUB_TOKEN || '';
+}
+
+module.exports = { SERVICE, NAMESPACE, SCHEMA, SECRETS, createSettings, activate, githubToken };
@@ -19,7 +19,7 @@ const B = 'usr_01JZ0000000000000000000BBB';
@@ -19,7 +19,7 @@ const B = 'usr_01JZ0000000000000000000BBB';
const accountData = create({ db: d.db });
const accountData = create({ db: d.db });
const missing = await uncovered(d.db, {
const missing = await uncovered(d.db, {
tables: [...TABLES, 'merchant_sources'], // the merchant's claim: exported and erased by this module
tables: [...TABLES, 'merchant_sources'], // the merchant's claim: exported and erased by this module
- exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Sources applied' },
+ exempt: { "config_snapshots":"D11 owner settings: created_by/activated_by is the acting administrator (usr_...) for the config audit trail; the values are network configuration, not the person's account data, and are kept for rollback/history", account_data_events: 'the ledger of requests itself: which export and deletion Sources applied' },
});
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+'use strict';
+/**
+ * Owner settings (D11) for Sources' data sources (server/settings.js, sources.data_sources):
+ * - the read-only GitHub token has a plain title, a 30+ character description, x-owner-input
+ * words and a test;
+ * - /internal/config is loopback only and needs sources.config.manage;
+ * - the token is write-only and marked in summaries, githubToken() reads the owner's value and
+ * falls back to SOURCES_GITHUB_TOKEN, and the test hook never echoes or logs the token.
+ * node test/owner-settings.test.js
+ */
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { boot, request, serviceToken } = require('./helpers');
+const { createSettings } = require('../server/settings');
+
+const CAP = 'sources.config.manage';
+const NS = 'sources.data_sources';
+const USR = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
+const TOKEN = 'github-owner-token-sentinel';
+
+(async () => {
+ const lines = [];
+ const log = { log: (...a) => lines.push(a.join(' ')), info: (...a) => lines.push(a.join(' ')), warn: (...a) => lines.push(a.join(' ')), error: (...a) => lines.push(a.join(' ')) };
+ const svc = await boot({ log });
+ const bearer = serviceToken('network', [CAP]);
+ const checks = [];
+ const check = async (name, fn) => {
+ try { await fn(); checks.push(name); console.log(` ✓ ${name}`); } catch (e) { console.log(` ✗ ${name}\n${e.stack}`); await svc.stop(); process.exit(1); }
+ };
+
+ await check('the GitHub token has owner words and a test', async () => {
+ const r = await request(svc.base, 'GET', `/internal/config/${NS}`, { token: bearer });
+ assert.strictEqual(r.status, 200, r.text);
+ const f = r.body.schema.properties.github_read_only_token;
+ assert.ok(f);
+ assert.ok(f.title && f.title.length >= 3);
+ assert.ok(f.description && f.description.length >= 30);
+ assert.strictEqual(f['x-owner-input'].group, 'data-sources');
+ assert.ok(f['x-owner-input'].unlocks.length > 20);
+ assert.strictEqual(f['x-owner-input'].get_it_at, 'https://github.com/settings/tokens');
+ assert.strictEqual(f['x-owner-input'].test, true);
+ assert.strictEqual(f['x-required-for'], 'GitHub source reads');
+ });
+
+ await check('the routes are loopback only and need the capability', async () => {
+ assert.strictEqual((await request(svc.base, 'GET', `/internal/config/${NS}`)).status, 401);
+ assert.strictEqual((await request(svc.base, 'GET', `/internal/config/${NS}`, { token: serviceToken('news', ['sources.source.read']) })).status, 403);
+ assert.strictEqual((await request(svc.base, 'GET', `/internal/config/${NS}`, { token: bearer, headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 404);
+ });
+
+ await check('the token is write-only and the environment is the fallback', async () => {
+ const envOnly = createSettings({ db: svc.db, env: { SOURCES_GITHUB_TOKEN: 'github-env-token' } });
+ await envOnly.ready;
+ assert.strictEqual(envOnly.githubToken(), 'github-env-token', 'the env is the fallback');
+ const put = await request(svc.base, 'PUT', `/internal/config/${NS}/secrets/github_read_only_token`, {
+ token: bearer, body: { value: TOKEN, reason: 'owner test' }, headers: { 'x-ov-subject': USR, 'x-ov-fresh': '1' },
+ });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.ok(put.body.fingerprint && put.body.hint);
+ assert.ok(!put.text.includes(TOKEN));
+ const summary = await request(svc.base, 'GET', `/internal/config/${NS}`, { token: bearer });
+ assert.ok(summary.body.values.github_read_only_token.redacted);
+ assert.ok(!summary.text.includes(TOKEN));
+ assert.strictEqual(svc.settings.githubToken(), TOKEN, 'the code reads the owner value when set');
+ });
+
+ await check('the test hook says ok/failed in one line and never echoes a token', async () => {
+ const fake = createSettings({
+ db: svc.db, env: {},
+ probes: { github: async ({ token }) => { throw new Error(`refused ${token}`); } },
+ });
+ await fake.ready;
+ const app = express();
+ app.use(express.json());
+ fake.mount(app, { guard: (_req, _res, next) => next(), log });
+ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ try {
+ const r = await fetch(`http://127.0.0.1:${server.address().port}/internal/config/${NS}/test/github_read_only_token`, { method: 'POST' });
+ const body = await r.json();
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(body.ok, false);
+ assert.ok(/^failed — GitHub said: /.test(body.detail), body.detail);
+ assert.ok(!body.detail.includes(TOKEN));
+ assert.ok(!body.detail.includes('\n'));
+ } finally { server.close(); }
+ assert.ok(!lines.join('\n').includes(TOKEN), 'no token in a captured log line');
+ });
+
+ await svc.stop();
+ console.log(`\n${checks.length} checks passed`);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -152,6 +152,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
@@ -152,6 +152,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
const REVIEWED = {
const REVIEWED = {
'server/net/fetcher.js': 'every source fetch; the guard (checkUrl up front, lookup at connect, on every redirect hop)',
'server/net/fetcher.js': 'every source fetch; the guard (checkUrl up front, lookup at connect, on every redirect hop)',
'server/merchant-auth.js': 'OAuth code exchange only, to the operator-configured Network URLs (never a merchant-supplied URL)',
'server/merchant-auth.js': 'OAuth code exchange only, to the operator-configured Network URLs (never a merchant-supplied URL)',
+ 'server/settings.js': 'the owner-settings test hook: one fixed GitHub URL (api.github.com/rate_limit), never a caller-supplied URL; the token only goes to GitHub',
};
};
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
const found = [];
const found = [];