openvibers/openvibe.shared Commits
4.125.0: Setup annotations, lintSchema and the config test hook (#310)
Changes
@@@ -1,5 +1,13 @@# Changelog++## 4.125.0 — 2026-11-07++++- **The owner's Setup page and clear settings** (`openvibe-shared/config`; OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07). New schema annotations, passed through to the summary: `x-owner-input { group, unlocks, get_it_at, get_it_how, test }` marks a field for Network's Setup tab (the groups are `config.OWNER_INPUT_GROUPS`), `x-required-for` makes an unset field **Needed**, `x-enum-labels` gives every option of a choice words and a line, `x-advanced` folds a field under a counted "Advanced settings (N)", and `x-example` is a placeholder. A secret-class property loses `x-example` and `x-enum-labels` in the summary, as it already lost its default, examples and enum.++- **A wrong Setup annotation never refuses the store.** `x-owner-input`, `x-required-for`, `x-enum-labels`, `x-advanced` and `x-example` only change how a field is presented, so a wrong shape is left out of the summary with one warning (and `lintSchema` names it) instead of taking a service down at boot after a pin bump. A group given by its display name (`Voices`) is read as its id (`voices`), and `lintSchema` asks for the id. The D11 annotations (`x-env`, `x-danger`, `x-bind`, `x-unit`) still refuse the store: they decide where a value comes from and how it is guarded.++- **`config.lintSchema(schema)`** for each service's own test: it lists a field without a title, a description under 30 characters, or a choice without a label for every option. Empty means the admin panel can explain every field.++- **The summary classifies every schema property**, set or not: an unset secret key was missing from `classification`, so a form drew it as a plain field and the service then refused the value (422 `config.secret_write_only`). `classification` still names every key of `values` too.++- **A test hook in the admin routes:** `POST <base>/:namespace/test/:key` calls the service's `test` option with the stored value on the server and answers `{ ok, message }` in one line, scrubbed of every secret of the namespace; 409 `config.not_set` with no value, 404 `config.test_not_available` without a tester. `authorize` sees the action `'test'`.++## 4.124.0 — 2026-11-07- **The Dock's agent view gets the four controls** (Agent Streams S5, Co-pilot): Pause and Resume, Curtain now, Mute and Unmute, and End, each sent through Codes to Live. The owner's own view may always resume; Live still refuses a moderator resuming the owner's pause. The line says "Paused by @name" when someone else paused the stream.@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.124.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.125.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -340,11 +340,34 @@ settings.sealed; settings.kek; // false / null without a KEK(e.g. a provider's `base_url`) into the AAD: changing it locks the secret (`get()` gives undefined)until the owner enters it again for the new value. `x-env: "NAME"` pins a key to the processenvironment: the summary shows the env value with `pinned: true`, and a write to it is 409-- `config.pinned_in_env`. `summary()` now carries `sealed`, `kek`, `pins`, `secrets` (each secret's++ `config.pinned_in_env`. `summary()`'s `classification` names every key of `values` and, since++ 4.125.0, every property of the schema, set or not, so a form knows a key is a secret before it is++ entered. `summary()` now carries `sealed`, `kek`, `pins`, `secrets` (each secret'smarker with its four-character `hint`, `set_at`, `set_by`, `bound_to`) and `schema`, the JSONSchema to draw the form from; a secret property's `default`, `examples`, `enum` and `const` areremoved from that copy. The annotations are `title`, `description`, `x-group`, `x-env`, `x-danger`,`x-bind` and `x-unit` (`nano_usd`, `ms`, `bps`, `seconds`).++- **The owner's Setup page and clear settings (4.125.0).** Network's admin panel draws every namespace++ from its summary schema, and lists on its Setup tab every field only the owner can give. These++ annotations are checked when the store is created (a wrong shape refuses it, naming the rule) and pass++ through to the summary as written:++++ | Annotation | Shape | What the panel does with it |++ |---|---|---|++ | `x-owner-input` | `{ group, unlocks, get_it_at?, get_it_how?, test? }` | A row on Setup. `group` is one of `config.OWNER_INPUT_GROUPS` (`money`, `ai-providers`, `gpus`, `voices`, `chat-apps`, `sign-in-apps`, `data-sources`, `design-publishing`, `domains`, `legal`); `unlocks` is the sentence "what it turns on"; `get_it_at` the https page at the provider; `get_it_how` one line on what to choose there; `test: true` when the service's tester (below) tests this key. |++ | `x-required-for` | `"<feature>"` | Setup says **Needed** while the key is unset, naming what waits on it. Without it an unset key is **Optional**. |++ | `x-enum-labels` | `{ <value>: { label, description? } }` | Every option of a choice in words and a line, never a raw value. Keys are the enum values (a non-string as its JSON: `"null"`, `"5"`); a label for a value the enum lacks is refused. |++ | `x-advanced` | `true` | The field folds under a counted "Advanced settings (N)". |++ | `x-example` | a string, number or boolean | The input's placeholder. Never a real value. |++++ A secret-class property loses `x-example` and `x-enum-labels` in the summary, with `default`,++ `examples`, `enum` and `const` (say a key's shape in `get_it_how` instead: "It starts with sk-").++- **`config.lintSchema(schema)`** returns `[{ path, key, message }]`, empty when every field reads++ clearly: a field without a `title`, with a `description` under 30 characters (what it does, what++ the default means, an example), or a choice (`enum`, or `items.enum`) without an `x-enum-labels`++ label for every option is one entry. Fields are the top-level properties and the properties of an++ object field; a summary is read through its `schema`. Run it in the service's own test on each++ namespace it publishes: `assert.deepStrictEqual(config.lintSchema(SCHEMA), [])`.- **Hooks.** `onChange({ namespace, keys, actor, revision })` runs after every successful activation(never for a proposal or the seed), and a throwing hook is logged and never refuses the revision.`requireFresh(req, { namespace, keys })`, an `adminRoutes` option, runs before a secret write, a@@@ -359,6 +382,7 @@ config.adminRoutes([settings, other], {actor: (req) => ({ type: 'user', id: req.user.subject_id }), // default: req.actor, req.subject, req.user.subject(_id)authorize: (req, { action, namespace, keys }) => action === 'read' || isOwner(req.user) || !keys.some(isSecret),requireFresh: (req, { namespace, keys }) => isFreshSignIn(req), // secrets and x-danger keys; false → 403++ test: async ({ namespace, key, value }) => ({ ok, message }), // optional: the Setup page's Test buttonbasePath: '/api/admin/config',}).mount(app); // or { router }, the handlers, or handle(req, res, next) on plain http```@@@ -372,11 +396,21 @@ config.adminRoutes([settings, other], {| `POST /api/admin/config/:namespace/rollback` `{ to?, reason }` | the new active snapshot; 409 `config.no_previous`, `config.not_good` || `PUT /api/admin/config/:namespace/secrets/:key` `{ value, reason }` | the marker `{ redacted, fingerprint, hint, set_at, set_by, bound_to }`; write-only || `DELETE /api/admin/config/:namespace/secrets/:key` `{ reason }` | the empty marker; a new revision without the key |++| `POST /api/admin/config/:namespace/test/:key` | `{ ok, message }`: the service's `test` hook tried the stored value; 409 `config.not_set` when the key has no value, 404 `config.test_not_available` when there is no tester or it answers null for that key |Errors are `application/problem+json` (`errors.problem@1`). Mount it after a JSON body parser, orlet it read the body itself. A normal POST cannot write a secret-class value: the two `/secrets`routes are the only write path, and they answer a marker, never the value.++The test hook is how the Setup page's **Test** button reaches a provider. `test({ namespace, key,++value, values, store, req })` gets the stored value (a secret opened) on the server, calls the++provider, and answers `{ ok, message }`, or null when it does not test that key. The route answers++that in one line of at most 300 characters, scrubbed of every secret value of the namespace (a++client id's tester may hold the client secret too), so it never echoes a key; a tester that throws++is a failed test with its scrubbed message, and one that runs past `testTimeoutMs` (15 s) is a failed++test as well. `authorize` sees the action `'test'`, and the route needs an actor like a write. It++changes nothing and keeps nothing: the caller (Network's proxy) records the result.++### Releases: `/release.json`, open tabs and the mixed-version test (ADR-016, Track R)```js@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.124.0",++ "version": "4.125.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -269,7 +269,96 @@ const UNITS = new Set(['nano_usd', 'ms', 'bps', 'seconds']);const ENV_NAME = /^[A-Z][A-Z0-9_]{0,127}$/;const patterns = new WeakMap();--/** The owner-settings annotations of the summary (D11): x-env, x-danger, x-bind and x-unit are checked. */++// ── The owner's Setup page (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07) ──++// A field with x-owner-input is something only the owner can give (a key, an account, a cap). Network's admin panel++// lists every such field of every service on one page, grouped by these ids, in this order.++const OWNER_INPUT_GROUPS = Object.freeze({++ money: 'Money',++ 'ai-providers': 'AI providers',++ gpus: 'GPUs',++ voices: 'Voices',++ 'chat-apps': 'Chat apps',++ 'sign-in-apps': 'Sign-in apps',++ 'data-sources': 'Data sources',++ 'design-publishing': 'Design and publishing',++ domains: 'Domains',++ legal: 'Legal and registrations',++});++const OWNER_INPUT_MEMBERS = new Set(['group', 'unlocks', 'get_it_at', 'get_it_how', 'test']);++const ENUM_LABEL_MEMBERS = new Set(['label', 'description']);++const LINT_DESCRIPTION_MIN = 30;++/** The x-enum-labels key of an enum value: a string as it is, anything else as its JSON (null → "null", 5 → "5"). */++const enumKey = (v) => (typeof v === 'string' ? v : JSON.stringify(v));++function isHttpsUrl(v) {++ if (typeof v !== 'string' || v.length > 2000) return false;++ try {++ const u = new URL(v);++ return u.protocol === 'https:' && !u.username && !u.password && !!u.hostname;++ } catch { return false; }++}++const text = (v) => typeof v === 'string' && v.trim().length > 0;++++/** A Setup group as an id: the id itself, or its display name in any case ("Voices" → voices). Else null. */++function ownerGroupId(g) {++ if (typeof g !== 'string') return null;++ if (has(OWNER_INPUT_GROUPS, g)) return g;++ const want = g.trim().toLowerCase();++ for (const [id, label] of Object.entries(OWNER_INPUT_GROUPS)) if (label.toLowerCase() === want || id === want) return id;++ return null;++}++++/**++ * The Setup page's annotations (2026-11-07): x-owner-input, x-required-for, x-enum-labels, x-advanced, x-example.++ * They only change how a field is presented, so a wrong shape never refuses the store (that would take a service down++ * at boot over a label): the problem is returned here, the summary leaves that annotation out, the store warns once,++ * and lintSchema reports it so the service's own tests fail. → [{ key, message }]++ */++function setupAnnotationProblems(s, at) {++ const out = [];++ const bad = (key, message) => out.push({ key, message: `config schema ${at}: ${message}` });++ const owner = s['x-owner-input'];++ if (owner !== undefined) {++ if (!isPlainObject(owner)) bad('x-owner-input', 'x-owner-input must be an object { group, unlocks, get_it_at, get_it_how, test }');++ else {++ const unknown = Object.keys(owner).find((k) => !OWNER_INPUT_MEMBERS.has(k));++ if (unknown !== undefined) bad('x-owner-input', `x-owner-input has an unknown member "${String(unknown).slice(0, 40)}"`);++ else if (!ownerGroupId(owner.group)) bad('x-owner-input', `x-owner-input.group must be one of ${Object.keys(OWNER_INPUT_GROUPS).join(', ')}`);++ else if (!text(owner.unlocks)) bad('x-owner-input', 'x-owner-input.unlocks must say what it turns on');++ 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');++ 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');++ else if (owner.test !== undefined && typeof owner.test !== 'boolean') bad('x-owner-input', 'x-owner-input.test must be true or false');++ }++ }++ 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)');++ if (s['x-advanced'] !== undefined && typeof s['x-advanced'] !== 'boolean') bad('x-advanced', 'x-advanced must be true or false');++ 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');++ const labels = s['x-enum-labels'];++ if (labels !== undefined) {++ if (!isPlainObject(labels)) bad('x-enum-labels', 'x-enum-labels must be an object { value: { label, description } }');++ else {++ const choices = enumOf(s);++ const known = choices ? new Set(choices.map(enumKey)) : null;++ for (const [v, l] of Object.entries(labels)) {++ const name = `x-enum-labels["${String(v).slice(0, 40)}"]`;++ if (!isPlainObject(l) || !text(l.label)) { bad('x-enum-labels', `${name} needs a label`); break; }++ const m = Object.keys(l).find((k) => !ENUM_LABEL_MEMBERS.has(k));++ if (m !== undefined) { bad('x-enum-labels', `${name} has an unknown member "${String(m).slice(0, 40)}"`); break; }++ if (l.description !== undefined && typeof l.description !== 'string') { bad('x-enum-labels', `${name}.description must be a string`); break; }++ 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; }++ }++ }++ }++ return out;++}++// The Setup annotations left out of a schema node's summary (a node warns once per key: a second store built from++// the same schema object stays quiet).++const droppedSetup = new WeakMap();++++/**++ * The owner-settings annotations. D11 (x-env, x-danger, x-bind, x-unit, x-group, title, description): a wrong shape++ * refuses the store at creation, like any other schema mistake, because these decide where a value comes from and how++ * it is guarded. The Setup ones (setupAnnotationProblems) are presentation: left out of the summary with a warning.++ */function checkAnnotations(s, at) {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, _)`);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`);@@@ -278,6 +367,70 @@ function checkAnnotations(s, at) {if (s['x-group'] !== undefined && typeof s['x-group'] !== 'string') throw new TypeError(`config schema ${at}: x-group must be a string`);if (s.title !== undefined && typeof s.title !== 'string') throw new TypeError(`config schema ${at}: title must be a string`);if (s.description !== undefined && typeof s.description !== 'string') throw new TypeError(`config schema ${at}: description must be a string`);++ const problems = setupAnnotationProblems(s, at);++ if (!problems.length) return;++ const keys = droppedSetup.get(s) || new Set();++ for (const p of problems) {++ if (keys.has(p.key)) continue;++ keys.add(p.key);++ console.warn(`[config] ${p.message}; the Setup page leaves it out (fix it: config.lintSchema)`);++ }++ droppedSetup.set(s, keys);++}++++/** A field's choices: its own enum, or its items' (a list of choices). */++function enumOf(p) {++ if (!isPlainObject(p)) return null;++ if (Array.isArray(p.enum)) return p.enum;++ if (isPlainObject(p.items) && Array.isArray(p.items.enum)) return p.items.enum;++ return null;++}++++/**++ * lintSchema(schema) → [{ path, key, message }], empty when every field reads clearly in the admin panel. The clarity++ * rules (design §2 rule 10) a service's own test runs on each namespace it publishes:++ * - every field has a title in plain words;++ * - every field has a description of at least 30 characters (what it does, what the default means, an example);++ * - every choice (enum) has x-enum-labels with a label for each option.++ * Fields are the schema's properties, and the properties of an object field (which the panel draws as a form).++ * A namespace summary (anything with a `schema` member and no `properties`) is read through its schema.++ * assert.deepStrictEqual(config.lintSchema(SCHEMA), []);++ */++function lintSchema(input) {++ const schema = isPlainObject(input) && !has(input, 'properties') && has(input, 'schema') ? input.schema : input;++ if (!isPlainObject(schema)) return [{ path: '#', key: null, message: 'is not a JSON Schema object' }];++ const errors = [];++ const walk = (props, at, prefix) => {++ for (const [name, p] of Object.entries(props)) {++ if (!isPlainObject(p)) continue;++ const path = `${at}/properties/${name}`;++ const key = prefix ? `${prefix}.${name}` : name;++ if (!text(p.title)) errors.push({ path, key, message: 'has no title: give it a name in plain words' });++ const description = typeof p.description === 'string' ? p.description.trim() : '';++ if (description.length < LINT_DESCRIPTION_MIN) {++ errors.push({ path, key, message: description++ ? `has a description of ${description.length} characters; it needs at least ${LINT_DESCRIPTION_MIN} (what it does, what the default means, an example)`++ : `has no description: say in at least ${LINT_DESCRIPTION_MIN} characters what it does, what the default means, and give an example` });++ }++ const choices = enumOf(p);++ if (choices) {++ const labels = isPlainObject(p['x-enum-labels']) ? p['x-enum-labels'] : null;++ if (!labels) errors.push({ path, key, message: 'is a choice without x-enum-labels: give every option words and a line' });++ else {++ const missing = choices.map(enumKey).filter((v) => !isPlainObject(labels[v]) || !text(labels[v].label));++ if (missing.length) errors.push({ path, key, message: `has no x-enum-labels label for ${missing.map((v) => `"${v}"`).join(', ')}` });++ }++ }++ // The Setup annotations' shape (the store only warns about these, so the service's tests are where they fail).++ for (const pr of setupAnnotationProblems(p, path)) errors.push({ path, key, message: pr.message.replace(/^config schema [^:]*: /, '') });++ if (isPlainObject(p['x-owner-input']) && has(OWNER_INPUT_GROUPS, p['x-owner-input'].group) === false && ownerGroupId(p['x-owner-input'].group)) {++ 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)}"` });++ }++ if (isPlainObject(p.properties)) walk(p.properties, path, key);++ }++ };++ if (isPlainObject(schema.properties)) walk(schema.properties, '#', '');++ return errors;}function compileSchema(s, at = '#') {@@@ -315,14 +468,35 @@ function schemaAnnotations(schema) {return out;}--/** A JSON Schema for the summary: secret-class properties lose default/examples, which could hold a secret. */++/**++ * A JSON Schema for the summary: secret-class properties lose default, examples, enum, const, x-example and++ * x-enum-labels, any of which could hold a secret. Every other annotation (title, description, x-owner-input,++ * x-required-for, x-advanced, …) passes through as the service wrote it.++ */function summarySchema(schema, isSecret) {if (schema == null) return null;const out = clone(schema);++ // The Setup annotations checkAnnotations found wrong are left out (one warning at creation), and a group named by++ // its display name is given as its id, so Network's Setup page groups it.++ const tidy = (orig, copy) => {++ if (!isPlainObject(orig) || !isPlainObject(copy)) return;++ const dropped = droppedSetup.get(orig);++ if (dropped) for (const k of dropped) delete copy[k];++ if (isPlainObject(copy['x-owner-input'])) {++ const id = ownerGroupId(copy['x-owner-input'].group);++ if (id) copy['x-owner-input'].group = id;++ }++ if (isPlainObject(orig.properties) && isPlainObject(copy.properties)) {++ for (const k of Object.keys(orig.properties)) tidy(orig.properties[k], copy.properties[k]);++ }++ if (isPlainObject(orig.items) && isPlainObject(copy.items)) tidy(orig.items, copy.items);++ };++ tidy(schema, out);if (isPlainObject(out.properties)) {for (const [k, p] of Object.entries(out.properties)) {if (!isPlainObject(p) || !isSecret(k)) continue;delete p.default; delete p.examples; delete p.enum; delete p.const;++ delete p['x-example']; delete p['x-enum-labels'];}}return deepFreeze(out);@@@ -453,4 +627,5 @@ function fromRows(rows, { prefix = '', type = null } = {}) {}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,-- KEK_ID, KEY_BYTES, IV_BYTES, TAG_BYTES, isEnvelope, parseEnvelope, sealEnvelope, openEnvelope, isWrappedFingerprintKey, sealFingerprintKey, openFingerprintKey, keksOf, hintOf, bindText, schemaAnnotations, summarySchema };++ KEK_ID, KEY_BYTES, IV_BYTES, TAG_BYTES, isEnvelope, parseEnvelope, sealEnvelope, openEnvelope, isWrappedFingerprintKey, sealFingerprintKey, openFingerprintKey, keksOf, hintOf, bindText, schemaAnnotations, summarySchema,++ OWNER_INPUT_GROUPS, LINT_DESCRIPTION_MIN, lintSchema, enumKey };@@@ -806,6 +806,11 @@ async function createPgConfigStore(opts = {}) {for (const [k, p] of Object.entries(pins)) values[k] = clone(p.value);const classification = classesFor(values, current ? current.classification : {});const shown = redact(values, classification);++ // Every property the schema names is classified too, set or not (4.125.0): a form then knows a key is a++ // secret before anyone has entered it, and offers the write-only route instead of a plain field.++ if (schema && isPlainObject(schema.properties)) {++ for (const k of Object.keys(schema.properties)) if (!has(classification, k)) classification[k] = classOf(k, undefined);++ }const secrets = {};for (const k of Object.keys(classification)) {if (classification[k] !== 'secret' || !has(values, k)) continue;@@@ -1,6 +1,9 @@'use strict';/** Configuration store on an openvibe-sdk/db handle. The store is ready when the promise resolves. */--const { fromRows, canonical, ConfigError, subjectRef, isPlainObject, isMarker, makeLog, scrub, errText } = require('./config-core');++const { fromRows, canonical, ConfigError, subjectRef, isPlainObject, isMarker, makeLog, scrub, errText, lintSchema, OWNER_INPUT_GROUPS } = require('./config-core');++++const TEST_TIMEOUT_MS = 15000;++const TEST_MESSAGE_MAX = 300;function createConfigStore(opts = {}) {if (!opts.db || typeof opts.db.prepare !== 'function' || typeof opts.db.tx !== 'function') {@@@ -63,6 +66,7 @@ function readBody(req, limit = 256 * 1024) {* POST <base>/:namespace/rollback { to?, reason } → the new revision* PUT <base>/:namespace/secrets/:key { value, reason } → the marker (write-only)* DELETE <base>/:namespace/secrets/:key { reason } → the empty marker (write-only)++ * POST <base>/:namespace/test/:key → { ok, message }: the service's tester tried the stored value* requireAdmin (a middleware or a list) guards every handler, mounted or called directly. actor(req)* names who acts (default: req.user.subject_id and friends; none → 403). authorize(req, { action:* 'read' | 'write', namespace, keys }) may refuse more (false → 403). requireFresh(req, { namespace,@@@ -72,6 +76,13 @@ function readBody(req, limit = 256 * 1024) {* pass `router` (anything with get/post/put/delete) or call mount(router), use the handlers, or* handle(req, res, next) for a plain http server. The body is req.body when a JSON parser ran, else* read from the request.++ *++ * test (optional) is the service's tester for the owner's Setup page: async ({ namespace, key, value, values, store,++ * req }) → { ok, message }, or null/undefined when it does not test that key (404 config.test_not_available). It gets++ * the stored value (a secret opened) and calls the provider with it on the server; the answer is one line, at most++ * 300 characters, scrubbed of every secret value of the namespace, so it can never echo a key. A tester that throws is++ * a failed test with its message (scrubbed the same way); one that runs longer than testTimeoutMs (15 s) is a failed++ * test too. authorize sees action 'test'. A key with no value is 409 config.not_set and the tester is not called.*/function adminRoutes(stores, o = {}) {const list = [].concat(stores || []);@@@ -88,6 +99,9 @@ function adminRoutes(stores, o = {}) {const authorize = o.authorize || null;const requireFresh = o.requireFresh || null;if (requireFresh !== null && typeof requireFresh !== 'function') throw new TypeError('adminRoutes: requireFresh must be a function');++ const tester = o.test || null;++ if (tester !== null && typeof tester !== 'function') throw new TypeError('adminRoutes: test must be a function');++ const testTimeoutMs = Number.isInteger(o.testTimeoutMs) && o.testTimeoutMs > 0 ? o.testTimeoutMs : TEST_TIMEOUT_MS;const log = makeLog(o.log);const allowed = async (req, ctx) => !authorize || (await authorize(req, ctx)) !== false;@@@ -186,6 +200,48 @@ function adminRoutes(stores, o = {}) {await fresh(req, s, [key]);send(res, 200, await s.deleteSecret(key, { actor, reason: body.reason }));},++ async test(req, res) {++ const s = storeFor(req);++ const key = keyOf(req);++ if (!(await allowed(req, { action: 'test', namespace: s.namespace, keys: [key] }))) throw new ConfigError('config.forbidden', 'not allowed to test this key', { status: 403 });++ const actor = await actorOf(req);++ if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });++ if (!tester) throw new ConfigError('config.test_not_available', 'this service has no tests for its settings', { status: 404 });++ const values = s.get();++ const value = values[key];++ if (value === undefined || value === null || value === '') throw new ConfigError('config.not_set', `${key} is not set, so there is nothing to test`, { status: 409 });++ // Every secret value of the namespace is scrubbed from the answer, not only the tested one: a tester for a++ // client id may well have the client secret in hand.++ const classes = await s.classes({}, { merge: true });++ const secrets = Object.entries(values)++ .filter(([k, v]) => (classes[k] || s.classify(k, v)) === 'secret')++ .map(([, v]) => (typeof v === 'string' ? v : canonical(v)))++ .filter((v) => v.length >= 4)++ .sort((a, b) => b.length - a.length);++ const line = (m) => {++ const one = String(m == null ? '' : m).replace(/\s+/g, ' ').trim();++ return one ? scrub(one, secrets).slice(0, TEST_MESSAGE_MAX) : '';++ };++ const seconds = Math.max(1, Math.ceil(testTimeoutMs / 1000));++ let timer = null;++ let result;++ try {++ result = await Promise.race([++ Promise.resolve().then(() => tester({ namespace: s.namespace, key, value, values, store: s, req })),++ new Promise((resolve) => { timer = setTimeout(() => resolve({ ok: false, message: `The test did not finish in ${seconds} second${seconds === 1 ? '' : 's'}.` }), testTimeoutMs); }),++ ]);++ } catch (err) {++ if (err instanceof ConfigError) throw err;++ result = { ok: false, message: errText(err) };++ } finally {++ clearTimeout(timer);++ }++ if (result == null) throw new ConfigError('config.test_not_available', `${key} has no test`, { status: 404 });++ const ok = result === true || (isPlainObject(result) && result.ok === true);++ const message = line(isPlainObject(result) ? result.message : '') || (ok ? 'It works.' : 'It did not work.');++ log.info(`[config] ${s.namespace} ${key}: test ${ok ? 'passed' : 'failed'}`);++ send(res, 200, { ok, message });++ },};function fail(res, err) {@@@ -216,12 +272,13 @@ function adminRoutes(stores, o = {}) {{ method: 'post', path: `${basePath}/:namespace/rollback`, handler: out.rollback },{ method: 'put', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretPut },{ method: 'delete', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretDelete },++ { method: 'post', path: `${basePath}/:namespace/test/:key`, handler: out.test },];function mount(router) {for (const r of routes) router[r.method](r.path, r.handler);return router;}-- /** For a plain http server: dispatches the seven routes, calls next() (or answers 404) for anything else. */++ /** For a plain http server: dispatches the eight routes, calls next() (or answers 404) for anything else. */function handle(req, res, next) {const url = new URL(req.url || '/', 'http://x');const method = String(req.method || 'GET').toLowerCase();@@@ -236,6 +293,7 @@ function adminRoutes(stores, o = {}) {else if (parts.length === 2 && parts[1] === 'rollback' && method === 'post') m = ['rollback', parts[0]];else if (parts.length === 3 && parts[1] === 'secrets' && method === 'put') m = ['secretPut', parts[0], parts[2]];else if (parts.length === 3 && parts[1] === 'secrets' && method === 'delete') m = ['secretDelete', parts[0], parts[2]];++ else if (parts.length === 3 && parts[1] === 'test' && method === 'post') m = ['test', parts[0], parts[2]];}if (!m) return typeof next === 'function' ? next() : sendProblem(res, 404, 'config.route_not_found', { detail: 'no such configuration route' });let ns = m[1] || '';@@@ -250,4 +308,4 @@ function adminRoutes(stores, o = {}) {return { ...out, routes, mount, handle };}--module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, configSchema: (...a) => require('./config-pg').configSchema(...a) };++module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, lintSchema, OWNER_INPUT_GROUPS, configSchema: (...a) => require('./config-pg').configSchema(...a) };@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.124.0",++ "version": "4.125.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {nnew file mode 100644@@@ -0,0 +1,273 @@++'use strict';++// The owner's Setup page and the clear admin panel (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07):++// - the annotations x-owner-input, x-required-for, x-enum-labels, x-advanced and x-example pass through to the++// summary's schema (a secret property loses x-example and x-enum-labels); a wrong one is left out with a warning,++// never a refused store, and lintSchema names it; a group's display name is read as its id;++// - lintSchema fails a field without a title, a description under 30 characters, and a choice without labels;++// - POST <base>/:namespace/test/:key calls the service's tester with the stored value and answers { ok, message } in++// one line, never echoing a secret, on Express and on a plain http server.++const assert = require('assert');++const crypto = require('crypto');++let createDb;++try { ({ createDb } = require('openvibe-sdk/db')); } catch { console.log('config-owner-setup: skipped (openvibe-sdk not installed)'); process.exit(0); }++const express = require('express');++const http = require('http');++const { configSchema } = require('../config-pg');++const config = require('../config');++const { createConfigStore, adminRoutes, lintSchema, OWNER_INPUT_GROUPS } = config;++++const USER = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };++const SECRET = 'MADE-UP-BOT-TOKEN-5e1d9a7c3b';++const CLIENT_SECRET = 'MADE-UP-CLIENT-SECRET-8f2b6d4a';++const KEKS = { current: 'k1', keys: { k1: crypto.randomBytes(32) } };++const logs = [];++const log = (m, level) => logs.push(`${level} ${m}`);++++const LONG = 'A sentence long enough to explain what it does and what the default means.';++const SCHEMA = {++ title: 'Chat apps', description: 'The bot tokens the agent uses to answer in chat apps.',++ type: 'object', additionalProperties: false,++ properties: {++ telegram_bot_token: {++ type: 'string', title: 'Telegram bot token', description: 'Lets agents answer in Telegram. Without it the Telegram option stays hidden.',++ 'x-owner-input': { group: 'chat-apps', unlocks: 'Lets agents answer in Telegram.', get_it_at: 'https://core.telegram.org/bots#how-do-i-create-a-bot', get_it_how: 'Ask @BotFather for /newbot and copy the token it gives you.', test: true },++ 'x-required-for': 'Agents in Telegram',++ 'x-example': '123456:ABC-DEF',++ },++ slack_client_id: { type: 'string', title: 'Slack client id', description: LONG, 'x-owner-input': { group: 'chat-apps', unlocks: 'Lets agents answer in Slack.' }, 'x-example': '1234.5678' },++ slack_client_secret: { type: 'string', title: 'Slack client secret', description: LONG, 'x-owner-input': { group: 'chat-apps', unlocks: 'Lets agents answer in Slack.', test: true } },++ mode: {++ type: 'string', title: 'Answer mode', description: LONG, enum: ['quiet', 'chatty'], 'x-advanced': true,++ 'x-enum-labels': { quiet: { label: 'Only when asked', description: 'Answers a message that names it.' }, chatty: { label: 'Joins in', description: 'Answers when it has something to add.' } },++ },++ unused: { type: 'string', title: 'Unused', description: LONG },++ },++};++const classify = (k) => (/_(token|secret)$/.test(k) ? 'secret' : undefined);++++(async () => {++ // ── The groups the Setup page shows, in order ──++ assert.deepStrictEqual(Object.keys(OWNER_INPUT_GROUPS), ['money', 'ai-providers', 'gpus', 'voices', 'chat-apps', 'sign-in-apps', 'data-sources', 'design-publishing', 'domains', 'legal']);++ assert.ok(Object.isFrozen(OWNER_INPUT_GROUPS));++++ const db = createDb({ pglite: true, log: { log() {}, warn() {} } });++ await db.query(configSchema());++++ // ── A wrong Setup annotation never refuses the store (a label must not take a service down at boot): the store++ // starts, warns once, the summary leaves that annotation out, and lintSchema names the rule for the tests ──++ let n = 0;++ const warned = [];++ const realWarn = console.warn;++ const leftOut = async (prop, key, re) => {++ n += 1;++ const schema = { type: 'object', properties: { k: { type: 'string', title: 'K', description: 'A field used only to check one annotation rule.', ...prop } } };++ warned.length = 0;++ console.warn = (...a) => warned.push(a.join(' '));++ let st;++ try { st = await createConfigStore({ db, service: 'chat', namespace: `chat.bad${n}`, schema, log: false }); } finally { console.warn = realWarn; }++ assert.ok(warned.some((w) => re.test(w)), `the store warns about ${key}: ${warned.join(' | ')}`);++ const sum = await st.summary();++ assert.strictEqual(sum.schema.properties.k[key], undefined, `the summary leaves out the wrong ${key}`);++ assert.strictEqual(sum.schema.properties.k.title, 'K', 'the rest of the field is untouched');++ assert.ok(lintSchema(schema).some((e) => re.test(e.message)), `lintSchema names the rule for ${key}`);++ };++ await leftOut({ 'x-owner-input': 'yes' }, 'x-owner-input', /x-owner-input must be an object/);++ await leftOut({ 'x-owner-input': { group: 'snacks', unlocks: 'x' } }, 'x-owner-input', /x-owner-input\.group must be one of money/);++ await leftOut({ 'x-owner-input': { group: 'money' } }, 'x-owner-input', /x-owner-input\.unlocks/);++ await leftOut({ 'x-owner-input': { group: 'money', unlocks: 'x', get_it_at: 'http://example.com' } }, 'x-owner-input', /get_it_at must be an https URL/);++ await leftOut({ 'x-owner-input': { group: 'money', unlocks: 'x', get_it_at: 'https://user:[email protected]' } }, 'x-owner-input', /get_it_at must be an https URL/);++ await leftOut({ 'x-owner-input': { group: 'money', unlocks: 'x', get_it_how: ' ' } }, 'x-owner-input', /get_it_how/);++ await leftOut({ 'x-owner-input': { group: 'money', unlocks: 'x', test: 'yes' } }, 'x-owner-input', /x-owner-input\.test must be true or false/);++ await leftOut({ 'x-owner-input': { group: 'money', unlocks: 'x', get_it_url: 'https://a.example' } }, 'x-owner-input', /unknown member "get_it_url"/);++ await leftOut({ 'x-required-for': '' }, 'x-required-for', /x-required-for/);++ await leftOut({ 'x-advanced': 'yes' }, 'x-advanced', /x-advanced must be true or false/);++ await leftOut({ 'x-example': { a: 1 } }, 'x-example', /x-example must be/);++ await leftOut({ enum: ['a'], 'x-enum-labels': ['a'] }, 'x-enum-labels', /x-enum-labels must be an object/);++ await leftOut({ enum: ['a'], 'x-enum-labels': { a: { description: 'no label' } } }, 'x-enum-labels', /x-enum-labels\["a"\] needs a label/);++ await leftOut({ enum: ['a'], 'x-enum-labels': { a: { label: 'A', note: 'x' } } }, 'x-enum-labels', /unknown member "note"/);++ await leftOut({ enum: ['a'], 'x-enum-labels': { b: { label: 'B' } } }, 'x-enum-labels', /names "b", which is not one of the enum values/);++++ // A group given by its display name still groups (as its id), and lintSchema asks for the id.++ {++ const schema = { type: 'object', properties: { k: { type: 'string', title: 'K', description: 'A voice key the owner gives on the Setup page.', 'x-owner-input': { group: 'Voices', unlocks: 'Speech in chat' } } } };++ const st = await createConfigStore({ db, service: 'chat', namespace: 'chat.voicesname', schema, log: false });++ assert.strictEqual((await st.summary()).schema.properties.k['x-owner-input'].group, 'voices');++ assert.ok(lintSchema(schema).some((e) => /display name: use its id "voices"/.test(e.message)));++ }++ // The D11 annotations still refuse: they decide where a value comes from and how it is guarded.++ await assert.rejects(createConfigStore({ db, service: 'chat', namespace: 'chat.badenv', schema: { type: 'object', properties: { k: { type: 'string', 'x-env': 'lower-case' } } }, log: false }), /x-env must be an env name/);++ await assert.rejects(createConfigStore({ db, service: 'chat', namespace: 'chat.baddanger', schema: { type: 'object', properties: { k: { type: 'string', 'x-danger': 'yes' } } }, log: false }), /x-danger must be true or false/);++++ // ── A good schema: the annotations reach the summary; a secret property loses its example and labels ──++ const store = await createConfigStore({ db, service: 'chat', namespace: 'chat.apps', schema: SCHEMA, classify, keks: KEKS, log });++ let summary = await store.summary();++ assert.strictEqual(summary.values.telegram_bot_token, undefined, 'nothing is set yet');++ assert.strictEqual(summary.classification.telegram_bot_token, 'secret', 'an unset secret is classified, so a form offers the write-only route');++ assert.strictEqual(summary.classification.slack_client_id, 'internal', 'every schema property is classified');++ const props = summary.schema.properties;++ assert.deepStrictEqual(props.telegram_bot_token['x-owner-input'], SCHEMA.properties.telegram_bot_token['x-owner-input'], 'x-owner-input passes through');++ assert.strictEqual(props.telegram_bot_token['x-required-for'], 'Agents in Telegram');++ assert.strictEqual(props.telegram_bot_token['x-example'], undefined, 'a secret property loses x-example');++ assert.strictEqual(props.slack_client_id['x-example'], '1234.5678', 'a plain property keeps x-example');++ assert.strictEqual(props.mode['x-advanced'], true);++ assert.deepStrictEqual(props.mode['x-enum-labels'], SCHEMA.properties.mode['x-enum-labels']);++ // A label set on a choice that is a secret goes with its enum.++ const secretChoice = await createConfigStore({ db, service: 'chat', namespace: 'chat.secretchoice', classify: () => 'secret', log: false,++ schema: { type: 'object', properties: { pick_secret: { type: 'string', enum: ['a', 'b'], 'x-enum-labels': { a: { label: 'A' }, b: { label: 'B' } } } } } });++ const scProps = (await secretChoice.summary()).schema.properties.pick_secret;++ assert.strictEqual(scProps.enum, undefined);++ assert.strictEqual(scProps['x-enum-labels'], undefined, 'a secret choice loses its labels with its enum');++++ // ── lintSchema: clean, then each of the three failures ──++ assert.deepStrictEqual(lintSchema(SCHEMA), [], 'a clear schema has nothing to fix');++ assert.deepStrictEqual(lintSchema(summary), [], 'a summary is linted through its schema');++ const lint = lintSchema({++ type: 'object',++ properties: {++ no_title: { type: 'string', description: LONG },++ short: { type: 'string', title: 'Short', description: 'Too short to help.' },++ none: { type: 'integer', title: 'None' },++ pick: { type: 'string', title: 'Pick', description: LONG, enum: ['a', 'b'] },++ half: { type: 'string', title: 'Half', description: LONG, enum: ['a', 'b', null], 'x-enum-labels': { a: { label: 'A' } } },++ tags: { type: 'array', title: 'Tags', description: LONG, items: { type: 'string', enum: ['x'] } },++ limits: { type: 'object', title: 'Limits', description: LONG, properties: { max: { type: 'integer', description: LONG } } },++ },++ });++ const byKey = (key) => lint.filter((e) => e.key === key).map((e) => e.message);++ assert.deepStrictEqual(byKey('no_title'), ['has no title: give it a name in plain words']);++ assert.match(byKey('short')[0], /description of 18 characters; it needs at least 30/);++ assert.match(byKey('none')[0], /has no description/);++ assert.deepStrictEqual(byKey('pick'), ['is a choice without x-enum-labels: give every option words and a line']);++ assert.deepStrictEqual(byKey('half'), ['has no x-enum-labels label for "b", "null"']);++ assert.deepStrictEqual(byKey('tags'), ['is a choice without x-enum-labels: give every option words and a line'], 'a list of choices needs labels too');++ assert.deepStrictEqual(byKey('limits.max'), ['has no title: give it a name in plain words'], 'an object field\'s own fields are linted');++ assert.strictEqual(lint.find((e) => e.key === 'limits.max').path, '#/properties/limits/properties/max');++ assert.deepStrictEqual(lintSchema(null), [{ path: '#', key: null, message: 'is not a JSON Schema object' }]);++++ // ── The test hook ──++ await store.setSecret('telegram_bot_token', SECRET, { actor: USER, reason: 'setup' });++ await store.setSecret('slack_client_secret', CLIENT_SECRET, { actor: USER, reason: 'setup' });++ await store.apply({ slack_client_id: '1234.5678' }, { merge: true, actor: USER, reason: 'setup' });++ const tried = [];++ let behaviour = 'ok';++ const tester = async ({ namespace, key, value, values, store: s, req }) => {++ tried.push({ namespace, key, value, hasValues: !!values, hasStore: typeof s.get === 'function', hasReq: !!req });++ if (key === 'unused') return null;++ if (behaviour === 'ok') return { ok: true, message: 'Telegram knows the bot\n@openvibe_bot.' };++ if (behaviour === 'echo') return { ok: false, message: `401 Unauthorized: token ${value} and ${CLIENT_SECRET} were refused` };++ if (behaviour === 'throw') throw new Error(`connect ECONNREFUSED with key=${value}`);++ if (behaviour === 'hang') return new Promise(() => {});++ if (behaviour === 'long') return { ok: false, message: 'x'.repeat(1000) };++ return { ok: true };++ };++ const app = express();++ app.use(express.json());++ app.use((req, _res, next) => { req.user = req.headers['x-test-user'] === 'owner' ? { role: 'admin', owner: true, subject_id: USER.id } : req.headers['x-test-user'] === 'admin' ? { role: 'admin', subject_id: USER.id } : null; next(); });++ const requireAdmin = (req, res, next) => (req.user ? next() : res.status(403).json({ error: 'Admin only' }));++ const actions = [];++ const authorize = (req, { action }) => { actions.push(action); return action === 'read' || !!req.user.owner; };++ adminRoutes([store], { requireAdmin, authorize, actor: (req) => ({ type: 'user', id: req.user.subject_id }), test: tester, testTimeoutMs: 300, log }).mount(app);++ const server = app.listen(0);++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}/api/admin/config`;++ const texts = [];++ const post = async (path, user = 'owner') => {++ const res = await fetch(base + path, { method: 'POST', headers: { 'x-test-user': user } });++ const text = await res.text();++ texts.push(text);++ return { status: res.status, body: text ? JSON.parse(text) : null };++ };++ try {++ let r = await post('/chat.apps/test/telegram_bot_token', 'nobody');++ assert.strictEqual(r.status, 403, 'requireAdmin guards the test route');++ r = await post('/chat.apps/test/telegram_bot_token', 'admin');++ assert.strictEqual(r.status, 403, 'authorize sees the test');++ assert.strictEqual(r.body.code, 'config.forbidden');++ assert.strictEqual(actions.at(-1), 'test', 'authorize is asked for the action "test"');++ assert.strictEqual(tried.length, 0, 'a refused test never reaches the tester');++++ r = await post('/chat.apps/test/telegram_bot_token');++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.deepStrictEqual(r.body, { ok: true, message: 'Telegram knows the bot @openvibe_bot.' }, 'one line, nothing else');++ assert.deepStrictEqual(tried.at(-1), { namespace: 'chat.apps', key: 'telegram_bot_token', value: SECRET, hasValues: true, hasStore: true, hasReq: true }, 'the tester gets the opened value on the server');++++ behaviour = 'echo';++ r = await post('/chat.apps/test/telegram_bot_token');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.ok, false);++ assert.ok(!r.body.message.includes(SECRET) && !r.body.message.includes(CLIENT_SECRET), 'every secret of the namespace is scrubbed');++ assert.match(r.body.message, /^401 Unauthorized: token \[redacted\] and \[redacted\] were refused$/);++++ behaviour = 'throw';++ r = await post('/chat.apps/test/slack_client_secret');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.ok, false, 'a tester that throws is a failed test');++ assert.ok(!r.body.message.includes(CLIENT_SECRET));++ assert.match(r.body.message, /ECONNREFUSED/);++++ behaviour = 'hang';++ r = await post('/chat.apps/test/telegram_bot_token');++ assert.deepStrictEqual(r.body, { ok: false, message: 'The test did not finish in 1 second.' }, 'a tester that hangs fails after testTimeoutMs');++++ behaviour = 'long';++ r = await post('/chat.apps/test/telegram_bot_token');++ assert.strictEqual(r.body.message.length, 300, 'the answer is at most 300 characters');++++ behaviour = 'bare';++ r = await post('/chat.apps/test/slack_client_id');++ assert.deepStrictEqual(r.body, { ok: true, message: 'It works.' }, 'a tester without a message gets words');++++ r = await post('/chat.apps/test/unused');++ assert.strictEqual(r.status, 409, 'a key with no value is not tested');++ assert.strictEqual(r.body.code, 'config.not_set');++ await store.apply({ unused: 'x' }, { merge: true, actor: USER, reason: 'set' });++ r = await post('/chat.apps/test/unused');++ assert.strictEqual(r.status, 404, 'a key the tester does not test');++ assert.strictEqual(r.body.code, 'config.test_not_available');++ r = await post('/chat.nope/test/unused');++ assert.strictEqual(r.status, 404);++ assert.strictEqual(r.body.code, 'config.namespace_not_found');++ } finally {++ await new Promise((r) => server.close(r));++ }++++ // ── Without a tester, on a plain http server through handle() ──++ const plain = adminRoutes([store], { requireAdmin: (_q, _s, nx) => nx(), actor: () => USER });++ const plainServer = http.createServer((req, res) => plain.handle(req, res));++ plainServer.listen(0);++ await new Promise((r) => plainServer.once('listening', r));++ try {++ const res = await fetch(`http://127.0.0.1:${plainServer.address().port}/api/admin/config/chat.apps/test/telegram_bot_token`, { method: 'POST' });++ const body = await res.json();++ texts.push(JSON.stringify(body));++ assert.strictEqual(res.status, 404);++ assert.strictEqual(body.code, 'config.test_not_available', 'a service without a tester says so');++ } finally {++ await new Promise((r) => plainServer.close(r));++ }++ // With a tester, through handle() too.++ const withTest = adminRoutes([store], { requireAdmin: (_q, _s, nx) => nx(), actor: () => USER, test: () => ({ ok: true, message: 'fine' }), log: false });++ const s2 = http.createServer((req, res) => withTest.handle(req, res));++ s2.listen(0);++ await new Promise((r) => s2.once('listening', r));++ try {++ const res = await fetch(`http://127.0.0.1:${s2.address().port}/api/admin/config/chat.apps/test/telegram_bot_token`, { method: 'POST' });++ assert.deepStrictEqual(await res.json(), { ok: true, message: 'fine' });++ const wrongMethod = await fetch(`http://127.0.0.1:${s2.address().port}/api/admin/config/chat.apps/test/telegram_bot_token`);++ assert.strictEqual(wrongMethod.status, 404, 'only POST tests');++ } finally {++ await new Promise((r) => s2.close(r));++ }++ assert.throws(() => adminRoutes([store], { requireAdmin: (_q, _s, nx) => nx(), test: 'yes' }), /test must be a function/);++ assert.ok(withTest.routes.some((r) => r.method === 'post' && r.path === '/api/admin/config/:namespace/test/:key'), 'the route is in the list mount() uses');++++ // ── No secret anywhere: answers, the log, the summary ──++ for (const t of texts) assert.ok(!t.includes(SECRET) && !t.includes(CLIENT_SECRET), 'a route answer leaks a secret');++ const allLogs = logs.join('\n');++ assert.ok(!allLogs.includes(SECRET) && !allLogs.includes(CLIENT_SECRET), 'the log leaks a secret');++ assert.ok(logs.some((l) => /chat\.apps telegram_bot_token: test (passed|failed)/.test(l)), 'a test is logged by name');++ summary = await store.summary();++ assert.ok(!JSON.stringify(summary).includes(SECRET));++++ console.log('config owner setup: annotations, lintSchema and the test hook ok');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -629,11 +629,12 @@ function counting(db) {direct.list({ url: '/api/admin/config', admin: true }, allowedRes);await sleep(5);assert.strictEqual(allowedRes.statusCode, 200);-- assert.strictEqual(direct.routes.length, 7);++ assert.strictEqual(direct.routes.length, 8);assert.deepStrictEqual(direct.routes.map((x) => `${x.method.toUpperCase()} ${x.path}`), ['GET /api/admin/config', 'GET /api/admin/config/:namespace', 'GET /api/admin/config/:namespace/history','POST /api/admin/config/:namespace', 'POST /api/admin/config/:namespace/rollback','PUT /api/admin/config/:namespace/secrets/:key', 'DELETE /api/admin/config/:namespace/secrets/:key',++ 'POST /api/admin/config/:namespace/test/:key',]);assertNoLeak(logs.join('\n'), (await leakForms(db)).concat(await leakForms(legacyDb)), 'the whole log');
@@ -1,5 +1,13 @@
@@ -1,5 +1,13 @@
# Changelog
# Changelog
+## 4.125.0 — 2026-11-07
+
+- **The owner's Setup page and clear settings** (`openvibe-shared/config`; OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07). New schema annotations, passed through to the summary: `x-owner-input { group, unlocks, get_it_at, get_it_how, test }` marks a field for Network's Setup tab (the groups are `config.OWNER_INPUT_GROUPS`), `x-required-for` makes an unset field **Needed**, `x-enum-labels` gives every option of a choice words and a line, `x-advanced` folds a field under a counted "Advanced settings (N)", and `x-example` is a placeholder. A secret-class property loses `x-example` and `x-enum-labels` in the summary, as it already lost its default, examples and enum.
+- **A wrong Setup annotation never refuses the store.** `x-owner-input`, `x-required-for`, `x-enum-labels`, `x-advanced` and `x-example` only change how a field is presented, so a wrong shape is left out of the summary with one warning (and `lintSchema` names it) instead of taking a service down at boot after a pin bump. A group given by its display name (`Voices`) is read as its id (`voices`), and `lintSchema` asks for the id. The D11 annotations (`x-env`, `x-danger`, `x-bind`, `x-unit`) still refuse the store: they decide where a value comes from and how it is guarded.
+- **`config.lintSchema(schema)`** for each service's own test: it lists a field without a title, a description under 30 characters, or a choice without a label for every option. Empty means the admin panel can explain every field.
+- **The summary classifies every schema property**, set or not: an unset secret key was missing from `classification`, so a form drew it as a plain field and the service then refused the value (422 `config.secret_write_only`). `classification` still names every key of `values` too.
+- **A test hook in the admin routes:** `POST <base>/:namespace/test/:key` calls the service's `test` option with the stored value on the server and answers `{ ok, message }` in one line, scrubbed of every secret of the namespace; 409 `config.not_set` with no value, 404 `config.test_not_available` without a tester. `authorize` sees the action `'test'`.
+
## 4.124.0 — 2026-11-07
## 4.124.0 — 2026-11-07
- **The Dock's agent view gets the four controls** (Agent Streams S5, Co-pilot): Pause and Resume, Curtain now, Mute and Unmute, and End, each sent through Codes to Live. The owner's own view may always resume; Live still refuses a moderator resuming the owner's pause. The line says "Paused by @name" when someone else paused the stream.
- **The Dock's agent view gets the four controls** (Agent Streams S5, Co-pilot): Pause and Resume, Curtain now, Mute and Unmute, and End, each sent through Codes to Live. The owner's own view may always resume; Live still refuses a moderator resuming the owner's pause. The line says "Paused by @name" when someone else paused the stream.
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.124.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.125.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -340,11 +340,34 @@ settings.sealed; settings.kek; // false / null without a KEK
@@ -340,11 +340,34 @@ settings.sealed; settings.kek; // false / null without a KEK
(e.g. a provider's `base_url`) into the AAD: changing it locks the secret (`get()` gives undefined)
(e.g. a provider's `base_url`) into the AAD: changing it locks the secret (`get()` gives undefined)
until the owner enters it again for the new value. `x-env: "NAME"` pins a key to the process
until the owner enters it again for the new value. `x-env: "NAME"` pins a key to the process
environment: the summary shows the env value with `pinned: true`, and a write to it is 409
environment: the summary shows the env value with `pinned: true`, and a write to it is 409
- `config.pinned_in_env`. `summary()` now carries `sealed`, `kek`, `pins`, `secrets` (each secret's
+ `config.pinned_in_env`. `summary()`'s `classification` names every key of `values` and, since
+ 4.125.0, every property of the schema, set or not, so a form knows a key is a secret before it is
+ entered. `summary()` now carries `sealed`, `kek`, `pins`, `secrets` (each secret's
marker with its four-character `hint`, `set_at`, `set_by`, `bound_to`) and `schema`, the JSON
marker with its four-character `hint`, `set_at`, `set_by`, `bound_to`) and `schema`, the JSON
Schema to draw the form from; a secret property's `default`, `examples`, `enum` and `const` are
Schema to draw the form from; a secret property's `default`, `examples`, `enum` and `const` are
removed from that copy. The annotations are `title`, `description`, `x-group`, `x-env`, `x-danger`,
removed from that copy. The annotations are `title`, `description`, `x-group`, `x-env`, `x-danger`,
`x-bind` and `x-unit` (`nano_usd`, `ms`, `bps`, `seconds`).
`x-bind` and `x-unit` (`nano_usd`, `ms`, `bps`, `seconds`).
+- **The owner's Setup page and clear settings (4.125.0).** Network's admin panel draws every namespace
+ from its summary schema, and lists on its Setup tab every field only the owner can give. These
+ annotations are checked when the store is created (a wrong shape refuses it, naming the rule) and pass
+ through to the summary as written:
+
+ | Annotation | Shape | What the panel does with it |
+ |---|---|---|
+ | `x-owner-input` | `{ group, unlocks, get_it_at?, get_it_how?, test? }` | A row on Setup. `group` is one of `config.OWNER_INPUT_GROUPS` (`money`, `ai-providers`, `gpus`, `voices`, `chat-apps`, `sign-in-apps`, `data-sources`, `design-publishing`, `domains`, `legal`); `unlocks` is the sentence "what it turns on"; `get_it_at` the https page at the provider; `get_it_how` one line on what to choose there; `test: true` when the service's tester (below) tests this key. |
+ | `x-required-for` | `"<feature>"` | Setup says **Needed** while the key is unset, naming what waits on it. Without it an unset key is **Optional**. |
+ | `x-enum-labels` | `{ <value>: { label, description? } }` | Every option of a choice in words and a line, never a raw value. Keys are the enum values (a non-string as its JSON: `"null"`, `"5"`); a label for a value the enum lacks is refused. |
+ | `x-advanced` | `true` | The field folds under a counted "Advanced settings (N)". |
+ | `x-example` | a string, number or boolean | The input's placeholder. Never a real value. |
+
+ A secret-class property loses `x-example` and `x-enum-labels` in the summary, with `default`,
+ `examples`, `enum` and `const` (say a key's shape in `get_it_how` instead: "It starts with sk-").
+- **`config.lintSchema(schema)`** returns `[{ path, key, message }]`, empty when every field reads
+ clearly: a field without a `title`, with a `description` under 30 characters (what it does, what
+ the default means, an example), or a choice (`enum`, or `items.enum`) without an `x-enum-labels`
+ label for every option is one entry. Fields are the top-level properties and the properties of an
+ object field; a summary is read through its `schema`. Run it in the service's own test on each
+ namespace it publishes: `assert.deepStrictEqual(config.lintSchema(SCHEMA), [])`.
- **Hooks.** `onChange({ namespace, keys, actor, revision })` runs after every successful activation
- **Hooks.** `onChange({ namespace, keys, actor, revision })` runs after every successful activation
(never for a proposal or the seed), and a throwing hook is logged and never refuses the revision.
(never for a proposal or the seed), and a throwing hook is logged and never refuses the revision.
`requireFresh(req, { namespace, keys })`, an `adminRoutes` option, runs before a secret write, a
`requireFresh(req, { namespace, keys })`, an `adminRoutes` option, runs before a secret write, a
@@ -359,6 +382,7 @@ config.adminRoutes([settings, other], {
@@ -359,6 +382,7 @@ config.adminRoutes([settings, other], {
actor: (req) => ({ type: 'user', id: req.user.subject_id }), // default: req.actor, req.subject, req.user.subject(_id)
actor: (req) => ({ type: 'user', id: req.user.subject_id }), // default: req.actor, req.subject, req.user.subject(_id)
authorize: (req, { action, namespace, keys }) => action === 'read' || isOwner(req.user) || !keys.some(isSecret),
authorize: (req, { action, namespace, keys }) => action === 'read' || isOwner(req.user) || !keys.some(isSecret),
requireFresh: (req, { namespace, keys }) => isFreshSignIn(req), // secrets and x-danger keys; false → 403
requireFresh: (req, { namespace, keys }) => isFreshSignIn(req), // secrets and x-danger keys; false → 403
+ test: async ({ namespace, key, value }) => ({ ok, message }), // optional: the Setup page's Test button
basePath: '/api/admin/config',
basePath: '/api/admin/config',
}).mount(app); // or { router }, the handlers, or handle(req, res, next) on plain http
}).mount(app); // or { router }, the handlers, or handle(req, res, next) on plain http
```
```
@@ -372,11 +396,21 @@ config.adminRoutes([settings, other], {
@@ -372,11 +396,21 @@ config.adminRoutes([settings, other], {
| `POST /api/admin/config/:namespace/rollback` `{ to?, reason }` | the new active snapshot; 409 `config.no_previous`, `config.not_good` |
| `POST /api/admin/config/:namespace/rollback` `{ to?, reason }` | the new active snapshot; 409 `config.no_previous`, `config.not_good` |
| `PUT /api/admin/config/:namespace/secrets/:key` `{ value, reason }` | the marker `{ redacted, fingerprint, hint, set_at, set_by, bound_to }`; write-only |
| `PUT /api/admin/config/:namespace/secrets/:key` `{ value, reason }` | the marker `{ redacted, fingerprint, hint, set_at, set_by, bound_to }`; write-only |
| `DELETE /api/admin/config/:namespace/secrets/:key` `{ reason }` | the empty marker; a new revision without the key |
| `DELETE /api/admin/config/:namespace/secrets/:key` `{ reason }` | the empty marker; a new revision without the key |
+| `POST /api/admin/config/:namespace/test/:key` | `{ ok, message }`: the service's `test` hook tried the stored value; 409 `config.not_set` when the key has no value, 404 `config.test_not_available` when there is no tester or it answers null for that key |
Errors are `application/problem+json` (`errors.problem@1`). Mount it after a JSON body parser, or
Errors are `application/problem+json` (`errors.problem@1`). Mount it after a JSON body parser, or
let it read the body itself. A normal POST cannot write a secret-class value: the two `/secrets`
let it read the body itself. A normal POST cannot write a secret-class value: the two `/secrets`
routes are the only write path, and they answer a marker, never the value.
routes are the only write path, and they answer a marker, never the value.
+The test hook is how the Setup page's **Test** button reaches a provider. `test({ namespace, key,
+value, values, store, req })` gets the stored value (a secret opened) on the server, calls the
+provider, and answers `{ ok, message }`, or null when it does not test that key. The route answers
+that in one line of at most 300 characters, scrubbed of every secret value of the namespace (a
+client id's tester may hold the client secret too), so it never echoes a key; a tester that throws
+is a failed test with its scrubbed message, and one that runs past `testTimeoutMs` (15 s) is a failed
+test as well. `authorize` sees the action `'test'`, and the route needs an actor like a write. It
+changes nothing and keeps nothing: the caller (Network's proxy) records the result.
+
### Releases: `/release.json`, open tabs and the mixed-version test (ADR-016, Track R)
### Releases: `/release.json`, open tabs and the mixed-version test (ADR-016, Track R)
```js
```js
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.124.0",
+ "version": "4.125.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -269,7 +269,96 @@ const UNITS = new Set(['nano_usd', 'ms', 'bps', 'seconds']);
@@ -269,7 +269,96 @@ const UNITS = new Set(['nano_usd', 'ms', 'bps', 'seconds']);
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,127}$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,127}$/;
const patterns = new WeakMap();
const patterns = new WeakMap();
-/** The owner-settings annotations of the summary (D11): x-env, x-danger, x-bind and x-unit are checked. */
+// ── The owner's Setup page (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07) ──
+// A field with x-owner-input is something only the owner can give (a key, an account, a cap). Network's admin panel
+// lists every such field of every service on one page, grouped by these ids, in this order.
+const OWNER_INPUT_GROUPS = Object.freeze({
+ money: 'Money',
+ 'ai-providers': 'AI providers',
+ gpus: 'GPUs',
+ voices: 'Voices',
+ 'chat-apps': 'Chat apps',
+ 'sign-in-apps': 'Sign-in apps',
+ 'data-sources': 'Data sources',
+ 'design-publishing': 'Design and publishing',
+ domains: 'Domains',
+ legal: 'Legal and registrations',
+});
+const OWNER_INPUT_MEMBERS = new Set(['group', 'unlocks', 'get_it_at', 'get_it_how', 'test']);
+const ENUM_LABEL_MEMBERS = new Set(['label', 'description']);
+const LINT_DESCRIPTION_MIN = 30;
+/** The x-enum-labels key of an enum value: a string as it is, anything else as its JSON (null → "null", 5 → "5"). */
+const enumKey = (v) => (typeof v === 'string' ? v : JSON.stringify(v));
+function isHttpsUrl(v) {
+ if (typeof v !== 'string' || v.length > 2000) return false;
+ try {
+ const u = new URL(v);
+ return u.protocol === 'https:' && !u.username && !u.password && !!u.hostname;
+ } catch { return false; }
+}
+const text = (v) => typeof v === 'string' && v.trim().length > 0;
+
+/** A Setup group as an id: the id itself, or its display name in any case ("Voices" → voices). Else null. */
+function ownerGroupId(g) {
+ if (typeof g !== 'string') return null;
+ if (has(OWNER_INPUT_GROUPS, g)) return g;
+ const want = g.trim().toLowerCase();
+ for (const [id, label] of Object.entries(OWNER_INPUT_GROUPS)) if (label.toLowerCase() === want || id === want) return id;
+ return null;
+}
+
+/**
+ * The Setup page's annotations (2026-11-07): x-owner-input, x-required-for, x-enum-labels, x-advanced, x-example.
+ * They only change how a field is presented, so a wrong shape never refuses the store (that would take a service down
+ * at boot over a label): the problem is returned here, the summary leaves that annotation out, the store warns once,
+ * and lintSchema reports it so the service's own tests fail. → [{ key, message }]
+ */
+function setupAnnotationProblems(s, at) {
+ const out = [];
+ const bad = (key, message) => out.push({ key, message: `config schema ${at}: ${message}` });
+ const owner = s['x-owner-input'];
+ if (owner !== undefined) {
+ if (!isPlainObject(owner)) bad('x-owner-input', 'x-owner-input must be an object { group, unlocks, get_it_at, get_it_how, test }');
+ else {
+ const unknown = Object.keys(owner).find((k) => !OWNER_INPUT_MEMBERS.has(k));
+ if (unknown !== undefined) bad('x-owner-input', `x-owner-input has an unknown member "${String(unknown).slice(0, 40)}"`);
+ else if (!ownerGroupId(owner.group)) bad('x-owner-input', `x-owner-input.group must be one of ${Object.keys(OWNER_INPUT_GROUPS).join(', ')}`);
+ else if (!text(owner.unlocks)) bad('x-owner-input', 'x-owner-input.unlocks must say what it turns on');
+ 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');
+ 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');
+ else if (owner.test !== undefined && typeof owner.test !== 'boolean') bad('x-owner-input', 'x-owner-input.test must be true or false');
+ }
+ }
+ 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)');
+ if (s['x-advanced'] !== undefined && typeof s['x-advanced'] !== 'boolean') bad('x-advanced', 'x-advanced must be true or false');
+ 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');
+ const labels = s['x-enum-labels'];
+ if (labels !== undefined) {
+ if (!isPlainObject(labels)) bad('x-enum-labels', 'x-enum-labels must be an object { value: { label, description } }');
+ else {
+ const choices = enumOf(s);
+ const known = choices ? new Set(choices.map(enumKey)) : null;
+ for (const [v, l] of Object.entries(labels)) {
+ const name = `x-enum-labels["${String(v).slice(0, 40)}"]`;
+ if (!isPlainObject(l) || !text(l.label)) { bad('x-enum-labels', `${name} needs a label`); break; }
+ const m = Object.keys(l).find((k) => !ENUM_LABEL_MEMBERS.has(k));
+ if (m !== undefined) { bad('x-enum-labels', `${name} has an unknown member "${String(m).slice(0, 40)}"`); break; }
+ if (l.description !== undefined && typeof l.description !== 'string') { bad('x-enum-labels', `${name}.description must be a string`); break; }
+ 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; }
+ }
+ }
+ }
+ return out;
+}
+// The Setup annotations left out of a schema node's summary (a node warns once per key: a second store built from
+// the same schema object stays quiet).
+const droppedSetup = new WeakMap();
+
+/**
+ * The owner-settings annotations. D11 (x-env, x-danger, x-bind, x-unit, x-group, title, description): a wrong shape
+ * refuses the store at creation, like any other schema mistake, because these decide where a value comes from and how
+ * it is guarded. The Setup ones (setupAnnotationProblems) are presentation: left out of the summary with a warning.
+ */
function checkAnnotations(s, at) {
function checkAnnotations(s, at) {
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, _)`);
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, _)`);
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`);
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`);
@@ -278,6 +367,70 @@ function checkAnnotations(s, at) {
@@ -278,6 +367,70 @@ function checkAnnotations(s, at) {
if (s['x-group'] !== undefined && typeof s['x-group'] !== 'string') throw new TypeError(`config schema ${at}: x-group must be a string`);
if (s['x-group'] !== undefined && typeof s['x-group'] !== 'string') throw new TypeError(`config schema ${at}: x-group must be a string`);
if (s.title !== undefined && typeof s.title !== 'string') throw new TypeError(`config schema ${at}: title must be a string`);
if (s.title !== undefined && typeof s.title !== 'string') throw new TypeError(`config schema ${at}: title must be a string`);
if (s.description !== undefined && typeof s.description !== 'string') throw new TypeError(`config schema ${at}: description must be a string`);
if (s.description !== undefined && typeof s.description !== 'string') throw new TypeError(`config schema ${at}: description must be a string`);
+ const problems = setupAnnotationProblems(s, at);
+ if (!problems.length) return;
+ const keys = droppedSetup.get(s) || new Set();
+ for (const p of problems) {
+ if (keys.has(p.key)) continue;
+ keys.add(p.key);
+ console.warn(`[config] ${p.message}; the Setup page leaves it out (fix it: config.lintSchema)`);
+ }
+ droppedSetup.set(s, keys);
+}
+
+/** A field's choices: its own enum, or its items' (a list of choices). */
+function enumOf(p) {
+ if (!isPlainObject(p)) return null;
+ if (Array.isArray(p.enum)) return p.enum;
+ if (isPlainObject(p.items) && Array.isArray(p.items.enum)) return p.items.enum;
+ return null;
+}
+
+/**
+ * lintSchema(schema) → [{ path, key, message }], empty when every field reads clearly in the admin panel. The clarity
+ * rules (design §2 rule 10) a service's own test runs on each namespace it publishes:
+ * - every field has a title in plain words;
+ * - every field has a description of at least 30 characters (what it does, what the default means, an example);
+ * - every choice (enum) has x-enum-labels with a label for each option.
+ * Fields are the schema's properties, and the properties of an object field (which the panel draws as a form).
+ * A namespace summary (anything with a `schema` member and no `properties`) is read through its schema.
+ * assert.deepStrictEqual(config.lintSchema(SCHEMA), []);
+ */
+function lintSchema(input) {
+ const schema = isPlainObject(input) && !has(input, 'properties') && has(input, 'schema') ? input.schema : input;
+ if (!isPlainObject(schema)) return [{ path: '#', key: null, message: 'is not a JSON Schema object' }];
+ const errors = [];
+ const walk = (props, at, prefix) => {
+ for (const [name, p] of Object.entries(props)) {
+ if (!isPlainObject(p)) continue;
+ const path = `${at}/properties/${name}`;
+ const key = prefix ? `${prefix}.${name}` : name;
+ if (!text(p.title)) errors.push({ path, key, message: 'has no title: give it a name in plain words' });
+ const description = typeof p.description === 'string' ? p.description.trim() : '';
+ if (description.length < LINT_DESCRIPTION_MIN) {
+ errors.push({ path, key, message: description
+ ? `has a description of ${description.length} characters; it needs at least ${LINT_DESCRIPTION_MIN} (what it does, what the default means, an example)`
+ : `has no description: say in at least ${LINT_DESCRIPTION_MIN} characters what it does, what the default means, and give an example` });
+ }
+ const choices = enumOf(p);
+ if (choices) {
+ const labels = isPlainObject(p['x-enum-labels']) ? p['x-enum-labels'] : null;
+ if (!labels) errors.push({ path, key, message: 'is a choice without x-enum-labels: give every option words and a line' });
+ else {
+ const missing = choices.map(enumKey).filter((v) => !isPlainObject(labels[v]) || !text(labels[v].label));
+ if (missing.length) errors.push({ path, key, message: `has no x-enum-labels label for ${missing.map((v) => `"${v}"`).join(', ')}` });
+ }
+ }
+ // The Setup annotations' shape (the store only warns about these, so the service's tests are where they fail).
+ for (const pr of setupAnnotationProblems(p, path)) errors.push({ path, key, message: pr.message.replace(/^config schema [^:]*: /, '') });
+ if (isPlainObject(p['x-owner-input']) && has(OWNER_INPUT_GROUPS, p['x-owner-input'].group) === false && ownerGroupId(p['x-owner-input'].group)) {
+ 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)}"` });
+ }
+ if (isPlainObject(p.properties)) walk(p.properties, path, key);
+ }
+ };
+ if (isPlainObject(schema.properties)) walk(schema.properties, '#', '');
+ return errors;
}
}
function compileSchema(s, at = '#') {
function compileSchema(s, at = '#') {
@@ -315,14 +468,35 @@ function schemaAnnotations(schema) {
@@ -315,14 +468,35 @@ function schemaAnnotations(schema) {
return out;
return out;
}
}
-/** A JSON Schema for the summary: secret-class properties lose default/examples, which could hold a secret. */
+/**
+ * A JSON Schema for the summary: secret-class properties lose default, examples, enum, const, x-example and
+ * x-enum-labels, any of which could hold a secret. Every other annotation (title, description, x-owner-input,
+ * x-required-for, x-advanced, …) passes through as the service wrote it.
+ */
function summarySchema(schema, isSecret) {
function summarySchema(schema, isSecret) {
if (schema == null) return null;
if (schema == null) return null;
const out = clone(schema);
const out = clone(schema);
+ // The Setup annotations checkAnnotations found wrong are left out (one warning at creation), and a group named by
+ // its display name is given as its id, so Network's Setup page groups it.
+ const tidy = (orig, copy) => {
+ if (!isPlainObject(orig) || !isPlainObject(copy)) return;
+ const dropped = droppedSetup.get(orig);
+ if (dropped) for (const k of dropped) delete copy[k];
+ if (isPlainObject(copy['x-owner-input'])) {
+ const id = ownerGroupId(copy['x-owner-input'].group);
+ if (id) copy['x-owner-input'].group = id;
+ }
+ if (isPlainObject(orig.properties) && isPlainObject(copy.properties)) {
+ for (const k of Object.keys(orig.properties)) tidy(orig.properties[k], copy.properties[k]);
+ }
+ if (isPlainObject(orig.items) && isPlainObject(copy.items)) tidy(orig.items, copy.items);
+ };
+ tidy(schema, out);
if (isPlainObject(out.properties)) {
if (isPlainObject(out.properties)) {
for (const [k, p] of Object.entries(out.properties)) {
for (const [k, p] of Object.entries(out.properties)) {
if (!isPlainObject(p) || !isSecret(k)) continue;
if (!isPlainObject(p) || !isSecret(k)) continue;
delete p.default; delete p.examples; delete p.enum; delete p.const;
delete p.default; delete p.examples; delete p.enum; delete p.const;
+ delete p['x-example']; delete p['x-enum-labels'];
}
}
}
}
return deepFreeze(out);
return deepFreeze(out);
@@ -453,4 +627,5 @@ function fromRows(rows, { prefix = '', type = null } = {}) {
@@ -453,4 +627,5 @@ function fromRows(rows, { prefix = '', type = null } = {}) {
}
}
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,
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,
- KEK_ID, KEY_BYTES, IV_BYTES, TAG_BYTES, isEnvelope, parseEnvelope, sealEnvelope, openEnvelope, isWrappedFingerprintKey, sealFingerprintKey, openFingerprintKey, keksOf, hintOf, bindText, schemaAnnotations, summarySchema };
+ KEK_ID, KEY_BYTES, IV_BYTES, TAG_BYTES, isEnvelope, parseEnvelope, sealEnvelope, openEnvelope, isWrappedFingerprintKey, sealFingerprintKey, openFingerprintKey, keksOf, hintOf, bindText, schemaAnnotations, summarySchema,
+ OWNER_INPUT_GROUPS, LINT_DESCRIPTION_MIN, lintSchema, enumKey };
@@ -806,6 +806,11 @@ async function createPgConfigStore(opts = {}) {
@@ -806,6 +806,11 @@ async function createPgConfigStore(opts = {}) {
for (const [k, p] of Object.entries(pins)) values[k] = clone(p.value);
for (const [k, p] of Object.entries(pins)) values[k] = clone(p.value);
const classification = classesFor(values, current ? current.classification : {});
const classification = classesFor(values, current ? current.classification : {});
const shown = redact(values, classification);
const shown = redact(values, classification);
+ // Every property the schema names is classified too, set or not (4.125.0): a form then knows a key is a
+ // secret before anyone has entered it, and offers the write-only route instead of a plain field.
+ if (schema && isPlainObject(schema.properties)) {
+ for (const k of Object.keys(schema.properties)) if (!has(classification, k)) classification[k] = classOf(k, undefined);
+ }
const secrets = {};
const secrets = {};
for (const k of Object.keys(classification)) {
for (const k of Object.keys(classification)) {
if (classification[k] !== 'secret' || !has(values, k)) continue;
if (classification[k] !== 'secret' || !has(values, k)) continue;
@@ -1,6 +1,9 @@
@@ -1,6 +1,9 @@
'use strict';
'use strict';
/** Configuration store on an openvibe-sdk/db handle. The store is ready when the promise resolves. */
/** Configuration store on an openvibe-sdk/db handle. The store is ready when the promise resolves. */
-const { fromRows, canonical, ConfigError, subjectRef, isPlainObject, isMarker, makeLog, scrub, errText } = require('./config-core');
+const { fromRows, canonical, ConfigError, subjectRef, isPlainObject, isMarker, makeLog, scrub, errText, lintSchema, OWNER_INPUT_GROUPS } = require('./config-core');
+
+const TEST_TIMEOUT_MS = 15000;
+const TEST_MESSAGE_MAX = 300;
function createConfigStore(opts = {}) {
function createConfigStore(opts = {}) {
if (!opts.db || typeof opts.db.prepare !== 'function' || typeof opts.db.tx !== 'function') {
if (!opts.db || typeof opts.db.prepare !== 'function' || typeof opts.db.tx !== 'function') {
@@ -63,6 +66,7 @@ function readBody(req, limit = 256 * 1024) {
@@ -63,6 +66,7 @@ function readBody(req, limit = 256 * 1024) {
* POST <base>/:namespace/rollback { to?, reason } → the new revision
* POST <base>/:namespace/rollback { to?, reason } → the new revision
* PUT <base>/:namespace/secrets/:key { value, reason } → the marker (write-only)
* PUT <base>/:namespace/secrets/:key { value, reason } → the marker (write-only)
* DELETE <base>/:namespace/secrets/:key { reason } → the empty marker (write-only)
* DELETE <base>/:namespace/secrets/:key { reason } → the empty marker (write-only)
+ * POST <base>/:namespace/test/:key → { ok, message }: the service's tester tried the stored value
* requireAdmin (a middleware or a list) guards every handler, mounted or called directly. actor(req)
* requireAdmin (a middleware or a list) guards every handler, mounted or called directly. actor(req)
* names who acts (default: req.user.subject_id and friends; none → 403). authorize(req, { action:
* names who acts (default: req.user.subject_id and friends; none → 403). authorize(req, { action:
* 'read' | 'write', namespace, keys }) may refuse more (false → 403). requireFresh(req, { namespace,
* 'read' | 'write', namespace, keys }) may refuse more (false → 403). requireFresh(req, { namespace,
@@ -72,6 +76,13 @@ function readBody(req, limit = 256 * 1024) {
@@ -72,6 +76,13 @@ function readBody(req, limit = 256 * 1024) {
* pass `router` (anything with get/post/put/delete) or call mount(router), use the handlers, or
* pass `router` (anything with get/post/put/delete) or call mount(router), use the handlers, or
* handle(req, res, next) for a plain http server. The body is req.body when a JSON parser ran, else
* handle(req, res, next) for a plain http server. The body is req.body when a JSON parser ran, else
* read from the request.
* read from the request.
+ *
+ * test (optional) is the service's tester for the owner's Setup page: async ({ namespace, key, value, values, store,
+ * req }) → { ok, message }, or null/undefined when it does not test that key (404 config.test_not_available). It gets
+ * the stored value (a secret opened) and calls the provider with it on the server; the answer is one line, at most
+ * 300 characters, scrubbed of every secret value of the namespace, so it can never echo a key. A tester that throws is
+ * a failed test with its message (scrubbed the same way); one that runs longer than testTimeoutMs (15 s) is a failed
+ * test too. authorize sees action 'test'. A key with no value is 409 config.not_set and the tester is not called.
*/
*/
function adminRoutes(stores, o = {}) {
function adminRoutes(stores, o = {}) {
const list = [].concat(stores || []);
const list = [].concat(stores || []);
@@ -88,6 +99,9 @@ function adminRoutes(stores, o = {}) {
@@ -88,6 +99,9 @@ function adminRoutes(stores, o = {}) {
const authorize = o.authorize || null;
const authorize = o.authorize || null;
const requireFresh = o.requireFresh || null;
const requireFresh = o.requireFresh || null;
if (requireFresh !== null && typeof requireFresh !== 'function') throw new TypeError('adminRoutes: requireFresh must be a function');
if (requireFresh !== null && typeof requireFresh !== 'function') throw new TypeError('adminRoutes: requireFresh must be a function');
+ const tester = o.test || null;
+ if (tester !== null && typeof tester !== 'function') throw new TypeError('adminRoutes: test must be a function');
+ const testTimeoutMs = Number.isInteger(o.testTimeoutMs) && o.testTimeoutMs > 0 ? o.testTimeoutMs : TEST_TIMEOUT_MS;
const log = makeLog(o.log);
const log = makeLog(o.log);
const allowed = async (req, ctx) => !authorize || (await authorize(req, ctx)) !== false;
const allowed = async (req, ctx) => !authorize || (await authorize(req, ctx)) !== false;
@@ -186,6 +200,48 @@ function adminRoutes(stores, o = {}) {
@@ -186,6 +200,48 @@ function adminRoutes(stores, o = {}) {
await fresh(req, s, [key]);
await fresh(req, s, [key]);
send(res, 200, await s.deleteSecret(key, { actor, reason: body.reason }));
send(res, 200, await s.deleteSecret(key, { actor, reason: body.reason }));
},
},
+ async test(req, res) {
+ const s = storeFor(req);
+ const key = keyOf(req);
+ if (!(await allowed(req, { action: 'test', namespace: s.namespace, keys: [key] }))) throw new ConfigError('config.forbidden', 'not allowed to test this key', { status: 403 });
+ const actor = await actorOf(req);
+ if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });
+ if (!tester) throw new ConfigError('config.test_not_available', 'this service has no tests for its settings', { status: 404 });
+ const values = s.get();
+ const value = values[key];
+ if (value === undefined || value === null || value === '') throw new ConfigError('config.not_set', `${key} is not set, so there is nothing to test`, { status: 409 });
+ // Every secret value of the namespace is scrubbed from the answer, not only the tested one: a tester for a
+ // client id may well have the client secret in hand.
+ const classes = await s.classes({}, { merge: true });
+ const secrets = Object.entries(values)
+ .filter(([k, v]) => (classes[k] || s.classify(k, v)) === 'secret')
+ .map(([, v]) => (typeof v === 'string' ? v : canonical(v)))
+ .filter((v) => v.length >= 4)
+ .sort((a, b) => b.length - a.length);
+ const line = (m) => {
+ const one = String(m == null ? '' : m).replace(/\s+/g, ' ').trim();
+ return one ? scrub(one, secrets).slice(0, TEST_MESSAGE_MAX) : '';
+ };
+ const seconds = Math.max(1, Math.ceil(testTimeoutMs / 1000));
+ let timer = null;
+ let result;
+ try {
+ result = await Promise.race([
+ Promise.resolve().then(() => tester({ namespace: s.namespace, key, value, values, store: s, req })),
+ new Promise((resolve) => { timer = setTimeout(() => resolve({ ok: false, message: `The test did not finish in ${seconds} second${seconds === 1 ? '' : 's'}.` }), testTimeoutMs); }),
+ ]);
+ } catch (err) {
+ if (err instanceof ConfigError) throw err;
+ result = { ok: false, message: errText(err) };
+ } finally {
+ clearTimeout(timer);
+ }
+ if (result == null) throw new ConfigError('config.test_not_available', `${key} has no test`, { status: 404 });
+ const ok = result === true || (isPlainObject(result) && result.ok === true);
+ const message = line(isPlainObject(result) ? result.message : '') || (ok ? 'It works.' : 'It did not work.');
+ log.info(`[config] ${s.namespace} ${key}: test ${ok ? 'passed' : 'failed'}`);
+ send(res, 200, { ok, message });
+ },
};
};
function fail(res, err) {
function fail(res, err) {
@@ -216,12 +272,13 @@ function adminRoutes(stores, o = {}) {
@@ -216,12 +272,13 @@ function adminRoutes(stores, o = {}) {
{ method: 'post', path: `${basePath}/:namespace/rollback`, handler: out.rollback },
{ method: 'post', path: `${basePath}/:namespace/rollback`, handler: out.rollback },
{ method: 'put', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretPut },
{ method: 'put', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretPut },
{ method: 'delete', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretDelete },
{ method: 'delete', path: `${basePath}/:namespace/secrets/:key`, handler: out.secretDelete },
+ { method: 'post', path: `${basePath}/:namespace/test/:key`, handler: out.test },
];
];
function mount(router) {
function mount(router) {
for (const r of routes) router[r.method](r.path, r.handler);
for (const r of routes) router[r.method](r.path, r.handler);
return router;
return router;
}
}
- /** For a plain http server: dispatches the seven routes, calls next() (or answers 404) for anything else. */
+ /** For a plain http server: dispatches the eight routes, calls next() (or answers 404) for anything else. */
function handle(req, res, next) {
function handle(req, res, next) {
const url = new URL(req.url || '/', 'http://x');
const url = new URL(req.url || '/', 'http://x');
const method = String(req.method || 'GET').toLowerCase();
const method = String(req.method || 'GET').toLowerCase();
@@ -236,6 +293,7 @@ function adminRoutes(stores, o = {}) {
@@ -236,6 +293,7 @@ function adminRoutes(stores, o = {}) {
else if (parts.length === 2 && parts[1] === 'rollback' && method === 'post') m = ['rollback', parts[0]];
else if (parts.length === 2 && parts[1] === 'rollback' && method === 'post') m = ['rollback', parts[0]];
else if (parts.length === 3 && parts[1] === 'secrets' && method === 'put') m = ['secretPut', parts[0], parts[2]];
else if (parts.length === 3 && parts[1] === 'secrets' && method === 'put') m = ['secretPut', parts[0], parts[2]];
else if (parts.length === 3 && parts[1] === 'secrets' && method === 'delete') m = ['secretDelete', parts[0], parts[2]];
else if (parts.length === 3 && parts[1] === 'secrets' && method === 'delete') m = ['secretDelete', parts[0], parts[2]];
+ else if (parts.length === 3 && parts[1] === 'test' && method === 'post') m = ['test', parts[0], parts[2]];
}
}
if (!m) return typeof next === 'function' ? next() : sendProblem(res, 404, 'config.route_not_found', { detail: 'no such configuration route' });
if (!m) return typeof next === 'function' ? next() : sendProblem(res, 404, 'config.route_not_found', { detail: 'no such configuration route' });
let ns = m[1] || '';
let ns = m[1] || '';
@@ -250,4 +308,4 @@ function adminRoutes(stores, o = {}) {
@@ -250,4 +308,4 @@ function adminRoutes(stores, o = {}) {
return { ...out, routes, mount, handle };
return { ...out, routes, mount, handle };
}
}
-module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, configSchema: (...a) => require('./config-pg').configSchema(...a) };
+module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, lintSchema, OWNER_INPUT_GROUPS, configSchema: (...a) => require('./config-pg').configSchema(...a) };
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.124.0",
+ "version": "4.125.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,273 @@
@@ -0,0 +1,273 @@
+'use strict';
+// The owner's Setup page and the clear admin panel (OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07):
+// - the annotations x-owner-input, x-required-for, x-enum-labels, x-advanced and x-example pass through to the
+// summary's schema (a secret property loses x-example and x-enum-labels); a wrong one is left out with a warning,
+// never a refused store, and lintSchema names it; a group's display name is read as its id;
+// - lintSchema fails a field without a title, a description under 30 characters, and a choice without labels;
+// - POST <base>/:namespace/test/:key calls the service's tester with the stored value and answers { ok, message } in
+// one line, never echoing a secret, on Express and on a plain http server.
+const assert = require('assert');
+const crypto = require('crypto');
+let createDb;
+try { ({ createDb } = require('openvibe-sdk/db')); } catch { console.log('config-owner-setup: skipped (openvibe-sdk not installed)'); process.exit(0); }
+const express = require('express');
+const http = require('http');
+const { configSchema } = require('../config-pg');
+const config = require('../config');
+const { createConfigStore, adminRoutes, lintSchema, OWNER_INPUT_GROUPS } = config;
+
+const USER = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
+const SECRET = 'MADE-UP-BOT-TOKEN-5e1d9a7c3b';
+const CLIENT_SECRET = 'MADE-UP-CLIENT-SECRET-8f2b6d4a';
+const KEKS = { current: 'k1', keys: { k1: crypto.randomBytes(32) } };
+const logs = [];
+const log = (m, level) => logs.push(`${level} ${m}`);
+
+const LONG = 'A sentence long enough to explain what it does and what the default means.';
+const SCHEMA = {
+ title: 'Chat apps', description: 'The bot tokens the agent uses to answer in chat apps.',
+ type: 'object', additionalProperties: false,
+ properties: {
+ telegram_bot_token: {
+ type: 'string', title: 'Telegram bot token', description: 'Lets agents answer in Telegram. Without it the Telegram option stays hidden.',
+ 'x-owner-input': { group: 'chat-apps', unlocks: 'Lets agents answer in Telegram.', get_it_at: 'https://core.telegram.org/bots#how-do-i-create-a-bot', get_it_how: 'Ask @BotFather for /newbot and copy the token it gives you.', test: true },
+ 'x-required-for': 'Agents in Telegram',
+ 'x-example': '123456:ABC-DEF',
+ },
+ slack_client_id: { type: 'string', title: 'Slack client id', description: LONG, 'x-owner-input': { group: 'chat-apps', unlocks: 'Lets agents answer in Slack.' }, 'x-example': '1234.5678' },
+ slack_client_secret: { type: 'string', title: 'Slack client secret', description: LONG, 'x-owner-input': { group: 'chat-apps', unlocks: 'Lets agents answer in Slack.', test: true } },
+ mode: {
+ type: 'string', title: 'Answer mode', description: LONG, enum: ['quiet', 'chatty'], 'x-advanced': true,
+ 'x-enum-labels': { quiet: { label: 'Only when asked', description: 'Answers a message that names it.' }, chatty: { label: 'Joins in', description: 'Answers when it has something to add.' } },
+ },
+ unused: { type: 'string', title: 'Unused', description: LONG },
+ },
+};
+const classify = (k) => (/_(token|secret)$/.test(k) ? 'secret' : undefined);
+
+(async () => {
+ // ── The groups the Setup page shows, in order ──
+ assert.deepStrictEqual(Object.keys(OWNER_INPUT_GROUPS), ['money', 'ai-providers', 'gpus', 'voices', 'chat-apps', 'sign-in-apps', 'data-sources', 'design-publishing', 'domains', 'legal']);
+ assert.ok(Object.isFrozen(OWNER_INPUT_GROUPS));
+
+ const db = createDb({ pglite: true, log: { log() {}, warn() {} } });
+ await db.query(configSchema());
+
+ // ── A wrong Setup annotation never refuses the store (a label must not take a service down at boot): the store
+ // starts, warns once, the summary leaves that annotation out, and lintSchema names the rule for the tests ──
+ let n = 0;
+ const warned = [];
+ const realWarn = console.warn;
+ const leftOut = async (prop, key, re) => {
+ n += 1;
+ const schema = { type: 'object', properties: { k: { type: 'string', title: 'K', description: 'A field used only to check one annotation rule.', ...prop } } };
+ warned.length = 0;
+ console.warn = (...a) => warned.push(a.join(' '));
+ let st;
+ try { st = await createConfigStore({ db, service: 'chat', namespace: `chat.bad${n}`, schema, log: false }); } finally { console.warn = realWarn; }
+ assert.ok(warned.some((w) => re.test(w)), `the store warns about ${key}: ${warned.join(' | ')}`);
+ const sum = await st.summary();
+ assert.strictEqual(sum.schema.properties.k[key], undefined, `the summary leaves out the wrong ${key}`);
+ assert.strictEqual(sum.schema.properties.k.title, 'K', 'the rest of the field is untouched');
+ assert.ok(lintSchema(schema).some((e) => re.test(e.message)), `lintSchema names the rule for ${key}`);
+ };
+ await leftOut({ 'x-owner-input': 'yes' }, 'x-owner-input', /x-owner-input must be an object/);
+ await leftOut({ 'x-owner-input': { group: 'snacks', unlocks: 'x' } }, 'x-owner-input', /x-owner-input\.group must be one of money/);
+ await leftOut({ 'x-owner-input': { group: 'money' } }, 'x-owner-input', /x-owner-input\.unlocks/);
+ await leftOut({ 'x-owner-input': { group: 'money', unlocks: 'x', get_it_at: 'http://example.com' } }, 'x-owner-input', /get_it_at must be an https URL/);
+ await leftOut({ 'x-owner-input': { group: 'money', unlocks: 'x', get_it_at: 'https://user:[email protected]' } }, 'x-owner-input', /get_it_at must be an https URL/);
+ await leftOut({ 'x-owner-input': { group: 'money', unlocks: 'x', get_it_how: ' ' } }, 'x-owner-input', /get_it_how/);
+ await leftOut({ 'x-owner-input': { group: 'money', unlocks: 'x', test: 'yes' } }, 'x-owner-input', /x-owner-input\.test must be true or false/);
+ await leftOut({ 'x-owner-input': { group: 'money', unlocks: 'x', get_it_url: 'https://a.example' } }, 'x-owner-input', /unknown member "get_it_url"/);
+ await leftOut({ 'x-required-for': '' }, 'x-required-for', /x-required-for/);
+ await leftOut({ 'x-advanced': 'yes' }, 'x-advanced', /x-advanced must be true or false/);
+ await leftOut({ 'x-example': { a: 1 } }, 'x-example', /x-example must be/);
+ await leftOut({ enum: ['a'], 'x-enum-labels': ['a'] }, 'x-enum-labels', /x-enum-labels must be an object/);
+ await leftOut({ enum: ['a'], 'x-enum-labels': { a: { description: 'no label' } } }, 'x-enum-labels', /x-enum-labels\["a"\] needs a label/);
+ await leftOut({ enum: ['a'], 'x-enum-labels': { a: { label: 'A', note: 'x' } } }, 'x-enum-labels', /unknown member "note"/);
+ await leftOut({ enum: ['a'], 'x-enum-labels': { b: { label: 'B' } } }, 'x-enum-labels', /names "b", which is not one of the enum values/);
+
+ // A group given by its display name still groups (as its id), and lintSchema asks for the id.
+ {
+ const schema = { type: 'object', properties: { k: { type: 'string', title: 'K', description: 'A voice key the owner gives on the Setup page.', 'x-owner-input': { group: 'Voices', unlocks: 'Speech in chat' } } } };
+ const st = await createConfigStore({ db, service: 'chat', namespace: 'chat.voicesname', schema, log: false });
+ assert.strictEqual((await st.summary()).schema.properties.k['x-owner-input'].group, 'voices');
+ assert.ok(lintSchema(schema).some((e) => /display name: use its id "voices"/.test(e.message)));
+ }
+ // The D11 annotations still refuse: they decide where a value comes from and how it is guarded.
+ await assert.rejects(createConfigStore({ db, service: 'chat', namespace: 'chat.badenv', schema: { type: 'object', properties: { k: { type: 'string', 'x-env': 'lower-case' } } }, log: false }), /x-env must be an env name/);
+ await assert.rejects(createConfigStore({ db, service: 'chat', namespace: 'chat.baddanger', schema: { type: 'object', properties: { k: { type: 'string', 'x-danger': 'yes' } } }, log: false }), /x-danger must be true or false/);
+
+ // ── A good schema: the annotations reach the summary; a secret property loses its example and labels ──
+ const store = await createConfigStore({ db, service: 'chat', namespace: 'chat.apps', schema: SCHEMA, classify, keks: KEKS, log });
+ let summary = await store.summary();
+ assert.strictEqual(summary.values.telegram_bot_token, undefined, 'nothing is set yet');
+ assert.strictEqual(summary.classification.telegram_bot_token, 'secret', 'an unset secret is classified, so a form offers the write-only route');
+ assert.strictEqual(summary.classification.slack_client_id, 'internal', 'every schema property is classified');
+ const props = summary.schema.properties;
+ assert.deepStrictEqual(props.telegram_bot_token['x-owner-input'], SCHEMA.properties.telegram_bot_token['x-owner-input'], 'x-owner-input passes through');
+ assert.strictEqual(props.telegram_bot_token['x-required-for'], 'Agents in Telegram');
+ assert.strictEqual(props.telegram_bot_token['x-example'], undefined, 'a secret property loses x-example');
+ assert.strictEqual(props.slack_client_id['x-example'], '1234.5678', 'a plain property keeps x-example');
+ assert.strictEqual(props.mode['x-advanced'], true);
+ assert.deepStrictEqual(props.mode['x-enum-labels'], SCHEMA.properties.mode['x-enum-labels']);
+ // A label set on a choice that is a secret goes with its enum.
+ const secretChoice = await createConfigStore({ db, service: 'chat', namespace: 'chat.secretchoice', classify: () => 'secret', log: false,
+ schema: { type: 'object', properties: { pick_secret: { type: 'string', enum: ['a', 'b'], 'x-enum-labels': { a: { label: 'A' }, b: { label: 'B' } } } } } });
+ const scProps = (await secretChoice.summary()).schema.properties.pick_secret;
+ assert.strictEqual(scProps.enum, undefined);
+ assert.strictEqual(scProps['x-enum-labels'], undefined, 'a secret choice loses its labels with its enum');
+
+ // ── lintSchema: clean, then each of the three failures ──
+ assert.deepStrictEqual(lintSchema(SCHEMA), [], 'a clear schema has nothing to fix');
+ assert.deepStrictEqual(lintSchema(summary), [], 'a summary is linted through its schema');
+ const lint = lintSchema({
+ type: 'object',
+ properties: {
+ no_title: { type: 'string', description: LONG },
+ short: { type: 'string', title: 'Short', description: 'Too short to help.' },
+ none: { type: 'integer', title: 'None' },
+ pick: { type: 'string', title: 'Pick', description: LONG, enum: ['a', 'b'] },
+ half: { type: 'string', title: 'Half', description: LONG, enum: ['a', 'b', null], 'x-enum-labels': { a: { label: 'A' } } },
+ tags: { type: 'array', title: 'Tags', description: LONG, items: { type: 'string', enum: ['x'] } },
+ limits: { type: 'object', title: 'Limits', description: LONG, properties: { max: { type: 'integer', description: LONG } } },
+ },
+ });
+ const byKey = (key) => lint.filter((e) => e.key === key).map((e) => e.message);
+ assert.deepStrictEqual(byKey('no_title'), ['has no title: give it a name in plain words']);
+ assert.match(byKey('short')[0], /description of 18 characters; it needs at least 30/);
+ assert.match(byKey('none')[0], /has no description/);
+ assert.deepStrictEqual(byKey('pick'), ['is a choice without x-enum-labels: give every option words and a line']);
+ assert.deepStrictEqual(byKey('half'), ['has no x-enum-labels label for "b", "null"']);
+ assert.deepStrictEqual(byKey('tags'), ['is a choice without x-enum-labels: give every option words and a line'], 'a list of choices needs labels too');
+ assert.deepStrictEqual(byKey('limits.max'), ['has no title: give it a name in plain words'], 'an object field\'s own fields are linted');
+ assert.strictEqual(lint.find((e) => e.key === 'limits.max').path, '#/properties/limits/properties/max');
+ assert.deepStrictEqual(lintSchema(null), [{ path: '#', key: null, message: 'is not a JSON Schema object' }]);
+
+ // ── The test hook ──
+ await store.setSecret('telegram_bot_token', SECRET, { actor: USER, reason: 'setup' });
+ await store.setSecret('slack_client_secret', CLIENT_SECRET, { actor: USER, reason: 'setup' });
+ await store.apply({ slack_client_id: '1234.5678' }, { merge: true, actor: USER, reason: 'setup' });
+ const tried = [];
+ let behaviour = 'ok';
+ const tester = async ({ namespace, key, value, values, store: s, req }) => {
+ tried.push({ namespace, key, value, hasValues: !!values, hasStore: typeof s.get === 'function', hasReq: !!req });
+ if (key === 'unused') return null;
+ if (behaviour === 'ok') return { ok: true, message: 'Telegram knows the bot\n@openvibe_bot.' };
+ if (behaviour === 'echo') return { ok: false, message: `401 Unauthorized: token ${value} and ${CLIENT_SECRET} were refused` };
+ if (behaviour === 'throw') throw new Error(`connect ECONNREFUSED with key=${value}`);
+ if (behaviour === 'hang') return new Promise(() => {});
+ if (behaviour === 'long') return { ok: false, message: 'x'.repeat(1000) };
+ return { ok: true };
+ };
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => { req.user = req.headers['x-test-user'] === 'owner' ? { role: 'admin', owner: true, subject_id: USER.id } : req.headers['x-test-user'] === 'admin' ? { role: 'admin', subject_id: USER.id } : null; next(); });
+ const requireAdmin = (req, res, next) => (req.user ? next() : res.status(403).json({ error: 'Admin only' }));
+ const actions = [];
+ const authorize = (req, { action }) => { actions.push(action); return action === 'read' || !!req.user.owner; };
+ adminRoutes([store], { requireAdmin, authorize, actor: (req) => ({ type: 'user', id: req.user.subject_id }), test: tester, testTimeoutMs: 300, log }).mount(app);
+ const server = app.listen(0);
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/admin/config`;
+ const texts = [];
+ const post = async (path, user = 'owner') => {
+ const res = await fetch(base + path, { method: 'POST', headers: { 'x-test-user': user } });
+ const text = await res.text();
+ texts.push(text);
+ return { status: res.status, body: text ? JSON.parse(text) : null };
+ };
+ try {
+ let r = await post('/chat.apps/test/telegram_bot_token', 'nobody');
+ assert.strictEqual(r.status, 403, 'requireAdmin guards the test route');
+ r = await post('/chat.apps/test/telegram_bot_token', 'admin');
+ assert.strictEqual(r.status, 403, 'authorize sees the test');
+ assert.strictEqual(r.body.code, 'config.forbidden');
+ assert.strictEqual(actions.at(-1), 'test', 'authorize is asked for the action "test"');
+ assert.strictEqual(tried.length, 0, 'a refused test never reaches the tester');
+
+ r = await post('/chat.apps/test/telegram_bot_token');
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.deepStrictEqual(r.body, { ok: true, message: 'Telegram knows the bot @openvibe_bot.' }, 'one line, nothing else');
+ assert.deepStrictEqual(tried.at(-1), { namespace: 'chat.apps', key: 'telegram_bot_token', value: SECRET, hasValues: true, hasStore: true, hasReq: true }, 'the tester gets the opened value on the server');
+
+ behaviour = 'echo';
+ r = await post('/chat.apps/test/telegram_bot_token');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.ok, false);
+ assert.ok(!r.body.message.includes(SECRET) && !r.body.message.includes(CLIENT_SECRET), 'every secret of the namespace is scrubbed');
+ assert.match(r.body.message, /^401 Unauthorized: token \[redacted\] and \[redacted\] were refused$/);
+
+ behaviour = 'throw';
+ r = await post('/chat.apps/test/slack_client_secret');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.ok, false, 'a tester that throws is a failed test');
+ assert.ok(!r.body.message.includes(CLIENT_SECRET));
+ assert.match(r.body.message, /ECONNREFUSED/);
+
+ behaviour = 'hang';
+ r = await post('/chat.apps/test/telegram_bot_token');
+ assert.deepStrictEqual(r.body, { ok: false, message: 'The test did not finish in 1 second.' }, 'a tester that hangs fails after testTimeoutMs');
+
+ behaviour = 'long';
+ r = await post('/chat.apps/test/telegram_bot_token');
+ assert.strictEqual(r.body.message.length, 300, 'the answer is at most 300 characters');
+
+ behaviour = 'bare';
+ r = await post('/chat.apps/test/slack_client_id');
+ assert.deepStrictEqual(r.body, { ok: true, message: 'It works.' }, 'a tester without a message gets words');
+
+ r = await post('/chat.apps/test/unused');
+ assert.strictEqual(r.status, 409, 'a key with no value is not tested');
+ assert.strictEqual(r.body.code, 'config.not_set');
+ await store.apply({ unused: 'x' }, { merge: true, actor: USER, reason: 'set' });
+ r = await post('/chat.apps/test/unused');
+ assert.strictEqual(r.status, 404, 'a key the tester does not test');
+ assert.strictEqual(r.body.code, 'config.test_not_available');
+ r = await post('/chat.nope/test/unused');
+ assert.strictEqual(r.status, 404);
+ assert.strictEqual(r.body.code, 'config.namespace_not_found');
+ } finally {
+ await new Promise((r) => server.close(r));
+ }
+
+ // ── Without a tester, on a plain http server through handle() ──
+ const plain = adminRoutes([store], { requireAdmin: (_q, _s, nx) => nx(), actor: () => USER });
+ const plainServer = http.createServer((req, res) => plain.handle(req, res));
+ plainServer.listen(0);
+ await new Promise((r) => plainServer.once('listening', r));
+ try {
+ const res = await fetch(`http://127.0.0.1:${plainServer.address().port}/api/admin/config/chat.apps/test/telegram_bot_token`, { method: 'POST' });
+ const body = await res.json();
+ texts.push(JSON.stringify(body));
+ assert.strictEqual(res.status, 404);
+ assert.strictEqual(body.code, 'config.test_not_available', 'a service without a tester says so');
+ } finally {
+ await new Promise((r) => plainServer.close(r));
+ }
+ // With a tester, through handle() too.
+ const withTest = adminRoutes([store], { requireAdmin: (_q, _s, nx) => nx(), actor: () => USER, test: () => ({ ok: true, message: 'fine' }), log: false });
+ const s2 = http.createServer((req, res) => withTest.handle(req, res));
+ s2.listen(0);
+ await new Promise((r) => s2.once('listening', r));
+ try {
+ const res = await fetch(`http://127.0.0.1:${s2.address().port}/api/admin/config/chat.apps/test/telegram_bot_token`, { method: 'POST' });
+ assert.deepStrictEqual(await res.json(), { ok: true, message: 'fine' });
+ const wrongMethod = await fetch(`http://127.0.0.1:${s2.address().port}/api/admin/config/chat.apps/test/telegram_bot_token`);
+ assert.strictEqual(wrongMethod.status, 404, 'only POST tests');
+ } finally {
+ await new Promise((r) => s2.close(r));
+ }
+ assert.throws(() => adminRoutes([store], { requireAdmin: (_q, _s, nx) => nx(), test: 'yes' }), /test must be a function/);
+ assert.ok(withTest.routes.some((r) => r.method === 'post' && r.path === '/api/admin/config/:namespace/test/:key'), 'the route is in the list mount() uses');
+
+ // ── No secret anywhere: answers, the log, the summary ──
+ for (const t of texts) assert.ok(!t.includes(SECRET) && !t.includes(CLIENT_SECRET), 'a route answer leaks a secret');
+ const allLogs = logs.join('\n');
+ assert.ok(!allLogs.includes(SECRET) && !allLogs.includes(CLIENT_SECRET), 'the log leaks a secret');
+ assert.ok(logs.some((l) => /chat\.apps telegram_bot_token: test (passed|failed)/.test(l)), 'a test is logged by name');
+ summary = await store.summary();
+ assert.ok(!JSON.stringify(summary).includes(SECRET));
+
+ console.log('config owner setup: annotations, lintSchema and the test hook ok');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -629,11 +629,12 @@ function counting(db) {
@@ -629,11 +629,12 @@ function counting(db) {
direct.list({ url: '/api/admin/config', admin: true }, allowedRes);
direct.list({ url: '/api/admin/config', admin: true }, allowedRes);
await sleep(5);
await sleep(5);
assert.strictEqual(allowedRes.statusCode, 200);
assert.strictEqual(allowedRes.statusCode, 200);
- assert.strictEqual(direct.routes.length, 7);
+ assert.strictEqual(direct.routes.length, 8);
assert.deepStrictEqual(direct.routes.map((x) => `${x.method.toUpperCase()} ${x.path}`), [
assert.deepStrictEqual(direct.routes.map((x) => `${x.method.toUpperCase()} ${x.path}`), [
'GET /api/admin/config', 'GET /api/admin/config/:namespace', 'GET /api/admin/config/:namespace/history',
'GET /api/admin/config', 'GET /api/admin/config/:namespace', 'GET /api/admin/config/:namespace/history',
'POST /api/admin/config/:namespace', 'POST /api/admin/config/:namespace/rollback',
'POST /api/admin/config/:namespace', 'POST /api/admin/config/:namespace/rollback',
'PUT /api/admin/config/:namespace/secrets/:key', 'DELETE /api/admin/config/:namespace/secrets/:key',
'PUT /api/admin/config/:namespace/secrets/:key', 'DELETE /api/admin/config/:namespace/secrets/:key',
+ 'POST /api/admin/config/:namespace/test/:key',
]);
]);
assertNoLeak(logs.join('\n'), (await leakForms(db)).concat(await leakForms(legacyDb)), 'the whole log');
assertNoLeak(logs.join('\n'), (await leakForms(db)).concat(await leakForms(legacyDb)), 'the whole log');