From 61dbef5c34d0f1bfff294bad20ed455ca89eba72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Mon, 9 Nov 2026 14:29:50 +0100 Subject: [PATCH] Read only the unclaimed-Space timing from /v1/pricing, so new API fields can't break a build Co-Authored-By: Claude Opus 5.5 --- schemas/pricing-document.schema.json | 496 --------------------------- scripts/prepare-pricing.mjs | 3 +- scripts/pricing-schema.test.mjs | 11 - scripts/pricing.mjs | 96 +----- scripts/pricing.test.mjs | 34 +- 5 files changed, 34 insertions(+), 606 deletions(-) delete mode 100644 schemas/pricing-document.schema.json delete mode 100644 scripts/pricing-schema.test.mjs diff --git a/schemas/pricing-document.schema.json b/schemas/pricing-document.schema.json deleted file mode 100644 index 9459b5b0..00000000 --- a/schemas/pricing-document.schema.json +++ /dev/null @@ -1,496 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "revision": { - "type": "string" - }, - "currency": { - "type": "string", - "const": "USD" - }, - "plans": { - "type": "array", - "items": { - "type": "object", - "properties": { - "code": { - "type": "string", - "enum": [ - "free", - "go", - "plus", - "enterprise" - ] - }, - "name": { - "type": "string" - }, - "monthlyPriceUsd": { - "anyOf": [ - { - "type": "number", - "minimum": 0 - }, - { - "type": "null" - } - ] - }, - "usageCreditUsd": { - "anyOf": [ - { - "type": "number", - "minimum": 0 - }, - { - "type": "null" - } - ] - }, - "selfServe": { - "type": "boolean" - }, - "seats": { - "type": "object", - "properties": { - "included": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "extraMonthlyUsd": { - "anyOf": [ - { - "type": "number", - "minimum": 0 - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "included", - "extraMonthlyUsd" - ], - "additionalProperties": false - }, - "limits": { - "type": "object", - "properties": { - "storageBytes": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "teamMembers": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "liveSpaces": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "customDomains": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "auditLogRetentionHours": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "buildAccessLogRetentionHours": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "buildsDeploysDaily": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "shareLinksPerSpace": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "invitesPerSpace": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "inviteEmailsPerSpaceDaily": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "inviteEmailsPerAccountDaily": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - }, - "pendingAccessRequestsPerSpace": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "string", - "const": "unlimited" - } - ], - "description": "Plan limit value; \"unlimited\" when the plan does not limit this axis." - } - }, - "required": [ - "storageBytes", - "teamMembers", - "liveSpaces", - "customDomains", - "auditLogRetentionHours", - "buildAccessLogRetentionHours", - "buildsDeploysDaily", - "shareLinksPerSpace", - "invitesPerSpace", - "inviteEmailsPerSpaceDaily", - "inviteEmailsPerAccountDaily", - "pendingAccessRequestsPerSpace" - ], - "additionalProperties": false, - "description": "Named plan limit table." - }, - "capabilities": { - "type": "object", - "properties": { - "externalProxy": { - "type": "boolean", - "description": "Whether external proxy upstreams compile on this plan." - }, - "pagesTemplates": { - "type": "boolean", - "description": "Whether _pages/*.html full-page takeovers compile for this team." - }, - "hideSpacefastBranding": { - "type": "boolean", - "description": "Whether theme.hideSpacefastBranding removes the badge from default pages." - }, - "networkPolicies": { - "type": "boolean", - "description": "Whether the team can set Space network policies." - } - }, - "required": [ - "externalProxy", - "pagesTemplates", - "hideSpacefastBranding", - "networkPolicies" - ], - "additionalProperties": false - } - }, - "required": [ - "code", - "name", - "monthlyPriceUsd", - "usageCreditUsd", - "selfServe", - "seats", - "limits", - "capabilities" - ], - "additionalProperties": false - } - }, - "usage": { - "type": "array", - "items": { - "type": "object", - "properties": { - "freeIncluded": { - "anyOf": [ - { - "type": "number", - "minimum": 0 - }, - { - "type": "null" - } - ] - }, - "freePeriod": { - "anyOf": [ - { - "type": "string", - "enum": [ - "month", - "day", - "stored" - ] - }, - { - "type": "null" - } - ] - }, - "freePool": { - "anyOf": [ - { - "type": "string", - "enum": [ - "compute", - "storage" - ] - }, - { - "type": "null" - } - ] - }, - "meter": { - "type": "string", - "enum": [ - "cached_bytes", - "origin_bytes", - "runtime_cpu_microseconds", - "sql_cpu_microseconds", - "origin_invocations", - "build_milliseconds", - "worker_cpu_microseconds", - "worker_requests", - "live_byte_seconds", - "file_byte_seconds", - "email_recipients", - "spam_checks" - ] - }, - "label": { - "type": "string" - }, - "unit": { - "type": "string", - "enum": [ - "GB", - "GB-month", - "CPU-second", - "million", - "minute", - "recipient", - "check" - ] - }, - "paidIncluded": { - "type": "number", - "minimum": 0 - }, - "paidRateUsd": { - "type": "number", - "minimum": 0 - } - }, - "required": [ - "freeIncluded", - "freePeriod", - "freePool", - "meter", - "label", - "unit", - "paidIncluded", - "paidRateUsd" - ], - "additionalProperties": false - } - }, - "boost": { - "type": "object", - "properties": { - "monthlyUsd": { - "type": "number", - "minimum": 0 - }, - "plans": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "go", - "plus" - ] - } - } - }, - "required": [ - "monthlyUsd", - "plans" - ], - "additionalProperties": false - }, - "anonymous": { - "type": "object", - "properties": { - "ttlSeconds": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "resetOn": { - "type": "string", - "const": "publish-with-changed-files" - }, - "publishAbsoluteCapSeconds": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "claimPageExtensionSeconds": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "claimPageTotalExtensionSeconds": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "recoveryWindowSeconds": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - } - }, - "required": [ - "ttlSeconds", - "resetOn", - "publishAbsoluteCapSeconds", - "claimPageExtensionSeconds", - "claimPageTotalExtensionSeconds", - "recoveryWindowSeconds" - ], - "additionalProperties": false - } - }, - "required": [ - "revision", - "currency", - "plans", - "usage", - "boost", - "anonymous" - ], - "additionalProperties": false -} diff --git a/scripts/prepare-pricing.mjs b/scripts/prepare-pricing.mjs index 3f945409..a869705b 100644 --- a/scripts/prepare-pricing.mjs +++ b/scripts/prepare-pricing.mjs @@ -1,7 +1,6 @@ import { mkdir, writeFile } from "node:fs/promises"; -import { loadPricing, pricingSnippets } from "./pricing.mjs"; +import { loadPricing } from "./pricing.mjs"; const pricing = await loadPricing(); await mkdir("content/_pricing", { recursive: true }); await writeFile("content/_pricing/document.json", JSON.stringify({ data: pricing })); -for (const [name, text] of Object.entries(pricingSnippets(pricing))) await writeFile(`content/_pricing/${name}.md`, `${text}\n`); console.log(`Prepared pricing document revision ${pricing.revision}.`); diff --git a/scripts/pricing-schema.test.mjs b/scripts/pricing-schema.test.mjs deleted file mode 100644 index f1b74410..00000000 --- a/scripts/pricing-schema.test.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import test from "node:test"; -import schema from "../schemas/pricing-document.schema.json" with { type: "json" }; - -test("the vendored pricing validator matches the producer's public API schema", async () => { - const api = JSON.parse(await readFile(new URL("../generated/openapi/api.json", import.meta.url), "utf8")); - const published = api.paths["/v1/pricing"].get.responses["200"].content["application/json"].schema.properties.data; - const { $schema, ...contract } = schema; - assert.deepEqual(published, contract); -}); diff --git a/scripts/pricing.mjs b/scripts/pricing.mjs index 3d3615cf..10715dad 100644 --- a/scripts/pricing.mjs +++ b/scripts/pricing.mjs @@ -1,8 +1,20 @@ import { readFile } from "node:fs/promises"; import { z } from "zod"; -import schema from "../schemas/pricing-document.schema.json" with { type: "json" }; -export const pricingDocumentSchema = z.fromJSONSchema(schema); +// The docs read only the unclaimed-Space timing from GET /v1/pricing. Plans, +// prices and limits come from scripts/pricing-next.mjs. So the check covers +// just those numbers and lets the API add or change everything else. +const seconds = z.number().int().positive(); +export const pricingDocumentSchema = z.object({ + revision: z.string(), + anonymous: z.object({ + ttlSeconds: seconds, + publishAbsoluteCapSeconds: seconds, + claimPageExtensionSeconds: seconds, + claimPageTotalExtensionSeconds: seconds, + recoveryWindowSeconds: seconds, + }), +}); export async function loadPricing({ fixture = process.env.SPACEFAST_PRICING_FIXTURE, origin = process.env.SPACEFAST_API_ORIGIN ?? "https://api.spacefast.com", fetcher = fetch } = {}) { const response = fixture @@ -14,88 +26,16 @@ export async function loadPricing({ fixture = process.env.SPACEFAST_PRICING_FIXT return pricingDocumentSchema.parse(response.data); } -const limitLabels = { - storageBytes: ["Storage", "bytes"], - teamMembers: ["Team members"], - liveSpaces: ["Live Spaces"], - customDomains: ["Custom domains"], - auditLogRetentionHours: ["Audit log retention", "hours"], - buildAccessLogRetentionHours: ["Build and access log retention", "hours"], - buildsDeploysDaily: ["Builds and deploys per day"], - shareLinksPerSpace: ["Share links per Space"], - invitesPerSpace: ["Invites per Space"], - inviteEmailsPerSpaceDaily: ["Invite emails per Space per day"], - inviteEmailsPerAccountDaily: ["Invite emails per account per day"], - pendingAccessRequestsPerSpace: ["Pending access requests per Space"], -}; -const capabilityLabels = { - externalProxy: "External API proxy", - pagesTemplates: "Pages templates", - hideSpacefastBranding: "Remove Spacefast branding", - networkPolicies: "Network policies", -}; -const periodLabels = { month: "per month", day: "per day", stored: "stored" }; -const label = (labels, key) => { - const found = labels[key]; - if (!found) throw new Error(`Pricing document has no docs label for ${key}`); - return found; -}; -const number = (value) => value === "unlimited" ? "Unlimited" : new Intl.NumberFormat("en-US", { maximumFractionDigits: 6 }).format(value); -const usd = (value) => value === null ? "Custom" : `$${new Intl.NumberFormat("en-US", { minimumFractionDigits: Number.isInteger(value) ? 0 : 2, maximumFractionDigits: 6 }).format(value)}`; -const bytes = (value) => { - for (const [size, unit] of [[1e12, "TB"], [1e9, "GB"], [1e6, "MB"]]) if (value >= size) return `${number(value / size)} ${unit}`; - return `${number(value)} bytes`; -}; -const plural = (count, unit) => count === 1 || /^(GB|GB-month|million)$/u.test(unit) ? unit : `${unit}s`; -const quantity = (count, unit) => `${number(count)} ${plural(count, unit)}`; -const limit = (key, value) => { - if (value === "unlimited") return "Unlimited"; - const [, unit] = label(limitLabels, key); - if (unit === "bytes") return bytes(value); - if (unit === "hours") return duration(value * 3600); - return number(value); -}; -const table = (headers, rows) => [headers, headers.map(() => "---"), ...rows].map((row) => `| ${row.join(" | ")} |`).join("\n"); +const number = (value) => new Intl.NumberFormat("en-US", { maximumFractionDigits: 6 }).format(value); const duration = (seconds) => { if (seconds % 86400 === 0) return `${number(seconds / 86400)} days`; if (seconds % 3600 === 0) return `${number(seconds / 3600)} hours`; return `${Math.floor(seconds / 3600)} hours and ${Math.floor(seconds % 3600 / 60)} minutes`; }; + +/** `{{anonymous-…}}` variables for the unclaimed-Space pages. */ export function pricingVariables(pricing) { - const plan = (code) => { - const found = pricing.plans.find((plan) => plan.code === code); - if (!found) throw new Error(`Pricing document is missing ${code}`); - return found; - }; const variables = {}; - for (const item of pricing.plans) { - const prefix = item.code; - variables[`${prefix}-price`] = usd(item.monthlyPriceUsd); - variables[`${prefix}-credit`] = usd(item.usageCreditUsd); - variables[`${prefix}-seats`] = number(item.seats.included); - variables[`${prefix}-extra-seat`] = usd(item.seats.extraMonthlyUsd); - for (const [key, value] of Object.entries(item.limits)) variables[`${prefix}-${key}`] = number(value); - } - for (const key of ["ttlSeconds", "publishAbsoluteCapSeconds", "claimPageExtensionSeconds", "claimPageTotalExtensionSeconds", "recoveryWindowSeconds"]) variables[`anonymous-${key}`] = duration(pricing.anonymous[key]); - const plus = plan("plus"); - if (typeof plus.seats.included !== "number" || plus.seats.extraMonthlyUsd === null || plus.monthlyPriceUsd === null || plus.usageCreditUsd === null) throw new Error("Plus must define seat billing for the worked example"); - const seatCost = Math.max(0, 12 - plus.seats.included) * plus.seats.extraMonthlyUsd; - const overage = Math.max(0, 20 - plus.usageCreditUsd); - variables["plus-example-seats"] = usd(seatCost); - variables["plus-example-overage"] = usd(overage); - variables["plus-example-total"] = usd(plus.monthlyPriceUsd + seatCost + overage); - const go = plan("go"); - if (go.monthlyPriceUsd === null || go.usageCreditUsd === null) throw new Error("Go must define a monthly price and credit"); - variables["go-example-overage"] = usd(Math.max(0, 8 - go.usageCreditUsd)); - variables["go-example-total"] = usd(go.monthlyPriceUsd + Math.max(0, 8 - go.usageCreditUsd)); + for (const [key, value] of Object.entries(pricing.anonymous)) variables[`anonymous-${key}`] = duration(value); return variables; } - -export function pricingSnippets(pricing) { - return { - plans: table(["Plan", "Per team, per month", "Monthly usage credit", "Editors"], pricing.plans.map((plan) => [plan.name, usd(plan.monthlyPriceUsd), usd(plan.usageCreditUsd), `${number(plan.seats.included)}${plan.seats.extraMonthlyUsd === null ? "" : `, then ${usd(plan.seats.extraMonthlyUsd)} per extra editor`}`])), - limits: table(["Allowance", ...pricing.plans.map((plan) => plan.name)], Object.keys(pricing.plans[0].limits).map((key) => [label(limitLabels, key)[0], ...pricing.plans.map((plan) => limit(key, plan.limits[key]))])), - capabilities: table(["Feature", ...pricing.plans.map((plan) => plan.name)], Object.keys(pricing.plans[0].capabilities).map((key) => [label(capabilityLabels, key), ...pricing.plans.map((plan) => plan.capabilities[key] ? "Included" : "Not included")])), - usage: table(["Usage", "Free allowance", "Paid included", "Paid rate (USD)"], pricing.usage.map((row) => [row.label, row.freeIncluded === null ? "None" : `${quantity(row.freeIncluded, row.unit)} ${periodLabels[row.freePeriod]}${row.freePool ? `, shared ${row.freePool} pool` : ""}`, row.paidIncluded === 0 ? "None" : quantity(row.paidIncluded, row.unit), `${usd(row.paidRateUsd)} per ${row.unit}`])), - }; -} diff --git a/scripts/pricing.test.mjs b/scripts/pricing.test.mjs index 32f9d71d..fed7d8f5 100644 --- a/scripts/pricing.test.mjs +++ b/scripts/pricing.test.mjs @@ -1,28 +1,24 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { loadPricing, pricingVariables, pricingSnippets } from "./pricing.mjs"; +import { loadPricing, pricingVariables } from "./pricing.mjs"; -test("pricing loads the public envelope and derives prices, pools, seats, and the worked bill", async () => { - const pricing = await loadPricing({ fixture: new URL("../test/fixtures/pricing-document.json", import.meta.url) }); - const plus = pricing.plans.find((plan) => plan.code === "plus"); +const fixture = new URL("../test/fixtures/pricing-document.json", import.meta.url); + +test("pricing loads the public envelope and formats the unclaimed-Space timing", async () => { + const pricing = await loadPricing({ fixture }); const variables = pricingVariables(pricing); - assert.equal(variables["plus-price"], `$${plus.monthlyPriceUsd}`); - assert.equal(variables["plus-example-total"], `$${plus.monthlyPriceUsd + Math.max(0, 12 - plus.seats.included) * plus.seats.extraMonthlyUsd + Math.max(0, 20 - plus.usageCreditUsd)}`); - const snippets = pricingSnippets(pricing); - assert.match(snippets.usage, /shared compute pool/u); - assert.match(snippets.usage, /shared storage pool/u); - assert.match(snippets.limits, /\| Storage \| 500 MB \| Unlimited \|/u); - assert.match(snippets.limits, /\| Audit log retention \| 2 days \| 30 days \|/u); - assert.match(snippets.plans, /\| Enterprise \| Custom \| Custom \| Unlimited \|/u); - assert.match(snippets.usage, /\| Builds \| 100 minutes per month \| None \|/u); - assert.match(snippets.capabilities, /\| Remove Spacefast branding \|/u); assert.match(variables["anonymous-ttlSeconds"], /hours and .* minutes/u); - const changed = structuredClone(pricing); - changed.plans.find((plan) => plan.code === "plus").monthlyPriceUsd += 2; - assert.notEqual(pricingVariables(changed)["plus-example-total"], variables["plus-example-total"]); + assert.match(variables["anonymous-publishAbsoluteCapSeconds"], /days$/u); +}); + +test("pricing accepts plans, meters and fields the API adds or changes later", async () => { + const { data } = JSON.parse(await (await import("node:fs/promises")).readFile(fixture, "utf8")); + const changed = { ...data, plans: [{ code: "business" }], usage: [], limits: { future: true } }; + const parsed = await loadPricing({ fixture: "", fetcher: async () => Response.json({ data: changed }) }); + assert.equal(parsed.anonymous.ttlSeconds, data.anonymous.ttlSeconds); }); -test("pricing rejects failed requests and malformed public documents without a fallback", async () => { +test("pricing rejects failed requests and documents without the unclaimed-Space timing", async () => { await assert.rejects(loadPricing({ fixture: "", fetcher: async () => new Response("unavailable", { status: 503 }) }), /HTTP 503/u); - await assert.rejects(loadPricing({ fixture: "", fetcher: async () => Response.json({ data: { currency: "EUR" } }) })); + await assert.rejects(loadPricing({ fixture: "", fetcher: async () => Response.json({ data: { revision: "x" } }) })); });