openvibers/openvibe.shared Commits

4.125.0: Setup annotations, lintSchema and the config test hook (#310)

OP OpenVibers committed 6 days ago · 02c16183ed · 9 files, +565 −11

* 4.125.0: Setup annotations, lintSchema and the config test hook

openvibe-shared/config checks and passes through the annotations Network's Setup tab and clear admin
panel read: x-owner-input { group, unlocks, get_it_at, get_it_how, test } (groups in
config.OWNER_INPUT_GROUPS), x-required-for, x-enum-labels, x-advanced and x-example. A wrong shape
refuses the store at creation; a secret property loses x-example and x-enum-labels in the summary.

config.lintSchema(schema) lists a field without a title, a description under 30 characters, or a
choice without a label for every option, for each service's own test.

adminRoutes takes an optional test hook: POST <base>/:namespace/test/:key calls it 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, 404 config.test_not_available, authorize action 'test'.

* 4.125.0: the summary classifies every schema property, set or not

An unset secret key was missing from the summary's classification, so the admin panel drew it as a
plain field and the service then refused the value (422 config.secret_write_only). Every property
the schema names is now classified too; classification still names every key of values.

* Config: a wrong Setup annotation is left out with a warning, never a refused store

A pin bump to 4.125.0 would have stopped every service whose schema named a Setup group by its display name
(Chat #366 says "Voices") or had any other presentational slip: the store refused at boot. Presentation now
degrades: the summary leaves the wrong annotation out, the store warns once, lintSchema fails the service's
tests. A display-name group reads as its id. D11's x-env/x-danger/x-bind/x-unit still refuse.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex eb2187d..b7dfff8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/README.md b/README.md
iindex 391f2d4..f716df6 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the 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 process
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
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`,
`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 button
basePath: '/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, or
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.
++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
ddiff --git a/STATUS.json b/STATUS.json
iindex 947c8c8..1405694 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/config-core.js b/config-core.js
iindex 8da644e..f4925fc 100644
---- a/config-core.js
++++ b/config-core.js
@@@ -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 };
ddiff --git a/config-pg.js b/config-pg.js
iindex 684ca31..74cae24 100644
---- a/config-pg.js
++++ b/config-pg.js
@@@ -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;
ddiff --git a/config.js b/config.js
iindex 4e17f9c..94a098a 100644
---- a/config.js
++++ b/config.js
@@@ -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) };
ddiff --git a/package.json b/package.json
iindex b51ddbc..85d0fcf 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/config-owner-setup.test.js b/test/config-owner-setup.test.js
nnew file mode 100644
iindex 0000000..fa870dd
---- /dev/null
++++ b/test/config-owner-setup.test.js
@@@ -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); });
ddiff --git a/test/config-pg.test.js b/test/config-pg.test.js
iindex cd50289..cdf0244 100644
---- a/test/config-pg.test.js
++++ b/test/config-pg.test.js
@@@ -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');