openvibers/openvibe.sources Commits
Sources: OVHcloud's VPS catalog (ovhcloud-vps), with api selectors, where patterns and max_bytes (contracts 0.180.0) (#51)
Changes
@@@ -125,6 +125,22 @@ An endpoint may also carry `where`, up to 10 `path -> expected` conditions (expenumber or boolean, or an array of up to 20 of them, matching any): a record that fails anycondition 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 -->@@@ -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": [@@@ -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",@@@ -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",@@@ -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"@@@ -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",@@@ -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, Parsconst 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;}@@@ -137,6 +137,8 @@ function createIngest({ db, config, registry, items, robots, fetcher, spacer, emetag: 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 hostbeforeHop: async (next) => {@@@ -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`);@@@ -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' } };nnew file mode 100644@@@ -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@@@ -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 });@@@ -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(); }});
@@ -125,6 +125,22 @@ An endpoint may also carry `where`, up to 10 `path -> expected` conditions (expe
@@ -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
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.
condition is left out before `maxItems` is counted and reported as `meta.filtered`, e.g.
`"where": { "productFamily": "Lightsail Instance", "attributes.operatingSystem": "Linux" }`.
`"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
**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
`extract: { kind, require, fields }` ([server/adapters/extract.js](server/adapters/extract.js)). Each field is read from
@@ -368,7 +384,7 @@ vhost serves it.
@@ -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).
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.163.0
+- openvibe-contracts: v0.180.0
- openvibe-sdk: v0.44.0
- openvibe-sdk: v0.44.0
- openvibe-shared: v4.1.7
- openvibe-shared: v4.1.7
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, fast-xml-parser",
"port": 4720,
"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\")",
"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",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.7",
"shared": "openvibe-shared v4.1.7",
"capabilities": [
"capabilities": [
@@ -113,15 +113,35 @@
@@ -113,15 +113,35 @@
"boolean"
"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": {
"item_kind": {
"type": "string",
"type": "string",
"pattern": "^[a-z][a-z0-9_]{1,39}$"
"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": {
"fields": {
"type": "object",
"type": "object",
"additionalProperties": {
"additionalProperties": {
@@ -133,7 +153,7 @@
@@ -133,7 +153,7 @@
"additionalProperties": {
"additionalProperties": {
"type": "string"
"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": {
"extract": {
"type": "object",
"type": "object",
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -769,9 +769,9 @@
@@ -769,9 +769,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1055,6 +1055,54 @@
@@ -1055,6 +1055,54 @@
"sensitivity": "none",
"sensitivity": "none",
"default_indexability": "noindex"
"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",
"key": "lowendtalk-offers",
"name": "LowEndTalk: the Offers board",
"name": "LowEndTalk: the Offers board",
@@ -32,6 +32,22 @@
@@ -32,6 +32,22 @@
*
*
* "where": { "productFamily": "Lightsail Instance", "attributes.operatingSystem": "Linux" }
* "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
* 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.
* `{ "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
* `"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
@@ -45,13 +61,44 @@ const { parseXml, toText, toIsoDate, epochSecondsToIso, canonicalUrl, toId, Pars
const PARSER_VERSION = 'api@1';
const PARSER_VERSION = 'api@1';
// A segment is `*` (the single child of an object), `{$key}` (the map key, only inside `$root.`),
// 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.
// 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})*$`);
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;
let cur = obj;
- for (const seg of path.split('.')) {
+ for (const seg of splitPath(path)) {
if (cur == null || typeof cur !== 'object') return undefined;
if (cur == null || typeof cur !== 'object') return undefined;
if (seg === '*') {
if (seg === '*') {
if (Array.isArray(cur)) return undefined;
if (Array.isArray(cur)) return undefined;
@@ -62,7 +109,16 @@ function walk(obj, path, key) {
@@ -62,7 +109,16 @@ function walk(obj, path, key) {
if (key === undefined) return undefined;
if (key === undefined) return undefined;
cur = cur[key];
cur = cur[key];
} else {
} 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;
return cur;
@@ -72,8 +128,8 @@ function walk(obj, path, key) {
@@ -72,8 +128,8 @@ function walk(obj, path, key) {
function getField(body, rec, key, p) {
function getField(body, rec, key, p) {
if (!p) return rec;
if (!p) return rec;
if (p === '$key') return key;
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) {
function at(obj, path) {
@@ -89,10 +145,22 @@ function scalar(v) {
@@ -89,10 +145,22 @@ function scalar(v) {
return null;
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) {
function matchesWhere(body, rec, key, where) {
for (const [p, expected] of Object.entries(where)) {
for (const [p, expected] of Object.entries(where)) {
const actual = scalar(getField(body, rec, key, p));
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];
const wanted = Array.isArray(expected) ? expected : [expected];
if (!wanted.some((w) => w === actual)) return false;
if (!wanted.some((w) => w === actual)) return false;
}
}
@@ -104,7 +172,7 @@ function matchesWhere(body, rec, key, where) {
@@ -104,7 +172,7 @@ function matchesWhere(body, rec, key, where) {
* segment `{$key}` inside a `$root.` path. `$root` must be followed by a path.
* segment `{$key}` inside a `$root.` path. `$root` must be followed by a path.
*/
*/
function keyPathViolation(p, shape) {
function keyPathViolation(p, shape) {
- const segs = p.split('.');
+ const segs = splitPath(p);
const hasBare = segs.includes('$key');
const hasBare = segs.includes('$key');
const hasBrace = segs.includes('{$key}');
const hasBrace = segs.includes('{$key}');
if (p === '$root' || p.startsWith('$root.')) {
if (p === '$root' || p.startsWith('$root.')) {
@@ -147,6 +215,11 @@ function checkMapping(ep) {
@@ -147,6 +215,11 @@ function checkMapping(ep) {
if (!PATH_RE.test(p)) return `where.${p} path is malformed`;
if (!PATH_RE.test(p)) return `where.${p} path is malformed`;
const kerr = keyPathViolation(p, shape);
const kerr = keyPathViolation(p, shape);
if (kerr) return `where.${p}: ${kerr}`;
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];
const wanted = Array.isArray(expected) ? expected : [expected];
if (wanted.length > 20) return `where.${p} has at most 20 expected values`;
if (wanted.length > 20) return `where.${p} has at most 20 expected values`;
for (const v of wanted) {
for (const v of wanted) {
@@ -154,6 +227,7 @@ function checkMapping(ep) {
@@ -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.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;
return null;
}
}
@@ -137,6 +137,8 @@ function createIngest({ db, config, registry, items, robots, fetcher, spacer, em
@@ -137,6 +137,8 @@ function createIngest({ db, config, registry, items, robots, fetcher, spacer, em
etag: validators.etag || null,
etag: validators.etag || null,
lastModified: validators.last_modified || null,
lastModified: validators.last_modified || null,
sensitiveHeaders: sensitive,
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
// 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
// host, spaced like any other request to that host
beforeHop: async (next) => {
beforeHop: async (next) => {
@@ -77,6 +77,7 @@ function validateSource(input, { maxItemsCap = 500 } = {}) {
@@ -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 || {} });
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.
// `where` is only copied when given, so records without conditions stay byte-identical.
if (e.where) out.where = e.where;
if (e.where) out.where = e.where;
+ if (e.max_bytes !== undefined) out.max_bytes = e.max_bytes;
} else {
} else {
// A feed may read structured facts out of its items' prose (adapters/extract.js).
// 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`);
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`);
@@ -118,6 +118,77 @@ t('checkMapping validates the new syntax and rejects malformed variants', () =>
@@ -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/);
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', () => {
t('the registry stores `where` only when an endpoint gives it', () => {
const { validateSource } = require('../server/registry');
const { validateSource } = require('../server/registry');
const endpoint = { url: 'https://x.example/', items_path: 'products', items_shape: 'map', fields: { identity: '$key' } };
const endpoint = { url: 'https://x.example/', items_path: 'products', items_shape: 'map', fields: { identity: '$key' } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,1840 @@
@@ -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
\ No newline at end of file
@@ -2,7 +2,8 @@
@@ -2,7 +2,8 @@
/**
/**
* The hosting seeds: each provider API maps through the api adapter exactly as the registry
* 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
* 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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -18,7 +19,7 @@ const seed = (key) => seeds.find((s) => s.key === key);
@@ -18,7 +19,7 @@ const seed = (key) => seeds.find((s) => s.key === key);
const endpoint = (key) => seed(key).endpoints[0];
const endpoint = (key) => seed(key).endpoints[0];
t('both hosting seeds declare an api mapping the adapter accepts', () => {
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);
const s = seed(key);
assert.ok(s, `${key} is seeded`);
assert.ok(s, `${key} is seeded`);
assert.strictEqual(s.type, 'api');
assert.strictEqual(s.type, 'api');
@@ -43,6 +44,22 @@ t('Vultr /v2/plans: one item per plan, identity from id, every extra field kept'
@@ -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');
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', () => {
t('Linode /v4/linode/types: identity id, title label, dotted price.monthly/hourly mapped', () => {
const ep = endpoint('linode-types');
const ep = endpoint('linode-types');
const r = api.parse(fx('linode-types.json'), { url: ep.url, endpoint: ep });
const r = api.parse(fx('linode-types.json'), { url: ep.url, endpoint: ep });
@@ -159,6 +159,7 @@ t('timeouts and oversize bodies are explicit states', async () => {
@@ -159,6 +159,7 @@ t('timeouts and oversize bodies are explicit states', async () => {
'/robots.txt': () => ({ status: 404 }),
'/robots.txt': () => ({ status: 404 }),
'/slow': () => ({ delayMs: 3000, body: FEED_V1 }),
'/slow': () => ({ delayMs: 3000, body: FEED_V1 }),
'/huge': () => ({ body: 'x'.repeat(4096) }),
'/huge': () => ({ body: 'x'.repeat(4096) }),
+ '/huge.json': () => ({ body: JSON.stringify(Array.from({ length: 60 }, (_, i) => ({ id: `p${i}`, pad: 'x'.repeat(50) }))) }),
});
});
try {
try {
const key = await create(sourceDef({ endpoints: [`${s.origin}/slow`] }));
const key = await create(sourceDef({ endpoints: [`${s.origin}/slow`] }));
@@ -171,6 +172,14 @@ t('timeouts and oversize bodies are explicit states', async () => {
@@ -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');
await small.registry.create(sourceDef({ key: 'huge', endpoints: [`${s.origin}/huge`] }), 'test');
const r = await small.ingest.run('huge', { trigger: 'manual' });
const r = await small.ingest.run('huge', { trigger: 'manual' });
assert.deepStrictEqual([r.runs[0].state, r.runs[0].error_code], ['http_error', 'too_large']);
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 small.stop(); }
} finally { await s.close(); }
} finally { await s.close(); }
});
});