openvibers/openvibe.host Commits
Host: the console — your servers on every provider in one place (Linode, DigitalOcean, Vultr, Hetzner Cloud with your own sealed tokens): status, prices, totals, power actions and the catalog's cheaper equivalent; account export and deletion (#120)
Changes
@@@ -32,3 +32,9 @@ HOST_CATALOG_WEBHOOK_SECRETS=HOST_COMMUNITY_URL=OV_COMMUNITY_URL=https://openvibe.communityOV_WATCH_URL=https://openvibe.watch++++# The console (server/console/): seals people's hosting API tokens (32 bytes, hex or base64). Unset: the console connects nothing.++#HOST_CREDENTIALS_KEY=++#HOST_CONSOLE_MAX_ACCOUNTS=10++# OpenVibe.Events → Host (account export and deletion): signs deliveries to /internal/events (32+ characters).++#HOST_EVENTS_SECRET=@@@ -35,6 +35,26 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id`, `/catalog/promos`, `/catalog/offers`, `/catalog/domains` (with each ending's page and the name check) and `/catalog/benchmarks`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.- **Discussion.** Active public provider and plan pages lazily resolve Community threads with refs `{ service: 'host', type: 'provider', id: provider.id }` and `{ service: 'host', type: 'plan', id: plan.id }` (plus a display label), then render recent comments with a link to the thread on OpenVibe.Community. Host stores only the thread reference. `HOST_COMMUNITY_URL` enables the internal client; `OV_COMMUNITY_URL` sets the public link. With the internal URL unset, no Discussion block appears. Hidden providers and hidden or gone plans have no public page; status changes hide or reopen existing threads after commit. Host's Network service principal needs `community.comment.write` and `community.comment.moderate` for audience `openvibe.community`.++## The console (your servers, every provider)++++Plan T20 step 3, the owner's "our own system that combines em somehow". At `/console` a signed-in person connects their++own hosting accounts (`server/console/`): Linode (Akamai), DigitalOcean, Vultr and Hetzner Cloud, with their own API++tokens.++++- **Tokens.** Each token is checked with its provider when added, then sealed with AES-256-GCM under++ `HOST_CREDENTIALS_KEY` (32 bytes, hex or base64). It is never shown again; only its last four characters are kept in++ clear, to tell tokens apart. It is opened only to call that provider's API host. Disconnecting deletes it.++- **The server list.** Every server across the accounts in one list: status, region, plan, size, IPv4 and monthly++ price, with totals per currency. Each account is cached for a minute; Refresh asks again.++- **Actions.** Power on, power off and reboot reach the provider, and each one is logged for the person.++- **The comparison.** When the catalog has a plan with at least the same vCPU and memory for 15% less, the server++ links to it.++- **Account data.** Export and deletion (ADR-033) go through openvibe-sdk/account-data (`server/account-data.js`,++ signed with `HOST_EVENTS_SECRET`). The export carries the accounts (never a token) and the action log; deletion++ removes both.++- **Without the key.** Without `HOST_CREDENTIALS_KEY` the console connects nothing and says so.++- Migration 0013 creates `host_console_accounts`, `host_console_actions` and `account_data_events`.++## Static hosting movedThe static hosting engine (Stage B: projects, sites, immutable content-addressed deploys, activation and rollback, previews, Git ingest, per-site configuration, custom domains, takedowns, the resource index and the dashboard) moved to **[OpenVibe.Space](https://openvibe.space)** on 2026-10-12 (plan T20 step 2), where sites are served at `<name>.openvibe.website` under `space.*` capabilities and events (openvibe-contracts 0.149.0). Production Host had 0 projects, 0 sites and 0 deploys, so no data moved.@@@ -77,6 +97,7 @@ Stage A adds a few safety rules of its own:- Stage A: the host inventory (services, units, env names, ports, probes, drain policy), environment validation, release install and rollback for git-checkout services, readiness and drain orchestration, the release log, certificate inventory, nginx vhost rendering and transactional install, config snapshots, scheduled backups (pgBackRest verification and logical PostgreSQL dumps) with retention and encrypted off-host copies, restore drills.- The hosting comparison catalog: providers, plans, price history, promotions and their sources (migrations 0005-0007), offers (0009), registrars' domain prices and their history (0011), benchmarks and their one-time links (0012), and the catalog's Search documents (`host.index_document.*`).++- The console: a person's connected hosting accounts (sealed tokens) and the actions they took (migration 0013).- Later (Stage C): sandbox profiles, budgets, secret references, outbound policy.- Host roles, provisioned from code so any machine can take them: the **data role** (`roles/data/`, ADR-035): PostgreSQL 18, PgBouncer, pgBackRest, Valkey and their exporters, plus `add-service.sh` giving each service its database, roles and Valkey user. `ovhost data provision <svc>` runs `add-service.sh` from the installed CLI, redacting every URL and password: [docs/data-provisioning.md](docs/data-provisioning.md).@@@ -93,6 +114,7 @@ Stage A adds a few safety rules of its own:- OpenVibe.Contracts (`openvibe-contracts`, pinned below): `host.catalog.manage`, the catalog's `host.index_document.*` events, and `host.release.published` from `ovhost announce`.- OpenVibe.Network: SSO for catalog staff, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay and `ovhost alerts relay`.- OpenVibe.Events: the catalog's events through the `openvibe-sdk` transactional outbox, and `ovhost announce`.++- Linode, DigitalOcean, Vultr and Hetzner Cloud APIs: the console, with each person's own token, only for them.- OpenVibe.Sources (optional): price ingestion for the catalog (`HOST_CATALOG_INGEST`); OpenVibe.Search: the catalog's pages as documents; OpenVibe.Community (optional): discussion on provider and plan pages; OpenVibe.Watch: the "Tell me when this price changes" link on plan pages (a link, no call).- `openvibe-shared` (the OpenVibe Frame, legal pages, `/release.json`, `/metrics`, `/api/ready`) and `openvibe-publishing` (ingest and the search feed).nnew file mode 100644@@@ -0,0 +1,40 @@++-- phase: expand++-- The console (plan T20 step 3; owner 2026-10-08: "our own system that combines em somehow"): a person connects their++-- own hosting accounts (Linode, DigitalOcean, Vultr, Hetzner Cloud) with API tokens and sees and runs every server in++-- one place (server/console/). Tokens are sealed with HOST_CREDENTIALS_KEY (AES-256-GCM) and never shown again.++CREATE TABLE host_console_accounts (++ id text COLLATE "C" PRIMARY KEY, -- hca_<random>++ owner_subject text COLLATE "C" NOT NULL, -- usr_…++ provider text COLLATE "C" NOT NULL CHECK (provider IN ('linode', 'digitalocean', 'vultr', 'hetzner')),++ label text NOT NULL,++ token_sealed text NOT NULL,++ token_hint text NOT NULL, -- the last four characters, to tell tokens apart++ created_at bigint NOT NULL,++ last_ok_at bigint,++ last_error text++);++CREATE INDEX host_console_accounts_owner ON host_console_accounts (owner_subject);++++-- What a person did to their servers from the console (power on, power off, reboot), kept for them.++CREATE TABLE host_console_actions (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ owner_subject text COLLATE "C" NOT NULL,++ account_id text COLLATE "C" NOT NULL,++ provider text COLLATE "C" NOT NULL,++ server_id text COLLATE "C" NOT NULL,++ server_name text NOT NULL DEFAULT '',++ action text COLLATE "C" NOT NULL,++ result text NOT NULL,++ at bigint NOT NULL++);++CREATE INDEX host_console_actions_owner ON host_console_actions (owner_subject, at DESC);++++-- Account export and deletion (ADR-033) through openvibe-sdk/account-data (server/account-data.js).++CREATE TABLE IF NOT EXISTS account_data_events (++ id text PRIMARY KEY,++ kind text NOT NULL,++ subject text NOT NULL,++ outcome jsonb,++ applied_at timestamptz NOT NULL DEFAULT now(),++ sent_at timestamptz++);nnew file mode 100644@@@ -0,0 +1,65 @@++/* The console (server/console/pages.js): every server across a person's hosting accounts. Colours come from the shared++ Frame's theme variables; filled buttons use --accent-strong for contrast. */++.cs { max-width: 1180px; margin: 0 auto; padding: 32px 16px 56px; }++.cs-head { display: flex; justify-content: space-between; gap: 24px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 20px; }++.cs-eyebrow { margin: 0; color: var(--accent, #60a5fa); font-weight: 700; font-size: 0.8rem; letter-spacing: .06em; text-transform: uppercase; }++.cs h1 { font-size: clamp(26px, 3.6vw, 38px); line-height: 1.1; letter-spacing: -0.02em; margin: 6px 0 8px; }++.cs-lede { margin: 0; color: var(--text-secondary, #9fb0c8); max-width: 62ch; }++.cs-stats { display: flex; gap: 10px; }++.cs-stats > div { min-width: 96px; padding: 10px 14px; border: 1px solid var(--border, #2c3d5c); border-radius: 14px; background: var(--bg-secondary, #101828); }++.cs-stats b { display: block; font-size: 1.35rem; font-variant-numeric: tabular-nums; }++.cs-stats span { color: var(--text-secondary, #9fb0c8); font-size: 0.82rem; }++.cs-muted { color: var(--text-secondary, #9fb0c8); font-size: 0.88rem; }++.cs-bad { color: #f87171; }++.cs-empty { padding: 28px; border: 1px dashed var(--border, #2c3d5c); border-radius: 14px; text-align: center; color: var(--text-secondary, #9fb0c8); }++.cs-table-wrap { overflow-x: auto; border: 1px solid var(--border, #2c3d5c); border-radius: 16px; background: var(--bg-secondary, #101828); }++.cs-table { width: 100%; border-collapse: collapse; min-width: 760px; }++.cs-table th, .cs-table td { text-align: left; padding: 12px 14px; border-bottom: 1px solid var(--border, #2c3d5c); vertical-align: top; }++.cs-table tbody tr:last-child td { border-bottom: 0; }++.cs-table th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-secondary, #9fb0c8); }++.cs-num { font-variant-numeric: tabular-nums; white-space: nowrap; }++.cs-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.88rem; }++.cs-save { font-size: 0.8rem; margin-top: 4px; }++.cs-save a { color: #4ade80; }++.cs-status { display: inline-flex; align-items: center; gap: 6px; font-size: 0.84rem; font-weight: 600; }++.cs-status::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }++.cs-status.is-ok { color: #4ade80; } .cs-status.is-off { color: var(--text-secondary, #9fb0c8); } .cs-status.is-busy { color: #fbbf24; }++.cs-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }++.cs-actions form { margin: 0; }++.cs-refresh { font-size: 0.8rem; text-transform: none; letter-spacing: 0; }++.cs-btn { display: inline-flex; align-items: center; gap: 6px; border: 1px solid transparent; border-radius: 10px; padding: 9px 16px; font: 600 0.92rem/1.2 inherit; cursor: pointer;++ background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }++.cs-btn:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent, #60a5fa) 55%, transparent); outline-offset: 2px; }++.cs-quiet, .cs-small { background: transparent; color: var(--text-primary, #e6edf7); border-color: var(--border, #2c3d5c); }++.cs-small { padding: 5px 10px; font-size: 0.82rem; }++.cs-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; margin-top: 18px; }++.cs-card { padding: 18px; border: 1px solid var(--border, #2c3d5c); border-radius: 16px; background: var(--bg-secondary, #101828); margin-top: 14px; }++.cs-grid .cs-card { margin-top: 0; }++.cs-card h2 { margin: 0 0 12px; font-size: 1.1rem; }++.cs-accts, .cs-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }++.cs-acct { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--border, #2c3d5c); border-radius: 12px; }++.cs-acct form { margin: 0; }++.cs-form { display: grid; gap: 12px; }++.cs-form label { display: grid; gap: 4px; font-weight: 600; font-size: 0.9rem; }++.cs-form input, .cs-form select { border: 1px solid var(--border, #2c3d5c); background: var(--bg-primary, #0b1220); color: inherit; border-radius: 10px; padding: 9px 12px; font: inherit; font-weight: 400; }++.cs-form .cs-btn { justify-self: start; }++.cs-help summary { cursor: pointer; color: var(--accent, #60a5fa); font-size: 0.9rem; }++.cs-help ul { margin: 8px 0 0; padding-left: 18px; color: var(--text-secondary, #9fb0c8); font-size: 0.86rem; display: grid; gap: 6px; }++.cs-log li { font-size: 0.9rem; }++@media (max-width: 640px) { .cs-stats { width: 100%; } .cs-stats > div { flex: 1; min-width: 0; } }++/* On a phone each server is a card: its name, then labelled facts, then its power buttons, all in view (no sideways scroll). */++@media (max-width: 720px) {++ .cs-table-wrap { overflow: visible; border: 0; background: transparent; }++ .cs-table { min-width: 0; display: block; }++ .cs-table thead { display: none; }++ .cs-table tbody { display: grid; gap: 10px; }++ .cs-table tr { display: block; border: 1px solid var(--border, #2c3d5c); border-radius: 14px; background: var(--bg-secondary, #101828); padding: 6px 0; }++ .cs-table td { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; border: 0; padding: 6px 14px; text-align: right; }++ .cs-table td::before { content: attr(data-label); color: var(--text-secondary, #9fb0c8); font-size: 0.82rem; text-align: left; }++ .cs-table td.cs-name { display: block; text-align: left; padding-bottom: 4px; }++ .cs-table td.cs-name::before, .cs-table td.cs-act-cell::before { content: none; }++ .cs-table td.cs-act-cell { padding-top: 8px; }++ .cs-table td.cs-act-cell .cs-actions { width: 100%; justify-content: stretch; }++ .cs-table td.cs-act-cell .cs-actions form { flex: 1; }++ .cs-table td.cs-act-cell .cs-btn { width: 100%; justify-content: center; padding: 10px; font-size: 0.92rem; }++}nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++/**++ * Account export and deletion → Host (ADR-033; openvibe-sdk/account-data). What Host holds about a person:++ *++ * the console the hosting accounts they connected (provider, label, the token's last four characters; never the++ * sealed token itself) and the actions they took on their servers. Deletion removes both, so their++ * tokens are gone from OpenVibe.Host.++ * benchmarks the results they submitted (migration 0012) and their one-time links (never the link's token).++ * Deletion removes the links and detaches the results, which stay as anonymous measurements.++ * catalog rows providers, plans and promotions a staff member entered: shared rows, so deletion keeps them and++ * erases only who entered them.++ *++ * Discussion lives on OpenVibe.Community. A table a held migration has not created yet is skipped.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const TABLES = [++ { table: 'host_console_accounts', subject: 'owner_subject', file: 'console-accounts.json', columns: ['provider', 'label', 'token_hint', 'created_at', 'last_ok_at'] },++ { table: 'host_console_actions', subject: 'owner_subject', file: 'console-actions.json', columns: ['provider', 'server_id', 'server_name', 'action', 'result', 'at'], order: 'at' },++ { table: 'catalog_benchmarks', subject: 'submitted_by', file: 'benchmarks.json', erase: { anonymize: {} } },++ { table: 'catalog_bench_links', subject: 'person', file: 'benchmark-links.json', columns: ['provider_name', 'plan_id', 'note', 'created_at', 'expires_at', 'used_at'] },++ { table: 'catalog_providers', subject: 'created_by', file: 'catalog-providers.json', erase: { anonymize: {} } },++ { table: 'catalog_plans', subject: 'created_by', file: 'catalog-plans.json', erase: { anonymize: {} } },++ { table: 'catalog_promos', subject: 'created_by', file: 'catalog-promos.json', erase: { anonymize: {} } },++];++++function create({ db, log = console } = {}) {++ return createAccountData({ db, service: 'host', tables: TABLES, log });++}++++module.exports = { create, TABLES, TOPICS };@@@ -83,6 +83,7 @@ async function createApp(opts = {}) {catalog.onChange(() => { if (config.worker.enabled) catalogSearch.sweepSoon(); });const worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });const ctx = { config, store, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log };++ ctx.console = require('./console/console').createConsole({ store, config, catalog, fetchImpl: opts.fetchImpl, log });const app = express();app.disable('x-powered-by');app.disable('etag');@@@ -168,6 +169,19 @@ async function createApp(opts = {}) {try { const result = await catalogIngest.consume(req.body, req.headers); res.status(result.status).json(result); }catch (err) { require('./http/errors').sendError(res, req, err, log); }});++ // ── OpenVibe.Events → Host (loopback only: nginx answers 404 for /internal/) ──++ // network.account.export_requested and network.account.deleted (ADR-033) through openvibe-sdk/account-data, which reads++ // the raw body itself (the v2 signature covers it) and refuses a request that came through a proxy.++ {++ const { createNetworkSender } = require('openvibe-sdk/account-data');++ const accountData = require('./account-data').create({ db: store.db, log });++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}) })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Host cannot answer account events'); });++ app.locals.accountData = accountData;++ app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));++ }++ app.use('/console', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), require('./console/pages').createConsolePages(ctx));app.use('/api/v1/catalog', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createCatalogApi(ctx));const movedApi = (req, res) => res.status(410).type('application/problem+json').json({ code: 'host.moved_to_space', title: 'Static hosting moved', detail: MOVED, url: SPACE_URL });const movedPage = (req, res) => res.status(410).type('html').send(renderPage({ title: 'Static hosting moved', body: `<h1>Static hosting moved</h1><p>${MOVED} <a href="${SPACE_URL}">Visit OpenVibe.Space</a>.</p>`, config, path: req.originalUrl }));ddeleted file mode 100644@@@ -1,36 +0,0 @@--'use strict';----/**-- * ADR-033: catalog rows are shared. Export authored rows; erase only the attribution. A person's benchmark results and-- * links (migration 0012) are exported; erasing deletes the links and detaches the results, which stay as anonymous-- * measurements. Tables a held migration has not created yet are skipped.-- */--const TABLES = Object.freeze(['catalog_providers', 'catalog_plans', 'catalog_promos']);--const exists = async (db, table) => Boolean((await db.prepare('SELECT to_regclass(?) AS t').get(table)).t);----async function exportFor(db, personId) {-- const out = {};-- for (const table of TABLES) out[table] = await db.prepare(`SELECT * FROM ${table} WHERE created_by = ? ORDER BY created_at, id`).all(personId);-- if (await exists(db, 'catalog_benchmarks')) {-- out.catalog_benchmarks = await db.prepare('SELECT * FROM catalog_benchmarks WHERE submitted_by = ? ORDER BY created_at, id').all(personId);-- out.catalog_bench_links = await db.prepare('SELECT provider_name, plan_id, note, created_at, expires_at, used_at FROM catalog_bench_links WHERE person = ? ORDER BY created_at').all(personId);-- }-- return out;--}--async function eraseFor(db, personId) {-- const counts = {};-- const benchmarks = await exists(db, 'catalog_benchmarks');-- await db.tx(async (t) => {-- for (const table of TABLES) {-- const rows = await t.prepare(`SELECT id FROM ${table} WHERE created_by = ?`).all(personId);-- await t.prepare(`UPDATE ${table} SET created_by = NULL WHERE created_by = ?`).run(personId);-- counts[table] = rows.length;-- }-- if (benchmarks) {-- counts.catalog_benchmarks = (await t.prepare('UPDATE catalog_benchmarks SET submitted_by = NULL WHERE submitted_by = ?').run(personId)).changes;-- counts.catalog_bench_links = (await t.prepare('DELETE FROM catalog_bench_links WHERE person = ?').run(personId)).changes;-- }-- });-- return counts;--}--module.exports = { TABLES, exportFor, eraseFor };@@@ -46,6 +46,9 @@ function load(env = process.env) {events: {url: trim(env.EVENTS_URL || ''),intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),++ // OpenVibe.Events → Host (ADR-033 account export and deletion, server/account-data.js): the secret(s) that sign++ // its deliveries to /internal/events (32+ characters); the first one is what the subscriptions are made with.++ secrets: String(env.HOST_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),},community: {internalUrl: trim(env.HOST_COMMUNITY_URL || ''),@@@ -56,6 +59,9 @@ function load(env = process.env) {catalog: { ingest: String(env.HOST_CATALOG_INGEST || 'off').trim().toLowerCase() === 'on', sourcesUrl: trim(env.HOST_CATALOG_SOURCES_URL || ''), webhookSecrets: list(env.HOST_CATALOG_WEBHOOK_SECRETS) },++ // The console (server/console/): a person's own hosting accounts, their tokens sealed with HOST_CREDENTIALS_KEY++ // (32 bytes, hex or base64). Without it the console shows servers of no account and connects none.++ console: { credentialsKey: env.HOST_CREDENTIALS_KEY || '', maxAccounts: int(env.HOST_CONSOLE_MAX_ACCOUNTS, 10) },worker: {enabled: env.HOST_WORKER !== 'off',id: env.HOST_WORKER_ID || `host-${process.pid}`,nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++/**++ * The console (plan T20 step 3; owner 2026-10-08: "openvibe.host should just be combined multi provider hosting++ * comparison … while also have our own system that combines em somehow"): a person connects their own hosting accounts++ * and sees every server in one list (status, region, size, address, monthly price, totals), powers them on, off or++ * reboots them, and sees where the catalog has the same size for less.++ *++ * A token is checked with the provider when it is added, sealed with AES-256-GCM under HOST_CREDENTIALS_KEY, and only++ * ever opened to call that provider. Without the key the console says so and keeps nothing.++ */++const crypto = require('crypto');++const { PROVIDERS, createProviders, ProviderError } = require('./providers');++++class ConsoleError extends Error {++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }++}++++function keyFrom(raw) {++ const s = String(raw || '').trim();++ if (!s) return null;++ const buf = /^[0-9a-f]{64}$/i.test(s) ? Buffer.from(s, 'hex') : Buffer.from(s, 'base64');++ return buf.length === 32 ? buf : null;++}++++function createConsole({ store, config, catalog = null, fetchImpl, log = console }) {++ const { db } = store;++ const key = keyFrom(config.console.credentialsKey);++ const providers = createProviders({ fetchImpl });++ const limits = config.console;++ const q = {++ accounts: db.prepare('SELECT id, provider, label, token_hint, created_at, last_ok_at, last_error FROM host_console_accounts WHERE owner_subject = ? ORDER BY created_at'),++ account: db.prepare('SELECT * FROM host_console_accounts WHERE id = ? AND owner_subject = ?'),++ count: db.prepare('SELECT COUNT(*) AS n FROM host_console_accounts WHERE owner_subject = ?'),++ insert: db.prepare(`INSERT INTO host_console_accounts (id, owner_subject, provider, label, token_sealed, token_hint, created_at, last_ok_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),++ remove: db.prepare('DELETE FROM host_console_accounts WHERE id = ? AND owner_subject = ?'),++ ok: db.prepare('UPDATE host_console_accounts SET last_ok_at = ?, last_error = NULL WHERE id = ?'),++ failed: db.prepare('UPDATE host_console_accounts SET last_error = ? WHERE id = ?'),++ logAction: db.prepare(`INSERT INTO host_console_actions (owner_subject, account_id, provider, server_id, server_name, action, result, at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),++ actions: db.prepare('SELECT provider, server_name, action, result, at FROM host_console_actions WHERE owner_subject = ? ORDER BY at DESC LIMIT 20'),++ };++++ const available = () => Boolean(key);++ function seal(token) {++ const iv = crypto.randomBytes(12);++ const c = crypto.createCipheriv('aes-256-gcm', key, iv);++ const body = Buffer.concat([c.update(token, 'utf8'), c.final()]);++ return `v1.${iv.toString('base64')}.${c.getAuthTag().toString('base64')}.${body.toString('base64')}`;++ }++ function open(sealed) {++ const [v, iv, tag, body] = String(sealed).split('.');++ if (v !== 'v1') throw new ConsoleError(500, 'console.sealed', 'a stored token is in an unknown format');++ const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));++ d.setAuthTag(Buffer.from(tag, 'base64'));++ return Buffer.concat([d.update(Buffer.from(body, 'base64')), d.final()]).toString('utf8');++ }++ const need = () => { if (!available()) throw new ConsoleError(503, 'console.unavailable', 'Connecting hosting accounts is not set up on this server yet (HOST_CREDENTIALS_KEY).'); };++++ async function accounts(subject) { return await q.accounts.all(subject); }++++ async function connect(subject, { provider, token, label }) {++ need();++ if (!PROVIDERS[provider]) throw new ConsoleError(422, 'console.provider', 'pick one of the listed providers');++ const t = String(token || '').trim();++ if (t.length < 16 || t.length > 512 || /\s/.test(t)) throw new ConsoleError(422, 'console.token', 'paste the whole API token (no spaces)');++ if ((await q.count.get(subject)).n >= limits.maxAccounts) throw new ConsoleError(429, 'console.too_many', `you can connect ${limits.maxAccounts} accounts`);++ let found;++ try { found = await providers[provider].verify(t); } catch (err) {++ throw new ConsoleError(err instanceof ProviderError && err.status === 401 ? 422 : 502, 'console.verify', err.message);++ }++ const id = `hca_${crypto.randomBytes(12).toString('hex')}`;++ const now = store.now();++ await q.insert.run(id, subject, provider, String(label || '').trim().slice(0, 60) || String(found).slice(0, 60), seal(t), t.slice(-4), now, now);++ cache.delete(id);++ return id;++ }++++ async function disconnect(subject, id) {++ const r = await q.remove.run(id, subject);++ cache.delete(id);++ return r.changes > 0;++ }++++ // Each account's server list, kept a minute (a refresh asks again).++ const cache = new Map();++ async function serversOf(account, { fresh = false } = {}) {++ const hit = cache.get(account.id);++ if (!fresh && hit && store.now() - hit.at < 60000) return hit.value;++ let value;++ try {++ const list = await providers[account.provider].list(open(account.token_sealed));++ value = { servers: list.map((s) => ({ ...s, provider: account.provider, account_id: account.id })), error: null };++ await q.ok.run(store.now(), account.id);++ } catch (err) {++ value = { servers: [], error: err.message || 'the provider did not answer' };++ await q.failed.run(String(value.error).slice(0, 300), account.id);++ }++ cache.set(account.id, { value, at: store.now() });++ return value;++ }++++ /** Where the catalog has at least the same vCPU and memory for less (the cheapest such plan, 15% or more cheaper). */++ async function cheaper(server) {++ if (!catalog || !server.vcpu || !server.ram_mb || !server.price || server.price.monthly == null || server.price.currency !== 'USD') return null;++ try {++ const r = await catalog.plans({ min_vcpu: server.vcpu, min_ram_mb: Math.floor(server.ram_mb * 0.97), currency: 'USD', sort: 'price' });++ const best = (r.plans || []).find((p) => Number(p.price_monthly) > 0);++ if (!best || Number(best.price_monthly) > server.price.monthly * 0.85) return null;++ return { id: best.id, name: best.name, provider: best.provider_name, monthly: Number(best.price_monthly), vcpu: best.vcpu, ram_mb: best.ram_mb };++ } catch { return null; }++ }++++ /** Everything the console page shows for a person. */++ async function overview(subject, { fresh = false } = {}) {++ const accs = await accounts(subject);++ const results = await Promise.all(accs.map(async (a) => ({ account: a, ...(await serversOf({ ...a, token_sealed: (await q.account.get(a.id, subject)).token_sealed }, { fresh })) })));++ const servers = results.flatMap((r) => r.servers);++ for (const s of servers) s.cheaper = await cheaper(s);++ const totals = {};++ for (const s of servers) if (s.price && s.price.monthly != null) totals[s.price.currency] = Math.round(((totals[s.price.currency] || 0) + s.price.monthly) * 100) / 100;++ return {++ accounts: results.map((r) => ({ ...r.account, servers: r.servers.length, error: r.error })),++ servers, totals, running: servers.filter((s) => s.status === 'running').length,++ actions: await q.actions.all(subject),++ };++ }++++ async function act(subject, accountId, serverId, action) {++ need();++ if (!['start', 'stop', 'reboot'].includes(action)) throw new ConsoleError(422, 'console.action', 'the action is start, stop or reboot');++ const account = await q.account.get(accountId, subject);++ if (!account) throw new ConsoleError(404, 'console.account', 'no such connected account');++ const known = (await serversOf(account)).servers.find((s) => s.id === String(serverId));++ if (!known) throw new ConsoleError(404, 'console.server', 'that server is not in this account');++ let result = 'sent';++ try { await providers[account.provider].act(open(account.token_sealed), known.id, action); } catch (err) { result = `failed: ${err.message}`.slice(0, 300); }++ await q.logAction.run(subject, account.id, account.provider, known.id, String(known.name || '').slice(0, 120), action, result, store.now());++ cache.delete(account.id);++ if (result !== 'sent') throw new ConsoleError(502, 'console.action_failed', result.replace(/^failed: /, ''));++ return { server: known, action };++ }++++ return { available, accounts, connect, disconnect, overview, act, PROVIDERS, _seal: seal, _open: open };++}++++module.exports = { createConsole, ConsoleError, keyFrom };nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * /console on openvibe.host, with the person's Host session:++ *++ * GET /console every server across the connected accounts, totals, the forms++ * POST /console/accounts connect an account (provider, API token, an optional label)++ * POST /console/accounts/:id/remove disconnect it (the sealed token is deleted)++ * POST /console/accounts/:id/servers/:server/:action start, stop or reboot a server++ *++ * Every POST is a form from this site with its form token (auth/forms.js); answers are private and never cached.++ */++const express = require('express');++const { renderPage, esc } = require('../render/layout');++const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');++const { PROVIDERS } = require('./providers');++++const money = (n, cur) => (n == null ? '—' : new Intl.NumberFormat('en-US', { style: 'currency', currency: cur || 'USD', minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 }).format(n));++const ram = (mb) => (mb == null ? '—' : mb >= 1024 ? `${Math.round((mb / 1024) * 10) / 10} GB` : `${mb} MB`);++const STATUS = { running: ['Running', 'is-ok'], stopped: ['Stopped', 'is-off'], other: ['Changing', 'is-busy'] };++++function page({ viewer, config, data, csrf, notice, available }) {++ const acctCards = data.accounts.map((a) => `<li class="cs-acct">++ <div><b>${esc(PROVIDERS[a.provider].name)}</b> <span class="cs-muted">${esc(a.label)} · token …${esc(a.token_hint)}</span>++ <div class="cs-muted">${a.error ? `<span class="cs-bad">${esc(a.error)}</span>` : `${a.servers} server${a.servers === 1 ? '' : 's'}`}</div></div>++ <form method="post" action="/console/accounts/${esc(a.id)}/remove" onsubmit="return confirm('Disconnect this account? Its token is deleted from OpenVibe.Host.')"><input type="hidden" name="csrf" value="${esc(csrf)}"><button class="cs-btn cs-quiet" type="submit">Disconnect</button></form>++</li>`).join('');++ const rows = data.servers.map((s) => {++ const [label, cls] = STATUS[s.status] || STATUS.other;++ const btn = (action, text) => `<form method="post" action="/console/accounts/${esc(s.account_id)}/servers/${esc(encodeURIComponent(s.id))}/${action}"${action === 'stop' ? ` onsubmit="return confirm('Power off ${esc(String(s.name).replace(/'/g, ''))}?')"` : ''}><input type="hidden" name="csrf" value="${esc(csrf)}"><button class="cs-btn cs-small" type="submit">${text}</button></form>`;++ return `<tr>++ <td class="cs-name"><b>${esc(s.name)}</b><div class="cs-muted">${esc(PROVIDERS[s.provider].name)} · ${esc(s.region || '—')}${s.plan ? ` · ${esc(s.plan)}` : ''}</div></td>++ <td data-label="Status"><span class="cs-status ${cls}">${label}</span></td>++ <td data-label="Size">${s.vcpu ?? '—'} vCPU · ${ram(s.ram_mb)}${s.disk_gb ? ` · ${s.disk_gb} GB` : ''}</td>++ <td data-label="IPv4" class="cs-mono">${esc(s.ipv4 || '—')}</td>++ <td data-label="Monthly" class="cs-num">${money(s.price?.monthly, s.price?.currency)}${s.cheaper ? `<div class="cs-save"><a href="/catalog/plans/${esc(s.cheaper.id)}">${esc(s.cheaper.provider)} has it for ${money(s.cheaper.monthly, 'USD')}</a></div>` : ''}</td>++ <td class="cs-act-cell"><div class="cs-actions">${s.status === 'running' ? btn('reboot', 'Reboot') + btn('stop', 'Power off') : s.status === 'stopped' ? btn('start', 'Power on') : ''}</div></td>++</tr>`;++ }).join('');++ const totals = Object.entries(data.totals).map(([cur, n]) => money(n, cur)).join(' + ') || '—';++ const body = `<section class="cs">++ <header class="cs-head">++ <div><p class="cs-eyebrow">OpenVibe.Host · console</p><h1>Your servers, every provider</h1>++ <p class="cs-lede">Connect your own hosting accounts and see and run all your servers in one place, with where the catalog has the same size for less.</p></div>++ <div class="cs-stats"><div><b>${data.servers.length}</b><span>servers</span></div><div><b>${data.running}</b><span>running</span></div><div><b>${esc(totals)}</b><span>a month</span></div></div>++ </header>++ ${data.servers.length ? `<div class="cs-table-wrap"><table class="cs-table"><thead><tr><th>Server</th><th>Status</th><th>Size</th><th>IPv4</th><th>Monthly</th><th><a class="cs-refresh" href="/console?fresh=1">Refresh</a></th></tr></thead><tbody>${rows}</tbody></table></div>`++ : `<p class="cs-empty">${data.accounts.length ? 'No servers in the connected accounts yet.' : 'Connect a hosting account below to see its servers here.'}</p>`}++ <div class="cs-grid">++ <section class="cs-card" aria-labelledby="cs-accts"><h2 id="cs-accts">Connected accounts</h2>++ ${data.accounts.length ? `<ul class="cs-accts">${acctCards}</ul>` : '<p class="cs-muted">None yet.</p>'}++ </section>++ <section class="cs-card" aria-labelledby="cs-add"><h2 id="cs-add">Connect an account</h2>++ ${available ? `<form method="post" action="/console/accounts" class="cs-form">++ <input type="hidden" name="csrf" value="${esc(csrf)}">++ <label>Provider<select name="provider" required>${Object.entries(PROVIDERS).map(([k, p]) => `<option value="${k}">${esc(p.name)}</option>`).join('')}</select></label>++ <label>API token<input name="token" type="password" autocomplete="off" required minlength="16" maxlength="512" placeholder="Paste the token"></label>++ <label><span>Label <span class="cs-muted">(optional)</span></span><input name="label" maxlength="60" placeholder="Work, side projects…"></label>++ <button class="cs-btn" type="submit">Connect</button>++ <details class="cs-help"><summary>Where do I get a token?</summary><ul>${Object.values(PROVIDERS).map((p) => `<li><a href="${esc(p.tokenUrl)}" rel="noopener" target="_blank">${esc(p.name)}</a>: ${esc(p.tokenHelp)}</li>`).join('')}</ul></details>++ <p class="cs-muted">The token is checked with the provider, then stored encrypted and used only to call that provider for you. Disconnecting deletes it.</p>++ </form>` : '<p class="cs-muted">Connecting accounts is not set up on this server yet.</p>'}++ </section>++ </div>++ ${data.actions.length ? `<section class="cs-card"><h2>Recent actions</h2><ul class="cs-log">${data.actions.map((a) => `<li><span class="cs-muted">${esc(new Date(Number(a.at)).toISOString().replace('T', ' ').slice(0, 16))}</span> ${esc(a.action)} <b>${esc(a.server_name)}</b> (${esc(PROVIDERS[a.provider]?.name || a.provider)}): ${esc(a.result)}</li>`).join('')}</ul></section>` : ''}++</section>`;++ return renderPage({ title: 'Console', body, viewer, config, path: '/console', notice, css: ['css/console.css'] });++}++++function createConsolePages(ctx) {++ const { config, viewers, console: con, log = console } = ctx;++ const router = express.Router();++ const urlencoded = express.urlencoded({ extended: false, limit: '8kb' });++ router.use(viewers.middleware('dashboard'));++ if (ctx.actorLimits) router.use(ctx.actorLimits);++ router.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user';++ const back = (res, kind, text) => res.redirect(303, `/console?${new URLSearchParams({ kind, notice: text })}`);++++ router.get('/', async (req, res, next) => {++ try {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fconsole');++ const data = await con.overview(req.viewer.subject, { fresh: req.query.fresh === '1' });++ const n = typeof req.query.notice === 'string' && req.query.notice.length < 300 ? { kind: req.query.kind === 'error' ? 'error' : 'info', text: req.query.notice } : null;++ res.type('html').send(page({ viewer: req.viewer, config, data, csrf: csrfToken(config, req.viewer), notice: n, available: con.available() }));++ } catch (err) { next(err); }++ });++++ const action = (fn) => [urlencoded, async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fconsole');++ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return back(res, 'error', 'That form expired or did not come from this page; try again.');++ try { await fn(req, res); } catch (err) {++ if (!err.status || err.status >= 500 && err.code !== 'console.action_failed' && err.code !== 'console.verify') log.error('[Host console]', err && err.stack ? err.stack : err);++ back(res, 'error', err.status && err.status < 600 && err.message ? err.message : 'Something went wrong on our side.');++ }++ }];++ router.post('/accounts', ...action(async (req, res) => {++ await con.connect(req.viewer.subject, { provider: req.body.provider, token: req.body.token, label: req.body.label });++ back(res, 'info', `${PROVIDERS[req.body.provider].name} connected.`);++ }));++ router.post('/accounts/:id/remove', ...action(async (req, res) => {++ await con.disconnect(req.viewer.subject, req.params.id);++ back(res, 'info', 'Account disconnected; its token is deleted.');++ }));++ router.post('/accounts/:id/servers/:server/:action', ...action(async (req, res) => {++ const r = await con.act(req.viewer.subject, req.params.id, req.params.server, req.params.action);++ back(res, 'info', `${{ start: 'Power on', stop: 'Power off', reboot: 'Reboot' }[r.action]} sent to ${r.server.name}.`);++ }));++ return router;++}++++module.exports = { createConsolePages };nnew file mode 100644@@@ -0,0 +1,140 @@++'use strict';++/**++ * The hosting accounts the console can combine, each through the provider's own documented API and the person's own++ * token (sent only to that provider's API host, never anywhere else):++ *++ * linode https://api.linode.com/v4 Bearer personal access token (Linodes: read/write)++ * digitalocean https://api.digitalocean.com/v2 Bearer personal access token++ * vultr https://api.vultr.com/v2 Bearer API key++ * hetzner https://api.hetzner.cloud/v1 Bearer project API token++ *++ * Every adapter answers verify(token) → a label for the account, list(token) → servers in one shape++ * { id, name, status: 'running' | 'stopped' | 'other', region, vcpu, ram_mb, disk_gb, ipv4, ipv6, plan, price: { monthly, currency }, created_at },++ * and act(token, id, 'start' | 'stop' | 'reboot').++ */++const PROVIDERS = {++ linode: { name: 'Linode (Akamai)', api: 'https://api.linode.com/v4', tokenUrl: 'https://cloud.linode.com/profile/tokens', tokenHelp: 'A personal access token with Linodes: read/write.' },++ digitalocean: { name: 'DigitalOcean', api: 'https://api.digitalocean.com/v2', tokenUrl: 'https://cloud.digitalocean.com/account/api/tokens', tokenHelp: 'A personal access token (read and write, or droplet scopes).' },++ vultr: { name: 'Vultr', api: 'https://api.vultr.com/v2', tokenUrl: 'https://my.vultr.com/settings/#settingsapi', tokenHelp: 'Your API key (allow this server\'s address if access control is on).' },++ hetzner: { name: 'Hetzner Cloud', api: 'https://api.hetzner.cloud/v1', tokenUrl: 'https://console.hetzner.cloud/', tokenHelp: 'A project API token (Security → API tokens, read and write).' },++};++++class ProviderError extends Error {++ constructor(message, status = 502) { super(message); this.status = status; }++}++++function createProviders({ fetchImpl = globalThis.fetch, timeoutMs = 15000 } = {}) {++ async function call(provider, token, path, { method = 'GET', body } = {}) {++ const base = PROVIDERS[provider].api;++ let res;++ try {++ res = await fetchImpl(`${base}${path}`, {++ method, headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },++ body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs), redirect: 'error',++ });++ } catch (err) {++ throw new ProviderError(`${PROVIDERS[provider].name} did not answer (${err && err.name === 'TimeoutError' ? 'timed out' : 'unreachable'})`);++ }++ const text = await res.text();++ let json = null;++ try { json = text ? JSON.parse(text) : null; } catch { json = null; }++ if (res.status === 401 || res.status === 403) throw new ProviderError(`${PROVIDERS[provider].name} refused the token (${res.status}): check it is valid and allows this`, 401);++ if (!res.ok) {++ const why = json && (json.errors?.[0]?.reason || json.message || json.error?.message || json.error);++ throw new ProviderError(`${PROVIDERS[provider].name} answered ${res.status}${why ? `: ${String(why).slice(0, 160)}` : ''}`);++ }++ return json || {};++ }++++ // Public price lists, cached (no token needed).++ const priceCache = new Map();++ async function cachedPublic(key, load) {++ const hit = priceCache.get(key);++ if (hit && Date.now() - hit.at < 6 * 3600 * 1000) return hit.value;++ const value = await load();++ priceCache.set(key, { value, at: Date.now() });++ return value;++ }++ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);++ const st = (s) => (['running', 'active'].includes(s) ? 'running' : ['offline', 'stopped', 'off', 'halted'].includes(s) ? 'stopped' : 'other');++++ const adapters = {++ linode: {++ async verify(token) { const p = await call('linode', token, '/profile'); return p.username ? `${p.username}` : 'Linode account'; },++ async list(token) {++ const r = await call('linode', token, '/linode/instances?page_size=200');++ const types = await cachedPublic('linode:types', async () => {++ const t = await call('linode', token, '/linode/types?page_size=200');++ return Object.fromEntries((t.data || []).map((x) => [x.id, x]));++ });++ return (r.data || []).map((l) => ({++ id: String(l.id), name: l.label, status: st(l.status), region: l.region, vcpu: num(l.specs?.vcpus), ram_mb: num(l.specs?.memory),++ disk_gb: num(l.specs?.disk) != null ? Math.round(l.specs.disk / 1024) : null, ipv4: (l.ipv4 || [])[0] || null, ipv6: l.ipv6 ? String(l.ipv6).split('/')[0] : null,++ plan: l.type, price: { monthly: num(types[l.type]?.price?.monthly), currency: 'USD' }, created_at: l.created,++ }));++ },++ async act(token, id, action) {++ const path = { start: 'boot', stop: 'shutdown', reboot: 'reboot' }[action];++ await call('linode', token, `/linode/instances/${encodeURIComponent(id)}/${path}`, { method: 'POST', body: {} });++ },++ },++ digitalocean: {++ async verify(token) { const a = await call('digitalocean', token, '/account'); return a.account?.email || 'DigitalOcean account'; },++ async list(token) {++ const r = await call('digitalocean', token, '/droplets?per_page=200');++ return (r.droplets || []).map((d) => ({++ id: String(d.id), name: d.name, status: d.status === 'active' ? 'running' : d.status === 'off' ? 'stopped' : 'other', region: d.region?.slug || null,++ vcpu: num(d.vcpus), ram_mb: num(d.memory), disk_gb: num(d.disk), ipv4: (d.networks?.v4 || []).find((n) => n.type === 'public')?.ip_address || null,++ ipv6: (d.networks?.v6 || []).find((n) => n.type === 'public')?.ip_address || null, plan: d.size_slug || d.size?.slug || null,++ price: { monthly: num(d.size?.price_monthly), currency: 'USD' }, created_at: d.created_at,++ }));++ },++ async act(token, id, action) {++ const type = { start: 'power_on', stop: 'power_off', reboot: 'reboot' }[action];++ await call('digitalocean', token, `/droplets/${encodeURIComponent(id)}/actions`, { method: 'POST', body: { type } });++ },++ },++ vultr: {++ async verify(token) { const a = await call('vultr', token, '/account'); return a.account?.email || a.account?.name || 'Vultr account'; },++ async list(token) {++ const r = await call('vultr', token, '/instances?per_page=500');++ const plans = await cachedPublic('vultr:plans', async () => {++ const p = await call('vultr', token, '/plans?per_page=500');++ return Object.fromEntries((p.plans || []).map((x) => [x.id, x]));++ });++ return (r.instances || []).map((v) => ({++ id: String(v.id), name: v.label || v.hostname || v.id, status: v.power_status === 'running' ? 'running' : v.power_status === 'stopped' ? 'stopped' : st(v.status),++ region: v.region, vcpu: num(v.vcpu_count), ram_mb: num(v.ram), disk_gb: num(v.disk), ipv4: v.main_ip || null, ipv6: v.v6_main_ip || null,++ plan: v.plan, price: { monthly: num(plans[v.plan]?.monthly_cost), currency: 'USD' }, created_at: v.date_created,++ }));++ },++ async act(token, id, action) {++ const path = { start: 'start', stop: 'halt', reboot: 'reboot' }[action];++ await call('vultr', token, `/instances/${encodeURIComponent(id)}/${path}`, { method: 'POST', body: {} });++ },++ },++ hetzner: {++ async verify(token) { await call('hetzner', token, '/servers?per_page=1'); return 'Hetzner Cloud project'; },++ async list(token) {++ const r = await call('hetzner', token, '/servers?per_page=50');++ return (r.servers || []).map((h) => {++ const loc = h.datacenter?.location?.name;++ const price = (h.server_type?.prices || []).find((p) => p.location === loc) || (h.server_type?.prices || [])[0];++ return {++ id: String(h.id), name: h.name, status: st(h.status), region: loc || null, vcpu: num(h.server_type?.cores),++ ram_mb: num(h.server_type?.memory) != null ? Math.round(h.server_type.memory * 1024) : null, disk_gb: num(h.server_type?.disk),++ ipv4: h.public_net?.ipv4?.ip || null, ipv6: h.public_net?.ipv6?.ip ? String(h.public_net.ipv6.ip).split('/')[0] : null,++ plan: h.server_type?.name || null, price: { monthly: num(price?.price_monthly?.gross), currency: 'EUR' }, created_at: h.created,++ };++ });++ },++ async act(token, id, action) {++ const path = { start: 'poweron', stop: 'shutdown', reboot: 'reboot' }[action];++ await call('hetzner', token, `/servers/${encodeURIComponent(id)}/actions/${path}`, { method: 'POST' });++ },++ },++ };++ return adapters;++}++++module.exports = { PROVIDERS, createProviders, ProviderError };@@@ -21,6 +21,13 @@ const telemetryRetention = startTelemetryRetention({ db: ctx.store.db, log: consserver.requestTimeout = 30_000;ctx.outbox.start();ctx.worker.start();++// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,++// HOST_EVENTS_SECRET or the client secret.++const { startSubscriptions, TOPICS } = require('openvibe-sdk/account-data');++const subscriptions = startSubscriptions({++ topics: TOPICS, eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++});// Signals belong to the kit: the worker stops taking new work, requests in flight drain for at most// 4000 ms (a shorter drain than the 10 min upload timeout cuts an upload exactly as the old 5 s@@@ -29,7 +36,7 @@ ctx.worker.start();const { stop } = gracefulStop({name: 'Host',server,-- stop: [() => ctx.worker.stop()],++ stop: [() => ctx.worker.stop(), () => { if (subscriptions) subscriptions.stop(); }],close: [() => ctx.outbox.stop(),() => ctx.stopMetrics(),@@@ -14,7 +14,7 @@ const frame = require('openvibe-shared/frame');const shell = require('openvibe-shared/shell');const NETWORK_URL = 'https://openvibe.network';--const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Domains', href: '/catalog/domains' }, { label: 'Benchmarks', href: '/catalog/benchmarks' }, { label: 'Offers', href: '/catalog/offers' }, { label: 'Promotions', href: '/catalog/promos' }];++const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Console', href: '/console' }, { label: 'Domains', href: '/catalog/domains' }, { label: 'Benchmarks', href: '/catalog/benchmarks' }, { label: 'Offers', href: '/catalog/offers' }, { label: 'Promotions', href: '/catalog/promos' }];const DESCRIPTION = 'OpenVibe.Host: hosting plans compared, every price dated and sourced.';const SITE_NAME = 'OpenVibe.Host';const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');@@@ -10,7 +10,7 @@ const assert = require('assert');const { boot, check, done } = require('./helpers/boot');const { parseYabs, LINKS_PER_DAY } = require('../server/catalog/benchmarks');const { csrfToken } = require('../server/auth/forms');--const accountData = require('../server/catalog/account-data');++const accountData = require('../server/account-data');const YABS = () => ({version: 'v2026-10-07', time: '20261016-120000',@@@ -145,13 +145,16 @@ const linode = (time = '2026-09-22T11:00:00Z') => ({ id: `itm_${'1'.repeat(26)}`const own = await t.api('GET', `/catalog/benchmarks/${id}`, { session: ada });assert.match(own.text, /Remove this result/);assert.strictEqual((await t.api('DELETE', `/api/v1/catalog/benchmarks/${id}`, { as: bo })).status, 403);-- const exported = await accountData.exportFor(t.ctx.store.db, ada.subject);-- assert.strictEqual(exported.catalog_benchmarks.length, 1);-- assert.ok(exported.catalog_bench_links.length >= 2);++ const data = accountData.create({ db: t.ctx.store.db, log: { warn() {}, info() {} } });++ const file = (part, name) => (part.files.find((f) => f.name === name) || { content: [] }).content;++ const exported = await data.exportPart(ada.subject);++ assert.strictEqual(file(exported, 'benchmarks.json').length, 1);++ assert.ok(file(exported, 'benchmark-links.json').length >= 2);++ assert.ok(file(exported, 'benchmark-links.json').every((l) => !('token_hash' in l)), 'never the link token');assert.strictEqual((await t.api('DELETE', `/api/v1/catalog/benchmarks/${id}`, { as: ada })).status, 200);assert.strictEqual((await t.api('GET', `/catalog/benchmarks/${id}`)).status, 404);-- const erased = await accountData.eraseFor(t.ctx.store.db, bo.subject);-- assert.deepStrictEqual([erased.catalog_benchmarks, erased.catalog_bench_links > 0], [1, true]);++ const erased = await data.erase([bo.subject]);++ assert.deepStrictEqual([erased.retained.tombstones, erased.erased.catalog_bench_links > 0], [1, true]);assert.strictEqual((await t.api('GET', '/api/v1/catalog/benchmarks?provider=racknerd')).json().benchmarks.length, 1, 'the measurement stays, anonymous');});} finally { await t.close(); }@@@ -6,7 +6,7 @@ const { createStore } = require('../server/db');const { createCatalog } = require('../server/catalog/store');const { createCatalogIngest } = require('../server/catalog/ingest');const { mapItem } = require('../server/catalog/mapping');--const accountData = require('../server/catalog/account-data');++const accountData = require('../server/account-data');const { check, done } = require('./helpers/boot');const at = '2026-10-11T12:00:00Z';// The shape the Sources `api` adapter stores: the plan id as identity, Linode's label as title, the mapped extras flat in fields.@@@ -100,8 +100,10 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repea});await check('ADR-033 export and erase keep shared rows but remove attribution', async () => {const p = await catalog.save('providers', { slug: 'staff', name: 'Staff', homepage_url: 'https://staff.test' }, null, 'usr_1');-- assert.strictEqual((await accountData.exportFor(db.db, 'usr_1')).catalog_providers.length, 1);-- assert.strictEqual((await accountData.eraseFor(db.db, 'usr_1')).catalog_providers, 1);++ const data = accountData.create({ db: db.db, log: { warn() {}, info() {} } });++ const part = await data.exportPart('usr_1');++ assert.strictEqual(part.files.find((f) => f.name === 'catalog-providers.json').content.length, 1);++ assert.strictEqual((await data.erase(['usr_1'])).retained.tombstones, 1);assert.strictEqual((await catalog.get('providers', p.id)).created_by, null);});await check('unknown source is held and ingest off consumes nothing', async () => {nnew file mode 100644@@@ -0,0 +1,149 @@++'use strict';++/**++ * The console (server/console/): a person connects their own hosting accounts and sees and runs every server in one++ * place. The token is checked with the provider, sealed (never stored or shown in clear), and sent only to that++ * provider; a refused token connects nothing. The list combines providers with prices, totals and the catalog's cheaper++ * equivalent; power actions reach the provider and are logged; nobody acts on another person's account; disconnecting++ * deletes the token. Account export carries the accounts without their tokens, and deletion removes them. Without++ * HOST_CREDENTIALS_KEY nothing is connected. Fake provider APIs stand in for Linode and DigitalOcean.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const ORIGIN = 'https://openvibe.host';++const KEY = 'a'.repeat(64);++const LINODE_TOKEN = 'linode-token-0123456789abcdef';++const DO_TOKEN = 'do-token-0123456789abcdef0000';++++function fakeProviders() {++ const calls = [];++ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });++ const fetchImpl = async (url, init = {}) => {++ const u = new URL(String(url));++ const auth = (init.headers && (init.headers.Authorization || init.headers.authorization)) || '';++ const method = init.method || 'GET';++ if (u.hostname === 'api.linode.com') {++ calls.push({ host: 'linode', method, path: u.pathname, auth });++ if (auth !== `Bearer ${LINODE_TOKEN}`) return json(401, { errors: [{ reason: 'Invalid Token' }] });++ if (u.pathname === '/v4/profile') return json(200, { username: 'alice-linode' });++ if (u.pathname === '/v4/linode/types') return json(200, { data: [{ id: 'g6-standard-2', price: { monthly: 24 } }] });++ if (u.pathname === '/v4/linode/instances') return json(200, { data: [{ id: 101, label: 'web-1', region: 'us-east', status: 'running', type: 'g6-standard-2', specs: { vcpus: 2, memory: 4096, disk: 81920 }, ipv4: ['203.0.113.10'], ipv6: '2001:db8::1/128', created: '2026-01-01T00:00:00' }] });++ if (method === 'POST' && /^\/v4\/linode\/instances\/101\/(boot|shutdown|reboot)$/.test(u.pathname)) return json(200, {});++ return json(404, { errors: [{ reason: 'Not found' }] });++ }++ if (u.hostname === 'api.digitalocean.com') {++ calls.push({ host: 'do', method, path: u.pathname, auth, body: init.body });++ if (auth !== `Bearer ${DO_TOKEN}`) return json(401, { message: 'Unable to authenticate you' });++ if (u.pathname === '/v2/account') return json(200, { account: { email: '[email protected]' } });++ if (u.pathname === '/v2/droplets') return json(200, { droplets: [{ id: 202, name: 'db-1', status: 'off', vcpus: 1, memory: 1024, disk: 25, region: { slug: 'fra1' }, size_slug: 's-1vcpu-1gb', size: { price_monthly: 6 }, networks: { v4: [{ type: 'public', ip_address: '198.51.100.7' }] }, created_at: '2026-02-01T00:00:00Z' }] });++ if (method === 'POST' && u.pathname === '/v2/droplets/202/actions') return json(201, { action: { id: 1 } });++ return json(404, { message: 'Not found' });++ }++ return globalThis.fetch(url, init);++ };++ return { calls, fetchImpl };++}++++(async () => {++ const fake = fakeProviders();++ const sent = [];++ const accountSend = async (path, body) => { sent.push({ path, body }); return { ok: true, status: 200, json: async () => ({}) }; };++ const t = await boot({ fetchImpl: fake.fetchImpl, accountSend, env: { HOST_CREDENTIALS_KEY: KEY } });++ const { csrfToken } = require('../server/auth/forms');++ const alice = t.user('alice'), bob = t.user('bob'), staff = t.user('editor', { role: 'admin' });++ const csrf = (u) => csrfToken(t.config, { subject: u.subject });++ const post = (u, path, form) => t.api('POST', path, { session: u, form: { csrf: csrf(u), ...form }, headers: { origin: ORIGIN } });++ const notice = (r) => new URLSearchParams(r.headers.location.split('?')[1] || '').get('notice');++ let linodeAcct;++ try {++ await check('signed out, the console sends you to sign in; signed in with nothing connected, it says how to start', async () => {++ const out = await t.api('GET', '/console');++ assert.strictEqual(out.status, 303);++ assert.match(out.headers.location, /^\/auth\/login\?next=%2Fconsole/);++ const r = await t.api('GET', '/console', { session: alice });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Your servers, every provider/);++ assert.match(r.text, /Connect a hosting account below/);++ assert.match(r.headers['cache-control'], /no-store/);++ });++++ await check('a refused token connects nothing; a good one is checked, sealed and labelled', async () => {++ const bad = await post(alice, '/console/accounts', { provider: 'linode', token: 'not-the-right-token-xyz' });++ assert.match(notice(bad), /refused the token/);++ assert.strictEqual(Number((await t.ctx.store.db.prepare('SELECT count(*) AS n FROM host_console_accounts').get()).n), 0);++ const ok = await post(alice, '/console/accounts', { provider: 'linode', token: LINODE_TOKEN });++ assert.match(notice(ok), /Linode \(Akamai\) connected/);++ const row = await t.ctx.store.db.prepare('SELECT * FROM host_console_accounts WHERE owner_subject = ?').get(alice.subject);++ linodeAcct = row.id;++ assert.deepStrictEqual([row.label, row.token_hint], ['alice-linode', 'cdef']);++ assert.ok(!row.token_sealed.includes(LINODE_TOKEN) && row.token_sealed.startsWith('v1.'), 'only the sealed token is stored');++ assert.strictEqual(t.ctx.console._open(row.token_sealed), LINODE_TOKEN);++ await post(alice, '/console/accounts', { provider: 'digitalocean', token: DO_TOKEN, label: 'Side projects' });++ const forged = await t.api('POST', '/console/accounts', { session: alice, form: { csrf: 'nope', provider: 'linode', token: LINODE_TOKEN }, headers: { origin: ORIGIN } });++ assert.match(notice(forged), /form expired/);++ });++++ await check('the console combines both providers: status, size, address, prices, totals, and a cheaper catalog plan', async () => {++ const p = await t.api('POST', '/api/v1/catalog/providers', { as: staff, json: { slug: 'cheapco', name: 'CheapCo', homepage_url: 'https://cheap.example' } });++ await t.api('POST', '/api/v1/catalog/plans', { as: staff, json: { provider_id: p.json().provider.id, external_id: 'c2', name: 'C2', kind: 'vps', currency: 'USD', price_monthly: 5, vcpu: 2, ram_mb: 4096, disk_gb: 40, source_url: 'https://cheap.example/pricing' } });++ const r = await t.api('GET', '/console?fresh=1', { session: alice });++ assert.match(r.text, /<b>web-1<\/b>/);++ assert.match(r.text, /<b>db-1<\/b>/);++ assert.match(r.text, /203\.0\.113\.10/);++ assert.match(r.text, /\$24</);++ assert.match(r.text, /CheapCo has it for \$5/, 'the catalog has the same size for less');++ assert.match(r.text, /<b>\$30<\/b><span>a month<\/span>/, 'the totals add both');++ assert.match(r.text, /Power on/, 'the stopped droplet can be powered on');++ assert.ok(fake.calls.every((c) => (c.host === 'linode' && c.auth.endsWith(LINODE_TOKEN)) || (c.host === 'do' && (c.auth.endsWith(DO_TOKEN) || c.auth.endsWith('xyz'))) || c.auth.endsWith('xyz')), 'each token goes only to its provider');++ });++++ await check('power actions reach the provider and are logged; nobody acts on another person\'s account', async () => {++ const r = await post(alice, `/console/accounts/${linodeAcct}/servers/101/stop`, {});++ assert.match(notice(r), /Power off sent to web-1/);++ assert.ok(fake.calls.some((c) => c.host === 'linode' && c.method === 'POST' && c.path === '/v4/linode/instances/101/shutdown'));++ const doAcct = (await t.ctx.store.db.prepare("SELECT id FROM host_console_accounts WHERE provider = 'digitalocean'").get()).id;++ await post(alice, `/console/accounts/${doAcct}/servers/202/start`, {});++ assert.ok(fake.calls.some((c) => c.host === 'do' && c.path === '/v2/droplets/202/actions' && /power_on/.test(c.body)));++ const page = await t.api('GET', '/console', { session: alice });++ assert.match(page.text, /stop <b>web-1<\/b> \(Linode \(Akamai\)\): sent/);++ const intruder = await post(bob, `/console/accounts/${linodeAcct}/servers/101/reboot`, {});++ assert.match(notice(intruder), /no such connected account/);++ const unknown = await post(alice, `/console/accounts/${linodeAcct}/servers/999/reboot`, {});++ assert.match(notice(unknown), /not in this account/);++ });++++ await check('account export carries the accounts and actions without the tokens; deletion removes them', async () => {++ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });++ assert.strictEqual(await t.app.locals.accountData.apply(ev('evt_01JZ0000000000000000HC0001', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000HCX001', subject: alice.subject }), { send: accountSend }), 'exported');++ const part = sent.find((c) => c.path === '/internal/account-exports/exp_01JZ0000000000000000HCX001/parts');++ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));++ assert.deepStrictEqual(files['console-accounts.json'].map((a) => a.provider).sort(), ['digitalocean', 'linode']);++ assert.ok(!/token_sealed|v1\.|linode-token|do-token/.test(JSON.stringify(part.body)), 'no token in the export');++ assert.ok(files['console-actions.json'].some((a) => a.action === 'stop'));++ assert.strictEqual(await t.app.locals.accountData.apply(ev('evt_01JZ0000000000000000HD0001', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000HDE001', subject: alice.subject }), { send: accountSend }), 'erased');++ assert.strictEqual(Number((await t.ctx.store.db.prepare('SELECT count(*) AS n FROM host_console_accounts WHERE owner_subject = ?').get(alice.subject)).n), 0);++ assert.strictEqual(Number((await t.ctx.store.db.prepare('SELECT count(*) AS n FROM host_console_actions WHERE owner_subject = ?').get(alice.subject)).n), 0);++ });++++ await check('disconnecting deletes the token', async () => {++ await post(bob, '/console/accounts', { provider: 'linode', token: LINODE_TOKEN });++ const id = (await t.ctx.store.db.prepare('SELECT id FROM host_console_accounts WHERE owner_subject = ?').get(bob.subject)).id;++ const r = await post(bob, `/console/accounts/${id}/remove`, {});++ assert.match(notice(r), /token is deleted/);++ assert.strictEqual(Number((await t.ctx.store.db.prepare('SELECT count(*) AS n FROM host_console_accounts WHERE id = ?').get(id)).n), 0);++ });++ } finally { await t.close(); }++++ const t2 = await boot({ fetchImpl: fake.fetchImpl, env: { HOST_CREDENTIALS_KEY: '' } });++ try {++ await check('without HOST_CREDENTIALS_KEY nothing is connected', async () => {++ const carol = t2.user('carol');++ const page = await t2.api('GET', '/console', { session: carol });++ assert.match(page.text, /not set up on this server yet/);++ const { csrfToken: tok } = require('../server/auth/forms');++ const r = await t2.api('POST', '/console/accounts', { session: carol, form: { csrf: tok(t2.config, { subject: carol.subject }), provider: 'linode', token: LINODE_TOKEN }, headers: { origin: ORIGIN } });++ assert.match(new URLSearchParams(r.headers.location.split('?')[1]).get('notice'), /HOST_CREDENTIALS_KEY/);++ });++ } finally { await t2.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -39,7 +39,7 @@ async function boot(opts = {}) {async function start() {const config = configLib.load(env);-- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, fetchImpl: opts.fetchImpl, rdap: opts.rdap });++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, 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;
@@ -32,3 +32,9 @@ HOST_CATALOG_WEBHOOK_SECRETS=
@@ -32,3 +32,9 @@ HOST_CATALOG_WEBHOOK_SECRETS=
HOST_COMMUNITY_URL=
HOST_COMMUNITY_URL=
OV_COMMUNITY_URL=https://openvibe.community
OV_COMMUNITY_URL=https://openvibe.community
OV_WATCH_URL=https://openvibe.watch
OV_WATCH_URL=https://openvibe.watch
+
+# The console (server/console/): seals people's hosting API tokens (32 bytes, hex or base64). Unset: the console connects nothing.
+#HOST_CREDENTIALS_KEY=
+#HOST_CONSOLE_MAX_ACCOUNTS=10
+# OpenVibe.Events → Host (account export and deletion): signs deliveries to /internal/events (32+ characters).
+#HOST_EVENTS_SECRET=
@@ -35,6 +35,26 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
@@ -35,6 +35,26 @@ OpenVibe's own static hosting moved to OpenVibe.Space ([below](#static-hosting-m
- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id`, `/catalog/promos`, `/catalog/offers`, `/catalog/domains` (with each ending's page and the name check) and `/catalog/benchmarks`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id`, `/catalog/promos`, `/catalog/offers`, `/catalog/domains` (with each ending's page and the name check) and `/catalog/benchmarks`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
- **Discussion.** Active public provider and plan pages lazily resolve Community threads with refs `{ service: 'host', type: 'provider', id: provider.id }` and `{ service: 'host', type: 'plan', id: plan.id }` (plus a display label), then render recent comments with a link to the thread on OpenVibe.Community. Host stores only the thread reference. `HOST_COMMUNITY_URL` enables the internal client; `OV_COMMUNITY_URL` sets the public link. With the internal URL unset, no Discussion block appears. Hidden providers and hidden or gone plans have no public page; status changes hide or reopen existing threads after commit. Host's Network service principal needs `community.comment.write` and `community.comment.moderate` for audience `openvibe.community`.
- **Discussion.** Active public provider and plan pages lazily resolve Community threads with refs `{ service: 'host', type: 'provider', id: provider.id }` and `{ service: 'host', type: 'plan', id: plan.id }` (plus a display label), then render recent comments with a link to the thread on OpenVibe.Community. Host stores only the thread reference. `HOST_COMMUNITY_URL` enables the internal client; `OV_COMMUNITY_URL` sets the public link. With the internal URL unset, no Discussion block appears. Hidden providers and hidden or gone plans have no public page; status changes hide or reopen existing threads after commit. Host's Network service principal needs `community.comment.write` and `community.comment.moderate` for audience `openvibe.community`.
+## The console (your servers, every provider)
+
+Plan T20 step 3, the owner's "our own system that combines em somehow". At `/console` a signed-in person connects their
+own hosting accounts (`server/console/`): Linode (Akamai), DigitalOcean, Vultr and Hetzner Cloud, with their own API
+tokens.
+
+- **Tokens.** Each token is checked with its provider when added, then sealed with AES-256-GCM under
+ `HOST_CREDENTIALS_KEY` (32 bytes, hex or base64). It is never shown again; only its last four characters are kept in
+ clear, to tell tokens apart. It is opened only to call that provider's API host. Disconnecting deletes it.
+- **The server list.** Every server across the accounts in one list: status, region, plan, size, IPv4 and monthly
+ price, with totals per currency. Each account is cached for a minute; Refresh asks again.
+- **Actions.** Power on, power off and reboot reach the provider, and each one is logged for the person.
+- **The comparison.** When the catalog has a plan with at least the same vCPU and memory for 15% less, the server
+ links to it.
+- **Account data.** Export and deletion (ADR-033) go through openvibe-sdk/account-data (`server/account-data.js`,
+ signed with `HOST_EVENTS_SECRET`). The export carries the accounts (never a token) and the action log; deletion
+ removes both.
+- **Without the key.** Without `HOST_CREDENTIALS_KEY` the console connects nothing and says so.
+- Migration 0013 creates `host_console_accounts`, `host_console_actions` and `account_data_events`.
+
## Static hosting moved
## Static hosting moved
The static hosting engine (Stage B: projects, sites, immutable content-addressed deploys, activation and rollback, previews, Git ingest, per-site configuration, custom domains, takedowns, the resource index and the dashboard) moved to **[OpenVibe.Space](https://openvibe.space)** on 2026-10-12 (plan T20 step 2), where sites are served at `<name>.openvibe.website` under `space.*` capabilities and events (openvibe-contracts 0.149.0). Production Host had 0 projects, 0 sites and 0 deploys, so no data moved.
The static hosting engine (Stage B: projects, sites, immutable content-addressed deploys, activation and rollback, previews, Git ingest, per-site configuration, custom domains, takedowns, the resource index and the dashboard) moved to **[OpenVibe.Space](https://openvibe.space)** on 2026-10-12 (plan T20 step 2), where sites are served at `<name>.openvibe.website` under `space.*` capabilities and events (openvibe-contracts 0.149.0). Production Host had 0 projects, 0 sites and 0 deploys, so no data moved.
@@ -77,6 +97,7 @@ Stage A adds a few safety rules of its own:
@@ -77,6 +97,7 @@ Stage A adds a few safety rules of its own:
- Stage A: the host inventory (services, units, env names, ports, probes, drain policy), environment validation, release install and rollback for git-checkout services, readiness and drain orchestration, the release log, certificate inventory, nginx vhost rendering and transactional install, config snapshots, scheduled backups (pgBackRest verification and logical PostgreSQL dumps) with retention and encrypted off-host copies, restore drills.
- Stage A: the host inventory (services, units, env names, ports, probes, drain policy), environment validation, release install and rollback for git-checkout services, readiness and drain orchestration, the release log, certificate inventory, nginx vhost rendering and transactional install, config snapshots, scheduled backups (pgBackRest verification and logical PostgreSQL dumps) with retention and encrypted off-host copies, restore drills.
- The hosting comparison catalog: providers, plans, price history, promotions and their sources (migrations 0005-0007), offers (0009), registrars' domain prices and their history (0011), benchmarks and their one-time links (0012), and the catalog's Search documents (`host.index_document.*`).
- The hosting comparison catalog: providers, plans, price history, promotions and their sources (migrations 0005-0007), offers (0009), registrars' domain prices and their history (0011), benchmarks and their one-time links (0012), and the catalog's Search documents (`host.index_document.*`).
+- The console: a person's connected hosting accounts (sealed tokens) and the actions they took (migration 0013).
- Later (Stage C): sandbox profiles, budgets, secret references, outbound policy.
- Later (Stage C): sandbox profiles, budgets, secret references, outbound policy.
- Host roles, provisioned from code so any machine can take them: the **data role** (`roles/data/`, ADR-035): PostgreSQL 18, PgBouncer, pgBackRest, Valkey and their exporters, plus `add-service.sh` giving each service its database, roles and Valkey user. `ovhost data provision <svc>` runs `add-service.sh` from the installed CLI, redacting every URL and password: [docs/data-provisioning.md](docs/data-provisioning.md).
- Host roles, provisioned from code so any machine can take them: the **data role** (`roles/data/`, ADR-035): PostgreSQL 18, PgBouncer, pgBackRest, Valkey and their exporters, plus `add-service.sh` giving each service its database, roles and Valkey user. `ovhost data provision <svc>` runs `add-service.sh` from the installed CLI, redacting every URL and password: [docs/data-provisioning.md](docs/data-provisioning.md).
@@ -93,6 +114,7 @@ Stage A adds a few safety rules of its own:
@@ -93,6 +114,7 @@ Stage A adds a few safety rules of its own:
- OpenVibe.Contracts (`openvibe-contracts`, pinned below): `host.catalog.manage`, the catalog's `host.index_document.*` events, and `host.release.published` from `ovhost announce`.
- OpenVibe.Contracts (`openvibe-contracts`, pinned below): `host.catalog.manage`, the catalog's `host.index_document.*` events, and `host.release.published` from `ovhost announce`.
- OpenVibe.Network: SSO for catalog staff, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay and `ovhost alerts relay`.
- OpenVibe.Network: SSO for catalog staff, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay and `ovhost alerts relay`.
- OpenVibe.Events: the catalog's events through the `openvibe-sdk` transactional outbox, and `ovhost announce`.
- OpenVibe.Events: the catalog's events through the `openvibe-sdk` transactional outbox, and `ovhost announce`.
+- Linode, DigitalOcean, Vultr and Hetzner Cloud APIs: the console, with each person's own token, only for them.
- OpenVibe.Sources (optional): price ingestion for the catalog (`HOST_CATALOG_INGEST`); OpenVibe.Search: the catalog's pages as documents; OpenVibe.Community (optional): discussion on provider and plan pages; OpenVibe.Watch: the "Tell me when this price changes" link on plan pages (a link, no call).
- OpenVibe.Sources (optional): price ingestion for the catalog (`HOST_CATALOG_INGEST`); OpenVibe.Search: the catalog's pages as documents; OpenVibe.Community (optional): discussion on provider and plan pages; OpenVibe.Watch: the "Tell me when this price changes" link on plan pages (a link, no call).
- `openvibe-shared` (the OpenVibe Frame, legal pages, `/release.json`, `/metrics`, `/api/ready`) and `openvibe-publishing` (ingest and the search feed).
- `openvibe-shared` (the OpenVibe Frame, legal pages, `/release.json`, `/metrics`, `/api/ready`) and `openvibe-publishing` (ingest and the search feed).
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+-- phase: expand
+-- The console (plan T20 step 3; owner 2026-10-08: "our own system that combines em somehow"): a person connects their
+-- own hosting accounts (Linode, DigitalOcean, Vultr, Hetzner Cloud) with API tokens and sees and runs every server in
+-- one place (server/console/). Tokens are sealed with HOST_CREDENTIALS_KEY (AES-256-GCM) and never shown again.
+CREATE TABLE host_console_accounts (
+ id text COLLATE "C" PRIMARY KEY, -- hca_<random>
+ owner_subject text COLLATE "C" NOT NULL, -- usr_…
+ provider text COLLATE "C" NOT NULL CHECK (provider IN ('linode', 'digitalocean', 'vultr', 'hetzner')),
+ label text NOT NULL,
+ token_sealed text NOT NULL,
+ token_hint text NOT NULL, -- the last four characters, to tell tokens apart
+ created_at bigint NOT NULL,
+ last_ok_at bigint,
+ last_error text
+);
+CREATE INDEX host_console_accounts_owner ON host_console_accounts (owner_subject);
+
+-- What a person did to their servers from the console (power on, power off, reboot), kept for them.
+CREATE TABLE host_console_actions (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ owner_subject text COLLATE "C" NOT NULL,
+ account_id text COLLATE "C" NOT NULL,
+ provider text COLLATE "C" NOT NULL,
+ server_id text COLLATE "C" NOT NULL,
+ server_name text NOT NULL DEFAULT '',
+ action text COLLATE "C" NOT NULL,
+ result text NOT NULL,
+ at bigint NOT NULL
+);
+CREATE INDEX host_console_actions_owner ON host_console_actions (owner_subject, at DESC);
+
+-- Account export and deletion (ADR-033) through openvibe-sdk/account-data (server/account-data.js).
+CREATE TABLE IF NOT EXISTS account_data_events (
+ id text PRIMARY KEY,
+ kind text NOT NULL,
+ subject text NOT NULL,
+ outcome jsonb,
+ applied_at timestamptz NOT NULL DEFAULT now(),
+ sent_at timestamptz
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+/* The console (server/console/pages.js): every server across a person's hosting accounts. Colours come from the shared
+ Frame's theme variables; filled buttons use --accent-strong for contrast. */
+.cs { max-width: 1180px; margin: 0 auto; padding: 32px 16px 56px; }
+.cs-head { display: flex; justify-content: space-between; gap: 24px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 20px; }
+.cs-eyebrow { margin: 0; color: var(--accent, #60a5fa); font-weight: 700; font-size: 0.8rem; letter-spacing: .06em; text-transform: uppercase; }
+.cs h1 { font-size: clamp(26px, 3.6vw, 38px); line-height: 1.1; letter-spacing: -0.02em; margin: 6px 0 8px; }
+.cs-lede { margin: 0; color: var(--text-secondary, #9fb0c8); max-width: 62ch; }
+.cs-stats { display: flex; gap: 10px; }
+.cs-stats > div { min-width: 96px; padding: 10px 14px; border: 1px solid var(--border, #2c3d5c); border-radius: 14px; background: var(--bg-secondary, #101828); }
+.cs-stats b { display: block; font-size: 1.35rem; font-variant-numeric: tabular-nums; }
+.cs-stats span { color: var(--text-secondary, #9fb0c8); font-size: 0.82rem; }
+.cs-muted { color: var(--text-secondary, #9fb0c8); font-size: 0.88rem; }
+.cs-bad { color: #f87171; }
+.cs-empty { padding: 28px; border: 1px dashed var(--border, #2c3d5c); border-radius: 14px; text-align: center; color: var(--text-secondary, #9fb0c8); }
+.cs-table-wrap { overflow-x: auto; border: 1px solid var(--border, #2c3d5c); border-radius: 16px; background: var(--bg-secondary, #101828); }
+.cs-table { width: 100%; border-collapse: collapse; min-width: 760px; }
+.cs-table th, .cs-table td { text-align: left; padding: 12px 14px; border-bottom: 1px solid var(--border, #2c3d5c); vertical-align: top; }
+.cs-table tbody tr:last-child td { border-bottom: 0; }
+.cs-table th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-secondary, #9fb0c8); }
+.cs-num { font-variant-numeric: tabular-nums; white-space: nowrap; }
+.cs-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.88rem; }
+.cs-save { font-size: 0.8rem; margin-top: 4px; }
+.cs-save a { color: #4ade80; }
+.cs-status { display: inline-flex; align-items: center; gap: 6px; font-size: 0.84rem; font-weight: 600; }
+.cs-status::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
+.cs-status.is-ok { color: #4ade80; } .cs-status.is-off { color: var(--text-secondary, #9fb0c8); } .cs-status.is-busy { color: #fbbf24; }
+.cs-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
+.cs-actions form { margin: 0; }
+.cs-refresh { font-size: 0.8rem; text-transform: none; letter-spacing: 0; }
+.cs-btn { display: inline-flex; align-items: center; gap: 6px; border: 1px solid transparent; border-radius: 10px; padding: 9px 16px; font: 600 0.92rem/1.2 inherit; cursor: pointer;
+ background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
+.cs-btn:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent, #60a5fa) 55%, transparent); outline-offset: 2px; }
+.cs-quiet, .cs-small { background: transparent; color: var(--text-primary, #e6edf7); border-color: var(--border, #2c3d5c); }
+.cs-small { padding: 5px 10px; font-size: 0.82rem; }
+.cs-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 14px; margin-top: 18px; }
+.cs-card { padding: 18px; border: 1px solid var(--border, #2c3d5c); border-radius: 16px; background: var(--bg-secondary, #101828); margin-top: 14px; }
+.cs-grid .cs-card { margin-top: 0; }
+.cs-card h2 { margin: 0 0 12px; font-size: 1.1rem; }
+.cs-accts, .cs-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+.cs-acct { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 12px; border: 1px solid var(--border, #2c3d5c); border-radius: 12px; }
+.cs-acct form { margin: 0; }
+.cs-form { display: grid; gap: 12px; }
+.cs-form label { display: grid; gap: 4px; font-weight: 600; font-size: 0.9rem; }
+.cs-form input, .cs-form select { border: 1px solid var(--border, #2c3d5c); background: var(--bg-primary, #0b1220); color: inherit; border-radius: 10px; padding: 9px 12px; font: inherit; font-weight: 400; }
+.cs-form .cs-btn { justify-self: start; }
+.cs-help summary { cursor: pointer; color: var(--accent, #60a5fa); font-size: 0.9rem; }
+.cs-help ul { margin: 8px 0 0; padding-left: 18px; color: var(--text-secondary, #9fb0c8); font-size: 0.86rem; display: grid; gap: 6px; }
+.cs-log li { font-size: 0.9rem; }
+@media (max-width: 640px) { .cs-stats { width: 100%; } .cs-stats > div { flex: 1; min-width: 0; } }
+/* On a phone each server is a card: its name, then labelled facts, then its power buttons, all in view (no sideways scroll). */
+@media (max-width: 720px) {
+ .cs-table-wrap { overflow: visible; border: 0; background: transparent; }
+ .cs-table { min-width: 0; display: block; }
+ .cs-table thead { display: none; }
+ .cs-table tbody { display: grid; gap: 10px; }
+ .cs-table tr { display: block; border: 1px solid var(--border, #2c3d5c); border-radius: 14px; background: var(--bg-secondary, #101828); padding: 6px 0; }
+ .cs-table td { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; border: 0; padding: 6px 14px; text-align: right; }
+ .cs-table td::before { content: attr(data-label); color: var(--text-secondary, #9fb0c8); font-size: 0.82rem; text-align: left; }
+ .cs-table td.cs-name { display: block; text-align: left; padding-bottom: 4px; }
+ .cs-table td.cs-name::before, .cs-table td.cs-act-cell::before { content: none; }
+ .cs-table td.cs-act-cell { padding-top: 8px; }
+ .cs-table td.cs-act-cell .cs-actions { width: 100%; justify-content: stretch; }
+ .cs-table td.cs-act-cell .cs-actions form { flex: 1; }
+ .cs-table td.cs-act-cell .cs-btn { width: 100%; justify-content: center; padding: 10px; font-size: 0.92rem; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+/**
+ * Account export and deletion → Host (ADR-033; openvibe-sdk/account-data). What Host holds about a person:
+ *
+ * the console the hosting accounts they connected (provider, label, the token's last four characters; never the
+ * sealed token itself) and the actions they took on their servers. Deletion removes both, so their
+ * tokens are gone from OpenVibe.Host.
+ * benchmarks the results they submitted (migration 0012) and their one-time links (never the link's token).
+ * Deletion removes the links and detaches the results, which stay as anonymous measurements.
+ * catalog rows providers, plans and promotions a staff member entered: shared rows, so deletion keeps them and
+ * erases only who entered them.
+ *
+ * Discussion lives on OpenVibe.Community. A table a held migration has not created yet is skipped.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const TABLES = [
+ { table: 'host_console_accounts', subject: 'owner_subject', file: 'console-accounts.json', columns: ['provider', 'label', 'token_hint', 'created_at', 'last_ok_at'] },
+ { table: 'host_console_actions', subject: 'owner_subject', file: 'console-actions.json', columns: ['provider', 'server_id', 'server_name', 'action', 'result', 'at'], order: 'at' },
+ { table: 'catalog_benchmarks', subject: 'submitted_by', file: 'benchmarks.json', erase: { anonymize: {} } },
+ { table: 'catalog_bench_links', subject: 'person', file: 'benchmark-links.json', columns: ['provider_name', 'plan_id', 'note', 'created_at', 'expires_at', 'used_at'] },
+ { table: 'catalog_providers', subject: 'created_by', file: 'catalog-providers.json', erase: { anonymize: {} } },
+ { table: 'catalog_plans', subject: 'created_by', file: 'catalog-plans.json', erase: { anonymize: {} } },
+ { table: 'catalog_promos', subject: 'created_by', file: 'catalog-promos.json', erase: { anonymize: {} } },
+];
+
+function create({ db, log = console } = {}) {
+ return createAccountData({ db, service: 'host', tables: TABLES, log });
+}
+
+module.exports = { create, TABLES, TOPICS };
@@ -83,6 +83,7 @@ async function createApp(opts = {}) {
@@ -83,6 +83,7 @@ async function createApp(opts = {}) {
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 worker = createWorker({ config, outbox, catalogIngest, catalogSearch, log });
const ctx = { config, store, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log };
const ctx = { config, store, outbox, catalog, domainCheck, benchmarks, community, catalogIngest, catalogSearch, auth, viewers, worker, log };
+ ctx.console = require('./console/console').createConsole({ store, config, catalog, fetchImpl: opts.fetchImpl, log });
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.disable('etag');
app.disable('etag');
@@ -168,6 +169,19 @@ async function createApp(opts = {}) {
@@ -168,6 +169,19 @@ async function createApp(opts = {}) {
try { const result = await catalogIngest.consume(req.body, req.headers); res.status(result.status).json(result); }
try { const result = await catalogIngest.consume(req.body, req.headers); res.status(result.status).json(result); }
catch (err) { require('./http/errors').sendError(res, req, err, log); }
catch (err) { require('./http/errors').sendError(res, req, err, log); }
});
});
+ // ── OpenVibe.Events → Host (loopback only: nginx answers 404 for /internal/) ──
+ // network.account.export_requested and network.account.deleted (ADR-033) through openvibe-sdk/account-data, which reads
+ // the raw body itself (the v2 signature covers it) and refuses a request that came through a proxy.
+ {
+ const { createNetworkSender } = require('openvibe-sdk/account-data');
+ const accountData = require('./account-data').create({ db: store.db, log });
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}) })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Host cannot answer account events'); });
+ app.locals.accountData = accountData;
+ app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
+ }
+ app.use('/console', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }), require('./console/pages').createConsolePages(ctx));
app.use('/api/v1/catalog', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createCatalogApi(ctx));
app.use('/api/v1/catalog', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createCatalogApi(ctx));
const movedApi = (req, res) => res.status(410).type('application/problem+json').json({ code: 'host.moved_to_space', title: 'Static hosting moved', detail: MOVED, url: SPACE_URL });
const movedApi = (req, res) => res.status(410).type('application/problem+json').json({ code: 'host.moved_to_space', title: 'Static hosting moved', detail: MOVED, url: SPACE_URL });
const movedPage = (req, res) => res.status(410).type('html').send(renderPage({ title: 'Static hosting moved', body: `<h1>Static hosting moved</h1><p>${MOVED} <a href="${SPACE_URL}">Visit OpenVibe.Space</a>.</p>`, config, path: req.originalUrl }));
const movedPage = (req, res) => res.status(410).type('html').send(renderPage({ title: 'Static hosting moved', body: `<h1>Static hosting moved</h1><p>${MOVED} <a href="${SPACE_URL}">Visit OpenVibe.Space</a>.</p>`, config, path: req.originalUrl }));
deleted file mode 100644
deleted file mode 100644
@@ -1,36 +0,0 @@
@@ -1,36 +0,0 @@
-'use strict';
-
-/**
- * ADR-033: catalog rows are shared. Export authored rows; erase only the attribution. A person's benchmark results and
- * links (migration 0012) are exported; erasing deletes the links and detaches the results, which stay as anonymous
- * measurements. Tables a held migration has not created yet are skipped.
- */
-const TABLES = Object.freeze(['catalog_providers', 'catalog_plans', 'catalog_promos']);
-const exists = async (db, table) => Boolean((await db.prepare('SELECT to_regclass(?) AS t').get(table)).t);
-
-async function exportFor(db, personId) {
- const out = {};
- for (const table of TABLES) out[table] = await db.prepare(`SELECT * FROM ${table} WHERE created_by = ? ORDER BY created_at, id`).all(personId);
- if (await exists(db, 'catalog_benchmarks')) {
- out.catalog_benchmarks = await db.prepare('SELECT * FROM catalog_benchmarks WHERE submitted_by = ? ORDER BY created_at, id').all(personId);
- out.catalog_bench_links = await db.prepare('SELECT provider_name, plan_id, note, created_at, expires_at, used_at FROM catalog_bench_links WHERE person = ? ORDER BY created_at').all(personId);
- }
- return out;
-}
-async function eraseFor(db, personId) {
- const counts = {};
- const benchmarks = await exists(db, 'catalog_benchmarks');
- await db.tx(async (t) => {
- for (const table of TABLES) {
- const rows = await t.prepare(`SELECT id FROM ${table} WHERE created_by = ?`).all(personId);
- await t.prepare(`UPDATE ${table} SET created_by = NULL WHERE created_by = ?`).run(personId);
- counts[table] = rows.length;
- }
- if (benchmarks) {
- counts.catalog_benchmarks = (await t.prepare('UPDATE catalog_benchmarks SET submitted_by = NULL WHERE submitted_by = ?').run(personId)).changes;
- counts.catalog_bench_links = (await t.prepare('DELETE FROM catalog_bench_links WHERE person = ?').run(personId)).changes;
- }
- });
- return counts;
-}
-module.exports = { TABLES, exportFor, eraseFor };
@@ -46,6 +46,9 @@ function load(env = process.env) {
@@ -46,6 +46,9 @@ function load(env = process.env) {
events: {
events: {
url: trim(env.EVENTS_URL || ''),
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
+ // OpenVibe.Events → Host (ADR-033 account export and deletion, server/account-data.js): the secret(s) that sign
+ // its deliveries to /internal/events (32+ characters); the first one is what the subscriptions are made with.
+ secrets: String(env.HOST_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
},
},
community: {
community: {
internalUrl: trim(env.HOST_COMMUNITY_URL || ''),
internalUrl: trim(env.HOST_COMMUNITY_URL || ''),
@@ -56,6 +59,9 @@ function load(env = process.env) {
@@ -56,6 +59,9 @@ function load(env = process.env) {
catalog: { ingest: String(env.HOST_CATALOG_INGEST || 'off').trim().toLowerCase() === 'on', sourcesUrl: trim(env.HOST_CATALOG_SOURCES_URL || ''), webhookSecrets: list(env.HOST_CATALOG_WEBHOOK_SECRETS) },
catalog: { ingest: String(env.HOST_CATALOG_INGEST || 'off').trim().toLowerCase() === 'on', sourcesUrl: trim(env.HOST_CATALOG_SOURCES_URL || ''), webhookSecrets: list(env.HOST_CATALOG_WEBHOOK_SECRETS) },
+ // The console (server/console/): a person's own hosting accounts, their tokens sealed with HOST_CREDENTIALS_KEY
+ // (32 bytes, hex or base64). Without it the console shows servers of no account and connects none.
+ console: { credentialsKey: env.HOST_CREDENTIALS_KEY || '', maxAccounts: int(env.HOST_CONSOLE_MAX_ACCOUNTS, 10) },
worker: {
worker: {
enabled: env.HOST_WORKER !== 'off',
enabled: env.HOST_WORKER !== 'off',
id: env.HOST_WORKER_ID || `host-${process.pid}`,
id: env.HOST_WORKER_ID || `host-${process.pid}`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+/**
+ * The console (plan T20 step 3; owner 2026-10-08: "openvibe.host should just be combined multi provider hosting
+ * comparison … while also have our own system that combines em somehow"): a person connects their own hosting accounts
+ * and sees every server in one list (status, region, size, address, monthly price, totals), powers them on, off or
+ * reboots them, and sees where the catalog has the same size for less.
+ *
+ * A token is checked with the provider when it is added, sealed with AES-256-GCM under HOST_CREDENTIALS_KEY, and only
+ * ever opened to call that provider. Without the key the console says so and keeps nothing.
+ */
+const crypto = require('crypto');
+const { PROVIDERS, createProviders, ProviderError } = require('./providers');
+
+class ConsoleError extends Error {
+ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
+}
+
+function keyFrom(raw) {
+ const s = String(raw || '').trim();
+ if (!s) return null;
+ const buf = /^[0-9a-f]{64}$/i.test(s) ? Buffer.from(s, 'hex') : Buffer.from(s, 'base64');
+ return buf.length === 32 ? buf : null;
+}
+
+function createConsole({ store, config, catalog = null, fetchImpl, log = console }) {
+ const { db } = store;
+ const key = keyFrom(config.console.credentialsKey);
+ const providers = createProviders({ fetchImpl });
+ const limits = config.console;
+ const q = {
+ accounts: db.prepare('SELECT id, provider, label, token_hint, created_at, last_ok_at, last_error FROM host_console_accounts WHERE owner_subject = ? ORDER BY created_at'),
+ account: db.prepare('SELECT * FROM host_console_accounts WHERE id = ? AND owner_subject = ?'),
+ count: db.prepare('SELECT COUNT(*) AS n FROM host_console_accounts WHERE owner_subject = ?'),
+ insert: db.prepare(`INSERT INTO host_console_accounts (id, owner_subject, provider, label, token_sealed, token_hint, created_at, last_ok_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
+ remove: db.prepare('DELETE FROM host_console_accounts WHERE id = ? AND owner_subject = ?'),
+ ok: db.prepare('UPDATE host_console_accounts SET last_ok_at = ?, last_error = NULL WHERE id = ?'),
+ failed: db.prepare('UPDATE host_console_accounts SET last_error = ? WHERE id = ?'),
+ logAction: db.prepare(`INSERT INTO host_console_actions (owner_subject, account_id, provider, server_id, server_name, action, result, at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
+ actions: db.prepare('SELECT provider, server_name, action, result, at FROM host_console_actions WHERE owner_subject = ? ORDER BY at DESC LIMIT 20'),
+ };
+
+ const available = () => Boolean(key);
+ function seal(token) {
+ const iv = crypto.randomBytes(12);
+ const c = crypto.createCipheriv('aes-256-gcm', key, iv);
+ const body = Buffer.concat([c.update(token, 'utf8'), c.final()]);
+ return `v1.${iv.toString('base64')}.${c.getAuthTag().toString('base64')}.${body.toString('base64')}`;
+ }
+ function open(sealed) {
+ const [v, iv, tag, body] = String(sealed).split('.');
+ if (v !== 'v1') throw new ConsoleError(500, 'console.sealed', 'a stored token is in an unknown format');
+ const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
+ d.setAuthTag(Buffer.from(tag, 'base64'));
+ return Buffer.concat([d.update(Buffer.from(body, 'base64')), d.final()]).toString('utf8');
+ }
+ const need = () => { if (!available()) throw new ConsoleError(503, 'console.unavailable', 'Connecting hosting accounts is not set up on this server yet (HOST_CREDENTIALS_KEY).'); };
+
+ async function accounts(subject) { return await q.accounts.all(subject); }
+
+ async function connect(subject, { provider, token, label }) {
+ need();
+ if (!PROVIDERS[provider]) throw new ConsoleError(422, 'console.provider', 'pick one of the listed providers');
+ const t = String(token || '').trim();
+ if (t.length < 16 || t.length > 512 || /\s/.test(t)) throw new ConsoleError(422, 'console.token', 'paste the whole API token (no spaces)');
+ if ((await q.count.get(subject)).n >= limits.maxAccounts) throw new ConsoleError(429, 'console.too_many', `you can connect ${limits.maxAccounts} accounts`);
+ let found;
+ try { found = await providers[provider].verify(t); } catch (err) {
+ throw new ConsoleError(err instanceof ProviderError && err.status === 401 ? 422 : 502, 'console.verify', err.message);
+ }
+ const id = `hca_${crypto.randomBytes(12).toString('hex')}`;
+ const now = store.now();
+ await q.insert.run(id, subject, provider, String(label || '').trim().slice(0, 60) || String(found).slice(0, 60), seal(t), t.slice(-4), now, now);
+ cache.delete(id);
+ return id;
+ }
+
+ async function disconnect(subject, id) {
+ const r = await q.remove.run(id, subject);
+ cache.delete(id);
+ return r.changes > 0;
+ }
+
+ // Each account's server list, kept a minute (a refresh asks again).
+ const cache = new Map();
+ async function serversOf(account, { fresh = false } = {}) {
+ const hit = cache.get(account.id);
+ if (!fresh && hit && store.now() - hit.at < 60000) return hit.value;
+ let value;
+ try {
+ const list = await providers[account.provider].list(open(account.token_sealed));
+ value = { servers: list.map((s) => ({ ...s, provider: account.provider, account_id: account.id })), error: null };
+ await q.ok.run(store.now(), account.id);
+ } catch (err) {
+ value = { servers: [], error: err.message || 'the provider did not answer' };
+ await q.failed.run(String(value.error).slice(0, 300), account.id);
+ }
+ cache.set(account.id, { value, at: store.now() });
+ return value;
+ }
+
+ /** Where the catalog has at least the same vCPU and memory for less (the cheapest such plan, 15% or more cheaper). */
+ async function cheaper(server) {
+ if (!catalog || !server.vcpu || !server.ram_mb || !server.price || server.price.monthly == null || server.price.currency !== 'USD') return null;
+ try {
+ const r = await catalog.plans({ min_vcpu: server.vcpu, min_ram_mb: Math.floor(server.ram_mb * 0.97), currency: 'USD', sort: 'price' });
+ const best = (r.plans || []).find((p) => Number(p.price_monthly) > 0);
+ if (!best || Number(best.price_monthly) > server.price.monthly * 0.85) return null;
+ return { id: best.id, name: best.name, provider: best.provider_name, monthly: Number(best.price_monthly), vcpu: best.vcpu, ram_mb: best.ram_mb };
+ } catch { return null; }
+ }
+
+ /** Everything the console page shows for a person. */
+ async function overview(subject, { fresh = false } = {}) {
+ const accs = await accounts(subject);
+ const results = await Promise.all(accs.map(async (a) => ({ account: a, ...(await serversOf({ ...a, token_sealed: (await q.account.get(a.id, subject)).token_sealed }, { fresh })) })));
+ const servers = results.flatMap((r) => r.servers);
+ for (const s of servers) s.cheaper = await cheaper(s);
+ const totals = {};
+ for (const s of servers) if (s.price && s.price.monthly != null) totals[s.price.currency] = Math.round(((totals[s.price.currency] || 0) + s.price.monthly) * 100) / 100;
+ return {
+ accounts: results.map((r) => ({ ...r.account, servers: r.servers.length, error: r.error })),
+ servers, totals, running: servers.filter((s) => s.status === 'running').length,
+ actions: await q.actions.all(subject),
+ };
+ }
+
+ async function act(subject, accountId, serverId, action) {
+ need();
+ if (!['start', 'stop', 'reboot'].includes(action)) throw new ConsoleError(422, 'console.action', 'the action is start, stop or reboot');
+ const account = await q.account.get(accountId, subject);
+ if (!account) throw new ConsoleError(404, 'console.account', 'no such connected account');
+ const known = (await serversOf(account)).servers.find((s) => s.id === String(serverId));
+ if (!known) throw new ConsoleError(404, 'console.server', 'that server is not in this account');
+ let result = 'sent';
+ try { await providers[account.provider].act(open(account.token_sealed), known.id, action); } catch (err) { result = `failed: ${err.message}`.slice(0, 300); }
+ await q.logAction.run(subject, account.id, account.provider, known.id, String(known.name || '').slice(0, 120), action, result, store.now());
+ cache.delete(account.id);
+ if (result !== 'sent') throw new ConsoleError(502, 'console.action_failed', result.replace(/^failed: /, ''));
+ return { server: known, action };
+ }
+
+ return { available, accounts, connect, disconnect, overview, act, PROVIDERS, _seal: seal, _open: open };
+}
+
+module.exports = { createConsole, ConsoleError, keyFrom };
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * /console on openvibe.host, with the person's Host session:
+ *
+ * GET /console every server across the connected accounts, totals, the forms
+ * POST /console/accounts connect an account (provider, API token, an optional label)
+ * POST /console/accounts/:id/remove disconnect it (the sealed token is deleted)
+ * POST /console/accounts/:id/servers/:server/:action start, stop or reboot a server
+ *
+ * Every POST is a form from this site with its form token (auth/forms.js); answers are private and never cached.
+ */
+const express = require('express');
+const { renderPage, esc } = require('../render/layout');
+const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
+const { PROVIDERS } = require('./providers');
+
+const money = (n, cur) => (n == null ? '—' : new Intl.NumberFormat('en-US', { style: 'currency', currency: cur || 'USD', minimumFractionDigits: Number.isInteger(n) ? 0 : 2, maximumFractionDigits: 2 }).format(n));
+const ram = (mb) => (mb == null ? '—' : mb >= 1024 ? `${Math.round((mb / 1024) * 10) / 10} GB` : `${mb} MB`);
+const STATUS = { running: ['Running', 'is-ok'], stopped: ['Stopped', 'is-off'], other: ['Changing', 'is-busy'] };
+
+function page({ viewer, config, data, csrf, notice, available }) {
+ const acctCards = data.accounts.map((a) => `<li class="cs-acct">
+ <div><b>${esc(PROVIDERS[a.provider].name)}</b> <span class="cs-muted">${esc(a.label)} · token …${esc(a.token_hint)}</span>
+ <div class="cs-muted">${a.error ? `<span class="cs-bad">${esc(a.error)}</span>` : `${a.servers} server${a.servers === 1 ? '' : 's'}`}</div></div>
+ <form method="post" action="/console/accounts/${esc(a.id)}/remove" onsubmit="return confirm('Disconnect this account? Its token is deleted from OpenVibe.Host.')"><input type="hidden" name="csrf" value="${esc(csrf)}"><button class="cs-btn cs-quiet" type="submit">Disconnect</button></form>
+</li>`).join('');
+ const rows = data.servers.map((s) => {
+ const [label, cls] = STATUS[s.status] || STATUS.other;
+ const btn = (action, text) => `<form method="post" action="/console/accounts/${esc(s.account_id)}/servers/${esc(encodeURIComponent(s.id))}/${action}"${action === 'stop' ? ` onsubmit="return confirm('Power off ${esc(String(s.name).replace(/'/g, ''))}?')"` : ''}><input type="hidden" name="csrf" value="${esc(csrf)}"><button class="cs-btn cs-small" type="submit">${text}</button></form>`;
+ return `<tr>
+ <td class="cs-name"><b>${esc(s.name)}</b><div class="cs-muted">${esc(PROVIDERS[s.provider].name)} · ${esc(s.region || '—')}${s.plan ? ` · ${esc(s.plan)}` : ''}</div></td>
+ <td data-label="Status"><span class="cs-status ${cls}">${label}</span></td>
+ <td data-label="Size">${s.vcpu ?? '—'} vCPU · ${ram(s.ram_mb)}${s.disk_gb ? ` · ${s.disk_gb} GB` : ''}</td>
+ <td data-label="IPv4" class="cs-mono">${esc(s.ipv4 || '—')}</td>
+ <td data-label="Monthly" class="cs-num">${money(s.price?.monthly, s.price?.currency)}${s.cheaper ? `<div class="cs-save"><a href="/catalog/plans/${esc(s.cheaper.id)}">${esc(s.cheaper.provider)} has it for ${money(s.cheaper.monthly, 'USD')}</a></div>` : ''}</td>
+ <td class="cs-act-cell"><div class="cs-actions">${s.status === 'running' ? btn('reboot', 'Reboot') + btn('stop', 'Power off') : s.status === 'stopped' ? btn('start', 'Power on') : ''}</div></td>
+</tr>`;
+ }).join('');
+ const totals = Object.entries(data.totals).map(([cur, n]) => money(n, cur)).join(' + ') || '—';
+ const body = `<section class="cs">
+ <header class="cs-head">
+ <div><p class="cs-eyebrow">OpenVibe.Host · console</p><h1>Your servers, every provider</h1>
+ <p class="cs-lede">Connect your own hosting accounts and see and run all your servers in one place, with where the catalog has the same size for less.</p></div>
+ <div class="cs-stats"><div><b>${data.servers.length}</b><span>servers</span></div><div><b>${data.running}</b><span>running</span></div><div><b>${esc(totals)}</b><span>a month</span></div></div>
+ </header>
+ ${data.servers.length ? `<div class="cs-table-wrap"><table class="cs-table"><thead><tr><th>Server</th><th>Status</th><th>Size</th><th>IPv4</th><th>Monthly</th><th><a class="cs-refresh" href="/console?fresh=1">Refresh</a></th></tr></thead><tbody>${rows}</tbody></table></div>`
+ : `<p class="cs-empty">${data.accounts.length ? 'No servers in the connected accounts yet.' : 'Connect a hosting account below to see its servers here.'}</p>`}
+ <div class="cs-grid">
+ <section class="cs-card" aria-labelledby="cs-accts"><h2 id="cs-accts">Connected accounts</h2>
+ ${data.accounts.length ? `<ul class="cs-accts">${acctCards}</ul>` : '<p class="cs-muted">None yet.</p>'}
+ </section>
+ <section class="cs-card" aria-labelledby="cs-add"><h2 id="cs-add">Connect an account</h2>
+ ${available ? `<form method="post" action="/console/accounts" class="cs-form">
+ <input type="hidden" name="csrf" value="${esc(csrf)}">
+ <label>Provider<select name="provider" required>${Object.entries(PROVIDERS).map(([k, p]) => `<option value="${k}">${esc(p.name)}</option>`).join('')}</select></label>
+ <label>API token<input name="token" type="password" autocomplete="off" required minlength="16" maxlength="512" placeholder="Paste the token"></label>
+ <label><span>Label <span class="cs-muted">(optional)</span></span><input name="label" maxlength="60" placeholder="Work, side projects…"></label>
+ <button class="cs-btn" type="submit">Connect</button>
+ <details class="cs-help"><summary>Where do I get a token?</summary><ul>${Object.values(PROVIDERS).map((p) => `<li><a href="${esc(p.tokenUrl)}" rel="noopener" target="_blank">${esc(p.name)}</a>: ${esc(p.tokenHelp)}</li>`).join('')}</ul></details>
+ <p class="cs-muted">The token is checked with the provider, then stored encrypted and used only to call that provider for you. Disconnecting deletes it.</p>
+ </form>` : '<p class="cs-muted">Connecting accounts is not set up on this server yet.</p>'}
+ </section>
+ </div>
+ ${data.actions.length ? `<section class="cs-card"><h2>Recent actions</h2><ul class="cs-log">${data.actions.map((a) => `<li><span class="cs-muted">${esc(new Date(Number(a.at)).toISOString().replace('T', ' ').slice(0, 16))}</span> ${esc(a.action)} <b>${esc(a.server_name)}</b> (${esc(PROVIDERS[a.provider]?.name || a.provider)}): ${esc(a.result)}</li>`).join('')}</ul></section>` : ''}
+</section>`;
+ return renderPage({ title: 'Console', body, viewer, config, path: '/console', notice, css: ['css/console.css'] });
+}
+
+function createConsolePages(ctx) {
+ const { config, viewers, console: con, log = console } = ctx;
+ const router = express.Router();
+ const urlencoded = express.urlencoded({ extended: false, limit: '8kb' });
+ router.use(viewers.middleware('dashboard'));
+ if (ctx.actorLimits) router.use(ctx.actorLimits);
+ router.use((req, res, next) => { res.set('Cache-Control', 'private, no-store'); next(); });
+ const signedIn = (req) => req.viewer && req.viewer.kind === 'user';
+ const back = (res, kind, text) => res.redirect(303, `/console?${new URLSearchParams({ kind, notice: text })}`);
+
+ router.get('/', async (req, res, next) => {
+ try {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fconsole');
+ const data = await con.overview(req.viewer.subject, { fresh: req.query.fresh === '1' });
+ const n = typeof req.query.notice === 'string' && req.query.notice.length < 300 ? { kind: req.query.kind === 'error' ? 'error' : 'info', text: req.query.notice } : null;
+ res.type('html').send(page({ viewer: req.viewer, config, data, csrf: csrfToken(config, req.viewer), notice: n, available: con.available() }));
+ } catch (err) { next(err); }
+ });
+
+ const action = (fn) => [urlencoded, async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fconsole');
+ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body && req.body.csrf)) return back(res, 'error', 'That form expired or did not come from this page; try again.');
+ try { await fn(req, res); } catch (err) {
+ if (!err.status || err.status >= 500 && err.code !== 'console.action_failed' && err.code !== 'console.verify') log.error('[Host console]', err && err.stack ? err.stack : err);
+ back(res, 'error', err.status && err.status < 600 && err.message ? err.message : 'Something went wrong on our side.');
+ }
+ }];
+ router.post('/accounts', ...action(async (req, res) => {
+ await con.connect(req.viewer.subject, { provider: req.body.provider, token: req.body.token, label: req.body.label });
+ back(res, 'info', `${PROVIDERS[req.body.provider].name} connected.`);
+ }));
+ router.post('/accounts/:id/remove', ...action(async (req, res) => {
+ await con.disconnect(req.viewer.subject, req.params.id);
+ back(res, 'info', 'Account disconnected; its token is deleted.');
+ }));
+ router.post('/accounts/:id/servers/:server/:action', ...action(async (req, res) => {
+ const r = await con.act(req.viewer.subject, req.params.id, req.params.server, req.params.action);
+ back(res, 'info', `${{ start: 'Power on', stop: 'Power off', reboot: 'Reboot' }[r.action]} sent to ${r.server.name}.`);
+ }));
+ return router;
+}
+
+module.exports = { createConsolePages };
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+'use strict';
+/**
+ * The hosting accounts the console can combine, each through the provider's own documented API and the person's own
+ * token (sent only to that provider's API host, never anywhere else):
+ *
+ * linode https://api.linode.com/v4 Bearer personal access token (Linodes: read/write)
+ * digitalocean https://api.digitalocean.com/v2 Bearer personal access token
+ * vultr https://api.vultr.com/v2 Bearer API key
+ * hetzner https://api.hetzner.cloud/v1 Bearer project API token
+ *
+ * Every adapter answers verify(token) → a label for the account, list(token) → servers in one shape
+ * { id, name, status: 'running' | 'stopped' | 'other', region, vcpu, ram_mb, disk_gb, ipv4, ipv6, plan, price: { monthly, currency }, created_at },
+ * and act(token, id, 'start' | 'stop' | 'reboot').
+ */
+const PROVIDERS = {
+ linode: { name: 'Linode (Akamai)', api: 'https://api.linode.com/v4', tokenUrl: 'https://cloud.linode.com/profile/tokens', tokenHelp: 'A personal access token with Linodes: read/write.' },
+ digitalocean: { name: 'DigitalOcean', api: 'https://api.digitalocean.com/v2', tokenUrl: 'https://cloud.digitalocean.com/account/api/tokens', tokenHelp: 'A personal access token (read and write, or droplet scopes).' },
+ vultr: { name: 'Vultr', api: 'https://api.vultr.com/v2', tokenUrl: 'https://my.vultr.com/settings/#settingsapi', tokenHelp: 'Your API key (allow this server\'s address if access control is on).' },
+ hetzner: { name: 'Hetzner Cloud', api: 'https://api.hetzner.cloud/v1', tokenUrl: 'https://console.hetzner.cloud/', tokenHelp: 'A project API token (Security → API tokens, read and write).' },
+};
+
+class ProviderError extends Error {
+ constructor(message, status = 502) { super(message); this.status = status; }
+}
+
+function createProviders({ fetchImpl = globalThis.fetch, timeoutMs = 15000 } = {}) {
+ async function call(provider, token, path, { method = 'GET', body } = {}) {
+ const base = PROVIDERS[provider].api;
+ let res;
+ try {
+ res = await fetchImpl(`${base}${path}`, {
+ method, headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
+ body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
+ });
+ } catch (err) {
+ throw new ProviderError(`${PROVIDERS[provider].name} did not answer (${err && err.name === 'TimeoutError' ? 'timed out' : 'unreachable'})`);
+ }
+ const text = await res.text();
+ let json = null;
+ try { json = text ? JSON.parse(text) : null; } catch { json = null; }
+ if (res.status === 401 || res.status === 403) throw new ProviderError(`${PROVIDERS[provider].name} refused the token (${res.status}): check it is valid and allows this`, 401);
+ if (!res.ok) {
+ const why = json && (json.errors?.[0]?.reason || json.message || json.error?.message || json.error);
+ throw new ProviderError(`${PROVIDERS[provider].name} answered ${res.status}${why ? `: ${String(why).slice(0, 160)}` : ''}`);
+ }
+ return json || {};
+ }
+
+ // Public price lists, cached (no token needed).
+ const priceCache = new Map();
+ async function cachedPublic(key, load) {
+ const hit = priceCache.get(key);
+ if (hit && Date.now() - hit.at < 6 * 3600 * 1000) return hit.value;
+ const value = await load();
+ priceCache.set(key, { value, at: Date.now() });
+ return value;
+ }
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : null);
+ const st = (s) => (['running', 'active'].includes(s) ? 'running' : ['offline', 'stopped', 'off', 'halted'].includes(s) ? 'stopped' : 'other');
+
+ const adapters = {
+ linode: {
+ async verify(token) { const p = await call('linode', token, '/profile'); return p.username ? `${p.username}` : 'Linode account'; },
+ async list(token) {
+ const r = await call('linode', token, '/linode/instances?page_size=200');
+ const types = await cachedPublic('linode:types', async () => {
+ const t = await call('linode', token, '/linode/types?page_size=200');
+ return Object.fromEntries((t.data || []).map((x) => [x.id, x]));
+ });
+ return (r.data || []).map((l) => ({
+ id: String(l.id), name: l.label, status: st(l.status), region: l.region, vcpu: num(l.specs?.vcpus), ram_mb: num(l.specs?.memory),
+ disk_gb: num(l.specs?.disk) != null ? Math.round(l.specs.disk / 1024) : null, ipv4: (l.ipv4 || [])[0] || null, ipv6: l.ipv6 ? String(l.ipv6).split('/')[0] : null,
+ plan: l.type, price: { monthly: num(types[l.type]?.price?.monthly), currency: 'USD' }, created_at: l.created,
+ }));
+ },
+ async act(token, id, action) {
+ const path = { start: 'boot', stop: 'shutdown', reboot: 'reboot' }[action];
+ await call('linode', token, `/linode/instances/${encodeURIComponent(id)}/${path}`, { method: 'POST', body: {} });
+ },
+ },
+ digitalocean: {
+ async verify(token) { const a = await call('digitalocean', token, '/account'); return a.account?.email || 'DigitalOcean account'; },
+ async list(token) {
+ const r = await call('digitalocean', token, '/droplets?per_page=200');
+ return (r.droplets || []).map((d) => ({
+ id: String(d.id), name: d.name, status: d.status === 'active' ? 'running' : d.status === 'off' ? 'stopped' : 'other', region: d.region?.slug || null,
+ vcpu: num(d.vcpus), ram_mb: num(d.memory), disk_gb: num(d.disk), ipv4: (d.networks?.v4 || []).find((n) => n.type === 'public')?.ip_address || null,
+ ipv6: (d.networks?.v6 || []).find((n) => n.type === 'public')?.ip_address || null, plan: d.size_slug || d.size?.slug || null,
+ price: { monthly: num(d.size?.price_monthly), currency: 'USD' }, created_at: d.created_at,
+ }));
+ },
+ async act(token, id, action) {
+ const type = { start: 'power_on', stop: 'power_off', reboot: 'reboot' }[action];
+ await call('digitalocean', token, `/droplets/${encodeURIComponent(id)}/actions`, { method: 'POST', body: { type } });
+ },
+ },
+ vultr: {
+ async verify(token) { const a = await call('vultr', token, '/account'); return a.account?.email || a.account?.name || 'Vultr account'; },
+ async list(token) {
+ const r = await call('vultr', token, '/instances?per_page=500');
+ const plans = await cachedPublic('vultr:plans', async () => {
+ const p = await call('vultr', token, '/plans?per_page=500');
+ return Object.fromEntries((p.plans || []).map((x) => [x.id, x]));
+ });
+ return (r.instances || []).map((v) => ({
+ id: String(v.id), name: v.label || v.hostname || v.id, status: v.power_status === 'running' ? 'running' : v.power_status === 'stopped' ? 'stopped' : st(v.status),
+ region: v.region, vcpu: num(v.vcpu_count), ram_mb: num(v.ram), disk_gb: num(v.disk), ipv4: v.main_ip || null, ipv6: v.v6_main_ip || null,
+ plan: v.plan, price: { monthly: num(plans[v.plan]?.monthly_cost), currency: 'USD' }, created_at: v.date_created,
+ }));
+ },
+ async act(token, id, action) {
+ const path = { start: 'start', stop: 'halt', reboot: 'reboot' }[action];
+ await call('vultr', token, `/instances/${encodeURIComponent(id)}/${path}`, { method: 'POST', body: {} });
+ },
+ },
+ hetzner: {
+ async verify(token) { await call('hetzner', token, '/servers?per_page=1'); return 'Hetzner Cloud project'; },
+ async list(token) {
+ const r = await call('hetzner', token, '/servers?per_page=50');
+ return (r.servers || []).map((h) => {
+ const loc = h.datacenter?.location?.name;
+ const price = (h.server_type?.prices || []).find((p) => p.location === loc) || (h.server_type?.prices || [])[0];
+ return {
+ id: String(h.id), name: h.name, status: st(h.status), region: loc || null, vcpu: num(h.server_type?.cores),
+ ram_mb: num(h.server_type?.memory) != null ? Math.round(h.server_type.memory * 1024) : null, disk_gb: num(h.server_type?.disk),
+ ipv4: h.public_net?.ipv4?.ip || null, ipv6: h.public_net?.ipv6?.ip ? String(h.public_net.ipv6.ip).split('/')[0] : null,
+ plan: h.server_type?.name || null, price: { monthly: num(price?.price_monthly?.gross), currency: 'EUR' }, created_at: h.created,
+ };
+ });
+ },
+ async act(token, id, action) {
+ const path = { start: 'poweron', stop: 'shutdown', reboot: 'reboot' }[action];
+ await call('hetzner', token, `/servers/${encodeURIComponent(id)}/actions/${path}`, { method: 'POST' });
+ },
+ },
+ };
+ return adapters;
+}
+
+module.exports = { PROVIDERS, createProviders, ProviderError };
@@ -21,6 +21,13 @@ const telemetryRetention = startTelemetryRetention({ db: ctx.store.db, log: cons
@@ -21,6 +21,13 @@ const telemetryRetention = startTelemetryRetention({ db: ctx.store.db, log: cons
server.requestTimeout = 30_000;
server.requestTimeout = 30_000;
ctx.outbox.start();
ctx.outbox.start();
ctx.worker.start();
ctx.worker.start();
+// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
+// HOST_EVENTS_SECRET or the client secret.
+const { startSubscriptions, TOPICS } = require('openvibe-sdk/account-data');
+const subscriptions = startSubscriptions({
+ topics: TOPICS, eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
+ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+});
// Signals belong to the kit: the worker stops taking new work, requests in flight drain for at most
// Signals belong to the kit: the worker stops taking new work, requests in flight drain for at most
// 4000 ms (a shorter drain than the 10 min upload timeout cuts an upload exactly as the old 5 s
// 4000 ms (a shorter drain than the 10 min upload timeout cuts an upload exactly as the old 5 s
@@ -29,7 +36,7 @@ ctx.worker.start();
@@ -29,7 +36,7 @@ ctx.worker.start();
const { stop } = gracefulStop({
const { stop } = gracefulStop({
name: 'Host',
name: 'Host',
server,
server,
- stop: [() => ctx.worker.stop()],
+ stop: [() => ctx.worker.stop(), () => { if (subscriptions) subscriptions.stop(); }],
close: [
close: [
() => ctx.outbox.stop(),
() => ctx.outbox.stop(),
() => ctx.stopMetrics(),
() => ctx.stopMetrics(),
@@ -14,7 +14,7 @@ const frame = require('openvibe-shared/frame');
@@ -14,7 +14,7 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
-const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Domains', href: '/catalog/domains' }, { label: 'Benchmarks', href: '/catalog/benchmarks' }, { label: 'Offers', href: '/catalog/offers' }, { label: 'Promotions', href: '/catalog/promos' }];
+const NAV_LINKS = () => [{ label: 'Compare', href: '/catalog' }, { label: 'Console', href: '/console' }, { label: 'Domains', href: '/catalog/domains' }, { label: 'Benchmarks', href: '/catalog/benchmarks' }, { label: 'Offers', href: '/catalog/offers' }, { label: 'Promotions', href: '/catalog/promos' }];
const DESCRIPTION = 'OpenVibe.Host: hosting plans compared, every price dated and sourced.';
const DESCRIPTION = 'OpenVibe.Host: hosting plans compared, every price dated and sourced.';
const SITE_NAME = 'OpenVibe.Host';
const SITE_NAME = 'OpenVibe.Host';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -10,7 +10,7 @@ const assert = require('assert');
@@ -10,7 +10,7 @@ const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { parseYabs, LINKS_PER_DAY } = require('../server/catalog/benchmarks');
const { parseYabs, LINKS_PER_DAY } = require('../server/catalog/benchmarks');
const { csrfToken } = require('../server/auth/forms');
const { csrfToken } = require('../server/auth/forms');
-const accountData = require('../server/catalog/account-data');
+const accountData = require('../server/account-data');
const YABS = () => ({
const YABS = () => ({
version: 'v2026-10-07', time: '20261016-120000',
version: 'v2026-10-07', time: '20261016-120000',
@@ -145,13 +145,16 @@ const linode = (time = '2026-09-22T11:00:00Z') => ({ id: `itm_${'1'.repeat(26)}`
@@ -145,13 +145,16 @@ const linode = (time = '2026-09-22T11:00:00Z') => ({ id: `itm_${'1'.repeat(26)}`
const own = await t.api('GET', `/catalog/benchmarks/${id}`, { session: ada });
const own = await t.api('GET', `/catalog/benchmarks/${id}`, { session: ada });
assert.match(own.text, /Remove this result/);
assert.match(own.text, /Remove this result/);
assert.strictEqual((await t.api('DELETE', `/api/v1/catalog/benchmarks/${id}`, { as: bo })).status, 403);
assert.strictEqual((await t.api('DELETE', `/api/v1/catalog/benchmarks/${id}`, { as: bo })).status, 403);
- const exported = await accountData.exportFor(t.ctx.store.db, ada.subject);
- assert.strictEqual(exported.catalog_benchmarks.length, 1);
- assert.ok(exported.catalog_bench_links.length >= 2);
+ const data = accountData.create({ db: t.ctx.store.db, log: { warn() {}, info() {} } });
+ const file = (part, name) => (part.files.find((f) => f.name === name) || { content: [] }).content;
+ const exported = await data.exportPart(ada.subject);
+ assert.strictEqual(file(exported, 'benchmarks.json').length, 1);
+ assert.ok(file(exported, 'benchmark-links.json').length >= 2);
+ assert.ok(file(exported, 'benchmark-links.json').every((l) => !('token_hash' in l)), 'never the link token');
assert.strictEqual((await t.api('DELETE', `/api/v1/catalog/benchmarks/${id}`, { as: ada })).status, 200);
assert.strictEqual((await t.api('DELETE', `/api/v1/catalog/benchmarks/${id}`, { as: ada })).status, 200);
assert.strictEqual((await t.api('GET', `/catalog/benchmarks/${id}`)).status, 404);
assert.strictEqual((await t.api('GET', `/catalog/benchmarks/${id}`)).status, 404);
- const erased = await accountData.eraseFor(t.ctx.store.db, bo.subject);
- assert.deepStrictEqual([erased.catalog_benchmarks, erased.catalog_bench_links > 0], [1, true]);
+ const erased = await data.erase([bo.subject]);
+ assert.deepStrictEqual([erased.retained.tombstones, erased.erased.catalog_bench_links > 0], [1, true]);
assert.strictEqual((await t.api('GET', '/api/v1/catalog/benchmarks?provider=racknerd')).json().benchmarks.length, 1, 'the measurement stays, anonymous');
assert.strictEqual((await t.api('GET', '/api/v1/catalog/benchmarks?provider=racknerd')).json().benchmarks.length, 1, 'the measurement stays, anonymous');
});
});
} finally { await t.close(); }
} finally { await t.close(); }
@@ -6,7 +6,7 @@ const { createStore } = require('../server/db');
@@ -6,7 +6,7 @@ const { createStore } = require('../server/db');
const { createCatalog } = require('../server/catalog/store');
const { createCatalog } = require('../server/catalog/store');
const { createCatalogIngest } = require('../server/catalog/ingest');
const { createCatalogIngest } = require('../server/catalog/ingest');
const { mapItem } = require('../server/catalog/mapping');
const { mapItem } = require('../server/catalog/mapping');
-const accountData = require('../server/catalog/account-data');
+const accountData = require('../server/account-data');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
const at = '2026-10-11T12:00:00Z';
const at = '2026-10-11T12:00:00Z';
// The shape the Sources `api` adapter stores: the plan id as identity, Linode's label as title, the mapped extras flat in fields.
// The shape the Sources `api` adapter stores: the plan id as identity, Linode's label as title, the mapped extras flat in fields.
@@ -100,8 +100,10 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repea
@@ -100,8 +100,10 @@ const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repea
});
});
await check('ADR-033 export and erase keep shared rows but remove attribution', async () => {
await check('ADR-033 export and erase keep shared rows but remove attribution', async () => {
const p = await catalog.save('providers', { slug: 'staff', name: 'Staff', homepage_url: 'https://staff.test' }, null, 'usr_1');
const p = await catalog.save('providers', { slug: 'staff', name: 'Staff', homepage_url: 'https://staff.test' }, null, 'usr_1');
- assert.strictEqual((await accountData.exportFor(db.db, 'usr_1')).catalog_providers.length, 1);
- assert.strictEqual((await accountData.eraseFor(db.db, 'usr_1')).catalog_providers, 1);
+ const data = accountData.create({ db: db.db, log: { warn() {}, info() {} } });
+ const part = await data.exportPart('usr_1');
+ assert.strictEqual(part.files.find((f) => f.name === 'catalog-providers.json').content.length, 1);
+ assert.strictEqual((await data.erase(['usr_1'])).retained.tombstones, 1);
assert.strictEqual((await catalog.get('providers', p.id)).created_by, null);
assert.strictEqual((await catalog.get('providers', p.id)).created_by, null);
});
});
await check('unknown source is held and ingest off consumes nothing', async () => {
await check('unknown source is held and ingest off consumes nothing', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+'use strict';
+/**
+ * The console (server/console/): a person connects their own hosting accounts and sees and runs every server in one
+ * place. The token is checked with the provider, sealed (never stored or shown in clear), and sent only to that
+ * provider; a refused token connects nothing. The list combines providers with prices, totals and the catalog's cheaper
+ * equivalent; power actions reach the provider and are logged; nobody acts on another person's account; disconnecting
+ * deletes the token. Account export carries the accounts without their tokens, and deletion removes them. Without
+ * HOST_CREDENTIALS_KEY nothing is connected. Fake provider APIs stand in for Linode and DigitalOcean.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const ORIGIN = 'https://openvibe.host';
+const KEY = 'a'.repeat(64);
+const LINODE_TOKEN = 'linode-token-0123456789abcdef';
+const DO_TOKEN = 'do-token-0123456789abcdef0000';
+
+function fakeProviders() {
+ const calls = [];
+ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+ const fetchImpl = async (url, init = {}) => {
+ const u = new URL(String(url));
+ const auth = (init.headers && (init.headers.Authorization || init.headers.authorization)) || '';
+ const method = init.method || 'GET';
+ if (u.hostname === 'api.linode.com') {
+ calls.push({ host: 'linode', method, path: u.pathname, auth });
+ if (auth !== `Bearer ${LINODE_TOKEN}`) return json(401, { errors: [{ reason: 'Invalid Token' }] });
+ if (u.pathname === '/v4/profile') return json(200, { username: 'alice-linode' });
+ if (u.pathname === '/v4/linode/types') return json(200, { data: [{ id: 'g6-standard-2', price: { monthly: 24 } }] });
+ if (u.pathname === '/v4/linode/instances') return json(200, { data: [{ id: 101, label: 'web-1', region: 'us-east', status: 'running', type: 'g6-standard-2', specs: { vcpus: 2, memory: 4096, disk: 81920 }, ipv4: ['203.0.113.10'], ipv6: '2001:db8::1/128', created: '2026-01-01T00:00:00' }] });
+ if (method === 'POST' && /^\/v4\/linode\/instances\/101\/(boot|shutdown|reboot)$/.test(u.pathname)) return json(200, {});
+ return json(404, { errors: [{ reason: 'Not found' }] });
+ }
+ if (u.hostname === 'api.digitalocean.com') {
+ calls.push({ host: 'do', method, path: u.pathname, auth, body: init.body });
+ if (auth !== `Bearer ${DO_TOKEN}`) return json(401, { message: 'Unable to authenticate you' });
+ if (u.pathname === '/v2/account') return json(200, { account: { email: '[email protected]' } });
+ if (u.pathname === '/v2/droplets') return json(200, { droplets: [{ id: 202, name: 'db-1', status: 'off', vcpus: 1, memory: 1024, disk: 25, region: { slug: 'fra1' }, size_slug: 's-1vcpu-1gb', size: { price_monthly: 6 }, networks: { v4: [{ type: 'public', ip_address: '198.51.100.7' }] }, created_at: '2026-02-01T00:00:00Z' }] });
+ if (method === 'POST' && u.pathname === '/v2/droplets/202/actions') return json(201, { action: { id: 1 } });
+ return json(404, { message: 'Not found' });
+ }
+ return globalThis.fetch(url, init);
+ };
+ return { calls, fetchImpl };
+}
+
+(async () => {
+ const fake = fakeProviders();
+ const sent = [];
+ const accountSend = async (path, body) => { sent.push({ path, body }); return { ok: true, status: 200, json: async () => ({}) }; };
+ const t = await boot({ fetchImpl: fake.fetchImpl, accountSend, env: { HOST_CREDENTIALS_KEY: KEY } });
+ const { csrfToken } = require('../server/auth/forms');
+ const alice = t.user('alice'), bob = t.user('bob'), staff = t.user('editor', { role: 'admin' });
+ const csrf = (u) => csrfToken(t.config, { subject: u.subject });
+ const post = (u, path, form) => t.api('POST', path, { session: u, form: { csrf: csrf(u), ...form }, headers: { origin: ORIGIN } });
+ const notice = (r) => new URLSearchParams(r.headers.location.split('?')[1] || '').get('notice');
+ let linodeAcct;
+ try {
+ await check('signed out, the console sends you to sign in; signed in with nothing connected, it says how to start', async () => {
+ const out = await t.api('GET', '/console');
+ assert.strictEqual(out.status, 303);
+ assert.match(out.headers.location, /^\/auth\/login\?next=%2Fconsole/);
+ const r = await t.api('GET', '/console', { session: alice });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Your servers, every provider/);
+ assert.match(r.text, /Connect a hosting account below/);
+ assert.match(r.headers['cache-control'], /no-store/);
+ });
+
+ await check('a refused token connects nothing; a good one is checked, sealed and labelled', async () => {
+ const bad = await post(alice, '/console/accounts', { provider: 'linode', token: 'not-the-right-token-xyz' });
+ assert.match(notice(bad), /refused the token/);
+ assert.strictEqual(Number((await t.ctx.store.db.prepare('SELECT count(*) AS n FROM host_console_accounts').get()).n), 0);
+ const ok = await post(alice, '/console/accounts', { provider: 'linode', token: LINODE_TOKEN });
+ assert.match(notice(ok), /Linode \(Akamai\) connected/);
+ const row = await t.ctx.store.db.prepare('SELECT * FROM host_console_accounts WHERE owner_subject = ?').get(alice.subject);
+ linodeAcct = row.id;
+ assert.deepStrictEqual([row.label, row.token_hint], ['alice-linode', 'cdef']);
+ assert.ok(!row.token_sealed.includes(LINODE_TOKEN) && row.token_sealed.startsWith('v1.'), 'only the sealed token is stored');
+ assert.strictEqual(t.ctx.console._open(row.token_sealed), LINODE_TOKEN);
+ await post(alice, '/console/accounts', { provider: 'digitalocean', token: DO_TOKEN, label: 'Side projects' });
+ const forged = await t.api('POST', '/console/accounts', { session: alice, form: { csrf: 'nope', provider: 'linode', token: LINODE_TOKEN }, headers: { origin: ORIGIN } });
+ assert.match(notice(forged), /form expired/);
+ });
+
+ await check('the console combines both providers: status, size, address, prices, totals, and a cheaper catalog plan', async () => {
+ const p = await t.api('POST', '/api/v1/catalog/providers', { as: staff, json: { slug: 'cheapco', name: 'CheapCo', homepage_url: 'https://cheap.example' } });
+ await t.api('POST', '/api/v1/catalog/plans', { as: staff, json: { provider_id: p.json().provider.id, external_id: 'c2', name: 'C2', kind: 'vps', currency: 'USD', price_monthly: 5, vcpu: 2, ram_mb: 4096, disk_gb: 40, source_url: 'https://cheap.example/pricing' } });
+ const r = await t.api('GET', '/console?fresh=1', { session: alice });
+ assert.match(r.text, /<b>web-1<\/b>/);
+ assert.match(r.text, /<b>db-1<\/b>/);
+ assert.match(r.text, /203\.0\.113\.10/);
+ assert.match(r.text, /\$24</);
+ assert.match(r.text, /CheapCo has it for \$5/, 'the catalog has the same size for less');
+ assert.match(r.text, /<b>\$30<\/b><span>a month<\/span>/, 'the totals add both');
+ assert.match(r.text, /Power on/, 'the stopped droplet can be powered on');
+ assert.ok(fake.calls.every((c) => (c.host === 'linode' && c.auth.endsWith(LINODE_TOKEN)) || (c.host === 'do' && (c.auth.endsWith(DO_TOKEN) || c.auth.endsWith('xyz'))) || c.auth.endsWith('xyz')), 'each token goes only to its provider');
+ });
+
+ await check('power actions reach the provider and are logged; nobody acts on another person\'s account', async () => {
+ const r = await post(alice, `/console/accounts/${linodeAcct}/servers/101/stop`, {});
+ assert.match(notice(r), /Power off sent to web-1/);
+ assert.ok(fake.calls.some((c) => c.host === 'linode' && c.method === 'POST' && c.path === '/v4/linode/instances/101/shutdown'));
+ const doAcct = (await t.ctx.store.db.prepare("SELECT id FROM host_console_accounts WHERE provider = 'digitalocean'").get()).id;
+ await post(alice, `/console/accounts/${doAcct}/servers/202/start`, {});
+ assert.ok(fake.calls.some((c) => c.host === 'do' && c.path === '/v2/droplets/202/actions' && /power_on/.test(c.body)));
+ const page = await t.api('GET', '/console', { session: alice });
+ assert.match(page.text, /stop <b>web-1<\/b> \(Linode \(Akamai\)\): sent/);
+ const intruder = await post(bob, `/console/accounts/${linodeAcct}/servers/101/reboot`, {});
+ assert.match(notice(intruder), /no such connected account/);
+ const unknown = await post(alice, `/console/accounts/${linodeAcct}/servers/999/reboot`, {});
+ assert.match(notice(unknown), /not in this account/);
+ });
+
+ await check('account export carries the accounts and actions without the tokens; deletion removes them', async () => {
+ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
+ assert.strictEqual(await t.app.locals.accountData.apply(ev('evt_01JZ0000000000000000HC0001', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000HCX001', subject: alice.subject }), { send: accountSend }), 'exported');
+ const part = sent.find((c) => c.path === '/internal/account-exports/exp_01JZ0000000000000000HCX001/parts');
+ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
+ assert.deepStrictEqual(files['console-accounts.json'].map((a) => a.provider).sort(), ['digitalocean', 'linode']);
+ assert.ok(!/token_sealed|v1\.|linode-token|do-token/.test(JSON.stringify(part.body)), 'no token in the export');
+ assert.ok(files['console-actions.json'].some((a) => a.action === 'stop'));
+ assert.strictEqual(await t.app.locals.accountData.apply(ev('evt_01JZ0000000000000000HD0001', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000HDE001', subject: alice.subject }), { send: accountSend }), 'erased');
+ assert.strictEqual(Number((await t.ctx.store.db.prepare('SELECT count(*) AS n FROM host_console_accounts WHERE owner_subject = ?').get(alice.subject)).n), 0);
+ assert.strictEqual(Number((await t.ctx.store.db.prepare('SELECT count(*) AS n FROM host_console_actions WHERE owner_subject = ?').get(alice.subject)).n), 0);
+ });
+
+ await check('disconnecting deletes the token', async () => {
+ await post(bob, '/console/accounts', { provider: 'linode', token: LINODE_TOKEN });
+ const id = (await t.ctx.store.db.prepare('SELECT id FROM host_console_accounts WHERE owner_subject = ?').get(bob.subject)).id;
+ const r = await post(bob, `/console/accounts/${id}/remove`, {});
+ assert.match(notice(r), /token is deleted/);
+ assert.strictEqual(Number((await t.ctx.store.db.prepare('SELECT count(*) AS n FROM host_console_accounts WHERE id = ?').get(id)).n), 0);
+ });
+ } finally { await t.close(); }
+
+ const t2 = await boot({ fetchImpl: fake.fetchImpl, env: { HOST_CREDENTIALS_KEY: '' } });
+ try {
+ await check('without HOST_CREDENTIALS_KEY nothing is connected', async () => {
+ const carol = t2.user('carol');
+ const page = await t2.api('GET', '/console', { session: carol });
+ assert.match(page.text, /not set up on this server yet/);
+ const { csrfToken: tok } = require('../server/auth/forms');
+ const r = await t2.api('POST', '/console/accounts', { session: carol, form: { csrf: tok(t2.config, { subject: carol.subject }), provider: 'linode', token: LINODE_TOKEN }, headers: { origin: ORIGIN } });
+ assert.match(new URLSearchParams(r.headers.location.split('?')[1]).get('notice'), /HOST_CREDENTIALS_KEY/);
+ });
+ } finally { await t2.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -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, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: quiet, fetchImpl: opts.fetchImpl, rdap: opts.rdap });
+ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, 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;