diff --git a/.github/workflows/validate-frontmatter.yml b/.github/workflows/validate-frontmatter.yml index 5c79549..5183138 100644 --- a/.github/workflows/validate-frontmatter.yml +++ b/.github/workflows/validate-frontmatter.yml @@ -6,11 +6,13 @@ on: paths: - "**/*.mdx" - "scripts/validate-frontmatter.mjs" + - "scripts/check-plan-limits.mjs" - "package.json" pull_request: paths: - "**/*.mdx" - "scripts/validate-frontmatter.mjs" + - "scripts/check-plan-limits.mjs" - "package.json" jobs: @@ -24,3 +26,5 @@ jobs: node-version: "20" - name: Run frontmatter validator run: node scripts/validate-frontmatter.mjs + - name: Check plan-limit tables match the source of truth + run: node scripts/check-plan-limits.mjs diff --git a/concepts/credits.mdx b/concepts/credits.mdx index 3ca631b..397349b 100644 --- a/concepts/credits.mdx +++ b/concepts/credits.mdx @@ -35,9 +35,10 @@ Per-job billing from a prepaid credit balance. New accounts get $5 free. Every t | Signup credits | $5 (one-time) | - | | Monthly credits included | - | $5 | | Credit-purchase bonus | - | +20% | -| Concurrent jobs | 1 | 25 | -| API requests/min | 30 | 300 | -| Max input file | 100 MB | 2 GB | +| Concurrent jobs | 1 | 20 | +| Queued backlog | 50 | 1000 | +| API requests/min | 120 | 600 | +| Max input file | 500 MB | 10 GB | | Job timeout | 5 min | 15 min | | Output retention | 7 days | 30 days | | Priority queue | No | Yes | @@ -59,7 +60,7 @@ Every job is billed in nanodollars (one-billionth of a US dollar) drawn from you ## Credit lifecycle -1. **Submit gate.** Submit is atomic. If your balance is greater than zero and you are under your concurrent-job limit, the job runs. We do not estimate cost up front. Insufficient balance returns `402 INSUFFICIENT_CREDITS`. +1. **Submit gate.** Submit is atomic and does not reject on concurrency. If your balance is greater than zero the job is accepted. Jobs run at your plan's concurrency and any extra wait in a queue, starting in order as slots free. We do not estimate cost up front. A zero balance returns `402 INSUFFICIENT_CREDITS`, and a full queue returns `429 QUEUE_FULL`. 2. **Execution.** Job runs. Balance untouched. 3. **Debit on terminal status.** Every terminal job (`complete`, `failed`, or `cancelled`) is charged for the compute it consumed. The debit is atomic with the status update. 4. **No auto-refund.** Failed jobs are still charged for the compute they consumed. If you believe a job failed due to a platform bug, contact support. Refunds are issued at our discretion via the dispute process. diff --git a/package.json b/package.json index 2142c6d..185915d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "type": "module", "scripts": { - "validate:frontmatter": "node scripts/validate-frontmatter.mjs" + "validate:frontmatter": "node scripts/validate-frontmatter.mjs", + "validate:plan-limits": "node scripts/check-plan-limits.mjs" } } diff --git a/scripts/check-plan-limits.mjs b/scripts/check-plan-limits.mjs new file mode 100644 index 0000000..a356420 --- /dev/null +++ b/scripts/check-plan-limits.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +/** + * Plan-limit consistency guard for the docs. + * + * The single source of truth for plan tiers is the monorepo's + * packages/shared/src/constants/price-tiers.ts, published (generated) at + * https://rendobar.com/plan-limits.json. This script fetches that export and + * asserts the tier tables in concepts/credits.mdx and support/limits.mdx match + * it, so the docs can never silently drift from the real limits. + * + * If the published JSON is unreachable (e.g. it has not deployed yet), the + * check warns and exits 0 rather than blocking. Once live it enforces. + * + * Exit 0 = consistent (or source unreachable). Exit 1 = a table cell is stale. + */ + +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, ".."); +const SOURCE_URL = "https://rendobar.com/plan-limits.json"; + +const fmtInt = (n) => String(n); +const fmtBytes = (n) => + n % 1024 ** 3 === 0 ? `${n / 1024 ** 3} GB` : `${n / 1024 ** 2} MB`; +const fmtMin = (sec) => `${sec / 60} min`; +const fmtDays = (n) => `${n} days`; + +// label substring (lowercased) -> [limits key, formatter] +const ROWS = [ + ["concurrent jobs", "concurrentJobs", fmtInt], + ["queued backlog", "maxQueuedJobs", fmtInt], + ["api requests", "apiRequestsPerMinute", fmtInt], + ["max input file", "maxInputFileSize", fmtBytes], + ["max batch size", "maxBatchSize", fmtInt], + ["job timeout", "maxJobTimeout", fmtMin], + ["output retention", "outputRetentionDays", fmtDays], + ["total r2 storage", "storageQuota", fmtBytes], +]; + +const FILES = ["concepts/credits.mdx", "support/limits.mdx"]; + +/** Find the `| label… | free | pro |` row and return the free/pro cells. */ +function tierRow(md, labelSubstring) { + const line = md + .split("\n") + .find((l) => l.includes("|") && l.toLowerCase().includes(labelSubstring)); + if (!line) return null; + const cells = line.split("|").map((c) => c.trim()).filter(Boolean); + return { free: cells[1], pro: cells[2] }; +} + +async function main() { + let source; + try { + const res = await fetch(SOURCE_URL); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + source = await res.json(); + } catch (err) { + console.warn(`⚠ plan-limits source unreachable (${SOURCE_URL}): ${err.message}. Skipping.`); + return; // exit 0 — let Node drain the socket pool cleanly + } + + const errors = []; + for (const file of FILES) { + const md = readFileSync(join(ROOT, file), "utf8"); + for (const [label, key, fmt] of ROWS) { + const row = tierRow(md, label); + if (!row) continue; // not every row is in every file + const wantFree = fmt(source.plans.free.limits[key]); + const wantPro = fmt(source.plans.pro.limits[key]); + if (row.free !== wantFree || row.pro !== wantPro) { + errors.push( + `${file}: "${label}" is | ${row.free} | ${row.pro} | but source says | ${wantFree} | ${wantPro} |`, + ); + } + } + } + + if (errors.length) { + console.error("✗ Plan-limit tables drifted from the source of truth:\n" + errors.map((e) => ` - ${e}`).join("\n")); + console.error(`\nUpdate the tables to match ${SOURCE_URL} (edit price-tiers.ts in the monorepo to change a value).`); + process.exitCode = 1; + return; + } + console.log("✓ Plan-limit tables match the source of truth."); +} + +main(); diff --git a/support/faq.mdx b/support/faq.mdx index 71ba129..e3b86a2 100644 --- a/support/faq.mdx +++ b/support/faq.mdx @@ -61,7 +61,7 @@ canonical: "https://rendobar.com/docs/support/faq" "name": "Is there a rate limit?", "acceptedAnswer": { "@type": "Answer", - "text": "Yes. 30 req/min (Free), 300 req/min (Pro). Applies to REST and MCP. Limit hit returns 429 RATE_LIMITED with a Retry-After header." + "text": "Yes. 120 req/min (Free), 600 req/min (Pro). Applies to REST and MCP. Limit hit returns 429 RATE_LIMITED with a Retry-After header." } }, { @@ -116,7 +116,7 @@ canonical: "https://rendobar.com/docs/support/faq" - Yes. 30 req/min (Free), 300 req/min (Pro). Applies to REST and MCP. Limit hit returns `429 RATE_LIMITED` with a `Retry-After` header. + Yes. 120 req/min (Free), 600 req/min (Pro). Applies to REST and MCP. Limit hit returns `429 RATE_LIMITED` with a `Retry-After` header. diff --git a/support/limits.mdx b/support/limits.mdx index e33496c..03dc25b 100644 --- a/support/limits.mdx +++ b/support/limits.mdx @@ -34,13 +34,14 @@ Every limit Rendobar enforces, by plan. Pulled from `PLAN_LIMITS` in the API. Fo | Signup credits | $5 (one-time) | - | | Monthly credits | - | $5/mo included | | Credit-purchase bonus | - | +20% | -| Concurrent jobs | 1 | 25 | -| API requests / min | 30 | 300 | -| Max input file | 100 MB | 2 GB | +| Concurrent jobs | 1 | 20 | +| Queued backlog | 50 | 1000 | +| API requests / min | 120 | 600 | +| Max input file | 500 MB | 10 GB | | Max batch size | 10 | 100 | | Max job timeout | 5 min | 15 min | | Output retention | 7 days | 30 days | -| Total R2 storage | 1 GB | 50 GB | +| Total R2 storage | 5 GB | 250 GB | | Priority queue | No | Yes | `ffmpeg`, MCP, and webhooks are on both plans.