openvibers/openvibe.chat Commits

Chat: the voice keys are owner settings (chat.voices) (#366)

OP OpenVibers committed 6 days ago · cf3e07fff8 · 16 files, +886 −22

* Chat: the voice keys are owner settings (chat.voices)

The Amazon Polly access key ID, secret and region and the Google Cloud service
account JSON are fields of chat.voices on openvibe-shared/config, with plain
titles, descriptions, x-owner-input words and a provider test that synthesizes
one short line. The keys are sealed and write-only; the TTS engine reads the
owner's value first and falls back to the environment and Live's old site
setting. /internal/config is mounted behind chat.config.manage.

* Fix the CI checks for the owner settings

The config tables are in the schema count and accounted for in the account-data
map; the Sources test hook's fixed GitHub read is in the reviewed outbound list.

* Account for the config audit columns in the account-data map

config_snapshots.created_by/activated_by is the administrator's audit trail for
the network's configuration history, not the person's account data.

* Merge origin/main into the Chat owner-settings branch

* Merge remote-tracking branch 'origin/main' into owner-inputs-chat

* Pin openvibe-contracts v0.311.0 and use the canonical voices Setup group id

* Chat apps: the Telegram, Discord and Slack bot accounts are owner settings (chat.apps) (#368)

* Chat apps: the Telegram, Discord and Slack bot accounts are owner settings (chat.apps)

The bot details the A4 chat apps read (config.chatApps) become a chat.apps namespace
on openvibe-shared/config, served by the same /internal/config routes as
chat.voices: the bot tokens and the Slack signing secret are sealed and write-only,
the Discord public key and application id and the Slack workspace id are plain
values, and each carries x-owner-input (group chat-apps) with x-required-for the app
it turns on. config.chatApps' fields become getters, the panel's value first and
CHAT_* from the environment second, so Telegram and Slack answer as soon as their
details are entered. Chat makes its own button secret and Telegram webhook check once
when nothing provides them.

POST /internal/config/chat.apps/test/:key checks the bot tokens with Telegram getMe,
Discord users/@me and Slack auth.test and answers in one line, the token scrubbed
(Telegram's travels in its URL). settings.mount takes other stores, so both
namespaces share one list.

* Merge remote-tracking branch 'origin/main' into feat/owner-inputs-chat-apps

* Merge the Chat voices pin and group fix into the chat apps branch

* Merge main; the config migration is 0041 (0040 is watch parties on main)

Changes

ddiff --git a/.env.example b/.env.example
iindex d2cbb92..63d1070 100644
---- a/.env.example
++++ b/.env.example
@@@ -103,6 +103,9 @@ SOUNDS_PATH=./data/sounds
# Your agent in Telegram, Discord and Slack (agent plan A4; docs/chat-apps.md). Each app is OFF
# until its own bot token and its own secret are here. Buttons are signed with
# CHAT_APPS_CALLBACK_SECRET: a long random string of its own, never an app token.
++# The owner can enter the bot details in the admin panel's Setup tab instead (chat.apps, sealed): a
++# value entered there wins and these stay the fallback. When neither has CHAT_APPS_CALLBACK_SECRET or
++# CHAT_TELEGRAM_SECRET_TOKEN, Chat makes them once and keeps them sealed in chat.apps.
#CHAT_APPS_CALLBACK_SECRET=
#CHAT_TELEGRAM_BOT_TOKEN=
#CHAT_TELEGRAM_SECRET_TOKEN=
@@@ -120,5 +123,13 @@ SOUNDS_PATH=./data/sounds
#CHAT_APPS_RATE_PER_HOUR=20
#CHAT_APPS_PER_USER_RATE_PER_HOUR=10
++
++# Voice keys (D11 owner settings): the admin panel's chat.voices namespace wins; these are the fallback
++# when no value was entered there, and the last fallback is Live's old tts_* site setting.
++#CHAT_POLLY_ACCESS_KEY_ID=
++#CHAT_POLLY_SECRET_ACCESS_KEY=
++#CHAT_POLLY_REGION=us-east-1
++#CHAT_GOOGLE_SERVICE_ACCOUNT_JSON=
++
# Optional diagnostics
#DEBUG_DM_DELIVERY=1
ddiff --git a/README.md b/README.md
iindex 3a52dcc..14ac551 100644
---- a/README.md
++++ b/README.md
@@@ -968,7 +968,7 @@ left OpenVibe.Sites, and this repository's vhost serves it.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.304.0
++- openvibe-contracts: v0.311.0
- openvibe-sdk: v0.93.0
- openvibe-shared: v4.124.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex f42e1d8..265cd04 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"runtime": "node 22, express, ws, PostgreSQL through openvibe-sdk/db (DATABASE_URL via PgBouncer, DATABASE_DIRECT_URL for migrations/; embedded PGlite outside production), Valkey for the per-actor limits (VALKEY_URL)",
"port": 4400,
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
-- "contracts": "openvibe-contracts v0.304.0",
++ "contracts": "openvibe-contracts v0.311.0",
"sdk": "openvibe-sdk v0.93.0",
"shared": "openvibe-shared v4.124.0",
"capabilities": [
ddiff --git a/docs/chat-apps.md b/docs/chat-apps.md
iindex bb92a4c..d0d9928 100644
---- a/docs/chat-apps.md
++++ b/docs/chat-apps.md
@@@ -20,6 +20,12 @@ gateway. Nothing here creates a bot account.
## Settings
++The owner enters the bot details in the admin panel's Setup tab (Chat apps): they are the `chat.apps`
++namespace on openvibe-shared/config, sealed and write-only, each with a Test button (Telegram getMe,
++Discord users/@me, Slack auth.test). A value entered there wins; the variables below stay the fallback,
++read every time they are used. `CHAT_APPS_CALLBACK_SECRET` and `CHAT_TELEGRAM_SECRET_TOKEN` need not be set:
++when neither the environment nor `chat.apps` has one, Chat makes it once and keeps it sealed.
++
| Variable | What it is |
|---|---|
| `CHAT_APPS_CALLBACK_SECRET` | signs every approval button; a long random string of its own |
@@@ -38,7 +44,7 @@ gateway. Nothing here creates a bot account.
| `CHAT_APPS_RATE_PER_HOUR` | answers an hour a group gets (default 20) |
| `CHAT_APPS_PER_USER_RATE_PER_HOUR` | messages an hour one person gets (default 10) |
--Secrets are read from the environment only, used only against the provider's fixed origin and
++Secrets are read from `chat.apps` or the environment, used only against the provider's fixed origin and
never printed or put into an error.
## Endpoints and what to set where
ddiff --git a/migrations/0041_config.sql b/migrations/0041_config.sql
nnew file mode 100644
iindex 0000000..f5f38e4
---- /dev/null
++++ b/migrations/0041_config.sql
@@@ -0,0 +1,30 @@
++-- phase: expand
++-- D11 owner settings (openvibe-shared/config): the configuration snapshots and the per-namespace
++-- fingerprint key. Chat's own voice keys live in chat.voices; the store seals secret-class values
++-- under OV_CONFIG_KEKS and keeps only markers in snapshots. Every table here is new, and an older
++-- release ignores all of it.
++CREATE TABLE IF NOT EXISTS config_snapshots (
++ namespace text COLLATE "C" NOT NULL,
++ revision bigint NOT NULL,
++ service text COLLATE "C" NOT NULL,
++ previous_revision bigint,
++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
++ values_json text COLLATE "C" NOT NULL,
++ classification_json text COLLATE "C" NOT NULL,
++ values_checksum text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" NOT NULL,
++ created_by text COLLATE "C" NOT NULL,
++ activated_at text COLLATE "C",
++ activated_by text COLLATE "C",
++ reason text COLLATE "C",
++ error text COLLATE "C",
++ copied_from bigint,
++ good bigint NOT NULL DEFAULT 0,
++ PRIMARY KEY (namespace, revision)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
++CREATE TABLE IF NOT EXISTS config_keys (
++ namespace text COLLATE "C" PRIMARY KEY,
++ hmac_key bytea NOT NULL,
++ created_at text COLLATE "C" NOT NULL
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 1feae59..b92cd60 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0",
@@@ -1557,9 +1557,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.305.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
-- "integrity": "sha512-jTU1zlnTmHlydwL3/c5HVdUiPpd8r2KucVdHYUYX5MZgJoqy4M7Hg2jy6UKbJQ6s665uIZyTvswbmJjB2uwc5Q==",
++ "version": "0.311.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
++ "integrity": "sha512-s0CubY2CA281mADLTwfnHSlpcC+fXbdmTEHFd0gExXrE6P2DGZlf+l+p6GyyOTF0k4CJZeCutDzwSjfSU/ZJRg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex bc6fd26..71da1b1 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.305.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.0",
ddiff --git a/server/app.js b/server/app.js
iindex 7e06c8e..cef2829 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -11,6 +11,7 @@
'use strict';
const express = require('express');
++const CONFIG_CAPABILITY = 'chat.config.manage';
const cookieParser = require('cookie-parser');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
@@@ -21,6 +22,7 @@ const { banExemptionToken, authenticateWs, requestUser } = require('./auth/auth'
const { trustProxy } = require('./net/client-ip');
const { createInternalIngress } = require('./chat/internal-ingress');
const { isExtensionOrigin } = require('./auth/extension-origin');
++const serviceAuth = require('./net/service-auth');
function normalizeOrigin(origin) {
if (!origin || typeof origin !== 'string') return null;
@@@ -62,7 +64,7 @@ function getAllowedOrigins() {
return allowed;
}
--function createApp({ chatServer, relay, events = null, callServer = null }) {
++function createApp({ chatServer, relay, events = null, callServer = null, settings = null, chatAppSettings = null }) {
const allowedOrigins = getAllowedOrigins();
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@@ -129,6 +131,20 @@ function createApp({ chatServer, relay, events = null, callServer = null }) {
app.use('/internal/calls', express.json({ limit: '64kb' }), require('./calls/internal').createInternalRouter());
// Live's read API for the chat tables Chat owns (plan T3; capability chat.moderation.read).
app.use('/internal/moderation', express.json({ limit: '64kb' }), require('./chat/internal-moderation'));
++ // D11 owner settings (openvibe-shared/config): Network's admin proxy, acting for the owner, reads and writes
++ // chat.voices (the Polly and Google Cloud voice keys). Loopback only, guarded by chat.config.manage; a secret
++ // or x-danger change needs X-OV-Fresh: 1, and the test route synthesizes one short line without echoing a key.
++ // chat.apps (the Telegram, Discord and Slack bots) is served by the same routes; its Test button is mounted first,
++ // because the voices' test route answers 404 for every other namespace.
++ if (settings || chatAppSettings) {
++ const guard = serviceAuth.guard(CONFIG_CAPABILITY);
++ app.use('/internal/config', express.json({ limit: '64kb' }));
++ if (chatAppSettings) app.post(`/internal/config/${chatAppSettings.namespace}/test/:key`, guard, require('./chat-apps/owner-settings').testRoute(chatAppSettings));
++ const also = chatAppSettings ? [chatAppSettings.store] : [];
++ if (settings) settings.mount(app, { guard, also });
++ else require('./settings').mountStores(app, { guard, stores: also });
++ }
++
// OpenVibe.Services reads Chat's room summaries on loopback (ADR-048); the service guard
// checks chat.resource.read and refuses requests that came through a public proxy.
// Control first (chat.resource.control: archive a person's room), then the index for everything else.
ddiff --git a/server/chat-apps/owner-settings.js b/server/chat-apps/owner-settings.js
nnew file mode 100644
iindex 0000000..d88b992
---- /dev/null
++++ b/server/chat-apps/owner-settings.js
@@@ -0,0 +1,257 @@
++'use strict';
++/**
++ * The chat apps' owner settings (the owner-setup design of 2026-11-07): the Telegram, Discord and Slack bot accounts
++ * your OpenVibe agent answers through (agent plan A4), as the `chat.apps` namespace on openvibe-shared/config, which
++ * the admin panel's Setup tab lists under Chat apps.
++ *
++ * telegram.bot_token BotFather's token (secret)
++ * discord.bot_token the application's bot token (secret)
++ * discord.public_key the key Discord signs interactions with (internal: it is public)
++ * discord.application_id the application's id (internal)
++ * slack.bot_token the app's bot token, xoxb-… (secret)
++ * slack.signing_secret the app's signing secret (secret)
++ * slack.team_id the workspace the app is installed in (internal)
++ * telegram.secret_token, callback_secret
++ * Chat's own secrets (the webhook check, the signed buttons): made by Chat
++ * once when neither the environment nor the store has one, never typed by
++ * anyone (x-advanced, not owner inputs)
++ *
++ * install(config) turns config.chatApps' fields into getters: the value entered in the panel wins, the environment
++ * (CHAT_TELEGRAM_BOT_TOKEN, …) stays the fallback, read at use time, so Telegram and Slack answer as soon as a token is
++ * entered. Discord's gateway connects when Chat starts, so a Discord token entered later is used from the next start.
++ *
++ * test(key) is the Setup tab's Test button: Telegram getMe, Discord users/@me, Slack auth.test, each read-only. The
++ * answer is one line and never carries a token (Telegram's token is in its URL, so every error is scrubbed).
++ */
++const crypto = require('crypto');
++const { createConfigStore } = require('openvibe-shared/config');
++
++const SERVICE = 'chat';
++const NAMESPACE = 'chat.apps';
++const TIMEOUT_MS = 10_000;
++
++const FIELDS = Object.freeze({
++ 'telegram.bot_token': { app: 'telegram', field: 'botToken', env: 'CHAT_TELEGRAM_BOT_TOKEN' },
++ 'telegram.secret_token': { app: 'telegram', field: 'secretToken', env: 'CHAT_TELEGRAM_SECRET_TOKEN' },
++ 'discord.bot_token': { app: 'discord', field: 'botToken', env: 'CHAT_DISCORD_BOT_TOKEN' },
++ 'discord.public_key': { app: 'discord', field: 'publicKey', env: 'CHAT_DISCORD_PUBLIC_KEY' },
++ 'discord.application_id': { app: 'discord', field: 'applicationId', env: 'CHAT_DISCORD_APPLICATION_ID' },
++ 'slack.bot_token': { app: 'slack', field: 'botToken', env: 'CHAT_SLACK_BOT_TOKEN' },
++ 'slack.signing_secret': { app: 'slack', field: 'signingSecret', env: 'CHAT_SLACK_SIGNING_SECRET' },
++ 'slack.team_id': { app: 'slack', field: 'teamId', env: 'CHAT_SLACK_TEAM_ID' },
++ callback_secret: { app: null, field: 'callbackSecret', env: 'CHAT_APPS_CALLBACK_SECRET' },
++});
++const SECRETS = new Set(['telegram.bot_token', 'telegram.secret_token', 'discord.bot_token', 'slack.bot_token', 'slack.signing_secret', 'callback_secret']);
++
++const SCHEMA = Object.freeze({
++ $schema: 'https://json-schema.org/draft/2020-12/schema',
++ title: 'Chat apps',
++ description: 'The Telegram, Discord and Slack bots your OpenVibe agent answers through. Enter an app\'s bot details to turn that app on; remove them to turn it off. An app with nothing entered stays hidden.',
++ type: 'object',
++ additionalProperties: false,
++ properties: {
++ 'telegram.bot_token': {
++ type: 'string', minLength: 20, maxLength: 200, title: 'Telegram bot token', 'x-group': 'Telegram', 'x-example': '123456789:AA…',
++ description: 'The token BotFather gives your bot. With it the agent answers in Telegram chats where people message the bot; without it the Telegram option stays hidden.',
++ 'x-owner-input': {
++ group: 'chat-apps',
++ unlocks: 'Lets the OpenVibe agent answer in Telegram. Without it the Telegram option stays hidden.',
++ get_it_at: 'https://t.me/BotFather',
++ get_it_how: 'Message @BotFather, send /newbot, choose a name and a username ending in bot, and copy the token it sends back.',
++ test: true,
++ },
++ 'x-required-for': 'The agent in Telegram',
++ },
++ 'discord.bot_token': {
++ type: 'string', minLength: 20, maxLength: 200, title: 'Discord bot token', 'x-group': 'Discord',
++ description: 'The token of your Discord application\'s bot. With the public key and application ID it lets the agent answer in Discord servers and direct messages; Chat connects with it when it starts.',
++ 'x-owner-input': {
++ group: 'chat-apps',
++ unlocks: 'Lets the OpenVibe agent answer in Discord (from Chat\'s next start). Without it the Discord option stays hidden.',
++ get_it_at: 'https://discord.com/developers/applications',
++ get_it_how: 'Create an application, open Bot, Reset Token and copy it; turn on the Message Content intent on the same page.',
++ test: true,
++ },
++ 'x-required-for': 'The agent in Discord',
++ },
++ 'discord.public_key': {
++ type: 'string', pattern: '^[0-9a-fA-F]{64}$', title: 'Discord public key', 'x-group': 'Discord', 'x-example': 'a1b2c3…',
++ description: 'The public key Discord signs its button and command deliveries with (64 hexadecimal characters). Chat checks every delivery with it, so a delivery that is not Discord\'s is refused.',
++ 'x-owner-input': {
++ group: 'chat-apps',
++ unlocks: 'Lets Chat check that Discord commands and buttons really come from Discord. The Discord app stays off without it.',
++ get_it_at: 'https://discord.com/developers/applications',
++ get_it_how: 'Open the application, General Information, and copy the Public Key.',
++ test: false,
++ },
++ 'x-required-for': 'The agent in Discord',
++ },
++ 'discord.application_id': {
++ type: 'string', pattern: '^[0-9]{5,30}$', title: 'Discord application ID', 'x-group': 'Discord', 'x-example': '1234567890123456789',
++ description: 'The number that names your Discord application. Chat registers the agent\'s commands (/link, /help, /settings) under it and the Discord app stays off without it.',
++ 'x-owner-input': {
++ group: 'chat-apps',
++ unlocks: 'Lets Chat register the agent\'s Discord commands. The Discord app stays off without it.',
++ get_it_at: 'https://discord.com/developers/applications',
++ get_it_how: 'Open the application, General Information, and copy the Application ID.',
++ test: false,
++ },
++ 'x-required-for': 'The agent in Discord',
++ },
++ 'slack.bot_token': {
++ type: 'string', minLength: 20, maxLength: 300, title: 'Slack bot token', 'x-group': 'Slack', 'x-example': 'xoxb-…',
++ description: 'The bot token (xoxb-…) of your Slack app, installed in your workspace. With the signing secret and team ID it lets the agent answer in Slack; without it the Slack option stays hidden.',
++ 'x-owner-input': {
++ group: 'chat-apps',
++ unlocks: 'Lets the OpenVibe agent answer in Slack. Without it the Slack option stays hidden.',
++ get_it_at: 'https://api.slack.com/apps',
++ get_it_how: 'Create an app, add the chat:write, app_mentions:read and im:history bot scopes, install it to the workspace, and copy the Bot User OAuth Token.',
++ test: true,
++ },
++ 'x-required-for': 'The agent in Slack',
++ },
++ 'slack.signing_secret': {
++ type: 'string', minLength: 16, maxLength: 200, title: 'Slack signing secret', 'x-group': 'Slack',
++ description: 'The secret Slack signs every delivery with. Chat refuses a delivery whose signature does not match, so only your Slack app can reach the agent. Never shown again after saving.',
++ 'x-owner-input': {
++ group: 'chat-apps',
++ unlocks: 'Lets Chat check that Slack messages really come from your Slack app. The Slack app stays off without it.',
++ get_it_at: 'https://api.slack.com/apps',
++ get_it_how: 'Open the app, Basic Information, App Credentials, and copy the Signing Secret.',
++ test: false,
++ },
++ 'x-required-for': 'The agent in Slack',
++ },
++ 'slack.team_id': {
++ type: 'string', pattern: '^T[A-Z0-9]{6,20}$', title: 'Slack workspace ID', 'x-group': 'Slack', 'x-example': 'T01ABCDEF',
++ description: 'The ID of the Slack workspace the app is installed in (it starts with T). Chat answers only deliveries from this workspace. The Test button on the bot token shows it.',
++ 'x-owner-input': {
++ group: 'chat-apps',
++ unlocks: 'Lets Chat answer deliveries from your Slack workspace and no other. The Slack app stays off without it.',
++ get_it_at: 'https://api.slack.com/apps',
++ get_it_how: 'Test the bot token here and copy the workspace ID it names, or read it from your workspace\'s URL in a browser.',
++ test: false,
++ },
++ 'x-required-for': 'The agent in Slack',
++ },
++ 'telegram.secret_token': {
++ type: 'string', minLength: 16, maxLength: 256, pattern: '^[A-Za-z0-9_-]+$', title: 'Telegram webhook check', 'x-group': 'Telegram', 'x-advanced': true,
++ description: 'A random secret Chat gives Telegram when it sets the webhook, and checks on every delivery. Chat makes it itself the first time; nobody needs to type it.',
++ },
++ callback_secret: {
++ type: 'string', minLength: 32, maxLength: 256, title: 'Button signing secret', 'x-group': 'All chat apps', 'x-advanced': true,
++ description: 'A random secret Chat signs the agent\'s Approve and Decline buttons with, so a button cannot be forged. Chat makes it itself the first time; nobody needs to type it.',
++ },
++ },
++});
++
++async function call(fetchImpl, url, init) {
++ let res;
++ try {
++ res = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ } catch (err) {
++ return { status: 0, json: null, error: err && err.name === 'TimeoutError' ? 'did not answer within 10 seconds' : 'could not be reached' };
++ }
++ return { status: res.status, json: await res.json().catch(() => null), error: null };
++}
++
++const TESTS = {
++ 'telegram.bot_token': async (token, fetchImpl) => {
++ const r = await call(fetchImpl, `https://api.telegram.org/bot${token}/getMe`, { method: 'GET' });
++ if (r.error) return { ok: false, message: `Telegram ${r.error}.` };
++ if (r.json && r.json.ok && r.json.result) return { ok: true, message: `Telegram accepted the token: the bot is @${r.json.result.username}.` };
++ return { ok: false, message: `Telegram refused the token (${r.status})${r.json && r.json.description ? `: ${r.json.description}` : '.'}` };
++ },
++ 'discord.bot_token': async (token, fetchImpl) => {
++ const r = await call(fetchImpl, 'https://discord.com/api/v10/users/@me', { method: 'GET', headers: { Authorization: `Bot ${token}` } });
++ if (r.error) return { ok: false, message: `Discord ${r.error}.` };
++ if (r.status === 200 && r.json && r.json.id) return { ok: true, message: `Discord accepted the token: the bot is ${r.json.username || r.json.id}.` };
++ return { ok: false, message: `Discord refused the token (${r.status})${r.json && r.json.message ? `: ${r.json.message}` : '.'}` };
++ },
++ 'slack.bot_token': async (token, fetchImpl) => {
++ const r = await call(fetchImpl, 'https://slack.com/api/auth.test', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: '' });
++ if (r.error) return { ok: false, message: `Slack ${r.error}.` };
++ if (r.json && r.json.ok) return { ok: true, message: `Slack accepted the token: the bot is ${r.json.user || r.json.user_id} in the workspace ${r.json.team || ''} (${r.json.team_id}).` };
++ return { ok: false, message: `Slack refused the token${r.json && r.json.error ? `: ${r.json.error}` : ` (${r.status})`}.` };
++ },
++};
++
++/** One line, at most 200 characters, with the secret (and its first 12 characters) replaced. */
++function oneLine(text, secret) {
++ let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
++ if (typeof secret === 'string' && secret.length >= 4) {
++ s = s.split(secret).join('[hidden]');
++ if (secret.length >= 12) s = s.split(secret.slice(0, 12)).join('[hidden]');
++ }
++ return s.length > 200 ? `${s.slice(0, 199)}…` : s;
++}
++
++/** Create the chat.apps store; make Chat's own two secrets once when nothing provides them. */
++async function createChatAppSettings({ db, env = process.env, log = console, fetchImpl = globalThis.fetch }) {
++ const store = await createConfigStore({
++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, env,
++ classify: (key) => (SECRETS.has(key) ? 'secret' : 'internal'), log,
++ onChange: ({ namespace, keys, actor, revision }) => {
++ (log.info || log.log).call(log, `[config] ${namespace} revision ${revision} by ${actor && actor.id ? `${actor.type}:${actor.id}` : 'unknown'}: ${keys.join(', ')}`);
++ },
++ });
++ if (env.OV_CONFIG_KEKS) {
++ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
++ }
++ for (const key of ['callback_secret', 'telegram.secret_token']) {
++ if (String(env[FIELDS[key].env] || '').trim() || store.get(key)) continue;
++ await store.setSecret(key, crypto.randomBytes(32).toString('hex'), { actor: { type: 'service', id: SERVICE }, reason: 'made by Chat: nobody needs to type it' })
++ .catch((err) => log.warn(`[config] ${NAMESPACE}: ${key} could not be made: ${(err && err.message) || err}`));
++ }
++
++ /** A key at use time: { value, source: vault | env | null, env }. The panel's value wins. */
++ function value(key) {
++ const f = FIELDS[key];
++ const own = store.get(key);
++ if (typeof own === 'string' && own) return { value: own, source: 'vault', env: f ? f.env : null };
++ const fallback = f ? String(env[f.env] || '').trim() : '';
++ return { value: fallback, source: fallback ? 'env' : null, env: f ? f.env : null };
++ }
++
++ /** config.chatApps' fields read the panel first, then the environment, every time they are read. */
++ function install(config) {
++ const apps = config.chatApps;
++ for (const [key, f] of Object.entries(FIELDS)) {
++ const target = f.app ? apps[f.app] : apps;
++ Object.defineProperty(target, f.field, { enumerable: true, configurable: true, get: () => value(key).value });
++ }
++ return config;
++ }
++
++ /** The Setup tab's Test: { value, source, env, run } for a key with a test, else null. */
++ function test(key) {
++ const t = TESTS[key];
++ if (!t) return null;
++ return { ...value(key), run: (v) => t(v, fetchImpl) };
++ }
++
++ return { store, namespace: NAMESPACE, value, install, test };
++}
++
++/**
++ * POST /internal/config/chat.apps/test/:key behind `guard`: 200 { ok, message, state, source } (openvibe-shared
++ * 4.125.0's { ok, message } with more beside it), 409 config.not_set, 404 for a key without a test.
++ */
++function testRoute(chatApps, { log = console } = {}) {
++ return async function chatAppsTest(req, res) {
++ let key = String(req.params.key || '');
++ try { key = decodeURIComponent(key); } catch { /* keep it as it is */ }
++ const found = chatApps.test(key);
++ if (!found) return res.status(404).json({ type: 'about:blank', status: 404, code: 'config.test_not_found', detail: `${NAMESPACE} has no test for ${key}` });
++ if (!found.value) return res.status(409).json({ type: 'about:blank', status: 409, code: 'config.not_set', detail: `${key} is not set, so there is nothing to test` });
++ let out;
++ try { out = await found.run(found.value); } catch (err) { out = { ok: false, message: `The test could not run: ${(err && err.message) || 'unknown error'}` }; }
++ const ok = Boolean(out && out.ok);
++ const from = found.source === 'env' && found.env ? ` (the value from the server's settings file, ${found.env})` : '';
++ const message = oneLine(`${(out && out.message) || (ok ? 'It works.' : 'It did not work.')}${from}`, found.value);
++ (log.info || log.log).call(log, `[config] test ${NAMESPACE} ${key}: ${ok ? 'connected' : 'not working'}`);
++ return res.json({ ok, message, state: ok ? 'connected' : 'not_working', source: found.source || null, namespace: NAMESPACE, key, tested_at: new Date().toISOString() });
++ };
++}
++
++module.exports = { NAMESPACE, SCHEMA, FIELDS, createChatAppSettings, testRoute, oneLine };
ddiff --git a/server/chat/tts-engine.js b/server/chat/tts-engine.js
iindex 2e8a3fd..07887b3 100644
---- a/server/chat/tts-engine.js
++++ b/server/chat/tts-engine.js
@@@ -19,6 +19,8 @@ const os = require('os');
const db = require('../db/database');
// Site settings are Live's (site_settings); read through the Live context projection.
const ctx = require('../live-context');
++// Owner settings (chat.voices): the admin panel's value wins, the env is the fallback, the Live site setting last.
++const owner = require('../settings');
// ── Voice Catalog ─────────────────────────────────────────────
// Every voice available on the platform — combines RS-Companion's
@@@ -160,10 +162,10 @@ function getTTSSettings() {
enabled: ctx.getSetting('tts_enabled') !== false,
provider: ctx.getSetting('tts_provider') || 'espeak-ng',
googleApiKey: ctx.getSetting('tts_google_api_key') || '',
-- googleServiceAccount: ctx.getSetting('tts_google_service_account') || '',
-- awsAccessKeyId: ctx.getSetting('tts_aws_access_key_id') || '',
-- awsSecretAccessKey: ctx.getSetting('tts_aws_secret_access_key') || '',
-- awsRegion: ctx.getSetting('tts_aws_region') || 'us-east-1',
++ googleServiceAccount: owner.value('google_cloud_service_account_json', 'CHAT_GOOGLE_SERVICE_ACCOUNT_JSON', ctx.getSetting('tts_google_service_account') || ''),
++ awsAccessKeyId: owner.value('amazon_polly_access_key_id', 'CHAT_POLLY_ACCESS_KEY_ID', ctx.getSetting('tts_aws_access_key_id') || ''),
++ awsSecretAccessKey: owner.value('amazon_polly_secret_access_key', 'CHAT_POLLY_SECRET_ACCESS_KEY', ctx.getSetting('tts_aws_secret_access_key') || ''),
++ awsRegion: owner.value('amazon_polly_region', 'CHAT_POLLY_REGION', ctx.getSetting('tts_aws_region') || 'us-east-1'),
maxLength: ctx.getSetting('tts_max_length') || 200,
maxQueuePerUser: ctx.getSetting('tts_max_queue_per_user') || 3,
maxQueueGlobal: ctx.getSetting('tts_max_queue_global') || 20,
@@@ -318,9 +320,9 @@ function _getGoogleAccessToken(sa) {
});
}
--function synthesizeGoogleCloud(text, voiceDef) {
++function synthesizeGoogleCloud(text, voiceDef, override) {
return new Promise(async (resolve, reject) => {
-- const settings = getTTSSettings();
++ const settings = override || getTTSSettings();
const sa = _getGoogleServiceAccount(settings);
const apiKey = settings.googleApiKey;
if (!sa && !apiKey) return reject(new Error('Google Cloud TTS not configured'));
@@@ -364,8 +366,8 @@ function synthesizeGoogleCloud(text, voiceDef) {
}
// ── Amazon Polly ──────────────────────────────────────────────
--function _awsSign({ method, service, region, path: urlPath, body, headers: extraHeaders }) {
-- const settings = getTTSSettings();
++function _awsSign({ method, service, region, path: urlPath, body, headers: extraHeaders, settings: creds }) {
++ const settings = creds || getTTSSettings();
const accessKey = settings.awsAccessKeyId;
const secretKey = settings.awsSecretAccessKey;
if (!accessKey || !secretKey) throw new Error('AWS credentials not configured');
@@@ -399,9 +401,9 @@ function _awsSign({ method, service, region, path: urlPath, body, headers: extra
return { host, headers };
}
--function synthesizePolly(text, voiceDef) {
++function synthesizePolly(text, voiceDef, override) {
return new Promise((resolve, reject) => {
-- const settings = getTTSSettings();
++ const settings = override || getTTSSettings();
const pv = voiceDef.pollyVoice;
let bodyObj = {
@@@ -418,7 +420,7 @@ function synthesizePolly(text, voiceDef) {
try {
signed = _awsSign({
method: 'POST', service: 'polly', region: settings.awsRegion,
-- path: '/v1/speech', body,
++ path: '/v1/speech', body, settings,
});
} catch (e) { return reject(e); }
@@@ -696,6 +698,17 @@ function getAvailableVoices() {
return voices;
}
++// Synthesize one short line for the owner-settings test (server/settings.js): explicit credentials, no cache.
++async function testSynthesize(provider, creds = {}) {
++ if (provider === 'amazon-polly') {
++ return await synthesizePolly('OpenVibe voice test', VOICE_CATALOG.pl_joanna_std, creds);
++ }
++ if (provider === 'google-cloud') {
++ return await synthesizeGoogleCloud('OpenVibe voice test', VOICE_CATALOG.gc_smooth_operator, creds);
++ }
++ throw new Error(`no such voice provider: ${provider}`);
++}
++
// ── Exports ───────────────────────────────────────────────────
// Synthesize an arbitrary espeak param set (admin voice preview).
async function synthesizeWithParams(text, params) {
@@@ -710,6 +723,7 @@ module.exports = {
synthesize,
synthesizeUserVoice,
synthesizeWithParams,
++ testSynthesize,
deriveUserVoiceParams,
autoUserVoiceParams,
clampVoiceParams: _clampVoiceParams,
ddiff --git a/server/index.js b/server/index.js
iindex 5e1e6e4..090101e 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -26,6 +26,7 @@ const { createValkey } = require('openvibe-sdk/valkey');
const { gracefulStop, telemetry } = require('openvibe-sdk/service');
const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { createApp } = require('./app');
++const settingsLib = require('./settings');
let rejectionsLogged = false;
@@@ -41,6 +42,28 @@ async function start() {
// PostgreSQL (DATABASE_URL; migrations first, as the owner) or, outside production, an embedded PGlite.
await db.initDb();
++ // D11 owner settings (chat.voices): created before the app mounts /internal/config. A failure leaves Chat
++ // serving (the voice keys fall back to the environment and Live's site setting) and says why in the log.
++ let settings = null;
++ try {
++ const made = settingsLib.createSettings({ db: db.getDb(), log: console });
++ await made.ready;
++ settings = settingsLib.activate(made);
++ console.log(`[Chat] owner settings ready (${settings.namespace})`);
++ } catch (err) {
++ console.warn('[Chat] owner settings unavailable, voice keys fall back to the environment:', err && err.message);
++ }
++ // The chat apps' bot accounts (chat.apps, the owner-setup design): config.chatApps reads the panel's values first
++ // and the environment second, every time. A failure leaves the environment's values in place.
++ let chatAppSettings = null;
++ try {
++ chatAppSettings = await require('./chat-apps/owner-settings').createChatAppSettings({ db: db.getDb(), log: console });
++ chatAppSettings.install(config);
++ console.log(`[Chat] owner settings ready (${chatAppSettings.namespace})`);
++ } catch (err) {
++ console.warn('[Chat] chat-app settings unavailable, the chat apps read the environment:', err && err.message);
++ }
++
// Shared per-actor rate-limit counters on Valkey (ADR-035); without VALKEY_URL they count in this
// process, as before. openvibe-sdk/valkey's createValkey returns null when the URL is unset.
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
@@@ -56,7 +79,7 @@ async function start() {
callServer.init(server);
const relay = serviceOutbox.configure({ config });
const events = createEventsConsumer({ chatServer, secrets: config.events.secrets });
-- const { app, handleUpgrade } = createApp({ chatServer, relay, events, callServer });
++ const { app, handleUpgrade } = createApp({ chatServer, relay, events, callServer, settings, chatAppSettings });
void app.locals.chatApps.start();
server.on('request', app);
server.on('upgrade', (req, socket, head) => { handleUpgrade(req, socket, head).catch(() => { try { socket.destroy(); } catch { /* */ } }); });
ddiff --git a/server/settings.js b/server/settings.js
nnew file mode 100644
iindex 0000000..9204b28
---- /dev/null
++++ b/server/settings.js
@@@ -0,0 +1,231 @@
++'use strict';
++
++/**
++ * Owner settings (D11) for OpenVibe.Chat: the voice keys in `chat.voices`, on openvibe-shared/config.
++ *
++ * The values are read through value() at the moment they are used: a value the owner entered in the
++ * admin panel wins, the environment is the fallback, and Live's old `tts_*` site setting is the last
++ * fallback so nothing changes until the owner sets one. Secret-class values are sealed by the shared
++ * store under OV_CONFIG_KEKS and are write-only through /internal/config/:namespace/secrets/:key;
++ * summaries, history and logs carry markers, never a value.
++ *
++ * Network's admin proxy reaches this namespace with the `chat.config.manage` capability, acting for
++ * the owner (X-OV-Subject); a secret or x-danger change needs X-OV-Fresh: 1. The test route asks the
++ * provider to synthesize one short line and answers ok or failed in one line, never echoing a key.
++ */
++const { createConfigStore, adminRoutes } = require('openvibe-shared/config');
++
++const SERVICE = 'chat';
++const NAMESPACE = 'chat.voices';
++const BASE_PATH = '/internal/config';
++const GROUP = 'Voices';
++
++const SECRETS = new Set(['amazon_polly_access_key_id', 'amazon_polly_secret_access_key', 'google_cloud_service_account_json']);
++
++const REGIONS = {
++ 'us-east-1': { label: 'US East (N. Virginia)', description: 'The default AWS region: lowest latency for most of the network.' },
++ 'us-west-2': { label: 'US West (Oregon)', description: 'The AWS region on the west coast of the United States.' },
++ 'eu-west-1': { label: 'Europe (Ireland)', description: 'The AWS region in Ireland, for European voices and people.' },
++ 'eu-central-1': { label: 'Europe (Frankfurt)', description: 'The AWS region in Frankfurt, Germany.' },
++ 'ap-northeast-1': { label: 'Asia Pacific (Tokyo)', description: 'The AWS region in Tokyo, Japan.' },
++ 'ap-southeast-2': { label: 'Asia Pacific (Sydney)', description: 'The AWS region in Sydney, Australia.' },
++};
++
++const SCHEMA = Object.freeze({
++ $schema: 'https://json-schema.org/draft/2020-12/schema',
++ title: 'Chat voices',
++ description: 'The accounts Chat speaks with: Amazon Polly and Google Cloud text-to-speech. A voice whose provider has no key stays unavailable; the free local voice always works.',
++ type: 'object',
++ additionalProperties: false,
++ properties: {
++ amazon_polly_access_key_id: {
++ type: 'string', title: 'Amazon Polly access key ID', 'x-group': GROUP,
++ description: 'The access key ID of an AWS user that may call Polly. Together with its secret it turns on the Polly voices in chat; the ID alone does nothing.',
++ 'x-owner-input': {
++ group: 'voices',
++ unlocks: 'The Amazon Polly voices can speak in chat (Joanna, Matthew, the neural and long-form voices).',
++ get_it_at: 'https://console.aws.amazon.com/iam/home#/security_credentials',
++ get_it_how: 'Open an IAM user that may call Polly, create an access key, and paste the ID and its secret here.',
++ test: true,
++ },
++ 'x-required-for': 'Amazon Polly voices',
++ },
++ amazon_polly_secret_access_key: {
++ type: 'string', title: 'Amazon Polly secret access key', 'x-group': GROUP,
++ description: 'The secret that goes with the access key ID. Entered here it is locked and never shown again; enter it again if you make a new key in AWS.',
++ 'x-owner-input': {
++ group: 'voices',
++ unlocks: 'The Amazon Polly voices can speak; without the secret the access key ID cannot sign a request.',
++ get_it_at: 'https://console.aws.amazon.com/iam/home#/security_credentials',
++ get_it_how: 'It is shown once when the access key is created. Copy it then; AWS cannot show it again.',
++ test: true,
++ },
++ 'x-required-for': 'Amazon Polly voices',
++ },
++ amazon_polly_region: {
++ type: 'string', title: 'Amazon Polly region', 'x-group': GROUP, enum: Object.keys(REGIONS),
++ default: 'us-east-1',
++ description: 'The AWS region the Polly requests are signed for. Pick the region nearest the people listening; voices are the same everywhere, the latency is not.',
++ 'x-owner-input': {
++ group: 'voices',
++ unlocks: 'Amazon Polly requests go to the region you choose instead of the default.',
++ get_it_at: 'https://console.aws.amazon.com/polly/home',
++ get_it_how: 'The region picker at the top right of the AWS console shows the region ID, such as us-east-1.',
++ test: true,
++ },
++ 'x-required-for': 'Amazon Polly voices',
++ 'x-enum-labels': REGIONS,
++ },
++ google_cloud_service_account_json: {
++ type: 'string', title: 'Google Cloud service account key', 'x-group': GROUP,
++ description: 'The JSON key of a Google Cloud service account with the text-to-speech permission. Entered here it is locked; it turns on the Google Cloud voices (Standard, WaveNet, Neural2, Studio and Journey).',
++ 'x-owner-input': {
++ group: 'voices',
++ unlocks: 'The Google Cloud voices can speak in chat, including WaveNet, Neural2, Studio and Journey.',
++ get_it_at: 'https://console.cloud.google.com/iam-admin/serviceaccounts',
++ get_it_how: 'Create a service account, give it the Cloud Text-to-Speech User role, add a JSON key, and paste the downloaded JSON here.',
++ test: true,
++ },
++ 'x-required-for': 'Google Cloud voices',
++ },
++ },
++});
++
++const TESTABLE = new Set(Object.keys(SCHEMA.properties));
++const TEST_PROVIDER = {
++ amazon_polly_access_key_id: 'amazon-polly',
++ amazon_polly_secret_access_key: 'amazon-polly',
++ amazon_polly_region: 'amazon-polly',
++ google_cloud_service_account_json: 'google-cloud',
++};
++
++const ACTIVE = { settings: null };
++
++/** One setting at the moment it is used: the owner's value, the env value, then the caller's fallback. */
++function value(key, envName, fallback = '', env = process.env) {
++ if (ACTIVE.settings) return ACTIVE.settings.value(key, envName, fallback, env);
++ const e = envName ? env[envName] : undefined;
++ return e !== undefined && e !== null && e !== '' ? e : fallback;
++}
++
++/** The settings created by this process (server/index.js), for the TTS engine's read path. */
++function current() { return ACTIVE.settings; }
++
++/**
++ * The test call for one key: the provider makes one short line of audio with the stored credentials.
++ * A provider's answer never includes the credentials; the shared store keeps them out of errors too.
++ */
++async function defaultProbe(provider, creds) {
++ const tts = require('./chat/tts-engine');
++ const out = await tts.testSynthesize(provider, creds);
++ if (!out || !out.audio) throw new Error('the provider returned no audio');
++ return out;
++}
++
++function redact(text, secrets) {
++ let out = String(text == null ? '' : text);
++ for (const secret of secrets) {
++ if (secret && secret.length >= 4) out = out.split(secret).join('[redacted]');
++ }
++ return out.split(/\s+/).join(' ').slice(0, 180);
++}
++
++function oneLine(text) { return String(text == null ? '' : text).replace(/\s+/g, ' ').trim().slice(0, 200); }
++
++/**
++ * Create the chat.voices store. `ready` resolves once the store has read its active revision; call it
++ * before mounting the routes (server/index.js does). `probes` is for tests only.
++ */
++function createSettings({ db, env = process.env, log = console, probes = null } = {}) {
++ let store = null;
++ const ready = createConfigStore({
++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA,
++ classify: (key) => (SECRETS.has(key) ? 'secret' : 'internal'),
++ env, log,
++ onActivate: () => { try { require('./chat/tts-engine').invalidateSettingsCache(); } catch { /* not loaded */ } },
++ }).then((s) => { store = s; return s; });
++
++ const values = () => (store ? store.get() : {});
++
++ function valueOf(key, envName, fallback = '') {
++ const v = values()[key];
++ if (v !== undefined && v !== null && v !== '') return v;
++ const e = envName ? env[envName] : undefined;
++ return e !== undefined && e !== null && e !== '' ? e : fallback;
++ }
++
++ const probeFor = (provider) => {
++ const p = probes && probes[provider];
++ return typeof p === 'function' ? p : (creds) => defaultProbe(provider, creds);
++ };
++
++ /** POST /internal/config/chat.voices/test/:key → { ok, detail } (one line, never a value). */
++ async function test(key) {
++ const provider = TEST_PROVIDER[key];
++ if (!provider) return { ok: false, detail: 'That setting has no test.' };
++ if (provider === 'amazon-polly') {
++ const accessKeyId = valueOf('amazon_polly_access_key_id', 'CHAT_POLLY_ACCESS_KEY_ID');
++ const secretAccessKey = valueOf('amazon_polly_secret_access_key', 'CHAT_POLLY_SECRET_ACCESS_KEY');
++ const region = valueOf('amazon_polly_region', 'CHAT_POLLY_REGION', 'us-east-1');
++ if (!accessKeyId || !secretAccessKey) return { ok: false, detail: 'Amazon Polly is not configured: both the access key ID and its secret are needed.' };
++ try {
++ await probeFor(provider)({ awsAccessKeyId: accessKeyId, awsSecretAccessKey: secretAccessKey, awsRegion: region });
++ return { ok: true, detail: `ok — Amazon Polly synthesized a short line in ${region}.` };
++ } catch (err) {
++ return { ok: false, detail: `failed — Amazon Polly said: ${redact(err && err.message, [accessKeyId, secretAccessKey])}` };
++ }
++ }
++ const serviceAccount = valueOf('google_cloud_service_account_json', 'CHAT_GOOGLE_SERVICE_ACCOUNT_JSON');
++ if (!serviceAccount) return { ok: false, detail: 'Google Cloud is not configured: paste the service account key first.' };
++ try {
++ await probeFor(provider)({ googleServiceAccount: serviceAccount });
++ return { ok: true, detail: 'ok — Google Cloud synthesized a short line.' };
++ } catch (err) {
++ return { ok: false, detail: `failed — Google Cloud said: ${redact(err && err.message, [serviceAccount])}` };
++ }
++ }
++
++ /** Mount the admin routes and the test route; `guard` is the service-token capability middleware. */
++ function mount(app, { guard, log: appLog = log, also = [] } = {}) {
++ if (!store) throw new Error('createSettings: await ready before mount()');
++ // `also`: other namespaces served by the same routes (chat.apps, server/chat-apps/owner-settings.js).
++ const routes = mountStores(app, { guard, stores: [store, ...also], log: appLog });
++ app.post(`${BASE_PATH}/:namespace/test/:key`, guard, async (req, res) => {
++ if (req.params.namespace !== NAMESPACE) return res.status(404).json({ error: 'no such configuration namespace' });
++ let key = String(req.params.key || '');
++ try { key = decodeURIComponent(key); } catch { /* keep it as it is */ }
++ if (!TESTABLE.has(key)) return res.status(404).json({ error: 'no such setting' });
++ try {
++ const out = await test(key);
++ return res.status(200).json({ ok: Boolean(out.ok), detail: oneLine(out.detail) });
++ } catch (err) {
++ appLog.warn(`[config] ${NAMESPACE}: the test for ${key} failed: ${redact(err && err.message, [])}`);
++ return res.status(200).json({ ok: false, detail: 'failed — the test did not finish. The service log has the reason.' });
++ }
++ });
++ return routes;
++ }
++
++ return { namespace: NAMESPACE, schema: SCHEMA, ready, value: valueOf, get: values, test, mount };
++}
++
++/** The admin routes for these stores at /internal/config, guarded; the actor is Network's X-OV-Subject. */
++function mountStores(app, { guard, stores, log = console }) {
++ const routes = adminRoutes(stores, {
++ basePath: BASE_PATH,
++ requireAdmin: guard,
++ actor: (req) => {
++ const id = String(req.get('x-ov-subject') || '').trim();
++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? { type: 'user', id } : null;
++ },
++ requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',
++ log,
++ });
++ app.use(routes.handle);
++ return routes;
++}
++
++/** Install the process's settings, so the read path sees the owner's values. */
++function activate(settings) { ACTIVE.settings = settings; return settings; }
++
++module.exports = { SERVICE, NAMESPACE, SCHEMA, SECRETS, createSettings, activate, current, value, ownerValue: value, mountStores };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 14a22dc..49fccc2 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -39,6 +39,7 @@ t('export, then erase and confirm', async () => {
'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage'],
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Chat applied',
++ config_snapshots: 'D11 owner settings: created_by/activated_by is the acting administrator (usr_...) for the config audit trail; the values are network configuration, not the person\'s account data, and are kept for rollback/history',
hidden_relay_users: 'a moderation record of a relay account hidden from a channel, kept for accountability',
room_member_timeouts: 'a moderation record of a room timeout, kept for accountability like moderation_actions',
agent_play_requests: "a chat plays request of a room: it belongs to the person who asked and to the room's queue, and the room's messages are exported",
ddiff --git a/test/chat-apps-settings.test.js b/test/chat-apps-settings.test.js
nnew file mode 100644
iindex 0000000..a9eb1cb
---- /dev/null
++++ b/test/chat-apps-settings.test.js
@@@ -0,0 +1,154 @@
++'use strict';
++/**
++ * The chat apps' bot accounts as owner settings (chat.apps, the owner-setup design of 2026-11-07), listed by the
++ * admin panel's Setup tab under Chat apps.
++ *
++ * Proves: every field has a title and a 30+ character description, and the outside credentials carry x-owner-input
++ * (chat-apps) with x-required-for; Chat makes its own two secrets once, so nobody types them; config.chatApps reads
++ * the panel's value first and the environment second, every time, so an app turns on as soon as its details are
++ * entered; Telegram getMe, Discord users/@me and Slack auth.test answer in one line and never carry the token (not
++ * even Telegram's, which is in the URL); no log or console line carries a token.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, suite } = require('./helpers');
++
++const t = suite('chat-apps-settings');
++const CAP = 'chat.config.manage';
++const NS = 'chat.apps';
++const USR = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++// Made-up tokens, built at run time so a secret scanner never reads a provider-shaped string in the source.
++const ENV_TG = ['555000111', 'madeUpTelegramTokenFROMTHESETTINGSFILE00'].join(':');
++const PANEL_TG = ['555000222', 'madeUpTelegramTokenENTEREDINSETUP0000000'].join(':');
++const BAD_TG = ['555000333', 'madeUpTelegramTokenTHATTELEGRAMREFUSES00'].join(':');
++const DISCORD = ['madeUpDiscordBotToken', 'NEVERSHOWN', 'ANYWHERE00000'].join('.');
++const SLACK = ['xoxb', '000000', 'madeUpSlackTokenNEVERSHOWN000'].join('-');
++const SIGNING = crypto.randomBytes(16).toString('hex');
++const SECRETS = [ENV_TG, PANEL_TG, BAD_TG, DISCORD, SLACK, SIGNING];
++
++const calls = [];
++const realFetch = globalThis.fetch;
++globalThis.fetch = async (url, init = {}) => {
++ const u = String(url);
++ if (!/^https:\/\/(api\.telegram\.org|discord\.com|slack\.com)\//.test(u)) return realFetch(url, init);
++ const auth = String((init.headers && (init.headers.Authorization || init.headers.authorization)) || '');
++ calls.push({ url: u, auth });
++ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
++ if (u.startsWith('https://api.telegram.org/bot')) {
++ const token = decodeURIComponent(u.slice('https://api.telegram.org/bot'.length).split('/')[0]);
++ if (token === ENV_TG || token === PANEL_TG) return json(200, { ok: true, result: { id: 1, is_bot: true, username: token === ENV_TG ? 'ov_env_bot' : 'ov_panel_bot' } });
++ return json(401, { ok: false, error_code: 401, description: `Unauthorized: the token ${token} is not valid` });
++ }
++ if (u === 'https://discord.com/api/v10/users/@me') return auth === `Bot ${DISCORD}` ? json(200, { id: '42', username: 'OpenVibe agent' }) : json(401, { message: '401: Unauthorized' });
++ if (u === 'https://slack.com/api/auth.test') return auth === `Bearer ${SLACK}` ? json(200, { ok: true, user: 'openvibe', user_id: 'U1', team: 'OpenVibe', team_id: 'T0123ABCD' }) : json(200, { ok: false, error: 'invalid_auth' });
++ return json(404, {});
++};
++
++let h;
++const consoleLines = [];
++const original = { log: console.log, warn: console.warn, error: console.error, info: console.info };
++const cfg = (method, p, body) => h.http(method, p, { token: h.serviceToken([CAP]), headers: { 'x-ov-subject': USR, 'x-ov-fresh': '1' }, ...(body ? { body } : {}) });
++const test = (key) => cfg('POST', `/internal/config/${NS}/test/${key}`);
++const config = () => require('../server/config');
++
++t('boot with a Telegram token in the environment', async () => {
++ for (const k of Object.keys(original)) console[k] = (...a) => { consoleLines.push(a.join(' ')); original[k](...a); };
++ h = await boot({ env: { CHAT_TELEGRAM_BOT_TOKEN: ENV_TG, CHAT_APPS_CALLBACK_SECRET: '' , CHAT_TELEGRAM_SECRET_TOKEN: '' } });
++});
++
++t('every field reads clearly; the bot details are owner inputs in Chat apps', async () => {
++ const r = await cfg('GET', `/internal/config/${NS}`);
++ assert.strictEqual(r.status, 200, r.text);
++ const props = r.body.schema.properties;
++ assert.ok(r.body.schema.description.length >= 30);
++ for (const [key, p] of Object.entries(props)) {
++ assert.ok(p.title && p.title.length >= 3, `${key}: a title`);
++ assert.ok(p.description && p.description.length >= 30, `${key}: a 30+ character description`);
++ }
++ const inputs = Object.keys(props).filter((k) => props[k]['x-owner-input']).sort();
++ assert.deepStrictEqual(inputs, ['discord.application_id', 'discord.bot_token', 'discord.public_key', 'slack.bot_token', 'slack.signing_secret', 'slack.team_id', 'telegram.bot_token']);
++ for (const k of inputs) {
++ const oi = props[k]['x-owner-input'];
++ assert.strictEqual(oi.group, 'chat-apps', k);
++ assert.match(oi.get_it_at, /^https:\/\//, k);
++ assert.ok(oi.unlocks.length > 20 && oi.get_it_how.length > 10, k);
++ assert.match(props[k]['x-required-for'], /^The agent in (Telegram|Discord|Slack)$/, k);
++ }
++ assert.deepStrictEqual(['telegram.bot_token', 'discord.bot_token', 'slack.bot_token'].map((k) => props[k]['x-owner-input'].test), [true, true, true]);
++ assert.strictEqual(props['telegram.bot_token']['x-owner-input'].get_it_at, 'https://t.me/BotFather');
++ assert.strictEqual(props.callback_secret['x-advanced'], true, 'Chat\'s own secret is advanced, not an owner input');
++});
++
++t('Chat makes its own two secrets once; the environment\'s Telegram token is read until the panel has one', async () => {
++ const summary = await cfg('GET', `/internal/config/${NS}`);
++ assert.ok(summary.body.values.callback_secret && summary.body.values.callback_secret.redacted, 'the button secret was made and sealed');
++ assert.ok(summary.body.values['telegram.secret_token'].redacted, 'the webhook check was made');
++ assert.strictEqual(config().chatApps.callbackSecret.length, 64);
++ assert.strictEqual(config().chatApps.telegram.botToken, ENV_TG);
++ const r = await test('telegram.bot_token');
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.body.ok, r.body.state, r.body.source], [true, 'connected', 'env']);
++ assert.strictEqual(r.body.message, "Telegram accepted the token: the bot is @ov_env_bot. (the value from the server's settings file, CHAT_TELEGRAM_BOT_TOKEN)");
++});
++
++t('a panel token is sealed, write-only and read at use time: Telegram turns on with it', async () => {
++ const put = await cfg('PUT', `/internal/config/${NS}/secrets/telegram.bot_token`, { value: PANEL_TG, reason: 'setup' });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.ok(!put.text.includes(PANEL_TG));
++ assert.strictEqual(config().chatApps.telegram.botToken, PANEL_TG, 'the panel value wins, without a restart');
++ const { createProviders } = require('../server/chat-apps/providers');
++ const { providers } = createProviders({ config: config() });
++ assert.strictEqual(providers.telegram.on, true, 'the token and Chat\'s own secrets turn Telegram on');
++ await providers.telegram.whoami();
++ assert.ok(calls.some((c) => c.url.startsWith(`https://api.telegram.org/bot${PANEL_TG}/getMe`)), 'the provider called Telegram with the panel token');
++ const r = await test('telegram.bot_token');
++ assert.deepStrictEqual([r.body.ok, r.body.source, r.body.message], [true, 'vault', 'Telegram accepted the token: the bot is @ov_panel_bot.']);
++ const carried = await cfg('POST', `/internal/config/${NS}`, { values: { 'telegram.bot_token': PANEL_TG }, reason: 'not here' });
++ assert.deepStrictEqual([carried.status, carried.body.code], [422, 'config.secret_write_only']);
++});
++
++t('a refused Telegram token is Not working in one line; the token in Telegram\'s URL and answer never comes back', async () => {
++ await cfg('PUT', `/internal/config/${NS}/secrets/telegram.bot_token`, { value: BAD_TG, reason: 'wrong' });
++ const r = await test('telegram.bot_token');
++ assert.deepStrictEqual([r.status, r.body.ok, r.body.state], [200, false, 'not_working']);
++ assert.match(r.body.message, /^Telegram refused the token \(401\): Unauthorized: the token \[hidden\] is not valid$/);
++ assert.ok(!r.text.includes(BAD_TG) && !r.text.includes(BAD_TG.slice(0, 12)));
++});
++
++t('Discord and Slack: the tokens are tested with users/@me and auth.test, and their apps turn on when complete', async () => {
++ const none = await test('discord.bot_token');
++ assert.deepStrictEqual([none.status, none.body.code], [409, 'config.not_set']);
++ assert.strictEqual((await cfg('PUT', `/internal/config/${NS}/secrets/discord.bot_token`, { value: DISCORD, reason: 'setup' })).status, 200);
++ const d = await test('discord.bot_token');
++ assert.deepStrictEqual([d.body.ok, d.body.message], [true, 'Discord accepted the token: the bot is OpenVibe agent.']);
++ assert.strictEqual((await cfg('PUT', `/internal/config/${NS}/secrets/slack.bot_token`, { value: SLACK, reason: 'setup' })).status, 200);
++ const s = await test('slack.bot_token');
++ assert.deepStrictEqual([s.body.ok, s.body.message], [true, 'Slack accepted the token: the bot is openvibe in the workspace OpenVibe (T0123ABCD).']);
++ const { createProviders } = require('../server/chat-apps/providers');
++ assert.strictEqual(createProviders({ config: config() }).providers.slack.on, false, 'Slack waits for its signing secret and workspace ID');
++ assert.strictEqual((await cfg('PUT', `/internal/config/${NS}/secrets/slack.signing_secret`, { value: SIGNING, reason: 'setup' })).status, 200);
++ const team = await cfg('POST', `/internal/config/${NS}`, { values: { 'slack.team_id': 'T0123ABCD', 'discord.public_key': 'a'.repeat(64), 'discord.application_id': '9'.repeat(18) }, merge: true, reason: 'setup' });
++ assert.strictEqual(team.status, 200, team.text);
++ const p = createProviders({ config: config() }).providers;
++ assert.deepStrictEqual([p.slack.on, p.discord.on], [true, true]);
++ const noTest = await test('slack.signing_secret');
++ assert.deepStrictEqual([noTest.status, noTest.body.code], [404, 'config.test_not_found']);
++});
++
++t('removing the panel token falls back to the environment; the hook is guarded', async () => {
++ const del = await cfg('DELETE', `/internal/config/${NS}/secrets/telegram.bot_token`, { reason: 'back to env' });
++ assert.strictEqual(del.status, 200, del.text);
++ assert.strictEqual(config().chatApps.telegram.botToken, ENV_TG);
++ const denied = await h.http('POST', `/internal/config/${NS}/test/telegram.bot_token`, { token: h.serviceToken(['chat.stats.read']) });
++ assert.strictEqual(denied.status, 403);
++ const voices = await cfg('GET', '/internal/config');
++ assert.deepStrictEqual(voices.body.namespaces.map((n) => n.namespace), ['chat.voices', 'chat.apps'], 'both namespaces on one route');
++});
++
++t('no log or console line carries a token', async () => {
++ for (const k of Object.keys(original)) console[k] = original[k];
++ for (const s of SECRETS) assert.ok(consoleLines.every((l) => !l.includes(s) && !l.includes(s.slice(0, 12))), `a line carries ${s.slice(0, 8)}…`);
++ assert.ok(consoleLines.some((l) => /\[config\] test chat\.apps telegram\.bot_token: not working/.test(l)), 'the outcome is logged without the token');
++});
++
++t.run(async () => { if (h) await h.close(); });
ddiff --git a/test/owner-settings.test.js b/test/owner-settings.test.js
nnew file mode 100644
iindex 0000000..d4f65b4
---- /dev/null
++++ b/test/owner-settings.test.js
@@@ -0,0 +1,121 @@
++'use strict';
++/**
++ * Owner settings (D11) for Chat's voice keys (server/settings.js, chat.voices):
++ * - every field carries a plain-English title, a 30+ character description, x-owner-input and,
++ * where a feature waits on it, x-required-for; the region enum names every option;
++ * - the admin proxy's capability is required, a secret is write-only and its marker never a value;
++ * - the read path prefers the owner's value and falls back to the environment;
++ * - the test route answers ok/failed in one line and never echoes a key (captured logs too).
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { boot, suite } = require('./helpers');
++const { createSettings } = require('../server/settings');
++
++const t = suite('owner-settings');
++const CAP = 'chat.config.manage';
++const NS = 'chat.voices';
++const USR = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++const ACCESS_ID = 'AKIAOWNERSETTINGSTEST';
++const SECRET = 'owner-secret-value-sentinel';
++let h;
++
++function listen(app) {
++ return new Promise((resolve) => {
++ const server = http.createServer(app);
++ server.listen(0, '127.0.0.1', () => resolve({ server, base: `http://127.0.0.1:${server.address().port}` }));
++ });
++}
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_POLLY_REGION: 'eu-west-1' } });
++});
++
++t('the namespace publishes every voice field with owner words', async () => {
++ const r = await h.http('GET', `/internal/config/${NS}`, { token: h.serviceToken([CAP]) });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.namespace, NS);
++ assert.strictEqual(r.body.service, 'chat');
++ const props = r.body.schema.properties;
++ for (const [key, p] of Object.entries(props)) {
++ assert.ok(p.title && p.title.length >= 3, `${key} has a plain title`);
++ assert.ok(p.description && p.description.length >= 30, `${key} has a 30+ character description`);
++ assert.ok(p['x-owner-input'] && p['x-owner-input'].group === 'voices', `${key} has an owner group`);
++ assert.ok(typeof p['x-owner-input'].unlocks === 'string' && p['x-owner-input'].unlocks.length > 20, `${key} says what it unlocks`);
++ assert.ok(/^https:\/\//.test(p['x-owner-input'].get_it_at), `${key} says where to get it`);
++ assert.ok(p['x-required-for'], `${key} names the feature that waits on it`);
++ }
++ assert.deepStrictEqual(Object.keys(props).sort(), ['amazon_polly_access_key_id', 'amazon_polly_region', 'amazon_polly_secret_access_key', 'google_cloud_service_account_json']);
++ const region = props.amazon_polly_region;
++ assert.ok(Array.isArray(region.enum) && region.enum.length >= 3);
++ for (const v of region.enum) {
++ assert.ok(region['x-enum-labels'][v].label && region['x-enum-labels'][v].description, `region ${v} has words`);
++ }
++});
++
++t('the proxy capability is required and the environment is the fallback', async () => {
++ assert.strictEqual((await h.http('GET', `/internal/config/${NS}`)).status, 401);
++ assert.strictEqual((await h.http('GET', `/internal/config/${NS}`, { token: h.serviceToken(['chat.stats.read']) })).status, 403);
++ const settings = require('../server/settings').current();
++ assert.ok(settings, 'server/index.js created the settings');
++ assert.strictEqual(settings.value('amazon_polly_region', 'CHAT_POLLY_REGION'), 'eu-west-1', 'env fallback when the owner has not set it');
++ assert.strictEqual(settings.value('amazon_polly_region', 'CHAT_POLLY_REGION', 'us-east-1'), 'eu-west-1');
++ assert.strictEqual(settings.value('amazon_polly_access_key_id', 'CHAT_POLLY_ACCESS_KEY_ID', 'none'), 'none', 'no value, no env: the caller fallback');
++});
++
++t('a secret is write-only and sealed in a marker', async () => {
++ const token = h.serviceToken([CAP]);
++ const put = (key, value) => h.http('PUT', `/internal/config/${NS}/secrets/${key}`, {
++ token, headers: { 'x-ov-subject': USR, 'x-ov-fresh': '1' }, body: { value, reason: 'owner test' },
++ });
++ const a = await put('amazon_polly_access_key_id', ACCESS_ID);
++ assert.strictEqual(a.status, 200, a.text);
++ assert.ok(a.body.fingerprint && a.body.hint, 'the answer is a marker');
++ assert.ok(!a.text.includes(ACCESS_ID), 'the answer never carries the value');
++ const b = await put('amazon_polly_secret_access_key', SECRET);
++ assert.strictEqual(b.status, 200, b.text);
++
++ const r = await h.http('GET', `/internal/config/${NS}`, { token });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.body.values.amazon_polly_secret_access_key.redacted, 'the summary shows a marker');
++ assert.ok(!r.text.includes(SECRET) && !r.text.includes(ACCESS_ID), 'no value in the summary');
++
++ const settings = require('../server/settings').current();
++ assert.strictEqual(settings.value('amazon_polly_access_key_id', 'CHAT_POLLY_ACCESS_KEY_ID'), ACCESS_ID, 'the code reads the owner value when set');
++ assert.strictEqual(settings.value('amazon_polly_secret_access_key', 'CHAT_POLLY_SECRET_ACCESS_KEY'), SECRET);
++});
++
++t('the test hook says ok/failed in one line and never echoes a key', async () => {
++ const logs = [];
++ const real = { log: console.log, warn: console.warn, error: console.error };
++ console.log = (...a) => logs.push(a.join(' '));
++ console.warn = (...a) => logs.push(a.join(' '));
++ console.error = (...a) => logs.push(a.join(' '));
++ let fake;
++ try {
++ fake = createSettings({
++ db: h.db.getDb(), env: {}, log: console,
++ probes: { 'amazon-polly': async (creds) => { throw new Error(`refused the key ${creds.awsSecretAccessKey}`); } },
++ });
++ await fake.ready;
++ const app = express();
++ app.use(express.json());
++ fake.mount(app, { guard: (_req, _res, next) => next(), log: console });
++ const { server, base } = await listen(app);
++ try {
++ const r = await fetch(`${base}/internal/config/${NS}/test/amazon_polly_secret_access_key`, { method: 'POST' });
++ const body = await r.json();
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(body.ok, false);
++ assert.ok(/^failed — /.test(body.detail), body.detail);
++ assert.ok(!String(body.detail).includes(SECRET), 'the provider answer is redacted');
++ assert.ok(!body.detail.includes('\n'), 'one line');
++ } finally { server.close(); }
++ } finally {
++ console.log = real.log; console.warn = real.warn; console.error = real.error;
++ }
++ assert.ok(!logs.join('\n').includes(SECRET), 'no key in a captured log line');
++});
++
++t.run(async () => { if (h) await h.close(); });
ddiff --git a/test/pg-schema.test.js b/test/pg-schema.test.js
iindex ae8b401..b419821 100644
---- a/test/pg-schema.test.js
++++ b/test/pg-schema.test.js
@@@ -31,7 +31,7 @@ const TABLES = [
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
'chat_app_link_tokens', 'chat_app_settings', 'chat_app_callbacks', 'chat_app_deliveries', 'chat_app_rates',
'agent_room_reads', 'agent_room_polls', 'agent_room_poll_votes', 'agent_room_identities', 'agent_room_controls', 'room_parties',
-- 'room_message_reactions', 'room_member_timeouts',
++ 'room_message_reactions', 'room_member_timeouts', 'config_snapshots', 'config_keys',
'agent_play_requests', 'agent_play_votes', 'agent_play_log',
];
const INDEXES = [