openvibers/openvibe.shared config.js

config.js

main 20.4 KB · 312 lines · javascript

1'use strict';
2/** Configuration store on an openvibe-sdk/db handle. The store is ready when the promise resolves. */
3const { fromRows, canonical, ConfigError, subjectRef, isPlainObject, isMarker, makeLog, scrub, errText, lintSchema, OWNER_INPUT_GROUPS } = require('./config-core');
4
5const TEST_TIMEOUT_MS = 15000;
6const TEST_MESSAGE_MAX = 300;
7
8function 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
17const 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(). */
20function 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
28function 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}
34const 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). */
37function 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
45function 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 */
87function 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
311module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, lintSchema, OWNER_INPUT_GROUPS, configSchema: (...a) => require('./config-pg').configSchema(...a) };
312