openvibers/openvibe.codes Commits
Owner inputs: the sign-in apps, the GitHub token and the publishing tokens (codes.accounts, codes.publishing) (#297)
Changes
@@@ -176,7 +176,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- 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, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4900,"domain": "openvibe.codes",-- "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"],@@@ -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.0"@@@ -1149,9 +1149,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",@@@ -30,7 +30,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.0"@@@ -7,6 +7,10 @@* /internal/config is openvibe-shared/config's adminRoutes, guarded by codes.config.manage (Network's service* token only). actor is the owner Network names in X-OV-Subject; X-OV-Fresh: 1 gates a secret or x-danger write* (this namespace has neither). Every activation writes one audit line with the actor and the keys, never a value.++ *++ * The same routes serve the stores passed as `also` (codes.accounts and codes.publishing, server/owner-inputs-config.js),++ * and POST /internal/config/:namespace/test/:key, the admin panel's Setup tab Test button, answers for the keys a++ * `test` hook knows (the owner-setup design of 2026-11-07); it is mounted before the config routes.*/const express = require('express');const shared = require('openvibe-shared/config');@@@ -19,14 +23,14 @@ const CAPABILITY = 'codes.config.manage';const SCHEMA = {$schema: 'https://json-schema.org/draft/2020-12/schema',title: 'OpenVibe.AI in Codes',-- description: 'Read OpenVibe.AI\'s model catalog so the pickers offer its aliases and exact ids.',++ description: 'Whether Codes\' model pickers offer every model OpenVibe.AI can run. Change it when you want people to pick an exact model instead of the one default entry.',type: 'object',additionalProperties: false,required: ['catalog'],properties: {catalog: {-- type: 'boolean', title: 'AI model catalog', 'x-group': 'AI Exchange', 'x-env': 'CODES_AI_CATALOG',-- description: 'Read GET /v1/models with Codes\' own service token and cache the answer about five minutes. Off, the pickers keep today\'s one entry.',++ type: 'boolean', title: 'Offer every AI model in the pickers', 'x-group': 'AI Exchange', 'x-env': 'CODES_AI_CATALOG',++ description: 'When on, Codes reads OpenVibe.AI\'s model list (refreshed about every five minutes) and its pickers offer every model by name. Off (the default) keeps the single default entry.',},},};@@@ -66,7 +70,7 @@ function actor(req) {const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';--async function create({ db, keys, config, env = process.env, log = console, onActivate = null }) {++async function create({ db, keys, config, env = process.env, log = console, onActivate = null, also = [], test = null }) {// CODES_AI_CATALOG has always accepted 1/true/yes/on; the shared store's boolean pin reads true/1.const storeEnv = { ...env };if (['1', 'true', 'yes', 'on'].includes(String(storeEnv.CODES_AI_CATALOG || '').trim().toLowerCase())) storeEnv.CODES_AI_CATALOG = '1';@@@ -80,14 +84,52 @@ async function create({ db, keys, config, env = process.env, log = console, onAcif (env.OV_CONFIG_KEKS) {try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }}-- const router = shared.adminRoutes([store], {-- basePath: '/internal/config', requireAdmin: capabilityGuard({ keys, config, log }), actor, requireFresh, log,++ const guard = capabilityGuard({ keys, config, log });++ const router = shared.adminRoutes([store, ...also], {++ basePath: '/internal/config', requireAdmin: guard, actor, requireFresh, log,});return {store, router,get: (key) => store.get(key),-- mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },++ mount(app) {++ app.use('/internal/config', express.json({ limit: '128kb' }));++ if (test) app.post('/internal/config/:namespace/test/:key', guard, testRoute({ test, log }));++ router.mount(app);++ },++ };++}++++/** One line, at most 200 characters, with the secret replaced. */++function oneLine(text, secret) {++ let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();++ if (typeof secret === 'string' && secret.length >= 4) {++ s = s.split(secret).join('[hidden]');++ if (secret.length >= 12) s = s.split(secret.slice(0, 12)).join('[hidden]');++ }++ return s.length > 200 ? `${s.slice(0, 199)}…` : s;++}++++/**++ * The Setup tab's Test button: test(namespace, key) answers { value, source, env, run(value) } or null (no test).++ * 200 { ok, message, state, source, namespace, key, tested_at } (openvibe-shared 4.125.0's { ok, message } with more++ * beside it), 409 config.not_set when neither the panel nor the settings file has a value. Never the value itself.++ */++function testRoute({ test, log }) {++ return async function configTest(req, res) {++ const namespace = String(req.params.namespace || '');++ const key = String(req.params.key || '');++ let found = null;++ try { found = await test(namespace, key); } catch { found = null; }++ if (!found) return http.sendProblem(res, 404, 'config.test_not_found', { detail: `${namespace} has no test for ${key}`, ctx: req.ov });++ if (!found.value) return http.sendProblem(res, 409, 'config.not_set', { detail: `${key} is not set, so there is nothing to test`, ctx: req.ov });++ let out;++ try { out = await found.run(found.value); } catch (err) { out = { ok: false, message: `The test could not run: ${(err && err.message) || 'unknown error'}` }; }++ const ok = Boolean(out && out.ok);++ const from = found.source === 'env' && found.env ? ` (the value from the server's settings file, ${found.env})` : '';++ const message = oneLine(`${(out && out.message) || (ok ? 'It works.' : 'It did not work.')}${from}`, found.value);++ (log.info || log.log).call(log, `[config] test ${namespace} ${key}: ${ok ? 'connected' : 'not working'}`);++ return res.json({ ok, message, state: ok ? 'connected' : 'not_working', source: found.source || null, namespace, key, tested_at: new Date().toISOString() });};}--module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };++module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY, oneLine };@@@ -55,6 +55,8 @@ const { createCodesReadiness } = require('./observability');const { createActorLimits } = require('./http/actor-limits');const { createForgejoClient } = require('./domain/forgejo');const createAiCatalogConfig = require('./ai-catalog-config').create;++const createOwnerInputs = require('./owner-inputs-config').create;++const { createOwnerInputTests } = require('./owner-inputs-tests');const { createMachines } = require('./domain/machines');const { createMemory } = require('./domain/memory');const { createKnowledge } = require('./domain/knowledge');@@@ -85,7 +87,19 @@ async function createApp(opts = {}) {const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });// D11 owner settings (codes.ai): the catalog switch reads through the store, env pins winning.-- const aiCatalogConfig = await createAiCatalogConfig({ db: store.db, keys, config, env: opts.env || process.env, log });++ // The owner's outside accounts (codes.accounts, codes.publishing; the owner-setup design): sealed in Codes' store,++ // read at use time with the environment as the fallback. config.git's app and config.github's token become getters,++ // so a value entered in the admin panel takes effect without a restart.++ const ownerInputs = await createOwnerInputs({ db: store.db, env: opts.env || process.env, log });++ const envGit = { clientId: config.git.clientId, clientSecret: config.git.clientSecret };++ const envGithubToken = config.github ? config.github.token : '';++ const fromPanel = (key, fallback) => { const v = ownerInputs.accounts.get(key); return typeof v === 'string' && v ? v : (fallback || ''); };++ Object.defineProperty(config.git, 'clientId', { enumerable: true, configurable: true, get: () => fromPanel('forge.client_id', envGit.clientId) });++ Object.defineProperty(config.git, 'clientSecret', { enumerable: true, configurable: true, get: () => fromPanel('forge.client_secret', envGit.clientSecret) });++ config.github = config.github || {};++ Object.defineProperty(config.github, 'token', { enumerable: true, configurable: true, get: () => fromPanel('github.token', envGithubToken) });++ const findTest = createOwnerInputTests({ config, ownerInputs, fetchImpl: opts.fetchImpl || globalThis.fetch });++ const aiCatalogConfig = await createAiCatalogConfig({ db: store.db, keys, config, env: opts.env || process.env, log, also: ownerInputs.stores, test: findTest });Object.defineProperty(config.aiCatalog, 'enabled', { enumerable: true, configurable: true, get: () => aiCatalogConfig.get('catalog') });const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });// The harness catalog (server/data/harness-offers.json), read once: a bad seed row fails boot.@@@ -59,7 +59,8 @@ function createForge({ config, store, log = console }) {const internalUrl = String(git.internalUrl || publicUrl).replace(/\/+$/, '');const host = (() => { try { return new URL(publicUrl).host.toLowerCase(); } catch { return ''; } })();const seal = createSeal(git.sealKey);-- const enabled = !!(git.clientId && git.clientSecret && seal);++ // At use time: the app's ID and secret may be entered in the admin panel after boot (codes.accounts forge.*).++ const isEnabled = () => !!(git.clientId && git.clientSecret && seal);const redirectUri = `${String(config.baseUrl || '').replace(/\/+$/, '')}/git/callback`;const db = store.db;const fail = (status, code, message) => Object.assign(new Error(message), { status, code });@@@ -89,7 +90,7 @@ function createForge({ config, store, log = console }) {}async function connect(subject, { code, verifier }) {-- if (!enabled) throw fail(503, 'forge.off', 'Linking OpenVibe Git is not open yet');++ if (!isEnabled()) throw fail(503, 'forge.off', 'Linking OpenVibe Git is not open yet');if (!USER.test(String(subject || ''))) throw fail(400, 'subject.required', 'a person signs in first');const t = await token({ grant_type: 'authorization_code', code: String(code || ''), code_verifier: String(verifier || ''), redirect_uri: redirectUri });const me = await call(internalUrl, 'GET', '/api/v1/user', { headers: { Authorization: `Bearer ${t.access_token}` } });@@@ -107,7 +108,7 @@ function createForge({ config, store, log = console }) {/** A live access token for the person: refreshed when it ends within `margin` ms. null when unlinked or refused. */async function tokenFor(subject, margin = 60_000) {-- if (!enabled) return null;++ if (!isEnabled()) return null;const row = await db.maybe('SELECT forge_user, token_sealed, refresh_sealed, expires_at FROM codes_forge_links WHERE subject = $1', [subject]);if (!row) return null;const ends = row.expires_at ? new Date(row.expires_at).getTime() : Infinity;@@@ -161,7 +162,7 @@ function createForge({ config, store, log = console }) {return { OPENVIBE_GIT_CREDENTIALS: `https://${encodeURIComponent(t.login)}:${encodeURIComponent(t.token)}@${host}` };}-- return { enabled, publicUrl, host, authorizeUrl, connect, link, unlink, createRepo, repos, jobEnv, tokenFor };++ return { get enabled() { return isEnabled(); }, publicUrl, host, authorizeUrl, connect, link, unlink, createRepo, repos, jobEnv, tokenFor };}module.exports = { createForge, REPO_NAME };@@@ -61,7 +61,8 @@ function ciWord(status) {}function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch, log = console, ship = null } = {}) {-- const token = String((config.github && config.github.token) || '').trim();++ // Read at use time: the owner's token from the admin panel (codes.accounts github.token), else CODES_GITHUB_TOKEN.++ const tokenOf = () => String((config.github && config.github.token) || '').trim();const cache = new Map();const searches = new Map();// The host's own deploy signal: the service's /release.json, which `ovhost status` reads too (Codes C9c).@@@ -70,7 +71,7 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,const headers = () => ({Accept: 'application/vnd.github+json','User-Agent': 'openvibe-codes',-- ...(token ? { Authorization: `Bearer ${token}` } : {}),++ ...(tokenOf() ? { Authorization: `Bearer ${tokenOf()}` } : {}),});async function getJson(path) {@@@ -29,7 +29,8 @@ function serviceFor(manifests, repo) {function createShip({ config = {}, fetchImpl = globalThis.fetch, manifests = null, log = console } = {}) {const list = manifests || require('openvibe-contracts').services.manifests;-- const token = String((config.github && config.github.token) || '').trim();++ // Read at use time: the owner's token from the admin panel (codes.accounts github.token), else CODES_GITHUB_TOKEN.++ const tokenOf = () => String((config.github && config.github.token) || '').trim();const base = String(process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');async function releaseOf(origin) {@@@ -45,7 +46,7 @@ function createShip({ config = {}, fetchImpl = globalThis.fetch, manifests = nulif (!SHA.test(String(release || ''))) return null;try {const res = await fetchImpl(`${base}/repos/${repo}/compare/${sha}...${release}`, {-- headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'openvibe-codes', ...(token ? { Authorization: `Bearer ${token}` } : {}) },++ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'openvibe-codes', ...(tokenOf() ? { Authorization: `Bearer ${tokenOf()}` } : {}) },signal: AbortSignal.timeout(6000),});if (!res.ok) return null;nnew file mode 100644@@@ -0,0 +1,197 @@++'use strict';++/**++ * Codes' owner inputs (the owner-setup design of 2026-11-07): the outside accounts and keys only the owner can give,++ * as two namespaces on openvibe-shared/config that the admin panel's Setup tab lists.++ *++ * codes.accounts the sign-in apps and the GitHub reader:++ * forge.client_id / forge.client_secret the OpenVibe Git (Forgejo) OAuth app that lets a++ * person link their OpenVibe Git account (read by++ * server/domain/forge.js; CODES_FORGE_CLIENT_* fallback)++ * github.client_id / github.client_secret the GitHub OAuth app for a GitHub sign-in in Codes.++ * Nothing reads it yet: Optional, waiting for that sign-in++ * github.token a read-only GitHub token that raises GitHub's limits++ * for the repository search and the CI state (read by++ * server/domain/github.js and ship.js; CODES_GITHUB_TOKEN++ * fallback)++ * codes.publishing the store and registry tokens release jobs need (Chrome Web Store, Firefox add-ons, npm, code++ * signing). No release job reads them yet, so every one is Optional and says what it unlocks.++ *++ * Secrets are write-only through /internal/config/:namespace/secrets/:key, sealed with the service's KEK and shown++ * only as their last four characters. No x-env pin: the value entered in the panel wins, and the environment stays++ * the fallback, read at use time through value() below.++ */++const shared = require('openvibe-shared/config');++++const SERVICE = 'codes';++const ACCOUNTS = 'codes.accounts';++const PUBLISHING = 'codes.publishing';++++const ACCOUNTS_SCHEMA = {++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'Sign-in apps and GitHub',++ description: 'The outside apps people sign in to from Codes, and the GitHub token Codes reads public repositories with. Change these when you register an app, rotate its secret or replace the token.',++ type: 'object',++ additionalProperties: false,++ properties: {++ 'forge.client_id': {++ type: 'string', minLength: 8, maxLength: 200, title: 'OpenVibe Git app: client ID', 'x-group': 'OpenVibe Git', 'x-example': '3f2a9c1e-…',++ description: 'The client ID of the OAuth2 application registered on OpenVibe Git for Codes. With its secret and the seal key it lets people link their OpenVibe Git account; without it linking stays closed.',++ 'x-owner-input': {++ group: 'sign-in-apps',++ unlocks: 'Lets people link their OpenVibe Git account in Codes, so tasks can make repositories and push as them. Without it the Link button says linking is not open yet.',++ get_it_at: 'https://git.openvibe.codes/user/settings/applications',++ get_it_how: 'Signed in as the owner, create an OAuth2 application named Codes with the redirect URI https://openvibe.codes/git/callback, and copy its Client ID.',++ test: false,++ },++ 'x-required-for': 'Linking an OpenVibe Git account',++ },++ 'forge.client_secret': {++ type: 'string', minLength: 8, maxLength: 512, title: 'OpenVibe Git app: client secret', 'x-group': 'OpenVibe Git',++ description: 'The secret of the same OpenVibe Git application, shown once when it is created. Codes sends it only to OpenVibe Git, when a person links their account; it is never shown again here.',++ 'x-owner-input': {++ group: 'sign-in-apps',++ unlocks: 'Completes the OpenVibe Git app: with the client ID it lets people link their OpenVibe Git account in Codes.',++ get_it_at: 'https://git.openvibe.codes/user/settings/applications',++ get_it_how: 'Copy the Client Secret shown when the application is created (or regenerate it there).',++ test: false,++ },++ 'x-required-for': 'Linking an OpenVibe Git account',++ },++ 'github.client_id': {++ type: 'string', minLength: 8, maxLength: 200, title: 'GitHub app: client ID', 'x-group': 'GitHub', 'x-example': 'Ov23li…',++ description: 'The client ID of a GitHub OAuth app for signing in to Codes with GitHub. Codes does not offer that sign-in yet; the app waits here until it does, and nothing uses it before then.',++ 'x-owner-input': {++ group: 'sign-in-apps',++ unlocks: 'Will let people connect their GitHub account in Codes, so tasks can read their private repositories and open pull requests as them. Optional: Codes has no GitHub sign-in yet.',++ get_it_at: 'https://github.com/organizations/OpenVibers/settings/applications/new',++ get_it_how: 'Register a new OAuth app for the OpenVibers organisation with the homepage https://openvibe.codes and the callback https://openvibe.codes/auth/github/callback.',++ test: false,++ },++ },++ 'github.client_secret': {++ type: 'string', minLength: 8, maxLength: 512, title: 'GitHub app: client secret', 'x-group': 'GitHub',++ description: 'The secret of the same GitHub OAuth app. The Test button checks the ID and secret with GitHub without signing anyone in; nothing else uses it until Codes has a GitHub sign-in.',++ 'x-owner-input': {++ group: 'sign-in-apps',++ unlocks: 'Completes the GitHub app for a future GitHub sign-in in Codes. Optional: nothing uses it yet.',++ get_it_at: 'https://github.com/organizations/OpenVibers/settings/applications',++ get_it_how: 'Open the app, Generate a new client secret, and copy it at once (GitHub shows it only once).',++ test: true,++ },++ },++ 'github.token': {++ type: 'string', minLength: 20, maxLength: 512, title: 'GitHub token (read only)', 'x-group': 'GitHub', 'x-example': 'github_pat_…',++ description: 'A read-only GitHub token Codes uses for the repository search and a pull request\'s CI state. Without it GitHub allows about 60 requests an hour, so searches start failing on a busy day.',++ 'x-owner-input': {++ group: 'data-sources',++ unlocks: 'Raises GitHub\'s limit from 60 to 5,000 requests an hour for Codes\' repository search and pull-request checks. Optional: without it they still work on a quiet day.',++ get_it_at: 'https://github.com/settings/personal-access-tokens/new',++ get_it_how: 'Create a fine-grained token with Public repositories (read-only) access and no other permission.',++ test: true,++ },++ },++ },++};++++const publishingField = (title, description, unlocks, getItAt, getItHow, extra = {}) => ({++ type: 'string', minLength: 8, maxLength: 16384, title, 'x-group': extra.group || 'Release jobs', description,++ ...(extra.example ? { 'x-example': extra.example } : {}),++ 'x-owner-input': { group: 'design-publishing', unlocks, get_it_at: getItAt, get_it_how: getItHow, test: Boolean(extra.test) },++});++++const PUBLISHING_SCHEMA = {++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'Publishing and signing',++ description: 'The store and registry accounts a release job would publish OpenVibe\'s extensions, packages and apps with. No release job uses them yet: they are kept here, locked, so publishing can be switched on without asking you again.',++ type: 'object',++ additionalProperties: false,++ properties: {++ 'chrome.client_id': publishingField('Chrome Web Store: OAuth client ID',++ 'The client ID of a Google Cloud OAuth client that may use the Chrome Web Store API. With the secret and refresh token below a release job could upload a new extension version.',++ 'Will let a release job publish OpenVibe\'s browser extensions to the Chrome Web Store. Optional: extensions are uploaded by hand until a job uses it.',++ 'https://console.cloud.google.com/apis/credentials',++ 'In a project with the Chrome Web Store API on, create an OAuth client ID of type Desktop app and copy its Client ID.', { group: 'Chrome Web Store', example: '1234-abc.apps.googleusercontent.com' }),++ 'chrome.client_secret': publishingField('Chrome Web Store: OAuth client secret',++ 'The secret of the same Google OAuth client. It is only ever sent to Google, to turn the refresh token into a short-lived upload token, and is never shown again here.',++ 'Completes the Chrome Web Store access for a future release job. Optional: nothing uses it yet.',++ 'https://console.cloud.google.com/apis/credentials',++ 'Open the OAuth client and copy its Client secret.', { group: 'Chrome Web Store' }),++ 'chrome.refresh_token': publishingField('Chrome Web Store: refresh token',++ 'A long-lived Google refresh token for the account that owns the extensions, with the chromewebstore scope. It lets a release job upload without anyone signing in each time.',++ 'Completes the Chrome Web Store access for a future release job. Optional: nothing uses it yet.',++ 'https://developer.chrome.com/docs/webstore/using-api',++ 'Follow "Get the refresh token" with the client above, signed in as the account that owns the extensions.', { group: 'Chrome Web Store' }),++ 'firefox.jwt_issuer': publishingField('Firefox add-ons: JWT issuer',++ 'The JWT issuer (user:12345:67) of an addons.mozilla.org API key. With its secret a release job could sign and submit a new add-on version.',++ 'Will let a release job publish OpenVibe\'s Firefox add-ons to addons.mozilla.org. Optional: add-ons are uploaded by hand until a job uses it.',++ 'https://addons.mozilla.org/developers/addon/api/key/',++ 'Generate new credentials and copy the JWT issuer.', { group: 'Firefox add-ons', example: 'user:12345:67' }),++ 'firefox.jwt_secret': publishingField('Firefox add-ons: JWT secret',++ 'The JWT secret of the same addons.mozilla.org API key. It signs each upload request and is never sent anywhere; it is never shown again here.',++ 'Completes the Firefox add-ons access for a future release job. Optional: nothing uses it yet.',++ 'https://addons.mozilla.org/developers/addon/api/key/',++ 'Copy the JWT secret shown with the issuer (it is shown only once).', { group: 'Firefox add-ons' }),++ 'npm.token': publishingField('npm publish token',++ 'An npm granular access token that may publish OpenVibe\'s packages. A release job would publish a tagged library version with it instead of someone typing npm publish.',++ 'Will let a release job publish OpenVibe\'s libraries to npm. Optional: libraries are installed from GitHub tags today and nothing publishes to npm yet.',++ 'https://www.npmjs.com/settings/~/tokens/granular-access-tokens/new',++ 'Create a granular access token with Read and write on the OpenVibe packages only, and an expiry you will remember to renew.', { group: 'npm', example: 'npm_…', test: true }),++ 'signing.windows_certificate': publishingField('Windows code-signing certificate (.pfx, base64)',++ 'The Windows code-signing certificate and its private key, as a .pfx file in base64. A release job would sign the Windows downloads with it so Windows stops warning people about an unknown publisher.',++ 'Will let a release job sign OpenVibe\'s Windows apps, so SmartScreen trusts them. Optional: the downloads are unsigned (with SHA256SUMS) until a job uses it.',++ 'https://learn.microsoft.com/windows/win32/seccrypto/cryptography-tools',++ 'Buy an OV or EV code-signing certificate from a certificate authority, export it with its key as .pfx, and paste the file as base64.', { group: 'Code signing' }),++ 'signing.windows_password': publishingField('Windows code-signing certificate password',++ 'The password that opens the .pfx above. It is only used on the release machine to sign files and is never shown again here.',++ 'Completes the Windows code signing for a future release job. Optional: nothing uses it yet.',++ 'https://learn.microsoft.com/windows/win32/seccrypto/cryptography-tools',++ 'The password you chose when exporting the .pfx.', { group: 'Code signing' }),++ 'signing.apple_certificate': publishingField('Apple Developer ID certificate (.p12, base64)',++ 'The Developer ID Application certificate and its key, as a .p12 file in base64. A release job would sign and notarize the macOS downloads with it so macOS opens them without a warning.',++ 'Will let a release job sign OpenVibe\'s macOS apps with a Developer ID. Optional: the downloads are unsigned until a job uses it.',++ 'https://developer.apple.com/account/resources/certificates/list',++ 'With an Apple Developer membership, create a Developer ID Application certificate, export it with its key from Keychain as .p12, and paste it as base64.', { group: 'Code signing' }),++ 'signing.apple_password': publishingField('Apple Developer ID certificate password',++ 'The password that opens the .p12 above. It is only used on the release machine to sign files and is never shown again here.',++ 'Completes the macOS code signing for a future release job. Optional: nothing uses it yet.',++ 'https://developer.apple.com/account/resources/certificates/list',++ 'The password you chose when exporting the .p12 from Keychain.', { group: 'Code signing' }),++ },++};++++const ACCOUNTS_SECRETS = new Set(['forge.client_secret', 'github.client_secret', 'github.token']);++const classifyAccounts = (key) => (ACCOUNTS_SECRETS.has(key) ? 'secret' : 'internal');++const classifyPublishing = (key) => (key === 'chrome.client_id' || key === 'firefox.jwt_issuer' ? 'internal' : 'secret');++++/** The environment variable each key falls back to (only the keys Codes reads today have one). */++const ENV_FALLBACK = Object.freeze({++ 'forge.client_id': 'CODES_FORGE_CLIENT_ID',++ 'forge.client_secret': 'CODES_FORGE_CLIENT_SECRET',++ 'github.token': 'CODES_GITHUB_TOKEN',++});++++async function create({ db, env = process.env, log = console }) {++ const onChange = ({ namespace, keys, actor: who, revision }) => {++ (log.info || log.log).call(log, `[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${keys.join(', ')}`);++ };++ const accounts = await shared.createConfigStore({ db, service: SERVICE, namespace: ACCOUNTS, schema: ACCOUNTS_SCHEMA, env, classify: classifyAccounts, log, onChange });++ const publishing = await shared.createConfigStore({ db, service: SERVICE, namespace: PUBLISHING, schema: PUBLISHING_SCHEMA, env, classify: classifyPublishing, log, onChange });++ if (env.OV_CONFIG_KEKS) {++ for (const s of [accounts, publishing]) {++ try { await s.reseal(); } catch (err) { log.warn(`[config] ${s.namespace}: reseal failed: ${(err && err.message) || err}`); }++ }++ }++ const storeOf = (namespace) => (namespace === ACCOUNTS ? accounts : namespace === PUBLISHING ? publishing : null);++ /** A key's value at use time: { value, source: vault | env | null, env }. The panel's value wins. */++ function value(namespace, key) {++ const s = storeOf(namespace);++ const own = s ? s.get(key) : undefined;++ const name = namespace === ACCOUNTS ? ENV_FALLBACK[key] || null : null;++ if (typeof own === 'string' && own) return { value: own, source: 'vault', env: name };++ const fallback = name ? String(env[name] || '').trim() : '';++ return { value: fallback, source: fallback ? 'env' : null, env: name };++ }++ return { accounts, publishing, stores: [accounts, publishing], value };++}++++module.exports = { create, ACCOUNTS, PUBLISHING, ACCOUNTS_SCHEMA, PUBLISHING_SCHEMA, ENV_FALLBACK };nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * The Setup tab's Test button for Codes' owner inputs (the owner-setup design of 2026-11-07). Each test is one call++ * that changes nothing: GitHub's rate limit for the token, GitHub's and OpenVibe Git's token endpoint with a code++ * that cannot exist (the answer says whether the app's ID and secret were accepted), npm's whoami. The answer is one++ * line; the value never appears in it, in a log line or in an error.++ */++const TIMEOUT_MS = 10_000;++++async function call(fetchImpl, url, init = {}) {++ let res;++ try {++ res = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });++ } catch (err) {++ return { status: 0, json: null, error: err && err.name === 'TimeoutError' ? 'did not answer within 10 seconds' : 'could not be reached' };++ }++ const json = await res.json().catch(() => null);++ return { status: res.status, json, error: null };++}++++const said = (j) => (j && (j.error_description || j.message || (typeof j.error === 'string' ? j.error : ''))) || '';++++/** An OAuth app's ID and secret against its token endpoint, with a code that cannot exist. */++async function oauthApp(name, url, clientId, clientSecret, fetchImpl) {++ if (!clientId) return { ok: false, message: `The ${name} app's client ID is not set yet; enter it first, then test the secret.` };++ const body = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code: 'openvibe-setup-check', grant_type: 'authorization_code', redirect_uri: 'https://openvibe.codes/setup-check' });++ const r = await call(fetchImpl, url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() });++ if (r.error) return { ok: false, message: `${name} ${r.error}.` };++ const code = r.json && typeof r.json.error === 'string' ? r.json.error : '';++ // The code is wrong by design: an app whose ID and secret are right gets "bad code", one whose are wrong gets refused.++ if (code === 'bad_verification_code' || code === 'invalid_grant') return { ok: true, message: `${name} accepted the app's client ID and secret.` };++ if (code === 'incorrect_client_credentials' || code === 'invalid_client' || code === 'unauthorized_client') return { ok: false, message: `${name} refused the app's client ID or secret${said(r.json) ? `: ${said(r.json)}` : '.'}` };++ return { ok: false, message: `${name} answered ${r.status}${said(r.json) ? `: ${said(r.json)}` : ''}; the app could not be checked.` };++}++++function createOwnerInputTests({ config, ownerInputs, fetchImpl = globalThis.fetch }) {++ const github = String(process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');++ const tests = {++ 'codes.accounts': {++ 'github.token': async (token) => {++ const r = await call(fetchImpl, `${github}/rate_limit`, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'openvibe-codes', Authorization: `Bearer ${token}` } });++ if (r.status === 200) {++ const limit = r.json && r.json.resources && r.json.resources.core && r.json.resources.core.limit;++ return { ok: true, message: `GitHub accepted the token${limit ? `: Codes may make ${Number(limit).toLocaleString('en-US')} requests an hour` : ''}.` };++ }++ if (r.error) return { ok: false, message: `GitHub ${r.error}.` };++ return { ok: false, message: `GitHub ${r.status === 401 ? 'refused the token (401)' : `answered ${r.status}`}${said(r.json) ? `: ${said(r.json)}` : '.'}` };++ },++ 'github.client_secret': async (secret) => oauthApp('GitHub', 'https://github.com/login/oauth/access_token', ownerInputs.value('codes.accounts', 'github.client_id').value, secret, fetchImpl),++ 'forge.client_secret': async (secret) => {++ const base = String((config.git && config.git.internalUrl) || 'http://127.0.0.1:5060').replace(/\/+$/, '');++ return oauthApp('OpenVibe Git', `${base}/login/oauth/access_token`, ownerInputs.value('codes.accounts', 'forge.client_id').value, secret, fetchImpl);++ },++ },++ 'codes.publishing': {++ 'npm.token': async (token) => {++ const r = await call(fetchImpl, 'https://registry.npmjs.org/-/whoami', { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });++ if (r.status === 200 && r.json && r.json.username) return { ok: true, message: `npm accepted the token: it belongs to ${r.json.username}.` };++ if (r.error) return { ok: false, message: `npm ${r.error}.` };++ return { ok: false, message: `npm ${r.status === 401 || r.status === 403 ? `refused the token (${r.status})` : `answered ${r.status}`}${said(r.json) ? `: ${said(r.json)}` : '.'}` };++ },++ },++ };++ /** { value, source, env, run } for a key that has a test, else null. */++ return function find(namespace, key) {++ const t = tests[namespace] && tests[namespace][key];++ if (!t) return null;++ return { ...ownerInputs.value(namespace, key), run: t };++ };++}++++module.exports = { createOwnerInputTests, oauthApp };@@@ -41,7 +41,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.const testdb = await require('./db').testDb();-- const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}) });++ const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++/**++ * A local stand-in for openvibe-shared/config's lintSchema and its annotation checks (Shared 4.125.0, Shared PR #310,++ * not tagged when this was written): the owner-setup design's clarity rules, checked on a namespace's schema, so a++ * schema that passes here also passes when the pin moves to 4.125.0. Returns a list of problems ([] is clean).++ *++ * - every field has a title and a description of at least 30 characters;++ * - every enum option has words (x-enum-labels: { value: { label, description } });++ * - an x-owner-input names a Setup group, says what it unlocks, links an https page and says what to pick there;++ * - x-required-for, when present, names a feature.++ */++// openvibe-shared 4.125.0's OWNER_INPUT_GROUPS ids, and the members an x-owner-input may have.++const GROUPS = new Set(['money', 'ai-providers', 'gpus', 'voices', 'chat-apps', 'sign-in-apps', 'data-sources',++ 'design-publishing', 'domains', 'legal']);++const MEMBERS = new Set(['group', 'unlocks', 'get_it_at', 'get_it_how', 'test']);++++function lintField(name, f, out) {++ if (!f || typeof f !== 'object') return;++ if (typeof f.title !== 'string' || !f.title.trim()) out.push(`${name}: no title`);++ if (typeof f.description !== 'string' || f.description.trim().length < 30) out.push(`${name}: the description is missing or under 30 characters`);++ const options = Array.isArray(f.enum) ? f.enum : (f.items && Array.isArray(f.items.enum) ? f.items.enum : null);++ if (options) {++ const labels = f['x-enum-labels'] || (f.items && f.items['x-enum-labels']);++ for (const v of options) {++ const l = labels && labels[String(v)];++ if (!l || typeof l.label !== 'string' || !l.label.trim() || typeof l.description !== 'string' || !l.description.trim()) out.push(`${name}: the option ${JSON.stringify(v)} has no label and line`);++ }++ }++ const oi = f['x-owner-input'];++ if (oi !== undefined) {++ if (!oi || typeof oi !== 'object') out.push(`${name}: x-owner-input must be an object`);++ else {++ for (const m of Object.keys(oi)) if (!MEMBERS.has(m)) out.push(`${name}: x-owner-input has an unknown member ${m}`);++ if (!GROUPS.has(oi.group)) out.push(`${name}: x-owner-input.group ${JSON.stringify(oi.group)} is not a Setup group`);++ if (typeof oi.unlocks !== 'string' || oi.unlocks.trim().length < 20) out.push(`${name}: x-owner-input.unlocks must say what it turns on`);++ if (oi.get_it_at !== undefined && !/^https:\/\/[^\s]+$/.test(String(oi.get_it_at))) out.push(`${name}: x-owner-input.get_it_at must be an https URL`);++ if (typeof oi.get_it_how !== 'string' || oi.get_it_how.trim().length < 10) out.push(`${name}: x-owner-input.get_it_how must say what to pick there`);++ if (typeof oi.test !== 'boolean') out.push(`${name}: x-owner-input.test must be true or false`);++ }++ }++ if (f['x-example'] !== undefined && !['string', 'number', 'boolean'].includes(typeof f['x-example'])) out.push(`${name}: x-example must be a string, a number or true/false`);++ if (f['x-advanced'] !== undefined && typeof f['x-advanced'] !== 'boolean') out.push(`${name}: x-advanced must be true or false`);++ if (f['x-enum-labels'] && options) for (const v of Object.keys(f['x-enum-labels'])) if (!options.map((o) => (typeof o === 'string' ? o : JSON.stringify(o))).includes(v)) out.push(`${name}: x-enum-labels names ${v}, which is not an option`);++ if (f['x-required-for'] !== undefined && (typeof f['x-required-for'] !== 'string' || !f['x-required-for'].trim())) out.push(`${name}: x-required-for must name a feature`);++ if (f.properties && typeof f.properties === 'object') for (const [k, sub] of Object.entries(f.properties)) lintField(`${name}.${k}`, sub, out);++}++++function lintSchema(schema) {++ const out = [];++ if (!schema || typeof schema !== 'object') return ['no schema'];++ if (typeof schema.title !== 'string' || !schema.title.trim()) out.push('(namespace): no title');++ if (typeof schema.description !== 'string' || schema.description.trim().length < 30) out.push('(namespace): the description is missing or under 30 characters');++ for (const [k, f] of Object.entries(schema.properties || {})) lintField(k, f, out);++ return out;++}++++module.exports = { lintSchema, GROUPS };nnew file mode 100644@@@ -0,0 +1,168 @@++'use strict';++/**++ * Owner inputs (the owner-setup design of 2026-11-07): Codes' sign-in apps, the GitHub reader token and the publishing++ * tokens, in `codes.accounts` and `codes.publishing`, listed by the admin panel's Setup tab.++ *++ * Proves: the fields carry x-owner-input, titles and 30+ character descriptions, and every field of every Codes++ * namespace does; the keys Codes reads (the OpenVibe Git app, the GitHub token) come from the panel's sealed value when++ * set and from the environment otherwise, at use time; the publishing tokens, which no job reads yet, are Optional++ * (no x-required-for) and say what they unlock; the test hook answers in one line and never carries the value, not++ * even when the provider echoes it; no log line carries a value.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/boot');++const { lintSchema } = require('./helpers/lint-schema');++++const CAP = ['codes.config.manage'];++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const KEK = crypto.randomBytes(32).toString('base64');++const ENV_GH = 'github_pat_madeUpENVtokenFROMSETTINGSFILE0001';++const PANEL_GH = 'github_pat_madeUpPANELtokenNEVERSHOWN000002';++const APP_SECRET = 'madeUpGitHubAppSecretNEVERSHOWN0000000003';++const BAD_NPM = 'npm_madeUpBadTokenTHATNPMECHOESBACK0000004';++const FORGE_SECRET = 'madeUpForgeSecretNEVERSHOWN00000000000005';++const SECRETS = [ENV_GH, PANEL_GH, APP_SECRET, BAD_NPM, FORGE_SECRET];++++const calls = [];++async function fakeFetch(url, init = {}) {++ const u = String(url);++ const outside = /^https:\/\/(api\.github\.com|github\.com|registry\.npmjs\.org)\//.test(u) || /\/login\/oauth\/access_token$/.test(u);++ if (!outside) return globalThis.fetch(url, init);++ const auth = String((init.headers && (init.headers.Authorization || init.headers.authorization)) || '');++ calls.push({ url: u, auth, body: init.body ? String(init.body) : '' });++ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });++ if (u.endsWith('/rate_limit')) {++ if (auth === `Bearer ${ENV_GH}` || auth === `Bearer ${PANEL_GH}`) return json(200, { resources: { core: { limit: 5000, remaining: 4999 } } });++ return json(401, { message: 'Bad credentials' });++ }++ if (u.startsWith('https://api.github.com/search/repositories')) return json(200, { items: [] });++ if (u === 'https://github.com/login/oauth/access_token') {++ const form = new URLSearchParams(String(init.body || ''));++ return form.get('client_secret') === APP_SECRET && form.get('client_id') === 'Ov23liMadeUpClient'++ ? json(200, { error: 'bad_verification_code', error_description: 'The code passed is incorrect or expired.' })++ : json(200, { error: 'incorrect_client_credentials', error_description: 'The client_id and/or client_secret passed are incorrect.' });++ }++ if (u.endsWith('/login/oauth/access_token')) {++ const form = new URLSearchParams(String(init.body || ''));++ return form.get('client_secret') === FORGE_SECRET ? json(400, { error: 'invalid_grant', error_description: 'client is not authorized' }) : json(400, { error: 'unauthorized_client', error_description: 'invalid client secret' });++ }++ if (u === 'https://registry.npmjs.org/-/whoami') return json(401, { error: `token ${auth.replace(/^Bearer /, '')} is not valid` });++ return json(404, { message: 'not here' });++}++++(async () => {++ const consoleLines = [];++ const original = { log: console.log, warn: console.warn, error: console.error, info: console.info };++ for (const k of Object.keys(original)) console[k] = (...a) => { consoleLines.push(a.join(' ')); original[k](...a); };++ const t = await boot({ env: { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1', CODES_GITHUB_TOKEN: ENV_GH, CODES_SEAL_KEY: crypto.randomBytes(32).toString('base64') }, fetchImpl: fakeFetch });++ const admin = () => t.network.mintService('svc:network', 'openvibe.codes', CAP);++ const cfg = (method, p, json) => t.get(p, { method, bearer: admin(), headers: { 'x-ov-subject': OWNER, 'x-ov-fresh': '1' }, ...(json ? { json } : {}) });++ const test = (ns, key) => cfg('POST', `/internal/config/${ns}/test/${key}`);++ try {++ await check('every field of every Codes namespace has a title, a 30+ character description and labelled options', async () => {++ const r = await cfg('GET', '/internal/config');++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.deepStrictEqual(r.json().namespaces.map((n) => n.namespace), ['codes.ai', 'codes.accounts', 'codes.publishing']);++ for (const ns of r.json().namespaces) assert.deepStrictEqual(lintSchema(ns.schema), [], `${ns.namespace}: ${lintSchema(ns.schema).join('; ')}`);++ });++++ await check('the sign-in apps, the GitHub token and the publishing tokens are owner inputs in their groups', async () => {++ const a = (await cfg('GET', '/internal/config/codes.accounts')).json().schema.properties;++ assert.deepStrictEqual(['forge.client_id', 'forge.client_secret', 'github.client_id', 'github.client_secret'].map((k) => a[k]['x-owner-input'].group), ['sign-in-apps', 'sign-in-apps', 'sign-in-apps', 'sign-in-apps']);++ assert.strictEqual(a['forge.client_secret']['x-required-for'], 'Linking an OpenVibe Git account', 'Codes reads it: linking is off without it');++ assert.strictEqual(a['github.client_secret']['x-required-for'], undefined, 'nothing reads the GitHub app yet: Optional');++ assert.match(a['github.client_id']['x-owner-input'].unlocks, /Optional/);++ assert.deepStrictEqual([a['github.token']['x-owner-input'].group, a['github.token']['x-owner-input'].test], ['data-sources', true]);++ const p = (await cfg('GET', '/internal/config/codes.publishing')).json().schema.properties;++ assert.deepStrictEqual(Object.keys(p).sort(), ['chrome.client_id', 'chrome.client_secret', 'chrome.refresh_token', 'firefox.jwt_issuer', 'firefox.jwt_secret', 'npm.token', 'signing.apple_certificate', 'signing.apple_password', 'signing.windows_certificate', 'signing.windows_password']);++ for (const [k, f] of Object.entries(p)) {++ assert.strictEqual(f['x-owner-input'].group, 'design-publishing', k);++ assert.strictEqual(f['x-required-for'], undefined, `${k}: no release job reads it yet, so it is Optional`);++ assert.match(f['x-owner-input'].unlocks, /Optional/, k);++ }++ });++++ await check('without a panel token GitHub is read with the settings file\'s; the test says so', async () => {++ assert.strictEqual(t.config.github.token, ENV_GH);++ const r = await test('codes.accounts', 'github.token');++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.deepStrictEqual([r.json().ok, r.json().state, r.json().source], [true, 'connected', 'env']);++ assert.strictEqual(r.json().message, "GitHub accepted the token: Codes may make 5,000 requests an hour. (the value from the server's settings file, CODES_GITHUB_TOKEN)");++ });++++ await check('a panel token is sealed, write-only and wins at use time', async () => {++ const put = await cfg('PUT', '/internal/config/codes.accounts/secrets/github.token', { value: PANEL_GH, reason: 'setup' });++ assert.strictEqual(put.status, 200, put.text.slice(0, 300));++ assert.ok(!put.text.includes(PANEL_GH));++ assert.strictEqual(t.config.github.token, PANEL_GH);++ const before = calls.length;++ await t.ctx.github.search('usr_01JAB2C3D4E5F6G7H8J9K0MNPR', 'openvibe', { limit: 1 }).catch(() => null);++ const sent = calls.slice(before).filter((c) => c.url.startsWith('https://api.github.com/'));++ assert.ok(sent.length && sent.every((c) => c.auth === `Bearer ${PANEL_GH}`), 'the repository search used the panel token');++ const carried = await cfg('POST', '/internal/config/codes.accounts', { values: { 'github.token': PANEL_GH }, reason: 'not here' });++ assert.deepStrictEqual([carried.status, carried.json().code], [422, 'config.secret_write_only']);++ const row = await t.ctx.store.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'codes.accounts' AND state = 'active'").get();++ assert.ok(!row.values_json.includes(PANEL_GH), 'sealed at rest');++ });++++ await check('the OpenVibe Git app from the panel opens linking at use time; its test checks the app with the forge', async () => {++ assert.strictEqual(t.ctx.forge.enabled, false, 'no app yet: linking is closed');++ const id = await cfg('POST', '/internal/config/codes.accounts', { values: { 'forge.client_id': 'forge-client-1234' }, merge: true, reason: 'setup' });++ assert.strictEqual(id.status, 200, id.text.slice(0, 300));++ const put = await cfg('PUT', '/internal/config/codes.accounts/secrets/forge.client_secret', { value: FORGE_SECRET, reason: 'setup' });++ assert.strictEqual(put.status, 200, put.text.slice(0, 300));++ assert.strictEqual(t.ctx.forge.enabled, true, 'the panel values opened linking without a restart');++ assert.strictEqual(t.config.git.clientId, 'forge-client-1234');++ const r = await test('codes.accounts', 'forge.client_secret');++ assert.deepStrictEqual([r.json().ok, r.json().source, r.json().message], [true, 'vault', 'OpenVibe Git accepted the app\'s client ID and secret.']);++ });++++ await check('the GitHub app is tested without signing anyone in; a wrong secret is refused in one line', async () => {++ const none = await test('codes.accounts', 'github.client_secret');++ assert.deepStrictEqual([none.status, none.json().code], [409, 'config.not_set']);++ await cfg('POST', '/internal/config/codes.accounts', { values: { 'github.client_id': 'Ov23liMadeUpClient' }, merge: true, reason: 'setup' });++ await cfg('PUT', '/internal/config/codes.accounts/secrets/github.client_secret', { value: APP_SECRET, reason: 'setup' });++ const ok = await test('codes.accounts', 'github.client_secret');++ assert.deepStrictEqual([ok.json().ok, ok.json().message], [true, 'GitHub accepted the app\'s client ID and secret.']);++ await cfg('POST', '/internal/config/codes.accounts', { values: { 'github.client_id': 'Ov23liWrongClient' }, merge: true, reason: 'wrong id' });++ const bad = await test('codes.accounts', 'github.client_secret');++ assert.deepStrictEqual([bad.json().ok, bad.json().state], [false, 'not_working']);++ assert.match(bad.json().message, /^GitHub refused the app's client ID or secret: The client_id and\/or client_secret passed are incorrect\.$/);++ assert.ok(calls.filter((c) => c.url === 'https://github.com/login/oauth/access_token').every((c) => /code=openvibe-setup-check/.test(c.body)), 'a code that cannot exist: nobody is signed in');++ });++++ await check('a refused npm token is Not working in one line, and the echoed token never comes back', async () => {++ await cfg('PUT', '/internal/config/codes.publishing/secrets/npm.token', { value: BAD_NPM, reason: 'setup' });++ const r = await test('codes.publishing', 'npm.token');++ assert.deepStrictEqual([r.status, r.json().ok, r.json().state], [200, false, 'not_working']);++ assert.match(r.json().message, /^npm refused the token \(401\): token \[hidden\] is not valid$/);++ assert.ok(!r.text.includes(BAD_NPM) && !r.text.includes(BAD_NPM.slice(0, 12)));++ });++++ await check('removing the panel token falls back to the settings file', async () => {++ const del = await cfg('DELETE', '/internal/config/codes.accounts/secrets/github.token', { reason: 'back to env' });++ assert.strictEqual(del.status, 200, del.text.slice(0, 300));++ assert.strictEqual(t.config.github.token, ENV_GH);++ });++++ await check('the test hook is guarded and answers only for keys with a test', async () => {++ const wrong = t.network.mintService('svc:reader', 'openvibe.codes', ['codes.task.read']);++ const denied = await t.get('/internal/config/codes.accounts/test/github.token', { method: 'POST', bearer: wrong });++ assert.strictEqual(denied.status, 403);++ const none = await test('codes.publishing', 'signing.windows_password');++ assert.deepStrictEqual([none.status, none.json().code], [404, 'config.test_not_found']);++ });++++ await check('no log line or console line carries a value', async () => {++ for (const k of Object.keys(original)) console[k] = original[k];++ const logs = t.logs();++ for (const s of SECRETS) {++ assert.ok(!logs.includes(s), `a log line carries ${s.slice(0, 14)}…`);++ assert.ok(consoleLines.every((l) => !l.includes(s)), `a console line carries ${s.slice(0, 14)}…`);++ }++ assert.match(logs, /\[config\] test codes\.publishing npm\.token: not working/);++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { console.error(err); process.exit(1); });@@@ -69,6 +69,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex','server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person','server/report.js': 'OpenVibe.Report (OV_REPORT_INTERNAL_URL, configured) for the report of a finished task, and the Network token endpoint (OV_NETWORK_INTERNAL_URL, configured) for the Codes service token; the paths are fixed, no URL from a person',++ 'server/owner-inputs-tests.js': 'the admin panel\'s Test button for the owner\'s keys, owner-only (codes.config.manage): fixed origins only (api.github.com/rate_limit, github.com/login/oauth/access_token, registry.npmjs.org/-/whoami) and OpenVibe Git\'s token endpoint on the configured loopback (CODES_GIT_INTERNAL_URL); one read-only call each, never a URL from a person','server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',};const root = path.join(__dirname, '..');
@@ -176,7 +176,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -176,7 +176,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- 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, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4900,
"port": 4900,
"domain": "openvibe.codes",
"domain": "openvibe.codes",
- "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"
],
],
@@ -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.0"
"pg": "^8.23.0"
@@ -1149,9 +1149,9 @@
@@ -1149,9 +1149,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",
@@ -30,7 +30,7 @@
@@ -30,7 +30,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.0"
"pg": "^8.23.0"
@@ -7,6 +7,10 @@
@@ -7,6 +7,10 @@
* /internal/config is openvibe-shared/config's adminRoutes, guarded by codes.config.manage (Network's service
* /internal/config is openvibe-shared/config's adminRoutes, guarded by codes.config.manage (Network's service
* token only). actor is the owner Network names in X-OV-Subject; X-OV-Fresh: 1 gates a secret or x-danger write
* token only). actor is the owner Network names in X-OV-Subject; X-OV-Fresh: 1 gates a secret or x-danger write
* (this namespace has neither). Every activation writes one audit line with the actor and the keys, never a value.
* (this namespace has neither). Every activation writes one audit line with the actor and the keys, never a value.
+ *
+ * The same routes serve the stores passed as `also` (codes.accounts and codes.publishing, server/owner-inputs-config.js),
+ * and POST /internal/config/:namespace/test/:key, the admin panel's Setup tab Test button, answers for the keys a
+ * `test` hook knows (the owner-setup design of 2026-11-07); it is mounted before the config routes.
*/
*/
const express = require('express');
const express = require('express');
const shared = require('openvibe-shared/config');
const shared = require('openvibe-shared/config');
@@ -19,14 +23,14 @@ const CAPABILITY = 'codes.config.manage';
@@ -19,14 +23,14 @@ const CAPABILITY = 'codes.config.manage';
const SCHEMA = {
const SCHEMA = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$schema: 'https://json-schema.org/draft/2020-12/schema',
title: 'OpenVibe.AI in Codes',
title: 'OpenVibe.AI in Codes',
- description: 'Read OpenVibe.AI\'s model catalog so the pickers offer its aliases and exact ids.',
+ description: 'Whether Codes\' model pickers offer every model OpenVibe.AI can run. Change it when you want people to pick an exact model instead of the one default entry.',
type: 'object',
type: 'object',
additionalProperties: false,
additionalProperties: false,
required: ['catalog'],
required: ['catalog'],
properties: {
properties: {
catalog: {
catalog: {
- type: 'boolean', title: 'AI model catalog', 'x-group': 'AI Exchange', 'x-env': 'CODES_AI_CATALOG',
- description: 'Read GET /v1/models with Codes\' own service token and cache the answer about five minutes. Off, the pickers keep today\'s one entry.',
+ type: 'boolean', title: 'Offer every AI model in the pickers', 'x-group': 'AI Exchange', 'x-env': 'CODES_AI_CATALOG',
+ description: 'When on, Codes reads OpenVibe.AI\'s model list (refreshed about every five minutes) and its pickers offer every model by name. Off (the default) keeps the single default entry.',
},
},
},
},
};
};
@@ -66,7 +70,7 @@ function actor(req) {
@@ -66,7 +70,7 @@ function actor(req) {
const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';
const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';
-async function create({ db, keys, config, env = process.env, log = console, onActivate = null }) {
+async function create({ db, keys, config, env = process.env, log = console, onActivate = null, also = [], test = null }) {
// CODES_AI_CATALOG has always accepted 1/true/yes/on; the shared store's boolean pin reads true/1.
// CODES_AI_CATALOG has always accepted 1/true/yes/on; the shared store's boolean pin reads true/1.
const storeEnv = { ...env };
const storeEnv = { ...env };
if (['1', 'true', 'yes', 'on'].includes(String(storeEnv.CODES_AI_CATALOG || '').trim().toLowerCase())) storeEnv.CODES_AI_CATALOG = '1';
if (['1', 'true', 'yes', 'on'].includes(String(storeEnv.CODES_AI_CATALOG || '').trim().toLowerCase())) storeEnv.CODES_AI_CATALOG = '1';
@@ -80,14 +84,52 @@ async function create({ db, keys, config, env = process.env, log = console, onAc
@@ -80,14 +84,52 @@ async function create({ db, keys, config, env = process.env, log = console, onAc
if (env.OV_CONFIG_KEKS) {
if (env.OV_CONFIG_KEKS) {
try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
}
}
- const router = shared.adminRoutes([store], {
- basePath: '/internal/config', requireAdmin: capabilityGuard({ keys, config, log }), actor, requireFresh, log,
+ const guard = capabilityGuard({ keys, config, log });
+ const router = shared.adminRoutes([store, ...also], {
+ basePath: '/internal/config', requireAdmin: guard, actor, requireFresh, log,
});
});
return {
return {
store, router,
store, router,
get: (key) => store.get(key),
get: (key) => store.get(key),
- mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },
+ mount(app) {
+ app.use('/internal/config', express.json({ limit: '128kb' }));
+ if (test) app.post('/internal/config/:namespace/test/:key', guard, testRoute({ test, log }));
+ router.mount(app);
+ },
+ };
+}
+
+/** One line, at most 200 characters, with the secret replaced. */
+function oneLine(text, secret) {
+ let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
+ if (typeof secret === 'string' && secret.length >= 4) {
+ s = s.split(secret).join('[hidden]');
+ if (secret.length >= 12) s = s.split(secret.slice(0, 12)).join('[hidden]');
+ }
+ return s.length > 200 ? `${s.slice(0, 199)}…` : s;
+}
+
+/**
+ * The Setup tab's Test button: test(namespace, key) answers { value, source, env, run(value) } or null (no test).
+ * 200 { ok, message, state, source, namespace, key, tested_at } (openvibe-shared 4.125.0's { ok, message } with more
+ * beside it), 409 config.not_set when neither the panel nor the settings file has a value. Never the value itself.
+ */
+function testRoute({ test, log }) {
+ return async function configTest(req, res) {
+ const namespace = String(req.params.namespace || '');
+ const key = String(req.params.key || '');
+ let found = null;
+ try { found = await test(namespace, key); } catch { found = null; }
+ if (!found) return http.sendProblem(res, 404, 'config.test_not_found', { detail: `${namespace} has no test for ${key}`, ctx: req.ov });
+ if (!found.value) return http.sendProblem(res, 409, 'config.not_set', { detail: `${key} is not set, so there is nothing to test`, ctx: req.ov });
+ let out;
+ try { out = await found.run(found.value); } catch (err) { out = { ok: false, message: `The test could not run: ${(err && err.message) || 'unknown error'}` }; }
+ const ok = Boolean(out && out.ok);
+ const from = found.source === 'env' && found.env ? ` (the value from the server's settings file, ${found.env})` : '';
+ const message = oneLine(`${(out && out.message) || (ok ? 'It works.' : 'It did not work.')}${from}`, found.value);
+ (log.info || log.log).call(log, `[config] test ${namespace} ${key}: ${ok ? 'connected' : 'not working'}`);
+ return res.json({ ok, message, state: ok ? 'connected' : 'not_working', source: found.source || null, namespace, key, tested_at: new Date().toISOString() });
};
};
}
}
-module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };
+module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY, oneLine };
@@ -55,6 +55,8 @@ const { createCodesReadiness } = require('./observability');
@@ -55,6 +55,8 @@ const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
const { createForgejoClient } = require('./domain/forgejo');
const { createForgejoClient } = require('./domain/forgejo');
const createAiCatalogConfig = require('./ai-catalog-config').create;
const createAiCatalogConfig = require('./ai-catalog-config').create;
+const createOwnerInputs = require('./owner-inputs-config').create;
+const { createOwnerInputTests } = require('./owner-inputs-tests');
const { createMachines } = require('./domain/machines');
const { createMachines } = require('./domain/machines');
const { createMemory } = require('./domain/memory');
const { createMemory } = require('./domain/memory');
const { createKnowledge } = require('./domain/knowledge');
const { createKnowledge } = require('./domain/knowledge');
@@ -85,7 +87,19 @@ async function createApp(opts = {}) {
@@ -85,7 +87,19 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
const keys = createKeyStore({ config, fetchImpl: fetchImpl || globalThis.fetch, log });
// D11 owner settings (codes.ai): the catalog switch reads through the store, env pins winning.
// D11 owner settings (codes.ai): the catalog switch reads through the store, env pins winning.
- const aiCatalogConfig = await createAiCatalogConfig({ db: store.db, keys, config, env: opts.env || process.env, log });
+ // The owner's outside accounts (codes.accounts, codes.publishing; the owner-setup design): sealed in Codes' store,
+ // read at use time with the environment as the fallback. config.git's app and config.github's token become getters,
+ // so a value entered in the admin panel takes effect without a restart.
+ const ownerInputs = await createOwnerInputs({ db: store.db, env: opts.env || process.env, log });
+ const envGit = { clientId: config.git.clientId, clientSecret: config.git.clientSecret };
+ const envGithubToken = config.github ? config.github.token : '';
+ const fromPanel = (key, fallback) => { const v = ownerInputs.accounts.get(key); return typeof v === 'string' && v ? v : (fallback || ''); };
+ Object.defineProperty(config.git, 'clientId', { enumerable: true, configurable: true, get: () => fromPanel('forge.client_id', envGit.clientId) });
+ Object.defineProperty(config.git, 'clientSecret', { enumerable: true, configurable: true, get: () => fromPanel('forge.client_secret', envGit.clientSecret) });
+ config.github = config.github || {};
+ Object.defineProperty(config.github, 'token', { enumerable: true, configurable: true, get: () => fromPanel('github.token', envGithubToken) });
+ const findTest = createOwnerInputTests({ config, ownerInputs, fetchImpl: opts.fetchImpl || globalThis.fetch });
+ const aiCatalogConfig = await createAiCatalogConfig({ db: store.db, keys, config, env: opts.env || process.env, log, also: ownerInputs.stores, test: findTest });
Object.defineProperty(config.aiCatalog, 'enabled', { enumerable: true, configurable: true, get: () => aiCatalogConfig.get('catalog') });
Object.defineProperty(config.aiCatalog, 'enabled', { enumerable: true, configurable: true, get: () => aiCatalogConfig.get('catalog') });
const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });
const sso = createSso({ config, keys, fetchImpl: fetchImpl || globalThis.fetch, now: store.now, log });
// The harness catalog (server/data/harness-offers.json), read once: a bad seed row fails boot.
// The harness catalog (server/data/harness-offers.json), read once: a bad seed row fails boot.
@@ -59,7 +59,8 @@ function createForge({ config, store, log = console }) {
@@ -59,7 +59,8 @@ function createForge({ config, store, log = console }) {
const internalUrl = String(git.internalUrl || publicUrl).replace(/\/+$/, '');
const internalUrl = String(git.internalUrl || publicUrl).replace(/\/+$/, '');
const host = (() => { try { return new URL(publicUrl).host.toLowerCase(); } catch { return ''; } })();
const host = (() => { try { return new URL(publicUrl).host.toLowerCase(); } catch { return ''; } })();
const seal = createSeal(git.sealKey);
const seal = createSeal(git.sealKey);
- const enabled = !!(git.clientId && git.clientSecret && seal);
+ // At use time: the app's ID and secret may be entered in the admin panel after boot (codes.accounts forge.*).
+ const isEnabled = () => !!(git.clientId && git.clientSecret && seal);
const redirectUri = `${String(config.baseUrl || '').replace(/\/+$/, '')}/git/callback`;
const redirectUri = `${String(config.baseUrl || '').replace(/\/+$/, '')}/git/callback`;
const db = store.db;
const db = store.db;
const fail = (status, code, message) => Object.assign(new Error(message), { status, code });
const fail = (status, code, message) => Object.assign(new Error(message), { status, code });
@@ -89,7 +90,7 @@ function createForge({ config, store, log = console }) {
@@ -89,7 +90,7 @@ function createForge({ config, store, log = console }) {
}
}
async function connect(subject, { code, verifier }) {
async function connect(subject, { code, verifier }) {
- if (!enabled) throw fail(503, 'forge.off', 'Linking OpenVibe Git is not open yet');
+ if (!isEnabled()) throw fail(503, 'forge.off', 'Linking OpenVibe Git is not open yet');
if (!USER.test(String(subject || ''))) throw fail(400, 'subject.required', 'a person signs in first');
if (!USER.test(String(subject || ''))) throw fail(400, 'subject.required', 'a person signs in first');
const t = await token({ grant_type: 'authorization_code', code: String(code || ''), code_verifier: String(verifier || ''), redirect_uri: redirectUri });
const t = await token({ grant_type: 'authorization_code', code: String(code || ''), code_verifier: String(verifier || ''), redirect_uri: redirectUri });
const me = await call(internalUrl, 'GET', '/api/v1/user', { headers: { Authorization: `Bearer ${t.access_token}` } });
const me = await call(internalUrl, 'GET', '/api/v1/user', { headers: { Authorization: `Bearer ${t.access_token}` } });
@@ -107,7 +108,7 @@ function createForge({ config, store, log = console }) {
@@ -107,7 +108,7 @@ function createForge({ config, store, log = console }) {
/** A live access token for the person: refreshed when it ends within `margin` ms. null when unlinked or refused. */
/** A live access token for the person: refreshed when it ends within `margin` ms. null when unlinked or refused. */
async function tokenFor(subject, margin = 60_000) {
async function tokenFor(subject, margin = 60_000) {
- if (!enabled) return null;
+ if (!isEnabled()) return null;
const row = await db.maybe('SELECT forge_user, token_sealed, refresh_sealed, expires_at FROM codes_forge_links WHERE subject = $1', [subject]);
const row = await db.maybe('SELECT forge_user, token_sealed, refresh_sealed, expires_at FROM codes_forge_links WHERE subject = $1', [subject]);
if (!row) return null;
if (!row) return null;
const ends = row.expires_at ? new Date(row.expires_at).getTime() : Infinity;
const ends = row.expires_at ? new Date(row.expires_at).getTime() : Infinity;
@@ -161,7 +162,7 @@ function createForge({ config, store, log = console }) {
@@ -161,7 +162,7 @@ function createForge({ config, store, log = console }) {
return { OPENVIBE_GIT_CREDENTIALS: `https://${encodeURIComponent(t.login)}:${encodeURIComponent(t.token)}@${host}` };
return { OPENVIBE_GIT_CREDENTIALS: `https://${encodeURIComponent(t.login)}:${encodeURIComponent(t.token)}@${host}` };
}
}
- return { enabled, publicUrl, host, authorizeUrl, connect, link, unlink, createRepo, repos, jobEnv, tokenFor };
+ return { get enabled() { return isEnabled(); }, publicUrl, host, authorizeUrl, connect, link, unlink, createRepo, repos, jobEnv, tokenFor };
}
}
module.exports = { createForge, REPO_NAME };
module.exports = { createForge, REPO_NAME };
@@ -61,7 +61,8 @@ function ciWord(status) {
@@ -61,7 +61,8 @@ function ciWord(status) {
}
}
function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch, log = console, ship = null } = {}) {
function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch, log = console, ship = null } = {}) {
- const token = String((config.github && config.github.token) || '').trim();
+ // Read at use time: the owner's token from the admin panel (codes.accounts github.token), else CODES_GITHUB_TOKEN.
+ const tokenOf = () => String((config.github && config.github.token) || '').trim();
const cache = new Map();
const cache = new Map();
const searches = new Map();
const searches = new Map();
// The host's own deploy signal: the service's /release.json, which `ovhost status` reads too (Codes C9c).
// The host's own deploy signal: the service's /release.json, which `ovhost status` reads too (Codes C9c).
@@ -70,7 +71,7 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,
@@ -70,7 +71,7 @@ function createGitHub({ config = {}, store = null, fetchImpl = globalThis.fetch,
const headers = () => ({
const headers = () => ({
Accept: 'application/vnd.github+json',
Accept: 'application/vnd.github+json',
'User-Agent': 'openvibe-codes',
'User-Agent': 'openvibe-codes',
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ ...(tokenOf() ? { Authorization: `Bearer ${tokenOf()}` } : {}),
});
});
async function getJson(path) {
async function getJson(path) {
@@ -29,7 +29,8 @@ function serviceFor(manifests, repo) {
@@ -29,7 +29,8 @@ function serviceFor(manifests, repo) {
function createShip({ config = {}, fetchImpl = globalThis.fetch, manifests = null, log = console } = {}) {
function createShip({ config = {}, fetchImpl = globalThis.fetch, manifests = null, log = console } = {}) {
const list = manifests || require('openvibe-contracts').services.manifests;
const list = manifests || require('openvibe-contracts').services.manifests;
- const token = String((config.github && config.github.token) || '').trim();
+ // Read at use time: the owner's token from the admin panel (codes.accounts github.token), else CODES_GITHUB_TOKEN.
+ const tokenOf = () => String((config.github && config.github.token) || '').trim();
const base = String(process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');
const base = String(process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');
async function releaseOf(origin) {
async function releaseOf(origin) {
@@ -45,7 +46,7 @@ function createShip({ config = {}, fetchImpl = globalThis.fetch, manifests = nul
@@ -45,7 +46,7 @@ function createShip({ config = {}, fetchImpl = globalThis.fetch, manifests = nul
if (!SHA.test(String(release || ''))) return null;
if (!SHA.test(String(release || ''))) return null;
try {
try {
const res = await fetchImpl(`${base}/repos/${repo}/compare/${sha}...${release}`, {
const res = await fetchImpl(`${base}/repos/${repo}/compare/${sha}...${release}`, {
- headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'openvibe-codes', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'openvibe-codes', ...(tokenOf() ? { Authorization: `Bearer ${tokenOf()}` } : {}) },
signal: AbortSignal.timeout(6000),
signal: AbortSignal.timeout(6000),
});
});
if (!res.ok) return null;
if (!res.ok) return null;
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
+'use strict';
+/**
+ * Codes' owner inputs (the owner-setup design of 2026-11-07): the outside accounts and keys only the owner can give,
+ * as two namespaces on openvibe-shared/config that the admin panel's Setup tab lists.
+ *
+ * codes.accounts the sign-in apps and the GitHub reader:
+ * forge.client_id / forge.client_secret the OpenVibe Git (Forgejo) OAuth app that lets a
+ * person link their OpenVibe Git account (read by
+ * server/domain/forge.js; CODES_FORGE_CLIENT_* fallback)
+ * github.client_id / github.client_secret the GitHub OAuth app for a GitHub sign-in in Codes.
+ * Nothing reads it yet: Optional, waiting for that sign-in
+ * github.token a read-only GitHub token that raises GitHub's limits
+ * for the repository search and the CI state (read by
+ * server/domain/github.js and ship.js; CODES_GITHUB_TOKEN
+ * fallback)
+ * codes.publishing the store and registry tokens release jobs need (Chrome Web Store, Firefox add-ons, npm, code
+ * signing). No release job reads them yet, so every one is Optional and says what it unlocks.
+ *
+ * Secrets are write-only through /internal/config/:namespace/secrets/:key, sealed with the service's KEK and shown
+ * only as their last four characters. No x-env pin: the value entered in the panel wins, and the environment stays
+ * the fallback, read at use time through value() below.
+ */
+const shared = require('openvibe-shared/config');
+
+const SERVICE = 'codes';
+const ACCOUNTS = 'codes.accounts';
+const PUBLISHING = 'codes.publishing';
+
+const ACCOUNTS_SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Sign-in apps and GitHub',
+ description: 'The outside apps people sign in to from Codes, and the GitHub token Codes reads public repositories with. Change these when you register an app, rotate its secret or replace the token.',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ 'forge.client_id': {
+ type: 'string', minLength: 8, maxLength: 200, title: 'OpenVibe Git app: client ID', 'x-group': 'OpenVibe Git', 'x-example': '3f2a9c1e-…',
+ description: 'The client ID of the OAuth2 application registered on OpenVibe Git for Codes. With its secret and the seal key it lets people link their OpenVibe Git account; without it linking stays closed.',
+ 'x-owner-input': {
+ group: 'sign-in-apps',
+ unlocks: 'Lets people link their OpenVibe Git account in Codes, so tasks can make repositories and push as them. Without it the Link button says linking is not open yet.',
+ get_it_at: 'https://git.openvibe.codes/user/settings/applications',
+ get_it_how: 'Signed in as the owner, create an OAuth2 application named Codes with the redirect URI https://openvibe.codes/git/callback, and copy its Client ID.',
+ test: false,
+ },
+ 'x-required-for': 'Linking an OpenVibe Git account',
+ },
+ 'forge.client_secret': {
+ type: 'string', minLength: 8, maxLength: 512, title: 'OpenVibe Git app: client secret', 'x-group': 'OpenVibe Git',
+ description: 'The secret of the same OpenVibe Git application, shown once when it is created. Codes sends it only to OpenVibe Git, when a person links their account; it is never shown again here.',
+ 'x-owner-input': {
+ group: 'sign-in-apps',
+ unlocks: 'Completes the OpenVibe Git app: with the client ID it lets people link their OpenVibe Git account in Codes.',
+ get_it_at: 'https://git.openvibe.codes/user/settings/applications',
+ get_it_how: 'Copy the Client Secret shown when the application is created (or regenerate it there).',
+ test: false,
+ },
+ 'x-required-for': 'Linking an OpenVibe Git account',
+ },
+ 'github.client_id': {
+ type: 'string', minLength: 8, maxLength: 200, title: 'GitHub app: client ID', 'x-group': 'GitHub', 'x-example': 'Ov23li…',
+ description: 'The client ID of a GitHub OAuth app for signing in to Codes with GitHub. Codes does not offer that sign-in yet; the app waits here until it does, and nothing uses it before then.',
+ 'x-owner-input': {
+ group: 'sign-in-apps',
+ unlocks: 'Will let people connect their GitHub account in Codes, so tasks can read their private repositories and open pull requests as them. Optional: Codes has no GitHub sign-in yet.',
+ get_it_at: 'https://github.com/organizations/OpenVibers/settings/applications/new',
+ get_it_how: 'Register a new OAuth app for the OpenVibers organisation with the homepage https://openvibe.codes and the callback https://openvibe.codes/auth/github/callback.',
+ test: false,
+ },
+ },
+ 'github.client_secret': {
+ type: 'string', minLength: 8, maxLength: 512, title: 'GitHub app: client secret', 'x-group': 'GitHub',
+ description: 'The secret of the same GitHub OAuth app. The Test button checks the ID and secret with GitHub without signing anyone in; nothing else uses it until Codes has a GitHub sign-in.',
+ 'x-owner-input': {
+ group: 'sign-in-apps',
+ unlocks: 'Completes the GitHub app for a future GitHub sign-in in Codes. Optional: nothing uses it yet.',
+ get_it_at: 'https://github.com/organizations/OpenVibers/settings/applications',
+ get_it_how: 'Open the app, Generate a new client secret, and copy it at once (GitHub shows it only once).',
+ test: true,
+ },
+ },
+ 'github.token': {
+ type: 'string', minLength: 20, maxLength: 512, title: 'GitHub token (read only)', 'x-group': 'GitHub', 'x-example': 'github_pat_…',
+ description: 'A read-only GitHub token Codes uses for the repository search and a pull request\'s CI state. Without it GitHub allows about 60 requests an hour, so searches start failing on a busy day.',
+ 'x-owner-input': {
+ group: 'data-sources',
+ unlocks: 'Raises GitHub\'s limit from 60 to 5,000 requests an hour for Codes\' repository search and pull-request checks. Optional: without it they still work on a quiet day.',
+ get_it_at: 'https://github.com/settings/personal-access-tokens/new',
+ get_it_how: 'Create a fine-grained token with Public repositories (read-only) access and no other permission.',
+ test: true,
+ },
+ },
+ },
+};
+
+const publishingField = (title, description, unlocks, getItAt, getItHow, extra = {}) => ({
+ type: 'string', minLength: 8, maxLength: 16384, title, 'x-group': extra.group || 'Release jobs', description,
+ ...(extra.example ? { 'x-example': extra.example } : {}),
+ 'x-owner-input': { group: 'design-publishing', unlocks, get_it_at: getItAt, get_it_how: getItHow, test: Boolean(extra.test) },
+});
+
+const PUBLISHING_SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Publishing and signing',
+ description: 'The store and registry accounts a release job would publish OpenVibe\'s extensions, packages and apps with. No release job uses them yet: they are kept here, locked, so publishing can be switched on without asking you again.',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ 'chrome.client_id': publishingField('Chrome Web Store: OAuth client ID',
+ 'The client ID of a Google Cloud OAuth client that may use the Chrome Web Store API. With the secret and refresh token below a release job could upload a new extension version.',
+ 'Will let a release job publish OpenVibe\'s browser extensions to the Chrome Web Store. Optional: extensions are uploaded by hand until a job uses it.',
+ 'https://console.cloud.google.com/apis/credentials',
+ 'In a project with the Chrome Web Store API on, create an OAuth client ID of type Desktop app and copy its Client ID.', { group: 'Chrome Web Store', example: '1234-abc.apps.googleusercontent.com' }),
+ 'chrome.client_secret': publishingField('Chrome Web Store: OAuth client secret',
+ 'The secret of the same Google OAuth client. It is only ever sent to Google, to turn the refresh token into a short-lived upload token, and is never shown again here.',
+ 'Completes the Chrome Web Store access for a future release job. Optional: nothing uses it yet.',
+ 'https://console.cloud.google.com/apis/credentials',
+ 'Open the OAuth client and copy its Client secret.', { group: 'Chrome Web Store' }),
+ 'chrome.refresh_token': publishingField('Chrome Web Store: refresh token',
+ 'A long-lived Google refresh token for the account that owns the extensions, with the chromewebstore scope. It lets a release job upload without anyone signing in each time.',
+ 'Completes the Chrome Web Store access for a future release job. Optional: nothing uses it yet.',
+ 'https://developer.chrome.com/docs/webstore/using-api',
+ 'Follow "Get the refresh token" with the client above, signed in as the account that owns the extensions.', { group: 'Chrome Web Store' }),
+ 'firefox.jwt_issuer': publishingField('Firefox add-ons: JWT issuer',
+ 'The JWT issuer (user:12345:67) of an addons.mozilla.org API key. With its secret a release job could sign and submit a new add-on version.',
+ 'Will let a release job publish OpenVibe\'s Firefox add-ons to addons.mozilla.org. Optional: add-ons are uploaded by hand until a job uses it.',
+ 'https://addons.mozilla.org/developers/addon/api/key/',
+ 'Generate new credentials and copy the JWT issuer.', { group: 'Firefox add-ons', example: 'user:12345:67' }),
+ 'firefox.jwt_secret': publishingField('Firefox add-ons: JWT secret',
+ 'The JWT secret of the same addons.mozilla.org API key. It signs each upload request and is never sent anywhere; it is never shown again here.',
+ 'Completes the Firefox add-ons access for a future release job. Optional: nothing uses it yet.',
+ 'https://addons.mozilla.org/developers/addon/api/key/',
+ 'Copy the JWT secret shown with the issuer (it is shown only once).', { group: 'Firefox add-ons' }),
+ 'npm.token': publishingField('npm publish token',
+ 'An npm granular access token that may publish OpenVibe\'s packages. A release job would publish a tagged library version with it instead of someone typing npm publish.',
+ 'Will let a release job publish OpenVibe\'s libraries to npm. Optional: libraries are installed from GitHub tags today and nothing publishes to npm yet.',
+ 'https://www.npmjs.com/settings/~/tokens/granular-access-tokens/new',
+ 'Create a granular access token with Read and write on the OpenVibe packages only, and an expiry you will remember to renew.', { group: 'npm', example: 'npm_…', test: true }),
+ 'signing.windows_certificate': publishingField('Windows code-signing certificate (.pfx, base64)',
+ 'The Windows code-signing certificate and its private key, as a .pfx file in base64. A release job would sign the Windows downloads with it so Windows stops warning people about an unknown publisher.',
+ 'Will let a release job sign OpenVibe\'s Windows apps, so SmartScreen trusts them. Optional: the downloads are unsigned (with SHA256SUMS) until a job uses it.',
+ 'https://learn.microsoft.com/windows/win32/seccrypto/cryptography-tools',
+ 'Buy an OV or EV code-signing certificate from a certificate authority, export it with its key as .pfx, and paste the file as base64.', { group: 'Code signing' }),
+ 'signing.windows_password': publishingField('Windows code-signing certificate password',
+ 'The password that opens the .pfx above. It is only used on the release machine to sign files and is never shown again here.',
+ 'Completes the Windows code signing for a future release job. Optional: nothing uses it yet.',
+ 'https://learn.microsoft.com/windows/win32/seccrypto/cryptography-tools',
+ 'The password you chose when exporting the .pfx.', { group: 'Code signing' }),
+ 'signing.apple_certificate': publishingField('Apple Developer ID certificate (.p12, base64)',
+ 'The Developer ID Application certificate and its key, as a .p12 file in base64. A release job would sign and notarize the macOS downloads with it so macOS opens them without a warning.',
+ 'Will let a release job sign OpenVibe\'s macOS apps with a Developer ID. Optional: the downloads are unsigned until a job uses it.',
+ 'https://developer.apple.com/account/resources/certificates/list',
+ 'With an Apple Developer membership, create a Developer ID Application certificate, export it with its key from Keychain as .p12, and paste it as base64.', { group: 'Code signing' }),
+ 'signing.apple_password': publishingField('Apple Developer ID certificate password',
+ 'The password that opens the .p12 above. It is only used on the release machine to sign files and is never shown again here.',
+ 'Completes the macOS code signing for a future release job. Optional: nothing uses it yet.',
+ 'https://developer.apple.com/account/resources/certificates/list',
+ 'The password you chose when exporting the .p12 from Keychain.', { group: 'Code signing' }),
+ },
+};
+
+const ACCOUNTS_SECRETS = new Set(['forge.client_secret', 'github.client_secret', 'github.token']);
+const classifyAccounts = (key) => (ACCOUNTS_SECRETS.has(key) ? 'secret' : 'internal');
+const classifyPublishing = (key) => (key === 'chrome.client_id' || key === 'firefox.jwt_issuer' ? 'internal' : 'secret');
+
+/** The environment variable each key falls back to (only the keys Codes reads today have one). */
+const ENV_FALLBACK = Object.freeze({
+ 'forge.client_id': 'CODES_FORGE_CLIENT_ID',
+ 'forge.client_secret': 'CODES_FORGE_CLIENT_SECRET',
+ 'github.token': 'CODES_GITHUB_TOKEN',
+});
+
+async function create({ db, env = process.env, log = console }) {
+ const onChange = ({ namespace, keys, actor: who, revision }) => {
+ (log.info || log.log).call(log, `[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${keys.join(', ')}`);
+ };
+ const accounts = await shared.createConfigStore({ db, service: SERVICE, namespace: ACCOUNTS, schema: ACCOUNTS_SCHEMA, env, classify: classifyAccounts, log, onChange });
+ const publishing = await shared.createConfigStore({ db, service: SERVICE, namespace: PUBLISHING, schema: PUBLISHING_SCHEMA, env, classify: classifyPublishing, log, onChange });
+ if (env.OV_CONFIG_KEKS) {
+ for (const s of [accounts, publishing]) {
+ try { await s.reseal(); } catch (err) { log.warn(`[config] ${s.namespace}: reseal failed: ${(err && err.message) || err}`); }
+ }
+ }
+ const storeOf = (namespace) => (namespace === ACCOUNTS ? accounts : namespace === PUBLISHING ? publishing : null);
+ /** A key's value at use time: { value, source: vault | env | null, env }. The panel's value wins. */
+ function value(namespace, key) {
+ const s = storeOf(namespace);
+ const own = s ? s.get(key) : undefined;
+ const name = namespace === ACCOUNTS ? ENV_FALLBACK[key] || null : null;
+ if (typeof own === 'string' && own) return { value: own, source: 'vault', env: name };
+ const fallback = name ? String(env[name] || '').trim() : '';
+ return { value: fallback, source: fallback ? 'env' : null, env: name };
+ }
+ return { accounts, publishing, stores: [accounts, publishing], value };
+}
+
+module.exports = { create, ACCOUNTS, PUBLISHING, ACCOUNTS_SCHEMA, PUBLISHING_SCHEMA, ENV_FALLBACK };
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * The Setup tab's Test button for Codes' owner inputs (the owner-setup design of 2026-11-07). Each test is one call
+ * that changes nothing: GitHub's rate limit for the token, GitHub's and OpenVibe Git's token endpoint with a code
+ * that cannot exist (the answer says whether the app's ID and secret were accepted), npm's whoami. The answer is one
+ * line; the value never appears in it, in a log line or in an error.
+ */
+const TIMEOUT_MS = 10_000;
+
+async function call(fetchImpl, url, init = {}) {
+ let res;
+ try {
+ res = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
+ } catch (err) {
+ return { status: 0, json: null, error: err && err.name === 'TimeoutError' ? 'did not answer within 10 seconds' : 'could not be reached' };
+ }
+ const json = await res.json().catch(() => null);
+ return { status: res.status, json, error: null };
+}
+
+const said = (j) => (j && (j.error_description || j.message || (typeof j.error === 'string' ? j.error : ''))) || '';
+
+/** An OAuth app's ID and secret against its token endpoint, with a code that cannot exist. */
+async function oauthApp(name, url, clientId, clientSecret, fetchImpl) {
+ if (!clientId) return { ok: false, message: `The ${name} app's client ID is not set yet; enter it first, then test the secret.` };
+ const body = new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code: 'openvibe-setup-check', grant_type: 'authorization_code', redirect_uri: 'https://openvibe.codes/setup-check' });
+ const r = await call(fetchImpl, url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() });
+ if (r.error) return { ok: false, message: `${name} ${r.error}.` };
+ const code = r.json && typeof r.json.error === 'string' ? r.json.error : '';
+ // The code is wrong by design: an app whose ID and secret are right gets "bad code", one whose are wrong gets refused.
+ if (code === 'bad_verification_code' || code === 'invalid_grant') return { ok: true, message: `${name} accepted the app's client ID and secret.` };
+ if (code === 'incorrect_client_credentials' || code === 'invalid_client' || code === 'unauthorized_client') return { ok: false, message: `${name} refused the app's client ID or secret${said(r.json) ? `: ${said(r.json)}` : '.'}` };
+ return { ok: false, message: `${name} answered ${r.status}${said(r.json) ? `: ${said(r.json)}` : ''}; the app could not be checked.` };
+}
+
+function createOwnerInputTests({ config, ownerInputs, fetchImpl = globalThis.fetch }) {
+ const github = String(process.env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');
+ const tests = {
+ 'codes.accounts': {
+ 'github.token': async (token) => {
+ const r = await call(fetchImpl, `${github}/rate_limit`, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'openvibe-codes', Authorization: `Bearer ${token}` } });
+ if (r.status === 200) {
+ const limit = r.json && r.json.resources && r.json.resources.core && r.json.resources.core.limit;
+ return { ok: true, message: `GitHub accepted the token${limit ? `: Codes may make ${Number(limit).toLocaleString('en-US')} requests an hour` : ''}.` };
+ }
+ if (r.error) return { ok: false, message: `GitHub ${r.error}.` };
+ return { ok: false, message: `GitHub ${r.status === 401 ? 'refused the token (401)' : `answered ${r.status}`}${said(r.json) ? `: ${said(r.json)}` : '.'}` };
+ },
+ 'github.client_secret': async (secret) => oauthApp('GitHub', 'https://github.com/login/oauth/access_token', ownerInputs.value('codes.accounts', 'github.client_id').value, secret, fetchImpl),
+ 'forge.client_secret': async (secret) => {
+ const base = String((config.git && config.git.internalUrl) || 'http://127.0.0.1:5060').replace(/\/+$/, '');
+ return oauthApp('OpenVibe Git', `${base}/login/oauth/access_token`, ownerInputs.value('codes.accounts', 'forge.client_id').value, secret, fetchImpl);
+ },
+ },
+ 'codes.publishing': {
+ 'npm.token': async (token) => {
+ const r = await call(fetchImpl, 'https://registry.npmjs.org/-/whoami', { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
+ if (r.status === 200 && r.json && r.json.username) return { ok: true, message: `npm accepted the token: it belongs to ${r.json.username}.` };
+ if (r.error) return { ok: false, message: `npm ${r.error}.` };
+ return { ok: false, message: `npm ${r.status === 401 || r.status === 403 ? `refused the token (${r.status})` : `answered ${r.status}`}${said(r.json) ? `: ${said(r.json)}` : '.'}` };
+ },
+ },
+ };
+ /** { value, source, env, run } for a key that has a test, else null. */
+ return function find(namespace, key) {
+ const t = tests[namespace] && tests[namespace][key];
+ if (!t) return null;
+ return { ...ownerInputs.value(namespace, key), run: t };
+ };
+}
+
+module.exports = { createOwnerInputTests, oauthApp };
@@ -41,7 +41,7 @@ async function boot(opts = {}) {
@@ -41,7 +41,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
- const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}) });
+ const built = await createApp({ config, env, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.benchAttemptEnded ? { benchAttemptEnded: opts.benchAttemptEnded } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}), ...(opts.connections ? { connections: opts.connections } : {}), ...(opts.notify ? { notify: opts.notify } : {}), ...(opts.balance ? { balance: opts.balance } : {}), ...(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}) });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+/**
+ * A local stand-in for openvibe-shared/config's lintSchema and its annotation checks (Shared 4.125.0, Shared PR #310,
+ * not tagged when this was written): the owner-setup design's clarity rules, checked on a namespace's schema, so a
+ * schema that passes here also passes when the pin moves to 4.125.0. Returns a list of problems ([] is clean).
+ *
+ * - every field has a title and a description of at least 30 characters;
+ * - every enum option has words (x-enum-labels: { value: { label, description } });
+ * - an x-owner-input names a Setup group, says what it unlocks, links an https page and says what to pick there;
+ * - x-required-for, when present, names a feature.
+ */
+// openvibe-shared 4.125.0's OWNER_INPUT_GROUPS ids, and the members an x-owner-input may have.
+const GROUPS = new Set(['money', 'ai-providers', 'gpus', 'voices', 'chat-apps', 'sign-in-apps', 'data-sources',
+ 'design-publishing', 'domains', 'legal']);
+const MEMBERS = new Set(['group', 'unlocks', 'get_it_at', 'get_it_how', 'test']);
+
+function lintField(name, f, out) {
+ if (!f || typeof f !== 'object') return;
+ if (typeof f.title !== 'string' || !f.title.trim()) out.push(`${name}: no title`);
+ if (typeof f.description !== 'string' || f.description.trim().length < 30) out.push(`${name}: the description is missing or under 30 characters`);
+ const options = Array.isArray(f.enum) ? f.enum : (f.items && Array.isArray(f.items.enum) ? f.items.enum : null);
+ if (options) {
+ const labels = f['x-enum-labels'] || (f.items && f.items['x-enum-labels']);
+ for (const v of options) {
+ const l = labels && labels[String(v)];
+ if (!l || typeof l.label !== 'string' || !l.label.trim() || typeof l.description !== 'string' || !l.description.trim()) out.push(`${name}: the option ${JSON.stringify(v)} has no label and line`);
+ }
+ }
+ const oi = f['x-owner-input'];
+ if (oi !== undefined) {
+ if (!oi || typeof oi !== 'object') out.push(`${name}: x-owner-input must be an object`);
+ else {
+ for (const m of Object.keys(oi)) if (!MEMBERS.has(m)) out.push(`${name}: x-owner-input has an unknown member ${m}`);
+ if (!GROUPS.has(oi.group)) out.push(`${name}: x-owner-input.group ${JSON.stringify(oi.group)} is not a Setup group`);
+ if (typeof oi.unlocks !== 'string' || oi.unlocks.trim().length < 20) out.push(`${name}: x-owner-input.unlocks must say what it turns on`);
+ if (oi.get_it_at !== undefined && !/^https:\/\/[^\s]+$/.test(String(oi.get_it_at))) out.push(`${name}: x-owner-input.get_it_at must be an https URL`);
+ if (typeof oi.get_it_how !== 'string' || oi.get_it_how.trim().length < 10) out.push(`${name}: x-owner-input.get_it_how must say what to pick there`);
+ if (typeof oi.test !== 'boolean') out.push(`${name}: x-owner-input.test must be true or false`);
+ }
+ }
+ if (f['x-example'] !== undefined && !['string', 'number', 'boolean'].includes(typeof f['x-example'])) out.push(`${name}: x-example must be a string, a number or true/false`);
+ if (f['x-advanced'] !== undefined && typeof f['x-advanced'] !== 'boolean') out.push(`${name}: x-advanced must be true or false`);
+ if (f['x-enum-labels'] && options) for (const v of Object.keys(f['x-enum-labels'])) if (!options.map((o) => (typeof o === 'string' ? o : JSON.stringify(o))).includes(v)) out.push(`${name}: x-enum-labels names ${v}, which is not an option`);
+ if (f['x-required-for'] !== undefined && (typeof f['x-required-for'] !== 'string' || !f['x-required-for'].trim())) out.push(`${name}: x-required-for must name a feature`);
+ if (f.properties && typeof f.properties === 'object') for (const [k, sub] of Object.entries(f.properties)) lintField(`${name}.${k}`, sub, out);
+}
+
+function lintSchema(schema) {
+ const out = [];
+ if (!schema || typeof schema !== 'object') return ['no schema'];
+ if (typeof schema.title !== 'string' || !schema.title.trim()) out.push('(namespace): no title');
+ if (typeof schema.description !== 'string' || schema.description.trim().length < 30) out.push('(namespace): the description is missing or under 30 characters');
+ for (const [k, f] of Object.entries(schema.properties || {})) lintField(k, f, out);
+ return out;
+}
+
+module.exports = { lintSchema, GROUPS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
+'use strict';
+/**
+ * Owner inputs (the owner-setup design of 2026-11-07): Codes' sign-in apps, the GitHub reader token and the publishing
+ * tokens, in `codes.accounts` and `codes.publishing`, listed by the admin panel's Setup tab.
+ *
+ * Proves: the fields carry x-owner-input, titles and 30+ character descriptions, and every field of every Codes
+ * namespace does; the keys Codes reads (the OpenVibe Git app, the GitHub token) come from the panel's sealed value when
+ * set and from the environment otherwise, at use time; the publishing tokens, which no job reads yet, are Optional
+ * (no x-required-for) and say what they unlock; the test hook answers in one line and never carries the value, not
+ * even when the provider echoes it; no log line carries a value.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/boot');
+const { lintSchema } = require('./helpers/lint-schema');
+
+const CAP = ['codes.config.manage'];
+const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const KEK = crypto.randomBytes(32).toString('base64');
+const ENV_GH = 'github_pat_madeUpENVtokenFROMSETTINGSFILE0001';
+const PANEL_GH = 'github_pat_madeUpPANELtokenNEVERSHOWN000002';
+const APP_SECRET = 'madeUpGitHubAppSecretNEVERSHOWN0000000003';
+const BAD_NPM = 'npm_madeUpBadTokenTHATNPMECHOESBACK0000004';
+const FORGE_SECRET = 'madeUpForgeSecretNEVERSHOWN00000000000005';
+const SECRETS = [ENV_GH, PANEL_GH, APP_SECRET, BAD_NPM, FORGE_SECRET];
+
+const calls = [];
+async function fakeFetch(url, init = {}) {
+ const u = String(url);
+ const outside = /^https:\/\/(api\.github\.com|github\.com|registry\.npmjs\.org)\//.test(u) || /\/login\/oauth\/access_token$/.test(u);
+ if (!outside) return globalThis.fetch(url, init);
+ const auth = String((init.headers && (init.headers.Authorization || init.headers.authorization)) || '');
+ calls.push({ url: u, auth, body: init.body ? String(init.body) : '' });
+ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+ if (u.endsWith('/rate_limit')) {
+ if (auth === `Bearer ${ENV_GH}` || auth === `Bearer ${PANEL_GH}`) return json(200, { resources: { core: { limit: 5000, remaining: 4999 } } });
+ return json(401, { message: 'Bad credentials' });
+ }
+ if (u.startsWith('https://api.github.com/search/repositories')) return json(200, { items: [] });
+ if (u === 'https://github.com/login/oauth/access_token') {
+ const form = new URLSearchParams(String(init.body || ''));
+ return form.get('client_secret') === APP_SECRET && form.get('client_id') === 'Ov23liMadeUpClient'
+ ? json(200, { error: 'bad_verification_code', error_description: 'The code passed is incorrect or expired.' })
+ : json(200, { error: 'incorrect_client_credentials', error_description: 'The client_id and/or client_secret passed are incorrect.' });
+ }
+ if (u.endsWith('/login/oauth/access_token')) {
+ const form = new URLSearchParams(String(init.body || ''));
+ return form.get('client_secret') === FORGE_SECRET ? json(400, { error: 'invalid_grant', error_description: 'client is not authorized' }) : json(400, { error: 'unauthorized_client', error_description: 'invalid client secret' });
+ }
+ if (u === 'https://registry.npmjs.org/-/whoami') return json(401, { error: `token ${auth.replace(/^Bearer /, '')} is not valid` });
+ return json(404, { message: 'not here' });
+}
+
+(async () => {
+ const consoleLines = [];
+ const original = { log: console.log, warn: console.warn, error: console.error, info: console.info };
+ for (const k of Object.keys(original)) console[k] = (...a) => { consoleLines.push(a.join(' ')); original[k](...a); };
+ const t = await boot({ env: { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1', CODES_GITHUB_TOKEN: ENV_GH, CODES_SEAL_KEY: crypto.randomBytes(32).toString('base64') }, fetchImpl: fakeFetch });
+ const admin = () => t.network.mintService('svc:network', 'openvibe.codes', CAP);
+ const cfg = (method, p, json) => t.get(p, { method, bearer: admin(), headers: { 'x-ov-subject': OWNER, 'x-ov-fresh': '1' }, ...(json ? { json } : {}) });
+ const test = (ns, key) => cfg('POST', `/internal/config/${ns}/test/${key}`);
+ try {
+ await check('every field of every Codes namespace has a title, a 30+ character description and labelled options', async () => {
+ const r = await cfg('GET', '/internal/config');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.deepStrictEqual(r.json().namespaces.map((n) => n.namespace), ['codes.ai', 'codes.accounts', 'codes.publishing']);
+ for (const ns of r.json().namespaces) assert.deepStrictEqual(lintSchema(ns.schema), [], `${ns.namespace}: ${lintSchema(ns.schema).join('; ')}`);
+ });
+
+ await check('the sign-in apps, the GitHub token and the publishing tokens are owner inputs in their groups', async () => {
+ const a = (await cfg('GET', '/internal/config/codes.accounts')).json().schema.properties;
+ assert.deepStrictEqual(['forge.client_id', 'forge.client_secret', 'github.client_id', 'github.client_secret'].map((k) => a[k]['x-owner-input'].group), ['sign-in-apps', 'sign-in-apps', 'sign-in-apps', 'sign-in-apps']);
+ assert.strictEqual(a['forge.client_secret']['x-required-for'], 'Linking an OpenVibe Git account', 'Codes reads it: linking is off without it');
+ assert.strictEqual(a['github.client_secret']['x-required-for'], undefined, 'nothing reads the GitHub app yet: Optional');
+ assert.match(a['github.client_id']['x-owner-input'].unlocks, /Optional/);
+ assert.deepStrictEqual([a['github.token']['x-owner-input'].group, a['github.token']['x-owner-input'].test], ['data-sources', true]);
+ const p = (await cfg('GET', '/internal/config/codes.publishing')).json().schema.properties;
+ assert.deepStrictEqual(Object.keys(p).sort(), ['chrome.client_id', 'chrome.client_secret', 'chrome.refresh_token', 'firefox.jwt_issuer', 'firefox.jwt_secret', 'npm.token', 'signing.apple_certificate', 'signing.apple_password', 'signing.windows_certificate', 'signing.windows_password']);
+ for (const [k, f] of Object.entries(p)) {
+ assert.strictEqual(f['x-owner-input'].group, 'design-publishing', k);
+ assert.strictEqual(f['x-required-for'], undefined, `${k}: no release job reads it yet, so it is Optional`);
+ assert.match(f['x-owner-input'].unlocks, /Optional/, k);
+ }
+ });
+
+ await check('without a panel token GitHub is read with the settings file\'s; the test says so', async () => {
+ assert.strictEqual(t.config.github.token, ENV_GH);
+ const r = await test('codes.accounts', 'github.token');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.deepStrictEqual([r.json().ok, r.json().state, r.json().source], [true, 'connected', 'env']);
+ assert.strictEqual(r.json().message, "GitHub accepted the token: Codes may make 5,000 requests an hour. (the value from the server's settings file, CODES_GITHUB_TOKEN)");
+ });
+
+ await check('a panel token is sealed, write-only and wins at use time', async () => {
+ const put = await cfg('PUT', '/internal/config/codes.accounts/secrets/github.token', { value: PANEL_GH, reason: 'setup' });
+ assert.strictEqual(put.status, 200, put.text.slice(0, 300));
+ assert.ok(!put.text.includes(PANEL_GH));
+ assert.strictEqual(t.config.github.token, PANEL_GH);
+ const before = calls.length;
+ await t.ctx.github.search('usr_01JAB2C3D4E5F6G7H8J9K0MNPR', 'openvibe', { limit: 1 }).catch(() => null);
+ const sent = calls.slice(before).filter((c) => c.url.startsWith('https://api.github.com/'));
+ assert.ok(sent.length && sent.every((c) => c.auth === `Bearer ${PANEL_GH}`), 'the repository search used the panel token');
+ const carried = await cfg('POST', '/internal/config/codes.accounts', { values: { 'github.token': PANEL_GH }, reason: 'not here' });
+ assert.deepStrictEqual([carried.status, carried.json().code], [422, 'config.secret_write_only']);
+ const row = await t.ctx.store.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'codes.accounts' AND state = 'active'").get();
+ assert.ok(!row.values_json.includes(PANEL_GH), 'sealed at rest');
+ });
+
+ await check('the OpenVibe Git app from the panel opens linking at use time; its test checks the app with the forge', async () => {
+ assert.strictEqual(t.ctx.forge.enabled, false, 'no app yet: linking is closed');
+ const id = await cfg('POST', '/internal/config/codes.accounts', { values: { 'forge.client_id': 'forge-client-1234' }, merge: true, reason: 'setup' });
+ assert.strictEqual(id.status, 200, id.text.slice(0, 300));
+ const put = await cfg('PUT', '/internal/config/codes.accounts/secrets/forge.client_secret', { value: FORGE_SECRET, reason: 'setup' });
+ assert.strictEqual(put.status, 200, put.text.slice(0, 300));
+ assert.strictEqual(t.ctx.forge.enabled, true, 'the panel values opened linking without a restart');
+ assert.strictEqual(t.config.git.clientId, 'forge-client-1234');
+ const r = await test('codes.accounts', 'forge.client_secret');
+ assert.deepStrictEqual([r.json().ok, r.json().source, r.json().message], [true, 'vault', 'OpenVibe Git accepted the app\'s client ID and secret.']);
+ });
+
+ await check('the GitHub app is tested without signing anyone in; a wrong secret is refused in one line', async () => {
+ const none = await test('codes.accounts', 'github.client_secret');
+ assert.deepStrictEqual([none.status, none.json().code], [409, 'config.not_set']);
+ await cfg('POST', '/internal/config/codes.accounts', { values: { 'github.client_id': 'Ov23liMadeUpClient' }, merge: true, reason: 'setup' });
+ await cfg('PUT', '/internal/config/codes.accounts/secrets/github.client_secret', { value: APP_SECRET, reason: 'setup' });
+ const ok = await test('codes.accounts', 'github.client_secret');
+ assert.deepStrictEqual([ok.json().ok, ok.json().message], [true, 'GitHub accepted the app\'s client ID and secret.']);
+ await cfg('POST', '/internal/config/codes.accounts', { values: { 'github.client_id': 'Ov23liWrongClient' }, merge: true, reason: 'wrong id' });
+ const bad = await test('codes.accounts', 'github.client_secret');
+ assert.deepStrictEqual([bad.json().ok, bad.json().state], [false, 'not_working']);
+ assert.match(bad.json().message, /^GitHub refused the app's client ID or secret: The client_id and\/or client_secret passed are incorrect\.$/);
+ assert.ok(calls.filter((c) => c.url === 'https://github.com/login/oauth/access_token').every((c) => /code=openvibe-setup-check/.test(c.body)), 'a code that cannot exist: nobody is signed in');
+ });
+
+ await check('a refused npm token is Not working in one line, and the echoed token never comes back', async () => {
+ await cfg('PUT', '/internal/config/codes.publishing/secrets/npm.token', { value: BAD_NPM, reason: 'setup' });
+ const r = await test('codes.publishing', 'npm.token');
+ assert.deepStrictEqual([r.status, r.json().ok, r.json().state], [200, false, 'not_working']);
+ assert.match(r.json().message, /^npm refused the token \(401\): token \[hidden\] is not valid$/);
+ assert.ok(!r.text.includes(BAD_NPM) && !r.text.includes(BAD_NPM.slice(0, 12)));
+ });
+
+ await check('removing the panel token falls back to the settings file', async () => {
+ const del = await cfg('DELETE', '/internal/config/codes.accounts/secrets/github.token', { reason: 'back to env' });
+ assert.strictEqual(del.status, 200, del.text.slice(0, 300));
+ assert.strictEqual(t.config.github.token, ENV_GH);
+ });
+
+ await check('the test hook is guarded and answers only for keys with a test', async () => {
+ const wrong = t.network.mintService('svc:reader', 'openvibe.codes', ['codes.task.read']);
+ const denied = await t.get('/internal/config/codes.accounts/test/github.token', { method: 'POST', bearer: wrong });
+ assert.strictEqual(denied.status, 403);
+ const none = await test('codes.publishing', 'signing.windows_password');
+ assert.deepStrictEqual([none.status, none.json().code], [404, 'config.test_not_found']);
+ });
+
+ await check('no log line or console line carries a value', async () => {
+ for (const k of Object.keys(original)) console[k] = original[k];
+ const logs = t.logs();
+ for (const s of SECRETS) {
+ assert.ok(!logs.includes(s), `a log line carries ${s.slice(0, 14)}…`);
+ assert.ok(consoleLines.every((l) => !l.includes(s)), `a console line carries ${s.slice(0, 14)}…`);
+ }
+ assert.match(logs, /\[config\] test codes\.publishing npm\.token: not working/);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -69,6 +69,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -69,6 +69,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',
'server/domain/ship.js': 'the service\'s own GET <publicOrigin>/release.json and api.github.com\'s compare, both at fixed paths: the origin is the contracts\' service manifest, never a URL from a person; the shas are checked hex',
'server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person',
'server/events-consumer.js': 'OpenVibe.Events subscriptions (EVENTS_URL, configured) at boot; no URL from a person',
'server/report.js': 'OpenVibe.Report (OV_REPORT_INTERNAL_URL, configured) for the report of a finished task, and the Network token endpoint (OV_NETWORK_INTERNAL_URL, configured) for the Codes service token; the paths are fixed, no URL from a person',
'server/report.js': 'OpenVibe.Report (OV_REPORT_INTERNAL_URL, configured) for the report of a finished task, and the Network token endpoint (OV_NETWORK_INTERNAL_URL, configured) for the Codes service token; the paths are fixed, no URL from a person',
+ 'server/owner-inputs-tests.js': 'the admin panel\'s Test button for the owner\'s keys, owner-only (codes.config.manage): fixed origins only (api.github.com/rate_limit, github.com/login/oauth/access_token, registry.npmjs.org/-/whoami) and OpenVibe Git\'s token endpoint on the configured loopback (CODES_GIT_INTERNAL_URL); one read-only call each, never a URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
};
};
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');