openvibers/openvibe.shared config-core.js
config-core.js
| 1 | ; |
| 2 | // Shared validation, redaction and row conversion for the PostgreSQL config store. |
| 3 | const crypto = require('crypto'); |
| 4 | |
| 5 | const CLASSES = ['public', 'internal', 'secret']; |
| 6 | const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/; |
| 7 | const NAMESPACE_RE = /^[a-z][a-z0-9-]{1,39}(\.[a-z0-9_-]+)+$/; |
| 8 | const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/; |
| 9 | const RESERVED = new Set(['__proto__', 'constructor', 'prototype']); |
| 10 | const validKey = (k) => KEY_RE.test(k) && !RESERVED.has(k); |
| 11 | const 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 | }; |
| 16 | const PREFIXED = { usr_: 'user', gst_: 'guest', app_: 'app', mod_: 'mod' }; |
| 17 | const REASON_MAX = 500; |
| 18 | const ERROR_MAX = 2000; |
| 19 | |
| 20 | class 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. */ |
| 34 | function 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 | } |
| 39 | const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex'); |
| 40 | const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v)); |
| 41 | const pointer = (k) => `/${String(k).replace(/~/g, '~0').replace(/\//g, '~1')}`; |
| 42 | const clone = (v) => JSON.parse(JSON.stringify(v)); |
| 43 | const same = (a, b) => canonical(a) === canonical(b); |
| 44 | const has = (o, k) => o != null && Object.prototype.hasOwnProperty.call(o, k); |
| 45 | |
| 46 | function 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. */ |
| 52 | function 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 | |
| 61 | const 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). */ |
| 63 | const looksRedacted = (v) => isPlainObject(v) && has(v, 'redacted'); |
| 64 | const 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 | |
| 73 | const KEK_ID = /^[a-z0-9][a-z0-9_-]{0,31}$/; |
| 74 | const B64_STD = /^[A-Za-z0-9+/]+={0,2}$/; |
| 75 | const B64_URL = /^[A-Za-z0-9_-]*$/; |
| 76 | const IV_BYTES = 12; |
| 77 | const TAG_BYTES = 16; |
| 78 | const KEY_BYTES = 32; |
| 79 | |
| 80 | function b64(buf) { return Buffer.from(buf).toString('base64'); } |
| 81 | function 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 | } |
| 87 | const b64url = (s) => Buffer.from(String(s), 'utf8').toString('base64url'); |
| 88 | function 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 | } |
| 94 | const aadOf = ({ service, namespace, key, bind }) => Buffer.from(`${service}|${namespace}|${key}|${bind == null ? '' : String(bind)}`, 'utf8'); |
| 95 | const 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). */ |
| 97 | function 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". */ |
| 100 | function 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 | } |
| 116 | const isEnvelope = (v) => parseEnvelope(v) !== null; |
| 117 | |
| 118 | /** Seal one secret-class value. `fingerprint` is the namespace HMAC of the plaintext; `kek` is 32 bytes. */ |
| 119 | function 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 | |
| 137 | const 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. |
| 144 | const FP_WRAP_PREFIX = 'ovfp.v1.'; |
| 145 | const fpAad = ({ service, namespace }) => Buffer.from(`openvibe-config-fingerprint|${service}|${namespace}`, 'utf8'); |
| 146 | const isWrappedFingerprintKey = (stored) => stored != null && Buffer.from(stored).toString('utf8').startsWith(FP_WRAP_PREFIX); |
| 147 | |
| 148 | function 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. */ |
| 161 | function 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. */ |
| 187 | function 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 | */ |
| 214 | function 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 | |
| 263 | const 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']); |
| 266 | const ANNOTATIONS = new Set(['$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'format']); |
| 267 | const TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']; |
| 268 | const UNITS = new Set(['nano_usd', 'ms', 'bps', 'seconds']); |
| 269 | const ENV_NAME = /^[A-Z][A-Z0-9_]{0,127}$/; |
| 270 | const 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. |
| 275 | const 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 | }); |
| 287 | const OWNER_INPUT_MEMBERS = new Set(['group', 'unlocks', 'get_it_at', 'get_it_how', 'test']); |
| 288 | const ENUM_LABEL_MEMBERS = new Set(['label', 'description']); |
| 289 | const 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"). */ |
| 291 | const enumKey = (v) => (typeof v === 'string' ? v : JSON.stringify(v)); |
| 292 | function 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 | } |
| 299 | const 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. */ |
| 302 | function 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 | */ |
| 316 | function 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). |
| 355 | const 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 | */ |
| 362 | function 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). */ |
| 382 | function 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 | */ |
| 399 | function 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 | |
| 436 | function 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. */ |
| 459 | function 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 | */ |
| 476 | function 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 | |
| 505 | function typeOf(v) { |
| 506 | if (v === null) return 'null'; |
| 507 | if (Array.isArray(v)) return 'array'; |
| 508 | return typeof v; |
| 509 | } |
| 510 | function 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. */ |
| 517 | function 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). */ |
| 560 | function 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_…). */ |
| 576 | function 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. */ |
| 589 | function 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 | |
| 597 | const errText = (err) => (err instanceof Error ? err.message : String(err)); |
| 598 | const summarize = (errors) => errors.map((e) => (e.path ? `${e.path} ${e.message}` : e.message)).join('; '); |
| 599 | |
| 600 | function 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 | */ |
| 613 | function 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 | |
| 629 | module.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 |