openvibers/openvibe.shared config-core.js

config-core.js

main 39.1 KB · 632 lines · javascript

1'use strict';
2// Shared validation, redaction and row conversion for the PostgreSQL config store.
3const crypto = require('crypto');
4
5const CLASSES = ['public', 'internal', 'secret'];
6const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
7const NAMESPACE_RE = /^[a-z][a-z0-9-]{1,39}(\.[a-z0-9_-]+)+$/;
8const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;
9const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);
10const validKey = (k) => KEY_RE.test(k) && !RESERVED.has(k);
11const SUBJECT_ID = {
12 user: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/, guest: /^gst_[0-9A-HJKMNP-TV-Z]{26}$/,
13 app: /^app_[0-9A-HJKMNP-TV-Z]{26}$/, mod: /^mod_[0-9A-HJKMNP-TV-Z]{26}$/,
14 service: SERVICE_RE, system: SERVICE_RE,
15};
16const PREFIXED = { usr_: 'user', gst_: 'guest', app_: 'app', mod_: 'mod' };
17const REASON_MAX = 500;
18const ERROR_MAX = 2000;
19
20class ConfigError extends Error {
21 constructor(code, message, { status = 400, errors = null, revision = null, cause } = {}) {
22 super(message, cause === undefined ? undefined : { cause });
23 this.name = 'ConfigError';
24 this.code = code;
25 this.status = status;
26 if (errors) this.errors = errors;
27 if (revision != null) this.revision = revision;
28 }
29}
30
31// ── JSON helpers ────────────────────────────────────────────────
32
33/** Canonical JSON: object keys sorted at every depth, no whitespace. */
34function canonical(v) {
35 if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
36 if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
37 return JSON.stringify(v);
38}
39const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
40const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v));
41const pointer = (k) => `/${String(k).replace(/~/g, '~0').replace(/\//g, '~1')}`;
42const clone = (v) => JSON.parse(JSON.stringify(v));
43const same = (a, b) => canonical(a) === canonical(b);
44const has = (o, k) => o != null && Object.prototype.hasOwnProperty.call(o, k);
45
46function deepFreeze(v) {
47 if (v && typeof v === 'object' && !Object.isFrozen(v)) { Object.freeze(v); for (const x of Object.values(v)) deepFreeze(x); }
48 return v;
49}
50
51/** Only what JSON keeps as it is: no undefined, functions, NaN/Infinity, Dates or class instances. */
52function jsonErrors(v, path, errors, depth = 0) {
53 if (depth > 32) { errors.push({ path, message: 'is nested too deeply' }); return; }
54 if (v === null || typeof v === 'string' || typeof v === 'boolean') return;
55 if (typeof v === 'number') { if (!Number.isFinite(v)) errors.push({ path, message: 'must be a finite number' }); return; }
56 if (Array.isArray(v)) { v.forEach((x, i) => jsonErrors(x, `${path}/${i}`, errors, depth + 1)); return; }
57 if (isPlainObject(v)) { for (const [k, x] of Object.entries(v)) jsonErrors(x, `${path}${pointer(k)}`, errors, depth + 1); return; }
58 errors.push({ path, message: 'is not a JSON value' });
59}
60
61const isMarker = (v) => isPlainObject(v) && v.redacted === true && Object.keys(v).length === 2 && typeof v.fingerprint === 'string';
62/** An object with a redacted member is reserved for markers (common.config-snapshot@1). */
63const looksRedacted = (v) => isPlainObject(v) && has(v, 'redacted');
64const sameHex = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
65
66// ── Sealed secrets (D11) ────────────────────────────────────────
67// A secret-class value is stored as v1:<kek_id>:<fingerprint>:<hint>:<bind>:<kek iv>:<wrapped key>:<body iv>:<body tag>:<body>.
68// The wrapped key is a random 256-bit data key sealed by the KEK (ciphertext then its 16-byte GCM tag); the body is the
69// JSON of the value sealed by that data key. Both use AES-256-GCM with AAD service|namespace|key|bind. fingerprint is
70// the namespace's HMAC of the plaintext (so markers stay stable across reseals), hint the last four characters (or
71// empty), and bind the value the secret was sealed to. A bad or foreign envelope never throws out of get(): it is locked.
72
73const KEK_ID = /^[a-z0-9][a-z0-9_-]{0,31}$/;
74const B64_STD = /^[A-Za-z0-9+/]+={0,2}$/;
75const B64_URL = /^[A-Za-z0-9_-]*$/;
76const IV_BYTES = 12;
77const TAG_BYTES = 16;
78const KEY_BYTES = 32;
79
80function b64(buf) { return Buffer.from(buf).toString('base64'); }
81function unb64(s, length) {
82 if (typeof s !== 'string' || !B64_STD.test(s) || s.length % 4 === 1) return null;
83 const b = Buffer.from(s, 'base64');
84 if (length !== undefined && b.length !== length) return null;
85 return b;
86}
87const b64url = (s) => Buffer.from(String(s), 'utf8').toString('base64url');
88function unb64url(s, max = 4096) {
89 if (typeof s !== 'string' || !B64_URL.test(s) || s.length > max * 2) return null;
90 const b = Buffer.from(s, 'base64url');
91 if (b.length > max) return null;
92 return b.toString('utf8');
93}
94const aadOf = ({ service, namespace, key, bind }) => Buffer.from(`${service}|${namespace}|${key}|${bind == null ? '' : String(bind)}`, 'utf8');
95const bindText = (v) => (v == null ? '' : typeof v === 'string' ? v : canonical(v));
96/** The last four characters of a long enough string, never a short one (a five-character secret must not be shown). */
97function hintOf(value) { return typeof value === 'string' && value.length >= 8 ? [...value].slice(-4).join('') : null; }
98
99/** Parse a stored envelope without any key. Null means "not an envelope". */
100function parseEnvelope(v) {
101 if (typeof v !== 'string' || !v.startsWith('v1:')) return null;
102 const parts = v.split(':');
103 if (parts.length !== 10) return null;
104 const [, kekId, fingerprint, hintB64, bindB64, kekIvB64, wrappedB64, bodyIvB64, tagB64, bodyB64] = parts;
105 if (!KEK_ID.test(kekId) || !/^[0-9a-f]{64}$/.test(fingerprint)) return null;
106 const hint = unb64url(hintB64, 64);
107 const bind = unb64url(bindB64, 4096);
108 const kekIv = unb64(kekIvB64, IV_BYTES);
109 const wrapped = unb64(wrappedB64, KEY_BYTES + TAG_BYTES);
110 const bodyIv = unb64(bodyIvB64, IV_BYTES);
111 const tag = unb64(tagB64, TAG_BYTES);
112 const body = unb64(bodyB64);
113 if (hint === null || bind === null || !kekIv || !wrapped || !bodyIv || !tag || !body) return null;
114 return { version: 'v1', kekId, fingerprint, hint, bind, kekIv, wrapped, bodyIv, tag, body };
115}
116const isEnvelope = (v) => parseEnvelope(v) !== null;
117
118/** Seal one secret-class value. `fingerprint` is the namespace HMAC of the plaintext; `kek` is 32 bytes. */
119function sealEnvelope({ service, namespace, key, value, bind = '', fingerprint, hint = null, kekId, kek }) {
120 if (typeof kekId !== 'string' || !KEK_ID.test(kekId)) throw new TypeError('sealEnvelope: kek id must be k1, k2, …');
121 if (!Buffer.isBuffer(kek) || kek.length !== KEY_BYTES) throw new TypeError('sealEnvelope: the KEK must be a 32-byte Buffer');
122 if (typeof fingerprint !== 'string' || !/^[0-9a-f]{64}$/.test(fingerprint)) throw new TypeError('sealEnvelope: fingerprint must be 64 hex characters');
123 const aad = aadOf({ service, namespace, key, bind });
124 const dataKey = crypto.randomBytes(KEY_BYTES);
125 const bodyIv = crypto.randomBytes(IV_BYTES);
126 const body = crypto.createCipheriv('aes-256-gcm', dataKey, bodyIv);
127 body.setAAD(aad);
128 const sealedBody = Buffer.concat([body.update(JSON.stringify(value), 'utf8'), body.final()]);
129 const tag = body.getAuthTag();
130 const kekIv = crypto.randomBytes(IV_BYTES);
131 const wrap = crypto.createCipheriv('aes-256-gcm', kek, kekIv);
132 wrap.setAAD(aad);
133 const wrapped = Buffer.concat([wrap.update(dataKey), wrap.final(), wrap.getAuthTag()]);
134 return ['v1', kekId, fingerprint, b64url(hint || ''), b64url(bind || ''), b64(kekIv), b64(wrapped), b64(bodyIv), b64(tag), b64(sealedBody)].join(':');
135}
136
137const secretUnreadable = (why) => Object.assign(new Error(`secret does not open (${why})`), { code: 'config.secret_unreadable' });
138
139// ── The namespace fingerprint key, sealed under the KEK (D11 S2 review) ─────────────────────────
140// With a KEK, config_keys.hmac_key is not the key: it is the 32-byte key wrapped with the current KEK
141// (AES-256-GCM, AAD openvibe-config-fingerprint|<service>|<namespace>) in this small versioned form.
142// A database dump then has the fingerprints but no key that reproduces them. The same key is kept
143// across KEK rotations (rewrapped on boot), so markers and checksums never move.
144const FP_WRAP_PREFIX = 'ovfp.v1.';
145const fpAad = ({ service, namespace }) => Buffer.from(`openvibe-config-fingerprint|${service}|${namespace}`, 'utf8');
146const isWrappedFingerprintKey = (stored) => stored != null && Buffer.from(stored).toString('utf8').startsWith(FP_WRAP_PREFIX);
147
148function sealFingerprintKey(key, { service, namespace, kekId, kek }) {
149 if (!Buffer.isBuffer(key) || key.length !== KEY_BYTES) throw new TypeError('sealFingerprintKey: the fingerprint key must be a 32-byte Buffer');
150 if (typeof kekId !== 'string' || !KEK_ID.test(kekId)) throw new TypeError('sealFingerprintKey: kek id must be k1, k2, …');
151 if (!Buffer.isBuffer(kek) || kek.length !== KEY_BYTES) throw new TypeError('sealFingerprintKey: the KEK must be a 32-byte Buffer');
152 const iv = crypto.randomBytes(IV_BYTES);
153 const cipher = crypto.createCipheriv('aes-256-gcm', kek, iv);
154 cipher.setAAD(fpAad({ service, namespace }));
155 const body = Buffer.concat([cipher.update(key), cipher.final()]);
156 const tag = cipher.getAuthTag();
157 return Buffer.from(`${FP_WRAP_PREFIX}${kekId}:${b64(iv)}:${b64(tag)}:${b64(body)}`, 'utf8');
158}
159
160/** Open a wrapped fingerprint key: { key, kekId }. Null when the value is a plain 32-byte key. */
161function openFingerprintKey(stored, { service, namespace, keys }) {
162 const text = Buffer.from(stored).toString('utf8');
163 if (!text.startsWith(FP_WRAP_PREFIX)) return null;
164 const fail = (why) => Object.assign(new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys ${why}`), { code: 'config.fingerprint_key_unreadable' });
165 const parts = text.slice(FP_WRAP_PREFIX.length).split(':');
166 if (parts.length !== 4 || !KEK_ID.test(parts[0])) throw fail('is not a valid wrapper');
167 const [kekId, ivB64, tagB64, bodyB64] = parts;
168 const iv = unb64(ivB64, IV_BYTES);
169 const tag = unb64(tagB64, TAG_BYTES);
170 const body = unb64(bodyB64, KEY_BYTES);
171 if (!iv || !tag || !body) throw fail('is not a valid wrapper');
172 const kek = keys && typeof keys.get === 'function' ? keys.get(kekId) : keys && keys[kekId];
173 if (!kek) throw fail(`is sealed under the KEK "${kekId}", which is not configured`);
174 try {
175 const decipher = crypto.createDecipheriv('aes-256-gcm', kek, iv);
176 decipher.setAAD(fpAad({ service, namespace }));
177 decipher.setAuthTag(tag);
178 const key = Buffer.concat([decipher.update(body), decipher.final()]);
179 if (key.length !== KEY_BYTES) throw new Error('wrong length');
180 return { key, kekId };
181 } catch {
182 throw fail(`does not open with the KEK "${kekId}"`);
183 }
184}
185
186/** Open one envelope: { value, meta }. Throws a coded error when the KEK, bind or tag is wrong. */
187function openEnvelope(envelope, { service, namespace, key, bind = '', keys }) {
188 const p = parseEnvelope(envelope);
189 if (!p) throw secretUnreadable('not an envelope');
190 const kek = keys && typeof keys.get === 'function' ? keys.get(p.kekId) : keys && keys[p.kekId];
191 if (!kek) throw secretUnreadable(`no KEK ${p.kekId}`);
192 const aad = aadOf({ service, namespace, key, bind });
193 try {
194 const wrapped = p.wrapped;
195 const unwrap = crypto.createDecipheriv('aes-256-gcm', kek, p.kekIv);
196 unwrap.setAAD(aad);
197 unwrap.setAuthTag(wrapped.subarray(KEY_BYTES));
198 const dataKey = Buffer.concat([unwrap.update(wrapped.subarray(0, KEY_BYTES)), unwrap.final()]);
199 const open = crypto.createDecipheriv('aes-256-gcm', dataKey, p.bodyIv);
200 open.setAAD(aad);
201 open.setAuthTag(p.tag);
202 const plain = Buffer.concat([open.update(p.body), open.final()]).toString('utf8');
203 return { value: JSON.parse(plain), meta: { ...p } };
204 } catch (err) {
205 throw secretUnreadable(err && err.code ? err.code : 'wrong KEK, bind or tag');
206 }
207}
208
209/**
210 * The KEKs of a store: opts.keks ({ current, keys: { k1: Buffer } }) when given (false/null disables sealing), else
211 * OV_CONFIG_KEKS and OV_CONFIG_KEK_CURRENT. Every value must be base64 of exactly 32 bytes. Errors name the variable
212 * or the option and the key id, never a byte of the key.
213 */
214function keksOf(opts = {}, env = process.env) {
215 if (opts.keks === false || opts.keks === null) return null;
216 if (opts.keks !== undefined) {
217 if (!isPlainObject(opts.keks)) throw new TypeError('createConfigStore: opts.keks must be { current, keys }');
218 const keys = new Map();
219 const raw = opts.keks.keys;
220 if (!isPlainObject(raw) || !Object.keys(raw).length) throw new TypeError('createConfigStore: opts.keks.keys must name at least one KEK');
221 for (const [id, k] of Object.entries(raw)) {
222 if (!KEK_ID.test(id)) throw new TypeError(`createConfigStore: opts.keks.keys has an invalid KEK id "${String(id).slice(0, 40)}"`);
223 if (!Buffer.isBuffer(k) || k.length !== KEY_BYTES) throw new TypeError(`createConfigStore: opts.keks.keys.${id} must be a 32-byte Buffer`);
224 keys.set(id, Buffer.from(k));
225 }
226 if (typeof opts.keks.current !== 'string' || !KEK_ID.test(opts.keks.current)) throw new TypeError('createConfigStore: opts.keks.current must name a KEK id (k1, k2, …)');
227 if (!keys.has(opts.keks.current)) throw new TypeError(`createConfigStore: opts.keks.current names the unknown KEK "${opts.keks.current}"`);
228 return { current: opts.keks.current, keys };
229 }
230 const list = env && env.OV_CONFIG_KEKS;
231 const current = env && env.OV_CONFIG_KEK_CURRENT;
232 if (list == null || list === '') {
233 if (current) throw new TypeError('createConfigStore: OV_CONFIG_KEK_CURRENT is set but OV_CONFIG_KEKS is empty');
234 return null;
235 }
236 const keys = new Map();
237 for (const part of String(list).split(',')) {
238 const s = part.trim();
239 if (!s) continue;
240 const at = s.indexOf(':');
241 if (at < 1) throw new TypeError('createConfigStore: every OV_CONFIG_KEKS entry must be <id>:<base64 32-byte key>');
242 const id = s.slice(0, at).trim();
243 const value = s.slice(at + 1).trim();
244 if (!KEK_ID.test(id)) throw new TypeError(`createConfigStore: OV_CONFIG_KEKS has an invalid KEK id "${id.slice(0, 40)}"`);
245 if (keys.has(id)) throw new TypeError(`createConfigStore: OV_CONFIG_KEKS names the KEK "${id}" twice`);
246 const k = unb64(value, KEY_BYTES);
247 if (!k) throw new TypeError(`createConfigStore: OV_CONFIG_KEKS entry "${id}" must be base64 of exactly 32 bytes`);
248 keys.set(id, k);
249 }
250 if (!keys.size) throw new TypeError('createConfigStore: OV_CONFIG_KEKS names no KEK');
251 if (!current) throw new TypeError('createConfigStore: OV_CONFIG_KEKS is set, so OV_CONFIG_KEK_CURRENT must name the current KEK id');
252 if (!KEK_ID.test(current)) throw new TypeError(`createConfigStore: OV_CONFIG_KEK_CURRENT is not a KEK id ("${String(current).slice(0, 40)}")`);
253 if (!keys.has(current)) throw new TypeError(`createConfigStore: OV_CONFIG_KEK_CURRENT names the unknown KEK "${current}"`);
254 return { current, keys };
255}
256
257// ── A small JSON Schema subset ──────────────────────────────────
258// type, enum, const, properties, required, additionalProperties, min/maxProperties, items,
259// min/maxItems, uniqueItems, min/maxLength, pattern, minimum, maximum, exclusiveMinimum/Maximum,
260// multipleOf. Annotations (title, description, default, format, x-…) are ignored. Anything else
261// throws when the store is created, so a schema is never silently checked less than it says.
262
263const KEYWORDS = new Set(['type', 'enum', 'const', 'properties', 'required', 'additionalProperties', 'minProperties', 'maxProperties',
264 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength', 'pattern', 'minimum', 'maximum',
265 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']);
266const ANNOTATIONS = new Set(['$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'format']);
267const TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'];
268const UNITS = new Set(['nano_usd', 'ms', 'bps', 'seconds']);
269const ENV_NAME = /^[A-Z][A-Z0-9_]{0,127}$/;
270const patterns = new WeakMap();
271
272// ── The owner's Setup page (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07) ──
273// A field with x-owner-input is something only the owner can give (a key, an account, a cap). Network's admin panel
274// lists every such field of every service on one page, grouped by these ids, in this order.
275const OWNER_INPUT_GROUPS = Object.freeze({
276 money: 'Money',
277 'ai-providers': 'AI providers',
278 gpus: 'GPUs',
279 voices: 'Voices',
280 'chat-apps': 'Chat apps',
281 'sign-in-apps': 'Sign-in apps',
282 'data-sources': 'Data sources',
283 'design-publishing': 'Design and publishing',
284 domains: 'Domains',
285 legal: 'Legal and registrations',
286});
287const OWNER_INPUT_MEMBERS = new Set(['group', 'unlocks', 'get_it_at', 'get_it_how', 'test']);
288const ENUM_LABEL_MEMBERS = new Set(['label', 'description']);
289const LINT_DESCRIPTION_MIN = 30;
290/** The x-enum-labels key of an enum value: a string as it is, anything else as its JSON (null → "null", 5 → "5"). */
291const enumKey = (v) => (typeof v === 'string' ? v : JSON.stringify(v));
292function isHttpsUrl(v) {
293 if (typeof v !== 'string' || v.length > 2000) return false;
294 try {
295 const u = new URL(v);
296 return u.protocol === 'https:' && !u.username && !u.password && !!u.hostname;
297 } catch { return false; }
298}
299const text = (v) => typeof v === 'string' && v.trim().length > 0;
300
301/** A Setup group as an id: the id itself, or its display name in any case ("Voices" → voices). Else null. */
302function ownerGroupId(g) {
303 if (typeof g !== 'string') return null;
304 if (has(OWNER_INPUT_GROUPS, g)) return g;
305 const want = g.trim().toLowerCase();
306 for (const [id, label] of Object.entries(OWNER_INPUT_GROUPS)) if (label.toLowerCase() === want || id === want) return id;
307 return null;
308}
309
310/**
311 * The Setup page's annotations (2026-11-07): x-owner-input, x-required-for, x-enum-labels, x-advanced, x-example.
312 * They only change how a field is presented, so a wrong shape never refuses the store (that would take a service down
313 * at boot over a label): the problem is returned here, the summary leaves that annotation out, the store warns once,
314 * and lintSchema reports it so the service's own tests fail. → [{ key, message }]
315 */
316function setupAnnotationProblems(s, at) {
317 const out = [];
318 const bad = (key, message) => out.push({ key, message: `config schema ${at}: ${message}` });
319 const owner = s['x-owner-input'];
320 if (owner !== undefined) {
321 if (!isPlainObject(owner)) bad('x-owner-input', 'x-owner-input must be an object { group, unlocks, get_it_at, get_it_how, test }');
322 else {
323 const unknown = Object.keys(owner).find((k) => !OWNER_INPUT_MEMBERS.has(k));
324 if (unknown !== undefined) bad('x-owner-input', `x-owner-input has an unknown member "${String(unknown).slice(0, 40)}"`);
325 else if (!ownerGroupId(owner.group)) bad('x-owner-input', `x-owner-input.group must be one of ${Object.keys(OWNER_INPUT_GROUPS).join(', ')}`);
326 else if (!text(owner.unlocks)) bad('x-owner-input', 'x-owner-input.unlocks must say what it turns on');
327 else if (owner.get_it_at !== undefined && !isHttpsUrl(owner.get_it_at)) bad('x-owner-input', 'x-owner-input.get_it_at must be an https URL');
328 else if (owner.get_it_how !== undefined && !text(owner.get_it_how)) bad('x-owner-input', 'x-owner-input.get_it_how must be one line of words');
329 else if (owner.test !== undefined && typeof owner.test !== 'boolean') bad('x-owner-input', 'x-owner-input.test must be true or false');
330 }
331 }
332 if (s['x-required-for'] !== undefined && (!text(s['x-required-for']) || s['x-required-for'].length > 200)) bad('x-required-for', 'x-required-for must name the feature that waits on it (at most 200 characters)');
333 if (s['x-advanced'] !== undefined && typeof s['x-advanced'] !== 'boolean') bad('x-advanced', 'x-advanced must be true or false');
334 if (s['x-example'] !== undefined && !['string', 'number', 'boolean'].includes(typeof s['x-example'])) bad('x-example', 'x-example must be a string, a number or true/false');
335 const labels = s['x-enum-labels'];
336 if (labels !== undefined) {
337 if (!isPlainObject(labels)) bad('x-enum-labels', 'x-enum-labels must be an object { value: { label, description } }');
338 else {
339 const choices = enumOf(s);
340 const known = choices ? new Set(choices.map(enumKey)) : null;
341 for (const [v, l] of Object.entries(labels)) {
342 const name = `x-enum-labels["${String(v).slice(0, 40)}"]`;
343 if (!isPlainObject(l) || !text(l.label)) { bad('x-enum-labels', `${name} needs a label`); break; }
344 const m = Object.keys(l).find((k) => !ENUM_LABEL_MEMBERS.has(k));
345 if (m !== undefined) { bad('x-enum-labels', `${name} has an unknown member "${String(m).slice(0, 40)}"`); break; }
346 if (l.description !== undefined && typeof l.description !== 'string') { bad('x-enum-labels', `${name}.description must be a string`); break; }
347 if (known && !known.has(v)) { bad('x-enum-labels', `x-enum-labels names "${String(v).slice(0, 40)}", which is not one of the enum values`); break; }
348 }
349 }
350 }
351 return out;
352}
353// The Setup annotations left out of a schema node's summary (a node warns once per key: a second store built from
354// the same schema object stays quiet).
355const droppedSetup = new WeakMap();
356
357/**
358 * The owner-settings annotations. D11 (x-env, x-danger, x-bind, x-unit, x-group, title, description): a wrong shape
359 * refuses the store at creation, like any other schema mistake, because these decide where a value comes from and how
360 * it is guarded. The Setup ones (setupAnnotationProblems) are presentation: left out of the summary with a warning.
361 */
362function checkAnnotations(s, at) {
363 if (s['x-env'] !== undefined && (typeof s['x-env'] !== 'string' || !ENV_NAME.test(s['x-env']))) throw new TypeError(`config schema ${at}: x-env must be an env name (A-Z, 0-9, _)`);
364 if (s['x-bind'] !== undefined && (typeof s['x-bind'] !== 'string' || !s['x-bind'])) throw new TypeError(`config schema ${at}: x-bind must name another key`);
365 if (s['x-danger'] !== undefined && typeof s['x-danger'] !== 'boolean') throw new TypeError(`config schema ${at}: x-danger must be true or false`);
366 if (s['x-unit'] !== undefined && !UNITS.has(s['x-unit'])) throw new TypeError(`config schema ${at}: x-unit must be one of ${[...UNITS].join(', ')}`);
367 if (s['x-group'] !== undefined && typeof s['x-group'] !== 'string') throw new TypeError(`config schema ${at}: x-group must be a string`);
368 if (s.title !== undefined && typeof s.title !== 'string') throw new TypeError(`config schema ${at}: title must be a string`);
369 if (s.description !== undefined && typeof s.description !== 'string') throw new TypeError(`config schema ${at}: description must be a string`);
370 const problems = setupAnnotationProblems(s, at);
371 if (!problems.length) return;
372 const keys = droppedSetup.get(s) || new Set();
373 for (const p of problems) {
374 if (keys.has(p.key)) continue;
375 keys.add(p.key);
376 console.warn(`[config] ${p.message}; the Setup page leaves it out (fix it: config.lintSchema)`);
377 }
378 droppedSetup.set(s, keys);
379}
380
381/** A field's choices: its own enum, or its items' (a list of choices). */
382function enumOf(p) {
383 if (!isPlainObject(p)) return null;
384 if (Array.isArray(p.enum)) return p.enum;
385 if (isPlainObject(p.items) && Array.isArray(p.items.enum)) return p.items.enum;
386 return null;
387}
388
389/**
390 * lintSchema(schema) → [{ path, key, message }], empty when every field reads clearly in the admin panel. The clarity
391 * rules (design §2 rule 10) a service's own test runs on each namespace it publishes:
392 * - every field has a title in plain words;
393 * - every field has a description of at least 30 characters (what it does, what the default means, an example);
394 * - every choice (enum) has x-enum-labels with a label for each option.
395 * Fields are the schema's properties, and the properties of an object field (which the panel draws as a form).
396 * A namespace summary (anything with a `schema` member and no `properties`) is read through its schema.
397 * assert.deepStrictEqual(config.lintSchema(SCHEMA), []);
398 */
399function lintSchema(input) {
400 const schema = isPlainObject(input) && !has(input, 'properties') && has(input, 'schema') ? input.schema : input;
401 if (!isPlainObject(schema)) return [{ path: '#', key: null, message: 'is not a JSON Schema object' }];
402 const errors = [];
403 const walk = (props, at, prefix) => {
404 for (const [name, p] of Object.entries(props)) {
405 if (!isPlainObject(p)) continue;
406 const path = `${at}/properties/${name}`;
407 const key = prefix ? `${prefix}.${name}` : name;
408 if (!text(p.title)) errors.push({ path, key, message: 'has no title: give it a name in plain words' });
409 const description = typeof p.description === 'string' ? p.description.trim() : '';
410 if (description.length < LINT_DESCRIPTION_MIN) {
411 errors.push({ path, key, message: description
412 ? `has a description of ${description.length} characters; it needs at least ${LINT_DESCRIPTION_MIN} (what it does, what the default means, an example)`
413 : `has no description: say in at least ${LINT_DESCRIPTION_MIN} characters what it does, what the default means, and give an example` });
414 }
415 const choices = enumOf(p);
416 if (choices) {
417 const labels = isPlainObject(p['x-enum-labels']) ? p['x-enum-labels'] : null;
418 if (!labels) errors.push({ path, key, message: 'is a choice without x-enum-labels: give every option words and a line' });
419 else {
420 const missing = choices.map(enumKey).filter((v) => !isPlainObject(labels[v]) || !text(labels[v].label));
421 if (missing.length) errors.push({ path, key, message: `has no x-enum-labels label for ${missing.map((v) => `"${v}"`).join(', ')}` });
422 }
423 }
424 // The Setup annotations' shape (the store only warns about these, so the service's tests are where they fail).
425 for (const pr of setupAnnotationProblems(p, path)) errors.push({ path, key, message: pr.message.replace(/^config schema [^:]*: /, '') });
426 if (isPlainObject(p['x-owner-input']) && has(OWNER_INPUT_GROUPS, p['x-owner-input'].group) === false && ownerGroupId(p['x-owner-input'].group)) {
427 errors.push({ path, key, message: `x-owner-input.group "${String(p['x-owner-input'].group).slice(0, 40)}" is a display name: use its id "${ownerGroupId(p['x-owner-input'].group)}"` });
428 }
429 if (isPlainObject(p.properties)) walk(p.properties, path, key);
430 }
431 };
432 if (isPlainObject(schema.properties)) walk(schema.properties, '#', '');
433 return errors;
434}
435
436function compileSchema(s, at = '#') {
437 if (typeof s === 'boolean') return;
438 if (!isPlainObject(s)) throw new TypeError(`config schema ${at}: must be an object or a boolean`);
439 for (const k of Object.keys(s)) {
440 if (!KEYWORDS.has(k) && !ANNOTATIONS.has(k) && !k.startsWith('x-')) throw new TypeError(`config schema ${at}: unsupported keyword "${k}"`);
441 }
442 if (s.type !== undefined && ![].concat(s.type).every((t) => TYPES.includes(t))) throw new TypeError(`config schema ${at}: unknown type`);
443 checkAnnotations(s, at);
444 if (at === '#' && isPlainObject(s.properties)) {
445 for (const [k, sub] of Object.entries(s.properties)) {
446 if (!isPlainObject(sub) || typeof sub['x-bind'] !== 'string') continue;
447 if (sub['x-bind'] === k) throw new TypeError(`config schema ${at}/properties/${k}: x-bind cannot name itself`);
448 if (!has(s.properties, sub['x-bind'])) throw new TypeError(`config schema ${at}/properties/${k}: x-bind names an unknown key "${sub['x-bind']}"`);
449 if (isPlainObject(s.properties[sub['x-bind']]) && has(s.properties[sub['x-bind']], 'x-bind')) throw new TypeError(`config schema ${at}/properties/${k}: x-bind cannot chain to another bound key`);
450 }
451 }
452 if (s.pattern !== undefined) patterns.set(s, new RegExp(s.pattern, 'u'));
453 if (s.properties) for (const [k, sub] of Object.entries(s.properties)) compileSchema(sub, `${at}/properties/${k}`);
454 if (s.additionalProperties !== undefined) compileSchema(s.additionalProperties, `${at}/additionalProperties`);
455 if (s.items !== undefined) compileSchema(s.items, `${at}/items`);
456}
457
458/** The declared env pins and binds of a schema's top-level properties. */
459function schemaAnnotations(schema) {
460 const out = { env: {}, bind: {} };
461 if (isPlainObject(schema) && isPlainObject(schema.properties)) {
462 for (const [k, p] of Object.entries(schema.properties)) {
463 if (!isPlainObject(p)) continue;
464 if (typeof p['x-env'] === 'string') out.env[k] = p['x-env'];
465 if (typeof p['x-bind'] === 'string') out.bind[k] = p['x-bind'];
466 }
467 }
468 return out;
469}
470
471/**
472 * A JSON Schema for the summary: secret-class properties lose default, examples, enum, const, x-example and
473 * x-enum-labels, any of which could hold a secret. Every other annotation (title, description, x-owner-input,
474 * x-required-for, x-advanced, …) passes through as the service wrote it.
475 */
476function summarySchema(schema, isSecret) {
477 if (schema == null) return null;
478 const out = clone(schema);
479 // The Setup annotations checkAnnotations found wrong are left out (one warning at creation), and a group named by
480 // its display name is given as its id, so Network's Setup page groups it.
481 const tidy = (orig, copy) => {
482 if (!isPlainObject(orig) || !isPlainObject(copy)) return;
483 const dropped = droppedSetup.get(orig);
484 if (dropped) for (const k of dropped) delete copy[k];
485 if (isPlainObject(copy['x-owner-input'])) {
486 const id = ownerGroupId(copy['x-owner-input'].group);
487 if (id) copy['x-owner-input'].group = id;
488 }
489 if (isPlainObject(orig.properties) && isPlainObject(copy.properties)) {
490 for (const k of Object.keys(orig.properties)) tidy(orig.properties[k], copy.properties[k]);
491 }
492 if (isPlainObject(orig.items) && isPlainObject(copy.items)) tidy(orig.items, copy.items);
493 };
494 tidy(schema, out);
495 if (isPlainObject(out.properties)) {
496 for (const [k, p] of Object.entries(out.properties)) {
497 if (!isPlainObject(p) || !isSecret(k)) continue;
498 delete p.default; delete p.examples; delete p.enum; delete p.const;
499 delete p['x-example']; delete p['x-enum-labels'];
500 }
501 }
502 return deepFreeze(out);
503}
504
505function typeOf(v) {
506 if (v === null) return 'null';
507 if (Array.isArray(v)) return 'array';
508 return typeof v;
509}
510function isType(v, t) {
511 if (t === 'integer') return Number.isInteger(v);
512 if (t === 'number') return typeof v === 'number' && Number.isFinite(v);
513 return typeOf(v) === t;
514}
515
516/** Messages name the rule, never the value (a value may be a secret). `skip` holds paths whose value cannot be read. */
517function checkSchema(s, v, path, errors, skip = null) {
518 if (skip && skip.has(path)) return;
519 if (s === true || s === undefined) return;
520 if (s === false) { errors.push({ path, message: 'is not allowed' }); return; }
521 if (s.type !== undefined) {
522 const types = [].concat(s.type);
523 if (!types.some((t) => isType(v, t))) { errors.push({ path, message: `must be ${types.join(' or ')}` }); return; }
524 }
525 if (s.enum !== undefined && !s.enum.some((e) => same(e, v))) errors.push({ path, message: 'must be one of the allowed values' });
526 if (s.const !== undefined && !same(s.const, v)) errors.push({ path, message: 'must equal the constant' });
527 if (typeof v === 'number') {
528 if (s.minimum !== undefined && v < s.minimum) errors.push({ path, message: `must be >= ${s.minimum}` });
529 if (s.maximum !== undefined && v > s.maximum) errors.push({ path, message: `must be <= ${s.maximum}` });
530 if (s.exclusiveMinimum !== undefined && v <= s.exclusiveMinimum) errors.push({ path, message: `must be > ${s.exclusiveMinimum}` });
531 if (s.exclusiveMaximum !== undefined && v >= s.exclusiveMaximum) errors.push({ path, message: `must be < ${s.exclusiveMaximum}` });
532 if (s.multipleOf !== undefined && Math.abs(v / s.multipleOf - Math.round(v / s.multipleOf)) > 1e-9) errors.push({ path, message: `must be a multiple of ${s.multipleOf}` });
533 }
534 if (typeof v === 'string') {
535 const len = [...v].length;
536 if (s.minLength !== undefined && len < s.minLength) errors.push({ path, message: `must be at least ${s.minLength} characters` });
537 if (s.maxLength !== undefined && len > s.maxLength) errors.push({ path, message: `must be at most ${s.maxLength} characters` });
538 if (s.pattern !== undefined && !(patterns.get(s) || new RegExp(s.pattern, 'u')).test(v)) errors.push({ path, message: `must match ${s.pattern}` });
539 }
540 if (Array.isArray(v)) {
541 if (s.minItems !== undefined && v.length < s.minItems) errors.push({ path, message: `must have at least ${s.minItems} items` });
542 if (s.maxItems !== undefined && v.length > s.maxItems) errors.push({ path, message: `must have at most ${s.maxItems} items` });
543 if (s.uniqueItems && new Set(v.map(canonical)).size !== v.length) errors.push({ path, message: 'must not repeat items' });
544 if (s.items !== undefined) v.forEach((x, i) => checkSchema(s.items, x, `${path}/${i}`, errors, skip));
545 }
546 if (isPlainObject(v)) {
547 const keys = Object.keys(v);
548 for (const r of s.required || []) if (!has(v, r) && !(skip && skip.has(`${path}${pointer(r)}`))) errors.push({ path: `${path}${pointer(r)}`, message: 'is required' });
549 if (s.minProperties !== undefined && keys.length < s.minProperties) errors.push({ path, message: `must have at least ${s.minProperties} keys` });
550 if (s.maxProperties !== undefined && keys.length > s.maxProperties) errors.push({ path, message: `must have at most ${s.maxProperties} keys` });
551 for (const k of keys) {
552 if (s.properties && has(s.properties, k)) checkSchema(s.properties[k], v[k], `${path}${pointer(k)}`, errors, skip);
553 else if (s.additionalProperties === false) errors.push({ path: `${path}${pointer(k)}`, message: 'is not a known key' });
554 else if (s.additionalProperties !== undefined) checkSchema(s.additionalProperties, v[k], `${path}${pointer(k)}`, errors, skip);
555 }
556 }
557}
558
559/** validate(values) may return true/undefined, false, a message, a list, or { valid, errors } (Ajv/contracts style). */
560function validateResult(r) {
561 const norm = (e) => (typeof e === 'string' ? { path: '', message: e } : { path: String(e.path ?? e.instancePath ?? ''), message: String((e && e.message) || 'is invalid') });
562 if (r === true || r == null) return [];
563 if (r === false) return [{ path: '', message: 'rejected by validate()' }];
564 if (typeof r === 'string') return [{ path: '', message: r }];
565 if (Array.isArray(r)) return r.map(norm);
566 if (typeof r === 'object') {
567 if (r.valid === false || (Array.isArray(r.errors) && r.errors.length)) return (r.errors && r.errors.length ? r.errors : ['is invalid']).map(norm);
568 return [];
569 }
570 return [{ path: '', message: 'rejected by validate()' }];
571}
572
573// ── Subjects and text ───────────────────────────────────────────
574
575/** A common subject reference ({ type, id }), or a prefixed id string (usr_…, gst_…, app_…, mod_…). */
576function subjectRef(actor) {
577 if (typeof actor === 'string') {
578 const type = PREFIXED[actor.slice(0, 4)];
579 if (type && SUBJECT_ID[type].test(actor)) return { type, id: actor };
580 return null;
581 }
582 if (actor && typeof actor === 'object' && SUBJECT_ID[actor.type] && typeof actor.id === 'string' && SUBJECT_ID[actor.type].test(actor.id)) {
583 return { type: actor.type, id: actor.id };
584 }
585 return null;
586}
587
588/** One line, no credentials in URLs or key=value pairs, and never any of the given secret values. */
589function scrub(text, secrets = []) {
590 let s = String(text == null ? '' : text).split('\n')[0];
591 for (const x of secrets) if (x.length >= 4) s = s.split(x).join('[redacted]');
592 s = s.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1')
593 .replace(/\b(key|token|secret|password|signature)=[^\s&]+/gi, '$1=…');
594 return s.slice(0, ERROR_MAX) || 'failed';
595}
596
597const errText = (err) => (err instanceof Error ? err.message : String(err));
598const summarize = (errors) => errors.map((e) => (e.path ? `${e.path} ${e.message}` : e.message)).join('; ');
599
600function makeLog(log) {
601 const noop = () => {};
602 if (log === false || log === null) return { info: noop, warn: noop, error: noop };
603 if (typeof log === 'function') return { info: (m) => log(m, 'info'), warn: (m) => log(m, 'warn'), error: (m) => log(m, 'error') };
604 const l = log || console;
605 return { info: (m) => (l.info || l.log || noop).call(l, m), warn: (m) => (l.warn || l.log || noop).call(l, m), error: (m) => (l.error || l.warn || noop).call(l, m) };
606}
607
608/**
609 * Typed key-value rows ({ key, value, type }, as site_settings/media_settings keep them) as a values
610 * object: number, boolean ('true'), json (parsed; the raw text if it does not parse), anything else
611 * a string. `prefix` keeps only the keys that start with it and strips it; `type` forces one type.
612 */
613function fromRows(rows, { prefix = '', type = null } = {}) {
614 const out = {};
615 for (const r of rows || []) {
616 if (!r || typeof r.key !== 'string' || !r.key.startsWith(prefix) || RESERVED.has(r.key.slice(prefix.length))) continue;
617 const raw = r.value == null ? '' : String(r.value);
618 const t = type || r.type || 'string';
619 let v = raw;
620 if (t === 'number') v = raw.trim() === '' ? null : Number(raw);
621 else if (t === 'boolean') v = raw === 'true';
622 else if (t === 'json') { try { v = JSON.parse(raw); } catch { v = raw; } }
623 if (typeof v === 'number' && !Number.isFinite(v)) v = raw;
624 out[r.key.slice(prefix.length)] = v;
625 }
626 return out;
627}
628
629module.exports = { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows,
630 KEK_ID, KEY_BYTES, IV_BYTES, TAG_BYTES, isEnvelope, parseEnvelope, sealEnvelope, openEnvelope, isWrappedFingerprintKey, sealFingerprintKey, openFingerprintKey, keksOf, hintOf, bindText, schemaAnnotations, summarySchema,
631 OWNER_INPUT_GROUPS, LINT_DESCRIPTION_MIN, lintSchema, enumKey };
632