openvibers/openvibe.host Commits
A plan states its term and what it renews at; every link says what it earns us (#246)
Changes
nnew file mode 100644@@@ -0,0 +1,11 @@++-- phase: expand++-- The term a catalog plan's price is quoted for, and the price it renews at when the source states one.++--++-- Every sourced plan's stored price is a monthly price (server/catalog/mapping.js reads Linode's price.monthly,++-- Vultr's monthly_cost, Lightsail's hourly rate for its 744-hour month, Azure's hourly rate for 730 hours, and++-- OVHcloud's commitment=0 one-month price), so its term is known: monthly. A staff entry states its own term, or++-- leaves it null and the page says "term not published". No source states a renewal price for a server plan, so++-- renewal_price stays null until one does and the page says "renewal price not published".++ALTER TABLE catalog_plans ADD COLUMN price_term text COLLATE "C" CHECK (price_term IN ('monthly', '1-year', '3-year'));++ALTER TABLE catalog_plans ADD COLUMN renewal_price numeric(12,4) CHECK (renewal_price >= 0);++UPDATE catalog_plans SET price_term = 'monthly' WHERE origin = 'sources' AND price_term IS NULL;@@@ -36,6 +36,8 @@.hc-kind { display: block; font-size: 12px; font-weight: 400; color: var(--hs-muted); }.hc-price { font-weight: 700; font-size: 15px; color: var(--hs-fg); }.hc-per { color: var(--hs-muted); font-size: 12px; margin-left: 1px; }++/* The term a price is quoted for, when it is not month to month (market outcome 1). */++.hc-term { display: block; color: var(--hs-muted); font-size: 11.5px; font-weight: 600; line-height: 1.3; }.hc-dim { color: var(--hs-muted); }.hc-unknown { color: var(--hs-muted); font-style: italic; font-size: 13px; }.hc-checked { white-space: nowrap; color: var(--hs-muted); font-variant-numeric: tabular-nums; }@@@ -115,6 +115,10 @@ function mapItem(item) {const common = {provider, external_id: id, currency: 'USD', source_key: item.source_key, source_item_id: item.id,source_url: provider.source_url, checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),++ // Every price the mappers below read is the source's monthly price (Lightsail's and Azure's hourly rates++ // become a month's worth, OVHcloud is its commitment=0 one-month price), so the row's term is monthly. No++ // source states a renewal price for a server plan, so it stays unknown rather than guessed.++ price_term: 'monthly', renewal_price: null,};if (item.source_key === 'aws-lightsail-bundles') return { ...common, ...lightsail(x, id) };if (item.source_key === 'azure-retail-prices') {@@@ -20,7 +20,7 @@ const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', mconst missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };const fields = {providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'],-- plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'setup_fee', 'source_url', 'checked_at', 'status'],++ plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'setup_fee', 'source_url', 'checked_at', 'status'],promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'],};const kinds = new Set(['cloud', 'vps', 'dedicated', 'storage', 'domain']);@@@ -30,7 +30,7 @@ const clean = (type, input, current, now) => {if (!input || typeof input !== 'object' || Array.isArray(input)) invalid('Body must be an object');for (const k of Object.keys(input)) if (!fields[type].includes(k)) invalid(`Unknown field: ${k}`);const v = { ...(current || {}), ...input };-- const strings = type === 'providers' ? ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'disk_type', 'currency', 'source_url', 'status'] : ['provider_id', 'title', 'details', 'code', 'url', 'status'];++ const strings = type === 'providers' ? ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'disk_type', 'currency', 'price_term', 'source_url', 'status'] : ['provider_id', 'title', 'details', 'code', 'url', 'status'];for (const k of strings) if (v[k] != null && (typeof v[k] !== 'string' || v[k].length > 2048 || (v[k] === '' && !['description', 'affiliate_note', 'details', 'code'].includes(k)))) invalid(`Invalid ${k}`);const required = type === 'providers' ? ['slug', 'name', 'homepage_url'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'currency', 'price_monthly', 'source_url'] : ['provider_id', 'title', 'url'];for (const k of required) if (v[k] == null || v[k] === '') invalid(`${k} is required`);@@@ -38,15 +38,16 @@ const clean = (type, input, current, now) => {if (type === 'providers' && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v.slug)) invalid('Invalid slug');if (type === 'plans') {if (!kinds.has(v.kind) || !currencies.has(v.currency)) invalid('Invalid kind or currency');-- for (const k of ['vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'setup_fee']) {++ for (const k of ['vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'renewal_price', 'setup_fee']) {if (input[k] != null && (typeof input[k] !== 'number' || !Number.isFinite(input[k]) || input[k] < 0)) invalid(`${k} must be a non-negative number`);}++ if (v.price_term != null && !['monthly', '1-year', '3-year'].includes(v.price_term)) invalid('Invalid price term');if (v.ipv4 != null && !Number.isInteger(v.ipv4)) invalid('ipv4 must be an integer');if (v.disk_type != null && !['ssd', 'nvme', 'hdd'].includes(v.disk_type)) invalid('Invalid disk type');if (v.unmetered != null && typeof v.unmetered !== 'boolean') invalid('unmetered must be boolean');if (v.locations != null && (!Array.isArray(v.locations) || v.locations.some((x) => typeof x !== 'string' || !x))) invalid('Invalid locations');if (v.checked_at == null || (current && input.price_monthly !== undefined && input.checked_at === undefined)) v.checked_at = now;-- if (v.price_monthly >= 100000000 || (v.price_hourly != null && v.price_hourly >= 1000000) || (v.setup_fee != null && v.setup_fee >= 100000000)) invalid('Price is outside catalog range');++ if (v.price_monthly >= 100000000 || (v.price_hourly != null && v.price_hourly >= 1000000) || (v.renewal_price != null && v.renewal_price >= 100000000) || (v.setup_fee != null && v.setup_fee >= 100000000)) invalid('Price is outside catalog range');if (!Number.isSafeInteger(v.checked_at) || v.checked_at < 0) invalid('Invalid checked_at');if (v.status != null && !['active', 'gone', 'hidden'].includes(v.status)) invalid('Invalid status');}@@@ -137,7 +138,7 @@ function createCatalog({ store, community = null, log = console, indexnow = nullfresh.push(`/catalog/providers/${encodeURIComponent(v.provider.slug)}`);}const old = await t.prepare('SELECT * FROM catalog_plans WHERE provider_id = ? AND external_id = ?').get(p.id, v.external_id);-- const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'source_key', 'source_item_id', 'source_url', 'checked_at'];++ const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'source_key', 'source_item_id', 'source_url', 'checked_at'];if (old) {if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';if (Number(old.price_monthly) !== v.price_monthly || old.currency !== v.currency) {@@@ -159,7 +159,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, conficonst csrf = csrfToken(config, req.viewer);const blocks = await Promise.all(['providers', 'plans', 'promos'].map(async (type) => {const rows = await catalog.all(type);-- const keys = { providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'], plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'setup_fee', 'source_url', 'checked_at', 'status'], promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'] }[type];++ const keys = { providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'], plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'setup_fee', 'source_url', 'checked_at', 'status'], promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'] }[type];const form = (row) => '<form method="post" action="/catalog/manage/' + type + (row ? '/' + esc(row.id) : '') + '"><input type="hidden" name="csrf" value="' + csrf + '">' + keys.map((k) => '<label>' + esc(k) + ' <input name="' + k + '" value="' + esc(row && row[k] != null ? Array.isArray(row[k]) ? row[k].join(',') : row[k] : '') + '"></label>').join('') + '<button>Save</button></form>';return '<section><h2>' + type + '</h2>' + form(null) + rows.map((r) => '<h3>' + esc(r.name || r.title || r.id) + '</h3>' + form(r)).join('') + '</section>';}));@@@ -174,7 +174,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, conficonst body = { ...req.body }; delete body.csrf;for (const k of Object.keys(body)) {if (body[k] === '') { delete body[k]; continue; }-- if (['provider_id', 'vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'setup_fee', 'checked_at', 'starts_at', 'ends_at'].includes(k)) body[k] = Number(body[k]);++ if (['provider_id', 'vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'renewal_price', 'setup_fee', 'checked_at', 'starts_at', 'ends_at'].includes(k)) body[k] = Number(body[k]);if (['unmetered', 'affiliate'].includes(k)) body[k] = body[k] === 'true';if (k === 'locations') body[k] = body[k].split(',').map((x) => x.trim()).filter(Boolean);}@@@ -61,7 +61,24 @@ function visit(p, label = 'Visit provider') {if (p.affiliate_url) {return `<span class="hc-visit">${out(p.affiliate_url, `${esc(label)} <span class="hc-chip">Affiliate</span>`, { affiliate: true, cls: 'button' })}${p.affiliate_note ? `<small class="hc-note">${esc(p.affiliate_note)}</small>` : ''}</span>`;}-- return p.homepage_url ? `<span class="hc-visit">${out(p.homepage_url, esc(label), { cls: 'button hc-button-quiet' })}</span>` : '';++ // What a link earns us, said on every link (market outcome 7): an affiliate link is marked above; this one earns nothing.++ return p.homepage_url ? `<span class="hc-visit">${out(p.homepage_url, esc(label), { cls: 'button hc-button-quiet' })}<small class="hc-note">No commission: we earn nothing from this link.</small></span>` : '';++}++++/**++ * The term a price is quoted for, and what it renews at (market outcomes 1 and 2, migration 0014). A sourced plan's++ * price is the provider's own monthly list price; a staff entry states its term or leaves it unknown, and an unknown++ * renewal price is said to be unknown, never assumed.++ */++const TERMS = { monthly: 'Month to month', '1-year': '1-year term', '3-year': '3-year term' };++function termLines(p) {++ if (isStorageRate(p)) return '';++ const term = TERMS[p.price_term];++ if (!term) return '<p class="hc-dim">Term not published.</p>';++ const renews = p.renewal_price != null && Number.isFinite(Number(p.renewal_price))++ ? `Renews at ${esc(money(Number(p.renewal_price), p.currency))} a month.`++ : p.price_term === 'monthly' ? 'At the provider\'s list price.' : 'Renewal price not published.';++ return `<p class="hc-dim">${esc(term)}. ${renews}</p>`;}function priceCell(p) {@@@ -69,7 +86,8 @@ function priceCell(p) {// A per-GB rate is not a monthly price: it is shown as the rate per GB it is (server/catalog/pricing.js).if (isStorageRate(p)) return `<span class="hc-price">${Number.isFinite(price) ? esc(money(price, p.currency, 4)) : 'price not stated'}</span><span class="hc-per">/GB/month</span>`;const m = Number.isFinite(price) && price > 0 ? money(price, p.currency) : null;-- return `<span class="hc-price">${m ? esc(m) : 'price not stated'}</span><span class="hc-per">/mo</span>`;++ const term = p.price_term === 'monthly' ? '' : TERMS[p.price_term] ? `<span class="hc-term">${esc(TERMS[p.price_term])}</span>` : '<span class="hc-term">term not published</span>';++ return `<span class="hc-price">${m ? esc(m) : 'price not stated'}</span><span class="hc-per">/mo</span>${term}`;}const isoAt = (v) => new Date(v).toISOString();@@@ -312,6 +330,7 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '', n${isStorageRate(p) ? '<p class="hc-dim">A storage rate: the price per GB a month, not a monthly server price.</p>' : ''}${p.price_hourly != null ? `<p class="hc-dim">${esc(money(p.price_hourly, p.currency, 4))} an hour</p>` : ''}${p.setup_fee != null && Number(p.setup_fee) > 0 ? `<p class="hc-dim">Setup fee ${esc(money(p.setup_fee, p.currency))}</p>` : ''}++${termLines(p)}<p class="hc-checked-line">Checked <strong>${esc(day(p.checked_at) || 'unknown')}</strong>${staleMark(p, now)} · ${out(p.source_url, 'source')}</p>${isStale(p.checked_at, now) ? '<p class="hc-dim">This price was last checked over a year ago, so it is not counted as a cheapest plan.</p>' : ''}${visit(p)}@@@ -21,6 +21,25 @@ const { boot, check, done } = require('./helpers/boot');const bad = await t.api('POST', '/api/v1/catalog/plans', { as: staff, json: { provider_id: provider.id, name: 'No source', external_id: 'bad', kind: 'vps', currency: 'USD', price_monthly: 1 } }); assert.strictEqual(bad.status, 422); assert.strictEqual(bad.json().code, 'host.catalog.invalid');const unknown = await t.api('PATCH', '/api/v1/catalog/promos/hpr_00000000000000000000000000', { as: staff, json: { title: 'x' } }); assert.strictEqual(unknown.status, 404); assert.strictEqual(unknown.json().code, 'host.catalog.not_found');});++ await check('a plan states its term and renewal, and every link what it earns us (market outcomes 1, 2, 7)', async () => {++ // A staff plan with no term: the page says so rather than guessing.++ let page = await t.api('GET', `/catalog/plans/${plan.id}`);++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));++ assert.match(page.text, /Term not published\./);++ assert.match(page.text, /No commission: we earn nothing from this link\./, 'a homepage link says it earns nothing');++ // A 1-year term with no renewal price stated, then one with it.++ let r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: '1-year' } }); assert.strictEqual(r.status, 200, r.text);++ page = await t.api('GET', `/catalog/plans/${plan.id}`);++ assert.match(page.text, /1-year term\. Renewal price not published\./);++ r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { renewal_price: 9.5 } }); assert.strictEqual(r.status, 200, r.text);++ page = await t.api('GET', `/catalog/plans/${plan.id}`);++ assert.match(page.text, /1-year term\. Renews at \$9\.50 a month\./);++ // A term that is not one of the three is refused.++ r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: 'weekly' } }); assert.strictEqual(r.status, 422, r.text);++ r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: 'monthly', renewal_price: null } }); assert.strictEqual(r.status, 200, r.text);++ page = await t.api('GET', `/catalog/plans/${plan.id}`);++ assert.match(page.text, /Month to month\. At the provider's list price\.|Month to month\. At the provider's list price\./);++ });await check('staff manage form is server rendered and CSRF protected', async () => {const r = await t.api('GET', '/catalog/manage', { session: staff }); assert.strictEqual(r.status, 200); assert.match(r.text, /<form method="post"/); assert.match(r.text, /name="csrf"/);const denied = await t.api('POST', '/catalog/manage/providers', { session: staff, form: { slug: 'injected', name: 'Injected', homepage_url: 'https://example.test' } }); assert.strictEqual(denied.status, 403);
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+-- phase: expand
+-- The term a catalog plan's price is quoted for, and the price it renews at when the source states one.
+--
+-- Every sourced plan's stored price is a monthly price (server/catalog/mapping.js reads Linode's price.monthly,
+-- Vultr's monthly_cost, Lightsail's hourly rate for its 744-hour month, Azure's hourly rate for 730 hours, and
+-- OVHcloud's commitment=0 one-month price), so its term is known: monthly. A staff entry states its own term, or
+-- leaves it null and the page says "term not published". No source states a renewal price for a server plan, so
+-- renewal_price stays null until one does and the page says "renewal price not published".
+ALTER TABLE catalog_plans ADD COLUMN price_term text COLLATE "C" CHECK (price_term IN ('monthly', '1-year', '3-year'));
+ALTER TABLE catalog_plans ADD COLUMN renewal_price numeric(12,4) CHECK (renewal_price >= 0);
+UPDATE catalog_plans SET price_term = 'monthly' WHERE origin = 'sources' AND price_term IS NULL;
@@ -36,6 +36,8 @@
@@ -36,6 +36,8 @@
.hc-kind { display: block; font-size: 12px; font-weight: 400; color: var(--hs-muted); }
.hc-kind { display: block; font-size: 12px; font-weight: 400; color: var(--hs-muted); }
.hc-price { font-weight: 700; font-size: 15px; color: var(--hs-fg); }
.hc-price { font-weight: 700; font-size: 15px; color: var(--hs-fg); }
.hc-per { color: var(--hs-muted); font-size: 12px; margin-left: 1px; }
.hc-per { color: var(--hs-muted); font-size: 12px; margin-left: 1px; }
+/* The term a price is quoted for, when it is not month to month (market outcome 1). */
+.hc-term { display: block; color: var(--hs-muted); font-size: 11.5px; font-weight: 600; line-height: 1.3; }
.hc-dim { color: var(--hs-muted); }
.hc-dim { color: var(--hs-muted); }
.hc-unknown { color: var(--hs-muted); font-style: italic; font-size: 13px; }
.hc-unknown { color: var(--hs-muted); font-style: italic; font-size: 13px; }
.hc-checked { white-space: nowrap; color: var(--hs-muted); font-variant-numeric: tabular-nums; }
.hc-checked { white-space: nowrap; color: var(--hs-muted); font-variant-numeric: tabular-nums; }
@@ -115,6 +115,10 @@ function mapItem(item) {
@@ -115,6 +115,10 @@ function mapItem(item) {
const common = {
const common = {
provider, external_id: id, currency: 'USD', source_key: item.source_key, source_item_id: item.id,
provider, external_id: id, currency: 'USD', source_key: item.source_key, source_item_id: item.id,
source_url: provider.source_url, checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),
source_url: provider.source_url, checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),
+ // Every price the mappers below read is the source's monthly price (Lightsail's and Azure's hourly rates
+ // become a month's worth, OVHcloud is its commitment=0 one-month price), so the row's term is monthly. No
+ // source states a renewal price for a server plan, so it stays unknown rather than guessed.
+ price_term: 'monthly', renewal_price: null,
};
};
if (item.source_key === 'aws-lightsail-bundles') return { ...common, ...lightsail(x, id) };
if (item.source_key === 'aws-lightsail-bundles') return { ...common, ...lightsail(x, id) };
if (item.source_key === 'azure-retail-prices') {
if (item.source_key === 'azure-retail-prices') {
@@ -20,7 +20,7 @@ const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', m
@@ -20,7 +20,7 @@ const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', m
const missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };
const missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };
const fields = {
const fields = {
providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'],
providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'],
- plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'setup_fee', 'source_url', 'checked_at', 'status'],
+ plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'setup_fee', 'source_url', 'checked_at', 'status'],
promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'],
promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'],
};
};
const kinds = new Set(['cloud', 'vps', 'dedicated', 'storage', 'domain']);
const kinds = new Set(['cloud', 'vps', 'dedicated', 'storage', 'domain']);
@@ -30,7 +30,7 @@ const clean = (type, input, current, now) => {
@@ -30,7 +30,7 @@ const clean = (type, input, current, now) => {
if (!input || typeof input !== 'object' || Array.isArray(input)) invalid('Body must be an object');
if (!input || typeof input !== 'object' || Array.isArray(input)) invalid('Body must be an object');
for (const k of Object.keys(input)) if (!fields[type].includes(k)) invalid(`Unknown field: ${k}`);
for (const k of Object.keys(input)) if (!fields[type].includes(k)) invalid(`Unknown field: ${k}`);
const v = { ...(current || {}), ...input };
const v = { ...(current || {}), ...input };
- const strings = type === 'providers' ? ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'disk_type', 'currency', 'source_url', 'status'] : ['provider_id', 'title', 'details', 'code', 'url', 'status'];
+ const strings = type === 'providers' ? ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'disk_type', 'currency', 'price_term', 'source_url', 'status'] : ['provider_id', 'title', 'details', 'code', 'url', 'status'];
for (const k of strings) if (v[k] != null && (typeof v[k] !== 'string' || v[k].length > 2048 || (v[k] === '' && !['description', 'affiliate_note', 'details', 'code'].includes(k)))) invalid(`Invalid ${k}`);
for (const k of strings) if (v[k] != null && (typeof v[k] !== 'string' || v[k].length > 2048 || (v[k] === '' && !['description', 'affiliate_note', 'details', 'code'].includes(k)))) invalid(`Invalid ${k}`);
const required = type === 'providers' ? ['slug', 'name', 'homepage_url'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'currency', 'price_monthly', 'source_url'] : ['provider_id', 'title', 'url'];
const required = type === 'providers' ? ['slug', 'name', 'homepage_url'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'currency', 'price_monthly', 'source_url'] : ['provider_id', 'title', 'url'];
for (const k of required) if (v[k] == null || v[k] === '') invalid(`${k} is required`);
for (const k of required) if (v[k] == null || v[k] === '') invalid(`${k} is required`);
@@ -38,15 +38,16 @@ const clean = (type, input, current, now) => {
@@ -38,15 +38,16 @@ const clean = (type, input, current, now) => {
if (type === 'providers' && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v.slug)) invalid('Invalid slug');
if (type === 'providers' && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v.slug)) invalid('Invalid slug');
if (type === 'plans') {
if (type === 'plans') {
if (!kinds.has(v.kind) || !currencies.has(v.currency)) invalid('Invalid kind or currency');
if (!kinds.has(v.kind) || !currencies.has(v.currency)) invalid('Invalid kind or currency');
- for (const k of ['vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'setup_fee']) {
+ for (const k of ['vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'renewal_price', 'setup_fee']) {
if (input[k] != null && (typeof input[k] !== 'number' || !Number.isFinite(input[k]) || input[k] < 0)) invalid(`${k} must be a non-negative number`);
if (input[k] != null && (typeof input[k] !== 'number' || !Number.isFinite(input[k]) || input[k] < 0)) invalid(`${k} must be a non-negative number`);
}
}
+ if (v.price_term != null && !['monthly', '1-year', '3-year'].includes(v.price_term)) invalid('Invalid price term');
if (v.ipv4 != null && !Number.isInteger(v.ipv4)) invalid('ipv4 must be an integer');
if (v.ipv4 != null && !Number.isInteger(v.ipv4)) invalid('ipv4 must be an integer');
if (v.disk_type != null && !['ssd', 'nvme', 'hdd'].includes(v.disk_type)) invalid('Invalid disk type');
if (v.disk_type != null && !['ssd', 'nvme', 'hdd'].includes(v.disk_type)) invalid('Invalid disk type');
if (v.unmetered != null && typeof v.unmetered !== 'boolean') invalid('unmetered must be boolean');
if (v.unmetered != null && typeof v.unmetered !== 'boolean') invalid('unmetered must be boolean');
if (v.locations != null && (!Array.isArray(v.locations) || v.locations.some((x) => typeof x !== 'string' || !x))) invalid('Invalid locations');
if (v.locations != null && (!Array.isArray(v.locations) || v.locations.some((x) => typeof x !== 'string' || !x))) invalid('Invalid locations');
if (v.checked_at == null || (current && input.price_monthly !== undefined && input.checked_at === undefined)) v.checked_at = now;
if (v.checked_at == null || (current && input.price_monthly !== undefined && input.checked_at === undefined)) v.checked_at = now;
- if (v.price_monthly >= 100000000 || (v.price_hourly != null && v.price_hourly >= 1000000) || (v.setup_fee != null && v.setup_fee >= 100000000)) invalid('Price is outside catalog range');
+ if (v.price_monthly >= 100000000 || (v.price_hourly != null && v.price_hourly >= 1000000) || (v.renewal_price != null && v.renewal_price >= 100000000) || (v.setup_fee != null && v.setup_fee >= 100000000)) invalid('Price is outside catalog range');
if (!Number.isSafeInteger(v.checked_at) || v.checked_at < 0) invalid('Invalid checked_at');
if (!Number.isSafeInteger(v.checked_at) || v.checked_at < 0) invalid('Invalid checked_at');
if (v.status != null && !['active', 'gone', 'hidden'].includes(v.status)) invalid('Invalid status');
if (v.status != null && !['active', 'gone', 'hidden'].includes(v.status)) invalid('Invalid status');
}
}
@@ -137,7 +138,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
@@ -137,7 +138,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
fresh.push(`/catalog/providers/${encodeURIComponent(v.provider.slug)}`);
fresh.push(`/catalog/providers/${encodeURIComponent(v.provider.slug)}`);
}
}
const old = await t.prepare('SELECT * FROM catalog_plans WHERE provider_id = ? AND external_id = ?').get(p.id, v.external_id);
const old = await t.prepare('SELECT * FROM catalog_plans WHERE provider_id = ? AND external_id = ?').get(p.id, v.external_id);
- const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
+ const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
if (old) {
if (old) {
if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
if (Number(old.price_monthly) !== v.price_monthly || old.currency !== v.currency) {
if (Number(old.price_monthly) !== v.price_monthly || old.currency !== v.currency) {
@@ -159,7 +159,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
@@ -159,7 +159,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
const csrf = csrfToken(config, req.viewer);
const csrf = csrfToken(config, req.viewer);
const blocks = await Promise.all(['providers', 'plans', 'promos'].map(async (type) => {
const blocks = await Promise.all(['providers', 'plans', 'promos'].map(async (type) => {
const rows = await catalog.all(type);
const rows = await catalog.all(type);
- const keys = { providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'], plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'setup_fee', 'source_url', 'checked_at', 'status'], promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'] }[type];
+ const keys = { providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'], plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'setup_fee', 'source_url', 'checked_at', 'status'], promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'] }[type];
const form = (row) => '<form method="post" action="/catalog/manage/' + type + (row ? '/' + esc(row.id) : '') + '"><input type="hidden" name="csrf" value="' + csrf + '">' + keys.map((k) => '<label>' + esc(k) + ' <input name="' + k + '" value="' + esc(row && row[k] != null ? Array.isArray(row[k]) ? row[k].join(',') : row[k] : '') + '"></label>').join('') + '<button>Save</button></form>';
const form = (row) => '<form method="post" action="/catalog/manage/' + type + (row ? '/' + esc(row.id) : '') + '"><input type="hidden" name="csrf" value="' + csrf + '">' + keys.map((k) => '<label>' + esc(k) + ' <input name="' + k + '" value="' + esc(row && row[k] != null ? Array.isArray(row[k]) ? row[k].join(',') : row[k] : '') + '"></label>').join('') + '<button>Save</button></form>';
return '<section><h2>' + type + '</h2>' + form(null) + rows.map((r) => '<h3>' + esc(r.name || r.title || r.id) + '</h3>' + form(r)).join('') + '</section>';
return '<section><h2>' + type + '</h2>' + form(null) + rows.map((r) => '<h3>' + esc(r.name || r.title || r.id) + '</h3>' + form(r)).join('') + '</section>';
}));
}));
@@ -174,7 +174,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
@@ -174,7 +174,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
const body = { ...req.body }; delete body.csrf;
const body = { ...req.body }; delete body.csrf;
for (const k of Object.keys(body)) {
for (const k of Object.keys(body)) {
if (body[k] === '') { delete body[k]; continue; }
if (body[k] === '') { delete body[k]; continue; }
- if (['provider_id', 'vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'setup_fee', 'checked_at', 'starts_at', 'ends_at'].includes(k)) body[k] = Number(body[k]);
+ if (['provider_id', 'vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'renewal_price', 'setup_fee', 'checked_at', 'starts_at', 'ends_at'].includes(k)) body[k] = Number(body[k]);
if (['unmetered', 'affiliate'].includes(k)) body[k] = body[k] === 'true';
if (['unmetered', 'affiliate'].includes(k)) body[k] = body[k] === 'true';
if (k === 'locations') body[k] = body[k].split(',').map((x) => x.trim()).filter(Boolean);
if (k === 'locations') body[k] = body[k].split(',').map((x) => x.trim()).filter(Boolean);
}
}
@@ -61,7 +61,24 @@ function visit(p, label = 'Visit provider') {
@@ -61,7 +61,24 @@ function visit(p, label = 'Visit provider') {
if (p.affiliate_url) {
if (p.affiliate_url) {
return `<span class="hc-visit">${out(p.affiliate_url, `${esc(label)} <span class="hc-chip">Affiliate</span>`, { affiliate: true, cls: 'button' })}${p.affiliate_note ? `<small class="hc-note">${esc(p.affiliate_note)}</small>` : ''}</span>`;
return `<span class="hc-visit">${out(p.affiliate_url, `${esc(label)} <span class="hc-chip">Affiliate</span>`, { affiliate: true, cls: 'button' })}${p.affiliate_note ? `<small class="hc-note">${esc(p.affiliate_note)}</small>` : ''}</span>`;
}
}
- return p.homepage_url ? `<span class="hc-visit">${out(p.homepage_url, esc(label), { cls: 'button hc-button-quiet' })}</span>` : '';
+ // What a link earns us, said on every link (market outcome 7): an affiliate link is marked above; this one earns nothing.
+ return p.homepage_url ? `<span class="hc-visit">${out(p.homepage_url, esc(label), { cls: 'button hc-button-quiet' })}<small class="hc-note">No commission: we earn nothing from this link.</small></span>` : '';
+}
+
+/**
+ * The term a price is quoted for, and what it renews at (market outcomes 1 and 2, migration 0014). A sourced plan's
+ * price is the provider's own monthly list price; a staff entry states its term or leaves it unknown, and an unknown
+ * renewal price is said to be unknown, never assumed.
+ */
+const TERMS = { monthly: 'Month to month', '1-year': '1-year term', '3-year': '3-year term' };
+function termLines(p) {
+ if (isStorageRate(p)) return '';
+ const term = TERMS[p.price_term];
+ if (!term) return '<p class="hc-dim">Term not published.</p>';
+ const renews = p.renewal_price != null && Number.isFinite(Number(p.renewal_price))
+ ? `Renews at ${esc(money(Number(p.renewal_price), p.currency))} a month.`
+ : p.price_term === 'monthly' ? 'At the provider\'s list price.' : 'Renewal price not published.';
+ return `<p class="hc-dim">${esc(term)}. ${renews}</p>`;
}
}
function priceCell(p) {
function priceCell(p) {
@@ -69,7 +86,8 @@ function priceCell(p) {
@@ -69,7 +86,8 @@ function priceCell(p) {
// A per-GB rate is not a monthly price: it is shown as the rate per GB it is (server/catalog/pricing.js).
// A per-GB rate is not a monthly price: it is shown as the rate per GB it is (server/catalog/pricing.js).
if (isStorageRate(p)) return `<span class="hc-price">${Number.isFinite(price) ? esc(money(price, p.currency, 4)) : 'price not stated'}</span><span class="hc-per">/GB/month</span>`;
if (isStorageRate(p)) return `<span class="hc-price">${Number.isFinite(price) ? esc(money(price, p.currency, 4)) : 'price not stated'}</span><span class="hc-per">/GB/month</span>`;
const m = Number.isFinite(price) && price > 0 ? money(price, p.currency) : null;
const m = Number.isFinite(price) && price > 0 ? money(price, p.currency) : null;
- return `<span class="hc-price">${m ? esc(m) : 'price not stated'}</span><span class="hc-per">/mo</span>`;
+ const term = p.price_term === 'monthly' ? '' : TERMS[p.price_term] ? `<span class="hc-term">${esc(TERMS[p.price_term])}</span>` : '<span class="hc-term">term not published</span>';
+ return `<span class="hc-price">${m ? esc(m) : 'price not stated'}</span><span class="hc-per">/mo</span>${term}`;
}
}
const isoAt = (v) => new Date(v).toISOString();
const isoAt = (v) => new Date(v).toISOString();
@@ -312,6 +330,7 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '', n
@@ -312,6 +330,7 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '', n
${isStorageRate(p) ? '<p class="hc-dim">A storage rate: the price per GB a month, not a monthly server price.</p>' : ''}
${isStorageRate(p) ? '<p class="hc-dim">A storage rate: the price per GB a month, not a monthly server price.</p>' : ''}
${p.price_hourly != null ? `<p class="hc-dim">${esc(money(p.price_hourly, p.currency, 4))} an hour</p>` : ''}
${p.price_hourly != null ? `<p class="hc-dim">${esc(money(p.price_hourly, p.currency, 4))} an hour</p>` : ''}
${p.setup_fee != null && Number(p.setup_fee) > 0 ? `<p class="hc-dim">Setup fee ${esc(money(p.setup_fee, p.currency))}</p>` : ''}
${p.setup_fee != null && Number(p.setup_fee) > 0 ? `<p class="hc-dim">Setup fee ${esc(money(p.setup_fee, p.currency))}</p>` : ''}
+${termLines(p)}
<p class="hc-checked-line">Checked <strong>${esc(day(p.checked_at) || 'unknown')}</strong>${staleMark(p, now)} · ${out(p.source_url, 'source')}</p>
<p class="hc-checked-line">Checked <strong>${esc(day(p.checked_at) || 'unknown')}</strong>${staleMark(p, now)} · ${out(p.source_url, 'source')}</p>
${isStale(p.checked_at, now) ? '<p class="hc-dim">This price was last checked over a year ago, so it is not counted as a cheapest plan.</p>' : ''}
${isStale(p.checked_at, now) ? '<p class="hc-dim">This price was last checked over a year ago, so it is not counted as a cheapest plan.</p>' : ''}
${visit(p)}
${visit(p)}
@@ -21,6 +21,25 @@ const { boot, check, done } = require('./helpers/boot');
@@ -21,6 +21,25 @@ const { boot, check, done } = require('./helpers/boot');
const bad = await t.api('POST', '/api/v1/catalog/plans', { as: staff, json: { provider_id: provider.id, name: 'No source', external_id: 'bad', kind: 'vps', currency: 'USD', price_monthly: 1 } }); assert.strictEqual(bad.status, 422); assert.strictEqual(bad.json().code, 'host.catalog.invalid');
const bad = await t.api('POST', '/api/v1/catalog/plans', { as: staff, json: { provider_id: provider.id, name: 'No source', external_id: 'bad', kind: 'vps', currency: 'USD', price_monthly: 1 } }); assert.strictEqual(bad.status, 422); assert.strictEqual(bad.json().code, 'host.catalog.invalid');
const unknown = await t.api('PATCH', '/api/v1/catalog/promos/hpr_00000000000000000000000000', { as: staff, json: { title: 'x' } }); assert.strictEqual(unknown.status, 404); assert.strictEqual(unknown.json().code, 'host.catalog.not_found');
const unknown = await t.api('PATCH', '/api/v1/catalog/promos/hpr_00000000000000000000000000', { as: staff, json: { title: 'x' } }); assert.strictEqual(unknown.status, 404); assert.strictEqual(unknown.json().code, 'host.catalog.not_found');
});
});
+ await check('a plan states its term and renewal, and every link what it earns us (market outcomes 1, 2, 7)', async () => {
+ // A staff plan with no term: the page says so rather than guessing.
+ let page = await t.api('GET', `/catalog/plans/${plan.id}`);
+ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
+ assert.match(page.text, /Term not published\./);
+ assert.match(page.text, /No commission: we earn nothing from this link\./, 'a homepage link says it earns nothing');
+ // A 1-year term with no renewal price stated, then one with it.
+ let r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: '1-year' } }); assert.strictEqual(r.status, 200, r.text);
+ page = await t.api('GET', `/catalog/plans/${plan.id}`);
+ assert.match(page.text, /1-year term\. Renewal price not published\./);
+ r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { renewal_price: 9.5 } }); assert.strictEqual(r.status, 200, r.text);
+ page = await t.api('GET', `/catalog/plans/${plan.id}`);
+ assert.match(page.text, /1-year term\. Renews at \$9\.50 a month\./);
+ // A term that is not one of the three is refused.
+ r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: 'weekly' } }); assert.strictEqual(r.status, 422, r.text);
+ r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: 'monthly', renewal_price: null } }); assert.strictEqual(r.status, 200, r.text);
+ page = await t.api('GET', `/catalog/plans/${plan.id}`);
+ assert.match(page.text, /Month to month\. At the provider's list price\.|Month to month\. At the provider's list price\./);
+ });
await check('staff manage form is server rendered and CSRF protected', async () => {
await check('staff manage form is server rendered and CSRF protected', async () => {
const r = await t.api('GET', '/catalog/manage', { session: staff }); assert.strictEqual(r.status, 200); assert.match(r.text, /<form method="post"/); assert.match(r.text, /name="csrf"/);
const r = await t.api('GET', '/catalog/manage', { session: staff }); assert.strictEqual(r.status, 200); assert.match(r.text, /<form method="post"/); assert.match(r.text, /name="csrf"/);
const denied = await t.api('POST', '/catalog/manage/providers', { session: staff, form: { slug: 'injected', name: 'Injected', homepage_url: 'https://example.test' } }); assert.strictEqual(denied.status, 403);
const denied = await t.api('POST', '/catalog/manage/providers', { session: staff, form: { slug: 'injected', name: 'Injected', homepage_url: 'https://example.test' } }); assert.strictEqual(denied.status, 403);