openvibers/openvibe.shared config-pg.js

config-pg.js

main 53.0 KB · 852 lines · javascript

1'use strict';
2// openvibe-shared/config on PostgreSQL (ADR-035): the configuration store on an openvibe-sdk/db handle. Every
3// method that reads or writes the database is async; get() and revision() stay in memory. createConfigStore picks this
4// implementation when it is handed an openvibe-sdk/db handle, and then returns a promise of the store (it has read or
5// seeded its active revision when it resolves). The tables come from
6// the service's migrations: configSchema() is their DDL.
7//
8// Secret-class values are sealed (D11) when the store has a KEK: a random AES-256-GCM data key seals the value, a
9// versioned KEK seals that data key, and values_json only ever holds the envelope. get() opens it for the service;
10// summaries, snapshots, history and errors carry a keyed fingerprint marker only. x-bind adds the value of another
11// key to the AAD, so changing that value locks the secret until it is entered again. Without a KEK nothing is sealed
12// and the store behaves exactly as before, but reports sealed: false.
13const crypto = require('crypto');
14const { 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,
15 isEnvelope, parseEnvelope, sealEnvelope, openEnvelope, isWrappedFingerprintKey, sealFingerprintKey, openFingerprintKey, keksOf, hintOf, bindText, schemaAnnotations, summarySchema } = require('./config-core');
16
17/** The DDL of config_snapshots and config_keys, for a service's migrations. */
18function configSchema() {
19 return `CREATE TABLE IF NOT EXISTS config_snapshots (
20 namespace text COLLATE "C" NOT NULL,
21 revision bigint NOT NULL,
22 service text COLLATE "C" NOT NULL,
23 previous_revision bigint,
24 state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
25 values_json text COLLATE "C" NOT NULL, -- the full values; secret-class values are envelopes (never their plaintext)
26 classification_json text COLLATE "C" NOT NULL,
27 values_checksum text COLLATE "C" NOT NULL, -- sha256 of the values as served (secrets as markers)
28 created_at text COLLATE "C" NOT NULL,
29 created_by text COLLATE "C" NOT NULL,
30 activated_at text COLLATE "C",
31 activated_by text COLLATE "C",
32 reason text COLLATE "C",
33 error text COLLATE "C",
34 copied_from bigint,
35 good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)
36 PRIMARY KEY (namespace, revision)
37);
38CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
39-- The fingerprint key of each namespace. Never served, never logged.
40CREATE TABLE IF NOT EXISTS config_keys (
41 namespace text COLLATE "C" PRIMARY KEY,
42 hmac_key bytea NOT NULL, -- the 32-byte fingerprint key, or that key wrapped under the current KEK
43 created_at text COLLATE "C" NOT NULL
44);`;
45}
46
47async function createPgConfigStore(opts = {}) {
48 const { db, service, namespace, schema = null, validate = null, classify = null, onActivate = null, onChange = null } = opts;
49 if (!db || typeof db.prepare !== 'function' || typeof db.tx !== 'function') throw new TypeError('createConfigStore: db must be an openvibe-sdk/db handle');
50 if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('createConfigStore: service must be a service id (live, media, …)');
51 if (!NAMESPACE_RE.test(String(namespace || '')) || !namespace.startsWith(`${service}.`) || namespace.length > 120) {
52 throw new TypeError(`createConfigStore: namespace must be <service>.<name>, e.g. ${service}.settings`);
53 }
54 const keks = keksOf(opts, opts.env || process.env);
55 if (schema !== null) compileSchema(schema);
56 if (validate !== null && typeof validate !== 'function') throw new TypeError('createConfigStore: validate must be a function');
57 if (onActivate !== null && typeof onActivate !== 'function') throw new TypeError('createConfigStore: onActivate must be a function');
58 if (onChange !== null && typeof onChange !== 'function') throw new TypeError('createConfigStore: onChange must be a function');
59 if (classify !== null && typeof classify !== 'function' && !isPlainObject(classify)) throw new TypeError('createConfigStore: classify must be a map or a function');
60 if (opts.defaults != null && !isPlainObject(opts.defaults)) throw new TypeError('createConfigStore: defaults must be an object');
61 const keep = Math.max(1, Math.floor(opts.keep || 50));
62 const maxBytes = opts.maxBytes || 1024 * 1024;
63 const log = makeLog(opts.log);
64 const now = opts.now || (() => new Date());
65 const iso = () => { const d = now(); return (d instanceof Date ? d : new Date(d)).toISOString(); };
66 const self = { type: 'service', id: service };
67 const defaults = deepFreeze(clone(opts.defaults || {}));
68 const tag = `[config] ${namespace}`;
69 const env = opts.env || process.env;
70
71 // ── the schema's owner annotations: env pins, binds and danger keys ──
72 const annotations = schemaAnnotations(schema);
73 const binds = annotations.bind;
74 const dangerKeys = new Set();
75 if (schema && isPlainObject(schema.properties)) {
76 for (const [k, p] of Object.entries(schema.properties)) {
77 if (isPlainObject(p) && p['x-danger'] === true) dangerKeys.add(k);
78 if (isPlainObject(p) && typeof p['x-bind'] === 'string' && classOf(p['x-bind'], undefined) === 'secret') throw new TypeError(`config schema: x-bind of ${k} names the secret key ${p['x-bind']}`);
79 }
80 }
81 function envValue(prop, raw) {
82 const types = prop && prop.type !== undefined ? [].concat(prop.type) : [];
83 if (types.includes('boolean')) return raw === 'true' || raw === '1';
84 if (types.includes('integer') || types.includes('number')) {
85 const n = Number(String(raw).trim());
86 return Number.isFinite(n) ? n : raw;
87 }
88 if (types.includes('array') || types.includes('object')) { try { return JSON.parse(raw); } catch { return raw; } }
89 return raw;
90 }
91 const pins = {};
92 for (const [k, name] of Object.entries(annotations.env)) {
93 const raw = env ? env[name] : undefined;
94 if (raw === undefined) continue;
95 pins[k] = { env: name, value: envValue(schema && schema.properties && schema.properties[k], raw) };
96 }
97 const pinKeys = new Set(Object.keys(pins));
98 const pinValues = () => Object.fromEntries(Object.entries(pins).map(([k, p]) => [k, clone(p.value)]));
99 const emptyEffective = deepFreeze({ ...clone(defaults), ...pinValues() });
100
101 // ── classification and redaction ──
102 const RANK = { public: 0, internal: 1, secret: 2 };
103 function classOf(key, value) {
104 let c;
105 try { c = typeof classify === 'function' ? classify(key, value) : (classify && has(classify, key) ? classify[key] : undefined); } catch { c = 'secret'; }
106 if (c == null) return 'internal';
107 if (!CLASSES.includes(c)) { log.warn(`${tag}: classify gave an invalid class for ${key}; treating it as secret`); return 'secret'; }
108 return c;
109 }
110 /** An envelope is always secret-class; everything else is the policy's call. */
111 const classOfValue = (k, v) => (isEnvelope(v) ? 'secret' : classOf(k, v));
112 const classifyAll = (values) => Object.fromEntries(Object.keys(values).map((k) => [k, classOfValue(k, values[k])]));
113 /** The stricter of what was stored and what the policy says now, so a key made secret later is redacted in old revisions too. */
114 function classesFor(values, stored = {}) {
115 const out = classifyAll(values);
116 for (const k of Object.keys(out)) if (stored[k] && RANK[stored[k]] > RANK[out[k]]) out[k] = stored[k];
117 return out;
118 }
119 /** The marker of a stored value: an envelope carries its fingerprint; plaintext is HMAC'd as before. */
120 function markerOf(v) {
121 const p = isEnvelope(v) ? parseEnvelope(v) : null;
122 if (p) return { redacted: true, fingerprint: p.fingerprint };
123 return { redacted: true, fingerprint: fingerprint(v) };
124 }
125 const redact = (values, cls) => Object.fromEntries(Object.entries(values).map(([k, v]) => [k, cls[k] === 'secret' ? markerOf(v) : v]));
126 /** The fingerprint a marker of this value has, whether the value is stored sealed or plain. */
127 const storedFingerprint = (v) => (isEnvelope(v) ? parseEnvelope(v).fingerprint : fingerprint(v));
128 function secretStrings(...valueSets) {
129 const out = [];
130 for (const values of valueSets) {
131 if (!values) continue;
132 for (const [k, v] of Object.entries(values)) {
133 if (v == null || isEnvelope(v)) continue;
134 if (classOf(k, v) !== 'secret') continue;
135 out.push(typeof v === 'string' ? v : JSON.stringify(v));
136 }
137 }
138 return out.sort((a, b) => b.length - a.length);
139 }
140 const plainStrings = (...valueSets) => {
141 const out = [];
142 for (const values of valueSets) {
143 if (!values) continue;
144 for (const v of Object.values(values)) if (v != null && !isEnvelope(v)) out.push(typeof v === 'string' ? v : JSON.stringify(v));
145 }
146 return out.sort((a, b) => b.length - a.length);
147 };
148
149 // config_snapshots and config_keys come from the service's migrations (configSchema()).
150 const q = {
151 row: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision = ?'),
152 active: db.prepare("SELECT * FROM config_snapshots WHERE namespace = ? AND state = 'active'"),
153 any: db.prepare('SELECT 1 AS x FROM config_snapshots WHERE namespace = ? LIMIT 1'),
154 max: db.prepare('SELECT COALESCE(MAX(revision), 0) AS n FROM config_snapshots WHERE namespace = ?'),
155 lastGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('active', 'superseded', 'rolled_back') ORDER BY revision DESC LIMIT 1"),
156 previousGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('superseded', 'rolled_back') AND revision < ? AND values_checksum != ? ORDER BY revision DESC LIMIT 1"),
157 history: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision < ? ORDER BY revision DESC LIMIT ?'),
158 insert: db.prepare(`INSERT INTO config_snapshots (namespace, revision, service, previous_revision, state, values_json, classification_json,
159 values_checksum, created_at, created_by, activated_at, activated_by, reason, copied_from, good)
160 VALUES (@namespace, @revision, @service, @previous_revision, @state, @values_json, @classification_json,
161 @values_checksum, @created_at, @created_by, @activated_at, @activated_by, @reason, @copied_from, @good)`),
162 setState: db.prepare('UPDATE config_snapshots SET state = ? WHERE namespace = ? AND revision = ?'),
163 activate: db.prepare("UPDATE config_snapshots SET state = 'active', activated_at = ?, activated_by = ?, good = ? WHERE namespace = ? AND revision = ?"),
164 good: db.prepare('UPDATE config_snapshots SET good = 1 WHERE namespace = ? AND revision = ?'),
165 reject: db.prepare("UPDATE config_snapshots SET state = 'rejected', error = ?, activated_at = NULL, activated_by = NULL, good = 0 WHERE namespace = ? AND revision = ?"),
166 prune: db.prepare("DELETE FROM config_snapshots WHERE namespace = ? AND revision <= ? AND state != 'active' AND revision != ?"),
167 reseal: db.prepare('UPDATE config_snapshots SET values_json = ?, classification_json = ?, values_checksum = ? WHERE namespace = ? AND revision = ?'),
168 all: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? ORDER BY revision'),
169 };
170 const tx = async (fn) => await db.tx(fn); // ambient: the store's statements inside join it
171
172 // ── the namespace's fingerprint key: made on first use, then read (a racing process keeps the first) ──
173 // With a KEK, config_keys holds that key wrapped under the current KEK (never the key); without one it is
174 // today's plain 32-byte key. The key itself never changes, so markers and checksums survive a KEK rotation.
175 let hmacKey = null; // loaded by init(): made on first use, then read (a racing process keeps the first)
176 async function loadKey() {
177 const read = db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?');
178 const insert = db.prepare('INSERT INTO config_keys (namespace, hmac_key, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING');
179 const conditional = db.prepare('UPDATE config_keys SET hmac_key = ? WHERE namespace = ? AND hmac_key = ?');
180 const unconditional = db.prepare('UPDATE config_keys SET hmac_key = ? WHERE namespace = ?');
181 const wrap = (key) => sealFingerprintKey(key, { service, namespace, kekId: keks.current, kek: keks.keys.get(keks.current) });
182 const open = (stored) => openFingerprintKey(stored, { service, namespace, keys: keks.keys });
183 let row = await read.get(namespace);
184 if (!row) {
185 const fresh = crypto.randomBytes(32);
186 await insert.run(namespace, keks ? wrap(fresh) : fresh, iso());
187 row = await read.get(namespace);
188 }
189 let stored = row && row.hmac_key ? Buffer.from(row.hmac_key) : null; // Buffer (pg) or Uint8Array (PGlite)
190 if (!stored) throw new Error(`createConfigStore: config_keys has no fingerprint key for ${namespace}`);
191 if (isWrappedFingerprintKey(stored)) {
192 if (!keks) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is sealed under a KEK, but no KEK is configured`);
193 let opened = open(stored);
194 if (!opened) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a valid wrapper`);
195 if (opened.kekId !== keks.current) {
196 const replacement = wrap(opened.key);
197 await conditional.run(replacement, namespace, stored);
198 let after = Buffer.from((await read.get(namespace)).hmac_key);
199 let next = isWrappedFingerprintKey(after) ? open(after) : null;
200 if (!next || next.kekId !== keks.current) {
201 // A row this process did not read (or a driver that did not match bytea): rewrap it outright.
202 await unconditional.run(replacement, namespace);
203 after = Buffer.from((await read.get(namespace)).hmac_key);
204 next = isWrappedFingerprintKey(after) ? open(after) : null;
205 }
206 if (!next) throw new Error(`createConfigStore: could not rewrap the fingerprint key of ${namespace} under ${keks.current}`);
207 opened = next;
208 }
209 hmacKey = opened.key;
210 return;
211 }
212 if (stored.length !== 32) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is neither a 32-byte key nor a KEK-wrapped key`);
213 if (!keks) { hmacKey = stored; return; }
214 // A KEK was just configured on an existing namespace: wrap the same key, so no marker or checksum moves.
215 let replacement = wrap(stored);
216 await conditional.run(replacement, namespace, stored);
217 let after = Buffer.from((await read.get(namespace)).hmac_key);
218 if (!isWrappedFingerprintKey(after)) {
219 // The conditional did not match: another process may have wrapped it (read again) or replaced
220 // the key, or the driver did not compare bytea. Wrap whichever 32-byte key is there now, once
221 // without the old-value guard, then fail closed.
222 if (after.length === 32) {
223 if (!after.equals(stored)) replacement = wrap(after);
224 await unconditional.run(replacement, namespace);
225 after = Buffer.from((await read.get(namespace)).hmac_key);
226 }
227 if (!isWrappedFingerprintKey(after)) throw new Error(`createConfigStore: could not seal the fingerprint key of ${namespace} under ${keks.current}`);
228 }
229 const opened = open(after);
230 if (!opened) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a valid wrapper`);
231 hmacKey = opened.key;
232 }
233 const fingerprint = (v) => crypto.createHmac('sha256', hmacKey).update(canonical(v)).digest('hex');
234 const markerFor = (v) => ({ redacted: true, fingerprint: fingerprint(v) });
235
236 /**
237 * Open every envelope of a stored values object with the bind each key's x-bind names. A secret that does not
238 * open (another bind, a missing KEK, a copied envelope) is locked: it stays a marker and never reaches get().
239 */
240 function materialize(stored) {
241 const base = {};
242 for (const [k, v] of Object.entries(stored)) if (!isEnvelope(v)) base[k] = v;
243 const opened = {};
244 const locked = new Set();
245 const meta = {};
246 const memo = new Map();
247 function valueOf(key, seen) {
248 if (pinKeys.has(key)) return clone(pins[key].value);
249 if (memo.has(key)) return memo.get(key);
250 if (has(opened, key)) return opened[key];
251 if (has(base, key)) return base[key];
252 if (has(stored, key)) {
253 const r = openKey(key, seen);
254 memo.set(key, r.value);
255 return r.value;
256 }
257 if (has(defaults, key)) return defaults[key];
258 return undefined;
259 }
260 function bindOf(key, seen) {
261 const target = binds[key];
262 if (!target) return '';
263 return bindText(valueOf(target, new Set(seen)));
264 }
265 function openKey(key, seen) {
266 if (!keks) throw Object.assign(new Error('no KEK is configured'), { code: 'config.secret_unreadable' });
267 if (seen.has(key)) throw Object.assign(new Error('x-bind cycle'), { code: 'config.secret_unreadable' });
268 const nextSeen = new Set(seen).add(key);
269 const bind = bindOf(key, nextSeen);
270 const { value, meta: m } = openEnvelope(stored[key], { service, namespace, key, bind, keys: keks.keys });
271 return { value, meta: m };
272 }
273 for (const [k, v] of Object.entries(stored)) {
274 if (!isEnvelope(v)) continue;
275 try {
276 const r = openKey(k, new Set());
277 opened[k] = r.value;
278 meta[k] = r.meta;
279 } catch { locked.add(k); }
280 }
281 const effective = { ...clone(defaults), ...clone(base), ...clone(opened) };
282 for (const [k, p] of Object.entries(pins)) effective[k] = clone(p.value);
283 for (const k of locked) if (!pinKeys.has(k)) delete effective[k];
284 return { opened, locked, effective, meta, stored };
285 }
286
287 function toSnapshot(row) {
288 const values = JSON.parse(row.values_json);
289 const classification = classesFor(values, JSON.parse(row.classification_json));
290 const shown = redact(values, classification);
291 return {
292 service: row.service,
293 namespace: row.namespace,
294 revision: row.revision,
295 previous_revision: row.previous_revision == null ? null : row.previous_revision,
296 state: row.state,
297 values: shown,
298 classification,
299 checksum: sha256(canonical(shown)),
300 created_at: row.created_at,
301 created_by: JSON.parse(row.created_by),
302 activated_at: row.activated_at || null,
303 activated_by: row.activated_by ? JSON.parse(row.activated_by) : null,
304 reason: row.reason == null ? null : row.reason,
305 error: row.error == null ? null : row.error,
306 copied_from: row.copied_from == null ? null : row.copied_from,
307 };
308 }
309
310 // ── in-memory state ──
311 let current = null; // { revision, checksum, stored, opened, locked, effective, classification }
312 function load(row) {
313 const stored = JSON.parse(row.values_json);
314 const classification = classesFor(stored, JSON.parse(row.classification_json));
315 const mat = materialize(stored);
316 for (const k of mat.locked) log.warn(`${tag}: secret ${k} is set but does not open (it must be entered again for the current value)`);
317 return {
318 revision: row.revision,
319 checksum: row.values_checksum,
320 stored,
321 values: stored,
322 opened: mat.opened,
323 locked: mat.locked,
324 envelopeMeta: mat.meta,
325 effective: deepFreeze(mat.effective),
326 classification,
327 };
328 }
329 const effectiveNow = () => (current ? current.effective : emptyEffective);
330
331 // ── validation ──
332 function problems(values) {
333 const errors = [];
334 if (!isPlainObject(values)) return [{ path: '', message: 'must be an object' }];
335 for (const k of Object.keys(values)) if (!validKey(k)) errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' });
336 for (const [k, v] of Object.entries(values)) if (looksRedacted(v)) errors.push({ path: pointer(k), message: 'is an object with a redacted member, which only a redaction marker may be' });
337 jsonErrors(values, '', errors);
338 if (errors.length) return errors;
339 const size = Buffer.byteLength(canonical(values));
340 if (size > maxBytes) return [{ path: '', message: `is ${size} bytes; at most ${maxBytes}` }];
341 const mat = materialize(values);
342 const skip = new Set([...mat.locked].map((k) => pointer(k)));
343 if (schema !== null) checkSchema(schema, mat.effective, '', errors, skip);
344 if (validate) {
345 let r;
346 try { r = validate(mat.effective); } catch (err) { r = [{ path: '', message: errText(err) }]; }
347 if (r && typeof r.then === 'function') throw new TypeError('createConfigStore: validate must be synchronous (check asynchronously in onActivate)');
348 errors.push(...validateResult(r));
349 }
350 const secrets = [...secretStrings(values, current && current.opened, current ? current.effective : emptyEffective), ...plainStrings(mat.opened)].sort((a, b) => b.length - a.length);
351 return errors.map((e) => ({ path: e.path, message: scrub(e.message, secrets) }));
352 }
353 const invalid = (errors, revision) => new ConfigError('config.invalid', `invalid configuration: ${summarize(errors)}`.slice(0, ERROR_MAX), { status: 422, errors, revision });
354
355 /** values (+ merge/unset) as the complete stored form a new revision would hold. A marker keeps the value it stands for. */
356 function resolve(input, { merge = false, unset = [] } = {}) {
357 if (!isPlainObject(input)) throw invalid([{ path: '', message: 'must be an object' }]);
358 if (!Array.isArray(unset) || !unset.every((k) => typeof k === 'string')) throw invalid([{ path: '', message: 'unset must be a list of keys' }]);
359 for (const k of [...Object.keys(input), ...unset]) {
360 if (pinKeys.has(k)) throw new ConfigError('config.pinned_in_env', `${k} is pinned by ${pins[k].env} and is read-only`, { status: 409 });
361 }
362 const base = merge && current ? clone(current.stored) : {};
363 const errors = [];
364 for (const [k, v] of Object.entries(input)) {
365 if (!validKey(k)) { errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }); continue; }
366 if (!isMarker(v)) { base[k] = v; continue; }
367 if (current && has(current.stored, k) && sameHex(storedFingerprint(current.stored[k]), v.fingerprint)) base[k] = clone(current.stored[k]);
368 else if (has(defaults, k) && sameHex(fingerprint(defaults[k]), v.fingerprint)) delete base[k];
369 else errors.push({ path: pointer(k), message: 'is a redacted value that is not the current one; send the value itself' });
370 }
371 if (errors.length) throw invalid(errors);
372 for (const k of unset) delete base[k];
373 return base;
374 }
375
376 /** Seal the new plaintext secrets of a complete values object; envelopes already sealed are carried unchanged. */
377 function sealValues(values) {
378 const classification = classesFor(values, current ? current.classification : {});
379 const mat = materialize(values);
380 const out = {};
381 for (const [k, v] of Object.entries(values)) {
382 if (classification[k] !== 'secret' || !keks || isEnvelope(v)) { out[k] = v; continue; }
383 const target = binds[k];
384 let bindVal;
385 if (target) {
386 if (has(values, target) && isEnvelope(values[target])) throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 });
387 if (pinKeys.has(target)) bindVal = pins[target].value;
388 else if (has(mat.effective, target)) bindVal = mat.effective[target];
389 else if (has(defaults, target)) bindVal = defaults[target];
390 if (bindVal !== undefined && classOfValue(target, bindVal) === 'secret') throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 });
391 }
392 const bind = bindText(bindVal);
393 out[k] = sealEnvelope({
394 service, namespace, key: k, value: v, bind,
395 fingerprint: fingerprint(v), hint: hintOf(v), kekId: keks.current, kek: keks.keys.get(keks.current),
396 });
397 }
398 return { values: out, classification };
399 }
400
401 function changedKeys(before, after) {
402 const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
403 return [...keys].filter((k) => has(before, k) !== has(after, k) || !same(before[k], after[k])).sort();
404 }
405 const listKeys = (keys) => (keys.length ? keys.slice(0, 20).join(', ') + (keys.length > 20 ? ` (+${keys.length - 20})` : '') : 'nothing');
406 const who = (a) => `${a.type}:${a.id}`;
407
408 function actorOf(actor) {
409 if (actor == null) return self;
410 const ref = subjectRef(actor);
411 if (!ref) throw new ConfigError('config.bad_actor', 'actor must be a subject reference ({ type, id })', { status: 400 });
412 return ref;
413 }
414 function reasonOf(reason) {
415 if (reason == null || reason === '') return null;
416 if (typeof reason !== 'string') throw invalid([{ path: '/reason', message: 'must be a string' }]);
417 return reason.slice(0, REASON_MAX);
418 }
419
420 async function insertRow(values, { state, actor, reason, previous, copiedFrom = null, activatedAt = null, good = 0 }) {
421 const sealed = sealValues(values);
422 const checksum = sha256(canonical(redact(sealed.values, sealed.classification)));
423 let row;
424 await tx(async () => {
425 const revision = (await q.max.get(namespace)).n + 1;
426 await q.insert.run({
427 namespace, revision, service, previous_revision: previous, state,
428 values_json: JSON.stringify(sealed.values), classification_json: JSON.stringify(sealed.classification),
429 values_checksum: checksum, created_at: iso(), created_by: JSON.stringify(actor),
430 activated_at: activatedAt, activated_by: activatedAt ? JSON.stringify(actor) : null,
431 reason, copied_from: copiedFrom, good,
432 });
433 row = await q.row.get(namespace, revision);
434 });
435 return row;
436 }
437
438 async function prune() {
439 const cutoff = (await q.max.get(namespace)).n - keep;
440 if (cutoff < 1) return;
441 const good = await q.lastGood.get(namespace);
442 await q.prune.run(namespace, cutoff, good ? good.revision : -1);
443 }
444
445 // ── boot: load the active revision, recover an interrupted activation, or seed revision 1 ──
446 async function seed(values, reason, { strict }) {
447 if (!strict) {
448 // A legacy key the contract cannot carry is left behind, by name, rather than imported.
449 const bad = Object.keys(values).filter((k) => !validKey(k) || looksRedacted(values[k]));
450 if (bad.length) {
451 log.warn(`${tag}: ${bad.length} legacy key(s) are not valid config keys and were not imported: ${bad.slice(0, 5).map((k) => JSON.stringify(k.slice(0, 64))).join(', ')}`);
452 values = Object.fromEntries(Object.entries(values).filter(([k]) => validKey(k)));
453 }
454 }
455 const errors = problems(values);
456 if (errors.length && strict) throw new ConfigError('config.invalid_defaults', `${namespace} defaults do not validate: ${summarize(errors)}`, { status: 500, errors });
457 if (errors.length) log.warn(`${tag}: the imported values do not validate (${summarize(errors).slice(0, 300)}); imported as they are`);
458 const row = await insertRow(clone(values), { state: 'active', actor: self, reason, previous: null, activatedAt: iso(), good: 1 });
459 current = load(row);
460 log.info(`${tag}: revision ${row.revision} ${reason} (${Object.keys(values).length} keys)`);
461 return toSnapshot(row);
462 }
463
464 async function importLegacy(legacy, { reason = 'imported from the legacy source' } = {}) {
465 if (await q.any.get(namespace)) return null;
466 let v = typeof legacy === 'function' ? legacy() : legacy;
467 if (Array.isArray(v)) v = fromRows(v);
468 if (v == null) return null;
469 if (!isPlainObject(v)) throw new TypeError('import: legacy must give an object or key-value rows');
470 return await seed(v, reasonOf(reason) || 'imported from the legacy source', { strict: false });
471 }
472
473 async function boot() {
474 await loadKey();
475 let row = await q.active.get(namespace);
476 if (row && !row.good) {
477 // The process stopped between the switch and the end of onActivate: that revision never
478 // proved itself, so the last-known-good one is active again.
479 const good = await q.lastGood.get(namespace);
480 await tx(async () => {
481 await q.reject.run('activation interrupted: the service stopped before onActivate finished', namespace, row.revision);
482 if (good) await q.setState.run('active', namespace, good.revision);
483 });
484 log.warn(`${tag}: revision ${row.revision} was interrupted while activating; ${good ? `revision ${good.revision} restored` : 'no earlier revision to restore'}`);
485 row = good ? await q.row.get(namespace, good.revision) : null;
486 }
487 if (row) {
488 current = load(row);
489 const errors = problems(current.values);
490 if (errors.length) log.warn(`${tag}: active revision ${row.revision} does not validate under this release (${summarize(errors).slice(0, 300)}); serving it as it is`);
491 return;
492 }
493 if (await q.any.get(namespace)) return;
494 if (opts.legacy !== undefined && await importLegacy(opts.legacy)) return;
495 if (opts.defaults != null) await seed(defaults, 'seeded from defaults', { strict: true });
496 }
497
498 // ── activation (serialized per store) ──
499 let queue = Promise.resolve();
500 function serialized(fn) {
501 const run = queue.then(fn, fn);
502 queue = run.catch(() => {});
503 return run;
504 }
505
506 async function notifyChange(keys, actor, revision) {
507 if (!onChange) return;
508 try { await onChange({ namespace, keys, actor, revision }); }
509 catch (err) { log.warn(`${tag}: onChange failed: ${scrub(errText(err), [...secretStrings(current && current.values, current && current.effective), ...plainStrings(current && current.opened)])}`); }
510 }
511
512 async function activateNow(revision, { actor, rollingBack = false } = {}) {
513 const by = actorOf(actor);
514 const row = await q.row.get(namespace, revision);
515 if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${revision}`, { status: 404, revision });
516 if (row.state !== 'proposed') throw new ConfigError('config.not_proposed', `revision ${revision} is ${row.state}, not proposed`, { status: 409, revision });
517 const values = JSON.parse(row.values_json);
518 const errors = problems(values);
519 if (errors.length) {
520 await q.reject.run(summarize(errors).slice(0, ERROR_MAX), namespace, revision);
521 log.warn(`${tag}: revision ${revision} rejected: ${summarize(errors).slice(0, 300)}`);
522 throw invalid(errors, revision);
523 }
524 const prev = current;
525 const at = iso();
526 await tx(async () => {
527 const dbActive = await q.active.get(namespace);
528 const activeRev = dbActive ? dbActive.revision : null;
529 if ((row.previous_revision == null ? null : row.previous_revision) !== activeRev) {
530 throw new ConfigError('config.stale', `revision ${revision} was proposed on ${row.previous_revision ? `revision ${row.previous_revision}` : 'an empty namespace'}, but ${activeRev ? `revision ${activeRev}` : 'nothing'} is active now; propose it again`, { status: 409, revision });
531 }
532 if (dbActive) await q.setState.run(rollingBack ? 'rolled_back' : 'superseded', namespace, dbActive.revision);
533 await q.activate.run(at, JSON.stringify(by), onActivate ? 0 : 1, namespace, revision);
534 });
535 current = load(await q.row.get(namespace, revision));
536 const changed = changedKeys(prev ? prev.effective : emptyEffective, current.effective);
537 if (onActivate) {
538 const next = current;
539 try {
540 await onActivate(next.effective, prev ? prev.effective : null, { namespace, revision, previous_revision: prev ? prev.revision : null, restoring: false });
541 } catch (err) {
542 const message = scrub(`onActivate: ${errText(err)}`, [...secretStrings(next.values, prev && prev.values, next.effective, prev && prev.effective), ...plainStrings(next.opened, prev && prev.opened)]);
543 await tx(async () => {
544 await q.reject.run(message, namespace, revision);
545 if (prev) await q.setState.run('active', namespace, prev.revision);
546 });
547 current = prev;
548 log.warn(`${tag}: revision ${revision} rejected (${message}); ${prev ? `revision ${prev.revision} restored` : 'nothing was active before'}`);
549 if (prev) {
550 try {
551 await onActivate(prev.effective, next.effective, { namespace, revision: prev.revision, previous_revision: revision, restoring: true });
552 } catch (again) {
553 log.error(`${tag}: re-applying revision ${prev.revision} failed too: ${scrub(errText(again), [...secretStrings(next.values, prev.values, next.effective, prev.effective), ...plainStrings(next.opened, prev.opened)])}`);
554 }
555 }
556 throw new ConfigError('config.activation_failed', message, { status: 422, revision, cause: err });
557 }
558 await q.good.run(namespace, revision);
559 }
560 await notifyChange(changed, by, revision);
561 await prune();
562 log.info(`${tag}: revision ${revision} active${prev ? ` (was ${prev.revision})` : ''} by ${who(by)}; changed ${listKeys(changed)}`);
563 return toSnapshot(await q.row.get(namespace, revision));
564 }
565
566 async function propose(values, o = {}) {
567 const actor = actorOf(o.actor);
568 const reason = reasonOf(o.reason);
569 const next = resolve(values, o);
570 const errors = problems(next);
571 if (errors.length) throw invalid(errors);
572 const row = await insertRow(next, { state: 'proposed', actor, reason, previous: current ? current.revision : null });
573 await prune();
574 log.info(`${tag}: revision ${row.revision} proposed by ${who(actor)}; changes ${listKeys(changedKeys(effectiveNow(), materialize(next).effective))}`);
575 return toSnapshot(row);
576 }
577
578 async function rollbackTarget(to) {
579 if (!current) throw new ConfigError('config.nothing_active', `${namespace} has no active revision to roll back`, { status: 409 });
580 if (to == null) {
581 const r = await q.previousGood.get(namespace, current.revision, current.checksum);
582 if (!r) throw new ConfigError('config.no_previous', `${namespace} has no earlier good revision to roll back to`, { status: 409 });
583 return r.revision;
584 }
585 const rev = Number(to);
586 const row = Number.isInteger(rev) ? await q.row.get(namespace, rev) : null;
587 if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${to}`, { status: 404, revision: Number.isInteger(rev) ? rev : null });
588 if (row.revision === current.revision) throw new ConfigError('config.already_active', `revision ${rev} is the active one`, { status: 409, revision: rev });
589 if (!row.good) throw new ConfigError('config.not_good', `revision ${rev} never activated successfully (${row.state})`, { status: 409, revision: rev });
590 return rev;
591 }
592
593 /** The marker of one secret-class key: the active revision's row, or an env pin / default that no row holds. */
594 async function secretMarker(key) {
595 const row = await q.active.get(namespace);
596 const values = row ? JSON.parse(row.values_json) : {};
597 let value;
598 let fromRow = false;
599 if (pinKeys.has(key)) value = pins[key].value;
600 else if (has(values, key)) { value = values[key]; fromRow = true; }
601 else if (has(defaults, key)) value = defaults[key];
602 else return null;
603 const classification = fromRow ? classesFor(values, JSON.parse(row.classification_json)) : { [key]: classOfValue(key, value) };
604 if (classification[key] !== 'secret') return null;
605 const envelope = isEnvelope(value) ? parseEnvelope(value) : null;
606 const readable = !envelope && current && has(current.effective, key) ? current.effective[key] : undefined;
607 const plain = readable !== undefined ? readable : value;
608 const fp = envelope ? envelope.fingerprint : fingerprint(plain);
609 let boundTo = envelope ? envelope.bind : null;
610 if (!envelope) {
611 const target = binds[key];
612 const mat = materialize(values);
613 const v = target && has(mat.effective, target) ? mat.effective[target] : undefined;
614 boundTo = target && !(v !== undefined && classOfValue(target, v) === 'secret') ? bindText(v) : '';
615 }
616 const activatedAt = fromRow ? (row.activated_at || row.created_at) : null;
617 const setBy = fromRow ? (row.activated_by ? JSON.parse(row.activated_by) : JSON.parse(row.created_by)) : null;
618 return {
619 redacted: true,
620 fingerprint: fp,
621 hint: envelope ? (envelope.hint || null) : hintOf(plain),
622 set_at: activatedAt || null,
623 set_by: setBy,
624 bound_to: boundTo || null,
625 };
626 }
627
628 async function resealNow() {
629 if (!keks) throw new ConfigError('config.no_kek', 'reseal needs a KEK (opts.keks or OV_CONFIG_KEKS)', { status: 409 });
630 const rows = await q.all.all(namespace);
631 const report = { namespace, kek: keks.current, revisions: rows.length, resealed: 0, current: 0, plaintext: 0, locked: 0, errors: [] };
632 const updates = [];
633 for (const row of rows) {
634 const values = JSON.parse(row.values_json);
635 const storedCls = JSON.parse(row.classification_json);
636 const cls = classesFor(values, storedCls);
637 const mat = materialize(values);
638 const next = {};
639 let changed = false;
640 for (const [k, v] of Object.entries(values)) {
641 if (cls[k] !== 'secret') { next[k] = v; continue; }
642 try {
643 const target = binds[k];
644 const bindVal = target ? (pinKeys.has(target) ? pins[target].value : (has(mat.effective, target) ? mat.effective[target] : undefined)) : undefined;
645 if (target && has(values, target) && isEnvelope(values[target])) throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 });
646 if (target && bindVal !== undefined && classOfValue(target, bindVal) === 'secret') throw new ConfigError('config.bind_secret', `the x-bind target of ${k} is secret-class`, { status: 422 });
647 const bind = bindText(bindVal);
648 if (isEnvelope(v)) {
649 const p = parseEnvelope(v);
650 const { value } = openEnvelope(v, { service, namespace, key: k, bind, keys: keks.keys });
651 const fpNow = fingerprint(value);
652 if (p.kekId === keks.current && fpNow === p.fingerprint) { next[k] = v; report.current++; continue; }
653 next[k] = sealEnvelope({
654 service, namespace, key: k, value, bind,
655 fingerprint: fpNow, hint: p.hint || hintOf(value),
656 kekId: keks.current, kek: keks.keys.get(keks.current),
657 });
658 changed = true;
659 report.resealed++;
660 } else {
661 next[k] = sealEnvelope({
662 service, namespace, key: k, value: v, bind,
663 fingerprint: fingerprint(v), hint: hintOf(v), kekId: keks.current, kek: keks.keys.get(keks.current),
664 });
665 changed = true;
666 report.plaintext++;
667 }
668 } catch (err) {
669 next[k] = v;
670 report.locked++;
671 report.errors.push({ revision: row.revision, key: k, reason: scrub(errText(err)) });
672 }
673 }
674 const nextJson = JSON.stringify(next);
675 const clsJson = JSON.stringify(cls);
676 if (changed || nextJson !== row.values_json || clsJson !== row.classification_json) {
677 updates.push({ revision: row.revision, values_json: nextJson, classification_json: clsJson, values_checksum: sha256(canonical(redact(next, cls))) });
678 }
679 }
680 if (updates.length) {
681 await tx(async () => {
682 for (const u of updates) await q.reseal.run(u.values_json, u.classification_json, u.values_checksum, namespace, u.revision);
683 });
684 }
685 if (current) current = load(await q.row.get(namespace, current.revision));
686 if (report.locked) log.warn(`${tag}: reseal left ${report.locked} secret(s) locked (${report.errors.slice(0, 3).map((e) => `${e.key}@${e.revision}`).join(', ')})`);
687 log.info(`${tag}: reseal to ${keks.current}: ${report.resealed} under an old KEK, ${report.plaintext} plaintext, ${report.current} current, ${report.locked} locked`);
688 return report;
689 }
690
691 /** The summary's schema: a clone whose secret-class properties never carry a default, examples, enum or const. */
692 function schemaForSummary() {
693 const isSecret = (k) => {
694 if (current && current.classification[k] === 'secret') return true;
695 if (pinKeys.has(k) && classOf(k, pins[k].value) === 'secret') return true;
696 if (classOf(k, undefined) === 'secret') return true;
697 const p = schema && schema.properties && schema.properties[k];
698 return !!(isPlainObject(p) && has(p, 'default') && classOf(k, p.default) === 'secret');
699 };
700 return summarySchema(schema, isSecret);
701 }
702
703 const store = {
704 service,
705 namespace,
706 /** Whether secret-class values are sealed with a KEK (D11). */
707 get sealed() { return !!keks; },
708 /** The current KEK id, or null when unsealed. */
709 get kek() { return keks ? keks.current : null; },
710 /** The active values over the defaults and env pins (frozen), or one of them. Never reads the database. */
711 get(key) { const e = effectiveNow(); return key === undefined ? e : e[key]; },
712 /** The active revision number, or null. */
713 revision() { return current ? current.revision : null; },
714 propose,
715 activate(revision, o = {}) { return serialized(async () => await activateNow(Number(revision), { actor: o.actor })); },
716 /** propose + activate, in turn with every other activation of this store. */
717 apply(values, o = {}) { return serialized(async () => await activateNow((await propose(values, o)).revision, { actor: o.actor })); },
718 /** A new revision copying the previous good one (or `to`), activated; the one it replaces becomes rolled_back. */
719 rollback(o = {}) {
720 return serialized(async () => {
721 const actor = actorOf(o.actor);
722 const target = await rollbackTarget(o.to);
723 const values = JSON.parse((await q.row.get(namespace, target)).values_json);
724 const row = await insertRow(values, { state: 'proposed', actor, reason: reasonOf(o.reason) || `rollback to revision ${target}`, previous: current.revision, copiedFrom: target });
725 log.info(`${tag}: revision ${row.revision} rolls back to revision ${target} (by ${who(actor)})`);
726 return await activateNow(row.revision, { actor, rollingBack: true });
727 });
728 },
729 rollbackTarget,
730 async lastKnownGood() { const r = await q.lastGood.get(namespace); return r ? toSnapshot(await q.row.get(namespace, r.revision)) : null; },
731 async snapshot(revision) { const row = await q.row.get(namespace, Number(revision)); return row ? toSnapshot(row) : null; },
732 async history({ limit = 20, before = null } = {}) {
733 const n = Math.min(200, Math.max(1, Math.floor(Number(limit)) || 20));
734 const b = before == null || before === '' ? Number.MAX_SAFE_INTEGER : Math.floor(Number(before));
735 return (await q.history.all(namespace, Number.isFinite(b) ? b : Number.MAX_SAFE_INTEGER, n)).map(toSnapshot);
736 },
737 /** Keys whose effective value would change: for new values (+ merge/unset), or for a revision's. */
738 async changes(values, o = {}) {
739 if (o.revision != null) {
740 const row = await q.row.get(namespace, Number(o.revision));
741 const stored = row ? JSON.parse(row.values_json) : {};
742 return changedKeys(effectiveNow(), materialize(stored).effective);
743 }
744 const next = resolve(values, o);
745 return changedKeys(effectiveNow(), materialize(next).effective);
746 },
747 /** The classification a change would give its keys (the active one for keys it removes). */
748 async classes(values, o = {}) {
749 let next;
750 if (o.revision != null) {
751 const row = await q.row.get(namespace, Number(o.revision));
752 next = row ? JSON.parse(row.values_json) : {};
753 } else next = resolve(values, o);
754 const out = classesFor(next, current ? current.classification : {});
755 if (current) for (const k of Object.keys(current.classification)) if (!has(out, k)) out[k] = current.classification[k];
756 return out;
757 },
758 isDanger(key) { return dangerKeys.has(key); },
759 isPinned(key) { return pinKeys.has(key); },
760 /** The classification policy for one value (an envelope is secret). */
761 classify(key, value) { return classOfValue(key, value); },
762 /** The marker of one secret-class key of the active revision, or null. */
763 secretMarker,
764 /** Write one secret through the write-only path: a new revision holding its envelope. Returns the marker. */
765 setSecret(key, value, o = {}) {
766 return serialized(async () => {
767 if (typeof key !== 'string' || !validKey(key)) throw invalid([{ path: pointer(key), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }]);
768 if (pinKeys.has(key)) throw new ConfigError('config.pinned_in_env', `${key} is pinned by ${pins[key].env} and is read-only`, { status: 409 });
769 if (classOfValue(key, value) !== 'secret') throw new ConfigError('config.not_secret', `${key} is not a secret-class key`, { status: 422 });
770 if (value === undefined) throw invalid([{ path: pointer(key), message: 'is required' }]);
771 if (looksRedacted(value)) throw invalid([{ path: pointer(key), message: 'a secret value cannot be a redaction marker' }]);
772 const actor = actorOf(o.actor);
773 const next = current ? clone(current.stored) : {};
774 next[key] = value;
775 const errors = problems(next);
776 if (errors.length) throw invalid(errors);
777 const row = await insertRow(next, { state: 'proposed', actor, reason: reasonOf(o.reason) || 'secret set', previous: current ? current.revision : null });
778 await activateNow(row.revision, { actor });
779 return await secretMarker(key);
780 });
781 },
782 /** Remove one secret through the write-only path. Returns the marker of the removed (empty) state. */
783 deleteSecret(key, o = {}) {
784 return serialized(async () => {
785 if (typeof key !== 'string' || !validKey(key)) throw invalid([{ path: pointer(key), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }]);
786 if (pinKeys.has(key)) throw new ConfigError('config.pinned_in_env', `${key} is pinned by ${pins[key].env} and is read-only`, { status: 409 });
787 if (!current || !has(current.stored, key)) throw new ConfigError('config.secret_not_set', `${key} is not set`, { status: 404 });
788 if (current.classification[key] !== 'secret') throw new ConfigError('config.not_secret', `${key} is not a secret-class key`, { status: 422 });
789 const actor = actorOf(o.actor);
790 const next = clone(current.stored);
791 delete next[key];
792 const errors = problems(next);
793 if (errors.length) throw invalid(errors);
794 const row = await insertRow(next, { state: 'proposed', actor, reason: reasonOf(o.reason) || 'secret deleted', previous: current.revision });
795 await activateNow(row.revision, { actor });
796 return { redacted: true, fingerprint: null, hint: null, set_at: null, set_by: actor, bound_to: null };
797 });
798 },
799 /** Move every plaintext secret and every envelope under an older KEK to the current KEK, revision by revision. */
800 reseal() { return serialized(resealNow); },
801 /** What /api/admin/config shows: the active revision, its effective values redacted, the schema, pins and state. */
802 async summary() {
803 const values = {};
804 for (const [k, v] of Object.entries(current ? current.stored : {})) values[k] = clone(v);
805 for (const k of Object.keys(defaults)) if (!has(values, k)) values[k] = clone(defaults[k]);
806 for (const [k, p] of Object.entries(pins)) values[k] = clone(p.value);
807 const classification = classesFor(values, current ? current.classification : {});
808 const shown = redact(values, classification);
809 // Every property the schema names is classified too, set or not (4.125.0): a form then knows a key is a
810 // secret before anyone has entered it, and offers the write-only route instead of a plain field.
811 if (schema && isPlainObject(schema.properties)) {
812 for (const k of Object.keys(schema.properties)) if (!has(classification, k)) classification[k] = classOf(k, undefined);
813 }
814 const secrets = {};
815 for (const k of Object.keys(classification)) {
816 if (classification[k] !== 'secret' || !has(values, k)) continue;
817 const m = await store.secretMarker(k);
818 if (m) secrets[k] = m;
819 }
820 return {
821 service, namespace,
822 revision: current ? current.revision : null,
823 values: shown,
824 classification,
825 sealed: !!keks,
826 kek: keks ? keks.current : null,
827 pins: Object.fromEntries(Object.entries(pins).map(([k, p]) => [k, { env: p.env, pinned: true }])),
828 secrets,
829 schema: schemaForSummary(),
830 active: current ? await store.snapshot(current.revision) : null,
831 last_known_good: await store.lastKnownGood(),
832 };
833 },
834 /** Seed revision 1 from a legacy source when the namespace has no revisions yet (else null). */
835 import: importLegacy,
836 /** Re-read the active revision (another process changed it). onActivate is not run. */
837 async reload() {
838 const row = await q.active.get(namespace);
839 current = row ? load(row) : null;
840 return store.revision();
841 },
842 };
843 await boot();
844 return store;
845}
846
847// ── /api/admin/config ───────────────────────────────────────────
848
849const TITLES = { 400: 'Bad Request', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', 413: 'Payload Too Large', 422: 'Unprocessable Content', 500: 'Internal Server Error' };
850
851module.exports = { createPgConfigStore, configSchema };
852