'use strict'; /** Configuration store on an openvibe-sdk/db handle. The store is ready when the promise resolves. */ const { fromRows, canonical, ConfigError, subjectRef, isPlainObject, isMarker, makeLog, scrub, errText, lintSchema, OWNER_INPUT_GROUPS } = require('./config-core'); const TEST_TIMEOUT_MS = 15000; const TEST_MESSAGE_MAX = 300; function createConfigStore(opts = {}) { if (!opts.db || typeof opts.db.prepare !== 'function' || typeof opts.db.tx !== 'function') { throw new TypeError('createConfigStore: pass an openvibe-sdk/db handle as db'); } return require('./config-pg').createPgConfigStore(opts); } // ── /api/admin/config ─────────────────────────────────────────── const TITLES = { 400: 'Bad Request', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', 413: 'Payload Too Large', 422: 'Unprocessable Content', 500: 'Internal Server Error' }; /** RFC 9457 body (errors.problem@1), with the legacy { error } field like openvibe-contracts' http.problem(). */ function problem(status, code, { detail, errors } = {}) { const body = { type: `https://openvibe.network/problems/${code}`, title: TITLES[status] || 'Error', status, code }; if (detail) body.detail = detail; if (errors && errors.length) body.errors = errors; body.error = detail || body.title; return body; } function send(res, status, body, type = 'application/json; charset=utf-8') { res.statusCode = status; res.setHeader('Content-Type', type); res.setHeader('Cache-Control', 'no-store'); res.end(JSON.stringify(body)); } const sendProblem = (res, status, code, o) => send(res, status, problem(status, code, o), 'application/problem+json'); /** The acting subject: req.actor, req.subject, req.user.subject or req.user.subject_id (a usr_… id). */ function defaultActor(req) { for (const c of [req.actor, req.subject, req.user && req.user.subject, req.user && req.user.subject_id]) { const ref = c ? subjectRef(c) : null; if (ref) return ref; } return null; } function readBody(req, limit = 256 * 1024) { if (req.body !== undefined) return Promise.resolve(req.body); if (typeof req.on !== 'function') return Promise.resolve({}); return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on('data', (c) => { size += c.length; if (size > limit) { reject(new ConfigError('config.too_large', 'request body too large', { status: 413 })); req.destroy(); } else chunks.push(c); }); req.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); if (!text.trim()) return resolve({}); try { resolve(JSON.parse(text)); } catch { reject(new ConfigError('config.bad_request', 'the body is not JSON', { status: 400 })); } }); req.on('error', reject); }); } /** * adminRoutes(stores, { requireAdmin, basePath, actor, authorize, requireFresh, router, log }) * GET every namespace: active revision, values (redacted), last-known-good * GET /:namespace one of them * GET /:namespace/history ?limit (≤200) &before=, newest first * POST /:namespace { values, reason, merge?, unset? } → apply; 422 with errors * POST /:namespace/rollback { to?, reason } → the new revision * PUT /:namespace/secrets/:key { value, reason } → the marker (write-only) * DELETE /:namespace/secrets/:key { reason } → the empty marker (write-only) * POST /:namespace/test/:key → { ok, message }: the service's tester tried the stored value * requireAdmin (a middleware or a list) guards every handler, mounted or called directly. actor(req) * names who acts (default: req.user.subject_id and friends; none → 403). authorize(req, { action: * 'read' | 'write', namespace, keys }) may refuse more (false → 403). requireFresh(req, { namespace, * keys }) runs before a secret write and before a change to an x-danger key; false → 403 * config.fresh_required. A normal POST may not carry a secret-class value (422 * config.secret_write_only) or an env-pinned key (409 config.pinned_in_env). No Express dependency: * pass `router` (anything with get/post/put/delete) or call mount(router), use the handlers, or * handle(req, res, next) for a plain http server. The body is req.body when a JSON parser ran, else * read from the request. * * test (optional) is the service's tester for the owner's Setup page: async ({ namespace, key, value, values, store, * req }) → { ok, message }, or null/undefined when it does not test that key (404 config.test_not_available). It gets * the stored value (a secret opened) and calls the provider with it on the server; the answer is one line, at most * 300 characters, scrubbed of every secret value of the namespace, so it can never echo a key. A tester that throws is * a failed test with its message (scrubbed the same way); one that runs longer than testTimeoutMs (15 s) is a failed * test too. authorize sees action 'test'. A key with no value is 409 config.not_set and the tester is not called. */ function adminRoutes(stores, o = {}) { const list = [].concat(stores || []); const byNs = new Map(); for (const s of list) { if (!s || typeof s.summary !== 'function') throw new TypeError('adminRoutes: every store must come from createConfigStore'); if (byNs.has(s.namespace)) throw new TypeError(`adminRoutes: ${s.namespace} is listed twice`); byNs.set(s.namespace, s); } const guards = [].concat(o.requireAdmin || []); if (!guards.length || !guards.every((g) => typeof g === 'function')) throw new TypeError('adminRoutes: requireAdmin middleware is required'); const basePath = (o.basePath || '/api/admin/config').replace(/\/+$/, ''); const actorOf = o.actor || defaultActor; const authorize = o.authorize || null; const requireFresh = o.requireFresh || null; if (requireFresh !== null && typeof requireFresh !== 'function') throw new TypeError('adminRoutes: requireFresh must be a function'); const tester = o.test || null; if (tester !== null && typeof tester !== 'function') throw new TypeError('adminRoutes: test must be a function'); const testTimeoutMs = Number.isInteger(o.testTimeoutMs) && o.testTimeoutMs > 0 ? o.testTimeoutMs : TEST_TIMEOUT_MS; const log = makeLog(o.log); const allowed = async (req, ctx) => !authorize || (await authorize(req, ctx)) !== false; function storeFor(req) { const ns = (req.params && req.params.namespace) || ''; const s = byNs.get(ns); if (!s) throw new ConfigError('config.namespace_not_found', `no configuration namespace ${ns || '(none)'}`, { status: 404 }); return s; } const queryOf = (req) => req.query || Object.fromEntries(new URL(req.url || '/', 'http://x').searchParams); const keyList = (keys) => keys.slice(0, 10).join(', ') + (keys.length > 10 ? ` (+${keys.length - 10})` : ''); const keyOf = (req) => { let key = (req.params && req.params.key) || ''; try { key = decodeURIComponent(key); } catch { /* keep it as it is */ } return key; }; /** requireFresh for a secret or x-danger write; absent means no extra gate (nothing new is on). */ async function fresh(req, s, keys) { if (!requireFresh) return; const ok = await requireFresh(req, { namespace: s.namespace, keys }); if (ok === false) throw new ConfigError('config.fresh_required', 'a fresh sign-in is required for this change', { status: 403 }); } const handlers = { async list(req, res) { const namespaces = []; for (const s of list) if (await allowed(req, { action: 'read', namespace: s.namespace, keys: [] })) namespaces.push(await s.summary()); send(res, 200, { namespaces }); }, async get(req, res) { const s = storeFor(req); if (!(await allowed(req, { action: 'read', namespace: s.namespace, keys: [] }))) throw new ConfigError('config.forbidden', 'not allowed to read this namespace', { status: 403 }); send(res, 200, await s.summary()); }, async history(req, res) { const s = storeFor(req); if (!(await allowed(req, { action: 'read', namespace: s.namespace, keys: [] }))) throw new ConfigError('config.forbidden', 'not allowed to read this namespace', { status: 403 }); const query = queryOf(req); const limit = Math.min(200, Math.max(1, parseInt(query.limit, 10) || 20)); const snapshots = await s.history({ limit, before: query.before != null && query.before !== '' ? parseInt(query.before, 10) : null }); send(res, 200, { namespace: s.namespace, snapshots, next_before: snapshots.length === limit ? snapshots[snapshots.length - 1].revision : null }); }, async apply(req, res) { const s = storeFor(req); const body = await readBody(req); if (!isPlainObject(body) || !isPlainObject(body.values)) throw new ConfigError('config.bad_request', 'the body must be { values, reason }', { status: 400 }); if (body.merge !== undefined && typeof body.merge !== 'boolean') throw new ConfigError('config.bad_request', 'merge must be true or false', { status: 400 }); const actor = await actorOf(req); if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 }); const opts = { merge: body.merge === true, unset: body.unset || [], actor, reason: body.reason }; const keys = await s.changes(body.values, opts); if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 }); const classes = await s.classes(body.values, opts); // A secret value may not even be carried unchanged through the normal route; a marker may. const carried = Object.entries(body.values).filter(([k, v]) => !isMarker(v) && s.classify(k, v) === 'secret').map(([k]) => k); const secretKeys = [...new Set([...keys.filter((k) => classes[k] === 'secret'), ...carried])]; if (secretKeys.length) throw new ConfigError('config.secret_write_only', `secret-class keys are write-only through /secrets: ${keyList(secretKeys)}`, { status: 422 }); const danger = keys.filter((k) => s.isDanger(k)); if (danger.length) await fresh(req, s, danger); send(res, 200, await s.apply(body.values, opts)); }, async rollback(req, res) { const s = storeFor(req); const body = await readBody(req); if (!isPlainObject(body)) throw new ConfigError('config.bad_request', 'the body must be { to?, reason? }', { status: 400 }); if (body.to != null && !Number.isInteger(body.to)) throw new ConfigError('config.bad_request', 'to must be a revision number', { status: 400 }); const actor = await actorOf(req); if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 }); const target = await s.rollbackTarget(body.to); const keys = await s.changes(null, { revision: target }); if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 }); const classes = await s.classes(null, { revision: target }); const gated = [...new Set([...keys.filter((k) => classes[k] === 'secret'), ...keys.filter((k) => s.isDanger(k))])]; if (gated.length) await fresh(req, s, gated); send(res, 200, await s.rollback({ to: target, reason: body.reason, actor })); }, async secretPut(req, res) { const s = storeFor(req); const key = keyOf(req); if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys: [key] }))) throw new ConfigError('config.forbidden', 'not allowed to change this key', { status: 403 }); const body = await readBody(req); if (!isPlainObject(body) || !Object.prototype.hasOwnProperty.call(body, 'value')) throw new ConfigError('config.bad_request', 'the body must be { value, reason }', { status: 400 }); const actor = await actorOf(req); if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 }); await fresh(req, s, [key]); send(res, 200, await s.setSecret(key, body.value, { actor, reason: body.reason })); }, async secretDelete(req, res) { const s = storeFor(req); const key = keyOf(req); if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys: [key] }))) throw new ConfigError('config.forbidden', 'not allowed to change this key', { status: 403 }); const body = await readBody(req); if (!isPlainObject(body)) throw new ConfigError('config.bad_request', 'the body must be { reason }', { status: 400 }); const actor = await actorOf(req); if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 }); await fresh(req, s, [key]); send(res, 200, await s.deleteSecret(key, { actor, reason: body.reason })); }, async test(req, res) { const s = storeFor(req); const key = keyOf(req); if (!(await allowed(req, { action: 'test', namespace: s.namespace, keys: [key] }))) throw new ConfigError('config.forbidden', 'not allowed to test this key', { status: 403 }); const actor = await actorOf(req); if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 }); if (!tester) throw new ConfigError('config.test_not_available', 'this service has no tests for its settings', { status: 404 }); const values = s.get(); const value = values[key]; if (value === undefined || value === null || value === '') throw new ConfigError('config.not_set', `${key} is not set, so there is nothing to test`, { status: 409 }); // Every secret value of the namespace is scrubbed from the answer, not only the tested one: a tester for a // client id may well have the client secret in hand. const classes = await s.classes({}, { merge: true }); const secrets = Object.entries(values) .filter(([k, v]) => (classes[k] || s.classify(k, v)) === 'secret') .map(([, v]) => (typeof v === 'string' ? v : canonical(v))) .filter((v) => v.length >= 4) .sort((a, b) => b.length - a.length); const line = (m) => { const one = String(m == null ? '' : m).replace(/\s+/g, ' ').trim(); return one ? scrub(one, secrets).slice(0, TEST_MESSAGE_MAX) : ''; }; const seconds = Math.max(1, Math.ceil(testTimeoutMs / 1000)); let timer = null; let result; try { result = await Promise.race([ Promise.resolve().then(() => tester({ namespace: s.namespace, key, value, values, store: s, req })), new Promise((resolve) => { timer = setTimeout(() => resolve({ ok: false, message: `The test did not finish in ${seconds} second${seconds === 1 ? '' : 's'}.` }), testTimeoutMs); }), ]); } catch (err) { if (err instanceof ConfigError) throw err; result = { ok: false, message: errText(err) }; } finally { clearTimeout(timer); } if (result == null) throw new ConfigError('config.test_not_available', `${key} has no test`, { status: 404 }); const ok = result === true || (isPlainObject(result) && result.ok === true); const message = line(isPlainObject(result) ? result.message : '') || (ok ? 'It works.' : 'It did not work.'); log.info(`[config] ${s.namespace} ${key}: test ${ok ? 'passed' : 'failed'}`); send(res, 200, { ok, message }); }, }; function fail(res, err) { if (res.headersSent) return; if (err instanceof ConfigError) return sendProblem(res, err.status, err.code, { detail: err.message, errors: err.errors }); log.error(`[config] admin route failed: ${scrub(errText(err))}`); sendProblem(res, 500, 'config.internal', { detail: 'the configuration request failed' }); } /** requireAdmin first, always; a guard that answers (401/403) ends the request there. */ function guarded(fn) { return (req, res, next) => { let i = 0; const step = (err) => { if (err) return typeof next === 'function' ? next(err) : fail(res, err); if (i >= guards.length) return Promise.resolve().then(() => fn(req, res)).catch((e) => fail(res, e)); const g = guards[i++]; try { g(req, res, step); } catch (e) { step(e); } }; step(); }; } const out = Object.fromEntries(Object.entries(handlers).map(([k, fn]) => [k, guarded(fn)])); const routes = [ { method: 'get', path: basePath, handler: out.list }, { method: 'get', path: `${basePath}/:namespace`, handler: out.get }, { method: 'get', path: `${basePath}/:namespace/history`, handler: out.history }, { method: 'post', path: `${basePath}/:namespace`, handler: out.apply }, { method: 'post', path: `${basePath}/:namespace/rollback`, handler: out.rollback }, { method: 'put', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretPut }, { method: 'delete', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretDelete }, { method: 'post', path: `${basePath}/:namespace/test/:key`, handler: out.test }, ]; function mount(router) { for (const r of routes) router[r.method](r.path, r.handler); return router; } /** For a plain http server: dispatches the eight routes, calls next() (or answers 404) for anything else. */ function handle(req, res, next) { const url = new URL(req.url || '/', 'http://x'); const method = String(req.method || 'GET').toLowerCase(); const rest = url.pathname === basePath ? '' : url.pathname.startsWith(`${basePath}/`) ? url.pathname.slice(basePath.length + 1) : null; let m = null; if (rest === '') m = method === 'get' ? ['list'] : null; else if (rest !== null) { const parts = rest.split('/'); if (parts.length === 1 && method === 'get') m = ['get', parts[0]]; else if (parts.length === 1 && method === 'post') m = ['apply', parts[0]]; else if (parts.length === 2 && parts[1] === 'history' && method === 'get') m = ['history', parts[0]]; else if (parts.length === 2 && parts[1] === 'rollback' && method === 'post') m = ['rollback', parts[0]]; else if (parts.length === 3 && parts[1] === 'secrets' && method === 'put') m = ['secretPut', parts[0], parts[2]]; else if (parts.length === 3 && parts[1] === 'secrets' && method === 'delete') m = ['secretDelete', parts[0], parts[2]]; else if (parts.length === 3 && parts[1] === 'test' && method === 'post') m = ['test', parts[0], parts[2]]; } if (!m) return typeof next === 'function' ? next() : sendProblem(res, 404, 'config.route_not_found', { detail: 'no such configuration route' }); let ns = m[1] || ''; try { ns = decodeURIComponent(ns); } catch { /* keep it as it is */ } const params = { ...(req.params || {}), namespace: ns }; if (m[2] !== undefined) { let key = m[2]; try { key = decodeURIComponent(key); } catch { /* keep it as it is */ } params.key = key; } req.params = params; if (!req.query) req.query = Object.fromEntries(url.searchParams); return out[m[0]](req, res, next); } if (o.router) mount(o.router); return { ...out, routes, mount, handle }; } module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, lintSchema, OWNER_INPUT_GROUPS, configSchema: (...a) => require('./config-pg').configSchema(...a) };