openvibers/openvibe.shared config.js
config.js
| 1 | ; |
| 2 | /** Configuration store on an openvibe-sdk/db handle. The store is ready when the promise resolves. */ |
| 3 | const { fromRows, canonical, ConfigError, subjectRef, isPlainObject, isMarker, makeLog, scrub, errText, lintSchema, OWNER_INPUT_GROUPS } = require('./config-core'); |
| 4 | |
| 5 | const TEST_TIMEOUT_MS = 15000; |
| 6 | const TEST_MESSAGE_MAX = 300; |
| 7 | |
| 8 | function createConfigStore(opts = {}) { |
| 9 | if (!opts.db || typeof opts.db.prepare !== 'function' || typeof opts.db.tx !== 'function') { |
| 10 | throw new TypeError('createConfigStore: pass an openvibe-sdk/db handle as db'); |
| 11 | } |
| 12 | return require('./config-pg').createPgConfigStore(opts); |
| 13 | } |
| 14 | |
| 15 | // ── /api/admin/config ─────────────────────────────────────────── |
| 16 | |
| 17 | const TITLES = { 400: 'Bad Request', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', 413: 'Payload Too Large', 422: 'Unprocessable Content', 500: 'Internal Server Error' }; |
| 18 | |
| 19 | /** RFC 9457 body (errors.problem@1), with the legacy { error } field like openvibe-contracts' http.problem(). */ |
| 20 | function problem(status, code, { detail, errors } = {}) { |
| 21 | const body = { type: `https://openvibe.network/problems/${code}`, title: TITLES[status] || 'Error', status, code }; |
| 22 | if (detail) body.detail = detail; |
| 23 | if (errors && errors.length) body.errors = errors; |
| 24 | body.error = detail || body.title; |
| 25 | return body; |
| 26 | } |
| 27 | |
| 28 | function send(res, status, body, type = 'application/json; charset=utf-8') { |
| 29 | res.statusCode = status; |
| 30 | res.setHeader('Content-Type', type); |
| 31 | res.setHeader('Cache-Control', 'no-store'); |
| 32 | res.end(JSON.stringify(body)); |
| 33 | } |
| 34 | const sendProblem = (res, status, code, o) => send(res, status, problem(status, code, o), 'application/problem+json'); |
| 35 | |
| 36 | /** The acting subject: req.actor, req.subject, req.user.subject or req.user.subject_id (a usr_… id). */ |
| 37 | function defaultActor(req) { |
| 38 | for (const c of [req.actor, req.subject, req.user && req.user.subject, req.user && req.user.subject_id]) { |
| 39 | const ref = c ? subjectRef(c) : null; |
| 40 | if (ref) return ref; |
| 41 | } |
| 42 | return null; |
| 43 | } |
| 44 | |
| 45 | function readBody(req, limit = 256 * 1024) { |
| 46 | if (req.body !== undefined) return Promise.resolve(req.body); |
| 47 | if (typeof req.on !== 'function') return Promise.resolve({}); |
| 48 | return new Promise((resolve, reject) => { |
| 49 | let size = 0; const chunks = []; |
| 50 | 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); }); |
| 51 | req.on('end', () => { |
| 52 | const text = Buffer.concat(chunks).toString('utf8'); |
| 53 | if (!text.trim()) return resolve({}); |
| 54 | try { resolve(JSON.parse(text)); } catch { reject(new ConfigError('config.bad_request', 'the body is not JSON', { status: 400 })); } |
| 55 | }); |
| 56 | req.on('error', reject); |
| 57 | }); |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * adminRoutes(stores, { requireAdmin, basePath, actor, authorize, requireFresh, router, log }) |
| 62 | * GET <base> every namespace: active revision, values (redacted), last-known-good |
| 63 | * GET <base>/:namespace one of them |
| 64 | * GET <base>/:namespace/history ?limit (≤200) &before=<revision>, newest first |
| 65 | * POST <base>/:namespace { values, reason, merge?, unset? } → apply; 422 with errors |
| 66 | * POST <base>/:namespace/rollback { to?, reason } → the new revision |
| 67 | * PUT <base>/:namespace/secrets/:key { value, reason } → the marker (write-only) |
| 68 | * DELETE <base>/:namespace/secrets/:key { reason } → the empty marker (write-only) |
| 69 | * POST <base>/:namespace/test/:key → { ok, message }: the service's tester tried the stored value |
| 70 | * requireAdmin (a middleware or a list) guards every handler, mounted or called directly. actor(req) |
| 71 | * names who acts (default: req.user.subject_id and friends; none → 403). authorize(req, { action: |
| 72 | * 'read' | 'write', namespace, keys }) may refuse more (false → 403). requireFresh(req, { namespace, |
| 73 | * keys }) runs before a secret write and before a change to an x-danger key; false → 403 |
| 74 | * config.fresh_required. A normal POST may not carry a secret-class value (422 |
| 75 | * config.secret_write_only) or an env-pinned key (409 config.pinned_in_env). No Express dependency: |
| 76 | * pass `router` (anything with get/post/put/delete) or call mount(router), use the handlers, or |
| 77 | * handle(req, res, next) for a plain http server. The body is req.body when a JSON parser ran, else |
| 78 | * read from the request. |
| 79 | * |
| 80 | * test (optional) is the service's tester for the owner's Setup page: async ({ namespace, key, value, values, store, |
| 81 | * req }) → { ok, message }, or null/undefined when it does not test that key (404 config.test_not_available). It gets |
| 82 | * the stored value (a secret opened) and calls the provider with it on the server; the answer is one line, at most |
| 83 | * 300 characters, scrubbed of every secret value of the namespace, so it can never echo a key. A tester that throws is |
| 84 | * a failed test with its message (scrubbed the same way); one that runs longer than testTimeoutMs (15 s) is a failed |
| 85 | * test too. authorize sees action 'test'. A key with no value is 409 config.not_set and the tester is not called. |
| 86 | */ |
| 87 | function adminRoutes(stores, o = {}) { |
| 88 | const list = [].concat(stores || []); |
| 89 | const byNs = new Map(); |
| 90 | for (const s of list) { |
| 91 | if (!s || typeof s.summary !== 'function') throw new TypeError('adminRoutes: every store must come from createConfigStore'); |
| 92 | if (byNs.has(s.namespace)) throw new TypeError(`adminRoutes: ${s.namespace} is listed twice`); |
| 93 | byNs.set(s.namespace, s); |
| 94 | } |
| 95 | const guards = [].concat(o.requireAdmin || []); |
| 96 | if (!guards.length || !guards.every((g) => typeof g === 'function')) throw new TypeError('adminRoutes: requireAdmin middleware is required'); |
| 97 | const basePath = (o.basePath || '/api/admin/config').replace(/\/+$/, ''); |
| 98 | const actorOf = o.actor || defaultActor; |
| 99 | const authorize = o.authorize || null; |
| 100 | const requireFresh = o.requireFresh || null; |
| 101 | if (requireFresh !== null && typeof requireFresh !== 'function') throw new TypeError('adminRoutes: requireFresh must be a function'); |
| 102 | const tester = o.test || null; |
| 103 | if (tester !== null && typeof tester !== 'function') throw new TypeError('adminRoutes: test must be a function'); |
| 104 | const testTimeoutMs = Number.isInteger(o.testTimeoutMs) && o.testTimeoutMs > 0 ? o.testTimeoutMs : TEST_TIMEOUT_MS; |
| 105 | const log = makeLog(o.log); |
| 106 | |
| 107 | const allowed = async (req, ctx) => !authorize || (await authorize(req, ctx)) !== false; |
| 108 | function storeFor(req) { |
| 109 | const ns = (req.params && req.params.namespace) || ''; |
| 110 | const s = byNs.get(ns); |
| 111 | if (!s) throw new ConfigError('config.namespace_not_found', `no configuration namespace ${ns || '(none)'}`, { status: 404 }); |
| 112 | return s; |
| 113 | } |
| 114 | const queryOf = (req) => req.query || Object.fromEntries(new URL(req.url || '/', 'http://x').searchParams); |
| 115 | const keyList = (keys) => keys.slice(0, 10).join(', ') + (keys.length > 10 ? ` (+${keys.length - 10})` : ''); |
| 116 | const keyOf = (req) => { |
| 117 | let key = (req.params && req.params.key) || ''; |
| 118 | try { key = decodeURIComponent(key); } catch { /* keep it as it is */ } |
| 119 | return key; |
| 120 | }; |
| 121 | /** requireFresh for a secret or x-danger write; absent means no extra gate (nothing new is on). */ |
| 122 | async function fresh(req, s, keys) { |
| 123 | if (!requireFresh) return; |
| 124 | const ok = await requireFresh(req, { namespace: s.namespace, keys }); |
| 125 | if (ok === false) throw new ConfigError('config.fresh_required', 'a fresh sign-in is required for this change', { status: 403 }); |
| 126 | } |
| 127 | |
| 128 | const handlers = { |
| 129 | async list(req, res) { |
| 130 | const namespaces = []; |
| 131 | for (const s of list) if (await allowed(req, { action: 'read', namespace: s.namespace, keys: [] })) namespaces.push(await s.summary()); |
| 132 | send(res, 200, { namespaces }); |
| 133 | }, |
| 134 | async get(req, res) { |
| 135 | const s = storeFor(req); |
| 136 | if (!(await allowed(req, { action: 'read', namespace: s.namespace, keys: [] }))) throw new ConfigError('config.forbidden', 'not allowed to read this namespace', { status: 403 }); |
| 137 | send(res, 200, await s.summary()); |
| 138 | }, |
| 139 | async history(req, res) { |
| 140 | const s = storeFor(req); |
| 141 | if (!(await allowed(req, { action: 'read', namespace: s.namespace, keys: [] }))) throw new ConfigError('config.forbidden', 'not allowed to read this namespace', { status: 403 }); |
| 142 | const query = queryOf(req); |
| 143 | const limit = Math.min(200, Math.max(1, parseInt(query.limit, 10) || 20)); |
| 144 | const snapshots = await s.history({ limit, before: query.before != null && query.before !== '' ? parseInt(query.before, 10) : null }); |
| 145 | send(res, 200, { namespace: s.namespace, snapshots, next_before: snapshots.length === limit ? snapshots[snapshots.length - 1].revision : null }); |
| 146 | }, |
| 147 | async apply(req, res) { |
| 148 | const s = storeFor(req); |
| 149 | const body = await readBody(req); |
| 150 | if (!isPlainObject(body) || !isPlainObject(body.values)) throw new ConfigError('config.bad_request', 'the body must be { values, reason }', { status: 400 }); |
| 151 | if (body.merge !== undefined && typeof body.merge !== 'boolean') throw new ConfigError('config.bad_request', 'merge must be true or false', { status: 400 }); |
| 152 | const actor = await actorOf(req); |
| 153 | if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 }); |
| 154 | const opts = { merge: body.merge === true, unset: body.unset || [], actor, reason: body.reason }; |
| 155 | const keys = await s.changes(body.values, opts); |
| 156 | if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 }); |
| 157 | const classes = await s.classes(body.values, opts); |
| 158 | // A secret value may not even be carried unchanged through the normal route; a marker may. |
| 159 | const carried = Object.entries(body.values).filter(([k, v]) => !isMarker(v) && s.classify(k, v) === 'secret').map(([k]) => k); |
| 160 | const secretKeys = [...new Set([...keys.filter((k) => classes[k] === 'secret'), ...carried])]; |
| 161 | if (secretKeys.length) throw new ConfigError('config.secret_write_only', `secret-class keys are write-only through /secrets: ${keyList(secretKeys)}`, { status: 422 }); |
| 162 | const danger = keys.filter((k) => s.isDanger(k)); |
| 163 | if (danger.length) await fresh(req, s, danger); |
| 164 | send(res, 200, await s.apply(body.values, opts)); |
| 165 | }, |
| 166 | async rollback(req, res) { |
| 167 | const s = storeFor(req); |
| 168 | const body = await readBody(req); |
| 169 | if (!isPlainObject(body)) throw new ConfigError('config.bad_request', 'the body must be { to?, reason? }', { status: 400 }); |
| 170 | if (body.to != null && !Number.isInteger(body.to)) throw new ConfigError('config.bad_request', 'to must be a revision number', { status: 400 }); |
| 171 | const actor = await actorOf(req); |
| 172 | if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 }); |
| 173 | const target = await s.rollbackTarget(body.to); |
| 174 | const keys = await s.changes(null, { revision: target }); |
| 175 | if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 }); |
| 176 | const classes = await s.classes(null, { revision: target }); |
| 177 | const gated = [...new Set([...keys.filter((k) => classes[k] === 'secret'), ...keys.filter((k) => s.isDanger(k))])]; |
| 178 | if (gated.length) await fresh(req, s, gated); |
| 179 | send(res, 200, await s.rollback({ to: target, reason: body.reason, actor })); |
| 180 | }, |
| 181 | async secretPut(req, res) { |
| 182 | const s = storeFor(req); |
| 183 | const key = keyOf(req); |
| 184 | if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys: [key] }))) throw new ConfigError('config.forbidden', 'not allowed to change this key', { status: 403 }); |
| 185 | const body = await readBody(req); |
| 186 | if (!isPlainObject(body) || !Object.prototype.hasOwnProperty.call(body, 'value')) throw new ConfigError('config.bad_request', 'the body must be { value, reason }', { status: 400 }); |
| 187 | const actor = await actorOf(req); |
| 188 | if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 }); |
| 189 | await fresh(req, s, [key]); |
| 190 | send(res, 200, await s.setSecret(key, body.value, { actor, reason: body.reason })); |
| 191 | }, |
| 192 | async secretDelete(req, res) { |
| 193 | const s = storeFor(req); |
| 194 | const key = keyOf(req); |
| 195 | if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys: [key] }))) throw new ConfigError('config.forbidden', 'not allowed to change this key', { status: 403 }); |
| 196 | const body = await readBody(req); |
| 197 | if (!isPlainObject(body)) throw new ConfigError('config.bad_request', 'the body must be { reason }', { status: 400 }); |
| 198 | const actor = await actorOf(req); |
| 199 | if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 }); |
| 200 | await fresh(req, s, [key]); |
| 201 | send(res, 200, await s.deleteSecret(key, { actor, reason: body.reason })); |
| 202 | }, |
| 203 | async test(req, res) { |
| 204 | const s = storeFor(req); |
| 205 | const key = keyOf(req); |
| 206 | if (!(await allowed(req, { action: 'test', namespace: s.namespace, keys: [key] }))) throw new ConfigError('config.forbidden', 'not allowed to test this key', { status: 403 }); |
| 207 | const actor = await actorOf(req); |
| 208 | if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 }); |
| 209 | if (!tester) throw new ConfigError('config.test_not_available', 'this service has no tests for its settings', { status: 404 }); |
| 210 | const values = s.get(); |
| 211 | const value = values[key]; |
| 212 | if (value === undefined || value === null || value === '') throw new ConfigError('config.not_set', `${key} is not set, so there is nothing to test`, { status: 409 }); |
| 213 | // Every secret value of the namespace is scrubbed from the answer, not only the tested one: a tester for a |
| 214 | // client id may well have the client secret in hand. |
| 215 | const classes = await s.classes({}, { merge: true }); |
| 216 | const secrets = Object.entries(values) |
| 217 | .filter(([k, v]) => (classes[k] || s.classify(k, v)) === 'secret') |
| 218 | .map(([, v]) => (typeof v === 'string' ? v : canonical(v))) |
| 219 | .filter((v) => v.length >= 4) |
| 220 | .sort((a, b) => b.length - a.length); |
| 221 | const line = (m) => { |
| 222 | const one = String(m == null ? '' : m).replace(/\s+/g, ' ').trim(); |
| 223 | return one ? scrub(one, secrets).slice(0, TEST_MESSAGE_MAX) : ''; |
| 224 | }; |
| 225 | const seconds = Math.max(1, Math.ceil(testTimeoutMs / 1000)); |
| 226 | let timer = null; |
| 227 | let result; |
| 228 | try { |
| 229 | result = await Promise.race([ |
| 230 | Promise.resolve().then(() => tester({ namespace: s.namespace, key, value, values, store: s, req })), |
| 231 | new Promise((resolve) => { timer = setTimeout(() => resolve({ ok: false, message: `The test did not finish in ${seconds} second${seconds === 1 ? '' : 's'}.` }), testTimeoutMs); }), |
| 232 | ]); |
| 233 | } catch (err) { |
| 234 | if (err instanceof ConfigError) throw err; |
| 235 | result = { ok: false, message: errText(err) }; |
| 236 | } finally { |
| 237 | clearTimeout(timer); |
| 238 | } |
| 239 | if (result == null) throw new ConfigError('config.test_not_available', `${key} has no test`, { status: 404 }); |
| 240 | const ok = result === true || (isPlainObject(result) && result.ok === true); |
| 241 | const message = line(isPlainObject(result) ? result.message : '') || (ok ? 'It works.' : 'It did not work.'); |
| 242 | log.info(`[config] ${s.namespace} ${key}: test ${ok ? 'passed' : 'failed'}`); |
| 243 | send(res, 200, { ok, message }); |
| 244 | }, |
| 245 | }; |
| 246 | |
| 247 | function fail(res, err) { |
| 248 | if (res.headersSent) return; |
| 249 | if (err instanceof ConfigError) return sendProblem(res, err.status, err.code, { detail: err.message, errors: err.errors }); |
| 250 | log.error(`[config] admin route failed: ${scrub(errText(err))}`); |
| 251 | sendProblem(res, 500, 'config.internal', { detail: 'the configuration request failed' }); |
| 252 | } |
| 253 | /** requireAdmin first, always; a guard that answers (401/403) ends the request there. */ |
| 254 | function guarded(fn) { |
| 255 | return (req, res, next) => { |
| 256 | let i = 0; |
| 257 | const step = (err) => { |
| 258 | if (err) return typeof next === 'function' ? next(err) : fail(res, err); |
| 259 | if (i >= guards.length) return Promise.resolve().then(() => fn(req, res)).catch((e) => fail(res, e)); |
| 260 | const g = guards[i++]; |
| 261 | try { g(req, res, step); } catch (e) { step(e); } |
| 262 | }; |
| 263 | step(); |
| 264 | }; |
| 265 | } |
| 266 | const out = Object.fromEntries(Object.entries(handlers).map(([k, fn]) => [k, guarded(fn)])); |
| 267 | const routes = [ |
| 268 | { method: 'get', path: basePath, handler: out.list }, |
| 269 | { method: 'get', path: `${basePath}/:namespace`, handler: out.get }, |
| 270 | { method: 'get', path: `${basePath}/:namespace/history`, handler: out.history }, |
| 271 | { method: 'post', path: `${basePath}/:namespace`, handler: out.apply }, |
| 272 | { method: 'post', path: `${basePath}/:namespace/rollback`, handler: out.rollback }, |
| 273 | { method: 'put', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretPut }, |
| 274 | { method: 'delete', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretDelete }, |
| 275 | { method: 'post', path: `${basePath}/:namespace/test/:key`, handler: out.test }, |
| 276 | ]; |
| 277 | function mount(router) { |
| 278 | for (const r of routes) router[r.method](r.path, r.handler); |
| 279 | return router; |
| 280 | } |
| 281 | /** For a plain http server: dispatches the eight routes, calls next() (or answers 404) for anything else. */ |
| 282 | function handle(req, res, next) { |
| 283 | const url = new URL(req.url || '/', 'http://x'); |
| 284 | const method = String(req.method || 'GET').toLowerCase(); |
| 285 | const rest = url.pathname === basePath ? '' : url.pathname.startsWith(`${basePath}/`) ? url.pathname.slice(basePath.length + 1) : null; |
| 286 | let m = null; |
| 287 | if (rest === '') m = method === 'get' ? ['list'] : null; |
| 288 | else if (rest !== null) { |
| 289 | const parts = rest.split('/'); |
| 290 | if (parts.length === 1 && method === 'get') m = ['get', parts[0]]; |
| 291 | else if (parts.length === 1 && method === 'post') m = ['apply', parts[0]]; |
| 292 | else if (parts.length === 2 && parts[1] === 'history' && method === 'get') m = ['history', parts[0]]; |
| 293 | else if (parts.length === 2 && parts[1] === 'rollback' && method === 'post') m = ['rollback', parts[0]]; |
| 294 | else if (parts.length === 3 && parts[1] === 'secrets' && method === 'put') m = ['secretPut', parts[0], parts[2]]; |
| 295 | else if (parts.length === 3 && parts[1] === 'secrets' && method === 'delete') m = ['secretDelete', parts[0], parts[2]]; |
| 296 | else if (parts.length === 3 && parts[1] === 'test' && method === 'post') m = ['test', parts[0], parts[2]]; |
| 297 | } |
| 298 | if (!m) return typeof next === 'function' ? next() : sendProblem(res, 404, 'config.route_not_found', { detail: 'no such configuration route' }); |
| 299 | let ns = m[1] || ''; |
| 300 | try { ns = decodeURIComponent(ns); } catch { /* keep it as it is */ } |
| 301 | const params = { ...(req.params || {}), namespace: ns }; |
| 302 | if (m[2] !== undefined) { let key = m[2]; try { key = decodeURIComponent(key); } catch { /* keep it as it is */ } params.key = key; } |
| 303 | req.params = params; |
| 304 | if (!req.query) req.query = Object.fromEntries(url.searchParams); |
| 305 | return out[m[0]](req, res, next); |
| 306 | } |
| 307 | if (o.router) mount(o.router); |
| 308 | return { ...out, routes, mount, handle }; |
| 309 | } |
| 310 | |
| 311 | module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, lintSchema, OWNER_INPUT_GROUPS, configSchema: (...a) => require('./config-pg').configSchema(...a) }; |
| 312 |