openvibers/openvibe.host Commits
Hosting prices: DigitalOcean and Hetzner with the owner's read-only tokens (host.data_sources) (#308)
Changes
@@@ -28,6 +28,10 @@ HOST_FORM_SECRET=#HOST_WORKER_ID=HOST_CATALOG_INGEST=offHOST_CATALOG_SOURCES_URL=++# Read-only provider tokens for DigitalOcean's and Hetzner Cloud's prices (server/catalog/keyed-feeds.js). The owner++# can enter them in the admin panel's Setup tab instead (host.data_sources, sealed); a token entered there wins.++#HOST_DIGITALOCEAN_TOKEN=++#HOST_HETZNER_TOKEN=HOST_CATALOG_WEBHOOK_SECRETS=HOST_COMMUNITY_URL=OV_COMMUNITY_URL=https://openvibe.community@@@ -25,6 +25,12 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-maddons apart from the plan, so the Sources mapping joins each default addon to its own product; Host multiplies thesocket count by the cores and one disk's size by the disk count. Vultr's API host disallows robots, so thatSources seed stays disabled.++ - DigitalOcean's Droplet sizes and Hetzner Cloud's server types, which their APIs show only to a signed-in account:++ Host reads them itself every six hours (`server/catalog/keyed-feeds.js`) with the owner's read-only token from the++ admin panel's Setup tab (`host.data_sources`, sealed; `HOST_DIGITALOCEAN_TOKEN` / `HOST_HETZNER_TOKEN` are the++ fallback), through the same mapping (`digitalocean-sizes`, `hetzner-server-types`, prices in euros as Hetzner++ states them). Without a token that provider is left out. `POST /internal/config/host.data_sources/test/:key` is the++ Setup tab's Test button: one read-only call, one line back, never the token.- Staff entries, which must name their source URL.- Nothing is estimated. A value the source does not state is shown as Unknown.- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.@@@ -383,7 +389,7 @@ the owner's gates (go-ahead, a DMCA agent, a Public Suffix List entry for openviPart 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.0@@@ -13,11 +13,11 @@"runtime": "node 22; Express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + optional Valkey","port": 4910,"domain": "openvibe.host",-- "contracts": "openvibe-contracts v0.289.1",++ "contracts": "openvibe-contracts v0.311.0","sdk": "openvibe-sdk v0.93.0","shared": "openvibe-shared v4.124.0","packages": [-- "openvibe-contracts v0.289.1",++ "openvibe-contracts v0.311.0","openvibe-sdk v0.93.0","openvibe-shared v4.124.0"],@@@ -15,7 +15,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.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.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",@@@ -1534,9 +1534,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",@@@ -31,7 +31,7 @@"express": "^4.21.2","express-rate-limit": "^7.5.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.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",@@@ -18,6 +18,8 @@ const { createHostOutbox } = require('./events/outbox');const { createSsoClient } = require('openvibe-sdk/sso');const { createViewerResolver } = require('./auth/viewer');const createInferencePlannerConfig = require('./inference-planner-config').create;++const createDataSourcesConfig = require('./data-sources-config').create;++const { createKeyedFeeds } = require('./catalog/keyed-feeds');const { createHostSession } = require('./auth/sso');const { createHostReadiness } = require('./observability');const { createWorker } = require('./worker');@@@ -53,7 +55,19 @@ async function createApp(opts = {}) {const log = opts.log || console;const store = opts.store || await openStore(config, { now: opts.now, log });// D11 owner settings (host.inference_planner): the planner's switches read through the store, env pins winning.-- const plannerConfig = await createInferencePlannerConfig({ db: store.db, config, env: opts.env || process.env, log });++ // host.data_sources (the owner-setup design): the hosting providers' read-only tokens the price feed reads at use++ // time, served by the same /internal/config routes, with a Test button per token.++ const dataSources = await createDataSourcesConfig({ db: store.db, env: opts.env || process.env, log });++ let keyedFeeds = null;++ const plannerConfig = await createInferencePlannerConfig({++ db: store.db, config, env: opts.env || process.env, log, also: [dataSources.store],++ test: (namespace, key) => {++ if (namespace !== dataSources.store.namespace) return null;++ const feed = Object.keys(dataSources.FEEDS).find((f) => dataSources.FEEDS[f].key === key);++ if (!feed || !keyedFeeds) return null;++ return { ...dataSources.token(feed), run: (value) => keyedFeeds.test(feed, value) };++ },++ });Object.defineProperty(config.inferencePlanner, 'enabled', { enumerable: true, configurable: true, get: () => plannerConfig.get('enabled') });Object.defineProperty(config.inferencePlanner, 'source', { enumerable: true, configurable: true, get: () => plannerConfig.get('source') });Object.defineProperty(config.inferencePlanner, 'budgetNanoUsdRaw', { enumerable: true, configurable: true, get: () => { const v = plannerConfig.get('budget_nano_usd'); return v == null ? '' : String(v); } });@@@ -80,14 +94,15 @@ async function createApp(opts = {}) {})();const catalog = createCatalog({ store, community, log, indexnow, baseUrl: config.baseUrl });const catalogIngest = createCatalogIngest({ config, store, catalog, fetchImpl: opts.fetchImpl });++ keyedFeeds = createKeyedFeeds({ catalog, dataSources, fetchImpl: opts.fetchImpl || globalThis.fetch, now: store.now, log });const domainCheck = createDomainCheck({ catalog, rdap: opts.rdap || createRdap({ log }) });const benchmarks = createBenchmarks({ store, baseUrl: config.baseUrl });const catalogSearch = createCatalogSearch({ config, store, outbox, log });// The public pages as data, shared by the routes and the agent-layer twins (server/agent-pages.js).const views = require('./http/catalog-views').createCatalogViews({ catalog, benchmarks, community, config, log });catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });-- const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });-- const ctx = { config, store, plannerConfig, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log, views };++ const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, keyedFeeds, log });++ const ctx = { config, store, plannerConfig, dataSources, keyedFeeds, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log, views };ctx.console = require('./console/console').createConsole({ store, config, catalog, fetchImpl: opts.fetchImpl, log });const app = express();plannerConfig.mount(app);nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++/**++ * The hosting providers whose prices need an account (the owner-setup design of 2026-11-07): DigitalOcean's Droplet++ * sizes and Hetzner Cloud's server types, read with the owner's read-only token from `host.data_sources` (the panel's++ * value, else HOST_DIGITALOCEAN_TOKEN / HOST_HETZNER_TOKEN), at use time. Without a token a feed is skipped and says so.++ *++ * Each plan becomes an item in the shape OpenVibe.Sources stores (source_key, identity, title, fields, provenance), so++ * it goes through the same mapping (server/catalog/mapping.js) and catalog.applyItem as every other price: a changed++ * price is recorded, a plan's first-seen time is kept. Nothing here is written but catalog rows; the token goes in the++ * Authorization header only and never into a log line, an error or an answer.++ *++ * test(feed, token) is the Setup tab's Test button: one read-only call (DigitalOcean's account, Hetzner's first server++ * type), answered in one line.++ */++const TIMEOUT_MS = 15_000;++const API = Object.freeze({ digitalocean: 'https://api.digitalocean.com', hetzner: 'https://api.hetzner.cloud' });++++async function getJson(fetchImpl, url, token) {++ let res;++ try {++ res = await fetchImpl(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });++ } catch (err) {++ return { status: 0, json: null, error: err && err.name === 'TimeoutError' ? 'did not answer within 15 seconds' : 'could not be reached' };++ }++ const json = await res.json().catch(() => null);++ return { status: res.status, json, error: null };++}++++function refusal(name, r) {++ if (r.error) return `${name} ${r.error}.`;++ const says = r.json && (r.json.message || (r.json.error && r.json.error.message)) ? String(r.json.message || r.json.error.message) : '';++ if (r.status === 401 || r.status === 403) return `${name} refused the token (${r.status})${says ? `: ${says}` : '.'}`;++ if (r.status === 429) return `${name} is limiting requests right now (429); try again later.`;++ return `${name} answered ${r.status}${says ? `: ${says}` : '.'}`;++}++++/** A plan as the Sources `api` adapter would have stored it. */++function item(sourceKey, identity, title, fields, at) {++ return {++ id: `host:${sourceKey}:${identity}`, source_key: sourceKey, category: 'hosting', kind: 'plan', identity, canonical_url: null,++ title, summary: null, authors: [], published_at: null, source_updated_at: null, fields, revision: 1,++ provenance: { retrieved_at: at, first_seen_at: at, content_hash: null, raw_body_hash: null, parser_version: 'host-keyed@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null },++ removed: null,++ };++}++++/** DigitalOcean: every available Droplet size (GET /v2/sizes, 200 a page). */++async function digitalOceanItems(fetchImpl, token, at) {++ const out = [];++ let url = `${API.digitalocean}/v2/sizes?per_page=200`;++ for (let page = 0; url && page < 10; page++) {++ const r = await getJson(fetchImpl, url, token);++ if (!(r.status >= 200 && r.status < 300)) throw new Error(refusal('DigitalOcean', r));++ for (const s of (r.json && Array.isArray(r.json.sizes) ? r.json.sizes : [])) {++ if (!s || !s.slug || s.available === false) continue;++ out.push(item('digitalocean-sizes', String(s.slug), String(s.slug), {++ description: s.description || null, vcpus: s.vcpus, memory: s.memory, disk: s.disk, transfer: s.transfer,++ price_monthly: s.price_monthly, price_hourly: s.price_hourly, regions: Array.isArray(s.regions) ? s.regions : [],++ }, at));++ }++ const next = r.json && r.json.links && r.json.links.pages && r.json.links.pages.next;++ url = typeof next === 'string' && next.startsWith(`${API.digitalocean}/`) ? next : null;++ }++ return out;++}++++/** Hetzner Cloud: every server type that is not deprecated (GET /v1/server_types), its cheapest location's price. */++async function hetznerItems(fetchImpl, token, at) {++ const out = [];++ for (let page = 1; page <= 10; page++) {++ const r = await getJson(fetchImpl, `${API.hetzner}/v1/server_types?per_page=50&page=${page}`, token);++ if (!(r.status >= 200 && r.status < 300)) throw new Error(refusal('Hetzner Cloud', r));++ const list = r.json && Array.isArray(r.json.server_types) ? r.json.server_types : [];++ for (const s of list) {++ if (!s || !s.name || s.deprecated || (s.deprecation && s.deprecation.unavailable_after)) continue;++ out.push(item('hetzner-server-types', String(s.name), String(s.name).toUpperCase(), {++ description: s.description || null, cores: s.cores, memory: s.memory, disk: s.disk, cpu_type: s.cpu_type || null,++ storage_type: s.storage_type || null, architecture: s.architecture || null, prices: Array.isArray(s.prices) ? s.prices : [],++ }, at));++ }++ const next = r.json && r.json.meta && r.json.meta.pagination && r.json.meta.pagination.next_page;++ if (!next) break;++ }++ return out;++}++++function createKeyedFeeds({ catalog, dataSources, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++ const read = { digitalocean: digitalOceanItems, hetzner: hetznerItems };++++ /** Read every feed that has a token. Answers { [feed]: { skipped } | { applied, held } | { error } }. */++ async function pull() {++ const report = {};++ for (const feed of Object.keys(read)) {++ const t = dataSources.token(feed);++ if (!t.value) { report[feed] = { skipped: 'no token' }; continue; }++ try {++ const items = await read[feed](fetchImpl, t.value, new Date(now()).toISOString());++ let applied = 0;++ let held = 0;++ for (const it of items) {++ const r = await catalog.applyItem(it).catch(() => 'hold');++ if (r === 'hold') held++; else applied++;++ }++ report[feed] = { applied, held };++ } catch (err) {++ const message = String((err && err.message) || 'failed').split(t.value).join('[hidden]').slice(0, 200);++ report[feed] = { error: message };++ log.warn(`[Host] ${feed} prices: ${message}`);++ }++ }++ return report;++ }++++ /** The Setup tab's Test: one read-only call with the token. */++ async function test(feed, token) {++ if (feed === 'digitalocean') {++ const r = await getJson(fetchImpl, `${API.digitalocean}/v2/account`, token);++ if (!(r.status >= 200 && r.status < 300)) return { ok: false, message: refusal('DigitalOcean', r) };++ const status = r.json && r.json.account && r.json.account.status;++ return { ok: true, message: `DigitalOcean accepted the token${status ? ` (the account is ${status})` : ''}; its Droplet prices can be read.` };++ }++ if (feed === 'hetzner') {++ const r = await getJson(fetchImpl, `${API.hetzner}/v1/server_types?per_page=1`, token);++ if (!(r.status >= 200 && r.status < 300)) return { ok: false, message: refusal('Hetzner Cloud', r) };++ const total = r.json && r.json.meta && r.json.meta.pagination && r.json.meta.pagination.total_entries;++ return { ok: true, message: `Hetzner Cloud accepted the token${Number.isFinite(Number(total)) ? ` and lists ${Number(total)} server types` : ''}.` };++ }++ return null;++ }++++ return { pull, test, feeds: Object.keys(read) };++}++++module.exports = { createKeyedFeeds, digitalOceanItems, hetznerItems, API };@@@ -9,6 +9,9 @@ const SOURCES = Object.freeze({'ovhcloud-vps': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/vps/' },'ovhcloud-baremetal': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/bare-metal/prices/' },'azure-retail-prices': { slug: 'azure', name: 'Microsoft Azure', homepage_url: 'https://azure.microsoft.com/', source_url: 'https://azure.microsoft.com/en-us/pricing/' },++ // Read by Host itself with the owner's read-only token (server/catalog/keyed-feeds.js), not through Sources.++ 'digitalocean-sizes': { slug: 'digitalocean', name: 'DigitalOcean', homepage_url: 'https://www.digitalocean.com/', source_url: 'https://www.digitalocean.com/pricing/droplets' },++ 'hetzner-server-types': { slug: 'hetzner', name: 'Hetzner', homepage_url: 'https://www.hetzner.com/', source_url: 'https://www.hetzner.com/cloud/' },});const number = (v, field) => {if (v == null) return null;@@@ -144,6 +147,48 @@ function azure(x, id, title) {}throw new TypeError('unknown Azure price unit');}++// DigitalOcean's Droplet sizes (GET /v2/sizes): memory in MB, disk in GB, transfer in TB, prices in USD. Named as its++// pricing page names them ("Basic · 1 vCPU, 1 GB"); a premium size (-amd/-intel) has NVMe disks, the rest SSD.++function digitalOcean(x, id) {++ const monthly = number(x.price_monthly, 'monthly price');++ if (monthly == null) throw new TypeError('missing monthly price');++ if (monthly <= 0) return null;++ const vcpu = number(x.vcpus, 'vcpu');++ const ram = number(x.memory, 'memory');++ const memory = ram == null ? null : ram >= 1024 ? `${Math.round((ram / 1024) * 10) / 10} GB` : `${ram} MB`;++ const family = typeof x.description === 'string' && x.description.trim() ? x.description.trim() : null;++ const transfer = number(x.transfer, 'transfer');++ return {++ name: family && vcpu != null && memory ? `${family} · ${vcpu} vCPU, ${memory}` : id, kind: 'cloud',++ vcpu, ram_mb: ram, disk_gb: number(x.disk, 'disk'), disk_type: /-(amd|intel)$/.test(id) ? 'nvme' : 'ssd',++ bandwidth_gb: transfer == null ? null : Math.round(transfer * 1000), unmetered: null, port_mbps: null,++ price_monthly: monthly, price_hourly: number(x.price_hourly, 'hourly price'), ipv4: 1,++ locations: Array.isArray(x.regions) && x.regions.length ? x.regions : null,++ };++}++++// Hetzner Cloud's server types (GET /v1/server_types): memory in GB, disk in GB, and a price per location in euros++// (net, before VAT, as text). The plan's price is its cheapest location's; every location it is sold in is listed.++function hetzner(x, id, title) {++ const prices = (Array.isArray(x.prices) ? x.prices : []).map((p) => ({++ location: p && p.location, monthly: Number(p && p.price_monthly && p.price_monthly.net), hourly: Number(p && p.price_hourly && p.price_hourly.net),++ traffic: Number(p && p.included_traffic),++ })).filter((p) => typeof p.location === 'string' && Number.isFinite(p.monthly) && p.monthly > 0);++ if (!prices.length) throw new TypeError('missing monthly price');++ const cheapest = prices.reduce((a, b) => (b.monthly < a.monthly ? b : a));++ const memoryGb = number(x.memory, 'memory');++ const cores = number(x.cores, 'cores');++ const name = (typeof title === 'string' && title.trim()) || id;++ return {++ name: cores != null && memoryGb != null ? `${name} · ${cores} vCPU${x.cpu_type === 'dedicated' ? ' (dedicated)' : ''}, ${memoryGb} GB` : name,++ kind: 'cloud', vcpu: cores, ram_mb: memoryGb == null ? null : Math.round(memoryGb * 1024), disk_gb: number(x.disk, 'disk'),++ disk_type: x.storage_type === 'local' ? 'nvme' : null,++ bandwidth_gb: Number.isFinite(cheapest.traffic) && cheapest.traffic > 0 ? Math.round(cheapest.traffic / 1e9) : null, unmetered: null, port_mbps: null,++ price_monthly: Math.round(cheapest.monthly * 100) / 100, price_hourly: Number.isFinite(cheapest.hourly) ? cheapest.hourly : null, ipv4: null,++ locations: [...new Set(prices.map((p) => p.location))],++ };++}++function mapItem(item) {const provider = SOURCES[item && item.source_key];if (!provider) return null;@@@ -170,6 +215,14 @@ function mapItem(item) {const region = typeof x.region === 'string' && x.region ? x.region : null;return { ...common, ...plan, external_id: region ? `${id}@${region}` : id, checked_at: observed };}++ if (item.source_key === 'digitalocean-sizes') {++ const plan = digitalOcean(x, id);++ return plan && { ...common, ...plan };++ }++ if (item.source_key === 'hetzner-server-types') {++ const plan = hetzner(x, id, item.title);++ return plan && { ...common, ...plan, currency: 'EUR' };++ }if (item.source_key === 'ovhcloud-vps') {const plan = ovhcloud(x, id, item.title);return plan && { ...common, ...plan };nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++/**++ * The `host.data_sources` namespace (the owner-setup design of 2026-11-07): the hosting providers' API tokens the++ * comparison needs to read their prices. DigitalOcean and Hetzner Cloud publish their plans and prices only to a++ * signed-in account (a read-only token is enough); Vultr, Linode, AWS Lightsail, OVHcloud and Azure need none and++ * come through OpenVibe.Sources.++ *++ * The tokens are secret class: write-only through /internal/config/host.data_sources/secrets/:key, sealed with the++ * service's KEK, shown only as their last four characters. Each carries x-owner-input (group data-sources), so the++ * admin panel's Setup tab lists it with where to get it and a Test button. No x-env pin: the token entered in the++ * panel wins, and HOST_DIGITALOCEAN_TOKEN / HOST_HETZNER_TOKEN from the environment stay the fallback, read at use++ * time (token() below) by the price feed (server/catalog/keyed-feeds.js).++ */++const shared = require('openvibe-shared/config');++++const SERVICE = 'host';++const NAMESPACE = 'host.data_sources';++const GROUP = 'Hosting prices';++++const FEEDS = Object.freeze({++ digitalocean: { key: 'digitalocean.token', env: 'HOST_DIGITALOCEAN_TOKEN', name: 'DigitalOcean' },++ hetzner: { key: 'hetzner.token', env: 'HOST_HETZNER_TOKEN', name: 'Hetzner Cloud' },++});++++const SCHEMA = {++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'Hosting price sources',++ description: 'The API tokens Host reads hosting providers\' plans and prices with, for the comparison on openvibe.host. Add one to bring that provider\'s plans into the comparison; remove it to stop reading them.',++ type: 'object',++ additionalProperties: false,++ properties: {++ 'digitalocean.token': {++ type: 'string', minLength: 16, maxLength: 512,++ title: 'DigitalOcean API token (read only)', 'x-group': GROUP, 'x-example': 'dop_v1_…',++ description: 'A read-only DigitalOcean token Host uses to read the Droplet plans and their prices every six hours. Without it DigitalOcean is missing from the comparison. Read access cannot create or change anything.',++ 'x-owner-input': {++ group: 'data-sources',++ unlocks: 'Adds DigitalOcean\'s Droplet plans and prices to the hosting comparison, refreshed every six hours. Without it DigitalOcean stays out of the comparison.',++ get_it_at: 'https://cloud.digitalocean.com/account/api/tokens',++ get_it_how: 'Generate a new personal access token with the Read scope only, and no expiry or a long one.',++ test: true,++ },++ 'x-required-for': 'DigitalOcean in the hosting comparison',++ },++ 'hetzner.token': {++ type: 'string', minLength: 16, maxLength: 512,++ title: 'Hetzner Cloud API token (read only)', 'x-group': GROUP, 'x-example': 'a1B2c3…',++ description: 'A read-only Hetzner Cloud token Host uses to read the server types and their prices every six hours. Without it Hetzner is missing from the comparison. Prices are in euros, as Hetzner states them.',++ 'x-owner-input': {++ group: 'data-sources',++ unlocks: 'Adds Hetzner Cloud\'s server plans and prices to the hosting comparison, refreshed every six hours. Without it Hetzner stays out of the comparison.',++ get_it_at: 'https://console.hetzner.cloud/projects',++ get_it_how: 'Open any project (an empty one is fine), then Security, API tokens, and generate a token with Read permission.',++ test: true,++ },++ 'x-required-for': 'Hetzner in the hosting comparison',++ },++ },++};++++const classify = (key) => (Object.values(FEEDS).some((f) => f.key === key) ? 'secret' : 'internal');++++/** Create the store; reseal old rows once when a KEK is configured. */++async function create({ db, env = process.env, log = console }) {++ const store = await shared.createConfigStore({++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, env, classify, log,++ onChange: ({ namespace, keys, actor: who, revision }) => {++ (log.info || log.log).call(log, `[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${keys.join(', ')}`);++ },++ });++ if (env.OV_CONFIG_KEKS) {++ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }++ }++ /** A feed's token at use time: { value, source: vault | env | null, env }. The panel's value wins. */++ function token(feed) {++ const f = FEEDS[feed];++ if (!f) return { value: '', source: null, env: null };++ const own = store.get(f.key);++ if (typeof own === 'string' && own) return { value: own, source: 'vault', env: f.env };++ const fallback = String(env[f.env] || '').trim();++ return { value: fallback, source: fallback ? 'env' : null, env: f.env };++ }++ return { store, token, FEEDS };++}++++module.exports = { create, SCHEMA, NAMESPACE, FEEDS };@@@ -4,6 +4,10 @@* the monthly budget it may recommend within. The env variables stay the pins (`x-env`): while one is set it wins,* is read-only in the summary and a write answers 409 config.pinned_in_env.*++ * The same admin routes also serve the stores passed as `also` (host.data_sources, server/data-sources-config.js),++ * and POST /internal/config/:namespace/test/:key, the Setup tab's Test button, answers for the keys a `test` hook++ * knows (the owner-setup design of 2026-11-07); it is mounted before the config routes.++ ** /internal/config is openvibe-shared/config's adminRoutes, guarded by host.config.manage (Network's service token* only). actor is the owner Network names in X-OV-Subject. budget_nano_usd is x-danger (a recommendation above it* needs the owner's budget), so changing it needs X-OV-Fresh: 1. Every activation writes one audit line with the@@@ -23,23 +27,27 @@ const AUDIENCE = 'openvibe.host';const SCHEMA = {$schema: 'https://json-schema.org/draft/2020-12/schema',title: 'Inference placement planner',-- description: 'The staff-only forecast and break-even page and JSON API. The planner recommends; it never starts, stops or rents a GPU.',++ description: 'A staff-only page that forecasts AI demand and says whether renting GPUs, buying them or staying on providers is cheaper. It only recommends; it never starts, stops or rents a GPU. Change it to switch the page on or to give it a budget.',type: 'object',additionalProperties: false,required: ['enabled', 'budget_nano_usd', 'source'],properties: {enabled: {-- type: 'boolean', title: 'Planner page and API', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER',-- description: 'Serve /catalog/inference and /api/v1/catalog/inference-planner to staff. Off adds no route.',++ type: 'boolean', title: 'Show the planner page', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER',++ description: 'When on, staff can open /catalog/inference and its JSON API. Off (the default) adds neither route, so the page answers Not found.',},budget_nano_usd: {-- type: ['integer', 'null'], minimum: 0, title: 'Monthly budget', 'x-group': 'Inference planner',-- 'x-env': 'HOST_INFERENCE_BUDGET_NANO_USD', 'x-unit': 'nano_usd', 'x-danger': true,-- description: 'Integer nano-USD a month. A recommendation at the forecast or the high band above it is marked "needs the owner\'s budget"; null means unset (0, nothing pre-approved).',++ type: ['integer', 'null'], minimum: 0, title: 'Monthly budget the planner may recommend within', 'x-group': 'Inference planner',++ 'x-env': 'HOST_INFERENCE_BUDGET_NANO_USD', 'x-unit': 'nano_usd', 'x-danger': true, 'x-example': 200000000000,++ description: 'The most a month the planner may recommend spending, in billionths of a dollar (200000000000 is $200). A plan above it is marked "needs the owner\'s budget". Empty (the default) approves nothing in advance.',},source: {-- type: 'string', enum: ['json', 'ai'], title: 'Demand source', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER_SOURCE',-- description: 'json reads the configured planning document; ai reads OpenVibe.AI\'s aggregate usage series and falls back only to a configured JSON input.',++ type: 'string', enum: ['json', 'ai'], title: 'Where the demand forecast comes from', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER_SOURCE',++ description: 'What the planner forecasts from. The planning document (the default) is a file staff write; AI\'s usage reads what OpenVibe.AI really served and falls back to the document only when one is set.',++ 'x-enum-labels': {++ json: { label: 'The planning document', description: 'A JSON file staff write with the expected demand; nothing is measured.' },++ ai: { label: 'AI\'s real usage', description: 'OpenVibe.AI\'s served requests per model, totalled; the document is used only when AI cannot be read.' },++ },},},};@@@ -80,7 +88,7 @@ function actor(req) {const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';--async function create({ db, config, env = process.env, log = console, onActivate = null }) {++async function create({ db, config, env = process.env, log = console, onActivate = null, also = [], test = null }) {// The shared store's boolean pin reads true/1; Host's env has always said "on". Normalise the pin value, not the// name: the summary still reports HOST_INFERENCE_PLANNER.const storeEnv = { ...env };@@@ -95,14 +103,52 @@ async function create({ db, config, env = process.env, log = console, onActivateif (env.OV_CONFIG_KEKS) {try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }}-- const router = shared.adminRoutes([store], {-- basePath: '/internal/config', requireAdmin: capabilityGuard({ config, log }), actor, requireFresh, log,++ const guard = capabilityGuard({ config, log });++ const router = shared.adminRoutes([store, ...also], {++ basePath: '/internal/config', requireAdmin: guard, actor, requireFresh, log,});return {store, router,get: (key) => store.get(key),-- mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },++ mount(app) {++ app.use('/internal/config', express.json({ limit: '128kb' }));++ if (test) app.post('/internal/config/:namespace/test/:key', guard, testRoute({ test, log }));++ router.mount(app);++ },++ };++}++++/** One line, at most 200 characters, with the secret replaced. */++function oneLine(text, secret) {++ let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();++ if (typeof secret === 'string' && secret.length >= 4) {++ s = s.split(secret).join('[hidden]');++ if (secret.length >= 12) s = s.split(secret.slice(0, 12)).join('[hidden]');++ }++ return s.length > 200 ? `${s.slice(0, 199)}…` : s;++}++++/**++ * The Setup tab's Test button: test(namespace, key) answers { value, source, env, run(value) } or null (no test).++ * 200 { ok, message, state, source, namespace, key, tested_at } (openvibe-shared 4.125.0's { ok, message } with more++ * beside it), 409 config.not_set when neither the panel nor the settings file has a value. Never the value itself.++ */++function testRoute({ test, log }) {++ return async function configTest(req, res) {++ const namespace = String(req.params.namespace || '');++ const key = String(req.params.key || '');++ let found = null;++ try { found = await test(namespace, key); } catch { found = null; }++ if (!found) return http.sendProblem(res, 404, 'config.test_not_found', { detail: `${namespace} has no test for ${key}`, ctx: req.ov });++ if (!found.value) return http.sendProblem(res, 409, 'config.not_set', { detail: `${key} is not set, so there is nothing to test`, ctx: req.ov });++ let out;++ try { out = await found.run(found.value); } catch (err) { out = { ok: false, message: `The test could not run: ${(err && err.message) || 'unknown error'}` }; }++ const ok = Boolean(out && out.ok);++ const from = found.source === 'env' && found.env ? ` (the value from the server's settings file, ${found.env})` : '';++ const message = oneLine(`${(out && out.message) || (ok ? 'It works.' : 'It did not work.')}${from}`, found.value);++ (log.info || log.log).call(log, `[config] test ${namespace} ${key}: ${ok ? 'connected' : 'not working'}`);++ return res.json({ ok, message, state: ok ? 'connected' : 'not_working', source: found.source || null, namespace, key, tested_at: new Date().toISOString() });};}--module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };++module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY, oneLine };@@@ -1,7 +1,7 @@'use strict';/** Background work for catalog ingestion, search indexing and the events outbox. */--function createWorker({ config, outbox, catalogIngest, catalogSearch, log = console }) {++function createWorker({ config, outbox, catalogIngest, catalogSearch, keyedFeeds = null, log = console }) {const timers = [];return {start() {@@@ -11,6 +11,12 @@ function createWorker({ config, outbox, catalogIngest, catalogSearch, log = consconst searchSweep = async () => { try { await catalogSearch.sweep(); } catch (err) { log.warn('[Host] catalog search sweep failed:', err.message); } };setTimeout(searchSweep, 60_000).unref();every(3600 * 1000, searchSweep);++ // DigitalOcean and Hetzner prices with the owner's read-only tokens (a feed without a token is skipped).++ if (keyedFeeds) {++ const feeds = async () => { try { await keyedFeeds.pull(); } catch (err) { log.warn('[Host] keyed price feeds failed:', err.message); } await searchSweep(); };++ every(6 * 3600 * 1000, feeds);++ setTimeout(feeds, 30_000).unref();++ }if (catalogIngest.enabled) {const pull = async () => { try { await catalogIngest.pull(); } catch (err) { log.warn('[Host] catalog pull failed:', err.message); } await searchSweep(); };every(5 * 60_000, pull);@@@ -39,7 +39,7 @@ async function boot(opts = {}) {async function start() {const config = configLib.load(env);-- built = await createApp({ config, env, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, fetchImpl: opts.fetchImpl, rdap: opts.rdap, accountSend: opts.accountSend });++ built = await createApp({ config, env, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, fetchImpl: opts.fetchImpl, rdap: opts.rdap, accountSend: opts.accountSend });server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });t.port = server.address().port;t.app = built.app;nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++/**++ * A local stand-in for openvibe-shared/config's lintSchema and its annotation checks (Shared 4.125.0, Shared PR #310,++ * not tagged when this was written): the owner-setup design's clarity rules, checked on a namespace's schema, so a++ * schema that passes here also passes when the pin moves to 4.125.0. Returns a list of problems ([] is clean).++ *++ * - every field has a title and a description of at least 30 characters;++ * - every enum option has words (x-enum-labels: { value: { label, description } });++ * - an x-owner-input names a Setup group, says what it unlocks, links an https page and says what to pick there;++ * - x-required-for, when present, names a feature.++ */++// openvibe-shared 4.125.0's OWNER_INPUT_GROUPS ids, and the members an x-owner-input may have.++const GROUPS = new Set(['money', 'ai-providers', 'gpus', 'voices', 'chat-apps', 'sign-in-apps', 'data-sources',++ 'design-publishing', 'domains', 'legal']);++const MEMBERS = new Set(['group', 'unlocks', 'get_it_at', 'get_it_how', 'test']);++++function lintField(name, f, out) {++ if (!f || typeof f !== 'object') return;++ if (typeof f.title !== 'string' || !f.title.trim()) out.push(`${name}: no title`);++ if (typeof f.description !== 'string' || f.description.trim().length < 30) out.push(`${name}: the description is missing or under 30 characters`);++ const options = Array.isArray(f.enum) ? f.enum : (f.items && Array.isArray(f.items.enum) ? f.items.enum : null);++ if (options) {++ const labels = f['x-enum-labels'] || (f.items && f.items['x-enum-labels']);++ for (const v of options) {++ const l = labels && labels[String(v)];++ if (!l || typeof l.label !== 'string' || !l.label.trim() || typeof l.description !== 'string' || !l.description.trim()) out.push(`${name}: the option ${JSON.stringify(v)} has no label and line`);++ }++ }++ const oi = f['x-owner-input'];++ if (oi !== undefined) {++ if (!oi || typeof oi !== 'object') out.push(`${name}: x-owner-input must be an object`);++ else {++ for (const m of Object.keys(oi)) if (!MEMBERS.has(m)) out.push(`${name}: x-owner-input has an unknown member ${m}`);++ if (!GROUPS.has(oi.group)) out.push(`${name}: x-owner-input.group ${JSON.stringify(oi.group)} is not a Setup group`);++ if (typeof oi.unlocks !== 'string' || oi.unlocks.trim().length < 20) out.push(`${name}: x-owner-input.unlocks must say what it turns on`);++ if (oi.get_it_at !== undefined && !/^https:\/\/[^\s]+$/.test(String(oi.get_it_at))) out.push(`${name}: x-owner-input.get_it_at must be an https URL`);++ if (typeof oi.get_it_how !== 'string' || oi.get_it_how.trim().length < 10) out.push(`${name}: x-owner-input.get_it_how must say what to pick there`);++ if (typeof oi.test !== 'boolean') out.push(`${name}: x-owner-input.test must be true or false`);++ }++ }++ if (f['x-example'] !== undefined && !['string', 'number', 'boolean'].includes(typeof f['x-example'])) out.push(`${name}: x-example must be a string, a number or true/false`);++ if (f['x-advanced'] !== undefined && typeof f['x-advanced'] !== 'boolean') out.push(`${name}: x-advanced must be true or false`);++ if (f['x-enum-labels'] && options) for (const v of Object.keys(f['x-enum-labels'])) if (!options.map((o) => (typeof o === 'string' ? o : JSON.stringify(o))).includes(v)) out.push(`${name}: x-enum-labels names ${v}, which is not an option`);++ if (f['x-required-for'] !== undefined && (typeof f['x-required-for'] !== 'string' || !f['x-required-for'].trim())) out.push(`${name}: x-required-for must name a feature`);++ if (f.properties && typeof f.properties === 'object') for (const [k, sub] of Object.entries(f.properties)) lintField(`${name}.${k}`, sub, out);++}++++function lintSchema(schema) {++ const out = [];++ if (!schema || typeof schema !== 'object') return ['no schema'];++ if (typeof schema.title !== 'string' || !schema.title.trim()) out.push('(namespace): no title');++ if (typeof schema.description !== 'string' || schema.description.trim().length < 30) out.push('(namespace): the description is missing or under 30 characters');++ for (const [k, f] of Object.entries(schema.properties || {})) lintField(k, f, out);++ return out;++}++++module.exports = { lintSchema, GROUPS };nnew file mode 100644@@@ -0,0 +1,160 @@++'use strict';++/**++ * Owner inputs (the owner-setup design of 2026-11-07): the hosting providers' read-only tokens in `host.data_sources`,++ * listed by the admin panel's Setup tab under Data sources.++ *++ * Proves: the fields carry x-owner-input, titles and 30+ character descriptions, and every field of both Host++ * namespaces does (the planner's choice has labelled options); the price feed uses the panel's sealed token when set++ * and the environment's otherwise, at use time, and brings DigitalOcean's and Hetzner's plans into the comparison;++ * the test hook answers in one line and never carries the token, not even when the provider echoes it; no log line++ * carries a token.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/boot');++const { lintSchema } = require('./helpers/lint-schema');++const { mapItem } = require('../server/catalog/mapping');++++const CAP = ['host.config.manage'];++const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const KEK = crypto.randomBytes(32).toString('base64');++const ENV_DO = 'dop_v1_madeupENVtokenFROMTHESETTINGSFILE0000000000000000001';++const PANEL_DO = 'dop_v1_madeupPANELtokenNEVERSHOWNANYWHERE00000000000000002';++const BAD_HZ = 'madeupHETZNERtokenTHEPROVIDERECHOESBACK000000000000000003';++const GOOD_HZ = 'madeupHETZNERtokenTHATWORKS0000000000000000000000000004';++const SECRETS = [ENV_DO, PANEL_DO, BAD_HZ, GOOD_HZ];++++const calls = [];++async function fakeFetch(url, init = {}) {++ const u = String(url);++ if (!u.startsWith('https://api.digitalocean.com/') && !u.startsWith('https://api.hetzner.cloud/')) return globalThis.fetch(url, init);++ const auth = String((init.headers && (init.headers.Authorization || init.headers.authorization)) || '');++ calls.push({ url: u, auth });++ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });++ if (u.startsWith('https://api.digitalocean.com/')) {++ if (auth !== `Bearer ${ENV_DO}` && auth !== `Bearer ${PANEL_DO}`) return json(401, { id: 'unauthorized', message: 'Unable to authenticate you' });++ if (u === 'https://api.digitalocean.com/v2/account') return json(200, { account: { status: 'active', email: '[email protected]' } });++ if (u.startsWith('https://api.digitalocean.com/v2/sizes')) {++ return json(200, { sizes: [++ { slug: 's-1vcpu-1gb', description: 'Basic', memory: 1024, vcpus: 1, disk: 25, transfer: 1, price_monthly: 6, price_hourly: 0.00893, regions: ['nyc1', 'sfo3'], available: true },++ { slug: 's-2vcpu-4gb-amd', description: 'Premium AMD', memory: 4096, vcpus: 2, disk: 80, transfer: 4, price_monthly: 28, price_hourly: 0.04167, regions: ['nyc3'], available: true },++ { slug: 'gone-1', description: 'Basic', memory: 512, vcpus: 1, disk: 10, transfer: 0.5, price_monthly: 4, price_hourly: 0.006, regions: [], available: false },++ ], links: {}, meta: { total: 3 } });++ }++ return json(404, { message: 'not here' });++ }++ if (auth !== `Bearer ${GOOD_HZ}`) return json(401, { error: { code: 'unauthorized', message: `invalid token ${auth.replace(/^Bearer /, '')}` } });++ return json(200, { server_types: [++ { name: 'cx22', description: 'CX22', cores: 2, memory: 4, disk: 40, cpu_type: 'shared', storage_type: 'local', architecture: 'x86', deprecated: false,++ prices: [{ location: 'fsn1', price_hourly: { net: '0.0060000000', gross: '0.0071' }, price_monthly: { net: '3.7900000000', gross: '4.51' }, included_traffic: 21990232555520 },++ { location: 'ash', price_hourly: { net: '0.0080000000', gross: '0.0080' }, price_monthly: { net: '4.9900000000', gross: '4.99' }, included_traffic: 1099511627776 }] },++ { name: 'cx11', description: 'CX11', cores: 1, memory: 2, disk: 20, cpu_type: 'shared', storage_type: 'local', deprecated: true, prices: [] },++ ], meta: { pagination: { page: 1, per_page: 50, next_page: null, total_entries: 2 } } });++}++++(async () => {++ const lines = [];++ const log = { log: (...a) => lines.push(a.join(' ')), info: (...a) => lines.push(a.join(' ')), warn: (...a) => lines.push(a.join(' ')), error: (...a) => lines.push(a.join(' ')) };++ const consoleLines = [];++ const original = { log: console.log, warn: console.warn, error: console.error, info: console.info };++ for (const k of Object.keys(original)) console[k] = (...a) => { consoleLines.push(a.join(' ')); original[k](...a); };++ const t = await boot({ env: { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1', HOST_DIGITALOCEAN_TOKEN: ENV_DO }, fetchImpl: fakeFetch, log });++ const admin = () => t.network.serviceToken('network', CAP);++ const cfg = (method, p, json) => t.api(method, p, { as: admin(), headers: { 'x-ov-subject': OWNER, 'x-ov-fresh': '1' }, ...(json ? { json } : {}) });++ const test = (key) => cfg('POST', `/internal/config/host.data_sources/test/${key}`);++ const ds = t.ctx ? t.ctx.dataSources : null;++ try {++ await check('every field of both Host namespaces has a title, a 30+ character description and labelled options', async () => {++ const r = await cfg('GET', '/internal/config');++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.deepStrictEqual(r.json().namespaces.map((n) => n.namespace), ['host.inference_planner', 'host.data_sources']);++ for (const ns of r.json().namespaces) assert.deepStrictEqual(lintSchema(ns.schema), [], `${ns.namespace}: ${lintSchema(ns.schema).join('; ')}`);++ const planner = r.json().namespaces[0].schema.properties.source['x-enum-labels'];++ assert.deepStrictEqual(Object.keys(planner), ['json', 'ai']);++ });++++ await check('the two tokens are owner inputs under Data sources, each required for its provider and tested', async () => {++ const p = (await cfg('GET', '/internal/config/host.data_sources')).json().schema.properties;++ assert.deepStrictEqual([p['digitalocean.token']['x-owner-input'].group, p['digitalocean.token']['x-owner-input'].test, p['digitalocean.token']['x-required-for']],++ ['data-sources', true, 'DigitalOcean in the hosting comparison']);++ assert.deepStrictEqual([p['hetzner.token']['x-owner-input'].group, p['hetzner.token']['x-owner-input'].test, p['hetzner.token']['x-required-for']],++ ['data-sources', true, 'Hetzner in the hosting comparison']);++ assert.strictEqual(p['digitalocean.token']['x-owner-input'].get_it_at, 'https://cloud.digitalocean.com/account/api/tokens');++ });++++ await check('without a panel token the settings file is used and tested; without either, 409 config.not_set', async () => {++ const r = await test('digitalocean.token');++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.deepStrictEqual([r.json().ok, r.json().state, r.json().source], [true, 'connected', 'env']);++ assert.strictEqual(r.json().message, "DigitalOcean accepted the token (the account is active); its Droplet prices can be read. (the value from the server's settings file, HOST_DIGITALOCEAN_TOKEN)");++ const none = await test('hetzner.token');++ assert.deepStrictEqual([none.status, none.json().code], [409, 'config.not_set']);++ });++++ await check('a panel token is sealed, write-only and wins at use time; the feed brings the plans in', async () => {++ const put = await cfg('PUT', '/internal/config/host.data_sources/secrets/digitalocean.token', { value: PANEL_DO, reason: 'setup' });++ assert.strictEqual(put.status, 200, put.text.slice(0, 300));++ assert.ok(!put.text.includes(PANEL_DO));++ const carried = await cfg('POST', '/internal/config/host.data_sources', { values: { 'hetzner.token': GOOD_HZ }, reason: 'not here' });++ assert.deepStrictEqual([carried.status, carried.json().code], [422, 'config.secret_write_only']);++ const r = await test('digitalocean.token');++ assert.deepStrictEqual([r.json().ok, r.json().source], [true, 'vault']);++ const put2 = await cfg('PUT', '/internal/config/host.data_sources/secrets/hetzner.token', { value: GOOD_HZ, reason: 'setup' });++ assert.strictEqual(put2.status, 200, put2.text.slice(0, 300));++ const before = calls.length;++ const report = await t.ctx.keyedFeeds.pull();++ assert.deepStrictEqual(report, { digitalocean: { applied: 2, held: 0 }, hetzner: { applied: 1, held: 0 } });++ assert.ok(calls.slice(before).filter((c) => c.url.includes('digitalocean')).every((c) => c.auth === `Bearer ${PANEL_DO}`), 'the feed used the panel token');++ const dO = (await t.ctx.catalog.plans({ provider: 'digitalocean' })).plans;++ assert.deepStrictEqual(dO.map((p) => p.name).sort(), ['Basic · 1 vCPU, 1 GB', 'Premium AMD · 2 vCPU, 4 GB']);++ const hz = (await t.ctx.catalog.plans({ provider: 'hetzner', currency: 'EUR' })).plans;++ assert.deepStrictEqual([hz.length, hz[0].currency, Number(hz[0].price_monthly), hz[0].locations], [1, 'EUR', 3.79, ['fsn1', 'ash']]);++ const row = await t.ctx.store.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'host.data_sources' AND state = 'active'").get();++ assert.ok(!row.values_json.includes(PANEL_DO) && !row.values_json.includes(GOOD_HZ), 'sealed at rest');++ });++++ await check('the mappers keep the providers\' own numbers', async () => {++ const it = (key, id, fields, title = id) => ({ id: `host:${key}:${id}`, source_key: key, category: 'hosting', identity: id, title, fields, provenance: { retrieved_at: '2026-11-07T00:00:00Z', first_seen_at: '2026-11-07T00:00:00Z' } });++ const d = mapItem(it('digitalocean-sizes', 's-1vcpu-1gb', { description: 'Basic', memory: 1024, vcpus: 1, disk: 25, transfer: 1, price_monthly: 6, price_hourly: 0.00893, regions: ['nyc1'] }));++ assert.deepStrictEqual([d.provider.slug, d.ram_mb, d.disk_gb, d.disk_type, d.bandwidth_gb, d.price_monthly, d.currency], ['digitalocean', 1024, 25, 'ssd', 1000, 6, 'USD']);++ assert.throws(() => mapItem(it('hetzner-server-types', 'x', { prices: [] })), /missing monthly price/);++ });++++ await check('a refused token is Not working in one line, and the echoed token never comes back', async () => {++ await cfg('PUT', '/internal/config/host.data_sources/secrets/hetzner.token', { value: BAD_HZ, reason: 'wrong one' });++ const r = await test('hetzner.token');++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.deepStrictEqual([r.json().ok, r.json().state], [false, 'not_working']);++ assert.match(r.json().message, /^Hetzner Cloud refused the token \(401\): invalid token \[hidden\]/);++ assert.ok(!r.text.includes(BAD_HZ) && !r.text.includes(BAD_HZ.slice(0, 12)));++ const report = await t.ctx.keyedFeeds.pull();++ assert.match(report.hetzner.error, /^Hetzner Cloud refused the token \(401\)/);++ assert.ok(!JSON.stringify(report).includes(BAD_HZ), 'the feed\'s report never carries the token');++ });++++ await check('removing the panel token falls back to the settings file', async () => {++ const del = await cfg('DELETE', '/internal/config/host.data_sources/secrets/digitalocean.token', { reason: 'back to env' });++ assert.strictEqual(del.status, 200, del.text.slice(0, 300));++ assert.deepStrictEqual(t.ctx.dataSources.token('digitalocean'), { value: ENV_DO, source: 'env', env: 'HOST_DIGITALOCEAN_TOKEN' });++ });++++ await check('the test hook is guarded and answers only for keys with a test', async () => {++ const denied = await t.api('POST', '/internal/config/host.data_sources/test/hetzner.token', { as: t.network.serviceToken('other', ['host.catalog.read']) });++ assert.strictEqual(denied.status, 403);++ const none = await cfg('POST', '/internal/config/host.inference_planner/test/enabled');++ assert.deepStrictEqual([none.status, none.json().code], [404, 'config.test_not_found']);++ });++++ await check('no log line or console line carries a token', async () => {++ for (const k of Object.keys(original)) console[k] = original[k];++ for (const s of SECRETS) {++ assert.ok(lines.every((l) => !l.includes(s)), `a log line carries ${s.slice(0, 14)}…`);++ assert.ok(consoleLines.every((l) => !l.includes(s)), `a console line carries ${s.slice(0, 14)}…`);++ }++ assert.ok(lines.some((l) => /\[config\] test host\.data_sources hetzner\.token: not working/.test(l)), 'the outcome is logged without the token');++ assert.ok(lines.some((l) => /\[Host\] hetzner prices: Hetzner Cloud refused the token \(401\): invalid token \[hidden\]/.test(l)));++ });++ void ds;++ } finally { await t.close(); }++ done();++})().catch((err) => { console.error(err); process.exit(1); });
@@ -28,6 +28,10 @@ HOST_FORM_SECRET=
@@ -28,6 +28,10 @@ HOST_FORM_SECRET=
#HOST_WORKER_ID=
#HOST_WORKER_ID=
HOST_CATALOG_INGEST=off
HOST_CATALOG_INGEST=off
HOST_CATALOG_SOURCES_URL=
HOST_CATALOG_SOURCES_URL=
+# Read-only provider tokens for DigitalOcean's and Hetzner Cloud's prices (server/catalog/keyed-feeds.js). The owner
+# can enter them in the admin panel's Setup tab instead (host.data_sources, sealed); a token entered there wins.
+#HOST_DIGITALOCEAN_TOKEN=
+#HOST_HETZNER_TOKEN=
HOST_CATALOG_WEBHOOK_SECRETS=
HOST_CATALOG_WEBHOOK_SECRETS=
HOST_COMMUNITY_URL=
HOST_COMMUNITY_URL=
OV_COMMUNITY_URL=https://openvibe.community
OV_COMMUNITY_URL=https://openvibe.community
@@ -25,6 +25,12 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
@@ -25,6 +25,12 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
addons apart from the plan, so the Sources mapping joins each default addon to its own product; Host multiplies the
addons apart from the plan, so the Sources mapping joins each default addon to its own product; Host multiplies the
socket count by the cores and one disk's size by the disk count. Vultr's API host disallows robots, so that
socket count by the cores and one disk's size by the disk count. Vultr's API host disallows robots, so that
Sources seed stays disabled.
Sources seed stays disabled.
+ - DigitalOcean's Droplet sizes and Hetzner Cloud's server types, which their APIs show only to a signed-in account:
+ Host reads them itself every six hours (`server/catalog/keyed-feeds.js`) with the owner's read-only token from the
+ admin panel's Setup tab (`host.data_sources`, sealed; `HOST_DIGITALOCEAN_TOKEN` / `HOST_HETZNER_TOKEN` are the
+ fallback), through the same mapping (`digitalocean-sizes`, `hetzner-server-types`, prices in euros as Hetzner
+ states them). Without a token that provider is left out. `POST /internal/config/host.data_sources/test/:key` is the
+ Setup tab's Test button: one read-only call, one line back, never the token.
- Staff entries, which must name their source URL.
- Staff entries, which must name their source URL.
- Nothing is estimated. A value the source does not state is shown as Unknown.
- Nothing is estimated. A value the source does not state is shown as Unknown.
- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.
- **Offers** (`/catalog/offers`, `GET /api/v1/catalog/offers`, a block on the front page). What providers post on LowEndSpirit's Offers board, from its public feed through Sources (`lowendspirit-offers`; `server/catalog/offers.js`). LowEndTalk's and LowEndBox's feeds (`lowendtalk-offers`, `lowendbox-posts`) answer 403 to the production host's network (2026-10-18), so those sources are off. Host keeps the headline, poster, date and link, and the cheapest "per month" or "per year" price the headline (or the post's opening) states, as stated, with its monthly equivalent (a yearly price divided by 12). LowEndBox editorials and giveaways are not offers; a post the source removes is hidden; offers older than 120 days drop off. Newest first, or cheapest a month within one currency (`sort=price` needs `currency`: nothing is converted); filters `currency`, `max_monthly`, `source`, `priced=1`.
@@ -383,7 +389,7 @@ the owner's gates (go-ahead, a DMCA agent, a Public Suffix List entry for openvi
@@ -383,7 +389,7 @@ the owner's gates (go-ahead, a DMCA agent, a Public Suffix List entry for openvi
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.289.1
+- openvibe-contracts: v0.311.0
- openvibe-sdk: v0.93.0
- openvibe-sdk: v0.93.0
- openvibe-shared: v4.124.0
- openvibe-shared: v4.124.0
- openvibe-publishing: v1.4.0
- openvibe-publishing: v1.4.0
@@ -13,11 +13,11 @@
@@ -13,11 +13,11 @@
"runtime": "node 22; Express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + optional Valkey",
"runtime": "node 22; Express 4 + PostgreSQL 18 (openvibe-sdk/db, async) + optional Valkey",
"port": 4910,
"port": 4910,
"domain": "openvibe.host",
"domain": "openvibe.host",
- "contracts": "openvibe-contracts v0.289.1",
+ "contracts": "openvibe-contracts v0.311.0",
"sdk": "openvibe-sdk v0.93.0",
"sdk": "openvibe-sdk v0.93.0",
"shared": "openvibe-shared v4.124.0",
"shared": "openvibe-shared v4.124.0",
"packages": [
"packages": [
- "openvibe-contracts v0.289.1",
+ "openvibe-contracts v0.311.0",
"openvibe-sdk v0.93.0",
"openvibe-sdk v0.93.0",
"openvibe-shared v4.124.0"
"openvibe-shared v4.124.0"
],
],
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.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",
@@ -1534,9 +1534,9 @@
@@ -1534,9 +1534,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",
@@ -31,7 +31,7 @@
@@ -31,7 +31,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.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",
@@ -18,6 +18,8 @@ const { createHostOutbox } = require('./events/outbox');
@@ -18,6 +18,8 @@ const { createHostOutbox } = require('./events/outbox');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
const createInferencePlannerConfig = require('./inference-planner-config').create;
const createInferencePlannerConfig = require('./inference-planner-config').create;
+const createDataSourcesConfig = require('./data-sources-config').create;
+const { createKeyedFeeds } = require('./catalog/keyed-feeds');
const { createHostSession } = require('./auth/sso');
const { createHostSession } = require('./auth/sso');
const { createHostReadiness } = require('./observability');
const { createHostReadiness } = require('./observability');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
@@ -53,7 +55,19 @@ async function createApp(opts = {}) {
@@ -53,7 +55,19 @@ async function createApp(opts = {}) {
const log = opts.log || console;
const log = opts.log || console;
const store = opts.store || await openStore(config, { now: opts.now, log });
const store = opts.store || await openStore(config, { now: opts.now, log });
// D11 owner settings (host.inference_planner): the planner's switches read through the store, env pins winning.
// D11 owner settings (host.inference_planner): the planner's switches read through the store, env pins winning.
- const plannerConfig = await createInferencePlannerConfig({ db: store.db, config, env: opts.env || process.env, log });
+ // host.data_sources (the owner-setup design): the hosting providers' read-only tokens the price feed reads at use
+ // time, served by the same /internal/config routes, with a Test button per token.
+ const dataSources = await createDataSourcesConfig({ db: store.db, env: opts.env || process.env, log });
+ let keyedFeeds = null;
+ const plannerConfig = await createInferencePlannerConfig({
+ db: store.db, config, env: opts.env || process.env, log, also: [dataSources.store],
+ test: (namespace, key) => {
+ if (namespace !== dataSources.store.namespace) return null;
+ const feed = Object.keys(dataSources.FEEDS).find((f) => dataSources.FEEDS[f].key === key);
+ if (!feed || !keyedFeeds) return null;
+ return { ...dataSources.token(feed), run: (value) => keyedFeeds.test(feed, value) };
+ },
+ });
Object.defineProperty(config.inferencePlanner, 'enabled', { enumerable: true, configurable: true, get: () => plannerConfig.get('enabled') });
Object.defineProperty(config.inferencePlanner, 'enabled', { enumerable: true, configurable: true, get: () => plannerConfig.get('enabled') });
Object.defineProperty(config.inferencePlanner, 'source', { enumerable: true, configurable: true, get: () => plannerConfig.get('source') });
Object.defineProperty(config.inferencePlanner, 'source', { enumerable: true, configurable: true, get: () => plannerConfig.get('source') });
Object.defineProperty(config.inferencePlanner, 'budgetNanoUsdRaw', { enumerable: true, configurable: true, get: () => { const v = plannerConfig.get('budget_nano_usd'); return v == null ? '' : String(v); } });
Object.defineProperty(config.inferencePlanner, 'budgetNanoUsdRaw', { enumerable: true, configurable: true, get: () => { const v = plannerConfig.get('budget_nano_usd'); return v == null ? '' : String(v); } });
@@ -80,14 +94,15 @@ async function createApp(opts = {}) {
@@ -80,14 +94,15 @@ async function createApp(opts = {}) {
})();
})();
const catalog = createCatalog({ store, community, log, indexnow, baseUrl: config.baseUrl });
const catalog = createCatalog({ store, community, log, indexnow, baseUrl: config.baseUrl });
const catalogIngest = createCatalogIngest({ config, store, catalog, fetchImpl: opts.fetchImpl });
const catalogIngest = createCatalogIngest({ config, store, catalog, fetchImpl: opts.fetchImpl });
+ keyedFeeds = createKeyedFeeds({ catalog, dataSources, fetchImpl: opts.fetchImpl || globalThis.fetch, now: store.now, log });
const domainCheck = createDomainCheck({ catalog, rdap: opts.rdap || createRdap({ log }) });
const domainCheck = createDomainCheck({ catalog, rdap: opts.rdap || createRdap({ log }) });
const benchmarks = createBenchmarks({ store, baseUrl: config.baseUrl });
const benchmarks = createBenchmarks({ store, baseUrl: config.baseUrl });
const catalogSearch = createCatalogSearch({ config, store, outbox, log });
const catalogSearch = createCatalogSearch({ config, store, outbox, log });
// The public pages as data, shared by the routes and the agent-layer twins (server/agent-pages.js).
// The public pages as data, shared by the routes and the agent-layer twins (server/agent-pages.js).
const views = require('./http/catalog-views').createCatalogViews({ catalog, benchmarks, community, config, log });
const views = require('./http/catalog-views').createCatalogViews({ catalog, benchmarks, community, config, log });
catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });
- const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });
- const ctx = { config, store, plannerConfig, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log, views };
+ const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, keyedFeeds, log });
+ const ctx = { config, store, plannerConfig, dataSources, keyedFeeds, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log, views };
ctx.console = require('./console/console').createConsole({ store, config, catalog, fetchImpl: opts.fetchImpl, log });
ctx.console = require('./console/console').createConsole({ store, config, catalog, fetchImpl: opts.fetchImpl, log });
const app = express();
const app = express();
plannerConfig.mount(app);
plannerConfig.mount(app);
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+/**
+ * The hosting providers whose prices need an account (the owner-setup design of 2026-11-07): DigitalOcean's Droplet
+ * sizes and Hetzner Cloud's server types, read with the owner's read-only token from `host.data_sources` (the panel's
+ * value, else HOST_DIGITALOCEAN_TOKEN / HOST_HETZNER_TOKEN), at use time. Without a token a feed is skipped and says so.
+ *
+ * Each plan becomes an item in the shape OpenVibe.Sources stores (source_key, identity, title, fields, provenance), so
+ * it goes through the same mapping (server/catalog/mapping.js) and catalog.applyItem as every other price: a changed
+ * price is recorded, a plan's first-seen time is kept. Nothing here is written but catalog rows; the token goes in the
+ * Authorization header only and never into a log line, an error or an answer.
+ *
+ * test(feed, token) is the Setup tab's Test button: one read-only call (DigitalOcean's account, Hetzner's first server
+ * type), answered in one line.
+ */
+const TIMEOUT_MS = 15_000;
+const API = Object.freeze({ digitalocean: 'https://api.digitalocean.com', hetzner: 'https://api.hetzner.cloud' });
+
+async function getJson(fetchImpl, url, token) {
+ let res;
+ try {
+ res = await fetchImpl(url, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
+ } catch (err) {
+ return { status: 0, json: null, error: err && err.name === 'TimeoutError' ? 'did not answer within 15 seconds' : 'could not be reached' };
+ }
+ const json = await res.json().catch(() => null);
+ return { status: res.status, json, error: null };
+}
+
+function refusal(name, r) {
+ if (r.error) return `${name} ${r.error}.`;
+ const says = r.json && (r.json.message || (r.json.error && r.json.error.message)) ? String(r.json.message || r.json.error.message) : '';
+ if (r.status === 401 || r.status === 403) return `${name} refused the token (${r.status})${says ? `: ${says}` : '.'}`;
+ if (r.status === 429) return `${name} is limiting requests right now (429); try again later.`;
+ return `${name} answered ${r.status}${says ? `: ${says}` : '.'}`;
+}
+
+/** A plan as the Sources `api` adapter would have stored it. */
+function item(sourceKey, identity, title, fields, at) {
+ return {
+ id: `host:${sourceKey}:${identity}`, source_key: sourceKey, category: 'hosting', kind: 'plan', identity, canonical_url: null,
+ title, summary: null, authors: [], published_at: null, source_updated_at: null, fields, revision: 1,
+ provenance: { retrieved_at: at, first_seen_at: at, content_hash: null, raw_body_hash: null, parser_version: 'host-keyed@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null },
+ removed: null,
+ };
+}
+
+/** DigitalOcean: every available Droplet size (GET /v2/sizes, 200 a page). */
+async function digitalOceanItems(fetchImpl, token, at) {
+ const out = [];
+ let url = `${API.digitalocean}/v2/sizes?per_page=200`;
+ for (let page = 0; url && page < 10; page++) {
+ const r = await getJson(fetchImpl, url, token);
+ if (!(r.status >= 200 && r.status < 300)) throw new Error(refusal('DigitalOcean', r));
+ for (const s of (r.json && Array.isArray(r.json.sizes) ? r.json.sizes : [])) {
+ if (!s || !s.slug || s.available === false) continue;
+ out.push(item('digitalocean-sizes', String(s.slug), String(s.slug), {
+ description: s.description || null, vcpus: s.vcpus, memory: s.memory, disk: s.disk, transfer: s.transfer,
+ price_monthly: s.price_monthly, price_hourly: s.price_hourly, regions: Array.isArray(s.regions) ? s.regions : [],
+ }, at));
+ }
+ const next = r.json && r.json.links && r.json.links.pages && r.json.links.pages.next;
+ url = typeof next === 'string' && next.startsWith(`${API.digitalocean}/`) ? next : null;
+ }
+ return out;
+}
+
+/** Hetzner Cloud: every server type that is not deprecated (GET /v1/server_types), its cheapest location's price. */
+async function hetznerItems(fetchImpl, token, at) {
+ const out = [];
+ for (let page = 1; page <= 10; page++) {
+ const r = await getJson(fetchImpl, `${API.hetzner}/v1/server_types?per_page=50&page=${page}`, token);
+ if (!(r.status >= 200 && r.status < 300)) throw new Error(refusal('Hetzner Cloud', r));
+ const list = r.json && Array.isArray(r.json.server_types) ? r.json.server_types : [];
+ for (const s of list) {
+ if (!s || !s.name || s.deprecated || (s.deprecation && s.deprecation.unavailable_after)) continue;
+ out.push(item('hetzner-server-types', String(s.name), String(s.name).toUpperCase(), {
+ description: s.description || null, cores: s.cores, memory: s.memory, disk: s.disk, cpu_type: s.cpu_type || null,
+ storage_type: s.storage_type || null, architecture: s.architecture || null, prices: Array.isArray(s.prices) ? s.prices : [],
+ }, at));
+ }
+ const next = r.json && r.json.meta && r.json.meta.pagination && r.json.meta.pagination.next_page;
+ if (!next) break;
+ }
+ return out;
+}
+
+function createKeyedFeeds({ catalog, dataSources, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const read = { digitalocean: digitalOceanItems, hetzner: hetznerItems };
+
+ /** Read every feed that has a token. Answers { [feed]: { skipped } | { applied, held } | { error } }. */
+ async function pull() {
+ const report = {};
+ for (const feed of Object.keys(read)) {
+ const t = dataSources.token(feed);
+ if (!t.value) { report[feed] = { skipped: 'no token' }; continue; }
+ try {
+ const items = await read[feed](fetchImpl, t.value, new Date(now()).toISOString());
+ let applied = 0;
+ let held = 0;
+ for (const it of items) {
+ const r = await catalog.applyItem(it).catch(() => 'hold');
+ if (r === 'hold') held++; else applied++;
+ }
+ report[feed] = { applied, held };
+ } catch (err) {
+ const message = String((err && err.message) || 'failed').split(t.value).join('[hidden]').slice(0, 200);
+ report[feed] = { error: message };
+ log.warn(`[Host] ${feed} prices: ${message}`);
+ }
+ }
+ return report;
+ }
+
+ /** The Setup tab's Test: one read-only call with the token. */
+ async function test(feed, token) {
+ if (feed === 'digitalocean') {
+ const r = await getJson(fetchImpl, `${API.digitalocean}/v2/account`, token);
+ if (!(r.status >= 200 && r.status < 300)) return { ok: false, message: refusal('DigitalOcean', r) };
+ const status = r.json && r.json.account && r.json.account.status;
+ return { ok: true, message: `DigitalOcean accepted the token${status ? ` (the account is ${status})` : ''}; its Droplet prices can be read.` };
+ }
+ if (feed === 'hetzner') {
+ const r = await getJson(fetchImpl, `${API.hetzner}/v1/server_types?per_page=1`, token);
+ if (!(r.status >= 200 && r.status < 300)) return { ok: false, message: refusal('Hetzner Cloud', r) };
+ const total = r.json && r.json.meta && r.json.meta.pagination && r.json.meta.pagination.total_entries;
+ return { ok: true, message: `Hetzner Cloud accepted the token${Number.isFinite(Number(total)) ? ` and lists ${Number(total)} server types` : ''}.` };
+ }
+ return null;
+ }
+
+ return { pull, test, feeds: Object.keys(read) };
+}
+
+module.exports = { createKeyedFeeds, digitalOceanItems, hetznerItems, API };
@@ -9,6 +9,9 @@ const SOURCES = Object.freeze({
@@ -9,6 +9,9 @@ const SOURCES = Object.freeze({
'ovhcloud-vps': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/vps/' },
'ovhcloud-vps': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/vps/' },
'ovhcloud-baremetal': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/bare-metal/prices/' },
'ovhcloud-baremetal': { slug: 'ovhcloud', name: 'OVHcloud', homepage_url: 'https://us.ovhcloud.com/', source_url: 'https://us.ovhcloud.com/bare-metal/prices/' },
'azure-retail-prices': { slug: 'azure', name: 'Microsoft Azure', homepage_url: 'https://azure.microsoft.com/', source_url: 'https://azure.microsoft.com/en-us/pricing/' },
'azure-retail-prices': { slug: 'azure', name: 'Microsoft Azure', homepage_url: 'https://azure.microsoft.com/', source_url: 'https://azure.microsoft.com/en-us/pricing/' },
+ // Read by Host itself with the owner's read-only token (server/catalog/keyed-feeds.js), not through Sources.
+ 'digitalocean-sizes': { slug: 'digitalocean', name: 'DigitalOcean', homepage_url: 'https://www.digitalocean.com/', source_url: 'https://www.digitalocean.com/pricing/droplets' },
+ 'hetzner-server-types': { slug: 'hetzner', name: 'Hetzner', homepage_url: 'https://www.hetzner.com/', source_url: 'https://www.hetzner.com/cloud/' },
});
});
const number = (v, field) => {
const number = (v, field) => {
if (v == null) return null;
if (v == null) return null;
@@ -144,6 +147,48 @@ function azure(x, id, title) {
@@ -144,6 +147,48 @@ function azure(x, id, title) {
}
}
throw new TypeError('unknown Azure price unit');
throw new TypeError('unknown Azure price unit');
}
}
+// DigitalOcean's Droplet sizes (GET /v2/sizes): memory in MB, disk in GB, transfer in TB, prices in USD. Named as its
+// pricing page names them ("Basic · 1 vCPU, 1 GB"); a premium size (-amd/-intel) has NVMe disks, the rest SSD.
+function digitalOcean(x, id) {
+ const monthly = number(x.price_monthly, 'monthly price');
+ if (monthly == null) throw new TypeError('missing monthly price');
+ if (monthly <= 0) return null;
+ const vcpu = number(x.vcpus, 'vcpu');
+ const ram = number(x.memory, 'memory');
+ const memory = ram == null ? null : ram >= 1024 ? `${Math.round((ram / 1024) * 10) / 10} GB` : `${ram} MB`;
+ const family = typeof x.description === 'string' && x.description.trim() ? x.description.trim() : null;
+ const transfer = number(x.transfer, 'transfer');
+ return {
+ name: family && vcpu != null && memory ? `${family} · ${vcpu} vCPU, ${memory}` : id, kind: 'cloud',
+ vcpu, ram_mb: ram, disk_gb: number(x.disk, 'disk'), disk_type: /-(amd|intel)$/.test(id) ? 'nvme' : 'ssd',
+ bandwidth_gb: transfer == null ? null : Math.round(transfer * 1000), unmetered: null, port_mbps: null,
+ price_monthly: monthly, price_hourly: number(x.price_hourly, 'hourly price'), ipv4: 1,
+ locations: Array.isArray(x.regions) && x.regions.length ? x.regions : null,
+ };
+}
+
+// Hetzner Cloud's server types (GET /v1/server_types): memory in GB, disk in GB, and a price per location in euros
+// (net, before VAT, as text). The plan's price is its cheapest location's; every location it is sold in is listed.
+function hetzner(x, id, title) {
+ const prices = (Array.isArray(x.prices) ? x.prices : []).map((p) => ({
+ location: p && p.location, monthly: Number(p && p.price_monthly && p.price_monthly.net), hourly: Number(p && p.price_hourly && p.price_hourly.net),
+ traffic: Number(p && p.included_traffic),
+ })).filter((p) => typeof p.location === 'string' && Number.isFinite(p.monthly) && p.monthly > 0);
+ if (!prices.length) throw new TypeError('missing monthly price');
+ const cheapest = prices.reduce((a, b) => (b.monthly < a.monthly ? b : a));
+ const memoryGb = number(x.memory, 'memory');
+ const cores = number(x.cores, 'cores');
+ const name = (typeof title === 'string' && title.trim()) || id;
+ return {
+ name: cores != null && memoryGb != null ? `${name} · ${cores} vCPU${x.cpu_type === 'dedicated' ? ' (dedicated)' : ''}, ${memoryGb} GB` : name,
+ kind: 'cloud', vcpu: cores, ram_mb: memoryGb == null ? null : Math.round(memoryGb * 1024), disk_gb: number(x.disk, 'disk'),
+ disk_type: x.storage_type === 'local' ? 'nvme' : null,
+ bandwidth_gb: Number.isFinite(cheapest.traffic) && cheapest.traffic > 0 ? Math.round(cheapest.traffic / 1e9) : null, unmetered: null, port_mbps: null,
+ price_monthly: Math.round(cheapest.monthly * 100) / 100, price_hourly: Number.isFinite(cheapest.hourly) ? cheapest.hourly : null, ipv4: null,
+ locations: [...new Set(prices.map((p) => p.location))],
+ };
+}
+
function mapItem(item) {
function mapItem(item) {
const provider = SOURCES[item && item.source_key];
const provider = SOURCES[item && item.source_key];
if (!provider) return null;
if (!provider) return null;
@@ -170,6 +215,14 @@ function mapItem(item) {
@@ -170,6 +215,14 @@ function mapItem(item) {
const region = typeof x.region === 'string' && x.region ? x.region : null;
const region = typeof x.region === 'string' && x.region ? x.region : null;
return { ...common, ...plan, external_id: region ? `${id}@${region}` : id, checked_at: observed };
return { ...common, ...plan, external_id: region ? `${id}@${region}` : id, checked_at: observed };
}
}
+ if (item.source_key === 'digitalocean-sizes') {
+ const plan = digitalOcean(x, id);
+ return plan && { ...common, ...plan };
+ }
+ if (item.source_key === 'hetzner-server-types') {
+ const plan = hetzner(x, id, item.title);
+ return plan && { ...common, ...plan, currency: 'EUR' };
+ }
if (item.source_key === 'ovhcloud-vps') {
if (item.source_key === 'ovhcloud-vps') {
const plan = ovhcloud(x, id, item.title);
const plan = ovhcloud(x, id, item.title);
return plan && { ...common, ...plan };
return plan && { ...common, ...plan };
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+/**
+ * The `host.data_sources` namespace (the owner-setup design of 2026-11-07): the hosting providers' API tokens the
+ * comparison needs to read their prices. DigitalOcean and Hetzner Cloud publish their plans and prices only to a
+ * signed-in account (a read-only token is enough); Vultr, Linode, AWS Lightsail, OVHcloud and Azure need none and
+ * come through OpenVibe.Sources.
+ *
+ * The tokens are secret class: write-only through /internal/config/host.data_sources/secrets/:key, sealed with the
+ * service's KEK, shown only as their last four characters. Each carries x-owner-input (group data-sources), so the
+ * admin panel's Setup tab lists it with where to get it and a Test button. No x-env pin: the token entered in the
+ * panel wins, and HOST_DIGITALOCEAN_TOKEN / HOST_HETZNER_TOKEN from the environment stay the fallback, read at use
+ * time (token() below) by the price feed (server/catalog/keyed-feeds.js).
+ */
+const shared = require('openvibe-shared/config');
+
+const SERVICE = 'host';
+const NAMESPACE = 'host.data_sources';
+const GROUP = 'Hosting prices';
+
+const FEEDS = Object.freeze({
+ digitalocean: { key: 'digitalocean.token', env: 'HOST_DIGITALOCEAN_TOKEN', name: 'DigitalOcean' },
+ hetzner: { key: 'hetzner.token', env: 'HOST_HETZNER_TOKEN', name: 'Hetzner Cloud' },
+});
+
+const SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Hosting price sources',
+ description: 'The API tokens Host reads hosting providers\' plans and prices with, for the comparison on openvibe.host. Add one to bring that provider\'s plans into the comparison; remove it to stop reading them.',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ 'digitalocean.token': {
+ type: 'string', minLength: 16, maxLength: 512,
+ title: 'DigitalOcean API token (read only)', 'x-group': GROUP, 'x-example': 'dop_v1_…',
+ description: 'A read-only DigitalOcean token Host uses to read the Droplet plans and their prices every six hours. Without it DigitalOcean is missing from the comparison. Read access cannot create or change anything.',
+ 'x-owner-input': {
+ group: 'data-sources',
+ unlocks: 'Adds DigitalOcean\'s Droplet plans and prices to the hosting comparison, refreshed every six hours. Without it DigitalOcean stays out of the comparison.',
+ get_it_at: 'https://cloud.digitalocean.com/account/api/tokens',
+ get_it_how: 'Generate a new personal access token with the Read scope only, and no expiry or a long one.',
+ test: true,
+ },
+ 'x-required-for': 'DigitalOcean in the hosting comparison',
+ },
+ 'hetzner.token': {
+ type: 'string', minLength: 16, maxLength: 512,
+ title: 'Hetzner Cloud API token (read only)', 'x-group': GROUP, 'x-example': 'a1B2c3…',
+ description: 'A read-only Hetzner Cloud token Host uses to read the server types and their prices every six hours. Without it Hetzner is missing from the comparison. Prices are in euros, as Hetzner states them.',
+ 'x-owner-input': {
+ group: 'data-sources',
+ unlocks: 'Adds Hetzner Cloud\'s server plans and prices to the hosting comparison, refreshed every six hours. Without it Hetzner stays out of the comparison.',
+ get_it_at: 'https://console.hetzner.cloud/projects',
+ get_it_how: 'Open any project (an empty one is fine), then Security, API tokens, and generate a token with Read permission.',
+ test: true,
+ },
+ 'x-required-for': 'Hetzner in the hosting comparison',
+ },
+ },
+};
+
+const classify = (key) => (Object.values(FEEDS).some((f) => f.key === key) ? 'secret' : 'internal');
+
+/** Create the store; reseal old rows once when a KEK is configured. */
+async function create({ db, env = process.env, log = console }) {
+ const store = await shared.createConfigStore({
+ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA, env, classify, log,
+ onChange: ({ namespace, keys, actor: who, revision }) => {
+ (log.info || log.log).call(log, `[config] ${SERVICE} ${namespace} revision ${revision} by ${who && who.id ? `${who.type}:${who.id}` : 'unknown'}: ${keys.join(', ')}`);
+ },
+ });
+ if (env.OV_CONFIG_KEKS) {
+ try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
+ }
+ /** A feed's token at use time: { value, source: vault | env | null, env }. The panel's value wins. */
+ function token(feed) {
+ const f = FEEDS[feed];
+ if (!f) return { value: '', source: null, env: null };
+ const own = store.get(f.key);
+ if (typeof own === 'string' && own) return { value: own, source: 'vault', env: f.env };
+ const fallback = String(env[f.env] || '').trim();
+ return { value: fallback, source: fallback ? 'env' : null, env: f.env };
+ }
+ return { store, token, FEEDS };
+}
+
+module.exports = { create, SCHEMA, NAMESPACE, FEEDS };
@@ -4,6 +4,10 @@
@@ -4,6 +4,10 @@
* the monthly budget it may recommend within. The env variables stay the pins (`x-env`): while one is set it wins,
* the monthly budget it may recommend within. The env variables stay the pins (`x-env`): while one is set it wins,
* is read-only in the summary and a write answers 409 config.pinned_in_env.
* is read-only in the summary and a write answers 409 config.pinned_in_env.
*
*
+ * The same admin routes also serve the stores passed as `also` (host.data_sources, server/data-sources-config.js),
+ * and POST /internal/config/:namespace/test/:key, the Setup tab's Test button, answers for the keys a `test` hook
+ * knows (the owner-setup design of 2026-11-07); it is mounted before the config routes.
+ *
* /internal/config is openvibe-shared/config's adminRoutes, guarded by host.config.manage (Network's service token
* /internal/config is openvibe-shared/config's adminRoutes, guarded by host.config.manage (Network's service token
* only). actor is the owner Network names in X-OV-Subject. budget_nano_usd is x-danger (a recommendation above it
* only). actor is the owner Network names in X-OV-Subject. budget_nano_usd is x-danger (a recommendation above it
* needs the owner's budget), so changing it needs X-OV-Fresh: 1. Every activation writes one audit line with the
* needs the owner's budget), so changing it needs X-OV-Fresh: 1. Every activation writes one audit line with the
@@ -23,23 +27,27 @@ const AUDIENCE = 'openvibe.host';
@@ -23,23 +27,27 @@ const AUDIENCE = 'openvibe.host';
const SCHEMA = {
const SCHEMA = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$schema: 'https://json-schema.org/draft/2020-12/schema',
title: 'Inference placement planner',
title: 'Inference placement planner',
- description: 'The staff-only forecast and break-even page and JSON API. The planner recommends; it never starts, stops or rents a GPU.',
+ description: 'A staff-only page that forecasts AI demand and says whether renting GPUs, buying them or staying on providers is cheaper. It only recommends; it never starts, stops or rents a GPU. Change it to switch the page on or to give it a budget.',
type: 'object',
type: 'object',
additionalProperties: false,
additionalProperties: false,
required: ['enabled', 'budget_nano_usd', 'source'],
required: ['enabled', 'budget_nano_usd', 'source'],
properties: {
properties: {
enabled: {
enabled: {
- type: 'boolean', title: 'Planner page and API', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER',
- description: 'Serve /catalog/inference and /api/v1/catalog/inference-planner to staff. Off adds no route.',
+ type: 'boolean', title: 'Show the planner page', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER',
+ description: 'When on, staff can open /catalog/inference and its JSON API. Off (the default) adds neither route, so the page answers Not found.',
},
},
budget_nano_usd: {
budget_nano_usd: {
- type: ['integer', 'null'], minimum: 0, title: 'Monthly budget', 'x-group': 'Inference planner',
- 'x-env': 'HOST_INFERENCE_BUDGET_NANO_USD', 'x-unit': 'nano_usd', 'x-danger': true,
- description: 'Integer nano-USD a month. A recommendation at the forecast or the high band above it is marked "needs the owner\'s budget"; null means unset (0, nothing pre-approved).',
+ type: ['integer', 'null'], minimum: 0, title: 'Monthly budget the planner may recommend within', 'x-group': 'Inference planner',
+ 'x-env': 'HOST_INFERENCE_BUDGET_NANO_USD', 'x-unit': 'nano_usd', 'x-danger': true, 'x-example': 200000000000,
+ description: 'The most a month the planner may recommend spending, in billionths of a dollar (200000000000 is $200). A plan above it is marked "needs the owner\'s budget". Empty (the default) approves nothing in advance.',
},
},
source: {
source: {
- type: 'string', enum: ['json', 'ai'], title: 'Demand source', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER_SOURCE',
- description: 'json reads the configured planning document; ai reads OpenVibe.AI\'s aggregate usage series and falls back only to a configured JSON input.',
+ type: 'string', enum: ['json', 'ai'], title: 'Where the demand forecast comes from', 'x-group': 'Inference planner', 'x-env': 'HOST_INFERENCE_PLANNER_SOURCE',
+ description: 'What the planner forecasts from. The planning document (the default) is a file staff write; AI\'s usage reads what OpenVibe.AI really served and falls back to the document only when one is set.',
+ 'x-enum-labels': {
+ json: { label: 'The planning document', description: 'A JSON file staff write with the expected demand; nothing is measured.' },
+ ai: { label: 'AI\'s real usage', description: 'OpenVibe.AI\'s served requests per model, totalled; the document is used only when AI cannot be read.' },
+ },
},
},
},
},
};
};
@@ -80,7 +88,7 @@ function actor(req) {
@@ -80,7 +88,7 @@ function actor(req) {
const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';
const requireFresh = (req) => String((req.headers && req.headers['x-ov-fresh']) || '') === '1';
-async function create({ db, config, env = process.env, log = console, onActivate = null }) {
+async function create({ db, config, env = process.env, log = console, onActivate = null, also = [], test = null }) {
// The shared store's boolean pin reads true/1; Host's env has always said "on". Normalise the pin value, not the
// The shared store's boolean pin reads true/1; Host's env has always said "on". Normalise the pin value, not the
// name: the summary still reports HOST_INFERENCE_PLANNER.
// name: the summary still reports HOST_INFERENCE_PLANNER.
const storeEnv = { ...env };
const storeEnv = { ...env };
@@ -95,14 +103,52 @@ async function create({ db, config, env = process.env, log = console, onActivate
@@ -95,14 +103,52 @@ async function create({ db, config, env = process.env, log = console, onActivate
if (env.OV_CONFIG_KEKS) {
if (env.OV_CONFIG_KEKS) {
try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
try { await store.reseal(); } catch (err) { log.warn(`[config] ${NAMESPACE}: reseal failed: ${(err && err.message) || err}`); }
}
}
- const router = shared.adminRoutes([store], {
- basePath: '/internal/config', requireAdmin: capabilityGuard({ config, log }), actor, requireFresh, log,
+ const guard = capabilityGuard({ config, log });
+ const router = shared.adminRoutes([store, ...also], {
+ basePath: '/internal/config', requireAdmin: guard, actor, requireFresh, log,
});
});
return {
return {
store, router,
store, router,
get: (key) => store.get(key),
get: (key) => store.get(key),
- mount(app) { app.use('/internal/config', express.json({ limit: '128kb' })); router.mount(app); },
+ mount(app) {
+ app.use('/internal/config', express.json({ limit: '128kb' }));
+ if (test) app.post('/internal/config/:namespace/test/:key', guard, testRoute({ test, log }));
+ router.mount(app);
+ },
+ };
+}
+
+/** One line, at most 200 characters, with the secret replaced. */
+function oneLine(text, secret) {
+ let s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
+ if (typeof secret === 'string' && secret.length >= 4) {
+ s = s.split(secret).join('[hidden]');
+ if (secret.length >= 12) s = s.split(secret.slice(0, 12)).join('[hidden]');
+ }
+ return s.length > 200 ? `${s.slice(0, 199)}…` : s;
+}
+
+/**
+ * The Setup tab's Test button: test(namespace, key) answers { value, source, env, run(value) } or null (no test).
+ * 200 { ok, message, state, source, namespace, key, tested_at } (openvibe-shared 4.125.0's { ok, message } with more
+ * beside it), 409 config.not_set when neither the panel nor the settings file has a value. Never the value itself.
+ */
+function testRoute({ test, log }) {
+ return async function configTest(req, res) {
+ const namespace = String(req.params.namespace || '');
+ const key = String(req.params.key || '');
+ let found = null;
+ try { found = await test(namespace, key); } catch { found = null; }
+ if (!found) return http.sendProblem(res, 404, 'config.test_not_found', { detail: `${namespace} has no test for ${key}`, ctx: req.ov });
+ if (!found.value) return http.sendProblem(res, 409, 'config.not_set', { detail: `${key} is not set, so there is nothing to test`, ctx: req.ov });
+ let out;
+ try { out = await found.run(found.value); } catch (err) { out = { ok: false, message: `The test could not run: ${(err && err.message) || 'unknown error'}` }; }
+ const ok = Boolean(out && out.ok);
+ const from = found.source === 'env' && found.env ? ` (the value from the server's settings file, ${found.env})` : '';
+ const message = oneLine(`${(out && out.message) || (ok ? 'It works.' : 'It did not work.')}${from}`, found.value);
+ (log.info || log.log).call(log, `[config] test ${namespace} ${key}: ${ok ? 'connected' : 'not working'}`);
+ return res.json({ ok, message, state: ok ? 'connected' : 'not_working', source: found.source || null, namespace, key, tested_at: new Date().toISOString() });
};
};
}
}
-module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY };
+module.exports = { create, SCHEMA, NAMESPACE, CAPABILITY, oneLine };
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/** Background work for catalog ingestion, search indexing and the events outbox. */
/** Background work for catalog ingestion, search indexing and the events outbox. */
-function createWorker({ config, outbox, catalogIngest, catalogSearch, log = console }) {
+function createWorker({ config, outbox, catalogIngest, catalogSearch, keyedFeeds = null, log = console }) {
const timers = [];
const timers = [];
return {
return {
start() {
start() {
@@ -11,6 +11,12 @@ function createWorker({ config, outbox, catalogIngest, catalogSearch, log = cons
@@ -11,6 +11,12 @@ function createWorker({ config, outbox, catalogIngest, catalogSearch, log = cons
const searchSweep = async () => { try { await catalogSearch.sweep(); } catch (err) { log.warn('[Host] catalog search sweep failed:', err.message); } };
const searchSweep = async () => { try { await catalogSearch.sweep(); } catch (err) { log.warn('[Host] catalog search sweep failed:', err.message); } };
setTimeout(searchSweep, 60_000).unref();
setTimeout(searchSweep, 60_000).unref();
every(3600 * 1000, searchSweep);
every(3600 * 1000, searchSweep);
+ // DigitalOcean and Hetzner prices with the owner's read-only tokens (a feed without a token is skipped).
+ if (keyedFeeds) {
+ const feeds = async () => { try { await keyedFeeds.pull(); } catch (err) { log.warn('[Host] keyed price feeds failed:', err.message); } await searchSweep(); };
+ every(6 * 3600 * 1000, feeds);
+ setTimeout(feeds, 30_000).unref();
+ }
if (catalogIngest.enabled) {
if (catalogIngest.enabled) {
const pull = async () => { try { await catalogIngest.pull(); } catch (err) { log.warn('[Host] catalog pull failed:', err.message); } await searchSweep(); };
const pull = async () => { try { await catalogIngest.pull(); } catch (err) { log.warn('[Host] catalog pull failed:', err.message); } await searchSweep(); };
every(5 * 60_000, pull);
every(5 * 60_000, pull);
@@ -39,7 +39,7 @@ async function boot(opts = {}) {
@@ -39,7 +39,7 @@ async function boot(opts = {}) {
async function start() {
async function start() {
const config = configLib.load(env);
const config = configLib.load(env);
- built = await createApp({ config, env, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, fetchImpl: opts.fetchImpl, rdap: opts.rdap, accountSend: opts.accountSend });
+ built = await createApp({ config, env, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, fetchImpl: opts.fetchImpl, rdap: opts.rdap, accountSend: opts.accountSend });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
t.port = server.address().port;
t.port = server.address().port;
t.app = built.app;
t.app = built.app;
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+/**
+ * A local stand-in for openvibe-shared/config's lintSchema and its annotation checks (Shared 4.125.0, Shared PR #310,
+ * not tagged when this was written): the owner-setup design's clarity rules, checked on a namespace's schema, so a
+ * schema that passes here also passes when the pin moves to 4.125.0. Returns a list of problems ([] is clean).
+ *
+ * - every field has a title and a description of at least 30 characters;
+ * - every enum option has words (x-enum-labels: { value: { label, description } });
+ * - an x-owner-input names a Setup group, says what it unlocks, links an https page and says what to pick there;
+ * - x-required-for, when present, names a feature.
+ */
+// openvibe-shared 4.125.0's OWNER_INPUT_GROUPS ids, and the members an x-owner-input may have.
+const GROUPS = new Set(['money', 'ai-providers', 'gpus', 'voices', 'chat-apps', 'sign-in-apps', 'data-sources',
+ 'design-publishing', 'domains', 'legal']);
+const MEMBERS = new Set(['group', 'unlocks', 'get_it_at', 'get_it_how', 'test']);
+
+function lintField(name, f, out) {
+ if (!f || typeof f !== 'object') return;
+ if (typeof f.title !== 'string' || !f.title.trim()) out.push(`${name}: no title`);
+ if (typeof f.description !== 'string' || f.description.trim().length < 30) out.push(`${name}: the description is missing or under 30 characters`);
+ const options = Array.isArray(f.enum) ? f.enum : (f.items && Array.isArray(f.items.enum) ? f.items.enum : null);
+ if (options) {
+ const labels = f['x-enum-labels'] || (f.items && f.items['x-enum-labels']);
+ for (const v of options) {
+ const l = labels && labels[String(v)];
+ if (!l || typeof l.label !== 'string' || !l.label.trim() || typeof l.description !== 'string' || !l.description.trim()) out.push(`${name}: the option ${JSON.stringify(v)} has no label and line`);
+ }
+ }
+ const oi = f['x-owner-input'];
+ if (oi !== undefined) {
+ if (!oi || typeof oi !== 'object') out.push(`${name}: x-owner-input must be an object`);
+ else {
+ for (const m of Object.keys(oi)) if (!MEMBERS.has(m)) out.push(`${name}: x-owner-input has an unknown member ${m}`);
+ if (!GROUPS.has(oi.group)) out.push(`${name}: x-owner-input.group ${JSON.stringify(oi.group)} is not a Setup group`);
+ if (typeof oi.unlocks !== 'string' || oi.unlocks.trim().length < 20) out.push(`${name}: x-owner-input.unlocks must say what it turns on`);
+ if (oi.get_it_at !== undefined && !/^https:\/\/[^\s]+$/.test(String(oi.get_it_at))) out.push(`${name}: x-owner-input.get_it_at must be an https URL`);
+ if (typeof oi.get_it_how !== 'string' || oi.get_it_how.trim().length < 10) out.push(`${name}: x-owner-input.get_it_how must say what to pick there`);
+ if (typeof oi.test !== 'boolean') out.push(`${name}: x-owner-input.test must be true or false`);
+ }
+ }
+ if (f['x-example'] !== undefined && !['string', 'number', 'boolean'].includes(typeof f['x-example'])) out.push(`${name}: x-example must be a string, a number or true/false`);
+ if (f['x-advanced'] !== undefined && typeof f['x-advanced'] !== 'boolean') out.push(`${name}: x-advanced must be true or false`);
+ if (f['x-enum-labels'] && options) for (const v of Object.keys(f['x-enum-labels'])) if (!options.map((o) => (typeof o === 'string' ? o : JSON.stringify(o))).includes(v)) out.push(`${name}: x-enum-labels names ${v}, which is not an option`);
+ if (f['x-required-for'] !== undefined && (typeof f['x-required-for'] !== 'string' || !f['x-required-for'].trim())) out.push(`${name}: x-required-for must name a feature`);
+ if (f.properties && typeof f.properties === 'object') for (const [k, sub] of Object.entries(f.properties)) lintField(`${name}.${k}`, sub, out);
+}
+
+function lintSchema(schema) {
+ const out = [];
+ if (!schema || typeof schema !== 'object') return ['no schema'];
+ if (typeof schema.title !== 'string' || !schema.title.trim()) out.push('(namespace): no title');
+ if (typeof schema.description !== 'string' || schema.description.trim().length < 30) out.push('(namespace): the description is missing or under 30 characters');
+ for (const [k, f] of Object.entries(schema.properties || {})) lintField(k, f, out);
+ return out;
+}
+
+module.exports = { lintSchema, GROUPS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+'use strict';
+/**
+ * Owner inputs (the owner-setup design of 2026-11-07): the hosting providers' read-only tokens in `host.data_sources`,
+ * listed by the admin panel's Setup tab under Data sources.
+ *
+ * Proves: the fields carry x-owner-input, titles and 30+ character descriptions, and every field of both Host
+ * namespaces does (the planner's choice has labelled options); the price feed uses the panel's sealed token when set
+ * and the environment's otherwise, at use time, and brings DigitalOcean's and Hetzner's plans into the comparison;
+ * the test hook answers in one line and never carries the token, not even when the provider echoes it; no log line
+ * carries a token.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/boot');
+const { lintSchema } = require('./helpers/lint-schema');
+const { mapItem } = require('../server/catalog/mapping');
+
+const CAP = ['host.config.manage'];
+const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const KEK = crypto.randomBytes(32).toString('base64');
+const ENV_DO = 'dop_v1_madeupENVtokenFROMTHESETTINGSFILE0000000000000000001';
+const PANEL_DO = 'dop_v1_madeupPANELtokenNEVERSHOWNANYWHERE00000000000000002';
+const BAD_HZ = 'madeupHETZNERtokenTHEPROVIDERECHOESBACK000000000000000003';
+const GOOD_HZ = 'madeupHETZNERtokenTHATWORKS0000000000000000000000000004';
+const SECRETS = [ENV_DO, PANEL_DO, BAD_HZ, GOOD_HZ];
+
+const calls = [];
+async function fakeFetch(url, init = {}) {
+ const u = String(url);
+ if (!u.startsWith('https://api.digitalocean.com/') && !u.startsWith('https://api.hetzner.cloud/')) return globalThis.fetch(url, init);
+ const auth = String((init.headers && (init.headers.Authorization || init.headers.authorization)) || '');
+ calls.push({ url: u, auth });
+ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+ if (u.startsWith('https://api.digitalocean.com/')) {
+ if (auth !== `Bearer ${ENV_DO}` && auth !== `Bearer ${PANEL_DO}`) return json(401, { id: 'unauthorized', message: 'Unable to authenticate you' });
+ if (u === 'https://api.digitalocean.com/v2/account') return json(200, { account: { status: 'active', email: '[email protected]' } });
+ if (u.startsWith('https://api.digitalocean.com/v2/sizes')) {
+ return json(200, { sizes: [
+ { slug: 's-1vcpu-1gb', description: 'Basic', memory: 1024, vcpus: 1, disk: 25, transfer: 1, price_monthly: 6, price_hourly: 0.00893, regions: ['nyc1', 'sfo3'], available: true },
+ { slug: 's-2vcpu-4gb-amd', description: 'Premium AMD', memory: 4096, vcpus: 2, disk: 80, transfer: 4, price_monthly: 28, price_hourly: 0.04167, regions: ['nyc3'], available: true },
+ { slug: 'gone-1', description: 'Basic', memory: 512, vcpus: 1, disk: 10, transfer: 0.5, price_monthly: 4, price_hourly: 0.006, regions: [], available: false },
+ ], links: {}, meta: { total: 3 } });
+ }
+ return json(404, { message: 'not here' });
+ }
+ if (auth !== `Bearer ${GOOD_HZ}`) return json(401, { error: { code: 'unauthorized', message: `invalid token ${auth.replace(/^Bearer /, '')}` } });
+ return json(200, { server_types: [
+ { name: 'cx22', description: 'CX22', cores: 2, memory: 4, disk: 40, cpu_type: 'shared', storage_type: 'local', architecture: 'x86', deprecated: false,
+ prices: [{ location: 'fsn1', price_hourly: { net: '0.0060000000', gross: '0.0071' }, price_monthly: { net: '3.7900000000', gross: '4.51' }, included_traffic: 21990232555520 },
+ { location: 'ash', price_hourly: { net: '0.0080000000', gross: '0.0080' }, price_monthly: { net: '4.9900000000', gross: '4.99' }, included_traffic: 1099511627776 }] },
+ { name: 'cx11', description: 'CX11', cores: 1, memory: 2, disk: 20, cpu_type: 'shared', storage_type: 'local', deprecated: true, prices: [] },
+ ], meta: { pagination: { page: 1, per_page: 50, next_page: null, total_entries: 2 } } });
+}
+
+(async () => {
+ const lines = [];
+ const log = { log: (...a) => lines.push(a.join(' ')), info: (...a) => lines.push(a.join(' ')), warn: (...a) => lines.push(a.join(' ')), error: (...a) => lines.push(a.join(' ')) };
+ const consoleLines = [];
+ const original = { log: console.log, warn: console.warn, error: console.error, info: console.info };
+ for (const k of Object.keys(original)) console[k] = (...a) => { consoleLines.push(a.join(' ')); original[k](...a); };
+ const t = await boot({ env: { OV_CONFIG_KEKS: `k1:${KEK}`, OV_CONFIG_KEK_CURRENT: 'k1', HOST_DIGITALOCEAN_TOKEN: ENV_DO }, fetchImpl: fakeFetch, log });
+ const admin = () => t.network.serviceToken('network', CAP);
+ const cfg = (method, p, json) => t.api(method, p, { as: admin(), headers: { 'x-ov-subject': OWNER, 'x-ov-fresh': '1' }, ...(json ? { json } : {}) });
+ const test = (key) => cfg('POST', `/internal/config/host.data_sources/test/${key}`);
+ const ds = t.ctx ? t.ctx.dataSources : null;
+ try {
+ await check('every field of both Host namespaces has a title, a 30+ character description and labelled options', async () => {
+ const r = await cfg('GET', '/internal/config');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.deepStrictEqual(r.json().namespaces.map((n) => n.namespace), ['host.inference_planner', 'host.data_sources']);
+ for (const ns of r.json().namespaces) assert.deepStrictEqual(lintSchema(ns.schema), [], `${ns.namespace}: ${lintSchema(ns.schema).join('; ')}`);
+ const planner = r.json().namespaces[0].schema.properties.source['x-enum-labels'];
+ assert.deepStrictEqual(Object.keys(planner), ['json', 'ai']);
+ });
+
+ await check('the two tokens are owner inputs under Data sources, each required for its provider and tested', async () => {
+ const p = (await cfg('GET', '/internal/config/host.data_sources')).json().schema.properties;
+ assert.deepStrictEqual([p['digitalocean.token']['x-owner-input'].group, p['digitalocean.token']['x-owner-input'].test, p['digitalocean.token']['x-required-for']],
+ ['data-sources', true, 'DigitalOcean in the hosting comparison']);
+ assert.deepStrictEqual([p['hetzner.token']['x-owner-input'].group, p['hetzner.token']['x-owner-input'].test, p['hetzner.token']['x-required-for']],
+ ['data-sources', true, 'Hetzner in the hosting comparison']);
+ assert.strictEqual(p['digitalocean.token']['x-owner-input'].get_it_at, 'https://cloud.digitalocean.com/account/api/tokens');
+ });
+
+ await check('without a panel token the settings file is used and tested; without either, 409 config.not_set', async () => {
+ const r = await test('digitalocean.token');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.deepStrictEqual([r.json().ok, r.json().state, r.json().source], [true, 'connected', 'env']);
+ assert.strictEqual(r.json().message, "DigitalOcean accepted the token (the account is active); its Droplet prices can be read. (the value from the server's settings file, HOST_DIGITALOCEAN_TOKEN)");
+ const none = await test('hetzner.token');
+ assert.deepStrictEqual([none.status, none.json().code], [409, 'config.not_set']);
+ });
+
+ await check('a panel token is sealed, write-only and wins at use time; the feed brings the plans in', async () => {
+ const put = await cfg('PUT', '/internal/config/host.data_sources/secrets/digitalocean.token', { value: PANEL_DO, reason: 'setup' });
+ assert.strictEqual(put.status, 200, put.text.slice(0, 300));
+ assert.ok(!put.text.includes(PANEL_DO));
+ const carried = await cfg('POST', '/internal/config/host.data_sources', { values: { 'hetzner.token': GOOD_HZ }, reason: 'not here' });
+ assert.deepStrictEqual([carried.status, carried.json().code], [422, 'config.secret_write_only']);
+ const r = await test('digitalocean.token');
+ assert.deepStrictEqual([r.json().ok, r.json().source], [true, 'vault']);
+ const put2 = await cfg('PUT', '/internal/config/host.data_sources/secrets/hetzner.token', { value: GOOD_HZ, reason: 'setup' });
+ assert.strictEqual(put2.status, 200, put2.text.slice(0, 300));
+ const before = calls.length;
+ const report = await t.ctx.keyedFeeds.pull();
+ assert.deepStrictEqual(report, { digitalocean: { applied: 2, held: 0 }, hetzner: { applied: 1, held: 0 } });
+ assert.ok(calls.slice(before).filter((c) => c.url.includes('digitalocean')).every((c) => c.auth === `Bearer ${PANEL_DO}`), 'the feed used the panel token');
+ const dO = (await t.ctx.catalog.plans({ provider: 'digitalocean' })).plans;
+ assert.deepStrictEqual(dO.map((p) => p.name).sort(), ['Basic · 1 vCPU, 1 GB', 'Premium AMD · 2 vCPU, 4 GB']);
+ const hz = (await t.ctx.catalog.plans({ provider: 'hetzner', currency: 'EUR' })).plans;
+ assert.deepStrictEqual([hz.length, hz[0].currency, Number(hz[0].price_monthly), hz[0].locations], [1, 'EUR', 3.79, ['fsn1', 'ash']]);
+ const row = await t.ctx.store.db.prepare("SELECT values_json FROM config_snapshots WHERE namespace = 'host.data_sources' AND state = 'active'").get();
+ assert.ok(!row.values_json.includes(PANEL_DO) && !row.values_json.includes(GOOD_HZ), 'sealed at rest');
+ });
+
+ await check('the mappers keep the providers\' own numbers', async () => {
+ const it = (key, id, fields, title = id) => ({ id: `host:${key}:${id}`, source_key: key, category: 'hosting', identity: id, title, fields, provenance: { retrieved_at: '2026-11-07T00:00:00Z', first_seen_at: '2026-11-07T00:00:00Z' } });
+ const d = mapItem(it('digitalocean-sizes', 's-1vcpu-1gb', { description: 'Basic', memory: 1024, vcpus: 1, disk: 25, transfer: 1, price_monthly: 6, price_hourly: 0.00893, regions: ['nyc1'] }));
+ assert.deepStrictEqual([d.provider.slug, d.ram_mb, d.disk_gb, d.disk_type, d.bandwidth_gb, d.price_monthly, d.currency], ['digitalocean', 1024, 25, 'ssd', 1000, 6, 'USD']);
+ assert.throws(() => mapItem(it('hetzner-server-types', 'x', { prices: [] })), /missing monthly price/);
+ });
+
+ await check('a refused token is Not working in one line, and the echoed token never comes back', async () => {
+ await cfg('PUT', '/internal/config/host.data_sources/secrets/hetzner.token', { value: BAD_HZ, reason: 'wrong one' });
+ const r = await test('hetzner.token');
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.deepStrictEqual([r.json().ok, r.json().state], [false, 'not_working']);
+ assert.match(r.json().message, /^Hetzner Cloud refused the token \(401\): invalid token \[hidden\]/);
+ assert.ok(!r.text.includes(BAD_HZ) && !r.text.includes(BAD_HZ.slice(0, 12)));
+ const report = await t.ctx.keyedFeeds.pull();
+ assert.match(report.hetzner.error, /^Hetzner Cloud refused the token \(401\)/);
+ assert.ok(!JSON.stringify(report).includes(BAD_HZ), 'the feed\'s report never carries the token');
+ });
+
+ await check('removing the panel token falls back to the settings file', async () => {
+ const del = await cfg('DELETE', '/internal/config/host.data_sources/secrets/digitalocean.token', { reason: 'back to env' });
+ assert.strictEqual(del.status, 200, del.text.slice(0, 300));
+ assert.deepStrictEqual(t.ctx.dataSources.token('digitalocean'), { value: ENV_DO, source: 'env', env: 'HOST_DIGITALOCEAN_TOKEN' });
+ });
+
+ await check('the test hook is guarded and answers only for keys with a test', async () => {
+ const denied = await t.api('POST', '/internal/config/host.data_sources/test/hetzner.token', { as: t.network.serviceToken('other', ['host.catalog.read']) });
+ assert.strictEqual(denied.status, 403);
+ const none = await cfg('POST', '/internal/config/host.inference_planner/test/enabled');
+ assert.deepStrictEqual([none.status, none.json().code], [404, 'config.test_not_found']);
+ });
+
+ await check('no log line or console line carries a token', async () => {
+ for (const k of Object.keys(original)) console[k] = original[k];
+ for (const s of SECRETS) {
+ assert.ok(lines.every((l) => !l.includes(s)), `a log line carries ${s.slice(0, 14)}…`);
+ assert.ok(consoleLines.every((l) => !l.includes(s)), `a console line carries ${s.slice(0, 14)}…`);
+ }
+ assert.ok(lines.some((l) => /\[config\] test host\.data_sources hetzner\.token: not working/.test(l)), 'the outcome is logged without the token');
+ assert.ok(lines.some((l) => /\[Host\] hetzner prices: Hetzner Cloud refused the token \(401\): invalid token \[hidden\]/.test(l)));
+ });
+ void ds;
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });