openvibers/openvibe.deals Commits

Deals: the Woot API key is an owner setting (deals.data_sources) (#236)

OP OpenVibers committed 6 days ago · 92cf49aae7 · 11 files, +302 −11

* Deals: the Woot API key is an owner setting (deals.data_sources)

The key has a plain title, description, x-owner-input words and a test that
reads one deal from Woot. It is sealed and write-only; wootApiKey() reads the
owner's value at use time and falls back to WOOT_API_KEY. /internal/config is
mounted loopback only behind deals.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-deals

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

Changes

ddiff --git a/.env.example b/.env.example
iindex c113331..21c896c 100644
---- a/.env.example
++++ b/.env.example
@@@ -104,6 +104,10 @@ OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
# engines. Unset: off, nothing is mounted and nothing is sent.
#INDEXNOW_KEY=
++# Data-source keys (D11 owner settings): the admin panel's deals.data_sources namespace wins; these
++# are the fallback when no value was entered there.
++#WOOT_API_KEY=
++
# release.json (openvibe-shared/release): the deployed commit when git is not available
#RELEASE_COMMIT=
#MIN_CLIENT_RELEASE=
ddiff --git a/README.md b/README.md
iindex f2cc1e3..d20ebd2 100644
---- a/README.md
++++ b/README.md
@@@ -497,7 +497,7 @@ First install (done once; kept for a rebuild):
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
- openvibe-publishing: v1.4.1
ddiff --git a/STATUS.json b/STATUS.json
iindex c415a68..44d3e7a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,12 +7,12 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4840,
"domain": "openvibe.deals",
-- "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-publishing v1.4.1",
-- "openvibe-contracts v0.289.1",
++ "openvibe-contracts v0.311.0",
"openvibe-shared v4.124.0",
"openvibe-sdk v0.93.0"
],
ddiff --git a/migrations/0013_config.sql b/migrations/0013_config.sql
nnew file mode 100644
iindex 0000000..6173010
---- /dev/null
++++ b/migrations/0013_config.sql
@@@ -0,0 +1,30 @@
++-- phase: expand
++-- D11 owner settings (openvibe-shared/config): the configuration snapshots and the per-namespace
++-- fingerprint key. Deals' data-source keys live in deals.data_sources; 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 88ec1e0..4e85c31 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"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",
@@@ -1148,9 +1148,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 e825c8b..0f1a432 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,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-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"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",
ddiff --git a/server/app.js b/server/app.js
iindex 03f452f..df2f7cb 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -14,6 +14,7 @@ const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
++const CONFIG_CAPABILITY = 'deals.config.manage';
const configLib = require('./config');
const { openStore } = require('./db');
@@@ -42,6 +43,8 @@ const { createPages } = require('./http/pages');
const { createApi } = require('./http/api');
const { createDiscoveryRoutes } = require('./http/discovery');
const { createEvents } = require('./http/events');
++const settingsLib = require('./settings');
++const { verifyServiceToken } = require('openvibe-sdk/auth');
const accountDataLib = require('./domain/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createDealsReadiness } = require('./observability');
@@@ -116,7 +119,21 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: null);
-- const ctx = { config, store, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, sharing, limits, access, offers, flags, votes, listings, importer, campaigns, auth, viewers, worker, indexnow, accountData, accountSend };
++ // D11 owner settings (deals.data_sources): created before the app mounts /internal/config. A failure leaves
++ // Deals serving (the Woot key falls 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.Deals] owner settings unavailable, the data-source keys fall back to the environment:', err && err.message);
++ }
++ }
++
++ const ctx = { config, store, settings, outbox, people, community, sources, reads, catalog, publication, indexing, hotness, watches, sharing, limits, access, offers, flags, votes, listings, importer, campaigns, auth, viewers, worker, indexnow, accountData, accountSend };
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@@ -214,6 +231,32 @@ async function createApp(opts = {}) {
// ── Events consumer, API ────────────────────────────────
// Never per-actor limited: Events pushes at its own pace, and a 429 would only make it retry and fall behind.
app.use(createEvents({ config, store, importer, accountData: ctx.accountData, accountSend: ctx.accountSend }));
++
++ // ── D11 owner settings (openvibe-shared/config): Network's admin proxy, acting for the owner, reads and
++ // writes deals.data_sources (the Woot API key). Loopback only, guarded by deals.config.manage; a secret change
++ // needs X-OV-Fresh: 1, and the test route reads one deal without echoing the key.
++ if (settings) {
++ const configGuard = async (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: 'deals.not_found', error: 'no such route' });
++ }
++ const header = String(req.headers.authorization || '');
++ if (!header.startsWith('Bearer ')) return res.status(401).json({ code: 'token.missing', error: 'a service token is required' });
++ const r = await verifyServiceToken(header.slice(7).trim(), {
++ jwks: `${config.networkInternalUrl}/api/.well-known/jwks`, issuer: config.networkUrl, audience: 'openvibe.deals',
++ contracts: require('openvibe-contracts'),
++ });
++ if (!r.ok) return res.status(r.code === 'token.unavailable' ? 503 : 401).json({ code: r.code, error: r.reason || 'the token does not verify' });
++ const contracts = require('openvibe-contracts');
++ const decision = contracts.capabilities.check(r.claims, CONFIG_CAPABILITY);
++ const granted = decision.allowed || contracts.capabilities.grants(r.claims.cap, CONFIG_CAPABILITY);
++ if (!granted) return res.status(403).json({ code: 'capability.denied', error: 'deals.config.manage is not granted' });
++ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
++ return next();
++ };
++ app.use('/internal/config', express.json({ limit: '64kb' }));
++ settings.mount(app, { guard: configGuard, log });
++ }
// ── /api/v1/watch/:id/share — the one Share sheet's API (openvibe-sdk/share) ──
// GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people
// and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only
ddiff --git a/server/settings.js b/server/settings.js
nnew file mode 100644
iindex 0000000..f9c21ea
---- /dev/null
++++ b/server/settings.js
@@@ -0,0 +1,125 @@
++'use strict';
++
++/**
++ * Owner settings (D11) for OpenVibe.Deals: the data-source keys in `deals.data_sources`.
++ *
++ * The Woot developer API key is read through wootApiKey() when a Woot read is made: the value the
++ * owner entered in the admin panel wins, WOOT_API_KEY is the fallback. The key is sealed by the
++ * shared store under OV_CONFIG_KEKS and is write-only through
++ * /internal/config/deals.data_sources/secrets/woot_api_key; summaries, history and logs carry markers.
++ * Network's admin proxy reaches this namespace with `deals.config.manage`, acting for the owner
++ * (X-OV-Subject); a secret change needs X-OV-Fresh: 1. The test route reads one deal from Woot and
++ * answers ok or failed in one line, never echoing the key.
++ */
++const { createConfigStore, adminRoutes } = require('openvibe-shared/config');
++
++const SERVICE = 'deals';
++const NAMESPACE = 'deals.data_sources';
++const BASE_PATH = '/internal/config';
++const GROUP = 'Data sources';
++
++const SCHEMA = Object.freeze({
++ $schema: 'https://json-schema.org/draft/2020-12/schema',
++ title: 'Deals data sources',
++ description: 'The accounts Deals reads deals from. A source with no key is skipped; the other sources keep working.',
++ type: 'object',
++ additionalProperties: false,
++ properties: {
++ woot_api_key: {
++ type: 'string', title: 'Woot API key', 'x-group': GROUP,
++ description: 'The developer key for Woot\'s own API. It lets Deals read Woot deals directly instead of crawling their pages; without it Woot is only covered by the crawler.',
++ 'x-owner-input': {
++ group: 'data-sources',
++ unlocks: 'Woot deals can be read from Woot\'s API, with current prices and stock instead of a page crawl.',
++ get_it_at: 'https://developer.woot.com/',
++ get_it_how: 'Request a developer key on the Woot developer portal and paste the key it gives you here.',
++ test: true,
++ },
++ 'x-required-for': 'Woot API deals',
++ },
++ },
++});
++
++const SECRETS = new Set(['woot_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 Woot key, read when a Woot read is made. */
++ function wootApiKey() { return valueOf('woot_api_key', 'WOOT_API_KEY', ''); }
++
++ async function test(key) {
++ if (key !== 'woot_api_key') return { ok: false, detail: 'That setting has no test.' };
++ const apiKey = wootApiKey();
++ if (!apiKey) return { ok: false, detail: 'Woot is not configured: paste the API key first.' };
++ const probe = probes && typeof probes.woot === 'function'
++ ? probes.woot
++ : async ({ apiKey: keyValue }) => {
++ const r = await fetchImpl('https://api.woot.com/v1/deals?limit=1', { headers: { accept: 'application/json', 'x-api-key': keyValue } });
++ let body = {};
++ try { body = await r.json(); } catch { /* not JSON */ }
++ if (!r.ok) throw new Error(`HTTP ${r.status}${body && body.message ? `: ${body.message}` : ''}`);
++ };
++ try {
++ await probe({ apiKey });
++ return { ok: true, detail: 'ok — Woot answered a one-deal read with this key.' };
++ } catch (err) {
++ const words = String((err && err.message) || 'the request failed').split(/\s+/).join(' ').slice(0, 160);
++ return { ok: false, detail: `failed — Woot said: ${words.split(apiKey).join('[redacted]')}` };
++ }
++ }
++
++ 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, wootApiKey, test, mount };
++}
++
++function activate(settings) { ACTIVE.settings = settings; return settings; }
++function wootApiKey() {
++ if (ACTIVE.settings) return ACTIVE.settings.wootApiKey();
++ return process.env.WOOT_API_KEY || '';
++}
++
++module.exports = { SERVICE, NAMESPACE, SCHEMA, SECRETS, createSettings, activate, wootApiKey };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex cbe8a8b..313c748 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -43,7 +43,7 @@ const envelope = (id, type, payload) => ({ event: { event_id: id, event_type: ty
tables: TABLES,
taskKeyed: ["deal_watches"],
-- exempt: {"account_data_events":"the ledger of requests itself: which export and deletion Deals applied","deal_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash","share_members":"the SDK's own sharing model: subject is an added person, erased with the watch by extraErase","share_access_requests":"the SDK's own sharing model: subject is the person who asked, erased by extraErase","deals_share_defaults":"a person's own default for new watches, erased by extraErase"},
++ 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 Deals applied","deal_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash","share_members":"the SDK's own sharing model: subject is an added person, erased with the watch by extraErase","share_access_requests":"the SDK's own sharing model: subject is the person who asked, erased by extraErase","deals_share_defaults":"a person's own default for new watches, erased by extraErase"},
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex a178ba6..771a20e 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -45,7 +45,7 @@ async function boot(opts = {}) {
let built = null;
async function start() {
const config = configLib.load(env);
-- built = await createApp({ config, telemetrySink: opts.telemetrySink, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend });
++ built = await createApp({ config, env, telemetrySink: opts.telemetrySink, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend });
await require('openvibe-sdk/auth').jwksClient(`${config.networkInternalUrl}/api/.well-known/jwks`).keys();
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/owner-settings.test.js b/test/owner-settings.test.js
nnew file mode 100644
iindex 0000000..e1e1516
---- /dev/null
++++ b/test/owner-settings.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * Owner settings (D11) for Deals' data sources (server/settings.js, deals.data_sources):
++ * - the Woot API key has a plain title, a 30+ character description, x-owner-input words and a test;
++ * - /internal/config is loopback only and needs deals.config.manage;
++ * - the key is write-only and marked in summaries, the code reads the owner value and falls back
++ * to WOOT_API_KEY, and the test hook never echoes or logs the key.
++ * node test/owner-settings.test.js
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { boot } = require('./helpers/boot');
++const { createSettings } = require('../server/settings');
++
++const CAP = 'deals.config.manage';
++const NS = 'deals.data_sources';
++const USR = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
++const KEY = 'woot-owner-key-sentinel';
++const ENV_KEY = 'woot-env-key';
++
++(async () => {
++ const t = await boot({ env: { WOOT_API_KEY: ENV_KEY } });
++ const TOKEN = t.network.serviceToken('network', [CAP]);
++ const checks = [];
++ const check = async (name, fn) => {
++ try { await fn(); checks.push(name); console.log(` ✓ ${name}`); } catch (e) { console.log(` ✗ ${name}\n${e.stack}`); await t.close(); process.exit(1); }
++ };
++
++ await check('the Woot key has owner words and a test', async () => {
++ const r = await t.get(`/internal/config/${NS}`, { as: TOKEN });
++ assert.strictEqual(r.status, 200, r.text);
++ const f = r.json().schema.properties.woot_api_key;
++ assert.ok(f, 'woot_api_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, 'data-sources');
++ assert.ok(f['x-owner-input'].unlocks.length > 20);
++ assert.strictEqual(f['x-owner-input'].get_it_at, 'https://developer.woot.com/');
++ assert.strictEqual(f['x-owner-input'].test, true);
++ assert.strictEqual(f['x-required-for'], 'Woot API deals');
++ });
++
++ 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}`, { as: t.network.serviceToken('network', ['deals.watch.read']) })).status, 403);
++ assert.strictEqual((await t.get(`/internal/config/${NS}`, { as: TOKEN, headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 404);
++ });
++
++ await check('the key is write-only and the environment is the fallback', async () => {
++ assert.strictEqual(t.ctx.settings.wootApiKey(), ENV_KEY, 'the env is the fallback');
++ const put = await t.get(`/internal/config/${NS}/secrets/woot_api_key`, {
++ as: TOKEN, method: 'PUT', json: { value: KEY, reason: 'owner test' }, headers: { 'x-ov-subject': USR, 'x-ov-fresh': '1' },
++ });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.ok(put.json().fingerprint && put.json().hint);
++ assert.ok(!put.text.includes(KEY), 'the marker never carries the value');
++ const summary = await t.get(`/internal/config/${NS}`, { as: TOKEN });
++ assert.ok(summary.json().values.woot_api_key.redacted);
++ assert.ok(!summary.text.includes(KEY));
++ assert.strictEqual(t.ctx.settings.wootApiKey(), KEY, 'the code reads the owner value when set');
++ });
++
++ await check('the test hook says ok/failed in one line and never echoes a key', async () => {
++ const before = t.logs ? t.logs().length : 0;
++ const fake = createSettings({
++ db: t.ctx.store.db, env: {},
++ probes: { woot: async ({ apiKey }) => { throw new Error(`refused ${apiKey}`); } },
++ });
++ await fake.ready;
++ const app = express();
++ app.use(express.json());
++ fake.mount(app, { guard: (_req, _res, next) => next(), log: t.log });
++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
++ try {
++ const r = await fetch(`http://127.0.0.1:${server.address().port}/internal/config/${NS}/test/woot_api_key`, { method: 'POST' });
++ const body = await r.json();
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(body.ok, false);
++ assert.ok(/^failed — Woot said: /.test(body.detail), body.detail);
++ assert.ok(!body.detail.includes(KEY));
++ assert.ok(!body.detail.includes('\n'));
++ } finally { server.close(); }
++ assert.ok(!(t.logs ? t.logs() : '').includes(KEY), 'no key in a captured log line');
++ });
++
++ await t.close();
++ console.log(`\n${checks.length} checks passed`);
++})().catch((e) => { console.error(e); process.exit(1); });