openvibers/openvibe.host Commits

Host: openvibe.host is hosting compared (the sourced hosting catalog, now the front page) (#69)

OP OpenVibers committed 11 Oct 2026 · ab1de18eae · 29 files, +1124 −57

* Host: openvibe.host is hosting compared (the sourced hosting catalog, now the front page)

**openvibe.host becomes hosting compared** (plan T20 step 3; owner, 2026-10-08: "multi provider hosting comparison … lowendbox/lowendtalk modern competitor").

What visitors get:
- **Front page** (signed out): leads with the comparison. The hero lists the five lowest monthly prices, followed by the plan table, "why this comparison is different", running promotions, and a short section on OpenVibe's own static hosting, which is still not open, with a link to `/hosting`.
- **`/catalog`:** a no-JS filter form (kind, vCPU, RAM, disk, max price, provider, location, currency) and sorts: price, per vCPU, per GB of RAM, per GB of disk, recently checked. Each row shows the price, the per-resource prices, the day it was checked and a source link. On phones the table becomes cards. The page explains how the comparison works.
- **`/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id`** (the plan page carries specs, value and price history) **and `/catalog/promos`.**
- **Affiliate links** are marked, carry `rel="sponsored nofollow noopener"`, and show the provider's own disclosure beside them. The order never depends on affiliation (tested).
- **`/hosting`:** the old front page (Stage B static hosting, not open yet, with its limits).

Data:
- migration `0005_catalog.sql` (expand): providers, plans, price changes and promotions;
- ingest from OpenVibe.Sources category `hosting` through `openvibe-publishing/ingest` (v1.4.0, new dependency), off by default (`HOST_CATALOG_INGEST`). Mappings for `linode-types` and `vultr-plans` read the real `api`-adapter item shape: identity, title, flat `fields`;
- staff entries via `host.catalog.manage` (Contracts 0.139.0, pinned) and no-JS forms at `/catalog/manage`;
- account data: `created_by` is exported and made authorless on erase (`docs/catalog-account-data.md`).

Also bumps openvibe-shared to v4.0.0 (share cards: Host's pages get `og:image` from `shell.page`), which replaces the pin-bump PR for this repository.

Built by Codex from my spec. I fixed the item shape, added the page design (`server/render/catalog.js`, `public/css/catalog.css`) and the front-page switch. Tests: `catalog-ingest`, `catalog-manage` and `catalog-public` are new; `host-launch-switch`, `host-secrets-dashboard` (robots, sitemap, SSR front) and `host-contracts-events` are updated. All 36 covering test files pass.

After deploy (operator): enable Sources' `linode-types` seed, set `HOST_CATALOG_INGEST=on` and `HOST_CATALOG_SOURCES_URL`, and grant Host's principal `sources.item.read` for category `hosting`.

* Host tests: six capabilities; the front page's CSS budget covers the catalog stylesheet

* Host catalog test: look for the tables in the current schema (CI runs each file in its own)

* Docs currency: STATUS and README name the pinned releases

Changes

ddiff --git a/.env.example b/.env.example
iindex f46f183..4a011f9 100644
---- a/.env.example
++++ b/.env.example
@@@ -116,3 +116,9 @@ HOST_FORM_SECRET=
# release.json (openvibe-shared/release): the deployed commit when git is not available
#RELEASE_COMMIT=
#MIN_CLIENT_RELEASE=
++
++# Sourced hosting catalog: off by default. The URL is the internal OpenVibe.Sources API.
++# Signed sources.item.* deliveries arrive at /internal/catalog/events when secrets are configured.
++HOST_CATALOG_INGEST=off
++HOST_CATALOG_SOURCES_URL=
++HOST_CATALOG_WEBHOOK_SECRETS=
ddiff --git a/README.md b/README.md
iindex 973a715..3b9936d 100644
---- a/README.md
++++ b/README.md
@@@ -7,6 +7,25 @@
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.3 and §15.17; roadmap Wave 21.
**License:** AGPL-3.0 (same as every OpenVibe service).
++## The hosting catalog (openvibe.host's front page)
++
++Plan T20 step 3 (owner, 2026-10-08): openvibe.host is **hosting compared**: a modern LowEndBox with honest data. The signed-out front page and `/catalog` compare cloud, VPS and dedicated plans side by side:
++- price per month, per vCPU and per GB of RAM and disk;
++- the day each price was checked, and a link to its source;
++- price changes kept as a history per plan.
++
++OpenVibe's own static hosting (Stage B below) has its page at `/hosting`; it is still not open to the public.
++
++- **Where plans come from.**
++ - OpenVibe.Sources items in category `hosting` (official provider APIs), read with `openvibe-publishing/ingest`: an event consumer at `/internal/catalog/events` plus a cursor pull. The switch is `HOST_CATALOG_INGEST` (default off) with `HOST_CATALOG_SOURCES_URL`.
++ - The mapping is `server/catalog/mapping.js`, keyed by source: `linode-types` and `vultr-plans`. Vultr's API host disallows robots, so that Sources seed stays disabled.
++ - Staff entries, which must name their source URL.
++ - Nothing is estimated. A value the source does not state is shown as Unknown.
++- **Order.** It comes only from the visitor's sort: price, per vCPU, per GB of RAM, per GB of disk, or recently checked. Affiliate links carry `rel="sponsored nofollow noopener"` with the provider's own disclosure beside them, and never change the order (`test/catalog-public.test.js`).
++- **Staff.** `host.catalog.manage` (Network staff only) covers `POST`/`PATCH /api/v1/catalog/{providers,plans,promos}` and the no-JS forms at `/catalog/manage`.
++- **Public reads.** `GET /api/v1/catalog/plans` (filters `kind`, `min_vcpu`, `min_ram_mb`, `min_disk_gb`, `max_price`, `currency`, `location`, `provider`; `sort`; cursor), `/plans/:id` (with price history), `/providers`, `/providers/:slug` and `/promos`.
++- **Pages.** `/catalog`, `/catalog/providers`, `/catalog/providers/:slug`, `/catalog/plans/:id` and `/catalog/promos`. They are server-rendered and work without JavaScript; the styles are `public/css/catalog.css`.
++
## Purpose
Repository deploy scripts call ovhost. Stage A applies the deploy rules for every service on the host:
@@@ -59,7 +78,8 @@ Stage A adds a few safety rules of its own:
Implemented here (the service manifest's `capabilities`, Stage B's API, audience `openvibe.host`):
`host.site.manage` (sites, members, takedowns), `host.deploy.create` (uploads, activation, rollback),
--`host.domain.manage` (custom domains) and `host.site.config` (headers, redirects, SPA fallback). See
++`host.domain.manage` (custom domains), `host.site.config` (headers, redirects, SPA fallback), and
++`host.catalog.manage` (staff-only hosting catalog writes). See
[Grants and registration](#grants-and-registration-for-the-lead).
`host.resource.read` (first-party) is the authority resource index Host serves for OpenVibe.Services'
@@@ -563,7 +583,8 @@ review is [docs/threat-review.md](docs/threat-review.md). The launch has not bee
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.129.0
++- openvibe-contracts: v0.139.0
- openvibe-sdk: v0.44.0
--- openvibe-shared: v3.0.1
++- openvibe-shared: v4.0.0
++- openvibe-publishing: v1.4.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 42b272e..691fa19 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -14,20 +14,21 @@
"port": 4910,
"domain": "openvibe.host",
"tenantDomain": "*.openvibe.host",
-- "contracts": "openvibe-contracts v0.129.0",
++ "contracts": "openvibe-contracts v0.139.0",
"sdk": "openvibe-sdk v0.44.0",
-- "shared": "openvibe-shared v3.0.1",
++ "shared": "openvibe-shared v4.0.0",
"packages": [
-- "openvibe-contracts v0.129.0",
++ "openvibe-contracts v0.139.0",
"openvibe-sdk v0.44.0",
-- "openvibe-shared v3.0.1"
++ "openvibe-shared v4.0.0"
],
"capabilities": [
"host.site.manage",
"host.deploy.create",
"host.domain.manage",
"host.site.config",
-- "host.resource.read"
++ "host.resource.read",
++ "host.catalog.manage"
],
"capabilitiesRegistered": true,
"serviceManifestRegistered": "host manifest v0.2.0 released (openvibe-contracts v0.24.0+), with the four host.* events",
ddiff --git a/docs/catalog-account-data.md b/docs/catalog-account-data.md
nnew file mode 100644
iindex 0000000..3abd653
---- /dev/null
++++ b/docs/catalog-account-data.md
@@@ -0,0 +1,11 @@
++# Hosting catalog account data (ADR-033)
++
++The catalog has three person columns: `catalog_providers.created_by`,
++`catalog_plans.created_by`, and `catalog_promos.created_by`. The
++`server/catalog/account-data.js` map exports the rows created by a person. On
++account deletion it sets those three columns to null. The shared provider,
++plan, and promo rows stay in the catalog without an author. Source-ingested
++rows have no `created_by`. `catalog_price_changes` has no person column.
++
++Host has no existing account-data event handler or service-wide ADR-033 map to
++register this with. The helpers are ready for that integration when it lands.
ddiff --git a/migrations/0005_catalog.sql b/migrations/0005_catalog.sql
nnew file mode 100644
iindex 0000000..5a389f3
---- /dev/null
++++ b/migrations/0005_catalog.sql
@@@ -0,0 +1,79 @@
++-- phase: expand
++-- Public, sourced hosting catalog. Times are epoch milliseconds, like Host's other tables.
++CREATE TABLE catalog_providers (
++ id text COLLATE "C" PRIMARY KEY,
++ slug text COLLATE "C" NOT NULL UNIQUE,
++ name text NOT NULL,
++ homepage_url text NOT NULL,
++ description text,
++ affiliate_url text,
++ affiliate_note text,
++ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','hidden')),
++ created_by text,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++CREATE TABLE catalog_plans (
++ id text COLLATE "C" PRIMARY KEY,
++ provider_id text COLLATE "C" NOT NULL REFERENCES catalog_providers(id),
++ external_id text COLLATE "C" NOT NULL,
++ name text NOT NULL,
++ kind text NOT NULL CHECK (kind IN ('cloud','vps','dedicated','storage','domain')),
++ vcpu numeric CHECK (vcpu >= 0),
++ ram_mb numeric CHECK (ram_mb >= 0),
++ disk_gb numeric CHECK (disk_gb >= 0),
++ disk_type text CHECK (disk_type IN ('ssd','nvme','hdd')),
++ bandwidth_gb numeric CHECK (bandwidth_gb >= 0),
++ unmetered boolean,
++ port_mbps numeric CHECK (port_mbps >= 0),
++ ipv4 integer CHECK (ipv4 >= 0),
++ locations text[],
++ currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
++ price_monthly numeric(12,4) NOT NULL CHECK (price_monthly >= 0),
++ price_hourly numeric(12,6) CHECK (price_hourly >= 0),
++ setup_fee numeric(12,4) CHECK (setup_fee >= 0),
++ origin text NOT NULL CHECK (origin IN ('sources','staff')),
++ source_key text,
++ source_item_id text,
++ source_url text NOT NULL,
++ checked_at bigint NOT NULL,
++ first_seen_at bigint NOT NULL,
++ last_seen_at bigint NOT NULL,
++ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','gone','hidden')),
++ created_by text,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ UNIQUE (provider_id, external_id),
++ CHECK (origin = 'staff' OR (source_key IS NOT NULL AND source_item_id IS NOT NULL))
++);
++CREATE INDEX catalog_plans_public ON catalog_plans (currency, status, price_monthly, id);
++CREATE TABLE catalog_price_changes (
++ plan_id text COLLATE "C" NOT NULL REFERENCES catalog_plans(id),
++ observed_at bigint NOT NULL,
++ currency text NOT NULL,
++ price_monthly_before numeric(12,4) NOT NULL,
++ price_monthly_after numeric(12,4) NOT NULL,
++ PRIMARY KEY (plan_id, observed_at),
++ CHECK (price_monthly_before <> price_monthly_after)
++);
++CREATE TABLE catalog_promos (
++ id text COLLATE "C" PRIMARY KEY,
++ provider_id text COLLATE "C" NOT NULL REFERENCES catalog_providers(id),
++ title text NOT NULL,
++ details text,
++ code text,
++ url text NOT NULL,
++ affiliate boolean NOT NULL DEFAULT false,
++ starts_at bigint,
++ ends_at bigint,
++ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','ended','hidden')),
++ created_by text,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL,
++ CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at <= ends_at)
++);
++CREATE INDEX catalog_promos_live ON catalog_promos (status, ends_at);
++CREATE TABLE catalog_ingest_cursor (name text COLLATE "C" PRIMARY KEY, cursor bigint NOT NULL, updated_at bigint NOT NULL);
++-- Historical observations are append-only.
++CREATE FUNCTION catalog_price_changes_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'host: catalog price history is immutable'; END $$;
++CREATE TRIGGER catalog_price_changes_immutable BEFORE UPDATE OR DELETE ON catalog_price_changes FOR EACH ROW EXECUTE FUNCTION catalog_price_changes_immutable();
ddiff --git a/package-lock.json b/package-lock.json
iindex 30aa218..8f2b40d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,9 +16,10 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.139.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
"pg": "^8.23.0"
},
"bin": {
@@@ -1270,9 +1271,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.129.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
-- "integrity": "sha512-Ly0F/ap6wmAf8hYpNrOhMLsU3cZYBjsFDW2P1F/xVmHVsRDzAPAZDXtFcohP0UNeZgyxgrR0BI0pPG5guD/Efw==",
++ "version": "0.139.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.139.0",
++ "integrity": "sha512-HXjKRDHpc+kRgDyRmPtDRdqwbQxWwyJDCj3F71UMoQk1DA/7vik5vEnZIIasDtAuO4Ut5roJG+wUgL7a1VvqIQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1285,6 +1286,24 @@
"node": ">=20"
}
},
++ "node_modules/openvibe-publishing": {
++ "version": "1.4.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
++ "integrity": "sha512-9vByyVqpvy2zVQI4PrJWk5QqCym/YQ/fXZDgrcloKGBqvukKrUn7fg4ZERiJHcgEq+rgDbxbU4rNflZhw62K/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "openvibe-sdk": ">=0.15.0",
++ "openvibe-shared": ">=1.5.0"
++ },
++ "peerDependenciesMeta": {
++ "openvibe-shared": {
++ "optional": true
++ }
++ }
++ },
"node_modules/openvibe-sdk": {
"version": "0.44.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
@@@ -1311,9 +1330,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "3.0.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
-- "integrity": "sha512-7EvVTZFzhvUQw1r2zB1Lpg2lTnyU6rQl7OqkDS2ESsmvRxoKsH/ztquFtVTNSRtwhI5NR2kmW1PADkCwiBJcZA==",
++ "version": "4.0.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
++ "integrity": "sha512-k87pgPboAD0+dfgb6LXNWP7Q949gazmVvYhee0Seo+OztVzOgoOSLwxU2oiQwLZLtDtEMPacdblMReGxEoINDg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 80edae7..74e7073 100644
---- a/package.json
++++ b/package.json
@@@ -32,16 +32,17 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.139.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.0",
"pg": "^8.23.0"
},
"engines": {
"node": ">=22.0.0"
},
"devDependencies": {
-- "jsonwebtoken": "^9.0.2",
-- "@electric-sql/pglite": "^0.5.8"
++ "@electric-sql/pglite": "^0.5.8",
++ "jsonwebtoken": "^9.0.2"
}
}
ddiff --git a/public/css/catalog.css b/public/css/catalog.css
nnew file mode 100644
iindex 0000000..16c1590
---- /dev/null
++++ b/public/css/catalog.css
@@@ -0,0 +1,127 @@
++/* OpenVibe.Host hosting catalog (server/render/catalog.js). Theme tokens from host.css / the shared theme. */
++.hc-head { margin: 1.5rem 0 1rem; max-width: 52rem; }
++.hc-head-row { max-width: none; display: flex; flex-wrap: wrap; gap: 1rem 2rem; align-items: flex-end; justify-content: space-between; }
++.hc-head h1 { font-size: clamp(1.7rem, 3.4vw, 2.4rem); letter-spacing: -.02em; line-height: 1.1; margin: .2rem 0 .4rem; }
++.hc-eyebrow { margin: 0; font-size: 13px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--hs-accent); }
++.hc-lede { margin: 0; font-size: 1.05rem; color: var(--hs-muted); max-width: 46rem; }
++.hc-facts { margin: .6rem 0 0; color: var(--hs-muted); }
++.hc-facts strong { color: var(--hs-fg); }
++.hc-crumbs { margin-top: 1rem; font-size: 14px; color: var(--hs-muted); }
++.hc-crumbs a { color: var(--hs-muted); }
++
++/* Filters: a compact grid that wraps to two columns on phones. */
++.hc-filters { display: grid; grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr)); gap: .75rem; align-items: end;
++ padding: 1rem; margin: 0 0 1.25rem; border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); }
++.hc-filters label { gap: .3rem; font-size: 13px; color: var(--hs-muted); }
++.hc-filters label span { font-weight: 600; }
++.hc-filters input, .hc-filters select { width: 100%; box-sizing: border-box; min-height: 2.4rem; color: var(--hs-fg); background: var(--hs-bg); }
++.hc-filter-actions { display: flex; align-items: center; gap: .75rem; }
++.hc-filter-actions button { min-height: 2.4rem; font-weight: 600; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
++.hc-reset { font-size: 14px; color: var(--hs-muted); }
++
++/* The comparison table. */
++.hc-table-wrap { border: 1px solid var(--hs-line); border-radius: 12px; overflow: hidden; background: var(--hs-card); }
++.hc-table { margin: 0; font-size: 14px; }
++.hc-table caption { caption-side: top; text-align: left; padding: .75rem 1rem; color: var(--hs-muted); font-size: 13px; border-bottom: 1px solid var(--hs-line); }
++.hc-table thead th { font-size: 12px; font-weight: 600; letter-spacing: .03em; text-transform: uppercase; color: var(--hs-muted); background: rgba(255,255,255,.02); white-space: nowrap; }
++.hc-table th, .hc-table td { padding: .65rem 1rem; border-bottom: 1px solid var(--hs-line); vertical-align: middle; }
++.hc-table tbody tr:last-child > * { border-bottom: 0; }
++.hc-table tbody tr:hover > * { background: rgba(96,165,250,.05); }
++.hc-table th[scope="row"] { font-weight: 600; }
++.hc-table th[scope="row"] a { color: var(--hs-fg); text-decoration: none; }
++.hc-table th[scope="row"] a:hover { color: var(--hs-accent); text-decoration: underline; }
++.hc-table td[data-label="Provider"] a { color: var(--hs-fg); text-decoration: none; font-weight: 500; }
++.hc-table td[data-label="Provider"] a:hover { color: var(--hs-accent); text-decoration: underline; }
++.hc-n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
++.hc-kind { display: block; font-size: 12px; font-weight: 400; color: var(--hs-muted); }
++.hc-price { font-weight: 700; font-size: 15px; color: var(--hs-fg); }
++.hc-per { color: var(--hs-muted); font-size: 12px; margin-left: 1px; }
++.hc-dim { color: var(--hs-muted); }
++.hc-unknown { color: var(--hs-muted); font-style: italic; font-size: 13px; }
++.hc-checked { white-space: nowrap; color: var(--hs-muted); font-variant-numeric: tabular-nums; }
++.hc-src { font-size: 12px; margin-left: .25rem; }
++.hc-chip { display: inline-block; vertical-align: middle; margin-left: .25rem; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 600;
++ letter-spacing: .02em; color: var(--hs-warn); border: 1px solid color-mix(in srgb, var(--hs-warn) 45%, transparent); background: color-mix(in srgb, var(--hs-warn) 10%, transparent); }
++.button .hc-chip { color: inherit; border-color: currentColor; background: transparent; }
++
++.hc-empty { padding: 1.5rem; border: 1px dashed var(--hs-line); border-radius: 12px; text-align: center; color: var(--hs-muted); }
++.hc-empty p { margin: .3rem 0; }
++.hc-empty strong { color: var(--hs-fg); }
++.hc-more { margin: 1rem 0; }
++.hc-links { margin: 1.25rem 0; color: var(--hs-muted); }
++.hc-disclosure { margin: 1.25rem 0; padding: .7rem .9rem; border-radius: 8px; background: var(--hs-card); border-left: 3px solid var(--hs-warn); color: var(--hs-muted); font-size: 14px; }
++.hc-button-quiet { background: transparent; color: var(--hs-fg); box-shadow: inset 0 0 0 1px var(--hs-line); }
++.hc-button-quiet:hover { box-shadow: inset 0 0 0 1px var(--hs-accent); }
++.hc-visit { display: inline-flex; flex-direction: column; gap: .35rem; align-items: flex-start; }
++.hc-visit .button { font-weight: 600; }
++.hc-note { display: block; max-width: 22rem; font-size: 12px; color: var(--hs-muted); }
++
++/* Method notes. */
++.hc-method { margin: 2.5rem 0 1rem; padding: 1.25rem 1.25rem .5rem; border-radius: 12px; border: 1px solid var(--hs-line); }
++.hc-method h2 { margin: 0 0 .5rem; font-size: 1.05rem; }
++.hc-method ul { margin: 0; padding-left: 1.1rem; color: var(--hs-muted); }
++.hc-method li { margin-bottom: .6rem; }
++.hc-method strong { color: var(--hs-fg); }
++
++/* Providers and promotions. */
++.hc-providers { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); gap: .75rem; }
++.hc-providers li { display: flex; flex-direction: column; gap: .3rem; padding: 1rem; border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); }
++.hc-providers li span { color: var(--hs-muted); font-size: 14px; }
++.hc-promos { list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: .75rem; }
++.hc-promo { padding: 1rem 1.1rem; border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); }
++.hc-promo h3 { margin: .1rem 0 .4rem; font-size: 1.05rem; }
++.hc-promo p { margin: .35rem 0; }
++.hc-promo-provider { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--hs-accent); }
++.hc-promo-meta { font-size: 13px; color: var(--hs-muted); }
++.hc-code code { padding: 2px 8px; border-radius: 6px; background: var(--hs-bg); border: 1px dashed var(--hs-line); font-size: 14px; user-select: all; }
++
++/* A plan. */
++.hc-plan { display: grid; grid-template-columns: minmax(16rem, 22rem) 1fr 1fr; gap: 1.25rem; align-items: start; }
++.hc-plan h2 { margin-top: 0; font-size: 1rem; }
++.hc-price-card { padding: 1.25rem; border-radius: 14px; border: 1px solid var(--hs-line); background: var(--hs-card); }
++.hc-big { margin: 0 0 .2rem; font-size: 2.2rem; font-weight: 800; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
++.hc-big span { font-size: 1rem; font-weight: 500; color: var(--hs-muted); margin-left: .2rem; }
++.hc-checked-line { margin: .75rem 0 1rem; font-size: 14px; color: var(--hs-muted); }
++.hc-spec { margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; }
++.hc-spec div { padding: .6rem .75rem; border-radius: 10px; border: 1px solid var(--hs-line); }
++.hc-spec dt { font-size: 12px; color: var(--hs-muted); text-transform: uppercase; letter-spacing: .03em; }
++.hc-spec dd { margin: .15rem 0 0; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
++.hc-history { padding-left: 1.2rem; font-variant-numeric: tabular-nums; }
++.hc-history time { color: var(--hs-muted); margin-right: .4rem; }
++
++/* The front page's hero aside: the five lowest prices. */
++.hc-aside { padding: 1rem 1.1rem; border-radius: 16px; border: 1px solid var(--hs-line); background: var(--hs-card); min-width: min(100%, 20rem); }
++.hc-aside-h { margin: 0 0 .5rem; font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--hs-muted); }
++.hc-aside ol { list-style: none; margin: 0; padding: 0; }
++.hc-aside li a { display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; column-gap: 1rem; padding: .55rem 0; border-bottom: 1px solid var(--hs-line); color: var(--hs-fg); text-decoration: none; }
++.hc-aside li:last-child a { border-bottom: 0; }
++.hc-aside li a:hover b { color: var(--hs-accent); }
++.hc-aside li strong { grid-row: 1 / span 2; grid-column: 2; align-self: center; font-variant-numeric: tabular-nums; }
++.hc-aside-spec { grid-column: 1; font-size: 12px; color: var(--hs-muted); }
++.hc-aside-foot { margin: .5rem 0 0; font-size: 12px; color: var(--hs-muted); }
++.hc-own { padding-top: 1rem; padding-bottom: 1rem; }
++
++@media (max-width: 60rem) { .hc-plan { grid-template-columns: 1fr; } }
++/* Phones: each plan becomes a card; the header row is read from each cell's label. */
++@media (max-width: 44rem) {
++ .hc-table-wrap { border: 0; background: none; overflow: visible; }
++ .hc-table caption { display: block; padding: 0 0 .5rem; border: 0; }
++ .hc-table, .hc-table tbody, .hc-table tr, .hc-table td, .hc-table th { display: block; }
++ .hc-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
++ .hc-table tr { display: grid; grid-template-columns: 1fr 1fr; gap: .1rem .75rem; margin-bottom: .75rem; padding: .75rem .9rem; border: 1px solid var(--hs-line); border-radius: 12px; background: var(--hs-card); }
++ .hc-table th, .hc-table td { padding: .2rem 0; border: 0; text-align: left; }
++ .hc-table tbody tr:hover > * { background: none; }
++ .hc-table th[scope="row"] { grid-column: 1; order: -2; font-size: 15px; }
++ .hc-table td[data-label="Provider"] { grid-column: 1 / -1; order: -3; font-size: 13px; }
++ .hc-table td[data-label="Price"] { grid-column: 2; order: -1; text-align: right; align-self: start; }
++ .hc-table td[data-label="Price"]::before { display: none; }
++ .hc-table td[data-label="Price"] .hc-price { font-size: 18px; }
++ .hc-table td::before { content: attr(data-label); display: block; font-size: 11px; text-transform: uppercase; letter-spacing: .03em; color: var(--hs-muted); }
++ .hc-table td[data-label="Provider"]::before { display: none; }
++ .hc-table td[data-label="Checked"] { grid-column: 1 / -1; white-space: normal; }
++ .hc-filters { grid-template-columns: 1fr 1fr; }
++ .hc-filter-actions { grid-column: 1 / -1; }
++ .hc-spec { grid-template-columns: 1fr 1fr; }
++}
++/* Filled buttons on the readable accent (white on --accent-strong passes AA; dark text on the light accent does not everywhere). */
++.hc-visit .button:not(.hc-button-quiet), .hc-more .button:not(.hc-button-quiet) { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
ddiff --git a/server/app.js b/server/app.js
iindex 4357b64..0cb58e1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -45,6 +45,9 @@ const resourceIndex = require('./registry/resource-index');
const { createDashboard } = require('./http/dashboard');
const { createHostReadiness } = require('./observability');
const { createWorker } = require('./worker');
++const { createCatalog } = require('./catalog/store');
++const { createCatalogIngest } = require('./catalog/ingest');
++const { createCatalogApi, createCatalogPages } = require('./http/catalog');
const { renderPage, assetVersion, setRelease } = require('./render/layout');
const pages = require('./render/pages');
@@@ -151,9 +154,11 @@ async function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, config, log });
const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
-- const worker = createWorker({ config, store, domains, blobs, outbox, log });
++ const catalog = createCatalog({ store });
++ const catalogIngest = createCatalogIngest({ config, store, catalog, fetchImpl: opts.fetchImpl });
++ const worker = createWorker({ config, store, domains, blobs, outbox, catalogIngest, log });
-- const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, auth, viewers, tenant, worker, uploadGate, indexnow, log };
++ const ctx = { config, store, blobs, outbox, access, takedowns, projects, sites, deploys, domains, siteConfig, siteSources, catalog, catalogIngest, auth, viewers, tenant, worker, uploadGate, indexnow, log };
const app = express();
app.disable('x-powered-by');
@@@ -227,38 +232,53 @@ async function createApp(opts = {}) {
// generated one when the deploy ships none.
const legalPaths = require('openvibe-shared/legal').PATHS;
app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
-- .send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
-- app.get('/sitemap.xml', (_req, res) => res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
-- .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${['/', ...legalPaths].map((p) => ` <url><loc>${config.baseUrl}${p}</loc></url>`).join('\n')}\n</urlset>\n`));
++ .send(['User-agent: *', 'Allow: /$', ...legalPaths.map((p) => `Allow: ${p}$`), 'Allow: /hosting$', 'Allow: /catalog$', 'Allow: /catalog/*', 'Disallow: /', '', `Sitemap: ${config.baseUrl}/sitemap.xml`, ''].join('\n')));
++ app.get('/sitemap.xml', async (_req, res, next) => {
++ try {
++ const providers = await catalog.providers();
++ const plans = await catalog.sitemapPlans();
++ const paths = ['/', '/hosting', ...legalPaths, '/catalog', '/catalog/promos', '/catalog/providers', ...providers.map((p) => `/catalog/providers/${p.slug}`), ...plans.map((p) => `/catalog/plans/${p.id}`)];
++ res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
++ .send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${paths.map((p) => ` <url><loc>${config.baseUrl}${p}</loc></url>`).join('\n')}\n</urlset>\n`);
++ } catch (err) { next(err); }
++ });
// /llms.txt and /llms-full.txt (llmstxt.org): a map of Host's own public pages only. A tenant site, a preview
// URL, the dashboard, /api and /internal are never listed (a tenant's own /llms.txt is its deploy's file).
-- const SITE_SUMMARY = 'OpenVibe.Host: static site hosting for OpenVibe projects (alpha). Immutable deploys, custom domains and one-step rollback.';
++ const SITE_SUMMARY = 'OpenVibe.Host: hosting plans compared (cloud, VPS and dedicated), every price dated and sourced and never ranked by who pays; and OpenVibe\'s own static hosting (alpha, not open to the public yet).';
const legalTitles = require('openvibe-shared/legal').TITLES;
const publicPages = [
-- { title: 'OpenVibe.Host', url: '/', text: 'The public front page of OpenVibe.Host: static site hosting for OpenVibe projects. Upload a folder or a .tar.gz of HTML, CSS, JavaScript, images and fonts; Host keeps every deploy as an immutable artifact, serves the active one on a <site>.openvibe.host address or a verified custom domain, and rolls back in one step. Alpha: static files only, no build step and no server-side code.' },
++ { title: 'OpenVibe.Host', url: '/', text: 'The front page of OpenVibe.Host: hosting plans compared. Cloud, VPS and dedicated plans from providers\' official APIs (through OpenVibe.Sources) and staff entries that name their source, with the price per month, per vCPU and per GB, the day each price was checked and its source link. The order comes only from the sort a visitor picks; affiliate links are marked and disclosed.' },
++ { title: 'Static hosting', url: '/hosting', text: 'OpenVibe.Host\'s own static site hosting: upload a folder or a .tar.gz, every deploy kept as an immutable artifact, served on a <site>.openvibe.host address or a verified custom domain, rolled back in one step. Not open to the public yet: only OpenVibe staff create projects until the launch review.' },
++ { title: 'Compare hosting plans', url: '/catalog', text: 'The comparison table: filter by kind, vCPU, RAM, disk, price, provider, location and currency; sort by monthly price or by price per vCPU, per GB of RAM or per GB of disk.' },
++ { title: 'Hosting providers', url: '/catalog/providers', text: 'Every provider in the catalog.' },
++ { title: 'Hosting promotions', url: '/catalog/promos', text: 'Hosting promotions running now, with codes and end dates as the providers state them.' },
{ title: 'What shipped on OpenVibe.Host', url: '/updates', text: 'The public update log for OpenVibe.Host: what shipped, newest first.' },
...legalPaths.map((p) => ({ title: legalTitles[p.slice(1)] || p.slice(1), url: p, text: `The ${legalTitles[p.slice(1)] || p.slice(1)} of OpenVibe.Host.` })),
];
-- app.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({
++ const catalogPublicPages = async () => [
++ ...((await catalog.providers()).map((p) => ({ title: p.name, url: `/catalog/providers/${p.slug}`, text: `Hosting plans from ${p.name}, with prices and sources.` }))),
++ ...((await catalog.sitemapPlans()).map((p) => ({ title: `Hosting plan ${p.id}`, url: `/catalog/plans/${p.id}`, text: 'Sourced hosting plan with a checked price and price history.' }))),
++ ];
++ app.get('/llms.txt', async (_req, res, next) => { try { res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsTxt({
name: 'OpenVibe.Host',
summary: SITE_SUMMARY,
details: 'The sites Host serves belong to their owners and are not listed here; the dashboard, sign-in and the API are per-person.',
sections: [
-- { title: 'Public pages', links: publicPages.map((p) => ({ title: p.title, url: `${config.baseUrl}${p.url}` })) },
++ { title: 'Public pages', links: [...publicPages, ...await catalogPublicPages()].map((p) => ({ title: p.title, url: `${config.baseUrl}${p.url}` })) },
{ title: 'Machine-readable', links: [
{ title: 'Sitemap', url: `${config.baseUrl}/sitemap.xml`, note: 'the public pages' },
{ title: 'robots.txt', url: `${config.baseUrl}/robots.txt` },
{ title: 'llms-full.txt', url: `${config.baseUrl}/llms-full.txt`, note: 'this same map with a description of each public page' },
] },
],
-- })));
-- app.get('/llms-full.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsFull({
++ })); } catch (err) { next(err); } });
++ app.get('/llms-full.txt', async (_req, res, next) => { try { res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(sharedSeo.llmsFull({
site: { name: 'OpenVibe.Host', url: config.baseUrl },
summary: SITE_SUMMARY,
base: config.baseUrl,
maxBytes: 512 * 1024,
-- sections: [{ title: 'Public pages', pages: publicPages }],
-- })));
++ sections: [{ title: 'Public pages', pages: [...publicPages, ...await catalogPublicPages()] }],
++ })); } catch (err) { next(err); } });
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
@@@ -284,7 +304,14 @@ async function createApp(opts = {}) {
app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }),
viewers.middleware('api'), resourceIndex.router({ guard: guard('host.resource.read') }));
++ // Signed Sources events; disabled unless ingest and webhook secrets are configured.
++ app.post('/internal/catalog/events', express.raw({ type: '*/*', limit: '256kb' }), async (req, res) => {
++ 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); }
++ });
++
// ── API ─────────────────────────────────────────────────
++ app.use('/api/v1/catalog', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createCatalogApi(ctx));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
// ── Preview deploys (plan T12 J4) ───────────────────────
@@@ -296,6 +323,8 @@ async function createApp(opts = {}) {
Promise.resolve(tenant.preview(req, res)).catch(next);
});
++ app.use('/catalog', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createCatalogPages(ctx));
++
// ── Dashboard ───────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
app.use((req, res) => {
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
iindex 8526ba6..170a3cc 100644
---- a/server/auth/capabilities.js
++++ b/server/auth/capabilities.js
@@@ -20,6 +20,7 @@ const CAPABILITIES = Object.freeze({
DOMAIN_MANAGE: 'host.domain.manage',
SITE_CONFIG: 'host.site.config',
RESOURCE_READ: 'host.resource.read',
++ CATALOG_MANAGE: 'host.catalog.manage',
});
/** → { allowed, code, reason } (capabilities.check()). */
ddiff --git a/server/catalog/account-data.js b/server/catalog/account-data.js
nnew file mode 100644
iindex 0000000..c992a96
---- /dev/null
++++ b/server/catalog/account-data.js
@@@ -0,0 +1,21 @@
++'use strict';
++
++/** ADR-033: catalog rows are shared. Export authored rows; erase only the attribution. */
++const TABLES = Object.freeze(['catalog_providers', 'catalog_plans', 'catalog_promos']);
++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);
++ return out;
++}
++async function eraseFor(db, personId) {
++ const counts = {};
++ 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;
++ }
++ });
++ return counts;
++}
++module.exports = { TABLES, exportFor, eraseFor };
ddiff --git a/server/catalog/ingest.js b/server/catalog/ingest.js
nnew file mode 100644
iindex 0000000..e1fb974
---- /dev/null
++++ b/server/catalog/ingest.js
@@@ -0,0 +1,32 @@
++'use strict';
++
++const ingest = require('openvibe-publishing/ingest');
++const { SOURCES } = require('./mapping');
++function createCatalogIngest({ config, store, catalog, fetchImpl, source }) {
++ if (!config.catalog.ingest || !config.catalog.sourcesUrl) return { enabled: false, pull: async () => ({ disabled: true }), consume: async () => ({ status: 503, code: 'ingest.disabled' }) };
++ const sources = source || ingest.createSourcesClient({ config: { ...config, sources: { internalUrl: config.catalog.sourcesUrl, category: 'hosting' } }, fetchImpl });
++ const cursor = ingest.createChangeCursor(store.db, { prefix: 'catalog', now: store.now });
++ const consumer = ingest.createEventConsumer({ db: store.db, secrets: config.catalog.webhookSecrets, consumer: 'host-catalog-sources', now: store.now });
++ return {
++ enabled: true,
++ async pull() {
++ let unknown = 0;
++ const result = await ingest.pullChanges({ db: store.db, cursor, source: sources, name: 'sources.hosting.after', apply: async (item, t) => {
++ const result = await catalog.applyItem(item, t);
++ if (result === 'hold' && !SOURCES[item.source_key]) unknown++;
++ return result;
++ } });
++ return { ...result, unknown };
++ },
++ async consume(raw, headers) {
++ return await consumer.apply(raw, headers, async (event) => {
++ if (!/^sources\.item\./.test(event.event_type)) return 'ignored';
++ const id = event.payload && event.payload.item_id;
++ if (typeof id !== 'string') return 'ignored';
++ const body = await sources.getItem(id);
++ return await catalog.applyItem(body.item);
++ });
++ },
++ };
++}
++module.exports = { createCatalogIngest };
ddiff --git a/server/catalog/mapping.js b/server/catalog/mapping.js
nnew file mode 100644
iindex 0000000..6751162
---- /dev/null
++++ b/server/catalog/mapping.js
@@@ -0,0 +1,37 @@
++'use strict';
++
++// Only these keys may create a provider. A Sources api item (sources.item@1, the `api` adapter) carries the plan's id as
++// `identity`, its label (Linode) as `title`, and the endpoint's mapped `extra` fields flat in `fields`, as the API gave them.
++const SOURCES = Object.freeze({
++ 'vultr-plans': { slug: 'vultr', name: 'Vultr', homepage_url: 'https://www.vultr.com/', source_url: 'https://www.vultr.com/pricing/' },
++ 'linode-types': { slug: 'linode', name: 'Linode', homepage_url: 'https://www.linode.com/', source_url: 'https://www.linode.com/pricing/' },
++});
++const number = (v, field) => {
++ if (v == null) return null;
++ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) throw new TypeError(`${field} must be a non-negative number`);
++ return v;
++};
++function mapItem(item) {
++ const provider = SOURCES[item && item.source_key];
++ if (!provider) return null;
++ if (item.category !== 'hosting' || !item.fields || typeof item.fields !== 'object' || !item.provenance || !item.provenance.retrieved_at || !item.provenance.first_seen_at) throw new TypeError('incomplete hosting item');
++ const x = item.fields;
++ const id = typeof item.identity === 'string' ? item.identity.trim() : '';
++ if (!id || id.length > 200 || (!Number.isFinite(Date.parse(item.provenance.retrieved_at)) || !Number.isFinite(Date.parse(item.provenance.first_seen_at)))) throw new TypeError('invalid plan identity or fetch time');
++ const vultr = item.source_key === 'vultr-plans';
++ const monthly = number(vultr ? x.monthly_cost : x.price_monthly, 'monthly price');
++ if (monthly == null) throw new TypeError('missing monthly price');
++ return {
++ provider, external_id: id, name: vultr ? id : ((typeof item.title === 'string' && item.title.trim()) || id),
++ kind: vultr && x.type === 'vdc' ? 'dedicated' : 'cloud',
++ vcpu: number(vultr ? x.vcpu_count : x.vcpus, 'vcpu'),
++ ram_mb: number(vultr ? x.ram : x.memory, 'ram'),
++ disk_gb: vultr ? number(x.disk, 'disk') : (x.disk == null ? null : number(x.disk, 'disk') / 1024),
++ disk_type: vultr ? (['vhf', 'vhp'].includes(x.type) ? 'nvme' : x.type === 'vc2' ? 'ssd' : null) : null,
++ bandwidth_gb: number(vultr ? x.bandwidth : x.transfer, 'bandwidth'),
++ price_monthly: monthly, price_hourly: number(vultr ? x.hourly_cost : x.price_hourly, 'hourly price'),
++ currency: 'USD', source_key: item.source_key, source_item_id: item.id,
++ source_url: provider.source_url, checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),
++ };
++}
++module.exports = { SOURCES, mapItem };
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
nnew file mode 100644
iindex 0000000..47c98e0
---- /dev/null
++++ b/server/catalog/store.js
@@@ -0,0 +1,155 @@
++'use strict';
++
++const { newId } = require('../ids');
++const { ApiError } = require('../http/errors');
++const { mapItem } = require('./mapping');
++const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', message); };
++const missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };
++const fields = {
++ providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'],
++ plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'setup_fee', 'source_url', 'checked_at', 'status'],
++ promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'],
++};
++const kinds = new Set(['cloud', 'vps', 'dedicated', 'storage', 'domain']);
++const currencies = new Set(Intl.supportedValuesOf('currency'));
++const url = (v) => { try { const u = new URL(v); return ['https:', 'http:'].includes(u.protocol) && !u.username && !u.password; } catch { return false; } };
++const clean = (type, input, current, now) => {
++ if (!input || typeof input !== 'object' || Array.isArray(input)) invalid('Body must be an object');
++ for (const k of Object.keys(input)) if (!fields[type].includes(k)) invalid(`Unknown field: ${k}`);
++ const v = { ...(current || {}), ...input };
++ const strings = type === 'providers' ? ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'disk_type', 'currency', 'source_url', 'status'] : ['provider_id', 'title', 'details', 'code', 'url', 'status'];
++ for (const k of strings) if (v[k] != null && (typeof v[k] !== 'string' || v[k].length > 2048 || (v[k] === '' && !['description', 'affiliate_note', 'details', 'code'].includes(k)))) invalid(`Invalid ${k}`);
++ const required = type === 'providers' ? ['slug', 'name', 'homepage_url'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'currency', 'price_monthly', 'source_url'] : ['provider_id', 'title', 'url'];
++ for (const k of required) if (v[k] == null || v[k] === '') invalid(`${k} is required`);
++ for (const k of ['homepage_url', 'affiliate_url', 'source_url', 'url']) if (v[k] != null && !url(v[k])) invalid(`${k} must be an HTTP(S) URL`);
++ if (type === 'providers' && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v.slug)) invalid('Invalid slug');
++ if (type === 'plans') {
++ if (!kinds.has(v.kind) || !currencies.has(v.currency)) invalid('Invalid kind or currency');
++ for (const k of ['vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'setup_fee']) {
++ if (input[k] != null && (typeof input[k] !== 'number' || !Number.isFinite(input[k]) || input[k] < 0)) invalid(`${k} must be a non-negative number`);
++ }
++ if (v.ipv4 != null && !Number.isInteger(v.ipv4)) invalid('ipv4 must be an integer');
++ if (v.disk_type != null && !['ssd', 'nvme', 'hdd'].includes(v.disk_type)) invalid('Invalid disk type');
++ if (v.unmetered != null && typeof v.unmetered !== 'boolean') invalid('unmetered must be boolean');
++ if (v.locations != null && (!Array.isArray(v.locations) || v.locations.some((x) => typeof x !== 'string' || !x))) invalid('Invalid locations');
++ if (v.checked_at == null || (current && input.price_monthly !== undefined && input.checked_at === undefined)) v.checked_at = now;
++ if (v.price_monthly >= 100000000 || (v.price_hourly != null && v.price_hourly >= 1000000) || (v.setup_fee != null && v.setup_fee >= 100000000)) invalid('Price is outside catalog range');
++ if (!Number.isSafeInteger(v.checked_at) || v.checked_at < 0) invalid('Invalid checked_at');
++ if (v.status != null && !['active', 'gone', 'hidden'].includes(v.status)) invalid('Invalid status');
++ }
++ if (type === 'providers' && v.status != null && !['active', 'hidden'].includes(v.status)) invalid('Invalid status');
++ if (type === 'promos') {
++ if (v.status != null && !['active', 'ended', 'hidden'].includes(v.status)) invalid('Invalid status');
++ if (v.affiliate != null && typeof v.affiliate !== 'boolean') invalid('affiliate must be boolean');
++ for (const k of ['starts_at', 'ends_at']) if (v[k] != null && !Number.isSafeInteger(v[k])) invalid(`Invalid ${k}`);
++ if (v.starts_at != null && v.ends_at != null && v.starts_at > v.ends_at) invalid('Promo end precedes start');
++ }
++ return v;
++};
++function createCatalog({ store }) {
++ const db = store.db;
++ const providerOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null });
++ const promoOut = (p) => ({ ...p, status: p.status === 'active' && p.ends_at != null && Number(p.ends_at) <= store.now() ? 'ended' : p.status, affiliate_rel: p.affiliate ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null });
++ const get = async (type, id) => { const row = await db.prepare(`SELECT * FROM catalog_${type} WHERE id = ?`).get(id); if (!row) missing(); return type === 'promos' ? promoOut(row) : row; };
++ const save = async (type, input, id, actor) => await db.tx(async () => {
++ const current = id ? await get(type, id) : null;
++ if (current && type === 'plans' && current.origin === 'sources' && Object.keys(input).some((k) => k !== 'status')) invalid('Only status can be edited on a Sources plan');
++ const now = store.now();
++ const v = clean(type, input, current, now);
++ if (type !== 'providers' && !(await db.prepare('SELECT 1 FROM catalog_providers WHERE id = ?').get(v.provider_id))) invalid('Unknown provider');
++ const cols = fields[type].filter((k) => v[k] !== undefined);
++ if (type === 'providers' && await db.prepare('SELECT id FROM catalog_providers WHERE slug = ? AND id <> ?').get(v.slug, id || '')) invalid('Provider slug already exists');
++ if (type === 'plans' && await db.prepare('SELECT id FROM catalog_plans WHERE provider_id = ? AND external_id = ? AND id <> ?').get(v.provider_id, v.external_id, id || '')) invalid('Plan external_id already exists for provider');
++ if (id) {
++ if (type === 'plans' && v.price_monthly !== undefined && (Number(current.price_monthly) !== Number(v.price_monthly) || current.currency !== v.currency)) {
++ await db.prepare('INSERT INTO catalog_price_changes (plan_id, observed_at, currency, price_monthly_before, price_monthly_after) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING').run(id, v.checked_at, v.currency, current.price_monthly, v.price_monthly);
++ }
++ await db.prepare(`UPDATE catalog_${type} SET ${cols.map((k) => `${k} = ?`).join(', ')}, updated_at = ? WHERE id = ?`).run(...cols.map((k) => v[k]), now, id);
++ return await get(type, id);
++ }
++ const key = { providers: 'catalogProvider', plans: 'catalogPlan', promos: 'catalogPromo' }[type];
++ const newKey = newId(key, now);
++ const extras = type === 'plans' ? { origin: 'staff', first_seen_at: now, last_seen_at: now } : {};
++ const all = { ...Object.fromEntries(cols.map((k) => [k, v[k]])), ...extras, id: newKey, created_by: actor, created_at: now, updated_at: now };
++ await db.prepare(`INSERT INTO catalog_${type} (${Object.keys(all).join(', ')}) VALUES (${Object.keys(all).map(() => '?').join(', ')})`).run(...Object.values(all));
++ return await get(type, newKey);
++ });
++ async function applyItem(item, t = db) {
++ if (item && item.removed) return 'hold';
++ const v = mapItem(item);
++ if (!v) return 'hold';
++ const now = store.now();
++ let p = await t.prepare('SELECT * FROM catalog_providers WHERE slug = ?').get(v.provider.slug);
++ if (!p) {
++ const id = newId('catalogProvider', now);
++ await t.prepare('INSERT INTO catalog_providers (id, slug, name, homepage_url, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(id, v.provider.slug, v.provider.name, v.provider.homepage_url, 'active', now, now);
++ p = { id };
++ }
++ const old = await t.prepare('SELECT * FROM catalog_plans WHERE provider_id = ? AND external_id = ?').get(p.id, v.external_id);
++ const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'currency', 'price_monthly', 'price_hourly', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
++ if (old) {
++ if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
++ if (Number(old.price_monthly) !== v.price_monthly || old.currency !== v.currency) {
++ await t.prepare('INSERT INTO catalog_price_changes (plan_id, observed_at, currency, price_monthly_before, price_monthly_after) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING').run(old.id, v.checked_at, v.currency, old.price_monthly, v.price_monthly);
++ }
++ await t.prepare(`UPDATE catalog_plans SET ${cols.map((k) => `${k} = ?`).join(', ')}, last_seen_at = ?, updated_at = ? WHERE id = ?`).run(...cols.map((k) => v[k]), v.checked_at, now, old.id);
++ } else {
++ const all = { id: newId('catalogPlan', now), provider_id: p.id, external_id: v.external_id, ...Object.fromEntries(cols.map((k) => [k, v[k]])), origin: 'sources', first_seen_at: v.first_seen_at, last_seen_at: v.checked_at, status: 'active', created_at: now, updated_at: now };
++ await t.prepare(`INSERT INTO catalog_plans (${Object.keys(all).join(', ')}) VALUES (${Object.keys(all).map(() => '?').join(', ')})`).run(...Object.values(all));
++ }
++ return 'applied';
++ }
++ const planOut = (p) => ({ ...p, affiliate_rel: p.affiliate_url ? 'sponsored nofollow noopener' : null, affiliate_disclosure: p.affiliate_url ? 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.' : null, price_per_vcpu: p.vcpu > 0 ? Number(p.price_monthly) / Number(p.vcpu) : null, price_per_gb_ram: p.ram_mb > 0 ? Number(p.price_monthly) * 1024 / Number(p.ram_mb) : null, price_per_gb_disk: p.disk_gb > 0 ? Number(p.price_monthly) / Number(p.disk_gb) : null, checked_at: new Date(Number(p.checked_at)).toISOString() });
++ async function plans(query = {}) {
++ const allowed = new Set(['kind', 'min_vcpu', 'min_ram_mb', 'min_disk_gb', 'max_price', 'currency', 'location', 'provider', 'sort', 'cursor']);
++ for (const k of Object.keys(query)) if (!allowed.has(k) || Array.isArray(query[k])) invalid(`Invalid filter: ${k}`);
++ const sort = query.sort || 'price';
++ const sorts = { price: 'p.price_monthly', price_per_vcpu: 'p.price_monthly / NULLIF(p.vcpu, 0)', price_per_gb_ram: 'p.price_monthly * 1024 / NULLIF(p.ram_mb, 0)', price_per_gb_disk: 'p.price_monthly / NULLIF(p.disk_gb, 0)', checked_at: 'p.checked_at' };
++ if (!sorts[sort]) invalid('Invalid sort');
++ const args = [query.currency || 'USD'];
++ if (!currencies.has(args[0])) invalid('Invalid currency');
++ let where = "p.status = 'active' AND v.status = 'active' AND p.currency = ?";
++ for (const [key, col] of [['kind', 'p.kind'], ['provider', 'v.slug'], ['location', 'p.locations']]) if (query[key]) { where += key === 'location' ? ' AND ? = ANY(p.locations)' : ` AND ${col} = ?`; args.push(query[key]); }
++ if (query.kind && !kinds.has(query.kind)) invalid('Invalid kind');
++ for (const [key, col] of [['min_vcpu', 'p.vcpu'], ['min_ram_mb', 'p.ram_mb'], ['min_disk_gb', 'p.disk_gb'], ['max_price', 'p.price_monthly']]) if (query[key] != null) {
++ const n = Number(query[key]); if (!Number.isFinite(n) || n < 0 || query[key] === '') invalid(`Invalid ${key}`);
++ where += ` AND ${col} ${key === 'max_price' ? '<=' : '>='} ?`; args.push(n);
++ }
++ const offset = query.cursor == null ? 0 : Number(query.cursor);
++ if (!Number.isSafeInteger(offset) || offset < 0) invalid('Invalid cursor');
++ // Affiliation is deliberately absent from the ORDER BY expression.
++ const rows = await db.prepare(`SELECT p.*, v.slug AS provider_slug, v.name AS provider_name, v.affiliate_url, v.affiliate_note FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE ${where} ORDER BY ${sorts[sort]} ASC NULLS LAST, p.id ASC LIMIT 51 OFFSET ${offset}`).all(...args);
++ return { plans: rows.slice(0, 50).map(planOut), next_cursor: rows.length > 50 ? String(offset + 50) : null };
++ }
++ async function plan(id) {
++ const p = await db.prepare("SELECT p.*, v.slug AS provider_slug, v.name AS provider_name, v.affiliate_url, v.affiliate_note FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.id = ? AND p.status = 'active' AND v.status = 'active'").get(id);
++ if (!p) missing();
++ return { ...planOut(p), price_history: (await db.prepare('SELECT * FROM catalog_price_changes WHERE plan_id = ? ORDER BY observed_at DESC').all(id)).map((h) => ({ ...h, checked_at: new Date(Number(h.observed_at)).toISOString(), source_url: p.source_url })) };
++ }
++ async function providers(slug) {
++ if (slug) {
++ const p = await db.prepare("SELECT * FROM catalog_providers WHERE slug = ? AND status = 'active'").get(slug);
++ if (!p) missing();
++ return { ...providerOut(p), plans: (await db.prepare("SELECT * FROM catalog_plans WHERE provider_id = ? AND status = 'active' ORDER BY price_monthly, id").all(p.id)).map((row) => planOut({ ...row, provider_name: p.name, provider_slug: p.slug, affiliate_url: p.affiliate_url, affiliate_note: p.affiliate_note })), promos: await promos(p.id) };
++ }
++ return (await db.prepare("SELECT * FROM catalog_providers WHERE status = 'active' ORDER BY name, id").all()).map(providerOut);
++ }
++ async function promos(providerId) {
++ const now = store.now();
++ const rows = providerId ? await db.prepare("SELECT p.*, v.affiliate_note FROM catalog_promos p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.provider_id = ? AND p.status = 'active' AND (p.starts_at IS NULL OR p.starts_at <= ?) AND (p.ends_at IS NULL OR p.ends_at > ?) ORDER BY p.id").all(providerId, now, now) : await db.prepare("SELECT p.*, v.name AS provider_name, v.affiliate_note FROM catalog_promos p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' AND (p.starts_at IS NULL OR p.starts_at <= ?) AND (p.ends_at IS NULL OR p.ends_at > ?) ORDER BY p.id").all(now, now);
++ return rows.map(promoOut);
++ }
++ /** The currencies active plans are priced in (the filter's choices), USD first. */
++ async function currenciesInUse() {
++ const rows = await db.prepare("SELECT DISTINCT p.currency FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.currency").all();
++ return ['USD', ...rows.map((r) => r.currency).filter((c) => c !== 'USD')];
++ }
++ /** Active plans, providers with an active plan, and the newest check (the front page's line). */
++ async function stats() {
++ const r = await db.prepare("SELECT COUNT(*) AS plans, COUNT(DISTINCT p.provider_id) AS providers, MAX(p.checked_at) AS last FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active'").get();
++ return { plans: Number(r.plans) || 0, providers: Number(r.providers) || 0, lastChecked: r.last == null ? null : Number(r.last) };
++ }
++ async function sitemapPlans() { return await db.prepare("SELECT p.id FROM catalog_plans p JOIN catalog_providers v ON v.id = p.provider_id WHERE p.status = 'active' AND v.status = 'active' ORDER BY p.id").all(); }
++ return { save, get, applyItem, plans, plan, providers, promos, currencies: currenciesInUse, stats, sitemapPlans, all: async (type) => { const rows = await db.prepare(`SELECT * FROM catalog_${type} ORDER BY created_at DESC, id DESC LIMIT 200`).all(); return type === 'promos' ? rows.map(promoOut) : rows; } };
++}
++module.exports = { createCatalog };
ddiff --git a/server/config.js b/server/config.js
iindex b3a565c..788ce25 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -112,6 +112,7 @@ function load(env = process.env) {
// Public hosting is owner-gated (plan T20/O34: the owner's go-ahead, a DMCA designated agent and a Public
// Suffix List entry come first). Until HOST_PUBLIC_HOSTING=on only OpenVibe staff can create projects, and the
// front page says hosting is not open yet; everything else about the engine is unchanged.
++ 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) },
publicHosting: String(env.HOST_PUBLIC_HOSTING || 'off').trim().toLowerCase() === 'on',
// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
ddiff --git a/server/db.js b/server/db.js
iindex e46c96d..1d1b9e4 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -30,7 +30,7 @@ const { createDb } = require('openvibe-sdk/db');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
--const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns', 'host_site_sources', 'host_deploy_git'];
++const CHARTER_TABLES = ['host_projects', 'host_project_members', 'host_quotas', 'host_sites', 'host_deploys', 'host_deploy_files', 'host_blobs', 'host_activations', 'host_deploy_logs', 'host_domains', 'host_takedowns', 'host_site_sources', 'host_deploy_git', 'catalog_providers', 'catalog_plans', 'catalog_price_changes', 'catalog_promos', 'catalog_ingest_cursor'];
/**
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
ddiff --git a/server/http/catalog.js b/server/http/catalog.js
nnew file mode 100644
iindex 0000000..5b3dcbe
---- /dev/null
++++ b/server/http/catalog.js
@@@ -0,0 +1,93 @@
++'use strict';
++
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { ApiError, run, privateNoStore, sendError } = require('./errors');
++const { renderPage, esc } = require('../render/layout');
++const render = require('../render/catalog');
++const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
++const { guard } = require('../auth/viewer');
++
++const disclosure = render.DISCLOSURE;
++function createCatalogApi({ catalog, viewers, actorLimits }) {
++ const router = express.Router();
++ router.use(viewers.middleware('api'));
++ if (actorLimits) router.use(actorLimits);
++ router.use((_req, res, next) => { privateNoStore(res); next(); });
++ const catalogJsonBody = (req, res, next) => express.json({ limit: '64kb' })(req, res, (err) => err ? contracts.http.sendProblem(res, 400, 'host.catalog.invalid', { detail: 'Malformed JSON body', ctx: req.ov }) : next());
++ const staff = (req, _res, next) => req.viewer.kind === 'user' && req.viewer.staff ? next() : next(new ApiError(403, 'host.catalog.forbidden', 'Staff access required'));
++ router.get('/plans', run(async (req) => await catalog.plans(req.query)));
++ router.get('/plans/:id', run(async (req) => ({ plan: await catalog.plan(req.params.id) })));
++ router.get('/providers', run(async () => ({ providers: await catalog.providers() })));
++ router.get('/providers/:slug', run(async (req) => ({ provider: await catalog.providers(req.params.slug) })));
++ router.get('/promos', run(async () => ({ promos: await catalog.promos() })));
++ for (const type of ['providers', 'plans', 'promos']) {
++ router.post(`/${type}`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, null, req.viewer.subject) }), 201));
++ router.patch(`/${type}/:id`, guard('host.catalog.manage'), staff, catalogJsonBody, run(async (req) => ({ [type.slice(0, -1)]: await catalog.save(type, req.body, req.params.id, req.viewer.subject) })));
++ }
++ router.use((err, req, res, next) => { if (res.headersSent) return next(err); sendError(res, req, err); });
++ return router;
++}
++function createCatalogPages({ catalog, config, viewers, actorLimits }) {
++ const router = express.Router();
++ router.use(viewers.middleware('dashboard'));
++ if (actorLimits) router.use(actorLimits);
++ const page = (req, res, title, body, indexable = true, description) => res.status(200).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, indexable, description, css: ['css/catalog.css'] }));
++ const view = (fn) => async (req, res) => {
++ try { await fn(req, res); } catch (err) {
++ const status = err.status || 500;
++ const body = status === 404 ? '<h1>Not in the catalog</h1><p>This entry is not listed (or no longer is).</p><p><a href="/catalog">Compare plans</a></p>'
++ : status === 422 ? `<h1>That filter did not work</h1><p>${esc(err.message)}</p><p><a href="/catalog">Clear the filters</a></p>`
++ : `<h1>Catalog error</h1><p>${esc(err.message)}</p>`;
++ res.status(status).type('html').send(renderPage({ title: 'Catalog', body, viewer: req.viewer, config, path: req.originalUrl, css: ['css/catalog.css'] }));
++ }
++ };
++ router.get('/', view(async (req, res) => {
++ const [result, providers, currencies] = await Promise.all([catalog.plans(req.query), catalog.providers(), catalog.currencies()]);
++ page(req, res, 'Compare hosting plans', render.catalog({ result, query: req.query, providers, currencies }), !Object.keys(req.query).length,
++ 'Compare cloud, VPS and dedicated hosting plans: price per month, per vCPU and per GB, each with the day it was checked and its source.');
++ }));
++ router.get('/providers', view(async (req, res) => {
++ page(req, res, 'Hosting providers', render.providerList(await catalog.providers()));
++ }));
++ router.get('/providers/:slug', view(async (req, res) => {
++ const p = await catalog.providers(req.params.slug);
++ page(req, res, `${p.name} plans and prices`, render.provider(p), true, `${p.name}'s hosting plans compared: monthly prices, per-resource prices and the day each was checked.`);
++ }));
++ router.get('/plans/:id', view(async (req, res) => {
++ const p = await catalog.plan(req.params.id);
++ page(req, res, `${p.provider_name} ${p.name}`, render.plan(p), true, `${p.provider_name} ${p.name}: ${p.currency} ${p.price_monthly} a month, with its specifications, price per resource and price history.`);
++ }));
++ router.get('/promos', view(async (req, res) => {
++ page(req, res, 'Hosting promotions', render.promos(await catalog.promos()), true, 'Hosting promotions running now, with their codes and end dates as the providers state them.');
++ }));
++ router.get('/manage', view(async (req, res) => {
++ if (req.viewer.kind !== 'user' || !req.viewer.staff) throw new ApiError(403, 'host.catalog.forbidden', 'Staff access required');
++ const csrf = csrfToken(config, req.viewer);
++ const blocks = await Promise.all(['providers', 'plans', 'promos'].map(async (type) => {
++ const rows = await catalog.all(type);
++ const keys = { providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'], plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'setup_fee', 'source_url', 'checked_at', 'status'], promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'] }[type];
++ const form = (row) => `<form method="post" action="/catalog/manage/${type}${row ? `/${esc(row.id)}` : ''}"><input type="hidden" name="csrf" value="${csrf}">${keys.map((k) => `<label>${esc(k)} <input name="${k}" value="${esc(row && row[k] != null ? Array.isArray(row[k]) ? row[k].join(',') : row[k] : '')}"></label>`).join('')}<button>${row ? 'Save' : 'Add'}</button></form>`;
++ return `<section><h2>${type}</h2>${form(null)}${rows.map((r) => `<h3>${esc(r.name || r.title || r.id)}</h3>${form(r)}`).join('')}</section>`;
++ }));
++ page(req, res, 'Manage catalog', `<h1>Manage catalog</h1>${blocks.join('')}`, false);
++ }));
++ const parser = express.urlencoded({ extended: false, limit: '16kb' });
++ router.post('/manage/:type/:id?', parser, view(async (req, res) => {
++ if (req.viewer.kind !== 'user' || !req.viewer.staff) throw new ApiError(403, 'host.catalog.forbidden', 'Staff access required');
++ if (!sameOrigin(config, req) || !checkCsrf(config, req.viewer, req.body.csrf)) throw new ApiError(403, 'host.catalog.forbidden', 'Invalid form token or origin');
++ const type = req.params.type;
++ if (!['providers', 'plans', 'promos'].includes(type)) throw new ApiError(404, 'host.catalog.not_found', 'Unknown catalog entry');
++ const body = { ...req.body }; delete body.csrf;
++ for (const k of Object.keys(body)) {
++ if (body[k] === '') { delete body[k]; continue; }
++ if (['vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'setup_fee', 'checked_at', 'starts_at', 'ends_at'].includes(k)) body[k] = Number(body[k]);
++ if (['unmetered', 'affiliate'].includes(k)) body[k] = body[k] === 'true';
++ if (k === 'locations') body[k] = body[k].split(',').map((x) => x.trim()).filter(Boolean);
++ }
++ await catalog.save(type, body, req.params.id || null, req.viewer.subject);
++ res.redirect(303, '/catalog/manage');
++ }));
++ return router;
++}
++module.exports = { createCatalogApi, createCatalogPages, disclosure };
ddiff --git a/server/http/dashboard.js b/server/http/dashboard.js
iindex 6bfe14d..4a348fc 100644
---- a/server/http/dashboard.js
++++ b/server/http/dashboard.js
@@@ -10,6 +10,7 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const { renderPage } = require('../render/layout');
const pages = require('../render/pages');
++const catalogPages = require('../render/catalog');
const showcase = require('openvibe-shared/showcase');
const { limitsOf } = require('../limits');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
@@@ -30,8 +31,8 @@ function createDashboard(ctx) {
if (ctx.actorLimits) router.use(ctx.actorLimits);
router.use((req, res, next) => { privateNoStore(res); next(); });
-- function page(req, res, status, title, body, notice, { indexable = false, styles } = {}) {
-- res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice, indexable, styles }));
++ function page(req, res, status, title, body, notice, { indexable = false, styles, css } = {}) {
++ res.status(status).type('html').send(renderPage({ title, body, viewer: req.viewer, config, path: req.originalUrl, notice, indexable, styles, css }));
}
function fail(req, res, err) {
@@@ -68,12 +69,19 @@ function createDashboard(ctx) {
// ── Pages ───────────────────────────────────────────────
// What shipped on OpenVibe.Host: the shared update log every OpenVibe site has.
router.get('/updates', view((req, res) => page(req, res, 200, 'What shipped on OpenVibe.Host', frame.updatesBody({ service: 'host', siteName: 'OpenVibe.Host' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`, null, { indexable: true })));
++ router.get('/hosting', view(async (req, res) => {
++ page(req, res, 200, 'Static hosting', pages.signedOut({ limits: limitsOf(config), limitsUrl: `${config.baseUrl}/limits.json`, launched: config.publicHosting }), null,
++ { indexable: !req.query || !Object.keys(req.query).length, styles: [showcase.STYLESHEET] });
++ }));
++
router.get('/', view(async (req, res) => {
-- // The signed-out front page is the public page of openvibe.host (sitemap.xml): indexable.
-- // Everything behind sign-in stays noindex and private.
++ // The signed-out front page is the public page of openvibe.host (sitemap.xml): indexable. It leads with the
++ // hosting catalog (plan T20 step 3: openvibe.host is hosting compared); OpenVibe's own static hosting has
++ // its page at /hosting. Everything behind sign-in stays noindex and private.
if (req.viewer.kind !== 'user') {
-- return page(req, res, 200, null, pages.signedOut({ limits: limitsOf(config), limitsUrl: `${config.baseUrl}/limits.json`, launched: config.publicHosting }), null,
-- { indexable: !req.query || !Object.keys(req.query).length, styles: [showcase.STYLESHEET] });
++ const [result, providers, promos, stats] = await Promise.all([ctx.catalog.plans({}), ctx.catalog.providers(), ctx.catalog.promos(), ctx.catalog.stats()]);
++ return page(req, res, 200, null, catalogPages.front({ plans: result.plans, providers, promos, stats, hostingLaunched: config.publicHosting }), null,
++ { indexable: !req.query || !Object.keys(req.query).length, styles: [showcase.STYLESHEET], css: ['css/catalog.css'] });
}
page(req, res, 200, 'Projects', pages.home({ projects: await projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer), canCreate: config.publicHosting || Boolean(req.viewer.staff) }), notice(req));
}));
ddiff --git a/server/ids.js b/server/ids.js
iindex 51655c0..43fbfb5 100644
---- a/server/ids.js
++++ b/server/ids.js
@@@ -7,7 +7,7 @@
*/
const { ids } = require('openvibe-contracts');
--const PREFIX = { project: 'prj', site: 'sit', deploy: 'dpl', domain: 'dom' };
++const PREFIX = { project: 'prj', site: 'sit', deploy: 'dpl', domain: 'dom', catalogProvider: 'hpv', catalogPlan: 'hpl', catalogPromo: 'hpr' };
const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
nnew file mode 100644
iindex 0000000..54b1984
---- /dev/null
++++ b/server/render/catalog.js
@@@ -0,0 +1,240 @@
++'use strict';
++
++/**
++ * The hosting catalog's pages (plan T20 step 3: hosting compared). Server-rendered, complete without JavaScript:
++ *
++ * front({ plans, providers, promos, stats }) the signed-out front page of openvibe.host
++ * catalog({ result, query, providers }) /catalog: the filterable comparison table
++ * provider(p) /catalog/providers/:slug
++ * plan(p) /catalog/plans/:id
++ * promos(list) /catalog/promos
++ *
++ * Every price shows when it was checked and links where it came from; an unknown value reads "Unknown", never 0;
++ * affiliate links carry rel="sponsored nofollow noopener" with the provider's own disclosure beside them, and the
++ * order never depends on affiliation (the store sorts; nothing here reorders).
++ */
++const showcase = require('openvibe-shared/showcase');
++const { esc } = require('./layout');
++
++const DISCLOSURE = 'OpenVibe may earn a commission from links marked as affiliate links; it never changes the order or the prices shown.';
++const KINDS = [['', 'Any kind'], ['cloud', 'Cloud'], ['vps', 'VPS'], ['dedicated', 'Dedicated'], ['storage', 'Storage'], ['domain', 'Domains']];
++const SORTS = [['price', 'Lowest price'], ['price_per_vcpu', 'Price per vCPU'], ['price_per_gb_ram', 'Price per GB of RAM'], ['price_per_gb_disk', 'Price per GB of disk'], ['checked_at', 'Recently checked']];
++const RAM = [['', 'Any RAM'], ['1024', '1 GB+'], ['2048', '2 GB+'], ['4096', '4 GB+'], ['8192', '8 GB+'], ['16384', '16 GB+'], ['32768', '32 GB+'], ['65536', '64 GB+']];
++
++const UNKNOWN = '<span class="hc-unknown" title="The source does not state it">Unknown</span>';
++const num = (v) => (v == null || v === '' ? null : Number(v));
++function money(v, currency, digits = 2) {
++ const n = num(v);
++ if (n == null || !Number.isFinite(n)) return null;
++ try { return new Intl.NumberFormat('en', { style: 'currency', currency, minimumFractionDigits: digits, maximumFractionDigits: digits }).format(n); } catch { return `${n.toFixed(digits)} ${currency}`; }
++}
++function bytesMb(mb) {
++ const n = num(mb);
++ if (n == null) return null;
++ if (n >= 1024) { const g = n / 1024; return `${Number.isInteger(g) ? g : g.toFixed(1)} GB`; }
++ return `${n} MB`;
++}
++function sizeGb(gb) {
++ const n = num(gb);
++ if (n == null) return null;
++ if (n >= 1000) { const t = n / 1000; return `${Number.isInteger(t) ? t : t.toFixed(1)} TB`; }
++ return `${Number.isInteger(n) ? n : n.toFixed(1)} GB`;
++}
++const transfer = (p) => (p.unmetered ? 'Unmetered' : sizeGb(p.bandwidth_gb));
++const day = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null; };
++const or = (v) => (v == null ? UNKNOWN : esc(v));
++const kindName = (k) => (KINDS.find(([v]) => v === k) || [k, k])[1];
++const link = (href, label, cls = '') => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''}>${label}</a>`;
++const out = (href, label, { affiliate = false, cls = '' } = {}) => `<a href="${esc(href)}"${cls ? ` class="${cls}"` : ''} rel="${affiliate ? 'sponsored nofollow noopener' : 'noopener'}">${label}</a>`;
++
++/** The visit button for a provider: its affiliate link (marked, with the provider's own disclosure) or its homepage. */
++function visit(p, label = 'Visit provider') {
++ if (p.affiliate_url) {
++ return `<span class="hc-visit">${out(p.affiliate_url, `${esc(label)} <span class="hc-chip">Affiliate</span>`, { affiliate: true, cls: 'button' })}${p.affiliate_note ? `<small class="hc-note">${esc(p.affiliate_note)}</small>` : ''}</span>`;
++ }
++ return p.homepage_url ? `<span class="hc-visit">${out(p.homepage_url, esc(label), { cls: 'button hc-button-quiet' })}</span>` : '';
++}
++
++function priceCell(p) {
++ const m = money(p.price_monthly, p.currency);
++ return `<span class="hc-price">${m ? esc(m) : UNKNOWN}</span><span class="hc-per">/mo</span>`;
++}
++
++function planTable(plans, { caption = 'Hosting plans', showProvider = true } = {}) {
++ if (!plans.length) return '';
++ const rows = plans.map((p) => {
++ const provider = showProvider ? `<td data-label="Provider">${link(`/catalog/providers/${encodeURIComponent(p.provider_slug)}`, esc(p.provider_name || p.provider_slug))}${p.affiliate_url ? ' <span class="hc-chip" title="This provider has an affiliate link">Affiliate</span>' : ''}</td>` : '';
++ return `<tr>
++${provider}<th scope="row" data-label="Plan">${link(`/catalog/plans/${encodeURIComponent(p.id)}`, esc(p.name))}<span class="hc-kind">${esc(kindName(p.kind))}</span></th>
++<td data-label="vCPU" class="hc-n">${or(p.vcpu == null ? null : num(p.vcpu))}</td>
++<td data-label="RAM" class="hc-n">${or(bytesMb(p.ram_mb))}</td>
++<td data-label="Disk" class="hc-n">${or(sizeGb(p.disk_gb))}${p.disk_type ? ` <span class="hc-dim">${esc(p.disk_type.toUpperCase())}</span>` : ''}</td>
++<td data-label="Transfer" class="hc-n">${or(transfer(p))}</td>
++<td data-label="Price" class="hc-n">${priceCell(p)}</td>
++<td data-label="Per vCPU" class="hc-n hc-dim">${or(money(p.price_per_vcpu, p.currency, 2))}</td>
++<td data-label="Per GB RAM" class="hc-n hc-dim">${or(money(p.price_per_gb_ram, p.currency, 2))}</td>
++<td data-label="Checked" class="hc-checked">${or(day(p.checked_at))} ${out(p.source_url, 'source', { cls: 'hc-src' })}</td>
++</tr>`;
++ }).join('');
++ return `<div class="hc-table-wrap"><table class="hc-table">
++<caption>${esc(caption)}</caption>
++<thead><tr>${showProvider ? '<th scope="col">Provider</th>' : ''}<th scope="col">Plan</th><th scope="col" class="hc-n">vCPU</th><th scope="col" class="hc-n">RAM</th><th scope="col" class="hc-n">Disk</th><th scope="col" class="hc-n">Transfer</th><th scope="col" class="hc-n">Price</th><th scope="col" class="hc-n">Per vCPU</th><th scope="col" class="hc-n">Per GB RAM</th><th scope="col">Checked</th></tr></thead>
++<tbody>${rows}</tbody></table></div>`;
++}
++
++const select = (name, label, options, current) => `<label><span>${esc(label)}</span><select name="${name}">${options.map(([v, l]) => `<option value="${esc(v)}"${String(current || '') === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
++const input = (name, label, current, attrs = '') => `<label><span>${esc(label)}</span><input name="${name}" value="${esc(current || '')}" ${attrs}></label>`;
++
++function filters(query, providers, currencies) {
++ const q = query || {};
++ return `<form class="hc-filters" method="get" action="/catalog" role="search" aria-label="Filter hosting plans">
++${select('kind', 'Kind', KINDS, q.kind)}
++${input('min_vcpu', 'Min vCPU', q.min_vcpu, 'type="number" min="0" step="1" inputmode="numeric" placeholder="Any"')}
++${select('min_ram_mb', 'Min RAM', RAM, q.min_ram_mb)}
++${input('min_disk_gb', 'Min disk (GB)', q.min_disk_gb, 'type="number" min="0" step="1" inputmode="numeric" placeholder="Any"')}
++${input('max_price', 'Max price /mo', q.max_price, 'type="number" min="0" step="0.01" inputmode="decimal" placeholder="Any"')}
++${select('provider', 'Provider', [['', 'Any provider'], ...providers.map((p) => [p.slug, p.name])], q.provider)}
++${input('location', 'Location', q.location, 'placeholder="Any"')}
++${select('currency', 'Currency', currencies.map((c) => [c, c]), q.currency || 'USD')}
++${select('sort', 'Sort by', SORTS, q.sort || 'price')}
++<div class="hc-filter-actions"><button type="submit">Compare</button> <a href="/catalog" class="hc-reset">Reset</a></div>
++</form>`;
++}
++
++function method(hasAffiliate) {
++ return `<section class="hc-method" aria-labelledby="hc-method-h">
++<h2 id="hc-method-h">How the comparison works</h2>
++<ul>
++<li><strong>Sourced.</strong> Plans come from providers' official APIs through OpenVibe.Sources, or from a staff entry that names the page it was copied from. Nothing is estimated.</li>
++<li><strong>Dated.</strong> Every price shows the day it was last checked and links its source. Prices change; the provider's own page is the final word.</li>
++<li><strong>Normalised.</strong> "Per vCPU" and "per GB" divide the monthly price by what the plan states, in the plan's own currency. A value the source does not state stays Unknown and sorts last.</li>
++<li><strong>Not for sale.</strong> The order comes only from the sort you pick.${hasAffiliate ? ` ${esc(DISCLOSURE)}` : ' No provider pays to be listed or ranked.'}</li>
++</ul>
++</section>`;
++}
++
++function catalog({ result, query, providers, currencies }) {
++ const plans = result.plans;
++ const filtered = Object.keys(query || {}).some((k) => k !== 'sort' && k !== 'cursor' && query[k]);
++ const empty = plans.length ? '' : filtered
++ ? `<div class="hc-empty"><p><strong>No plan matches these filters.</strong></p><p>${link('/catalog', 'Clear the filters')} or widen one of them.</p></div>`
++ : `<div class="hc-empty"><p><strong>The catalog is filling up.</strong></p><p>Plans arrive from providers' official APIs as each source is checked and enabled, and from staff entries with their source pages.</p></div>`;
++ const next = result.next_cursor ? `<p class="hc-more">${link(`/catalog?${new URLSearchParams({ ...query, cursor: result.next_cursor })}`, 'Next 50 plans →', 'button hc-button-quiet')}</p>` : '';
++ return `<header class="hc-head">
++<p class="hc-eyebrow">OpenVibe.Host · hosting compared · alpha</p>
++<h1>Compare hosting plans</h1>
++<p class="hc-lede">Cloud, VPS and dedicated plans side by side, each with the day its price was checked and a link to where it came from.</p>
++</header>
++${filters(query, providers, currencies)}
++${planTable(plans, { caption: `${plans.length}${result.next_cursor ? '+' : ''} plan${plans.length === 1 ? '' : 's'}, sorted by ${(SORTS.find(([v]) => v === (query.sort || 'price')) || SORTS[0])[1].toLowerCase()}` })}
++${empty}${next}
++<p class="hc-links">${link('/catalog/promos', 'Current promotions')} · ${link('/catalog/providers', 'All providers')}</p>
++${method(plans.some((p) => p.affiliate_url))}`;
++}
++
++function providerList(providers) {
++ return `<header class="hc-head"><p class="hc-eyebrow">OpenVibe.Host · hosting compared</p><h1>Providers</h1>
++<p class="hc-lede">Every provider in the catalog, with where its plans come from.</p></header>
++${providers.length ? `<ul class="hc-providers">${providers.map((p) => `<li>${link(`/catalog/providers/${encodeURIComponent(p.slug)}`, `<strong>${esc(p.name)}</strong>`)}${p.description ? `<span>${esc(p.description)}</span>` : ''}${p.affiliate_url ? '<span class="hc-chip">Affiliate</span>' : ''}</li>`).join('')}</ul>` : '<div class="hc-empty"><p>No provider is listed yet.</p></div>'}
++${providers.some((p) => p.affiliate_url) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}`;
++}
++
++function promoCards(list, { showProvider = true } = {}) {
++ if (!list.length) return '<div class="hc-empty"><p>No promotion is running right now.</p></div>';
++ return `<ul class="hc-promos">${list.map((p) => `<li class="hc-promo">
++${showProvider && p.provider_name ? `<p class="hc-promo-provider">${esc(p.provider_name)}</p>` : ''}
++<h3>${esc(p.title)}</h3>
++${p.details ? `<p>${esc(p.details)}</p>` : ''}
++${p.code ? `<p class="hc-code">Code <code>${esc(p.code)}</code></p>` : ''}
++<p class="hc-promo-meta">${p.ends_at ? `Ends ${esc(day(Number(p.ends_at)))}` : 'No end date stated'}</p>
++<p>${out(p.url, `See the offer${p.affiliate ? ' <span class="hc-chip">Affiliate</span>' : ''}`, { affiliate: Boolean(p.affiliate), cls: 'button hc-button-quiet' })}${p.affiliate && p.affiliate_note ? `<small class="hc-note">${esc(p.affiliate_note)}</small>` : ''}</p>
++</li>`).join('')}</ul>`;
++}
++
++function promos(list) {
++ return `<header class="hc-head"><p class="hc-eyebrow">OpenVibe.Host · hosting compared</p><h1>Hosting promotions</h1>
++<p class="hc-lede">Offers running now, with their codes and end dates as the provider states them. An ended offer drops off by itself.</p></header>
++${promoCards(list)}
++${list.some((p) => p.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
++<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
++}
++
++function provider(p) {
++ const cheapest = p.plans.length ? p.plans.reduce((m, x) => (Number(x.price_monthly) < Number(m.price_monthly) ? x : m), p.plans[0]) : null;
++ return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link('/catalog/providers', 'Providers')}</nav>
++<header class="hc-head hc-head-row">
++<div><h1>${esc(p.name)}</h1>${p.description ? `<p class="hc-lede">${esc(p.description)}</p>` : ''}
++<p class="hc-facts">${p.plans.length} plan${p.plans.length === 1 ? '' : 's'} listed${cheapest ? ` · from <strong>${esc(money(cheapest.price_monthly, cheapest.currency))}</strong>/mo` : ''}${p.homepage_url ? ` · ${out(p.homepage_url, esc(new URL(p.homepage_url).hostname))}` : ''}</p></div>
++${visit(p)}
++</header>
++${p.plans.length ? planTable(p.plans, { caption: `${p.name}'s plans, lowest price first`, showProvider: false }) : '<div class="hc-empty"><p>No plan of this provider is listed right now.</p></div>'}
++<h2>Promotions</h2>
++${promoCards(p.promos, { showProvider: false })}
++${p.affiliate_url || p.promos.some((x) => x.affiliate) ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}`;
++}
++
++function plan(p) {
++ const spec = [
++ ['vCPU', p.vcpu == null ? null : num(p.vcpu)], ['RAM', bytesMb(p.ram_mb)],
++ ['Disk', p.disk_gb == null ? null : `${sizeGb(p.disk_gb)}${p.disk_type ? ` ${p.disk_type.toUpperCase()}` : ''}`],
++ ['Transfer', transfer(p)], ['Port', p.port_mbps == null ? null : `${num(p.port_mbps)} Mbps`], ['IPv4', p.ipv4 == null ? null : num(p.ipv4)],
++ ['Locations', p.locations && p.locations.length ? p.locations.join(', ') : null], ['Kind', kindName(p.kind)],
++ ];
++ const value = [['Per vCPU', money(p.price_per_vcpu, p.currency)], ['Per GB of RAM', money(p.price_per_gb_ram, p.currency)], ['Per GB of disk', money(p.price_per_gb_disk, p.currency, 3)]];
++ const history = p.price_history.length
++ ? `<ol class="hc-history">${p.price_history.map((h) => `<li><time datetime="${esc(day(Number(h.observed_at)))}">${esc(day(Number(h.observed_at)))}</time> ${esc(money(h.price_monthly_before, h.currency) || h.price_monthly_before)} → <strong>${esc(money(h.price_monthly_after, h.currency) || h.price_monthly_after)}</strong></li>`).join('')}</ol>`
++ : '<p class="hc-dim">No price change recorded since the catalog first saw this plan.</p>';
++ return `<nav class="hc-crumbs" aria-label="Breadcrumb">${link('/catalog', 'Catalog')} / ${link(`/catalog/providers/${encodeURIComponent(p.provider_slug)}`, esc(p.provider_name))}</nav>
++<header class="hc-head"><p class="hc-eyebrow">${esc(p.provider_name)} · ${esc(kindName(p.kind))}</p><h1>${esc(p.name)}</h1></header>
++<div class="hc-plan">
++<section class="hc-price-card" aria-label="Price">
++<p class="hc-big">${esc(money(p.price_monthly, p.currency) || '')}<span>/month</span></p>
++${p.price_hourly != null ? `<p class="hc-dim">${esc(money(p.price_hourly, p.currency, 4))} an hour</p>` : ''}
++${p.setup_fee != null && Number(p.setup_fee) > 0 ? `<p class="hc-dim">Setup fee ${esc(money(p.setup_fee, p.currency))}</p>` : ''}
++<p class="hc-checked-line">Checked <strong>${esc(day(p.checked_at) || 'unknown')}</strong> · ${out(p.source_url, 'source')}</p>
++${visit(p)}
++</section>
++<section aria-labelledby="hc-spec-h"><h2 id="hc-spec-h">What you get</h2><dl class="hc-spec">${spec.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${or(v)}</dd></div>`).join('')}</dl></section>
++<section aria-labelledby="hc-value-h"><h2 id="hc-value-h">Value</h2><dl class="hc-spec">${value.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${or(v)}</dd></div>`).join('')}</dl></section>
++</div>
++<h2>Price history</h2>
++${history}
++${p.affiliate_url ? `<p class="hc-disclosure">${esc(DISCLOSURE)}</p>` : ''}
++<p class="hc-links">${link('/catalog', '← Compare plans')}</p>`;
++}
++
++/** The signed-out front page: the comparison first, then OpenVibe's own static hosting (still in review). */
++function front({ plans, providers, promos: live, stats, hostingLaunched }) {
++ const aside = plans.length ? `<div class="hc-aside" aria-label="Lowest prices right now">
++<p class="hc-aside-h">Lowest monthly prices right now</p>
++<ol>${plans.slice(0, 5).map((p) => `<li>${link(`/catalog/plans/${encodeURIComponent(p.id)}`, `<span>${esc(p.provider_name)} <b>${esc(p.name)}</b></span><span class="hc-aside-spec">${[p.vcpu != null ? `${num(p.vcpu)} vCPU` : null, bytesMb(p.ram_mb)].filter(Boolean).map(esc).join(' · ')}</span><strong>${esc(money(p.price_monthly, p.currency))}</strong>`)}</li>`).join('')}</ol>
++<p class="hc-aside-foot">Checked ${esc(day(stats.lastChecked) || '')} · ${link('/catalog', 'compare all')}</p>
++</div>` : '';
++ return showcase.hero({
++ eyebrow: 'OpenVibe.Host · hosting compared · alpha',
++ title: 'Hosting, compared honestly.',
++ accent: 'Every price dated and sourced.',
++ lede: 'Cloud, VPS and dedicated plans from providers\' official APIs, side by side: price per month, per vCPU and per GB, the day each price was checked and a link to where it came from. Sorted by what you ask for, never by who pays.',
++ actions: [{ label: 'Compare plans', href: '/catalog', primary: true }, { label: 'Current promotions', href: '/catalog/promos' }],
++ note: stats.plans ? `${stats.plans} plans from ${stats.providers} provider${stats.providers === 1 ? '' : 's'} so far. More providers join as their sources are checked.` : 'The catalog is filling up: providers join as their official sources are checked and enabled.',
++ aside: aside ? { html: aside } : undefined,
++ }) + `<section class="sc-sec">${planTable(plans.slice(0, 8), { caption: 'Lowest price first' })}
++<p class="hc-more">${link('/catalog', 'Filter and sort every plan →', 'button')}</p></section>`
++ + showcase.features({
++ id: 'why',
++ title: 'Why this comparison is different',
++ items: [
++ { icon: 'ov:check', title: 'Sourced, not scraped', text: 'Plans come from providers\' official APIs or from staff entries that name their source page. Nothing is estimated.' },
++ { icon: 'ov:history', title: 'Dated prices', text: 'Every price shows when it was last checked, and price changes are kept as a history per plan.' },
++ { icon: 'ov:search', title: 'Compare what matters', text: 'Sort by monthly price, or by price per vCPU, per GB of RAM or per GB of disk. Unknown stays unknown.' },
++ { icon: 'ov:account', title: 'Ranked by you', text: 'No paid placement. Affiliate links are marked, disclosed beside the link, and never change the order.' },
++ ],
++ }) + (live.length ? `<section class="sc-sec"><h2>Promotions running now</h2>${promoCards(live.slice(0, 3))}<p class="hc-more">${link('/catalog/promos', 'All promotions →')}</p></section>` : '')
++ + `<section class="sc-sec hc-own" id="static-hosting"><h2>OpenVibe's own static hosting</h2>
++<p>${hostingLaunched
++ ? 'Publish a static site from a folder or a .tar.gz: every deploy is kept as an immutable artifact, served at &lt;site&gt;.openvibe.host and on domains you verify, and rolled back in one step.'
++ : 'The engine is built and tested (immutable deploys, one-step rollback, custom domains), but it is <strong>not open to the public yet</strong>: abuse handling and a designated DMCA agent come first. Until then only OpenVibe staff create projects.'}</p>
++<p>${link('/hosting', 'How it works and its limits →')}</p></section>`;
++}
++
++module.exports = { front, catalog, providerList, provider, plan, promos, planTable, DISCLOSURE, money, bytesMb, sizeGb };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 0bf3f00..c328b9f 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -14,6 +14,10 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const NETWORK_URL = 'https://openvibe.network';
++// The catalog first (openvibe.host is hosting compared, plan T20 step 3); a signed-in person's projects after it.
++const NAV_LINKS = (signedIn) => [{ label: 'Compare', href: '/catalog' }, { label: 'Promotions', href: '/catalog/promos' },
++ signedIn ? { label: 'Projects', href: '/' } : { label: 'Static hosting', href: '/hosting' }];
++const DESCRIPTION = 'OpenVibe.Host: hosting plans compared, every price dated and sourced; and OpenVibe\'s own static hosting (alpha).';
const SITE_NAME = 'OpenVibe.Host';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@@ -42,7 +46,7 @@ function renderPage(o) {
const nav = {
service: 'host',
apiBase: NETWORK_URL,
-- links: [{ label: 'Projects', href: '/' }],
++ links: NAV_LINKS(signedIn),
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
@@@ -68,13 +72,15 @@ function renderPage(o) {
title: o.title || SITE_NAME,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
siteName: SITE_NAME,
-- description: 'OpenVibe.Host: static site hosting for OpenVibe projects (alpha).',
++ description: o.description || DESCRIPTION,
canonical,
robots: o.indexable ? 'index, follow' : 'noindex, nofollow',
-- navbar: nav, footer, home: '/', navLinks: [{ label: 'Projects', href: '/' }],
++ navbar: nav, footer, home: '/', navLinks: NAV_LINKS(signedIn),
head: [
appIcon.headTags({ site: 'host' }),
`<link rel="stylesheet" href="${asset('css/host.css')}">`,
++ // Host's own stylesheets a page asks for (the catalog's css/catalog.css)
++ ...(o.css || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`),
// openvibe-shared stylesheets a page asks for by name (the front page's showcase.css)
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`),
`<meta name="ov-boost" content="host@${esc(RELEASE)}">`,
ddiff --git a/server/worker.js b/server/worker.js
iindex acd6595..cf4a353 100644
---- a/server/worker.js
++++ b/server/worker.js
@@@ -11,7 +11,7 @@
*/
const fs = require('fs');
--function createWorker({ config, store, domains, blobs, outbox, log = console }) {
++function createWorker({ config, store, domains, blobs, outbox, catalogIngest, log = console }) {
const timers = [];
let checking = false;
@@@ -61,6 +61,11 @@ function createWorker({ config, store, domains, blobs, outbox, log = console })
every(3600 * 1000, async () => { try { await sweepObjects(); } catch (err) { log.warn('[Host] object sweep failed:', err.message); } });
every(24 * 3600 * 1000, async () => { try { await outbox.outbox.prune(); } catch (err) { log.warn('[Host] outbox prune failed:', err.message); } });
setTimeout(domainTick, 5000).unref();
++ if (catalogIngest && catalogIngest.enabled) {
++ const pull = async () => { try { await catalogIngest.pull(); } catch (err) { log.warn('[Host] catalog pull failed:', err.message); } };
++ every(5 * 60_000, pull);
++ setTimeout(pull, 5000).unref();
++ }
},
stop() { for (const t of timers) clearInterval(t); timers.length = 0; },
};
ddiff --git a/test/catalog-ingest.test.js b/test/catalog-ingest.test.js
nnew file mode 100644
iindex 0000000..6e743cb
---- /dev/null
++++ b/test/catalog-ingest.test.js
@@@ -0,0 +1,81 @@
++'use strict';
++const assert = require('assert');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { testDb } = require('./helpers/db');
++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 { check, done } = require('./stageb/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.
++const item = (key, id, fields, time = at, title = id) => ({ id: `itm_${'0'.repeat(26)}`, source_key: key, category: 'hosting', kind: 'plan', identity: id, canonical_url: null, title, summary: null, authors: [], published_at: null, source_updated_at: null, fields, revision: 1, provenance: { retrieved_at: time, first_seen_at: at, content_hash: 'a'.repeat(64), raw_body_hash: null, parser_version: 'api@1', fetch_run_id: null, license_note: null, terms_note: null, entered_by: null }, removed: null });
++(async () => {
++ const db = await testDb();
++ const store = createStore(db.db, { now: () => Date.parse(at) });
++ const catalog = createCatalog({ store });
++ try {
++ await check('Vultr and Linode API fields map without invented values', async () => {
++ const v = mapItem(item('vultr-plans', 'vc2-1c-1gb', { vcpu_count: 1, ram: 1024, disk: 25, bandwidth: 1000, monthly_cost: 5, hourly_cost: 0.007, type: 'vc2' }));
++ assert.strictEqual(v.kind, 'cloud'); assert.strictEqual(v.disk_type, 'ssd'); assert.strictEqual(v.currency, 'USD'); assert.strictEqual(v.ram_mb, 1024);
++ const linode = { vcpus: 1, memory: 2048, disk: 51200, transfer: 2000, price_monthly: 12, price_hourly: 0.018, class: 'standard' };
++ const l = mapItem(item('linode-types', 'g6-standard-1', linode, at, 'Linode 2 GB'));
++ assert.strictEqual(l.external_id, 'g6-standard-1'); assert.strictEqual(l.name, 'Linode 2 GB');
++ await catalog.applyItem(item('linode-types', 'g6-standard-1', linode, at, 'Linode 2 GB'));
++ assert.strictEqual((await catalog.plans()).plans[0].provider_slug, 'linode');
++ assert.strictEqual(l.disk_gb, 50); assert.strictEqual(l.ram_mb, 2048); assert.strictEqual(l.disk_type, null);
++ assert.strictEqual(mapItem(item('vultr-plans', 'vdc-1', { monthly_cost: 50, type: 'vdc' })).kind, 'dedicated');
++ assert.strictEqual(mapItem(item('vultr-plans', 'vhf-1', { monthly_cost: 10, type: 'vhf' })).disk_type, 'nvme');
++ });
++ await check('upsert preserves first seen and records only a changed price', async () => {
++ const raw = { vcpu_count: 1, ram: 1024, disk: 25, bandwidth: 1000, monthly_cost: 5, hourly_cost: 0.007, type: 'vc2' };
++ await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', raw));
++ await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', { ...raw, monthly_cost: 6 }, '2026-10-12T12:00:00Z'));
++ await catalog.applyItem(item('vultr-plans', 'vc2-1c-1gb', { ...raw, monthly_cost: 6 }, '2026-10-13T12:00:00Z'));
++ const p = (await catalog.plans()).plans[0];
++ assert.strictEqual(Number(p.price_monthly), 6);
++ assert.strictEqual(p.first_seen_at, Date.parse(at));
++ assert.strictEqual(p.unmetered, null); assert.strictEqual(p.locations, null);
++ assert.strictEqual(p.last_seen_at, Date.parse('2026-10-13T12:00:00Z'));
++ assert.strictEqual((await catalog.plan(p.id)).price_history.length, 1);
++ });
++ await check('staff alone marks a missing Sources plan gone', async () => {
++ const p = (await catalog.plans()).plans.find((x) => x.external_id === 'vc2-1c-1gb');
++ await catalog.save('plans', { status: 'gone' }, p.id, 'usr_staff');
++ assert.strictEqual((await catalog.plans()).plans.some((x) => x.id === p.id), false);
++ });
++ await check('signed sources.item.updated delivery is consumed once', async () => {
++ const sourceItem = item('vultr-plans', 'event-plan', { monthly_cost: 7, type: 'vc2' });
++ const enabled = createCatalogIngest({ config: { catalog: { ingest: true, sourcesUrl: 'https://sources.test', webhookSecrets: ['test-secret'] } }, store, catalog, source: { getItem: async () => ({ item: sourceItem }) } });
++ const event = { event_id: `evt_${'0'.repeat(26)}`, event_type: 'sources.item.updated', version: 1, source: 'sources', actor: { type: 'service', id: 'sources' }, timestamp: at, visibility: 'internal', subject: { type: 'item', id: sourceItem.id }, payload: { category: 'hosting', item_id: sourceItem.id } };
++ const raw = Buffer.from(JSON.stringify({ event, seq: 1 }));
++ const headers = signDeliveryHeaders(raw, 'test-secret');
++ const first = await enabled.consume(raw, headers);
++ assert.strictEqual(first.status, 200); assert.strictEqual(first.outcome, 'applied');
++ assert.strictEqual((await enabled.consume(raw, headers)).duplicate, true);
++ });
++ await check('0005 tables and append-only price history migrated', async () => {
++ const tables = await db.db.prepare("SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name LIKE 'catalog_%'").all();
++ assert.ok(tables.some((x) => x.table_name === 'catalog_price_changes'));
++ const h = await db.db.prepare('SELECT plan_id, observed_at FROM catalog_price_changes LIMIT 1').get();
++ await assert.rejects(db.db.prepare('DELETE FROM catalog_price_changes WHERE plan_id = ? AND observed_at = ?').run(h.plan_id, h.observed_at), /immutable/);
++ });
++ 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);
++ assert.strictEqual((await catalog.get('providers', p.id)).created_by, null);
++ });
++ await check('unknown source is held and ingest off consumes nothing', async () => {
++ assert.strictEqual(await catalog.applyItem(item('unknown-host', 'x', { monthly_cost: 1 })), 'hold');
++ const off = createCatalogIngest({ config: { catalog: { ingest: false, sourcesUrl: '' } }, store, catalog });
++ assert.deepStrictEqual(await off.pull(), { disabled: true });
++ assert.strictEqual((await off.consume('', {})).status, 503);
++ const on = createCatalogIngest({ config: { catalog: { ingest: true, sourcesUrl: 'https://sources.test', webhookSecrets: [] } }, store, catalog, source: { listItems: async () => ({ items: [item('unrecognised-key', 'x', { monthly_cost: 1 })], next_after: 1, more: false }) } });
++ const result = await on.pull();
++ assert.strictEqual(result.unknown, 1); assert.strictEqual(result.after, 1);
++ });
++ } finally { await db.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/catalog-manage.test.js b/test/catalog-manage.test.js
nnew file mode 100644
iindex 0000000..ae98c89
---- /dev/null
++++ b/test/catalog-manage.test.js
@@@ -0,0 +1,30 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./stageb/boot');
++(async () => {
++ const t = await boot();
++ const staff = t.user('catalog-editor', { role: 'admin' });
++ const reader = t.user('catalog-reader');
++ try {
++ let provider, plan, promo;
++ await check('staff creates and edits providers, plans and promos', async () => {
++ let r = await t.api('POST', '/api/v1/catalog/providers', { as: staff, json: { slug: 'example', name: 'Example', homepage_url: 'https://example.test' } });
++ assert.strictEqual(r.status, 201, r.text); provider = r.json().provider;
++ r = await t.api('PATCH', `/api/v1/catalog/providers/${provider.id}`, { as: staff, json: { description: 'Official' } }); assert.strictEqual(r.json().provider.description, 'Official');
++ r = await t.api('POST', '/api/v1/catalog/plans', { as: staff, json: { provider_id: provider.id, external_id: 'small', name: 'Small', kind: 'vps', currency: 'USD', price_monthly: 5, source_url: 'https://example.test/pricing' } }); assert.strictEqual(r.status, 201, r.text); plan = r.json().plan;
++ r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_monthly: 6 } }); assert.strictEqual(r.status, 200, r.text);
++ r = await t.api('POST', '/api/v1/catalog/promos', { as: staff, json: { provider_id: provider.id, title: 'Credit', url: 'https://example.test/promo' } }); assert.strictEqual(r.status, 201, r.text); promo = r.json().promo;
++ r = await t.api('PATCH', `/api/v1/catalog/promos/${promo.id}`, { as: staff, json: { details: 'New accounts' } }); assert.strictEqual(r.json().promo.details, 'New accounts');
++ });
++ await check('non-staff forbidden; invalid and unsourced plans refused', async () => {
++ const deny = await t.api('POST', '/api/v1/catalog/providers', { as: reader, json: { slug: 'bad' } }); assert.strictEqual(deny.status, 403); assert.strictEqual(deny.json().code, 'host.catalog.forbidden');
++ const bad = await t.api('POST', '/api/v1/catalog/plans', { as: staff, json: { provider_id: provider.id, name: 'No source', external_id: 'bad', kind: 'vps', currency: 'USD', price_monthly: 1 } }); assert.strictEqual(bad.status, 422); assert.strictEqual(bad.json().code, 'host.catalog.invalid');
++ const unknown = await t.api('PATCH', '/api/v1/catalog/promos/hpr_00000000000000000000000000', { as: staff, json: { title: 'x' } }); assert.strictEqual(unknown.status, 404); assert.strictEqual(unknown.json().code, 'host.catalog.not_found');
++ });
++ await check('staff manage form is server rendered and CSRF protected', async () => {
++ const r = await t.api('GET', '/catalog/manage', { session: staff }); assert.strictEqual(r.status, 200); assert.match(r.text, /<form method="post"/); assert.match(r.text, /name="csrf"/);
++ const denied = await t.api('POST', '/catalog/manage/providers', { session: staff, form: { slug: 'injected', name: 'Injected', homepage_url: 'https://example.test' } }); assert.strictEqual(denied.status, 403);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/catalog-public.test.js b/test/catalog-public.test.js
nnew file mode 100644
iindex 0000000..cf6346b
---- /dev/null
++++ b/test/catalog-public.test.js
@@@ -0,0 +1,50 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./stageb/boot');
++(async () => {
++ const t = await boot();
++ const staff = t.user('catalog-public-editor', { role: 'admin' });
++ try {
++ const post = async (type, body) => { const r = await t.api('POST', `/api/v1/catalog/${type}`, { as: staff, json: body }); assert.strictEqual(r.status, 201, r.text); return r.json()[type.slice(0, -1)]; };
++ const p = await post('providers', { slug: 'alpha', name: 'Alpha', homepage_url: 'https://alpha.test', affiliate_url: 'https://alpha.test/ref', affiliate_note: 'Referral to Alpha.' });
++ const q = await post('providers', { slug: 'beta', name: 'Beta', homepage_url: 'https://beta.test' });
++ const plan = async (provider, id, price, extra = {}) => await post('plans', { provider_id: provider.id, external_id: id, name: id, kind: 'vps', currency: 'USD', price_monthly: price, source_url: 'https://alpha.test/pricing', ...extra });
++ const cheap = await plan(p, 'cheap', 4, { vcpu: 1, ram_mb: 1024, disk_gb: 20, locations: ['us'] });
++ await plan(q, 'other', 8, { vcpu: 4, ram_mb: 4096, disk_gb: 80, locations: ['eu'] });
++ const hidden = await plan(p, 'hidden', 1, { status: 'hidden' });
++ const gone = await plan(p, 'gone', 1, { status: 'gone' });
++ await post('promos', { provider_id: p.id, title: 'Expired', url: 'https://alpha.test/expired', ends_at: 1 });
++ await post('promos', { provider_id: p.id, title: 'Live', url: 'https://alpha.test/live', affiliate: true });
++ await check('filters and every sort exclude hidden and gone', async () => {
++ for (const sort of ['price', 'price_per_vcpu', 'price_per_gb_ram', 'price_per_gb_disk', 'checked_at']) {
++ const r = await t.api('GET', `/api/v1/catalog/plans?sort=${sort}`); assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json().plans.length, 2); assert.ok(!r.json().plans.some((x) => [hidden.id, gone.id].includes(x.id)));
++ }
++ const byCpu = (await t.api('GET', '/api/v1/catalog/plans?sort=price_per_vcpu')).json().plans;
++ assert.strictEqual(byCpu[0].provider_slug, 'beta', 'affiliate provider must not affect ratio sort');
++ const byPrice = (await t.api('GET', '/api/v1/catalog/plans?sort=price')).json().plans;
++ assert.strictEqual(byPrice[0].provider_slug, 'alpha', 'affiliate provider must not affect price sort');
++ for (const filter of ['provider=alpha', 'location=us', 'min_vcpu=1', 'min_ram_mb=1024', 'min_disk_gb=20', 'max_price=4', 'kind=vps', 'currency=USD']) assert.strictEqual((await t.api('GET', `/api/v1/catalog/plans?${filter}`)).status, 200);
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/plans?location=us')).json().plans.length, 1);
++ });
++ await check('ratios are correct and unknown divisors remain null', async () => {
++ const rows = (await t.api('GET', '/api/v1/catalog/plans')).json().plans;
++ assert.strictEqual(rows.find((x) => x.id === cheap.id).price_per_gb_disk, 0.2);
++ await plan(q, 'unknown', 9);
++ assert.strictEqual((await t.api('GET', '/api/v1/catalog/plans')).json().plans.find((x) => x.external_id === 'unknown').price_per_vcpu, null);
++ });
++ await check('affiliate disclosure and rel render; promos end by time', async () => {
++ const page = await t.api('GET', `/catalog/providers/${p.slug}`); assert.strictEqual(page.status, 200); assert.match(page.text, /rel="sponsored nofollow noopener"/); assert.match(page.text, /OpenVibe may earn a commission/);
++ const plans = await t.api('GET', '/catalog'); assert.match(plans.text, /<form class="hc-filters" method="get" action="\/catalog"/); assert.match(plans.text, /<table class="hc-table">/);
++ assert.match(plans.text, /How the comparison works/); assert.doesNotMatch(plans.text, /<script[^>]*src="[^"]*catalog/, 'the catalog needs no script of its own');
++ const front = await t.api('GET', '/'); assert.strictEqual(front.status, 200);
++ assert.match(front.text, /Lowest monthly prices right now/); assert.match(front.text, /\$4\.00/, 'the cheapest plan is on the front page');
++ assert.match(front.text, /css\/catalog\.css\?v=/);
++ assert.strictEqual((await t.api('GET', '/catalog/providers')).status, 200);
++ const nothing = await t.api('GET', '/catalog?min_vcpu=999'); assert.match(nothing.text, /No plan matches these filters/);
++ const bad = await t.api('GET', '/catalog?sort=affiliate'); assert.strictEqual(bad.status, 422);
++ const promos = await t.api('GET', '/api/v1/catalog/promos'); assert.strictEqual(promos.json().promos.length, 1);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/cli-inventory.test.js b/test/cli-inventory.test.js
iindex 5435bef..13f841c 100644
---- a/test/cli-inventory.test.js
++++ b/test/cli-inventory.test.js
@@@ -47,7 +47,7 @@ runTests([
const m = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/manifests/services/host.json'), 'utf8'));
contracts.assertValid('registry.service-manifest', m);
assert.strictEqual(m.id, 'host');
-- assert.deepStrictEqual([...m.capabilities].sort(), ['host.deploy.create', 'host.domain.manage', 'host.resource.read', 'host.site.config', 'host.site.manage'], 'Stage A has no API; the Stage B service enforces these five (host.resource.read: the resource index, openvibe-contracts 0.107.0)');
++ assert.deepStrictEqual([...m.capabilities].sort(), ['host.catalog.manage', 'host.deploy.create', 'host.domain.manage', 'host.resource.read', 'host.site.config', 'host.site.manage'], 'Stage A has no API; the Stage B service enforces these six (host.resource.read: the resource index, openvibe-contracts 0.107.0; host.catalog.manage: the hosting catalog, 0.139.0)');
}),
test('as root, an inventory that is not root-owned or is writable by others is refused', async () => {
ddiff --git a/test/host-launch-switch.test.js b/test/host-launch-switch.test.js
iindex 9559634..e89a00c 100644
---- a/test/host-launch-switch.test.js
++++ b/test/host-launch-switch.test.js
@@@ -13,8 +13,17 @@ const { csrfToken } = require('../server/auth/forms');
const alice = t.user('alice');
const staff = t.user('root', { role: 'admin' });
-- await check('the signed-out front page says hosting is not open yet and invites no one to publish', async () => {
-- const r = await t.api('GET', '/');
++ await check('the signed-out front page leads with the catalog and says static hosting is not open yet', async () => {
++ const front = await t.api('GET', '/');
++ assert.strictEqual(front.status, 200);
++ assert.match(front.text, /Hosting, compared honestly/);
++ assert.match(front.text, /not open to the public yet/);
++ assert.match(front.text, /href="\/hosting"/);
++ assert.doesNotMatch(front.text, /class="sc-btn sc-primary" href="\/auth\/login/, 'no primary sign-in-to-publish call');
++ });
++
++ await check('/hosting says hosting is not open yet and invites no one to publish', async () => {
++ const r = await t.api('GET', '/hosting');
assert.strictEqual(r.status, 200);
assert.match(r.text, /not open yet/);
assert.match(r.text, /launch review/);
ddiff --git a/test/host-secrets-dashboard.test.js b/test/host-secrets-dashboard.test.js
iindex d7cdc67..fa4cd83 100644
---- a/test/host-secrets-dashboard.test.js
++++ b/test/host-secrets-dashboard.test.js
@@@ -54,10 +54,13 @@ const ORIGIN = 'https://openvibe.host';
assert.match(r.text, /<noscript><nav aria-label="Site"/);
assert.match(r.text, /id="ov-footer"/);
assert.match(r.text, /Sign in with OpenVibe/);
-- assert.match(r.text, /nothing you upload is ever executed/);
++ assert.match(r.text, /Hosting, compared honestly/, 'the front page leads with the catalog');
++ const h = await t.api('GET', '/hosting');
++ assert.strictEqual(h.status, 200);
++ assert.match(h.text, /nothing you upload is ever executed/);
// The limits table is the one /limits.json serves (limitsOf(config)), never a second copy.
-- assert.match(r.text, /id="limits"/);
-- assert.match(r.text, /Files in one deploy/);
++ assert.match(h.text, /id="limits"/);
++ assert.match(h.text, /Files in one deploy/);
assert.match(r.text, /href="\/shared\/showcase\.css\?v=[0-9a-f]{12}"/);
assert.match(r.headers['content-security-policy'], /frame-ancestors 'none'/);
// Release notifications: release-watch's EventSource on the Events realtime stream (openvibe-shared 1.17).
@@@ -74,15 +77,16 @@ const ORIGIN = 'https://openvibe.host';
assert.match(withQuery.text, /<meta name="robots" content="noindex, nofollow">/);
});
-- await check('robots.txt and sitemap.xml: the front page and the legal pages only; tenant sites keep their own', async () => {
++ await check('robots.txt and sitemap.xml: the front page, static hosting, the catalog and the legal pages only; tenant sites keep their own', async () => {
const robots = await t.api('GET', '/robots.txt');
assert.strictEqual(robots.status, 200);
-- assert.strictEqual(robots.text, 'User-agent: *\nAllow: /$\nAllow: /terms$\nAllow: /privacy$\nAllow: /dmca$\nDisallow: /\n\nSitemap: https://openvibe.host/sitemap.xml\n');
++ assert.strictEqual(robots.text, 'User-agent: *\nAllow: /$\nAllow: /terms$\nAllow: /privacy$\nAllow: /dmca$\nAllow: /hosting$\nAllow: /catalog$\nAllow: /catalog/*\nDisallow: /\n\nSitemap: https://openvibe.host/sitemap.xml\n');
const map = await t.api('GET', '/sitemap.xml');
assert.strictEqual(map.status, 200);
assert.match(map.headers['content-type'], /application\/xml/);
const locs = [...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
-- assert.deepStrictEqual(locs, ['https://openvibe.host/', 'https://openvibe.host/terms', 'https://openvibe.host/privacy', 'https://openvibe.host/dmca']);
++ assert.deepStrictEqual(locs, ['https://openvibe.host/', 'https://openvibe.host/hosting', 'https://openvibe.host/terms', 'https://openvibe.host/privacy', 'https://openvibe.host/dmca',
++ 'https://openvibe.host/catalog', 'https://openvibe.host/catalog/promos', 'https://openvibe.host/catalog/providers']);
for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);
});
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 487bc76..d7f35f7 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -17,9 +17,9 @@ const BUDGETS = {
jsFiles: 5, // 5 (openvibe-shared 2.10's shell adds web-runtime.js)
jsRawKB: 250, // 239.1 (openvibe-shared 2.6.0 served scripts; was 212.2 on 2.2.0)
jsBrotliKB: 60, // 56.3 (was 49.9)
-- cssFiles: 2, // 2 (host.css, openvibe-shared showcase.css)
-- cssRawKB: 16, // 14.8 (3.1 before showcase.css)
-- cssBrotliKB: 4.2, // 3.6 (0.9 before showcase.css)
++ cssFiles: 3, // 3 (host.css, openvibe-shared showcase.css, catalog.css: the front page is the hosting catalog since Host#69)
++ cssRawKB: 27, // 25 (14.8 before catalog.css)
++ cssBrotliKB: 6.5, // 5.7 (3.6 before catalog.css)
externalFiles: 1, // 0
};