openvibers/openvibe.domains Commits

Domains: the registrar keys are owner settings (domains.registrars) (#90)

OP OpenVibers committed 6 days ago · 8cba12af63 · 10 files, +370 −10

* Domains: the registrar keys are owner settings (domains.registrars)

The Cloudflare API token and Porkbun's API key and secret key have plain titles,
descriptions, x-owner-input words and a test each. They are sealed and
write-only; registrarKeys() reads the owner's values at use time and falls back
to the environment. /internal/config is mounted loopback only behind
domains.config.manage.

* Fix the CI checks for the owner settings

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

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

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

Changes

ddiff --git a/.env.example b/.env.example
iindex dad4447..55e8fc3 100644
---- a/.env.example
++++ b/.env.example
@@@ -27,3 +27,9 @@ OV_OAUTH_CLIENT_SECRET=
# OpenVibe.Events → POST /internal/events (account export and deletion); 32+ characters, comma-separated to rotate
DOMAINS_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=
ddiff --git a/README.md b/README.md
iindex 20e3a8d..c9f0ca9 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex f3b0a53..412d153 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
],
ddiff --git a/migrations/0002_config.sql b/migrations/0002_config.sql
nnew file mode 100644
iindex 0000000..b3870dd
---- /dev/null
++++ b/migrations/0002_config.sql
@@@ -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
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 83eeb89..566fa6a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex bcc3620..c677999 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/app.js b/server/app.js
iindex db0c29f..0a50b3e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/settings.js b/server/settings.js
nnew file mode 100644
iindex 0000000..5ef0c23
---- /dev/null
++++ b/server/settings.js
@@@ -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 };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex ea1d416..22a459d 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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(); }
ddiff --git a/test/owner-settings.test.js b/test/owner-settings.test.js
nnew file mode 100644
iindex 0000000..630bd01
---- /dev/null
++++ b/test/owner-settings.test.js
@@@ -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); });