openvibers/openvibe.host Commits
Host: openvibe.host is hosting compared (the sourced hosting catalog, now the front page) (#69)
Changes
@@@ -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=@@@ -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`.++## PurposeRepository 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 beePart 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 -->@@@ -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",nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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();@@@ -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"@@@ -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"}}nnew file mode 100644@@@ -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); }@@@ -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) => {@@@ -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()). */nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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).@@@ -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 databasennew file mode 100644@@@ -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 };@@@ -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));}));@@@ -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}$`)]));nnew file mode 100644@@@ -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 <site>.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 };@@@ -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)}">`,@@@ -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; },};nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });@@@ -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 () => {@@@ -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/);@@@ -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);});@@@ -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};
@@ -116,3 +116,9 @@ HOST_FORM_SECRET=
@@ -116,3 +116,9 @@ HOST_FORM_SECRET=
# release.json (openvibe-shared/release): the deployed commit when git is not available
# release.json (openvibe-shared/release): the deployed commit when git is not available
#RELEASE_COMMIT=
#RELEASE_COMMIT=
#MIN_CLIENT_RELEASE=
#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=
@@ -7,6 +7,25 @@
@@ -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.
**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).
**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
## Purpose
Repository deploy scripts call ovhost. Stage A applies the deploy rules for every service on the host:
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:
@@ -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`):
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.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).
[Grants and registration](#grants-and-registration-for-the-lead).
`host.resource.read` (first-party) is the authority resource index Host serves for OpenVibe.Services'
`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
@@ -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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.129.0
+- openvibe-contracts: v0.139.0
- openvibe-sdk: v0.44.0
- openvibe-sdk: v0.44.0
-- openvibe-shared: v3.0.1
+- openvibe-shared: v4.0.0
+- openvibe-publishing: v1.4.0
<!-- versions:end -->
<!-- versions:end -->
@@ -14,20 +14,21 @@
@@ -14,20 +14,21 @@
"port": 4910,
"port": 4910,
"domain": "openvibe.host",
"domain": "openvibe.host",
"tenantDomain": "*.openvibe.host",
"tenantDomain": "*.openvibe.host",
- "contracts": "openvibe-contracts v0.129.0",
+ "contracts": "openvibe-contracts v0.139.0",
"sdk": "openvibe-sdk v0.44.0",
"sdk": "openvibe-sdk v0.44.0",
- "shared": "openvibe-shared v3.0.1",
+ "shared": "openvibe-shared v4.0.0",
"packages": [
"packages": [
- "openvibe-contracts v0.129.0",
+ "openvibe-contracts v0.139.0",
"openvibe-sdk v0.44.0",
"openvibe-sdk v0.44.0",
- "openvibe-shared v3.0.1"
+ "openvibe-shared v4.0.0"
],
],
"capabilities": [
"capabilities": [
"host.site.manage",
"host.site.manage",
"host.deploy.create",
"host.deploy.create",
"host.domain.manage",
"host.domain.manage",
"host.site.config",
"host.site.config",
- "host.resource.read"
+ "host.resource.read",
+ "host.catalog.manage"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"serviceManifestRegistered": "host manifest v0.2.0 released (openvibe-contracts v0.24.0+), with the four host.* events",
"serviceManifestRegistered": "host manifest v0.2.0 released (openvibe-contracts v0.24.0+), with the four host.* events",
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -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();
@@ -16,9 +16,10 @@
@@ -16,9 +16,10 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-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"
"pg": "^8.23.0"
},
},
"bin": {
"bin": {
@@ -1270,9 +1271,9 @@
@@ -1270,9 +1271,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1285,6 +1286,24 @@
@@ -1285,6 +1286,24 @@
"node": ">=20"
"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": {
"node_modules/openvibe-sdk": {
"version": "0.44.0",
"version": "0.44.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
@@ -1311,9 +1330,9 @@
@@ -1311,9 +1330,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -32,16 +32,17 @@
@@ -32,16 +32,17 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-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"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
},
},
"devDependencies": {
"devDependencies": {
- "jsonwebtoken": "^9.0.2",
- "@electric-sql/pglite": "^0.5.8"
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.2"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -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); }
@@ -45,6 +45,9 @@ const resourceIndex = require('./registry/resource-index');
@@ -45,6 +45,9 @@ const resourceIndex = require('./registry/resource-index');
const { createDashboard } = require('./http/dashboard');
const { createDashboard } = require('./http/dashboard');
const { createHostReadiness } = require('./observability');
const { createHostReadiness } = require('./observability');
const { createWorker } = require('./worker');
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 { renderPage, assetVersion, setRelease } = require('./render/layout');
const pages = require('./render/pages');
const pages = require('./render/pages');
@@ -151,9 +154,11 @@ async function createApp(opts = {}) {
@@ -151,9 +154,11 @@ async function createApp(opts = {}) {
const viewers = createViewerResolver({ auth, config, log });
const viewers = createViewerResolver({ auth, config, log });
const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
const tenant = createTenantServer({ store, config, blobs, takedowns, siteConfig, access, log });
const uploadGate = createUploadGate(config.uploads.maxConcurrent);
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -227,38 +232,53 @@ async function createApp(opts = {}) {
@@ -227,38 +232,53 @@ async function createApp(opts = {}) {
// generated one when the deploy ships none.
// generated one when the deploy ships none.
const legalPaths = require('openvibe-shared/legal').PATHS;
const legalPaths = require('openvibe-shared/legal').PATHS;
app.get('/robots.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
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
// /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).
// 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 legalTitles = require('openvibe-shared/legal').TITLES;
const publicPages = [
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.' },
{ 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.` })),
...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',
name: 'OpenVibe.Host',
summary: SITE_SUMMARY,
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.',
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: [
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: 'Machine-readable', links: [
{ title: 'Sitemap', url: `${config.baseUrl}/sitemap.xml`, note: 'the public pages' },
{ title: 'Sitemap', url: `${config.baseUrl}/sitemap.xml`, note: 'the public pages' },
{ title: 'robots.txt', url: `${config.baseUrl}/robots.txt` },
{ 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' },
{ 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 },
site: { name: 'OpenVibe.Host', url: config.baseUrl },
summary: SITE_SUMMARY,
summary: SITE_SUMMARY,
base: config.baseUrl,
base: config.baseUrl,
maxBytes: 512 * 1024,
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) ─────────
// ── Sign-in (OAuth2 client of OpenVibe.Network) ─────────
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, max: 60, standardHeaders: true, legacyHeaders: false }));
@@ -284,7 +304,14 @@ async function createApp(opts = {}) {
@@ -284,7 +304,14 @@ async function createApp(opts = {}) {
app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }),
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') }));
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 ─────────────────────────────────────────────────
// ── 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));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
// ── Preview deploys (plan T12 J4) ───────────────────────
// ── Preview deploys (plan T12 J4) ───────────────────────
@@ -296,6 +323,8 @@ async function createApp(opts = {}) {
@@ -296,6 +323,8 @@ async function createApp(opts = {}) {
Promise.resolve(tenant.preview(req, res)).catch(next);
Promise.resolve(tenant.preview(req, res)).catch(next);
});
});
+ app.use('/catalog', rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createCatalogPages(ctx));
+
// ── Dashboard ───────────────────────────────────────────
// ── Dashboard ───────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
app.use(rateLimit({ windowMs: 60_000, max: 300, standardHeaders: true, legacyHeaders: false }), createDashboard(ctx));
app.use((req, res) => {
app.use((req, res) => {
@@ -20,6 +20,7 @@ const CAPABILITIES = Object.freeze({
@@ -20,6 +20,7 @@ const CAPABILITIES = Object.freeze({
DOMAIN_MANAGE: 'host.domain.manage',
DOMAIN_MANAGE: 'host.domain.manage',
SITE_CONFIG: 'host.site.config',
SITE_CONFIG: 'host.site.config',
RESOURCE_READ: 'host.resource.read',
RESOURCE_READ: 'host.resource.read',
+ CATALOG_MANAGE: 'host.catalog.manage',
});
});
/** → { allowed, code, reason } (capabilities.check()). */
/** → { allowed, code, reason } (capabilities.check()). */
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -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 };
@@ -112,6 +112,7 @@ function load(env = process.env) {
@@ -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
// 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
// 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.
// 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',
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).
// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
@@ -30,7 +30,7 @@ const { createDb } = require('openvibe-sdk/db');
@@ -30,7 +30,7 @@ const { createDb } = require('openvibe-sdk/db');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
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
* The serving handle (ADR-035): DATABASE_URL through PgBouncer; in development without it, an embedded PGlite database
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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 };
@@ -10,6 +10,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -10,6 +10,7 @@ const ovServe = require('openvibe-shared/serve');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const { renderPage } = require('../render/layout');
const { renderPage } = require('../render/layout');
const pages = require('../render/pages');
const pages = require('../render/pages');
+const catalogPages = require('../render/catalog');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
const { limitsOf } = require('../limits');
const { limitsOf } = require('../limits');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
const { csrfToken, checkCsrf, sameOrigin } = require('../auth/forms');
@@ -30,8 +31,8 @@ function createDashboard(ctx) {
@@ -30,8 +31,8 @@ function createDashboard(ctx) {
if (ctx.actorLimits) router.use(ctx.actorLimits);
if (ctx.actorLimits) router.use(ctx.actorLimits);
router.use((req, res, next) => { privateNoStore(res); next(); });
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) {
function fail(req, res, err) {
@@ -68,12 +69,19 @@ function createDashboard(ctx) {
@@ -68,12 +69,19 @@ function createDashboard(ctx) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
// What shipped on OpenVibe.Host: the shared update log every OpenVibe site has.
// 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('/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) => {
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') {
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));
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));
}));
}));
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
*/
*/
const { ids } = require('openvibe-contracts');
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 ULID = '[0-9A-HJKMNP-TV-Z]{26}';
const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));
const RE = Object.fromEntries(Object.entries(PREFIX).map(([k, p]) => [k, new RegExp(`^${p}_${ULID}$`)]));
new file mode 100644
new file mode 100644
@@ -0,0 +1,240 @@
@@ -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 <site>.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 };
@@ -14,6 +14,10 @@ const frame = require('openvibe-shared/frame');
@@ -14,6 +14,10 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
+// 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 SITE_NAME = 'OpenVibe.Host';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -42,7 +46,7 @@ function renderPage(o) {
@@ -42,7 +46,7 @@ function renderPage(o) {
const nav = {
const nav = {
service: 'host',
service: 'host',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
- links: [{ label: 'Projects', href: '/' }],
+ links: NAV_LINKS(signedIn),
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -68,13 +72,15 @@ function renderPage(o) {
@@ -68,13 +72,15 @@ function renderPage(o) {
title: o.title || SITE_NAME,
title: o.title || SITE_NAME,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
titleSuffix: o.title ? ` · ${SITE_NAME}` : undefined,
siteName: SITE_NAME,
siteName: SITE_NAME,
- description: 'OpenVibe.Host: static site hosting for OpenVibe projects (alpha).',
+ description: o.description || DESCRIPTION,
canonical,
canonical,
robots: o.indexable ? 'index, follow' : 'noindex, nofollow',
robots: o.indexable ? 'index, follow' : 'noindex, nofollow',
- navbar: nav, footer, home: '/', navLinks: [{ label: 'Projects', href: '/' }],
+ navbar: nav, footer, home: '/', navLinks: NAV_LINKS(signedIn),
head: [
head: [
appIcon.headTags({ site: 'host' }),
appIcon.headTags({ site: 'host' }),
`<link rel="stylesheet" href="${asset('css/host.css')}">`,
`<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)
// 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))}">`),
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`),
`<meta name="ov-boost" content="host@${esc(RELEASE)}">`,
`<meta name="ov-boost" content="host@${esc(RELEASE)}">`,
@@ -11,7 +11,7 @@
@@ -11,7 +11,7 @@
*/
*/
const fs = require('fs');
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 = [];
const timers = [];
let checking = false;
let checking = false;
@@ -61,6 +61,11 @@ function createWorker({ config, store, domains, blobs, outbox, log = console })
@@ -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(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); } });
every(24 * 3600 * 1000, async () => { try { await outbox.outbox.prune(); } catch (err) { log.warn('[Host] outbox prune failed:', err.message); } });
setTimeout(domainTick, 5000).unref();
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; },
stop() { for (const t of timers) clearInterval(t); timers.length = 0; },
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -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); });
@@ -47,7 +47,7 @@ runTests([
@@ -47,7 +47,7 @@ runTests([
const m = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/manifests/services/host.json'), 'utf8'));
const m = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/manifests/services/host.json'), 'utf8'));
contracts.assertValid('registry.service-manifest', m);
contracts.assertValid('registry.service-manifest', m);
assert.strictEqual(m.id, 'host');
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 () => {
test('as root, an inventory that is not root-owned or is writable by others is refused', async () => {
@@ -13,8 +13,17 @@ const { csrfToken } = require('../server/auth/forms');
@@ -13,8 +13,17 @@ const { csrfToken } = require('../server/auth/forms');
const alice = t.user('alice');
const alice = t.user('alice');
const staff = t.user('root', { role: 'admin' });
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.strictEqual(r.status, 200);
assert.match(r.text, /not open yet/);
assert.match(r.text, /not open yet/);
assert.match(r.text, /launch review/);
assert.match(r.text, /launch review/);
@@ -54,10 +54,13 @@ const ORIGIN = 'https://openvibe.host';
@@ -54,10 +54,13 @@ const ORIGIN = 'https://openvibe.host';
assert.match(r.text, /<noscript><nav aria-label="Site"/);
assert.match(r.text, /<noscript><nav aria-label="Site"/);
assert.match(r.text, /id="ov-footer"/);
assert.match(r.text, /id="ov-footer"/);
assert.match(r.text, /Sign in with OpenVibe/);
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.
// 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.text, /href="\/shared\/showcase\.css\?v=[0-9a-f]{12}"/);
assert.match(r.headers['content-security-policy'], /frame-ancestors 'none'/);
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).
// Release notifications: release-watch's EventSource on the Events realtime stream (openvibe-shared 1.17).
@@ -74,15 +77,16 @@ const ORIGIN = 'https://openvibe.host';
@@ -74,15 +77,16 @@ const ORIGIN = 'https://openvibe.host';
assert.match(withQuery.text, /<meta name="robots" content="noindex, nofollow">/);
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');
const robots = await t.api('GET', '/robots.txt');
assert.strictEqual(robots.status, 200);
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');
const map = await t.api('GET', '/sitemap.xml');
assert.strictEqual(map.status, 200);
assert.strictEqual(map.status, 200);
assert.match(map.headers['content-type'], /application\/xml/);
assert.match(map.headers['content-type'], /application\/xml/);
const locs = [...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
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);
for (const p of ['/terms', '/privacy', '/dmca']) assert.strictEqual((await t.api('GET', p)).status, 200, p);
});
});
@@ -17,9 +17,9 @@ const BUDGETS = {
@@ -17,9 +17,9 @@ const BUDGETS = {
jsFiles: 5, // 5 (openvibe-shared 2.10's shell adds web-runtime.js)
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)
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)
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
externalFiles: 1, // 0
};
};