openvibers/openvibe.sources Commits

Sources: OVHcloud's VPS catalog (ovhcloud-vps), with api selectors, where patterns and max_bytes (contracts 0.180.0) (#51)

OP OpenVibers committed 29 days ago · 931493873f · 13 files, +2118 −20

**Sources reads OVHcloud's public VPS catalog (`ovhcloud-vps`, seeded disabled). The api adapter gains array selectors, `where` patterns and a per-endpoint `max_bytes`, on Contracts 0.180.0.**

**Why:** OpenVibe.Host compares only Linode and Amazon Lightsail.
- Vultr's API host is `Disallow: /` (the seed says so), and so are Scaleway's and Cherry's. UpCloud, Latitude and Hivelocity need keys.
- OVHcloud's `GET /order/catalog/public/vps` is marked `noAuthentication: true` and "Stable production version" in OVHcloud's own API schema, and the API host has no robots.txt (404).
- The catalog keeps plans, their hardware (`products`) and every price variant apart. It holds about 240 plan codes (region copies, local zones, commitment discounts, legacy upgrade codes) and weighs about 12 MB.

**The api adapter (`server/adapters/api.js`):**
- **Selectors.** `key[field=value,…]`, with one to four conditions, steps into the first element of the array `key` whose fields equal the values, compared as text. A value is literal text or `$rec.<path>`, read from the record. A path is split on dots outside brackets. No match, a non-array or a missing `$rec` value gives null.
- **Patterns.** A `where` expectation may be `{ "pattern": "…" }` (at most 200 characters, it must compile, no other keys), and the value must be matching text. An object without `pattern` is refused as before.
- **`max_bytes`.** An integer from 1 KiB to 32 MiB. The registry keeps it only when given, and ingest passes it to the fetcher as that endpoint's cap.

**The seed (`ovhcloud-vps`):** the US catalog, filtered to `^vps-[0-9]{4}-model[0-9]+$`.
- Each plan's name (`invoiceName`) comes with its product's cores, memory, first disk (capacity, interface, technology), bandwidth (Mbit/s, unlimited) and the monthly no-commitment price in hundred-millionths of USD.
- Against today's live catalog it yields 10 plans, VPS-1…6 2026 and VPS-1…4 2027, from $5.35. Parsing the 12 MB file takes 64 ms.
- The robots, terms and licence notes are recorded. The seed is disabled with `review_required`.

**Contracts:** pinned to v0.180.0 (from v0.163.0), which adds these three to `sources.source@1` and the write request. The proposal copy in `docs/contracts-proposal/` mirrors it, and STATUS and README name the version.

**Tests:**
- `api-join`: selectors (literal, numeric-as-text, `$rec.`, non-array, no match), patterns, `checkMapping` refusals (five conditions, a bare `$rec`, a dotted literal, an invalid, overlong or extra-key pattern, `max_bytes` bounds), and the registry keeping `max_bytes` only when given.
- `hosting-sources`: the OVHcloud fixture (the live catalog trimmed to five plans): two kept, three filtered, and every field, including the $86 plan rather than its 12-month price.
- `ingest`: under a 1 KiB deployment cap, the same 4 KB JSON is `too_large` without `max_bytes` and ingested (60 items) with it.

All 8 test files that reach the change pass.

Changes

