openvibers/openvibe.domains Commits
Domains: the registrar keys are owner settings (domains.registrars) (#90)
Changes
@@@ -27,3 +27,9 @@ OV_OAUTH_CLIENT_SECRET=# OpenVibe.Events → POST /internal/events (account export and deletion); 32+ characters, comma-separated to rotateDOMAINS_EVENTS_SECRET=EVENTS_URL=http://127.0.0.1:4300++++# Registrar keys (D11 owner settings): the admin panel's domains.registrars namespace wins; these are++# the fallback when no value was entered there.++#CLOUDFLARE_API_TOKEN=++#PORKBUN_API_KEY=++#PORKBUN_SECRET_API_KEY=@@@ -145,7 +145,7 @@ gate.AGPL-3.0-only. See [LICENSE](LICENSE).<!-- versions:start -->--- openvibe-contracts: v0.289.1++- openvibe-contracts: v0.311.0- openvibe-sdk: v0.93.0- openvibe-shared: v4.124.0<!-- versions:end -->@@@ -8,11 +8,11 @@"runtime": "node 22, express 4","port": "5070","domain": "openvibe.domains",-- "contracts": "openvibe-contracts v0.289.1",++ "contracts": "openvibe-contracts v0.311.0","sdk": "openvibe-sdk v0.93.0","shared": "openvibe-shared v4.124.0","packages": [-- "openvibe-contracts v0.289.1",++ "openvibe-contracts v0.311.0","openvibe-sdk v0.93.0","openvibe-shared v4.124.0"],nnew file mode 100644@@@ -0,0 +1,30 @@++-- phase: expand++-- D11 owner settings (openvibe-shared/config): the configuration snapshots and the per-namespace++-- fingerprint key. The registrar keys Domains can use live in domains.registrars; the store seals++-- secret-class values under OV_CONFIG_KEKS and keeps only markers in snapshots. Every table here is++-- new, and an older release ignores all of it.++CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL,++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0,++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL,++ created_at text COLLATE "C" NOT NULL++);@@@ -14,7 +14,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.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"@@@ -1076,9 +1076,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",@@@ -20,7 +20,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.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"@@@ -37,6 +37,7 @@ const { openStore } = require('./db');const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');const { createPrincipal } = require('./http/principal');++const settingsLib = require('./settings');const { createVault } = require('./panel/vault');const { createCloudflare } = require('./panel/cloudflare');const { createPorkbun } = require('./panel/porkbun');@@@ -63,12 +64,25 @@ async function createApp(opts = {}) {const keys = createKeyStore({ config, fetchImpl, log });const sso = createSso({ config, keys, fetchImpl, now: store.now, log });const principal = createPrincipal({ config, keys });++ // D11 owner settings (domains.registrars): created before the app mounts /internal/config. A failure leaves++ // Domains serving (the registrar keys fall back to the environment) and says why in the log.++ let settings = null;++ if (opts.settings) settings = opts.settings;++ else {++ try {++ const made = settingsLib.createSettings({ db: store.db, env: opts.env || process.env, log });++ await made.ready;++ settings = settingsLib.activate(made);++ } catch (err) {++ log.warn('[OpenVibe.Domains] owner settings unavailable, the registrar keys fall back to the environment:', err && err.message);++ }++ }const vault = opts.vault || createVault({ config, fetchImpl });const providers = opts.providers || { cloudflare: createCloudflare({ fetchImpl }), porkbun: createPorkbun({ fetchImpl }) };const panel = createPanel({ store, vault, providers, log });const accountData = accountDataLib.create({ db: store.db, log });const accountSend = opts.accountSend || (config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }) : null);-- const ctx = { config, host, tools, log, release, store, keys, sso, principal, vault, panel, accountData };++ const ctx = { config, host, tools, log, release, store, keys, sso, principal, settings, vault, panel, accountData };const app = express();// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts@@@ -135,6 +149,29 @@ async function createApp(opts = {}) {// body itself, so it is mounted before any body parser.app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend }));++ // ── D11 owner settings (openvibe-shared/config): Network's admin proxy, acting for the owner, reads and++ // writes domains.registrars (the Cloudflare and Porkbun keys). Loopback only, guarded by++ // domains.config.manage; a secret change needs X-OV-Fresh: 1.++ if (settings) {++ const internalOnly = (req, res, next) => {++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {++ return res.status(404).json({ code: 'domains.not_found', error: 'no such route' });++ }++ return next();++ };++ const configCapability = (req, res, next) => {++ const p = req.principal;++ if (!p || p.kind !== 'service') return res.status(401).json({ code: 'token.required', error: 'a Network service token with domains.config.manage is required' });++ const contracts = require('openvibe-contracts');++ const decision = contracts.capabilities.check(p.claims, 'domains.config.manage');++ const granted = decision.allowed || contracts.capabilities.grants(p.claims.cap, 'domains.config.manage');++ if (!granted) return res.status(403).json({ code: 'capability.denied', error: 'domains.config.manage is not granted' });++ return next();++ };++ app.use('/internal/config', express.json({ limit: '64kb' }));++ settings.mount(app, { guard: [internalOnly, principal.middleware, configCapability], log });++ }++// ── Who is asking (the session cookie, verified offline) and sign-in ──app.use(sso.middleware());app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));nnew file mode 100644@@@ -0,0 +1,185 @@++'use strict';++++/**++ * Owner settings (D11) for OpenVibe.Domains: the registrar keys in `domains.registrars`.++ *++ * The registrars Domains works with are Cloudflare and Porkbun (server/panel/panel.js). A person's++ * own connection token is still read from their Network vault for that person's zones; these owner++ * keys are the service-wide fallback, read through registrarKeys() at the moment a registrar call is++ * made. The admin panel's value wins over CLOUDFLARE_API_TOKEN / PORKBUN_API_KEY /++ * PORKBUN_SECRET_API_KEY. Secrets are sealed by the shared store under OV_CONFIG_KEKS and are++ * write-only through /internal/config/domains.registrars/secrets/:key; summaries, history and logs++ * carry markers. Network's admin proxy reaches this namespace with `domains.config.manage`, acting++ * for the owner (X-OV-Subject); a secret change needs X-OV-Fresh: 1. The test route makes one cheap++ * authenticated read per registrar and answers ok or failed in one line, never echoing a key.++ */++const { createConfigStore, adminRoutes } = require('openvibe-shared/config');++++const SERVICE = 'domains';++const NAMESPACE = 'domains.registrars';++const BASE_PATH = '/internal/config';++const GROUP = 'Domains';++++const SCHEMA = Object.freeze({++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'Domain registrars',++ description: 'The registrar accounts Domains reads prices and changes DNS with. A registrar with no key is skipped; people can still connect their own account.',++ type: 'object',++ additionalProperties: false,++ properties: {++ cloudflare_api_token: {++ type: 'string', title: 'Cloudflare API token', 'x-group': GROUP,++ description: 'A Cloudflare API token that may list and edit the zones Domains manages. It lets the service read zones for people who connected Cloudflare; without it only a person\'s own connection works.',++ 'x-owner-input': {++ group: 'domains',++ unlocks: 'Cloudflare zones and DNS records can be read and changed with the service-wide account.',++ get_it_at: 'https://dash.cloudflare.com/profile/api-tokens',++ get_it_how: 'Create a token with Zone:Read and DNS:Edit, then paste it here.',++ test: true,++ },++ 'x-required-for': 'Cloudflare domains',++ },++ porkbun_api_key: {++ type: 'string', title: 'Porkbun API key', 'x-group': GROUP,++ description: 'The Porkbun API key from your account\'s API page. Together with its secret it lets Domains read and change Porkbun DNS; the key alone does nothing.',++ 'x-owner-input': {++ group: 'domains',++ unlocks: 'Porkbun domains and DNS records can be read and changed with the service-wide account.',++ get_it_at: 'https://porkbun.com/account/api',++ get_it_how: 'Enable the API, create a key pair, and paste the API key and its secret key here.',++ test: true,++ },++ 'x-required-for': 'Porkbun domains',++ },++ porkbun_secret_api_key: {++ type: 'string', title: 'Porkbun secret API key', 'x-group': GROUP,++ description: 'The secret half of the Porkbun API key pair. It is locked when saved and never shown again; make a new pair at Porkbun if it is lost.',++ 'x-owner-input': {++ group: 'domains',++ unlocks: 'Porkbun accepts Domains\' requests: the secret signs every call.',++ get_it_at: 'https://porkbun.com/account/api',++ get_it_how: 'It is shown next to the API key when the pair is made. Copy it then; Porkbun cannot show it again.',++ test: true,++ },++ 'x-required-for': 'Porkbun domains',++ },++ },++});++++const SECRETS = new Set(['cloudflare_api_token', 'porkbun_api_key', 'porkbun_secret_api_key']);++const ACTIVE = { settings: null };++++function createSettings({ db, env = process.env, log = console, fetchImpl = globalThis.fetch, probes = null } = {}) {++ let store = null;++ const ready = createConfigStore({++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA,++ classify: (key) => (SECRETS.has(key) ? 'secret' : 'internal'),++ env, log,++ }).then((s) => { store = s; return s; });++++ const values = () => (store ? store.get() : {});++++ function valueOf(key, envName, fallback = '') {++ const v = values()[key];++ if (v !== undefined && v !== null && v !== '') return v;++ const e = envName ? env[envName] : undefined;++ return e !== undefined && e !== null && e !== '' ? e : fallback;++ }++++ /** The registrar keys, read when a registrar call is made. */++ function registrarKeys() {++ return {++ cloudflare: { apiToken: valueOf('cloudflare_api_token', 'CLOUDFLARE_API_TOKEN', '') },++ porkbun: {++ apiKey: valueOf('porkbun_api_key', 'PORKBUN_API_KEY', ''),++ secretApiKey: valueOf('porkbun_secret_api_key', 'PORKBUN_SECRET_API_KEY', ''),++ },++ };++ }++++ async function probeCloudflare(apiToken) {++ const r = await fetchImpl('https://api.cloudflare.com/client/v4/user/tokens/verify', { headers: { accept: 'application/json', authorization: `Bearer ${apiToken}` } });++ let body = {};++ try { body = await r.json(); } catch { /* not JSON */ }++ if (!r.ok || !body.success) throw new Error((body.errors && body.errors[0] && body.errors[0].message) || `HTTP ${r.status}`);++ }++++ async function probePorkbun(apiKey, secretApiKey) {++ const r = await fetchImpl('https://api.porkbun.com/api/json/v3/ping', {++ method: 'POST', headers: { 'content-type': 'application/json' },++ body: JSON.stringify({ apikey: apiKey, secretapikey: secretApiKey }),++ });++ let body = {};++ try { body = await r.json(); } catch { /* not JSON */ }++ if (!r.ok || body.status !== 'SUCCESS') throw new Error(body && body.message ? body.message : `HTTP ${r.status}`);++ }++++ async function test(key) {++ if (!['cloudflare_api_token', 'porkbun_api_key', 'porkbun_secret_api_key'].includes(key)) return { ok: false, detail: 'That setting has no test.' };++ const keys = registrarKeys();++ if (key === 'cloudflare_api_token') {++ if (!keys.cloudflare.apiToken) return { ok: false, detail: 'Cloudflare is not configured: paste the API token first.' };++ const probe = probes && probes.cloudflare ? probes.cloudflare : ({ apiToken }) => probeCloudflare(apiToken);++ try {++ await probe({ apiToken: keys.cloudflare.apiToken });++ return { ok: true, detail: 'ok — Cloudflare verified the token.' };++ } catch (err) {++ const words = String((err && err.message) || 'the request failed').split(/\s+/).join(' ').slice(0, 160);++ return { ok: false, detail: `failed — Cloudflare said: ${words.split(keys.cloudflare.apiToken).join('[redacted]')}` };++ }++ }++ const { apiKey, secretApiKey } = keys.porkbun;++ if (!apiKey || !secretApiKey) return { ok: false, detail: 'Porkbun is not configured: both the API key and its secret key are needed.' };++ const probe = probes && probes.porkbun ? probes.porkbun : ({ apiKey: a, secretApiKey: s }) => probePorkbun(a, s);++ try {++ await probe({ apiKey, secretApiKey });++ return { ok: true, detail: 'ok — Porkbun answered the ping with this key pair.' };++ } catch (err) {++ let words = String((err && err.message) || 'the request failed');++ for (const secret of [apiKey, secretApiKey]) words = words.split(secret).join('[redacted]');++ return { ok: false, detail: `failed — Porkbun said: ${words.split(/\s+/).join(' ').slice(0, 160)}` };++ }++ }++++ function mount(app, { guard, log: appLog = log } = {}) {++ if (!store) throw new Error('createSettings: await ready before mount()');++ const routes = adminRoutes([store], {++ basePath: BASE_PATH,++ requireAdmin: guard,++ actor: (req) => {++ const id = String(req.get('x-ov-subject') || '').trim();++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? { type: 'user', id } : null;++ },++ requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',++ log: appLog,++ });++ app.use(routes.handle);++ app.post(`${BASE_PATH}/:namespace/test/:key`, guard, async (req, res) => {++ if (req.params.namespace !== NAMESPACE) return res.status(404).json({ error: 'no such configuration namespace' });++ let key = String(req.params.key || '');++ try { key = decodeURIComponent(key); } catch { /* keep it as it is */ }++ if (!SCHEMA.properties[key]) return res.status(404).json({ error: 'no such setting' });++ try {++ const out = await test(key);++ return res.status(200).json({ ok: Boolean(out.ok), detail: String(out.detail).replace(/\s+/g, ' ').slice(0, 200) });++ } catch (err) {++ appLog.warn(`[config] ${NAMESPACE}: the test for ${key} failed: ${String((err && err.message) || err).slice(0, 160)}`);++ return res.status(200).json({ ok: false, detail: 'failed — the test did not finish. The service log has the reason.' });++ }++ });++ return routes;++ }++++ return { namespace: NAMESPACE, schema: SCHEMA, ready, value: valueOf, get: values, registrarKeys, test, mount };++}++++function activate(settings) { ACTIVE.settings = settings; return settings; }++function registrarKeys() {++ if (ACTIVE.settings) return ACTIVE.settings.registrarKeys();++ return {++ cloudflare: { apiToken: process.env.CLOUDFLARE_API_TOKEN || '' },++ porkbun: { apiKey: process.env.PORKBUN_API_KEY || '', secretApiKey: process.env.PORKBUN_SECRET_API_KEY || '' },++ };++}++++module.exports = { SERVICE, NAMESPACE, SCHEMA, SECRETS, createSettings, activate, registrarKeys };@@@ -15,7 +15,7 @@ const { TABLES } = require('../server/identity/account-data');try {const missing = await uncovered(t.store.db, {tables: TABLES,-- exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Domains applied' },++ exempt: { "config_snapshots":"D11 owner settings: created_by/activated_by is the acting administrator (usr_...) for the config audit trail; the values are network configuration, not the person's account data, and are kept for rollback/history", account_data_events: 'the ledger of requests itself: which export and deletion Domains applied' },});assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);} finally { await t.close(); }nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++/**++ * Owner settings (D11) for Domains' registrar keys (server/settings.js, domains.registrars):++ * - Cloudflare's API token and Porkbun's key pair have plain titles, 30+ character descriptions,++ * x-owner-input words and a test each;++ * - /internal/config is loopback only and needs domains.config.manage;++ * - the secrets are write-only and marked in summaries, registrarKeys() reads the owner's values and++ * falls back to the environment, and the test hooks never echo or log a key.++ */++const assert = require('assert');++const http = require('http');++const express = require('express');++const { boot, done } = require('./helpers');++const { createSettings } = require('../server/settings');++++const CAP = 'domains.config.manage';++const NS = 'domains.registrars';++const USR = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';++const CF = 'cloudflare-owner-token-sentinel';++const PB = 'porkbun-owner-key-sentinel';++const PBS = 'porkbun-owner-secret-sentinel';++++(async () => {++ const t = await boot();++ const bearer = t.network.serviceToken([CAP]);++ const cases = [];++ const check = async (name, fn) => {++ try { await fn(); cases.push(name); console.log(` ✓ ${name}`); } catch (e) { console.log(` ✗ ${name}\n${e.stack}`); process.exit(1); }++ };++++ await check('the registrar keys have owner words and tests', async () => {++ const r = await t.get(`/internal/config/${NS}`, { bearer });++ assert.strictEqual(r.status, 200, r.text);++ const props = r.json().schema.properties;++ for (const key of ['cloudflare_api_token', 'porkbun_api_key', 'porkbun_secret_api_key']) {++ const f = props[key];++ assert.ok(f, `${key} exists`);++ assert.ok(f.title && f.title.length >= 3);++ assert.ok(f.description && f.description.length >= 30);++ assert.strictEqual(f['x-owner-input'].group, 'domains');++ assert.ok(f['x-owner-input'].unlocks.length > 20);++ assert.ok(/^https:\/\//.test(f['x-owner-input'].get_it_at));++ assert.strictEqual(f['x-owner-input'].test, true);++ assert.ok(f['x-required-for'].endsWith('domains'), `${key} names the feature`);++ }++ });++++ await check('the routes are loopback only and need the capability', async () => {++ assert.strictEqual((await t.get(`/internal/config/${NS}`)).status, 401);++ assert.strictEqual((await t.get(`/internal/config/${NS}`, { bearer: t.network.serviceToken(['domains.actions.read']) })).status, 403);++ assert.strictEqual((await t.get(`/internal/config/${NS}`, { bearer, headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 404);++ });++++ await check('the keys are write-only and the environment is the fallback', async () => {++ const envOnly = createSettings({ db: t.store.db, env: { CLOUDFLARE_API_TOKEN: 'cf-env', PORKBUN_API_KEY: 'pb-env', PORKBUN_SECRET_API_KEY: 'pbs-env' } });++ await envOnly.ready;++ assert.deepStrictEqual(envOnly.registrarKeys(), { cloudflare: { apiToken: 'cf-env' }, porkbun: { apiKey: 'pb-env', secretApiKey: 'pbs-env' } });++ for (const [key, value] of [['cloudflare_api_token', CF], ['porkbun_api_key', PB], ['porkbun_secret_api_key', PBS]]) {++ const put = await t.get(`/internal/config/${NS}/secrets/${key}`, {++ method: 'PUT', json: { value, reason: 'owner test' }, bearer, headers: { 'x-ov-subject': USR, 'x-ov-fresh': '1' },++ });++ assert.strictEqual(put.status, 200, put.text);++ assert.ok(put.json().fingerprint, `${key} returns a marker`);++ assert.ok(!put.text.includes(value));++ }++ const summary = await t.get(`/internal/config/${NS}`, { bearer });++ assert.ok(summary.json().values.porkbun_secret_api_key.redacted);++ assert.ok(!summary.text.includes(PBS));++ assert.deepStrictEqual(t.ctx.settings.registrarKeys(), { cloudflare: { apiToken: CF }, porkbun: { apiKey: PB, secretApiKey: PBS } });++ });++++ await check('the test hooks say ok/failed in one line and never echo a key', async () => {++ const logs = [];++ const log = { log: (...a) => logs.push(a.join(' ')), info: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };++ const fake = createSettings({++ db: t.store.db, env: {}, log,++ probes: {++ cloudflare: async ({ apiToken }) => { throw new Error(`refused ${apiToken}`); },++ porkbun: async ({ apiKey, secretApiKey }) => { throw new Error(`refused ${apiKey} / ${secretApiKey}`); },++ },++ });++ await fake.ready;++ const app = express();++ app.use(express.json());++ fake.mount(app, { guard: (_req, _res, next) => next(), log });++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ try {++ for (const key of ['cloudflare_api_token', 'porkbun_api_key']) {++ const r = await fetch(`http://127.0.0.1:${server.address().port}/internal/config/${NS}/test/${key}`, { method: 'POST' });++ const body = await r.json();++ assert.strictEqual(r.status, 200);++ assert.strictEqual(body.ok, false);++ assert.ok(/^failed — /.test(body.detail), body.detail);++ assert.ok(!body.detail.includes(CF) && !body.detail.includes(PB) && !body.detail.includes(PBS), 'the answer is redacted');++ assert.ok(!body.detail.includes('\n'), 'one line');++ }++ } finally { server.close(); }++ assert.ok(!logs.join('\n').includes(CF) && !logs.join('\n').includes(PB) && !logs.join('\n').includes(PBS), 'no key in a captured log line');++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -27,3 +27,9 @@ OV_OAUTH_CLIENT_SECRET=
@@ -27,3 +27,9 @@ OV_OAUTH_CLIENT_SECRET=
# OpenVibe.Events → POST /internal/events (account export and deletion); 32+ characters, comma-separated to rotate
# OpenVibe.Events → POST /internal/events (account export and deletion); 32+ characters, comma-separated to rotate
DOMAINS_EVENTS_SECRET=
DOMAINS_EVENTS_SECRET=
EVENTS_URL=http://127.0.0.1:4300
EVENTS_URL=http://127.0.0.1:4300
+
+# Registrar keys (D11 owner settings): the admin panel's domains.registrars namespace wins; these are
+# the fallback when no value was entered there.
+#CLOUDFLARE_API_TOKEN=
+#PORKBUN_API_KEY=
+#PORKBUN_SECRET_API_KEY=
@@ -145,7 +145,7 @@ gate.
@@ -145,7 +145,7 @@ gate.
AGPL-3.0-only. See [LICENSE](LICENSE).
AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.289.1
+- openvibe-contracts: v0.311.0
- openvibe-sdk: v0.93.0
- openvibe-sdk: v0.93.0
- openvibe-shared: v4.124.0
- openvibe-shared: v4.124.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,11 +8,11 @@
@@ -8,11 +8,11 @@
"runtime": "node 22, express 4",
"runtime": "node 22, express 4",
"port": "5070",
"port": "5070",
"domain": "openvibe.domains",
"domain": "openvibe.domains",
- "contracts": "openvibe-contracts v0.289.1",
+ "contracts": "openvibe-contracts v0.311.0",
"sdk": "openvibe-sdk v0.93.0",
"sdk": "openvibe-sdk v0.93.0",
"shared": "openvibe-shared v4.124.0",
"shared": "openvibe-shared v4.124.0",
"packages": [
"packages": [
- "openvibe-contracts v0.289.1",
+ "openvibe-contracts v0.311.0",
"openvibe-sdk v0.93.0",
"openvibe-sdk v0.93.0",
"openvibe-shared v4.124.0"
"openvibe-shared v4.124.0"
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+-- phase: expand
+-- D11 owner settings (openvibe-shared/config): the configuration snapshots and the per-namespace
+-- fingerprint key. The registrar keys Domains can use live in domains.registrars; the store seals
+-- secret-class values under OV_CONFIG_KEKS and keeps only markers in snapshots. Every table here is
+-- new, and an older release ignores all of it.
+CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL,
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0,
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL,
+ created_at text COLLATE "C" NOT NULL
+);
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.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"
@@ -1076,9 +1076,9 @@
@@ -1076,9 +1076,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",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.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"
@@ -37,6 +37,7 @@ const { openStore } = require('./db');
@@ -37,6 +37,7 @@ const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
+const settingsLib = require('./settings');
const { createVault } = require('./panel/vault');
const { createVault } = require('./panel/vault');
const { createCloudflare } = require('./panel/cloudflare');
const { createCloudflare } = require('./panel/cloudflare');
const { createPorkbun } = require('./panel/porkbun');
const { createPorkbun } = require('./panel/porkbun');
@@ -63,12 +64,25 @@ async function createApp(opts = {}) {
@@ -63,12 +64,25 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: store.now, log });
const sso = createSso({ config, keys, fetchImpl, now: store.now, log });
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
+ // D11 owner settings (domains.registrars): created before the app mounts /internal/config. A failure leaves
+ // Domains serving (the registrar keys fall back to the environment) and says why in the log.
+ let settings = null;
+ if (opts.settings) settings = opts.settings;
+ else {
+ try {
+ const made = settingsLib.createSettings({ db: store.db, env: opts.env || process.env, log });
+ await made.ready;
+ settings = settingsLib.activate(made);
+ } catch (err) {
+ log.warn('[OpenVibe.Domains] owner settings unavailable, the registrar keys fall back to the environment:', err && err.message);
+ }
+ }
const vault = opts.vault || createVault({ config, fetchImpl });
const vault = opts.vault || createVault({ config, fetchImpl });
const providers = opts.providers || { cloudflare: createCloudflare({ fetchImpl }), porkbun: createPorkbun({ fetchImpl }) };
const providers = opts.providers || { cloudflare: createCloudflare({ fetchImpl }), porkbun: createPorkbun({ fetchImpl }) };
const panel = createPanel({ store, vault, providers, log });
const panel = createPanel({ store, vault, providers, log });
const accountData = accountDataLib.create({ db: store.db, log });
const accountData = accountDataLib.create({ db: store.db, log });
const accountSend = opts.accountSend || (config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }) : null);
const accountSend = opts.accountSend || (config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }) : null);
- const ctx = { config, host, tools, log, release, store, keys, sso, principal, vault, panel, accountData };
+ const ctx = { config, host, tools, log, release, store, keys, sso, principal, settings, vault, panel, accountData };
const app = express();
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@ -135,6 +149,29 @@ async function createApp(opts = {}) {
@@ -135,6 +149,29 @@ async function createApp(opts = {}) {
// body itself, so it is mounted before any body parser.
// body itself, so it is mounted before any body parser.
app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend }));
app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend }));
+ // ── D11 owner settings (openvibe-shared/config): Network's admin proxy, acting for the owner, reads and
+ // writes domains.registrars (the Cloudflare and Porkbun keys). Loopback only, guarded by
+ // domains.config.manage; a secret change needs X-OV-Fresh: 1.
+ if (settings) {
+ const internalOnly = (req, res, next) => {
+ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {
+ return res.status(404).json({ code: 'domains.not_found', error: 'no such route' });
+ }
+ return next();
+ };
+ const configCapability = (req, res, next) => {
+ const p = req.principal;
+ if (!p || p.kind !== 'service') return res.status(401).json({ code: 'token.required', error: 'a Network service token with domains.config.manage is required' });
+ const contracts = require('openvibe-contracts');
+ const decision = contracts.capabilities.check(p.claims, 'domains.config.manage');
+ const granted = decision.allowed || contracts.capabilities.grants(p.claims.cap, 'domains.config.manage');
+ if (!granted) return res.status(403).json({ code: 'capability.denied', error: 'domains.config.manage is not granted' });
+ return next();
+ };
+ app.use('/internal/config', express.json({ limit: '64kb' }));
+ settings.mount(app, { guard: [internalOnly, principal.middleware, configCapability], log });
+ }
+
// ── Who is asking (the session cookie, verified offline) and sign-in ──
// ── Who is asking (the session cookie, verified offline) and sign-in ──
app.use(sso.middleware());
app.use(sso.middleware());
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
+'use strict';
+
+/**
+ * Owner settings (D11) for OpenVibe.Domains: the registrar keys in `domains.registrars`.
+ *
+ * The registrars Domains works with are Cloudflare and Porkbun (server/panel/panel.js). A person's
+ * own connection token is still read from their Network vault for that person's zones; these owner
+ * keys are the service-wide fallback, read through registrarKeys() at the moment a registrar call is
+ * made. The admin panel's value wins over CLOUDFLARE_API_TOKEN / PORKBUN_API_KEY /
+ * PORKBUN_SECRET_API_KEY. Secrets are sealed by the shared store under OV_CONFIG_KEKS and are
+ * write-only through /internal/config/domains.registrars/secrets/:key; summaries, history and logs
+ * carry markers. Network's admin proxy reaches this namespace with `domains.config.manage`, acting
+ * for the owner (X-OV-Subject); a secret change needs X-OV-Fresh: 1. The test route makes one cheap
+ * authenticated read per registrar and answers ok or failed in one line, never echoing a key.
+ */
+const { createConfigStore, adminRoutes } = require('openvibe-shared/config');
+
+const SERVICE = 'domains';
+const NAMESPACE = 'domains.registrars';
+const BASE_PATH = '/internal/config';
+const GROUP = 'Domains';
+
+const SCHEMA = Object.freeze({
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Domain registrars',
+ description: 'The registrar accounts Domains reads prices and changes DNS with. A registrar with no key is skipped; people can still connect their own account.',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ cloudflare_api_token: {
+ type: 'string', title: 'Cloudflare API token', 'x-group': GROUP,
+ description: 'A Cloudflare API token that may list and edit the zones Domains manages. It lets the service read zones for people who connected Cloudflare; without it only a person\'s own connection works.',
+ 'x-owner-input': {
+ group: 'domains',
+ unlocks: 'Cloudflare zones and DNS records can be read and changed with the service-wide account.',
+ get_it_at: 'https://dash.cloudflare.com/profile/api-tokens',
+ get_it_how: 'Create a token with Zone:Read and DNS:Edit, then paste it here.',
+ test: true,
+ },
+ 'x-required-for': 'Cloudflare domains',
+ },
+ porkbun_api_key: {
+ type: 'string', title: 'Porkbun API key', 'x-group': GROUP,
+ description: 'The Porkbun API key from your account\'s API page. Together with its secret it lets Domains read and change Porkbun DNS; the key alone does nothing.',
+ 'x-owner-input': {
+ group: 'domains',
+ unlocks: 'Porkbun domains and DNS records can be read and changed with the service-wide account.',
+ get_it_at: 'https://porkbun.com/account/api',
+ get_it_how: 'Enable the API, create a key pair, and paste the API key and its secret key here.',
+ test: true,
+ },
+ 'x-required-for': 'Porkbun domains',
+ },
+ porkbun_secret_api_key: {
+ type: 'string', title: 'Porkbun secret API key', 'x-group': GROUP,
+ description: 'The secret half of the Porkbun API key pair. It is locked when saved and never shown again; make a new pair at Porkbun if it is lost.',
+ 'x-owner-input': {
+ group: 'domains',
+ unlocks: 'Porkbun accepts Domains\' requests: the secret signs every call.',
+ get_it_at: 'https://porkbun.com/account/api',
+ get_it_how: 'It is shown next to the API key when the pair is made. Copy it then; Porkbun cannot show it again.',
+ test: true,
+ },
+ 'x-required-for': 'Porkbun domains',
+ },
+ },
+});
+
+const SECRETS = new Set(['cloudflare_api_token', 'porkbun_api_key', 'porkbun_secret_api_key']);
+const ACTIVE = { settings: null };
+
+function createSettings({ db, env = process.env, log = console, fetchImpl = globalThis.fetch, probes = null } = {}) {
+ let store = null;
+ const ready = createConfigStore({
+ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA,
+ classify: (key) => (SECRETS.has(key) ? 'secret' : 'internal'),
+ env, log,
+ }).then((s) => { store = s; return s; });
+
+ const values = () => (store ? store.get() : {});
+
+ function valueOf(key, envName, fallback = '') {
+ const v = values()[key];
+ if (v !== undefined && v !== null && v !== '') return v;
+ const e = envName ? env[envName] : undefined;
+ return e !== undefined && e !== null && e !== '' ? e : fallback;
+ }
+
+ /** The registrar keys, read when a registrar call is made. */
+ function registrarKeys() {
+ return {
+ cloudflare: { apiToken: valueOf('cloudflare_api_token', 'CLOUDFLARE_API_TOKEN', '') },
+ porkbun: {
+ apiKey: valueOf('porkbun_api_key', 'PORKBUN_API_KEY', ''),
+ secretApiKey: valueOf('porkbun_secret_api_key', 'PORKBUN_SECRET_API_KEY', ''),
+ },
+ };
+ }
+
+ async function probeCloudflare(apiToken) {
+ const r = await fetchImpl('https://api.cloudflare.com/client/v4/user/tokens/verify', { headers: { accept: 'application/json', authorization: `Bearer ${apiToken}` } });
+ let body = {};
+ try { body = await r.json(); } catch { /* not JSON */ }
+ if (!r.ok || !body.success) throw new Error((body.errors && body.errors[0] && body.errors[0].message) || `HTTP ${r.status}`);
+ }
+
+ async function probePorkbun(apiKey, secretApiKey) {
+ const r = await fetchImpl('https://api.porkbun.com/api/json/v3/ping', {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ apikey: apiKey, secretapikey: secretApiKey }),
+ });
+ let body = {};
+ try { body = await r.json(); } catch { /* not JSON */ }
+ if (!r.ok || body.status !== 'SUCCESS') throw new Error(body && body.message ? body.message : `HTTP ${r.status}`);
+ }
+
+ async function test(key) {
+ if (!['cloudflare_api_token', 'porkbun_api_key', 'porkbun_secret_api_key'].includes(key)) return { ok: false, detail: 'That setting has no test.' };
+ const keys = registrarKeys();
+ if (key === 'cloudflare_api_token') {
+ if (!keys.cloudflare.apiToken) return { ok: false, detail: 'Cloudflare is not configured: paste the API token first.' };
+ const probe = probes && probes.cloudflare ? probes.cloudflare : ({ apiToken }) => probeCloudflare(apiToken);
+ try {
+ await probe({ apiToken: keys.cloudflare.apiToken });
+ return { ok: true, detail: 'ok — Cloudflare verified the token.' };
+ } catch (err) {
+ const words = String((err && err.message) || 'the request failed').split(/\s+/).join(' ').slice(0, 160);
+ return { ok: false, detail: `failed — Cloudflare said: ${words.split(keys.cloudflare.apiToken).join('[redacted]')}` };
+ }
+ }
+ const { apiKey, secretApiKey } = keys.porkbun;
+ if (!apiKey || !secretApiKey) return { ok: false, detail: 'Porkbun is not configured: both the API key and its secret key are needed.' };
+ const probe = probes && probes.porkbun ? probes.porkbun : ({ apiKey: a, secretApiKey: s }) => probePorkbun(a, s);
+ try {
+ await probe({ apiKey, secretApiKey });
+ return { ok: true, detail: 'ok — Porkbun answered the ping with this key pair.' };
+ } catch (err) {
+ let words = String((err && err.message) || 'the request failed');
+ for (const secret of [apiKey, secretApiKey]) words = words.split(secret).join('[redacted]');
+ return { ok: false, detail: `failed — Porkbun said: ${words.split(/\s+/).join(' ').slice(0, 160)}` };
+ }
+ }
+
+ function mount(app, { guard, log: appLog = log } = {}) {
+ if (!store) throw new Error('createSettings: await ready before mount()');
+ const routes = adminRoutes([store], {
+ basePath: BASE_PATH,
+ requireAdmin: guard,
+ actor: (req) => {
+ const id = String(req.get('x-ov-subject') || '').trim();
+ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? { type: 'user', id } : null;
+ },
+ requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',
+ log: appLog,
+ });
+ app.use(routes.handle);
+ app.post(`${BASE_PATH}/:namespace/test/:key`, guard, async (req, res) => {
+ if (req.params.namespace !== NAMESPACE) return res.status(404).json({ error: 'no such configuration namespace' });
+ let key = String(req.params.key || '');
+ try { key = decodeURIComponent(key); } catch { /* keep it as it is */ }
+ if (!SCHEMA.properties[key]) return res.status(404).json({ error: 'no such setting' });
+ try {
+ const out = await test(key);
+ return res.status(200).json({ ok: Boolean(out.ok), detail: String(out.detail).replace(/\s+/g, ' ').slice(0, 200) });
+ } catch (err) {
+ appLog.warn(`[config] ${NAMESPACE}: the test for ${key} failed: ${String((err && err.message) || err).slice(0, 160)}`);
+ return res.status(200).json({ ok: false, detail: 'failed — the test did not finish. The service log has the reason.' });
+ }
+ });
+ return routes;
+ }
+
+ return { namespace: NAMESPACE, schema: SCHEMA, ready, value: valueOf, get: values, registrarKeys, test, mount };
+}
+
+function activate(settings) { ACTIVE.settings = settings; return settings; }
+function registrarKeys() {
+ if (ACTIVE.settings) return ACTIVE.settings.registrarKeys();
+ return {
+ cloudflare: { apiToken: process.env.CLOUDFLARE_API_TOKEN || '' },
+ porkbun: { apiKey: process.env.PORKBUN_API_KEY || '', secretApiKey: process.env.PORKBUN_SECRET_API_KEY || '' },
+ };
+}
+
+module.exports = { SERVICE, NAMESPACE, SCHEMA, SECRETS, createSettings, activate, registrarKeys };
@@ -15,7 +15,7 @@ const { TABLES } = require('../server/identity/account-data');
@@ -15,7 +15,7 @@ const { TABLES } = require('../server/identity/account-data');
try {
try {
const missing = await uncovered(t.store.db, {
const missing = await uncovered(t.store.db, {
tables: TABLES,
tables: TABLES,
- exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Domains applied' },
+ exempt: { "config_snapshots":"D11 owner settings: created_by/activated_by is the acting administrator (usr_...) for the config audit trail; the values are network configuration, not the person's account data, and are kept for rollback/history", account_data_events: 'the ledger of requests itself: which export and deletion Domains applied' },
});
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await t.close(); }
} finally { await t.close(); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+/**
+ * Owner settings (D11) for Domains' registrar keys (server/settings.js, domains.registrars):
+ * - Cloudflare's API token and Porkbun's key pair have plain titles, 30+ character descriptions,
+ * x-owner-input words and a test each;
+ * - /internal/config is loopback only and needs domains.config.manage;
+ * - the secrets are write-only and marked in summaries, registrarKeys() reads the owner's values and
+ * falls back to the environment, and the test hooks never echo or log a key.
+ */
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { boot, done } = require('./helpers');
+const { createSettings } = require('../server/settings');
+
+const CAP = 'domains.config.manage';
+const NS = 'domains.registrars';
+const USR = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
+const CF = 'cloudflare-owner-token-sentinel';
+const PB = 'porkbun-owner-key-sentinel';
+const PBS = 'porkbun-owner-secret-sentinel';
+
+(async () => {
+ const t = await boot();
+ const bearer = t.network.serviceToken([CAP]);
+ const cases = [];
+ const check = async (name, fn) => {
+ try { await fn(); cases.push(name); console.log(` ✓ ${name}`); } catch (e) { console.log(` ✗ ${name}\n${e.stack}`); process.exit(1); }
+ };
+
+ await check('the registrar keys have owner words and tests', async () => {
+ const r = await t.get(`/internal/config/${NS}`, { bearer });
+ assert.strictEqual(r.status, 200, r.text);
+ const props = r.json().schema.properties;
+ for (const key of ['cloudflare_api_token', 'porkbun_api_key', 'porkbun_secret_api_key']) {
+ const f = props[key];
+ assert.ok(f, `${key} exists`);
+ assert.ok(f.title && f.title.length >= 3);
+ assert.ok(f.description && f.description.length >= 30);
+ assert.strictEqual(f['x-owner-input'].group, 'domains');
+ assert.ok(f['x-owner-input'].unlocks.length > 20);
+ assert.ok(/^https:\/\//.test(f['x-owner-input'].get_it_at));
+ assert.strictEqual(f['x-owner-input'].test, true);
+ assert.ok(f['x-required-for'].endsWith('domains'), `${key} names the feature`);
+ }
+ });
+
+ await check('the routes are loopback only and need the capability', async () => {
+ assert.strictEqual((await t.get(`/internal/config/${NS}`)).status, 401);
+ assert.strictEqual((await t.get(`/internal/config/${NS}`, { bearer: t.network.serviceToken(['domains.actions.read']) })).status, 403);
+ assert.strictEqual((await t.get(`/internal/config/${NS}`, { bearer, headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 404);
+ });
+
+ await check('the keys are write-only and the environment is the fallback', async () => {
+ const envOnly = createSettings({ db: t.store.db, env: { CLOUDFLARE_API_TOKEN: 'cf-env', PORKBUN_API_KEY: 'pb-env', PORKBUN_SECRET_API_KEY: 'pbs-env' } });
+ await envOnly.ready;
+ assert.deepStrictEqual(envOnly.registrarKeys(), { cloudflare: { apiToken: 'cf-env' }, porkbun: { apiKey: 'pb-env', secretApiKey: 'pbs-env' } });
+ for (const [key, value] of [['cloudflare_api_token', CF], ['porkbun_api_key', PB], ['porkbun_secret_api_key', PBS]]) {
+ const put = await t.get(`/internal/config/${NS}/secrets/${key}`, {
+ method: 'PUT', json: { value, reason: 'owner test' }, bearer, headers: { 'x-ov-subject': USR, 'x-ov-fresh': '1' },
+ });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.ok(put.json().fingerprint, `${key} returns a marker`);
+ assert.ok(!put.text.includes(value));
+ }
+ const summary = await t.get(`/internal/config/${NS}`, { bearer });
+ assert.ok(summary.json().values.porkbun_secret_api_key.redacted);
+ assert.ok(!summary.text.includes(PBS));
+ assert.deepStrictEqual(t.ctx.settings.registrarKeys(), { cloudflare: { apiToken: CF }, porkbun: { apiKey: PB, secretApiKey: PBS } });
+ });
+
+ await check('the test hooks say ok/failed in one line and never echo a key', async () => {
+ const logs = [];
+ const log = { log: (...a) => logs.push(a.join(' ')), info: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
+ const fake = createSettings({
+ db: t.store.db, env: {}, log,
+ probes: {
+ cloudflare: async ({ apiToken }) => { throw new Error(`refused ${apiToken}`); },
+ porkbun: async ({ apiKey, secretApiKey }) => { throw new Error(`refused ${apiKey} / ${secretApiKey}`); },
+ },
+ });
+ await fake.ready;
+ const app = express();
+ app.use(express.json());
+ fake.mount(app, { guard: (_req, _res, next) => next(), log });
+ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ try {
+ for (const key of ['cloudflare_api_token', 'porkbun_api_key']) {
+ const r = await fetch(`http://127.0.0.1:${server.address().port}/internal/config/${NS}/test/${key}`, { method: 'POST' });
+ const body = await r.json();
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(body.ok, false);
+ assert.ok(/^failed — /.test(body.detail), body.detail);
+ assert.ok(!body.detail.includes(CF) && !body.detail.includes(PB) && !body.detail.includes(PBS), 'the answer is redacted');
+ assert.ok(!body.detail.includes('\n'), 'one line');
+ }
+ } finally { server.close(); }
+ assert.ok(!logs.join('\n').includes(CF) && !logs.join('\n').includes(PB) && !logs.join('\n').includes(PBS), 'no key in a captured log line');
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });