openvibers/openvibe.connect Commits

Connect: the outside sign-in apps are owner settings (connect.signin_apps) (#46)

OP OpenVibers committed 6 days ago · ab266d69e1 · 10 files, +472 −10

* Connect: the outside sign-in apps are owner settings (connect.signin_apps)

Twitch, Google, Linear, Notion, Meta and Twilio each get a client id and a
client secret with plain titles, descriptions, x-owner-input words, the feature
the pair is required for and a test that makes one cheap authenticated read.
The secrets are sealed and write-only; credentials() reads the owner's value at
use time and falls back to the environment. /internal/config is mounted behind
connect.config.manage, loopback only.

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

* Pin openvibe-contracts v0.311.0, use the canonical Setup group ids and name the config capability once

Changes

ddiff --git a/.env.example b/.env.example
iindex 0ceab2d..8731b43 100644
---- a/.env.example
++++ b/.env.example
@@@ -16,6 +16,22 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_OAUTH_CLIENT_ID=connect
OV_OAUTH_CLIENT_SECRET=
++
++# Outside sign-in apps (D11 owner settings): the admin panel's connect.signin_apps namespace wins;
++# these are the fallback when no value was entered there.
++CONNECT_TWITCH_CLIENT_ID=
++CONNECT_TWITCH_CLIENT_SECRET=
++CONNECT_GOOGLE_CLIENT_ID=
++CONNECT_GOOGLE_CLIENT_SECRET=
++CONNECT_LINEAR_CLIENT_ID=
++CONNECT_LINEAR_CLIENT_SECRET=
++CONNECT_NOTION_CLIENT_ID=
++CONNECT_NOTION_CLIENT_SECRET=
++CONNECT_META_CLIENT_ID=
++CONNECT_META_CLIENT_SECRET=
++CONNECT_TWILIO_CLIENT_ID=
++CONNECT_TWILIO_CLIENT_SECRET=
++
# OpenVibe.Events: account export/deletion deliveries (comma-separated secrets for rotation).
CONNECT_EVENTS_URL=
CONNECT_EVENTS_SECRET=
ddiff --git a/README.md b/README.md
iindex c6aa910..53d7fb2 100644
---- a/README.md
++++ b/README.md
@@@ -216,7 +216,7 @@ Environment: `.env.example`. Production: `/etc/openvibe/connect.env`, unit `open
AGPL-3.0-only. The client (`public/ov-connect.js`) is part of this repository under the same license.
<!-- versions:start -->
--- openvibe-contracts: v0.289.1
++- 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 4d6c106..89b53a1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, ws 8, PostgreSQL 18 (openvibe-sdk/db, async)",
"port": "5240",
"domain": "connect.openvibe.website",
-- "contracts": "openvibe-contracts v0.289.1",
++ "contracts": "openvibe-contracts v0.311.0",
"sdk": "openvibe-sdk v0.93.0",
"shared": "openvibe-shared v4.124.0",
"packages": [
-- "openvibe-contracts v0.289.1",
++ "openvibe-contracts v0.311.0",
"openvibe-sdk v0.93.0",
"openvibe-shared v4.124.0"
],
ddiff --git a/migrations/0013_config.sql b/migrations/0013_config.sql
nnew file mode 100644
iindex 0000000..4e05224
---- /dev/null
++++ b/migrations/0013_config.sql
@@@ -0,0 +1,30 @@
++-- phase: expand
++-- D11 owner settings (openvibe-shared/config): the configuration snapshots and the per-namespace
++-- fingerprint key. Connect's outside sign-in app credentials live in connect.signin_apps; 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 701d504..0211d42 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
++ "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.1",
@@@ -1158,9 +1158,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.289.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
-- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
++ "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 47ddcd2..c4e4333 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
++ "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.1",
ddiff --git a/server/app.js b/server/app.js
iindex eadad25..0f53025 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -20,6 +20,7 @@ const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
++const CONFIG_CAPABILITY = 'connect.config.manage';
const cache = require('openvibe-shared/cache-policy');
const configLib = require('./config');
@@@ -27,6 +28,7 @@ const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
++const settingsLib = require('./settings');
const { createApi } = require('./http/api');
const { createNodeApi } = require('./http/node-api');
const { createNodeAuth } = require('./auth/node-auth');
@@@ -67,6 +69,18 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
++ // D11 owner settings (connect.signin_apps): created before the app mounts /internal/config. A failure leaves
++ // Connect serving (the sign-in apps fall back to the environment) and says why in the log.
++ let settings = opts.settings || null;
++ if (!settings) {
++ try {
++ const made = settingsLib.createSettings({ db: s.db, env: opts.env || process.env, log });
++ await made.ready;
++ settings = settingsLib.activate(made);
++ } catch (err) {
++ log.warn('[OpenVibe.Connect] owner settings unavailable, sign-in apps fall back to the environment:', err && err.message);
++ }
++ }
const shares = createShares(s, { config });
// "Tell me when it's back" (CON-17): when a share's computer connects, everyone waiting on it gets one notification.
const notifier = opts.notifier || require('./notify').createNotifier({ config, fetchImpl, log });
@@@ -106,7 +120,7 @@ async function createApp(opts = {}) {
});
const hooks = createHooks(s, { config, outbox, now: s.now, log });
-- const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, apiKeys, oauth, notifier, nodeAuth, machines, hooks, outbox, accountData, accountSend, log };
++ const ctx = { config, s, keys, sso, principal, settings, shares, tunnels, gate, proxy, apiKeys, oauth, notifier, nodeAuth, machines, hooks, outbox, accountData, accountSend, log };
ctx.probe = createProbe({ shares, proxy, config, log, now: s.now });
ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
@@@ -223,6 +237,28 @@ async function createApp(opts = {}) {
// ── Webhooks to a share's hooks, and Events' deliveries of them back (CON-15; /internal/ is loopback only) ──
app.use(createHookRoutes(ctx));
++ // ── D11 owner settings (openvibe-shared/config): Network's admin proxy, acting for the owner, reads and
++ // writes connect.signin_apps (the outside OAuth apps). Loopback only, guarded by connect.config.manage; a
++ // secret change needs X-OV-Fresh: 1, and the test route makes one cheap authenticated read per provider.
++ if (settings) {
++ const internalOnly = (req, res, next) => {
++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {
++ return contracts.http.sendProblem(res, 404, 'connect.not_found', { detail: 'no such route', ctx: req.ov });
++ }
++ return next();
++ };
++ const configCapability = (req, res, next) => {
++ const p = req.principal;
++ if (!p || p.kind !== 'app') return contracts.http.sendProblem(res, 401, 'token.required', { detail: 'a Network service token with connect.config.manage is required', ctx: req.ov });
++ const decision = contracts.capabilities.check(p.claims, CONFIG_CAPABILITY);
++ const granted = decision.allowed || contracts.capabilities.grants(p.claims.cap, CONFIG_CAPABILITY);
++ if (!granted) return contracts.http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || 'connect.config.manage not granted', ctx: req.ov });
++ return next();
++ };
++ app.use('/internal/config', express.json({ limit: '64kb' }));
++ settings.mount(app, { guard: [internalOnly, principal.middleware, configCapability], log });
++ }
++
// ── OpenVibe.Events → this service (loopback only; nginx answers 404 for /internal/) ──
app.use('/internal/events', accountData.consumer({
secrets: config.events.secrets,
ddiff --git a/server/settings.js b/server/settings.js
nnew file mode 100644
iindex 0000000..d5aab6c
---- /dev/null
++++ b/server/settings.js
@@@ -0,0 +1,273 @@
++'use strict';
++
++/**
++ * Owner settings (D11) for OpenVibe.Connect: the outside sign-in apps in `connect.signin_apps`.
++ *
++ * Connect's apps and trigger sources sign in to Twitch, Google, Linear, Notion, Meta and Twilio with
++ * an OAuth app that the owner registers. Each app has a client id and a client secret; the secret is
++ * sealed by the shared store under OV_CONFIG_KEKS and is write-only through
++ * /internal/config/connect.signin_apps/secrets/:key. Credentials are read at the moment a sign-in
++ * starts (credentials()); the environment is the fallback. Network's admin proxy reaches this
++ * namespace with `connect.config.manage`, acting for the owner (X-OV-Subject), and a secret change
++ * needs X-OV-Fresh: 1. The test route makes one cheap authenticated read per provider and answers
++ * ok or failed in one line, never echoing a key.
++ */
++const { createConfigStore, adminRoutes } = require('openvibe-shared/config');
++
++const SERVICE = 'connect';
++const NAMESPACE = 'connect.signin_apps';
++const BASE_PATH = '/internal/config';
++const GROUP = 'Sign-in apps';
++const REDIRECT = 'https://connect.openvibe.website/auth/callback';
++
++/**
++ * One entry per outside service. `env` names the fallback variables; `test` is the one cheap
++ * authenticated read the owner's Test button makes. A provider's own error text is redacted before
++ * it is answered.
++ */
++const PROVIDERS = Object.freeze({
++ twitch: {
++ label: 'Twitch', feature: 'Twitch sign-in',
++ unlocks: 'People can sign in with Twitch and let their agents use their Twitch account.',
++ get_it_at: 'https://dev.twitch.tv/console/apps',
++ get_it_how: 'Create an application, add the Connect callback URL it shows you, and paste the client ID and secret.',
++ test: async ({ clientId, clientSecret, fetchImpl }) => {
++ const r = await fetchImpl('https://id.twitch.tv/oauth2/token', {
++ method: 'POST',
++ headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: 'client_credentials' }),
++ });
++ const body = await json(r);
++ if (r.ok && body.access_token) return;
++ throw new Error(oauthReason(body, r.status));
++ },
++ },
++ google: {
++ label: 'Google', feature: 'Google sign-in',
++ unlocks: 'People can sign in with Google and let their agents use Google services.',
++ get_it_at: 'https://console.cloud.google.com/apis/credentials',
++ get_it_how: 'Create an OAuth client ID of type Web application, add the Connect callback URL, and paste the client ID and secret.',
++ test: async ({ clientId, clientSecret, fetchImpl }) => {
++ // Google has no client-credentials grant. A deliberately bad code with a good client answers
++ // invalid_grant; a bad client answers invalid_client, which is the cheap authenticated check.
++ const r = await fetchImpl('https://oauth2.googleapis.com/token', {
++ method: 'POST',
++ headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code: 'openvibe-test', grant_type: 'authorization_code', redirect_uri: REDIRECT }),
++ });
++ const body = await json(r);
++ if (body.error === 'invalid_grant') return;
++ throw new Error(oauthReason(body, r.status));
++ },
++ },
++ linear: {
++ label: 'Linear', feature: 'Linear sign-in',
++ unlocks: 'People can sign in with Linear and let their agents read and write their Linear issues.',
++ get_it_at: 'https://linear.app/settings/api/applications',
++ get_it_how: 'Create an OAuth application, add the Connect callback URL, and paste its client ID and secret.',
++ test: async ({ clientId, clientSecret, fetchImpl }) => {
++ const r = await fetchImpl('https://api.linear.app/oauth/token', {
++ method: 'POST',
++ headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code: 'openvibe-test', grant_type: 'authorization_code', redirect_uri: REDIRECT }),
++ });
++ const body = await json(r);
++ if (body.error === 'invalid_grant') return;
++ throw new Error(oauthReason(body, r.status));
++ },
++ },
++ notion: {
++ label: 'Notion', feature: 'Notion sign-in',
++ unlocks: 'People can sign in with Notion and let their agents read and write the pages they choose.',
++ get_it_at: 'https://www.notion.so/my-integrations',
++ get_it_how: 'Create a public integration, set the Connect callback URL as its redirect URI, and paste the OAuth client ID and secret.',
++ test: async ({ clientId, clientSecret, fetchImpl }) => {
++ const r = await fetchImpl('https://api.notion.com/v1/oauth/token', {
++ method: 'POST',
++ headers: { 'content-type': 'application/json', authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, 'notion-version': '2022-06-28' },
++ body: JSON.stringify({ grant_type: 'authorization_code', code: 'openvibe-test', redirect_uri: REDIRECT }),
++ });
++ const body = await json(r);
++ if (body.error === 'invalid_grant') return;
++ throw new Error(oauthReason(body, r.status));
++ },
++ },
++ meta: {
++ label: 'Meta', feature: 'Meta sign-in',
++ unlocks: 'People can sign in with Facebook or Instagram and let their agents use the accounts they choose.',
++ get_it_at: 'https://developers.facebook.com/apps/',
++ get_it_how: 'Create an app with Facebook Login, add the Connect callback URL, and paste the app ID and app secret from Settings.',
++ test: async ({ clientId, clientSecret, fetchImpl }) => {
++ const q = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, grant_type: 'client_credentials' });
++ const r = await fetchImpl(`https://graph.facebook.com/oauth/access_token?${q}`);
++ const body = await json(r);
++ if (r.ok && body.access_token) return;
++ throw new Error(oauthReason(body, r.status));
++ },
++ },
++ twilio: {
++ label: 'Twilio', feature: 'Twilio sign-in',
++ unlocks: 'People can sign in with Twilio and let their agents send and read messages from their Twilio account.',
++ get_it_at: 'https://console.twilio.com/us1/account/keys-credentials/api-keys',
++ get_it_how: 'Create a standard API key. The client ID is the account SID (AC...) or the API key SID (SK...); the secret is the auth token or API key secret.',
++ test: async ({ clientId, clientSecret, fetchImpl }) => {
++ const r = await fetchImpl(`https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(clientId)}.json`, {
++ headers: { authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}` },
++ });
++ const body = await json(r);
++ if (r.ok && body.sid) return;
++ throw new Error(oauthReason(body, r.status));
++ },
++ },
++});
++
++async function json(res) {
++ try { return await res.json(); } catch { return {}; }
++}
++
++/** The provider's reason without a credential in it (a client id can appear in some OAuth errors). */
++function oauthReason(body, status) {
++ const s = String((body && (body.error_description || body.error && body.error.message || body.error || body.message)) || `HTTP ${status}`);
++ return s.split(/\s+/).join(' ').slice(0, 160);
++}
++
++const SECRETS = new Set(Object.keys(PROVIDERS).map((p) => `${p}_client_secret`));
++
++function field(provider, kind) {
++ const p = PROVIDERS[provider];
++ const secret = kind === 'secret';
++ const name = `${provider}_client_${secret ? 'secret' : 'id'}`;
++ return [name, {
++ type: 'string',
++ title: `${p.label} client ${secret ? 'secret' : 'ID'}`,
++ 'x-group': GROUP,
++ description: secret
++ ? `The OAuth client secret of the ${p.label} app. It is locked when saved and never shown again; enter it again if you rotate it at ${p.label}.`
++ : `The OAuth client ID of the ${p.label} app people sign in with. It is public in the sign-in redirect, but only with the secret does it let anyone in.`,
++ 'x-owner-input': {
++ group: 'sign-in-apps',
++ unlocks: p.unlocks,
++ get_it_at: p.get_it_at,
++ get_it_how: p.get_it_how,
++ test: true,
++ },
++ 'x-required-for': p.feature,
++ }];
++}
++
++const SCHEMA = Object.freeze({
++ $schema: 'https://json-schema.org/draft/2020-12/schema',
++ title: 'Sign-in apps',
++ description: 'The OAuth apps Connect uses to sign people in to outside services. An app with no client id or secret stays hidden; the others keep working.',
++ type: 'object',
++ additionalProperties: false,
++ properties: Object.fromEntries(Object.keys(PROVIDERS).flatMap((p) => [field(p, 'id'), field(p, 'secret')])),
++});
++
++const ACTIVE = { settings: null };
++
++function providerOfKey(key) {
++ const m = /^([a-z]+)_client_(id|secret)$/.exec(String(key || ''));
++ return m && PROVIDERS[m[1]] ? m[1] : null;
++}
++
++/** Create the connect.signin_apps store. `ready` must resolve before mount(). */
++function createSettings({ db, env = process.env, log = console, fetchImpl = globalThis.fetch, probes = null } = {}) {
++ let store = null;
++ const ready = createConfigStore({
++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA,
++ classify: (key) => (SECRETS.has(key) ? 'secret' : 'internal'),
++ env, log,
++ }).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;
++ }
++
++ /** The app a provider signs in with, read at the moment the sign-in starts. */
++ function credentials(provider) {
++ const p = PROVIDERS[provider];
++ if (!p) return null;
++ const upper = provider.toUpperCase();
++ return {
++ clientId: valueOf(`${provider}_client_id`, `CONNECT_${upper}_CLIENT_ID`),
++ clientSecret: valueOf(`${provider}_client_secret`, `CONNECT_${upper}_CLIENT_SECRET`),
++ };
++ }
++
++ function configured(provider) {
++ const c = credentials(provider);
++ return Boolean(c && c.clientId && c.clientSecret);
++ }
++
++ const probeFor = (provider) => {
++ const p = probes && probes[provider];
++ return typeof p === 'function' ? p : (creds) => PROVIDERS[provider].test({ ...creds, fetchImpl });
++ };
++
++ /** POST /internal/config/connect.signin_apps/test/:key → { ok, detail } (one line, never a value). */
++ async function test(key) {
++ const provider = providerOfKey(key);
++ if (!provider) return { ok: false, detail: 'That setting has no test.' };
++ const creds = credentials(provider);
++ if (!creds.clientId || !creds.clientSecret) return { ok: false, detail: `${PROVIDERS[provider].label} is not configured: both the client ID and its secret are needed.` };
++ try {
++ await probeFor(provider)(creds);
++ return { ok: true, detail: `ok — ${PROVIDERS[provider].label} accepted the app credentials.` };
++ } catch (err) {
++ const words = String((err && err.message) || 'the provider refused the credentials').split(/\s+/).join(' ').slice(0, 160);
++ const redacted = [creds.clientId, creds.clientSecret].filter((s) => s && s.length >= 4).reduce((out, s) => out.split(s).join('[redacted]'), words);
++ return { ok: false, detail: `failed — ${PROVIDERS[provider].label} said: ${redacted}` };
++ }
++ }
++
++ /** Mount the admin routes and the test route; `guard` is the service-token capability middleware. */
++ function mount(app, { guard, log: appLog = log } = {}) {
++ if (!store) throw new Error('createSettings: await ready before mount()');
++ const routes = adminRoutes([store], {
++ 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: appLog,
++ });
++ app.use(routes.handle);
++ 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 (!SCHEMA.properties[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: String(out.detail).replace(/\s+/g, ' ').slice(0, 200) });
++ } catch (err) {
++ appLog.warn(`[config] ${NAMESPACE}: the test for ${key} failed: ${String((err && err.message) || err).slice(0, 160)}`);
++ 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, providers: PROVIDERS, ready, value: valueOf, get: values, credentials, configured, test, mount };
++}
++
++function activate(settings) { ACTIVE.settings = settings; return settings; }
++
++/** The app a provider signs in with (server/oauth-apps.js), read at use time. */
++function credentials(provider) {
++ if (ACTIVE.settings) return ACTIVE.settings.credentials(provider);
++ const p = PROVIDERS[provider];
++ if (!p) return null;
++ const upper = String(provider).toUpperCase();
++ return { clientId: process.env[`CONNECT_${upper}_CLIENT_ID`] || '', clientSecret: process.env[`CONNECT_${upper}_CLIENT_SECRET`] || '' };
++}
++
++module.exports = { SERVICE, NAMESPACE, SCHEMA, SECRETS, PROVIDERS, createSettings, activate, credentials, configured: (p) => { const c = credentials(p); return Boolean(c && c.clientId && c.clientSecret); } };
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex f5d6625..7565cb5 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, notifier: opts.notifier, probeDelayMs: opts.probeDelayMs != null ? opts.probeDelayMs : 60_000 });
++ const built = await createApp({ config, store, log, env, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, notifier: opts.notifier, probeDelayMs: opts.probeDelayMs != null ? opts.probeDelayMs : 60_000 });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.on('upgrade', built.ctx.onUpgrade); s.listen(0, '127.0.0.1', () => resolve(s)); });
const port = server.address().port;
ddiff --git a/test/owner-settings.test.js b/test/owner-settings.test.js
nnew file mode 100644
iindex 0000000..200b308
---- /dev/null
++++ b/test/owner-settings.test.js
@@@ -0,0 +1,107 @@
++'use strict';
++/**
++ * Owner settings (D11) for Connect's outside sign-in apps (server/settings.js, connect.signin_apps):
++ * - six providers, a client id and secret each, with plain titles, 30+ character descriptions,
++ * x-owner-input words, x-required-for and a test hook;
++ * - /internal/config is loopback only and needs connect.config.manage;
++ * - 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 or logs a key.
++ * node test/owner-settings.test.js
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { boot } = require('./helpers/boot');
++const { check, done } = require('./helpers/check');
++const { createSettings, PROVIDERS } = require('../server/settings');
++
++const CAP = 'connect.config.manage';
++const NS = 'connect.signin_apps';
++const USR = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++const SECRET = 'connect-owner-secret-sentinel';
++const ENV_ID = 'env-google-client-id';
++
++(async () => {
++ const t = await boot({ env: { CONNECT_GOOGLE_CLIENT_ID: ENV_ID } });
++ const issuer = t.network.issuer();
++ const now = Math.floor(Date.now() / 1000);
++ const tokenFor = (caps) => t.network.signJwt({ iss: issuer, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.website'], cap: caps, iat: now, exp: now + 300, jti: `tok_${Math.random().toString(36).slice(2)}` });
++ const TOKEN = tokenFor([CAP]);
++
++ await check('every provider publishes an ID and a secret with owner words', async () => {
++ const r = await t.get(`/internal/config/${NS}`, { bearer: TOKEN });
++ assert.strictEqual(r.status, 200, r.text);
++ const props = r.json().schema.properties;
++ assert.strictEqual(Object.keys(props).length, Object.keys(PROVIDERS).length * 2);
++ for (const p of Object.keys(PROVIDERS)) {
++ for (const kind of ['id', 'secret']) {
++ const key = `${p}_client_${kind}`;
++ const f = props[key];
++ assert.ok(f, `${key} exists`);
++ assert.ok(f.title && f.title.length >= 3, `${key} has a title`);
++ assert.ok(f.description && f.description.length >= 30, `${key} has a 30+ character description`);
++ assert.strictEqual(f['x-owner-input'].group, 'sign-in-apps', `${key} is in Sign-in apps`);
++ assert.ok(f['x-owner-input'].unlocks && f['x-owner-input'].unlocks.length > 20, `${key} says what it unlocks`);
++ assert.ok(/^https:\/\//.test(f['x-owner-input'].get_it_at), `${key} says where to get it`);
++ assert.ok(f['x-owner-input'].get_it_how, `${key} says how`);
++ assert.strictEqual(f['x-owner-input'].test, true, `${key} has a test`);
++ assert.ok(f['x-required-for'], `${key} names the feature that waits on it`);
++ }
++ }
++ });
++
++ await check('the config routes are loopback only and need the capability', async () => {
++ assert.strictEqual((await t.get(`/internal/config/${NS}`, { bearer: tokenFor(['connect.share.read']) })).status, 403);
++ assert.strictEqual((await t.get(`/internal/config/${NS}`)).status, 401);
++ assert.strictEqual((await t.get(`/internal/config/${NS}`, { bearer: TOKEN, headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 404);
++ });
++
++ await check('a secret is write-only and the environment is the fallback', async () => {
++ assert.strictEqual(t.ctx.settings.credentials('google').clientId, ENV_ID, 'the env is the fallback');
++ const r = await t.get(`/internal/config/${NS}/secrets/twitch_client_secret`, {
++ bearer: TOKEN, method: 'PUT', json: { value: SECRET, reason: 'owner test' },
++ headers: { 'x-ov-subject': USR, 'x-ov-fresh': '1' },
++ });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.json().fingerprint && r.json().hint);
++ assert.ok(!r.text.includes(SECRET), 'the answer never carries the value');
++ const id = await t.get(`/internal/config/${NS}`, {
++ bearer: TOKEN, method: 'POST', json: { values: { twitch_client_id: 'twitch-client-id' }, merge: true, reason: 'owner test' },
++ headers: { 'x-ov-subject': USR },
++ });
++ assert.strictEqual(id.status, 200, id.text);
++ const summary = await t.get(`/internal/config/${NS}`, { bearer: TOKEN });
++ assert.ok(summary.json().values.twitch_client_secret.redacted, 'the summary shows a marker');
++ assert.ok(!summary.text.includes(SECRET), 'no secret value in the summary');
++ assert.strictEqual(t.ctx.settings.credentials('twitch').clientSecret, SECRET, 'the code reads the owner value when set');
++ assert.strictEqual(t.ctx.settings.credentials('twitch').clientId, 'twitch-client-id');
++ assert.strictEqual(t.ctx.settings.credentials('google').clientId, ENV_ID, 'unset keys still fall back');
++ });
++
++ await check('the test hook says ok/failed in one line and never echoes or logs a key', async () => {
++ const before = t.logs ? t.logs() : '';
++ const fake = createSettings({
++ db: t.ctx.s.db, env: {},
++ probes: { twitch: async (creds) => { throw new Error(`refused ${creds.clientSecret}`); } },
++ });
++ await fake.ready;
++ const app = express();
++ app.use(express.json());
++ fake.mount(app, { guard: (_req, _res, next) => next(), log: t.log });
++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ try {
++ const r = await fetch(`http://127.0.0.1:${server.address().port}/internal/config/${NS}/test/twitch_client_secret`, { method: 'POST' });
++ const body = await r.json();
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(body.ok, false);
++ assert.ok(/^failed — Twitch said: /.test(body.detail), body.detail);
++ assert.ok(!body.detail.includes(SECRET), 'the provider answer is redacted');
++ assert.ok(!body.detail.includes('\n'), 'one line');
++ } finally { server.close(); }
++ const lines = t.logs ? t.logs().slice(before.length) : '';
++ assert.ok(!lines.includes(SECRET), 'no key in a captured log line');
++ });
++
++ await done(t);
++})().catch((e) => { console.error(e); process.exit(1); });