ddiff --git a/README.md b/README.md
iindex f232280..63fa40a 100644
---- a/README.md
++++ b/README.md
@@@ -125,6 +125,22 @@ An endpoint may also carry `where`, up to 10 `path -> expected` conditions (expe
number or boolean, or an array of up to 20 of them, matching any): a record that fails any
condition is left out before `maxItems` is counted and reported as `meta.filtered`, e.g.
`"where": { "productFamily": "Lightsail Instance", "attributes.operatingSystem": "Linux" }`.
++An expectation may instead be `{ "pattern": "<regular expression, at most 200 characters>" }`, which the value must
++match as text.
++
++**Catalogs that keep plans, hardware and prices apart** (OVHcloud's public order catalog, `ovhcloud-vps`). A key may
++carry a selector, `key[field=value,…]` (one to four conditions): `key` must be an array, and the path steps into its
++first element whose fields equal every value, compared as text after `scalar()` (so `interval=1` matches the number 1).
++A value is literal text or `$rec.<path>`, read from the record itself. No match is null.
++
++```json
++"where": { "planCode": { "pattern": "^vps-[0-9]{4}-model[0-9]+$" } },
++"extra": { "vcpu": "$root.products[name=$rec.product].blobs.technical.cpu.cores",
++ "price_monthly": "pricings[mode=default,commitment=0,intervalUnit=month,interval=1].price" }
++```
++
++`max_bytes` (an integer from 1 KiB to 32 MiB) sets one endpoint's body cap instead of `SOURCES_MAX_BYTES`. OVHcloud's
++catalog is about 12 MB.
**Facts stated in prose (`extract`, rss/atom only).** A feed endpoint may carry
`extract: { kind, require, fields }` ([server/adapters/extract.js](server/adapters/extract.js)). Each field is read from
@@@ -368,7 +384,7 @@ vhost serves it.
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.163.0
++- openvibe-contracts: v0.180.0
- openvibe-sdk: v0.44.0
- openvibe-shared: v4.1.7
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 1282af5..f0358e1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"port": 4720,
"domain": "sources.openvibe.network (vhost in deploy/nginx: an internal-service page, health and JSON 404s; the API is loopback-only; README \"Public host\")",
-- "contracts": "openvibe-contracts v0.163.0",
++ "contracts": "openvibe-contracts v0.180.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.7",
"capabilities": [
ddiff --git a/docs/contracts-proposal/contracts/sources/source.v1.json b/docs/contracts-proposal/contracts/sources/source.v1.json
iindex 18f011d..cb9f58c 100644
---- a/docs/contracts-proposal/contracts/sources/source.v1.json
++++ b/docs/contracts-proposal/contracts/sources/source.v1.json
@@@ -113,15 +113,35 @@
"boolean"
]
}
++ },
++ {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "pattern"
++ ],
++ "properties": {
++ "pattern": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200
++ }
++ }
}
]
},
-- "description": "Records kept only when every path equals its value (or one of an array of values); others are left out before max_items counts. Paths follow the field rules: relative to the record, $key, an absolute $root.<path> into the response (a {$key} segment there is the record's map key), and * for an object's single child (Sources#39)."
++ "description": "Records kept only when every path equals its value (or one of an array of values), or, for { pattern }, is text the regular expression matches; others are left out before max_items counts. Paths follow the field rules: relative to the record, $key, an absolute $root.<path> into the response (a {$key} segment there is the record's map key), * for an object's single child, and key[field=value,...] selectors (Sources#39, Sources#49)."
},
"item_kind": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,39}$"
},
++ "max_bytes": {
++ "type": "integer",
++ "minimum": 1024,
++ "maximum": 33554432,
++ "description": "The body cap for this endpoint, in bytes (1 KiB to 32 MiB); the deployment's cap otherwise. OVHcloud's VPS catalog is about 12 MB (Sources#49)."
++ },
"fields": {
"type": "object",
"additionalProperties": {
@@@ -133,7 +153,7 @@
"additionalProperties": {
"type": "string"
},
-- "description": "Extra fields copied as the API gave them (scalars). A path is relative to the record; $key is the map key; $root.<path> reads the response from its root, where a {$key} segment is the record's map key; * steps into an object's single child."
++ "description": "Extra fields copied as the API gave them (scalars). A path is relative to the record; $key is the map key; $root.<path> reads the response from its root, where a {$key} segment is the record's map key; * steps into an object's single child; key[field=value,...] (one to four conditions) steps into the first element of the array key whose fields equal the values, compared as text, where a value is literal text or $rec.<path> read from the record (Sources#49)."
},
"extract": {
"type": "object",
ddiff --git a/package-lock.json b/package-lock.json
iindex 9ba4870..51e1936 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.163.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.180.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/v4.1.7",
"pg": "^8.23.0"
@@@ -769,9 +769,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.163.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.163.0",
-- "integrity": "sha512-ASRT/sE3h9O+1sXXlLUKY5RfKmceqCSGKiM1iV4o+OZYBbORwwU+gMon4kEC2tar0/8Z/X/fIPNFufxBStK8bA==",
++ "version": "0.180.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.180.0",
++ "integrity": "sha512-oJudG0iBunYod2ykVXxVWfsAd6XN3mln0rifFR3atHwUXSzPK+pi1EeN9OYDpnevhma0Vr+pyYaMilmiNb5hmQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex e65c1c9..4525245 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.163.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.180.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/v4.1.7",
"pg": "^8.23.0"
ddiff --git a/seeds/sources.json b/seeds/sources.json
iindex b89334e..1d45c2d 100644
---- a/seeds/sources.json
++++ b/seeds/sources.json
@@@ -1055,6 +1055,54 @@
"sensitivity": "none",
"default_indexability": "noindex"
},
++ {
++ "key": "ovhcloud-vps",
++ "name": "OVHcloud VPS plans (US catalog)",
++ "type": "api",
++ "category": "hosting",
++ "homepage_url": "https://us.ovhcloud.com/vps/",
++ "endpoints": [
++ {
++ "url": "https://api.us.ovhcloud.com/1.0/order/catalog/public/vps?ovhSubsidiary=US",
++ "format": "json",
++ "items_path": "plans",
++ "item_kind": "plan",
++ "max_bytes": 25165824,
++ "where": {
++ "planCode": {
++ "pattern": "^vps-[0-9]{4}-model[0-9]+$"
++ }
++ },
++ "fields": {
++ "identity": "planCode",
++ "title": "invoiceName"
++ },
++ "extra": {
++ "product": "product",
++ "currency": "$root.locale.currencyCode",
++ "vcpu": "$root.products[name=$rec.product].blobs.technical.cpu.cores",
++ "cpu_type": "$root.products[name=$rec.product].blobs.technical.cpu.type",
++ "memory_gb": "$root.products[name=$rec.product].blobs.technical.memory.size",
++ "disk_gb": "$root.products[name=$rec.product].blobs.technical.storage.disks.0.capacity",
++ "disk_interface": "$root.products[name=$rec.product].blobs.technical.storage.disks.0.interface",
++ "disk_technology": "$root.products[name=$rec.product].blobs.technical.storage.disks.0.technology",
++ "bandwidth_mbps": "$root.products[name=$rec.product].blobs.technical.bandwidth.level",
++ "bandwidth_unlimited": "$root.products[name=$rec.product].blobs.technical.bandwidth.unlimited",
++ "price_monthly": "pricings[mode=default,commitment=0,intervalUnit=month,interval=1].price"
++ }
++ }
++ ],
++ "robots_note": "robots.txt checked 2026-10-15: https://api.us.ovhcloud.com/robots.txt answers 404 (nginx), and api.ovh.com/robots.txt redirects to https://api.eu.ovhcloud.com/robots.txt, also 404, so the API hosts publish no robots.txt rules; a 404 is treated as no restrictions (never guessed as a disallow). The fetcher re-checks robots.txt on every run. (us.ovhcloud.com, the marketing site, is a different host and is not fetched.)",
++ "terms_note": "OVHcloud's own API schema (https://api.us.ovhcloud.com/1.0/order.json) marks GET /order/catalog/public/vps 'noAuthentication': true with apiStatus 'Stable production version' ('Retrieve VPS catalog'): the public catalog is meant to be read without an account, and it answered our keyless fetcher. OVHcloud's general terms (https://us.ovhcloud.com/legal/) govern its services; no page found states a right to, or a ban on, republishing catalog prices, so a person reads them before enabling. One file (about 12 MB, mostly operating-system options) a day. Prices are the catalog's integers in hundred-millionths of the currency (535000000 is $5.35); show each as an observation 'checked on <date>' with a link to https://us.ovhcloud.com/vps/, never a quote, and imply no endorsement. Only the current plan codes (vps-<year>-model<n>, US datacenters) are kept: the -eu/-ca region copies, local-zone (.LZ) plans, commitment discounts and legacy upgrade codes are filtered out. Re-verify before enabling.",
++ "license_note": "Plan specifications and prices are OVHcloud's data (https://us.ovhcloud.com/vps/). No licence to republish them was found beyond the public catalog being offered without authentication. Attribute OVHcloud and link the VPS page; no endorsement implied.",
++ "min_interval_ms": 60000,
++ "poll_interval_sec": 86400,
++ "max_items": 50,
++ "enabled": false,
++ "review_required": true,
++ "sensitivity": "none",
++ "default_indexability": "noindex"
++ },
{
"key": "lowendtalk-offers",
"name": "LowEndTalk: the Offers board",
ddiff --git a/server/adapters/api.js b/server/adapters/api.js
iindex e2ed31c..ee99ac2 100644
---- a/server/adapters/api.js
++++ b/server/adapters/api.js
@@@ -32,6 +32,22 @@
*
* "where": { "productFamily": "Lightsail Instance", "attributes.operatingSystem": "Linux" }
*
++ * A key may carry a selector, `key[field=value,…]` (one to four conditions): `key` must be an array, and
++ * the path steps into its first element whose fields equal every value (compared as text after
++ * `scalar()`, so `interval=1` matches the number 1). A value is literal text (letters, digits, spaces,
++ * `_ : @ + -`) or `$rec.<path>`, a plain dotted path read from the record itself. No match, a missing
++ * value or a non-array is undefined (the field is null). OVHcloud's catalog lists plans and their
++ * hardware separately and keeps every price variant in one array:
++ *
++ * "extra": { "vcpu": "$root.products[name=$rec.product].blobs.technical.cpu.cores",
++ * "price": "pricings[mode=default,commitment=0,intervalUnit=month].price" }
++ *
++ * A `where` expectation may also be `{ "pattern": "<regular expression>" }` (at most 200 characters):
++ * the value must be text the expression matches, e.g. `{ "planCode": { "pattern": "^vps-[0-9]{4}-model[0-9]+$" } }`.
++ *
++ * `max_bytes` (an integer from 1 KiB to 32 MiB) raises or lowers the body cap for one endpoint
++ * (SOURCES_MAX_BYTES otherwise); OVHcloud's VPS catalog is about 12 MB.
++ *
* Some price lists key their records by id instead of listing them: Porkbun returns
* `{ "pricing": { "com": {…}, "net": {…} } }` and AWS Lightsail keys its price list by SKU.
* `"items_shape": "map"` reads such an object: each own property becomes one record, in sorted
@@@ -45,13 +61,44 @@ const { parseXml, toText, toIsoDate, epochSecondsToIso, canonicalUrl, toId, Pars
const PARSER_VERSION = 'api@1';
// A segment is `*` (the single child of an object), `{$key}` (the map key, only inside `$root.`),
// or an ordinary key. PATH_RE is syntax only; keyPathViolation enforces where each token may sit.
--const PATH_SEG = '(?:\\*|\\{\\$key\\}|[A-Za-z0-9_@:$-]+)';
++// A selector condition is `field=value`, the value literal text or `$rec.` and a plain dotted path.
++const SEL_COND = '[A-Za-z0-9_]+=(?:\\$rec(?:\\.[A-Za-z0-9_]+)+|[A-Za-z0-9_ :@+-]+)';
++const SELECTOR = `\\[${SEL_COND}(?:,${SEL_COND}){0,3}\\]`;
++const PATH_SEG = `(?:\\*|\\{\\$key\\}|[A-Za-z0-9_@:$-]+(?:${SELECTOR})?)`;
const PATH_RE = new RegExp(`^${PATH_SEG}(?:\\.${PATH_SEG})*$`);
--/** Walk a dot path: `*` steps into an object's single own property, `{$key}` uses the map key. */
--function walk(obj, path, key) {
++/** A path's segments: dots split it, except inside a selector (whose `$rec.` values have dots of their own). */
++const SPLIT = new Map();
++function splitPath(path) {
++ let segs = SPLIT.get(path);
++ if (segs) return segs;
++ segs = [];
++ let depth = 0;
++ let cur = '';
++ for (const ch of path) {
++ if (ch === '[') depth++;
++ else if (ch === ']') depth--;
++ if (ch === '.' && depth === 0) { segs.push(cur); cur = ''; } else cur += ch;
++ }
++ segs.push(cur);
++ if (SPLIT.size < 1000) SPLIT.set(path, segs);
++ return segs;
++}
++
++/** `key[a=b,c=$rec.d]` → { name, conds: [[field, value]] }, or null for a plain segment. */
++function selectorOf(seg) {
++ const m = /^([^[]+)\[(.+)\]$/.exec(seg);
++ if (!m) return null;
++ return { name: m[1], conds: m[2].split(',').map((c) => [c.slice(0, c.indexOf('=')), c.slice(c.indexOf('=') + 1)]) };
++}
++
++/**
++ * Walk a dot path: `*` steps into an object's single own property, `{$key}` uses the map key, and
++ * `key[field=value,…]` steps into the first element of the array `key` whose fields match.
++ */
++function walk(obj, path, key, rec) {
let cur = obj;
-- for (const seg of path.split('.')) {
++ for (const seg of splitPath(path)) {
if (cur == null || typeof cur !== 'object') return undefined;
if (seg === '*') {
if (Array.isArray(cur)) return undefined;
@@@ -62,7 +109,16 @@ function walk(obj, path, key) {
if (key === undefined) return undefined;
cur = cur[key];
} else {
-- cur = cur[seg];
++ const sel = selectorOf(seg);
++ if (!sel) { cur = cur[seg]; continue; }
++ const list = cur[sel.name];
++ if (!Array.isArray(list)) return undefined;
++ const wanted = sel.conds.map(([f, v]) => [f, v.startsWith('$rec.') ? scalar(walk(rec, v.slice('$rec.'.length), key, rec)) : v]);
++ if (wanted.some(([, v]) => v == null)) return undefined;
++ cur = list.find((el) => el && typeof el === 'object' && wanted.every(([f, v]) => {
++ const a = scalar(el[f]);
++ return a != null && String(a) === String(v);
++ }));
}
}
return cur;
@@@ -72,8 +128,8 @@ function walk(obj, path, key) {
function getField(body, rec, key, p) {
if (!p) return rec;
if (p === '$key') return key;
-- if (p.startsWith('$root.')) return walk(body, p.slice('$root.'.length), key);
-- return walk(rec, p, key);
++ if (p.startsWith('$root.')) return walk(body, p.slice('$root.'.length), key, rec);
++ return walk(rec, p, key, rec);
}
function at(obj, path) {
@@@ -89,10 +145,22 @@ function scalar(v) {
return null;
}
--/** Does one record satisfy every `where` condition? Values compare after scalar(). */
++const PATTERNS = new Map();
++function patternOf(source) {
++ let re = PATTERNS.get(source);
++ if (!re) { re = new RegExp(source); if (PATTERNS.size < 100) PATTERNS.set(source, re); }
++ return re;
++}
++const isPattern = (v) => v != null && typeof v === 'object' && !Array.isArray(v) && 'pattern' in v;
++
++/** Does one record satisfy every `where` condition? Values compare after scalar(); a pattern tests text. */
function matchesWhere(body, rec, key, where) {
for (const [p, expected] of Object.entries(where)) {
const actual = scalar(getField(body, rec, key, p));
++ if (isPattern(expected)) {
++ if (typeof actual !== 'string' || !patternOf(expected.pattern).test(actual)) return false;
++ continue;
++ }
const wanted = Array.isArray(expected) ? expected : [expected];
if (!wanted.some((w) => w === actual)) return false;
}
@@@ -104,7 +172,7 @@ function matchesWhere(body, rec, key, where) {
* segment `{$key}` inside a `$root.` path. `$root` must be followed by a path.
*/
function keyPathViolation(p, shape) {
-- const segs = p.split('.');
++ const segs = splitPath(p);
const hasBare = segs.includes('$key');
const hasBrace = segs.includes('{$key}');
if (p === '$root' || p.startsWith('$root.')) {
@@@ -147,6 +215,11 @@ function checkMapping(ep) {
if (!PATH_RE.test(p)) return `where.${p} path is malformed`;
const kerr = keyPathViolation(p, shape);
if (kerr) return `where.${p}: ${kerr}`;
++ if (isPattern(expected)) {
++ if (Object.keys(expected).length !== 1 || typeof expected.pattern !== 'string' || !expected.pattern || expected.pattern.length > 200) return `where.${p}: a pattern is { "pattern": "<at most 200 characters>" }`;
++ try { new RegExp(expected.pattern); } catch { return `where.${p}: the pattern is not a valid regular expression`; }
++ continue;
++ }
const wanted = Array.isArray(expected) ? expected : [expected];
if (wanted.length > 20) return `where.${p} has at most 20 expected values`;
for (const v of wanted) {
@@@ -154,6 +227,7 @@ function checkMapping(ep) {
}
}
if (ep.item_kind !== undefined && !/^[a-z][a-z0-9_]{1,39}$/.test(ep.item_kind)) return 'item_kind is malformed';
++ if (ep.max_bytes !== undefined && (!Number.isInteger(ep.max_bytes) || ep.max_bytes < 1024 || ep.max_bytes > 32 * 1024 * 1024)) return 'max_bytes is an integer from 1024 to 33554432';
return null;
}
ddiff --git a/server/ingest.js b/server/ingest.js
iindex c2e6b12..16f6e3c 100644
---- a/server/ingest.js
++++ b/server/ingest.js
@@@ -137,6 +137,8 @@ function createIngest({ db, config, registry, items, robots, fetcher, spacer, em
etag: validators.etag || null,
lastModified: validators.last_modified || null,
sensitiveHeaders: sensitive,
++ // an api endpoint may set its own body cap (adapters/api.js `max_bytes`)
++ capBytes: endpoint.max_bytes || undefined,
// every redirect hop is checked against its own origin's robots.txt and, on a new
// host, spaced like any other request to that host
beforeHop: async (next) => {
ddiff --git a/server/registry.js b/server/registry.js
iindex 8938f19..8574aae 100644
---- a/server/registry.js
++++ b/server/registry.js
@@@ -77,6 +77,7 @@ function validateSource(input, { maxItemsCap = 500 } = {}) {
Object.assign(out, { format: e.format || 'json', items_shape: e.items_shape || 'array', items_path: e.items_path || '', item_kind: e.item_kind || 'record', fields: e.fields || {}, extra: e.extra || {} });
// `where` is only copied when given, so records without conditions stay byte-identical.
if (e.where) out.where = e.where;
++ if (e.max_bytes !== undefined) out.max_bytes = e.max_bytes;
} else {
// A feed may read structured facts out of its items' prose (adapters/extract.js).
for (const k of Object.keys(e)) if (k !== 'url' && !(k === 'extract' && (input.type === 'rss' || input.type === 'atom'))) throw new RegistryError(`endpoints[${i}].${k} is only for api sources`);
ddiff --git a/test/api-join.test.js b/test/api-join.test.js
iindex 94acbe3..cfe9ac3 100644
---- a/test/api-join.test.js
++++ b/test/api-join.test.js
@@@ -118,6 +118,77 @@ t('checkMapping validates the new syntax and rejects malformed variants', () =>
assert.match(api.checkMapping({ ...map, where: ['x'] }), /where must be an object/);
});
++const CATALOG = JSON.stringify({
++ locale: { currencyCode: 'USD' },
++ plans: [
++ { planCode: 'vps-1', product: 'p-small', pricings: [
++ { mode: 'default', commitment: 0, intervalUnit: 'none', price: 0 },
++ { mode: 'upfront12', commitment: 12, intervalUnit: 'month', price: 400 },
++ { mode: 'default', commitment: 0, intervalUnit: 'month', price: 500 },
++ ] },
++ { planCode: 'vps-2', product: 'p-big', pricings: 'not an array' },
++ { planCode: 'vps-3', product: 'p-gone', pricings: [] },
++ { planCode: 'vps-1-eu', product: 'p-small', pricings: [] },
++ { planCode: 7, product: 'p-small', pricings: [] },
++ ],
++ products: [
++ { name: 'p-small', cores: 2, disks: [{ capacity: 40 }] },
++ { name: 'p-big', cores: 8, disks: [{ capacity: 200 }] },
++ ],
++});
++const selectEndpoint = {
++ format: 'json', items_path: 'plans', item_kind: 'plan',
++ where: { planCode: { pattern: '^vps-[0-9]+$' } },
++ fields: { identity: 'planCode' },
++ extra: {
++ cores: '$root.products[name=$rec.product].cores',
++ disk: '$root.products[name=$rec.product].disks.0.capacity',
++ price: 'pricings[mode=default,commitment=0,intervalUnit=month].price',
++ currency: '$root.locale.currencyCode',
++ },
++};
++
++t('a selector steps into the first array element whose fields match; $rec. values come from the record', () => {
++ const r = api.parse(CATALOG, { url: 'https://x.example/', endpoint: selectEndpoint });
++ assert.deepStrictEqual(r.items.map((i) => i.identity), ['vps-1', 'vps-2', 'vps-3']);
++ assert.deepStrictEqual(r.items[0].fields, { cores: 2, disk: 40, price: 500, currency: 'USD' }, 'numbers match as text: commitment=0');
++ assert.deepStrictEqual(r.items[1].fields, { cores: 8, disk: 200, price: null, currency: 'USD' }, 'a selector on a non-array is null');
++ assert.deepStrictEqual(r.items[2].fields, { cores: null, disk: null, price: null, currency: 'USD' }, 'no matching element is null');
++});
++
++t('a where pattern keeps text it matches; a number or a non-matching code is filtered', () => {
++ const r = api.parse(CATALOG, { url: 'https://x.example/', endpoint: selectEndpoint });
++ assert.strictEqual(r.meta.filtered, 2, 'vps-1-eu and the numeric plan code');
++});
++
++t('checkMapping accepts selectors, patterns and max_bytes, and refuses their malformed variants', () => {
++ assert.strictEqual(api.checkMapping(selectEndpoint), null);
++ assert.strictEqual(api.checkMapping({ ...selectEndpoint, max_bytes: 32 * 1024 * 1024 }), null);
++ const extra = (x) => api.checkMapping({ fields: { identity: 'id' }, extra: { x } });
++ assert.strictEqual(extra('a[b=Monthly fees].c'), null, 'literal values may hold spaces');
++ assert.match(extra('a[b=1,c=2,d=3,e=4,f=5].x'), /malformed/, 'at most four conditions');
++ assert.match(extra('a[b=$rec].x'), /malformed/, '$rec needs a path');
++ assert.match(extra('a[b=c.d].x'), /malformed/, 'a literal has no dots');
++ assert.match(extra('a[=c].x'), /malformed/);
++ assert.match(extra('a[b=c'), /malformed/);
++ const where = (w) => api.checkMapping({ fields: { identity: 'id' }, where: { code: w } });
++ assert.match(where({ pattern: '(' }), /not a valid regular expression/);
++ assert.match(where({ pattern: 'x'.repeat(201) }), /at most 200/);
++ assert.match(where({ pattern: 'x', flags: 'i' }), /a pattern is/);
++ assert.match(where({ y: 1 }), /strings, numbers or booleans/, 'an object without pattern is still refused as before');
++ for (const max of [0, 1023, 32 * 1024 * 1024 + 1, 1.5, '5000000']) {
++ assert.match(api.checkMapping({ fields: { identity: 'id' }, max_bytes: max }), /max_bytes is an integer/, String(max));
++ }
++});
++
++t('the registry stores max_bytes only when an endpoint gives it', () => {
++ const { validateSource } = require('../server/registry');
++ const endpoint = { url: 'https://x.example/', items_path: 'plans', fields: { identity: 'planCode' } };
++ const base = { key: 'catalog', name: 'A catalog', type: 'api', category: 'hosting', endpoints: [endpoint] };
++ assert.ok(!('max_bytes' in validateSource(base).endpoints[0]));
++ assert.strictEqual(validateSource({ ...base, endpoints: [{ ...endpoint, max_bytes: 25165824 }] }).endpoints[0].max_bytes, 25165824);
++});
++
t('the registry stores `where` only when an endpoint gives it', () => {
const { validateSource } = require('../server/registry');
const endpoint = { url: 'https://x.example/', items_path: 'products', items_shape: 'map', fields: { identity: '$key' } };
ddiff --git a/test/fixtures/ovhcloud-vps.json b/test/fixtures/ovhcloud-vps.json
nnew file mode 100644
iindex 0000000..2bc83f6
---- /dev/null
++++ b/test/fixtures/ovhcloud-vps.json
@@@ -0,0 +1,1840 @@
++{
++ "catalogId": 3701,
++ "locale": {
++ "currencyCode": "USD",
++ "subsidiary": "US",
++ "taxRate": 0
++ },
++ "plans": [
++ {
++ "planCode": "vps-2027-model1",
++ "invoiceName": "VPS-1 2027",
++ "product": "vps-2027-model1",
++ "pricingType": "rental",
++ "consumptionConfiguration": null,
++ "pricings": [
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 0,
++ "description": "Monthly fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": null
++ },
++ "price": 535000000,
++ "formattedPrice": "$5.35 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 12,
++ "description": "12 months fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 12,
++ "max": 12
++ },
++ "price": 454000000,
++ "formattedPrice": "$4.54 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": {
++ "defaultEndAction": "REACTIVATE_ENGAGEMENT",
++ "duration": "P12M",
++ "type": "upfront"
++ }
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 6,
++ "description": "6 months fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 6,
++ "max": 6
++ },
++ "price": 508000000,
++ "formattedPrice": "$5.08 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": {
++ "defaultEndAction": "REACTIVATE_ENGAGEMENT",
++ "duration": "P6M",
++ "type": "upfront"
++ }
++ }
++ ],
++ "configurations": [
++ {
++ "name": "region",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "united_states"
++ ]
++ },
++ {
++ "name": "vps_datacenter",
++ "isCustom": false,
++ "isMandatory": true,
++ "values": [
++ "US-EAST-VA",
++ "US-WEST-OR"
++ ]
++ },
++ {
++ "name": "vps_os",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "AlmaLinux 10",
++ "AlmaLinux 8",
++ "AlmaLinux 8 - cPanel",
++ "AlmaLinux 9",
++ "AlmaLinux 9 - cPanel",
++ "CloudLinux 9",
++ "Debian 11",
++ "Debian 12",
++ "Debian 12 - Docker",
++ "Debian 12 - Plesk",
++ "Debian 12 - n8n",
++ "Debian 13",
++ "Fedora 43",
++ "Fedora 44",
++ "FreeBSD-14.3 - UEFI",
++ "FreeBSD-15-ufs - UEFI",
++ "FreeBSD-15-zfs - UEFI",
++ "Rocky Linux 10",
++ "Rocky Linux 8",
++ "Rocky Linux 9",
++ "Ubuntu 22.04",
++ "Ubuntu 24.04",
++ "Ubuntu 26.04"
++ ]
++ }
++ ],
++ "family": "vps-2025",
++ "blobs": {
++ "commercial": {
++ "brick": "VPS Model 1",
++ "features": [
++ {
++ "name": "Anti-DDoS",
++ "value": "Yes"
++ },
++ {
++ "name": "KVM",
++ "value": "Yes"
++ },
++ {
++ "name": "Root access",
++ "value": "Yes"
++ },
++ {
++ "name": "API",
++ "value": "Yes"
++ },
++ {
++ "name": "IPv4 Address",
++ "value": "1"
++ },
++ {
++ "name": "IPv6 Address",
++ "value": "/128"
++ },
++ {
++ "name": "Monitoring and Interventions",
++ "value": "24/7"
++ },
++ {
++ "name": "SLA",
++ "value": "99.9%"
++ }
++ ],
++ "line": "2027",
++ "range": "2027"
++ },
++ "tags": [
++ "order-funnel:show",
++ "website:show",
++ "os:distribution-only",
++ "os:distribution-with-application"
++ ]
++ }
++ },
++ {
++ "planCode": "vps-2027-model1-eu",
++ "invoiceName": "VPS-1 2027",
++ "product": "vps-2027-model1-resell",
++ "pricingType": "rental",
++ "consumptionConfiguration": null,
++ "pricings": [
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 0,
++ "description": "Monthly fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": null
++ },
++ "price": 535000000,
++ "formattedPrice": "$5.35 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 12,
++ "description": "12 months fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 12,
++ "max": 12
++ },
++ "price": 454000000,
++ "formattedPrice": "$4.54 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": {
++ "defaultEndAction": "REACTIVATE_ENGAGEMENT",
++ "duration": "P12M",
++ "type": "upfront"
++ }
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 6,
++ "description": "6 months fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 6,
++ "max": 6
++ },
++ "price": 508000000,
++ "formattedPrice": "$5.08 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": {
++ "defaultEndAction": "REACTIVATE_ENGAGEMENT",
++ "duration": "P6M",
++ "type": "upfront"
++ }
++ }
++ ],
++ "configurations": [
++ {
++ "name": "infrastructure",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "preproduction",
++ "production"
++ ]
++ },
++ {
++ "name": "region",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "europe"
++ ]
++ },
++ {
++ "name": "vps_datacenter",
++ "isCustom": false,
++ "isMandatory": true,
++ "values": [
++ "DE",
++ "EU-SOUTH-MIL",
++ "EU-WEST-RBX",
++ "GRA",
++ "SBG",
++ "UK",
++ "WAW"
++ ]
++ },
++ {
++ "name": "vps_os",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "AlmaLinux 10",
++ "AlmaLinux 8",
++ "AlmaLinux 8 - cPanel",
++ "AlmaLinux 9",
++ "AlmaLinux 9 - cPanel",
++ "CloudLinux 9",
++ "Debian 11",
++ "Debian 12",
++ "Debian 12 - Docker",
++ "Debian 12 - Plesk",
++ "Debian 12 - n8n",
++ "Debian 13",
++ "Fedora 43",
++ "Fedora 44",
++ "FreeBSD-14.3 - UEFI",
++ "FreeBSD-15-ufs - UEFI",
++ "FreeBSD-15-zfs - UEFI",
++ "Rocky Linux 10",
++ "Rocky Linux 8",
++ "Rocky Linux 9",
++ "Ubuntu 22.04",
++ "Ubuntu 24.04",
++ "Ubuntu 26.04"
++ ]
++ }
++ ],
++ "family": "vps-2025-eu",
++ "blobs": {
++ "commercial": {
++ "features": [
++ {
++ "name": "Anti-DDoS",
++ "value": "Yes"
++ },
++ {
++ "name": "KVM",
++ "value": "Yes"
++ },
++ {
++ "name": "Root access",
++ "value": "Yes"
++ },
++ {
++ "name": "API",
++ "value": "Yes"
++ },
++ {
++ "name": "IPv4 Address",
++ "value": "1"
++ },
++ {
++ "name": "IPv6 Address",
++ "value": "/128"
++ },
++ {
++ "name": "Monitoring and Interventions",
++ "value": "24/7"
++ },
++ {
++ "name": "SLA",
++ "value": "99.9%"
++ }
++ ],
++ "line": "2027",
++ "range": "2027"
++ },
++ "tags": [
++ "order-funnel:show",
++ "os:distribution-only",
++ "os:distribution-with-application"
++ ]
++ }
++ },
++ {
++ "planCode": "vps-value-1-2-40-vps-2025-model1",
++ "invoiceName": "VPS-1 2026",
++ "product": "vps-2020v2-model1",
++ "pricingType": "rental",
++ "consumptionConfiguration": null,
++ "pricings": [
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 0,
++ "description": "Monthly fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": null
++ },
++ "price": 700000000,
++ "formattedPrice": "$7.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 12,
++ "description": "12 months fees",
++ "interval": 12,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": null
++ },
++ "price": 7342000000,
++ "formattedPrice": "$73.42 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": {
++ "defaultEndAction": "REACTIVATE_ENGAGEMENT",
++ "duration": "P12M",
++ "type": "upfront"
++ }
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront24",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront24",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 24,
++ "description": "24 months fees",
++ "interval": 24,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": null
++ },
++ "price": 13566000000,
++ "formattedPrice": "$135.66 USD",
++ "tax": 0,
++ "mode": "upfront24",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": {
++ "defaultEndAction": "REACTIVATE_ENGAGEMENT",
++ "duration": "P24M",
++ "type": "upfront"
++ }
++ }
++ ],
++ "configurations": [
++ {
++ "name": "region",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "united_states"
++ ]
++ },
++ {
++ "name": "vps_datacenter",
++ "isCustom": false,
++ "isMandatory": true,
++ "values": [
++ "US-EAST-VA",
++ "US-WEST-OR"
++ ]
++ },
++ {
++ "name": "vps_install_rtm",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "if_available",
++ "no"
++ ]
++ },
++ {
++ "name": "vps_os",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "AlmaLinux 8",
++ "AlmaLinux 8 - cPanel",
++ "AlmaLinux 9",
++ "AlmaLinux 9 - cPanel",
++ "CloudLinux 9",
++ "Debian 11",
++ "Debian 12",
++ "Debian 12 - Docker",
++ "Debian 12 - Plesk",
++ "Debian 12 - n8n",
++ "Rocky Linux 8",
++ "Rocky Linux 9",
++ "Ubuntu 22.04",
++ "Ubuntu 24.04",
++ "Windows Server 2016 Standard (Desktop)",
++ "Windows Server 2019 Standard (Desktop)",
++ "Windows Server 2022 Standard (Desktop)",
++ "Windows Server 2025 Standard (Desktop)",
++ "FreeBSD-14.3 - UEFI",
++ "Debian 13",
++ "AlmaLinux 10",
++ "Rocky Linux 10",
++ "Fedora 43",
++ "FreeBSD-15-ufs - UEFI",
++ "FreeBSD-15-zfs - UEFI",
++ "Fedora 44",
++ "Ubuntu 26.04"
++ ]
++ }
++ ],
++ "family": "vps-value-1-2-40-vps-2025-model1",
++ "blobs": null
++ },
++ {
++ "planCode": "vps-2025-model6",
++ "invoiceName": "VPS-6 2026",
++ "product": "vps-2025-model6",
++ "pricingType": "rental",
++ "consumptionConfiguration": null,
++ "pricings": [
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 0,
++ "description": "Monthly fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": null
++ },
++ "price": 8600000000,
++ "formattedPrice": "$86.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 12,
++ "description": "12 months fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 12,
++ "max": 12
++ },
++ "price": 7310000000,
++ "formattedPrice": "$73.10 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": {
++ "defaultEndAction": "REACTIVATE_ENGAGEMENT",
++ "duration": "P12M",
++ "type": "upfront"
++ }
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 6,
++ "description": "6 months fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 6,
++ "max": 6
++ },
++ "price": 8170000000,
++ "formattedPrice": "$81.70 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": {
++ "defaultEndAction": "REACTIVATE_ENGAGEMENT",
++ "duration": "P6M",
++ "type": "upfront"
++ }
++ }
++ ],
++ "configurations": [
++ {
++ "name": "region",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "united_states"
++ ]
++ },
++ {
++ "name": "vps_datacenter",
++ "isCustom": false,
++ "isMandatory": true,
++ "values": [
++ "US-EAST-VA",
++ "US-WEST-OR"
++ ]
++ },
++ {
++ "name": "vps_install_rtm",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "if_available",
++ "no"
++ ]
++ },
++ {
++ "name": "vps_os",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "AlmaLinux 8",
++ "AlmaLinux 8 - cPanel",
++ "AlmaLinux 9",
++ "AlmaLinux 9 - cPanel",
++ "CloudLinux 9",
++ "Debian 11",
++ "Debian 12",
++ "Debian 12 - Docker",
++ "Debian 12 - Plesk",
++ "Rocky Linux 8",
++ "Rocky Linux 9",
++ "Ubuntu 22.04",
++ "Ubuntu 24.04",
++ "Windows Server 2016 Standard (Desktop)",
++ "Windows Server 2019 Standard (Desktop)",
++ "Windows Server 2022 Standard (Desktop)",
++ "Windows Server 2025 Standard (Desktop)",
++ "Debian 12 - n8n",
++ "FreeBSD-14.3 - UEFI",
++ "Debian 13",
++ "AlmaLinux 10",
++ "Rocky Linux 10",
++ "Fedora 43",
++ "FreeBSD-15-ufs - UEFI",
++ "FreeBSD-15-zfs - UEFI",
++ "Fedora 44",
++ "Ubuntu 26.04"
++ ]
++ }
++ ],
++ "family": "vps-2025",
++ "blobs": {
++ "commercial": {
++ "features": [
++ {
++ "name": "Anti-DDoS",
++ "value": "Yes"
++ },
++ {
++ "name": "KVM",
++ "value": "Yes"
++ },
++ {
++ "name": "Root access",
++ "value": "Yes"
++ },
++ {
++ "name": "API",
++ "value": "Yes"
++ },
++ {
++ "name": "IPv4 Address",
++ "value": "1"
++ },
++ {
++ "name": "IPv6 Address",
++ "value": "/128"
++ },
++ {
++ "name": "Monitoring and Interventions",
++ "value": "24/7"
++ },
++ {
++ "name": "SLA",
++ "value": "99.9%"
++ }
++ ],
++ "line": "2025",
++ "range": "2025"
++ },
++ "tags": [
++ "os:distribution-only",
++ "os:distribution-with-application"
++ ]
++ }
++ },
++ {
++ "planCode": "option-storage-remote-2027-ca",
++ "invoiceName": "Remote Storage 2027",
++ "product": "vps-option-storage-remote-resell",
++ "pricingType": "rental",
++ "consumptionConfiguration": null,
++ "pricings": [
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 0,
++ "description": "Monthly fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": null
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "default",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 12,
++ "description": "12 months fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 12,
++ "max": 12
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront12",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": {
++ "defaultEndAction": "REACTIVATE_ENGAGEMENT",
++ "duration": "P12M",
++ "type": "upfront"
++ }
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "upgrade"
++ ],
++ "commitment": 0,
++ "description": "Upgrade fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 0,
++ "capacities": [
++ "installation"
++ ],
++ "commitment": 0,
++ "description": "Installation fees",
++ "interval": 0,
++ "intervalUnit": "none",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 1,
++ "max": 1
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": null
++ },
++ {
++ "phase": 1,
++ "capacities": [
++ "renew"
++ ],
++ "commitment": 6,
++ "description": "6 months fees",
++ "interval": 1,
++ "intervalUnit": "month",
++ "quantity": {
++ "min": 1,
++ "max": 100
++ },
++ "repeat": {
++ "min": 6,
++ "max": 6
++ },
++ "price": 0,
++ "formattedPrice": "$0.00 USD",
++ "tax": 0,
++ "mode": "upfront6",
++ "strategy": "tiered",
++ "mustBeCompleted": false,
++ "type": "rental",
++ "promotions": [],
++ "engagementConfiguration": {
++ "defaultEndAction": "REACTIVATE_ENGAGEMENT",
++ "duration": "P6M",
++ "type": "upfront"
++ }
++ }
++ ],
++ "configurations": [
++ {
++ "name": "region",
++ "isCustom": false,
++ "isMandatory": false,
++ "values": [
++ "canada"
++ ]
++ }
++ ],
++ "family": "option-storage-ca",
++ "blobs": null
++ }
++ ],
++ "products": [
++ {
++ "name": "vps-option-storage-remote-resell",
++ "description": "VPS Linux option",
++ "blobs": {}
++ },
++ {
++ "name": "vps-2020v2-model1",
++ "description": "VPS 4 vCPU 8 GB RAM 75 GB disk",
++ "blobs": {
++ "technical": {
++ "cpu": {
++ "type": "vCore",
++ "brand": " ",
++ "cores": 4,
++ "model": " "
++ },
++ "memory": {
++ "size": 8
++ },
++ "storage": {
++ "disks": [
++ {
++ "capacity": 75,
++ "interface": "NVMe",
++ "technology": "SSD"
++ }
++ ]
++ },
++ "bandwidth": {
++ "level": 500,
++ "unlimited": true
++ },
++ "virtualization": {
++ "hypervisor": "KVM OpenStack"
++ }
++ }
++ }
++ },
++ {
++ "name": "vps-2027-model1",
++ "description": "VPS 2 vCPU 4 GB RAM 40 GB disk",
++ "blobs": {
++ "technical": {
++ "cpu": {
++ "type": "vCore",
++ "brand": " ",
++ "cores": 2,
++ "model": " "
++ },
++ "memory": {
++ "size": 4
++ },
++ "storage": {
++ "disks": [
++ {
++ "capacity": 40,
++ "interface": "NVMe",
++ "technology": "SSD"
++ }
++ ]
++ },
++ "bandwidth": {
++ "level": 500,
++ "unlimited": true
++ },
++ "virtualization": {
++ "hypervisor": "KVM OpenStack"
++ }
++ }
++ }
++ },
++ {
++ "name": "vps-2025-model6",
++ "description": "VPS 24 vCPU 96 GB RAM 400 GB disk",
++ "blobs": {
++ "technical": {
++ "cpu": {
++ "type": "vCore",
++ "brand": " ",
++ "cores": 24,
++ "model": " "
++ },
++ "memory": {
++ "size": 96
++ },
++ "storage": {
++ "disks": [
++ {
++ "capacity": 400,
++ "interface": "NVMe",
++ "technology": "SSD"
++ }
++ ]
++ },
++ "bandwidth": {
++ "level": 3000,
++ "unlimited": true
++ },
++ "virtualization": {
++ "hypervisor": "KVM OpenStack"
++ }
++ }
++ }
++ },
++ {
++ "name": "vps-2027-model1-resell",
++ "description": "VPS 2 vCPU 4 GB RAM 40 GB disk",
++ "blobs": {
++ "technical": {
++ "cpu": {
++ "type": "vCore",
++ "brand": " ",
++ "cores": 2,
++ "model": " "
++ },
++ "memory": {
++ "size": 4
++ },
++ "storage": {
++ "disks": [
++ {
++ "capacity": 40,
++ "interface": "NVMe",
++ "technology": "SSD"
++ }
++ ]
++ },
++ "bandwidth": {
++ "level": 500,
++ "unlimited": true
++ },
++ "virtualization": {
++ "hypervisor": "KVM OpenStack"
++ }
++ }
++ }
++ }
++ ],
++ "addons": [],
++ "planFamilies": []
++}
\\ No newline at end of file
ddiff --git a/test/hosting-sources.test.js b/test/hosting-sources.test.js
iindex 15c2d88..8bc1e8d 100644
---- a/test/hosting-sources.test.js
++++ b/test/hosting-sources.test.js
@@@ -2,7 +2,8 @@
/**
* The hosting seeds: each provider API maps through the api adapter exactly as the registry
* declares it, and the registry accepts category `hosting` (refusing anything else). The fixtures
-- * are the live responses (Vultr /v2/plans, Akamai Linode /v4/linode/types) trimmed to three records.
++ * are the live responses (Vultr /v2/plans, Akamai Linode /v4/linode/types) trimmed to three records,
++ * and OVHcloud's US VPS catalog trimmed to five plans and their products.
*/
const assert = require('assert');
const fs = require('fs');
@@@ -18,7 +19,7 @@ const seed = (key) => seeds.find((s) => s.key === key);
const endpoint = (key) => seed(key).endpoints[0];
t('both hosting seeds declare an api mapping the adapter accepts', () => {
-- for (const key of ['vultr-plans', 'linode-types']) {
++ for (const key of ['vultr-plans', 'linode-types', 'ovhcloud-vps']) {
const s = seed(key);
assert.ok(s, `${key} is seeded`);
assert.strictEqual(s.type, 'api');
@@@ -43,6 +44,22 @@ t('Vultr /v2/plans: one item per plan, identity from id, every extra field kept'
assert.strictEqual(r.items[0].canonical_url, null, 'no url is mapped for Vultr');
});
++t('OVHcloud catalog: only current plan codes, each joined to its product\'s hardware and its monthly no-commitment price', () => {
++ const s = seed('ovhcloud-vps');
++ assert.deepStrictEqual([s.enabled, s.review_required], [false, true], 'seeded disabled, for a person to enable');
++ const ep = endpoint('ovhcloud-vps');
++ const r = api.parse(fx('ovhcloud-vps.json'), { url: ep.url, endpoint: ep });
++ // The -eu region copy, a legacy upgrade code and an option plan are filtered by the plan-code pattern.
++ assert.deepStrictEqual([r.items.map((i) => i.identity), r.meta.filtered, r.skipped], [['vps-2027-model1', 'vps-2025-model6'], 3, 0]);
++ assert.deepStrictEqual(r.items.map((i) => i.title), ['VPS-1 2027', 'VPS-6 2026']);
++ assert.deepStrictEqual(r.items[0].fields, {
++ product: 'vps-2027-model1', currency: 'USD', vcpu: 2, cpu_type: 'vCore', memory_gb: 4, disk_gb: 40, disk_interface: 'NVMe',
++ disk_technology: 'SSD', bandwidth_mbps: 500, bandwidth_unlimited: true, price_monthly: 535000000,
++ });
++ // 8600000000 hundred-millionths: the $86.00 a month plan, not its 12-month commitment price.
++ assert.deepStrictEqual([r.items[1].fields.vcpu, r.items[1].fields.memory_gb, r.items[1].fields.price_monthly], [24, 96, 8600000000]);
++});
++
t('Linode /v4/linode/types: identity id, title label, dotted price.monthly/hourly mapped', () => {
const ep = endpoint('linode-types');
const r = api.parse(fx('linode-types.json'), { url: ep.url, endpoint: ep });
ddiff --git a/test/ingest.test.js b/test/ingest.test.js
iindex c59f2e6..c686937 100644
---- a/test/ingest.test.js
++++ b/test/ingest.test.js
@@@ -159,6 +159,7 @@ t('timeouts and oversize bodies are explicit states', async () => {
'/robots.txt': () => ({ status: 404 }),
'/slow': () => ({ delayMs: 3000, body: FEED_V1 }),
'/huge': () => ({ body: 'x'.repeat(4096) }),
++ '/huge.json': () => ({ body: JSON.stringify(Array.from({ length: 60 }, (_, i) => ({ id: `p${i}`, pad: 'x'.repeat(50) }))) }),
});
try {
const key = await create(sourceDef({ endpoints: [`${s.origin}/slow`] }));
@@@ -171,6 +172,14 @@ t('timeouts and oversize bodies are explicit states', async () => {
await small.registry.create(sourceDef({ key: 'huge', endpoints: [`${s.origin}/huge`] }), 'test');
const r = await small.ingest.run('huge', { trigger: 'manual' });
assert.deepStrictEqual([r.runs[0].state, r.runs[0].error_code], ['http_error', 'too_large']);
++ // An api endpoint's own max_bytes lifts the cap for that endpoint only.
++ const api = (key, extra) => sourceDef({ key, type: 'api', category: 'hosting', endpoints: [{ url: `${s.origin}/huge.json`, items_path: '', fields: { identity: 'id' }, ...extra }] });
++ await small.registry.create(api('capped', {}), 'test');
++ await small.registry.create(api('lifted', { max_bytes: 8192 }), 'test');
++ const capped = await small.ingest.run('capped', { trigger: 'manual' });
++ assert.deepStrictEqual([capped.runs[0].state, capped.runs[0].error_code], ['http_error', 'too_large']);
++ const lifted = await small.ingest.run('lifted', { trigger: 'manual' });
++ assert.deepStrictEqual([lifted.runs[0].state, lifted.runs[0].items_seen], ['ok', 60]);
} finally { await small.stop(); }
} finally { await s.close(); }
});