openvibers/openvibe.connect Commits
Connect: the outside sign-in apps are owner settings (connect.signin_apps) (#46)
Changes
@@@ -16,6 +16,22 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000OV_OAUTH_CLIENT_ID=connectOV_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=@@@ -216,7 +216,7 @@ Environment: `.env.example`. Production: `/etc/openvibe/connect.env`, unit `openAGPL-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 -->@@@ -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"],nnew file mode 100644@@@ -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++);@@@ -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",@@@ -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",@@@ -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,nnew file mode 100644@@@ -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); } };@@@ -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;nnew file mode 100644@@@ -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); });
@@ -16,6 +16,22 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -16,6 +16,22 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_OAUTH_CLIENT_ID=connect
OV_OAUTH_CLIENT_ID=connect
OV_OAUTH_CLIENT_SECRET=
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).
# OpenVibe.Events: account export/deletion deliveries (comma-separated secrets for rotation).
CONNECT_EVENTS_URL=
CONNECT_EVENTS_URL=
CONNECT_EVENTS_SECRET=
CONNECT_EVENTS_SECRET=
@@ -216,7 +216,7 @@ Environment: `.env.example`. Production: `/etc/openvibe/connect.env`, unit `open
@@ -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.
AGPL-3.0-only. The client (`public/ov-connect.js`) is part of this repository under the same license.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.289.1
+- openvibe-contracts: v0.311.0
- openvibe-sdk: v0.93.0
- openvibe-sdk: v0.93.0
- openvibe-shared: v4.124.0
- openvibe-shared: v4.124.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,11 +7,11 @@
@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, ws 8, PostgreSQL 18 (openvibe-sdk/db, async)",
"runtime": "node 22, express 4, ws 8, PostgreSQL 18 (openvibe-sdk/db, async)",
"port": "5240",
"port": "5240",
"domain": "connect.openvibe.website",
"domain": "connect.openvibe.website",
- "contracts": "openvibe-contracts v0.289.1",
+ "contracts": "openvibe-contracts v0.311.0",
"sdk": "openvibe-sdk v0.93.0",
"sdk": "openvibe-sdk v0.93.0",
"shared": "openvibe-shared v4.124.0",
"shared": "openvibe-shared v4.124.0",
"packages": [
"packages": [
- "openvibe-contracts v0.289.1",
+ "openvibe-contracts v0.311.0",
"openvibe-sdk v0.93.0",
"openvibe-sdk v0.93.0",
"openvibe-shared v4.124.0"
"openvibe-shared v4.124.0"
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -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
+);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -1158,9 +1158,9 @@
@@ -1158,9 +1158,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
@@ -20,6 +20,7 @@ const helmet = require('helmet');
@@ -20,6 +20,7 @@ const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
+const CONFIG_CAPABILITY = 'connect.config.manage';
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const configLib = require('./config');
const configLib = require('./config');
@@ -27,6 +28,7 @@ const { openStore } = require('./db');
@@ -27,6 +28,7 @@ const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
+const settingsLib = require('./settings');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createNodeApi } = require('./http/node-api');
const { createNodeApi } = require('./http/node-api');
const { createNodeAuth } = require('./auth/node-auth');
const { createNodeAuth } = require('./auth/node-auth');
@@ -67,6 +69,18 @@ async function createApp(opts = {}) {
@@ -67,6 +69,18 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
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 });
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.
// "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 });
const notifier = opts.notifier || require('./notify').createNotifier({ config, fetchImpl, log });
@@ -106,7 +120,7 @@ async function createApp(opts = {}) {
@@ -106,7 +120,7 @@ async function createApp(opts = {}) {
});
});
const hooks = createHooks(s, { config, outbox, now: s.now, log });
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.probe = createProbe({ shares, proxy, config, log, now: s.now });
ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
ctx.probeDelayMs = opts.probeDelayMs != null ? opts.probeDelayMs : 10_000;
@@ -223,6 +237,28 @@ async function createApp(opts = {}) {
@@ -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) ──
// ── Webhooks to a share's hooks, and Events' deliveries of them back (CON-15; /internal/ is loopback only) ──
app.use(createHookRoutes(ctx));
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/) ──
// ── OpenVibe.Events → this service (loopback only; nginx answers 404 for /internal/) ──
app.use('/internal/events', accountData.consumer({
app.use('/internal/events', accountData.consumer({
secrets: config.events.secrets,
secrets: config.events.secrets,
new file mode 100644
new file mode 100644
@@ -0,0 +1,273 @@
@@ -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); } };
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
@@ -44,7 +44,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
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();
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 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;
const port = server.address().port;
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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); });