From 7da24325c12b8d80f12adf296aef1b348db057c9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 16:42:54 +0000 Subject: [PATCH 1/8] feat: restructure purchasable plan tiers --- .../contributing/architecture/entitlements.md | 73 +++-- docs/contributing/architecture/index.md | 2 +- docs/contributing/environment-variables.md | 16 +- docs/contributing/setup-manifest.md | 8 +- docs/use/email-primitives.md | 3 + docs/use/index.md | 4 +- docs/use/workflows.md | 4 +- packages/worker/.env.example | 1 + .../worker/client/routes/account-billing.tsx | 59 ++-- .../worker/client/routes/admin-insights.tsx | 2 +- packages/worker/client/routes/pricing.tsx | 36 ++- .../0002-restructure-plan-tiers.sql | 306 ++++++++++++++++++ .../src/app/account-billing-data.node.test.ts | 4 +- .../worker/src/app/account-billing-data.ts | 6 +- .../src/app/handlers/account-billing.ts | 17 +- packages/worker/src/app/loader-data.ts | 4 +- .../src/billing/billing-config.node.test.ts | 64 ++++ packages/worker/src/billing/billing-config.ts | 51 ++- .../src/blog/posts/zero-inference-calls.md | 8 +- .../entitlements/entitlements.node.test.ts | 143 ++++++-- .../plan-tier-migration.node.test.ts | 88 +++++ packages/worker/src/entitlements/plans.ts | 97 +++--- packages/worker/src/entitlements/service.ts | 16 +- .../worker/src/entitlements/user-meter-do.ts | 67 +++- .../entitlements/user-meter.workers.test.ts | 9 + packages/worker/src/env-schema.ts | 1 + .../services/service-start.node.test.ts | 51 ++- .../capabilities/services/service-start.ts | 10 + packages/worker/src/og/pages.ts | 6 +- .../src/package-runtime/package-service.ts | 6 + packages/worker/worker-configuration.d.ts | 12 +- packages/worker/wrangler.jsonc | 9 +- tools/migration-ledger.json | 4 + 33 files changed, 977 insertions(+), 210 deletions(-) create mode 100644 packages/worker/migrations/0002-restructure-plan-tiers.sql create mode 100644 packages/worker/src/entitlements/plan-tier-migration.node.test.ts diff --git a/docs/contributing/architecture/entitlements.md b/docs/contributing/architecture/entitlements.md index 5ba21ce2ec..c563c31436 100644 --- a/docs/contributing/architecture/entitlements.md +++ b/docs/contributing/architecture/entitlements.md @@ -8,7 +8,7 @@ in `planLimits` remain independently configured placeholders. Module: `packages/worker/src/entitlements/` -- `plans.ts` — plan names (`free`, `pro`, `partner`, `max`), the `PlanLimits` +- `plans.ts` — plan names (`free`, `standard`, `pro`, `max`), the `PlanLimits` config per plan, `max` email caps (`maxPlanEmailLimits`), the `EntitlementResource` registry, `resolvePlanLimit(plan, resource)`, `resolveEmailResourceLimit(plan, resource)`, `getPlanRank`, `parsePlanName` @@ -26,7 +26,7 @@ Module: `packages/worker/src/entitlements/` ## Plan model -The plan registry in `plans.ts` includes `free`, `pro`, `partner`, and `max`. +The plan registry in `plans.ts` includes `free`, `standard`, `pro`, and `max`. Every plan has finite numeric limits for every resource; there are no uncapped tiers and no env-var backstops. @@ -56,6 +56,10 @@ without including the raw value or user data. Untrusted admin/API input uses `parsePlanName` so typos, unknown strings, and residual `'unlimited'` are rejected as validation failures. +Migration `0002-restructure-plan-tiers.sql` maps the former `pro` tier to +`standard`, the former `partner` tier to `pro`, and rebuilds both CHECK +constraints for the current registry. + `users.stripe_plan` stays nullable because it is Stripe-derived; `max` is manual-only — admin-visible, not paid or public — and never written from Stripe (`parseStripePlanName` rejects it, as well as residual `'unlimited'`). @@ -73,25 +77,25 @@ deliberately. It is not a public or Stripe-purchasable plan. Email resources use sending is an outreach-abuse surface — use `resolveEmailResourceLimit` to read those caps. The caps stay finite but dominate every other plan's email limits, so granting `max` never reduces email capacity (`email_message_bytes` stays at -pro/partner parity because the per-message ceiling is a platform bound, not a +standard/pro parity because the per-message ceiling is a platform bound, not a scalable quota). All other resources use the ordinary `planLimits.max` numbers. -| Resource | Limit | -| ----------------------------- | ----------- | -| `email_sends_per_day` | 10,000 | -| `email_receives_per_day` | 20,000 | -| `stored_email_messages` | 100,000 | -| `email_message_bytes` | 768 KiB | -| `concurrent_workflows` | 5,000 | -| `scheduled_jobs` | 5,000 | -| `saved_packages` | 10,000 | -| `package_services` | 1,000 | -| `persistent_package_services` | 1 (allowed) | -| `repo_sessions` | 20,000 | -| `secrets` | 10,000 | -| `storage_bytes` | 100 GiB | -| `execute_calls_per_day` | 500,000 | -| `outbound_fetches_per_day` | 2,000,000 | +| Resource | Limit | +| ----------------------------- | --------- | +| `email_sends_per_day` | 10,000 | +| `email_receives_per_day` | 20,000 | +| `stored_email_messages` | 100,000 | +| `email_message_bytes` | 768 KiB | +| `concurrent_workflows` | 5,000 | +| `scheduled_jobs` | 5,000 | +| `saved_packages` | 10,000 | +| `package_services` | 1,000 | +| `persistent_package_services` | 10 | +| `repo_sessions` | 20,000 | +| `secrets` | 10,000 | +| `storage_bytes` | 100 GiB | +| `execute_calls_per_day` | 500,000 | +| `outbound_fetches_per_day` | 2,000,000 | ## Compute rate limits @@ -379,7 +383,9 @@ to `'max'` on `users.plan` and `invites.plan` only; it does not touch to `'max'`, fails closed if any remain, and rebuilds `users` and `invites` with NOT NULL DEFAULT `'free'`. Migration `0113-plan-check-constraints.sql` verifies that every stored plan is registered, then rebuilds both tables with CHECK -constraints for `free`, `partner`, `pro`, and `max`. +constraints for the plan registry at that point. Migration +`0002-restructure-plan-tiers.sql` renames stored tier values and replaces those +constraints with `free`, `standard`, `pro`, and `max`. ## Assigning plans @@ -654,7 +660,7 @@ Use `getCurrent` only when the built-in D1 counter cannot express the resource. | `scheduled_jobs` | `createJob` in `packages/worker/src/jobs/service.ts` (exemplar) | | `saved_packages` | new-package branch of `package_save` and projection insert | | `package_services` | `service_start` capability path (`getCurrent` with authoritative `countRunningPackageServices(env)`; no D1 liveness read) | -| `persistent_package_services` | `service_start` for services declared `mode: 'persistent'` | +| `persistent_package_services` | `service_start` for services declared `mode: 'persistent'` (`getCurrent` counts only persistent running rows in the authoritative per-user UserMeter) | | `repo_sessions` | `repo_open_session` before creating a new session | | `email_sends_per_day` | `sendOutboundEmail` (`consumeDailyEntitlement`; plan limit from `resolvePlanLimit`) | | `email_receives_per_day` | `handleInboundEmail` (`consumeDailyEntitlement`; same plan limits; refund only on `RetryableInboundStorageError`) | @@ -669,17 +675,19 @@ Use `getCurrent` only when the built-in D1 counter cannot express the resource. Optional Stripe subscription billing lives in `packages/worker/src/billing/` (raw `fetch` client — no Stripe SDK; `STRIPE_API_BASE_URL` overrides the API host for tests/mocks). Without `STRIPE_SECRET_KEY`, billing surfaces degrade to -manual plans only. +manual plans only. `STRIPE_STANDARD_PRICE_ID` and `STRIPE_PRO_PRICE_ID` +independently enable checkout for their corresponding tier; an unset price id +only disables purchase of that tier. Checkout sessions are created server-side for authenticated users via -`POST /account/billing/checkout.json` (Stripe Checkout Session, -`mode=subscription`, with a signed `client_reference_id` and -`metadata.kody_stable_user_id`). There is no public Payment Link path — checkout -requires a signed-in session so unauthenticated card-testing is not possible. -`GET /account/billing/success` verifies `client_reference_id` before linking -`users.stripe_customer_id`, then refreshes `users.stripe_plan`. -`GET /account/billing/portal` opens the Stripe customer portal for linked -customers. +`POST /account/billing/checkout.json` (Stripe Checkout Session, JSON body +`{ plan: "standard" | "pro" }`, `mode=subscription`, with a signed +`client_reference_id` and `metadata.kody_stable_user_id`). There is no public +Payment Link path — checkout requires a signed-in session so unauthenticated +card-testing is not possible. `GET /account/billing/success` verifies +`client_reference_id` before linking `users.stripe_customer_id`, then refreshes +`users.stripe_plan`. `GET /account/billing/portal` opens the Stripe customer +portal for linked customers. ### Webhooks (primary sync) @@ -722,8 +730,9 @@ instead of acknowledging an unrecoverable stale projection. Migration `stripe_plan`, and `stripe_plan_refreshed_at`; Wrangler migration `v23` adds the alarm DO class without moving canonical billing data out of D1. -Published prices: Free $0, Pro $5/mo. Env vars and deploy wiring are documented -in [`../environment-variables.md`](../environment-variables.md). +Published prices: Free $0, Standard $5/mo, Pro $20/mo. Env vars and deploy +wiring are documented in +[`../environment-variables.md`](../environment-variables.md). ## Related tables and coordination diff --git a/docs/contributing/architecture/index.md b/docs/contributing/architecture/index.md index 84e4cdb454..c9513f2cb5 100644 --- a/docs/contributing/architecture/index.md +++ b/docs/contributing/architecture/index.md @@ -20,7 +20,7 @@ to become. package scopes. - [Authorization](./authorization.md): role-based access control (RBAC), admin routes, and the `any`-access exception to per-user isolation. -- [Entitlements](./entitlements.md): per-user plans (`free`, `pro`, `partner`, +- [Entitlements](./entitlements.md): per-user plans (`free`, `standard`, `pro`, `max`; live DDL defaults and writers use `free`; `max` is a manual-only high finite ceiling), finite per-plan resource limits, and the shared `assertWithinEntitlement` enforcement helper (`parseStoredPlanName` for reads; diff --git a/docs/contributing/environment-variables.md b/docs/contributing/environment-variables.md index fcf4b5e60e..7b6802e2f7 100644 --- a/docs/contributing/environment-variables.md +++ b/docs/contributing/environment-variables.md @@ -167,10 +167,18 @@ safely. Manual `users.plan` grants and invite-assigned plans apply regardless. production deploy when set. - `STRIPE_API_BASE_URL` — optional API base URL; defaults to `https://api.stripe.com` when unset. Override for tests/mocks. -- `STRIPE_PRO_PRICE_ID` — Stripe Price id used to map active/trialing - subscriptions to the `pro` plan and to create authenticated Checkout Sessions. - Production value is committed as a Wrangler `var` in - `packages/worker/wrangler.jsonc`. +- `STRIPE_STANDARD_PRICE_ID` — Stripe Price id for the $5/month `standard` plan. + This must receive the price id that `STRIPE_PRO_PRICE_ID` used before the tier + rename. +- `STRIPE_PRO_PRICE_ID` — Stripe Price id for the $20/month `pro` plan. + +Each price id independently enables authenticated Checkout and subscription +matching for its tier; leaving either unset makes only that tier unavailable for +purchase. Store both as production Worker secrets. For the tier-rename deploy, +create the +$20/month Pro recurring price in Stripe, set +`STRIPE_PRO_PRICE_ID` to that new id, and move the existing $5/month +price id to `STRIPE_STANDARD_PRICE_ID` before deploying. See [`architecture/entitlements.md`](./architecture/entitlements.md) (Billing). diff --git a/docs/contributing/setup-manifest.md b/docs/contributing/setup-manifest.md index 8c13a81019..60d07773d2 100644 --- a/docs/contributing/setup-manifest.md +++ b/docs/contributing/setup-manifest.md @@ -359,9 +359,11 @@ automatically: for `POST /webhooks/stripe`. When unset, the webhook endpoint returns 503.) - `STRIPE_API_BASE_URL` (optional; defaults to `https://api.stripe.com`. Override for tests/mocks.) -- `STRIPE_PRO_PRICE_ID` (optional Worker var; Stripe Price id mapped to the - `pro` plan and used for authenticated Checkout Sessions. Production value is - set in `packages/worker/wrangler.jsonc`.) +- `STRIPE_STANDARD_PRICE_ID` (optional Worker secret; Stripe Price id mapped to + the $5/month `standard` plan and used for authenticated Checkout Sessions.) +- `STRIPE_PRO_PRICE_ID` (optional Worker secret; Stripe Price id mapped to the + $20/month `pro` plan and used for authenticated Checkout Sessions.) Each price + id is independent; an unset value only disables checkout for that tier. Tests run with `CLOUDFLARE_ENV=test` (set by Playwright) and read local secrets from `packages/worker/.env`. diff --git a/docs/use/email-primitives.md b/docs/use/email-primitives.md index 98a8dc7308..94df381a7d 100644 --- a/docs/use/email-primitives.md +++ b/docs/use/email-primitives.md @@ -93,6 +93,9 @@ Inbound storage is quota-gated per user: unless an invite assigns another tier. The operator-only `max` plan uses finite email caps (10,000 sends/day, 20,000 receives/day, 100,000 stored messages, 768 KiB per message); it is not a public or paid tier. +- Paid email caps are Standard: 200 sends/day, 1,000 receives/day, 10,000 stored + messages; Pro: 500 sends/day, 2,000 receives/day, 25,000 stored messages. Both + allow up to 768 KiB per message. - Quota, size, and unverified-account rejections store at most five detailed `rejected` delivery events per inbox per UTC day; further rejections increment a single daily aggregate event (with a total count and the last reason) so diff --git a/docs/use/index.md b/docs/use/index.md index 0b58338214..fb007e6550 100644 --- a/docs/use/index.md +++ b/docs/use/index.md @@ -38,8 +38,8 @@ Read in order for a full tour, or jump to a topic. provider POSTs to a saved-package export - [Activity](./activity.md) — failures and recent runs for jobs, apps, webhooks, and other runtimes (`/account/activity` and the `runs` MCP capabilities) -- [Plans and pricing](https://heykody.dev/pricing) — Free and Pro prices and the - finite limits enforced for each plan +- [Plans and pricing](https://heykody.dev/pricing) — Free, Standard, and Pro + prices and the finite limits enforced for each plan - [Mutating actions and confirmations](./mutating-actions.md) - [Privacy](./privacy.md) — what Kody stores and what deployment admins can see (Terms and Acceptable Use are in-app at [`/terms`](https://heykody.dev/terms)) diff --git a/docs/use/workflows.md b/docs/use/workflows.md index 6ca73ac7da..da11a9e625 100644 --- a/docs/use/workflows.md +++ b/docs/use/workflows.md @@ -53,8 +53,8 @@ Calling `create` again with the same explicit `idempotencyKey` and matching workflow identity for the same user returns the existing workflow instead of starting a duplicate. Choose keys that include the logical job identity, for example `storage-sweep:2026-05-08`. Kody enforces a finite per-user concurrent -workflow limit from the account plan (free 3, pro 50, partner 100, max 5000); if -the cap is reached, `workflows.create` returns a clear quota error. +workflow limit from the account plan (free 3, standard 50, pro 100, max 5000); +if the cap is reached, `workflows.create` returns a clear quota error. Use `workflow_run_list` to inspect recent workflow runs and statuses, and `workflow_run_cancel` to stop a run by id. diff --git a/packages/worker/.env.example b/packages/worker/.env.example index a562fe78ba..ce1b68150f 100644 --- a/packages/worker/.env.example +++ b/packages/worker/.env.example @@ -90,5 +90,6 @@ X_CLIENT_SECRET=MOCK_X_CLIENT_SECRET # When unset, the webhook endpoint returns 503. # STRIPE_WEBHOOK_SECRET=whsec_... # STRIPE_API_BASE_URL=https://api.stripe.com +# STRIPE_STANDARD_PRICE_ID=price_... # STRIPE_PRO_PRICE_ID=price_... diff --git a/packages/worker/client/routes/account-billing.tsx b/packages/worker/client/routes/account-billing.tsx index 8424239723..bfb52f6f32 100644 --- a/packages/worker/client/routes/account-billing.tsx +++ b/packages/worker/client/routes/account-billing.tsx @@ -42,7 +42,7 @@ const billingCheckoutApiPath = '/account/billing/checkout.json' const billingPath = '/account/billing' const billingPortalPath = '/account/billing/portal' -type PaidTier = 'pro' +type PaidTier = 'standard' | 'pro' type PlanTier = 'free' | PaidTier type SubscriptionStatusTone = 'ok' | 'warn' | 'action' | 'muted' @@ -60,21 +60,28 @@ const planTiers: Array<{ 'Room to build real automations. Capped on daily volume, not on how much you build.', }, { - id: 'pro', - name: 'Pro', + id: 'standard', + name: 'Standard', price: '$5/month', description: 'Higher daily volume, more services, and persistent ones.', }, + { + id: 'pro', + name: 'Pro', + price: '$20/month', + description: + 'For heavy daily automation — roughly double Standard on every axis.', + }, ] -/** Mirrors server rank: free < pro < partner < max. */ +/** Mirrors server rank: free < standard < pro < max. */ function getPlanRank(plan: AdminPlanName): number { switch (plan) { case 'free': return 0 - case 'pro': + case 'standard': return 1 - case 'partner': + case 'pro': return 2 case 'max': return 3 @@ -131,7 +138,7 @@ function describeSubscriptionStatus(status: string): { return { label: 'Past due', detail: - 'Payment is past due, so paid plan limits are not active. Update your payment method in Manage subscription to restore Pro.', + 'Payment is past due, so paid plan limits are not active. Update your payment method in Manage subscription to restore your paid plan.', tone: 'action', } case 'unpaid': @@ -236,7 +243,8 @@ export function AccountBillingRoute(handle: Handle) { messageTone = payload.error ? 'error' : 'info' } - async function startCheckout() { + async function startCheckout(plan: PlanTier) { + if (plan === 'free') return if (checkoutPending) return checkoutPending = true message = null @@ -249,7 +257,7 @@ export function AccountBillingRoute(handle: Handle) { 'Content-Type': 'application/json', }, credentials: 'include', - body: JSON.stringify({}), + body: JSON.stringify({ plan }), }) const payload = await readJson<{ ok?: boolean @@ -340,12 +348,6 @@ export function AccountBillingRoute(handle: Handle) { : null const paymentActionNeeded = subscriptionStatus === 'past_due' || subscriptionStatus === 'unpaid' - const showSubscribeCta = Boolean( - billing?.checkoutAvailable && - billing && - !planCoversTier(billing.effectivePlan, 'pro') && - !paymentActionNeeded, - ) const showManageCta = Boolean( billing?.configured && billing.hasStripeCustomer, ) @@ -367,7 +369,7 @@ export function AccountBillingRoute(handle: Handle) { @@ -392,8 +394,9 @@ export function AccountBillingRoute(handle: Handle) { ) : null} {billing.configured && !billing.hasStripeCustomer ? ( - No Stripe customer is linked yet. Subscribe to Pro to create one - and manage billing in Stripe. + {billing.purchasablePlans.length > 0 + ? 'No Stripe customer is linked yet. Subscribe to a paid plan to create one and manage billing in Stripe.' + : 'No paid tier is configured for checkout on this deployment.'} ) : null} {billing.configured && billing.hasStripeCustomer ? ( @@ -457,7 +460,7 @@ export function AccountBillingRoute(handle: Handle) {
- {showSubscribeCta ? ( - - ) : null} {showManageCta ? ( void startCheckout()), + on('click', () => void startCheckout(tier.id)), css(primaryButtonCss), ]} > diff --git a/packages/worker/client/routes/admin-insights.tsx b/packages/worker/client/routes/admin-insights.tsx index 43038b47f5..a1e361fda4 100644 --- a/packages/worker/client/routes/admin-insights.tsx +++ b/packages/worker/client/routes/admin-insights.tsx @@ -45,7 +45,7 @@ const adminInsightsApiPath = '/admin/insights.json' const planColors: Record = { pro: chartColor.violet, free: chartColor.blue, - partner: chartColor.emerald, + standard: chartColor.emerald, none: chartColor.cyan, } diff --git a/packages/worker/client/routes/pricing.tsx b/packages/worker/client/routes/pricing.tsx index 7b20feb5a7..2ad3fa5ac4 100644 --- a/packages/worker/client/routes/pricing.tsx +++ b/packages/worker/client/routes/pricing.tsx @@ -26,8 +26,7 @@ const limitRows: ReadonlyArray = [ { label: 'Running package services', key: 'maxPackageServices' }, { label: 'Persistent package services', - key: 'packageServicePersistentAllowed', - format: (value) => (value === 1 ? 'Allowed' : 'Not included'), + key: 'maxPersistentPackageServices', }, { label: 'Active repo sessions', key: 'maxRepoSessions' }, { label: 'Email sends per day', key: 'maxEmailSendsPerDay' }, @@ -55,7 +54,8 @@ export function PricingRoute(_handle: Handle) {

Pricing

- Start free. Pro is $5 per month when you need more room to run. + Start free. Standard is $5 per month; Pro is $20 per month for heavy + daily automation.

@@ -74,9 +74,9 @@ export function PricingRoute(_handle: Handle) {
-
+
-

Pro

+

Standard

$5/month

@@ -87,6 +87,19 @@ export function PricingRoute(_handle: Handle) { Upgrade in Account settings

+ +
+
+

Pro

+

$20/month

+
+

+ For heavy daily automation — roughly double Standard on every axis. +

+ + Upgrade in Account settings + +
@@ -107,6 +120,9 @@ export function PricingRoute(_handle: Handle) { Free + + Standard + Pro @@ -121,6 +137,9 @@ export function PricingRoute(_handle: Handle) { {formatLimit(row, planLimits.free)} + + {formatLimit(row, planLimits.standard)} + {formatLimit(row, planLimits.pro)} @@ -132,11 +151,10 @@ export function PricingRoute(_handle: Handle) {
-

Invite and admin plans

+

Operator plan

Max is a finite, invite-only plan assigned by a Kody administrator; it - is not available for self-serve purchase. Some partners may also - receive an admin-assigned Partner plan. + is not available for self-serve purchase.

@@ -186,7 +204,7 @@ const planCardCss = { alignContent: 'space-between', } -const proPlanCardCss = { +const paidPlanCardCss = { ...planCardCss, borderColor: colors.primary, backgroundColor: colors.primarySoftest, diff --git a/packages/worker/migrations/0002-restructure-plan-tiers.sql b/packages/worker/migrations/0002-restructure-plan-tiers.sql new file mode 100644 index 0000000000..eb81e58d51 --- /dev/null +++ b/packages/worker/migrations/0002-restructure-plan-tiers.sql @@ -0,0 +1,306 @@ +-- Rename the paid tiers without double-applying either stored value: +-- old partner -> new pro, old pro -> new standard. The single CASE updates +-- users.plan, users.stripe_plan, and invites.plan from their pre-migration +-- meanings exactly once. +-- +-- SQLite cannot replace a CHECK constraint in place, so rebuild users and +-- invites. D1 wraps this file in a transaction with foreign keys enabled; +-- snapshot and clear every table that references users before dropping it, +-- then restore those rows after both parent tables are rebuilt. +-- +-- Fail closed before destructive work when any stored plan is outside the +-- known pre/post-rename vocabulary. AUTOINCREMENT preserves the users +-- high-water mark so deleted ids are never reused. + +PRAGMA defer_foreign_keys = TRUE; + +DROP TABLE IF EXISTS __migration_assertions; + +-- CHECK (0) is an intentionally unreachable assertion trap. +CREATE TABLE __migration_assertions ( + message TEXT NOT NULL CHECK (0) +); + +INSERT INTO __migration_assertions (message) +SELECT 'users.plan contains an unregistered value; aborting 0002.' +WHERE EXISTS ( + SELECT 1 + FROM users + WHERE plan NOT IN ('free', 'partner', 'pro', 'standard', 'max') +); + +INSERT INTO __migration_assertions (message) +SELECT 'users.stripe_plan contains an unregistered value; aborting 0002.' +WHERE EXISTS ( + SELECT 1 + FROM users + WHERE stripe_plan IS NOT NULL + AND stripe_plan NOT IN ('free', 'partner', 'pro', 'standard') +); + +INSERT INTO __migration_assertions (message) +SELECT 'invites.plan contains an unregistered value; aborting 0002.' +WHERE EXISTS ( + SELECT 1 + FROM invites + WHERE plan NOT IN ('free', 'partner', 'pro', 'standard', 'max') +); + +DROP TABLE __migration_assertions; + +CREATE TABLE _mig0002_password_resets AS SELECT * FROM password_resets; +CREATE TABLE _mig0002_email_verifications AS SELECT * FROM email_verifications; +CREATE TABLE _mig0002_pending_email_changes AS SELECT * FROM pending_email_changes; +CREATE TABLE _mig0002_passkeys AS SELECT * FROM passkeys; +CREATE TABLE _mig0002_oauth_connections AS SELECT * FROM oauth_connections; +CREATE TABLE _mig0002_user_roles AS SELECT * FROM user_roles; +CREATE TABLE _mig0002_invites AS SELECT * FROM invites; +CREATE TABLE _mig0002_feature_flags AS SELECT * FROM feature_flags; +CREATE TABLE _mig0002_feature_flag_user_overrides AS +SELECT * FROM feature_flag_user_overrides; +CREATE TABLE _mig0002_users_seq AS +SELECT seq FROM sqlite_sequence WHERE name = 'users'; + +DELETE FROM feature_flag_user_overrides; +DELETE FROM feature_flags; +DELETE FROM invites; +DELETE FROM user_roles; +DELETE FROM oauth_connections; +DELETE FROM passkeys; +DELETE FROM pending_email_changes; +DELETE FROM email_verifications; +DELETE FROM password_resets; + +CREATE TABLE users_next ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + email_verified_at TEXT, + plan TEXT NOT NULL DEFAULT 'free' CHECK ( + plan IN ('free', 'standard', 'pro', 'max') + ), + stable_user_id TEXT NOT NULL, + stripe_customer_id TEXT, + stripe_plan TEXT, + stripe_plan_refreshed_at TEXT, + display_name TEXT, + bio TEXT, + profile_visibility TEXT NOT NULL DEFAULT 'public' CHECK ( + profile_visibility IN ('public', 'private') + ), + avatar_key TEXT, + account_type TEXT NOT NULL DEFAULT 'person' CHECK ( + account_type IN ('person', 'platform') + ), + deleting_at TEXT, + active_write_count INTEGER NOT NULL DEFAULT 0, + active_write_expires_at TEXT, + suspended_at TEXT, + email_outbound_paused_at TEXT, + password_changed_at TEXT, + job_retention_success_once_days INTEGER CHECK ( + job_retention_success_once_days IS NULL + OR ( + job_retention_success_once_days >= 1 + AND job_retention_success_once_days <= 365 + ) + ), + job_retention_failed_once_days INTEGER CHECK ( + job_retention_failed_once_days IS NULL + OR ( + job_retention_failed_once_days >= 1 + AND job_retention_failed_once_days <= 365 + ) + ), + job_retention_disabled_recurring_days INTEGER CHECK ( + job_retention_disabled_recurring_days IS NULL + OR ( + job_retention_disabled_recurring_days >= 1 + AND job_retention_disabled_recurring_days <= 365 + ) + ) +); + +INSERT INTO users_next ( + id, + username, + email, + password_hash, + created_at, + updated_at, + email_verified_at, + plan, + stable_user_id, + stripe_customer_id, + stripe_plan, + stripe_plan_refreshed_at, + display_name, + bio, + profile_visibility, + avatar_key, + account_type, + deleting_at, + active_write_count, + active_write_expires_at, + suspended_at, + email_outbound_paused_at, + password_changed_at, + job_retention_success_once_days, + job_retention_failed_once_days, + job_retention_disabled_recurring_days +) +SELECT + id, + username, + email, + password_hash, + created_at, + updated_at, + email_verified_at, + CASE plan + WHEN 'partner' THEN 'pro' + WHEN 'pro' THEN 'standard' + ELSE plan + END, + stable_user_id, + stripe_customer_id, + CASE stripe_plan + WHEN 'partner' THEN 'pro' + WHEN 'pro' THEN 'standard' + ELSE stripe_plan + END, + stripe_plan_refreshed_at, + display_name, + bio, + profile_visibility, + avatar_key, + account_type, + deleting_at, + active_write_count, + active_write_expires_at, + suspended_at, + email_outbound_paused_at, + password_changed_at, + job_retention_success_once_days, + job_retention_failed_once_days, + job_retention_disabled_recurring_days +FROM users; + +DROP TABLE users; + +ALTER TABLE users_next RENAME TO users; + +UPDATE sqlite_sequence +SET seq = ( + SELECT MAX(value) FROM ( + SELECT seq AS value FROM sqlite_sequence WHERE name = 'users' + UNION ALL + SELECT seq AS value FROM _mig0002_users_seq + ) +) +WHERE name = 'users' + AND EXISTS (SELECT 1 FROM _mig0002_users_seq); + +INSERT INTO sqlite_sequence (name, seq) +SELECT 'users', seq FROM _mig0002_users_seq +WHERE EXISTS (SELECT 1 FROM _mig0002_users_seq) + AND NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = 'users'); + +DROP TABLE _mig0002_users_seq; + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_stable_user_id + ON users(stable_user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_stripe_customer_id + ON users(stripe_customer_id) + WHERE stripe_customer_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_deleting_at + ON users(deleting_at) + WHERE deleting_at IS NOT NULL; + +CREATE TABLE invites_next ( + code TEXT PRIMARY KEY NOT NULL, + created_by INTEGER, + note TEXT NOT NULL DEFAULT '', + max_uses INTEGER NOT NULL DEFAULT 1 CHECK (max_uses > 0), + use_count INTEGER NOT NULL DEFAULT 0 CHECK (use_count >= 0), + expires_at TEXT, + revoked_at TEXT, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + plan TEXT NOT NULL DEFAULT 'free' CHECK ( + plan IN ('free', 'standard', 'pro', 'max') + ), + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +); + +INSERT INTO invites_next ( + code, + created_by, + note, + max_uses, + use_count, + expires_at, + revoked_at, + created_at, + plan +) +SELECT + code, + created_by, + note, + max_uses, + use_count, + expires_at, + revoked_at, + created_at, + CASE plan + WHEN 'partner' THEN 'pro' + WHEN 'pro' THEN 'standard' + ELSE plan + END +FROM _mig0002_invites; + +DROP TABLE invites; + +ALTER TABLE invites_next RENAME TO invites; + +CREATE INDEX IF NOT EXISTS idx_invites_created_at ON invites(created_at); +CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by); +CREATE INDEX IF NOT EXISTS idx_invites_expires_at ON invites(expires_at); + +INSERT INTO password_resets SELECT * FROM _mig0002_password_resets; +INSERT INTO email_verifications SELECT * FROM _mig0002_email_verifications; +INSERT INTO pending_email_changes SELECT * FROM _mig0002_pending_email_changes; +INSERT INTO passkeys SELECT * FROM _mig0002_passkeys; +INSERT INTO oauth_connections SELECT * FROM _mig0002_oauth_connections; +INSERT INTO user_roles SELECT * FROM _mig0002_user_roles; +INSERT INTO feature_flags SELECT * FROM _mig0002_feature_flags; +INSERT INTO feature_flag_user_overrides +SELECT * FROM _mig0002_feature_flag_user_overrides; + +DROP TABLE _mig0002_password_resets; +DROP TABLE _mig0002_email_verifications; +DROP TABLE _mig0002_pending_email_changes; +DROP TABLE _mig0002_passkeys; +DROP TABLE _mig0002_oauth_connections; +DROP TABLE _mig0002_user_roles; +DROP TABLE _mig0002_invites; +DROP TABLE _mig0002_feature_flags; +DROP TABLE _mig0002_feature_flag_user_overrides; + +DROP TABLE IF EXISTS __migration_assertions; +CREATE TABLE __migration_assertions ( + message TEXT NOT NULL CHECK (0) +); + +INSERT INTO __migration_assertions (message) +SELECT 'legacy plan values remain after tier restructure; aborting 0002.' +WHERE EXISTS (SELECT 1 FROM users WHERE plan IN ('partner')) + OR EXISTS (SELECT 1 FROM users WHERE stripe_plan IN ('partner')) + OR EXISTS (SELECT 1 FROM invites WHERE plan IN ('partner')); + +DROP TABLE __migration_assertions; diff --git a/packages/worker/src/app/account-billing-data.node.test.ts b/packages/worker/src/app/account-billing-data.node.test.ts index 156b555edd..da450cbd42 100644 --- a/packages/worker/src/app/account-billing-data.node.test.ts +++ b/packages/worker/src/app/account-billing-data.node.test.ts @@ -81,6 +81,7 @@ test('loadAccountBillingData refreshes Stripe status and degrades when refresh i stripeCustomerId: 'cus_test', }), STRIPE_SECRET_KEY: 'sk_test', + STRIPE_STANDARD_PRICE_ID: 'price_standard', STRIPE_PRO_PRICE_ID: 'price_pro', } as Env @@ -98,7 +99,7 @@ test('loadAccountBillingData refreshes Stripe status and degrades when refresh i expect(data.subscriptionStatus).toBe('past_due') expect(data.cancelAt).toBe('2026-08-01T00:00:00.000Z') expect(data.usageHref).toBe('/account/usage') - expect(data.checkoutAvailable).toBe(true) + expect(data.purchasablePlans).toEqual(['standard', 'pro']) expect(refreshStripePlanForUser).toHaveBeenCalledWith( expect.objectContaining({ userId: 9, @@ -139,6 +140,7 @@ test('loadAccountBillingData refreshes Stripe status and degrades when refresh i expect(noCustomer.hasStripeCustomer).toBe(false) expect(noCustomer.subscriptionStatus).toBeNull() expect(noCustomer.configured).toBe(true) + expect(noCustomer.purchasablePlans).toEqual(['pro']) expect(refreshStripePlanForUser).not.toHaveBeenCalled() expect(scheduleStripePlanRefreshBackstop).toHaveBeenCalledTimes(2) }) diff --git a/packages/worker/src/app/account-billing-data.ts b/packages/worker/src/app/account-billing-data.ts index b9de203165..3656166478 100644 --- a/packages/worker/src/app/account-billing-data.ts +++ b/packages/worker/src/app/account-billing-data.ts @@ -1,6 +1,6 @@ import { type AccountBillingLoaderData } from '#app/loader-data.ts' import { - getProPriceId, + getPurchasablePlans, isBillingConfigured, } from '#worker/billing/billing-config.ts' import { scheduleStripePlanRefreshBackstop } from '#worker/billing/stripe-plan-refresh-client.ts' @@ -105,7 +105,7 @@ export async function loadAccountBillingData(input: { } } - const checkoutAvailable = configured && Boolean(getProPriceId(input.env)) + const purchasablePlans = configured ? getPurchasablePlans(input.env) : [] return { ok: true, @@ -116,7 +116,7 @@ export async function loadAccountBillingData(input: { hasStripeCustomer, cancelAt, subscriptionStatus, - checkoutAvailable, + purchasablePlans, usageHref: '/account/usage', ...(error ? { error } : {}), } diff --git a/packages/worker/src/app/handlers/account-billing.ts b/packages/worker/src/app/handlers/account-billing.ts index b22ca4e2a2..d417d8d30a 100644 --- a/packages/worker/src/app/handlers/account-billing.ts +++ b/packages/worker/src/app/handlers/account-billing.ts @@ -14,7 +14,7 @@ import { } from '#worker/billing/stripe-client.ts' import { createBillingLinkReference, - getProPriceId, + getPriceIdForPlan, isBillingConfigured, } from '#worker/billing/billing-config.ts' import { @@ -90,12 +90,23 @@ export function createAccountBillingCheckoutApiHandler(env: Env) { return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) } - const priceId = getProPriceId(env) + const body = (await request.json().catch(() => null)) as { + plan?: unknown + } | null + const plan = + body?.plan === 'standard' || body?.plan === 'pro' ? body.plan : null + if (!plan) { + return jsonResponse( + { ok: false, error: 'Choose Standard or Pro.' }, + 400, + ) + } + const priceId = getPriceIdForPlan(env, plan) if (!isBillingConfigured(env) || !priceId) { return jsonResponse( { ok: false, - error: 'Billing is not configured on this deployment.', + error: `${plan === 'standard' ? 'Standard' : 'Pro'} checkout is not configured on this deployment.`, }, 409, ) diff --git a/packages/worker/src/app/loader-data.ts b/packages/worker/src/app/loader-data.ts index f31334e803..9f20b7c139 100644 --- a/packages/worker/src/app/loader-data.ts +++ b/packages/worker/src/app/loader-data.ts @@ -285,7 +285,7 @@ export type AdminUsageEntitlementResource = | 'execute_calls_per_day' | 'outbound_fetches_per_day' -export type AdminPlanName = 'free' | 'partner' | 'pro' | 'max' +export type AdminPlanName = 'free' | 'standard' | 'pro' | 'max' export type AdminUsageRollup = { metric: AdminUsageMetric @@ -1261,7 +1261,7 @@ export type AccountBillingLoaderData = { cancelAt: string | null /** Stripe subscription status from on-page refresh; null if unknown/unavailable. */ subscriptionStatus: string | null - checkoutAvailable: boolean + purchasablePlans: Array<'standard' | 'pro'> /** Deep link to the account usage page (limits / consumption). */ usageHref: '/account/usage' error?: string diff --git a/packages/worker/src/billing/billing-config.node.test.ts b/packages/worker/src/billing/billing-config.node.test.ts index cf632a4a7e..3197a6ba82 100644 --- a/packages/worker/src/billing/billing-config.node.test.ts +++ b/packages/worker/src/billing/billing-config.node.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest' import { createBillingLinkReference, + getPurchasablePlans, resolveSubscriptionPlan, } from './billing-config.ts' import { type StripeSubscription } from './stripe-client.ts' @@ -41,6 +42,7 @@ test('createBillingLinkReference is stable per user and not the raw stable id', test('resolveSubscriptionPlan maps active price and metadata plans with soonest cancel_at', () => { const env = { + STRIPE_STANDARD_PRICE_ID: 'price_standard', STRIPE_PRO_PRICE_ID: 'price_pro', } @@ -64,6 +66,22 @@ test('resolveSubscriptionPlan maps active price and metadata plans with soonest subscriptionStatus: 'incomplete', }) + expect( + resolveSubscriptionPlan( + [ + subscription({ + status: 'active', + priceIds: ['price_standard'], + }), + ], + env, + ), + ).toEqual({ + stripePlan: 'standard', + cancelAt: null, + subscriptionStatus: 'active', + }) + expect( resolveSubscriptionPlan( [ @@ -107,12 +125,46 @@ test('resolveSubscriptionPlan maps active price and metadata plans with soonest ], env, ), + ).toEqual({ + stripePlan: 'standard', + cancelAt: null, + subscriptionStatus: 'active', + }) + + expect( + resolveSubscriptionPlan( + [ + subscription({ + status: 'active', + priceIds: ['price_other'], + metadata: { kody_plan: 'partner' }, + }), + ], + env, + ), ).toEqual({ stripePlan: 'pro', cancelAt: null, subscriptionStatus: 'active', }) + expect( + resolveSubscriptionPlan( + [ + subscription({ + status: 'active', + priceIds: ['price_standard'], + metadata: { kody_plan: 'partner' }, + }), + ], + env, + ), + ).toEqual({ + stripePlan: 'standard', + cancelAt: null, + subscriptionStatus: 'active', + }) + expect( resolveSubscriptionPlan( [ @@ -212,3 +264,15 @@ test('resolveSubscriptionPlan maps active price and metadata plans with soonest subscriptionStatus: 'past_due', }) }) + +test('price ids independently control purchasable tiers', () => { + expect( + getPurchasablePlans({ + STRIPE_STANDARD_PRICE_ID: 'price_standard', + }), + ).toEqual(['standard']) + expect(getPurchasablePlans({ STRIPE_PRO_PRICE_ID: 'price_pro' })).toEqual([ + 'pro', + ]) + expect(getPurchasablePlans({})).toEqual([]) +}) diff --git a/packages/worker/src/billing/billing-config.ts b/packages/worker/src/billing/billing-config.ts index daa1cdbaa6..9e029fb85a 100644 --- a/packages/worker/src/billing/billing-config.ts +++ b/packages/worker/src/billing/billing-config.ts @@ -8,6 +8,7 @@ import { type StripeSubscription } from './stripe-client.ts' type BillingEnv = { STRIPE_SECRET_KEY?: string + STRIPE_STANDARD_PRICE_ID?: string STRIPE_PRO_PRICE_ID?: string } @@ -33,10 +34,39 @@ export function isBillingConfigured(env: BillingEnv) { return Boolean(env.STRIPE_SECRET_KEY?.trim()) } +export function getStandardPriceId(env: BillingEnv) { + return env.STRIPE_STANDARD_PRICE_ID?.trim() || null +} + export function getProPriceId(env: BillingEnv) { return env.STRIPE_PRO_PRICE_ID?.trim() || null } +export function getPurchasablePlans( + env: BillingEnv, +): Array<'standard' | 'pro'> { + return [ + ...(getStandardPriceId(env) ? (['standard'] as const) : []), + ...(getProPriceId(env) ? (['pro'] as const) : []), + ] +} + +export function getPriceIdForPlan( + env: BillingEnv, + plan: 'standard' | 'pro', +): string | null { + switch (plan) { + case 'standard': + return getStandardPriceId(env) + case 'pro': + return getProPriceId(env) + default: { + const exhaustive: never = plan + throw new Error(`Unknown purchasable plan: ${String(exhaustive)}`) + } + } +} + /** * Unguessable per-user checkout attribution value. The stable user id * alone is NOT sufficient (it is a plain SHA-256 of the account email, so @@ -75,17 +105,23 @@ function pickHigherPlan( function planFromSubscription( subscription: StripeSubscription, + standardPriceId: string | null, proPriceId: string | null, ): PlanName | null { - if (proPriceId) { - for (const item of subscription.items.data) { - if (item.price.id === proPriceId) { - return 'pro' - } + let matchedStandardPrice = false + for (const item of subscription.items.data) { + if (proPriceId && item.price.id === proPriceId) return 'pro' + if (standardPriceId && item.price.id === standardPriceId) { + matchedStandardPrice = true } } + if (matchedStandardPrice) return 'standard' + + // Price ids are the current, unambiguous source of truth. `kody_plan` + // metadata was written before the tier rename, so old partner means new + // pro and old pro means standard. const metadataPlan = subscription.metadata?.['kody_plan'] - return parseStripePlanName(metadataPlan) + return parseStripePlanName(metadataPlan, { legacyMetadata: true }) } function pickSubscriptionStatus( @@ -130,6 +166,7 @@ export function resolveSubscriptionPlan( subscriptions: ReadonlyArray, env: BillingEnv, ): ResolvedSubscriptionPlan { + const standardPriceId = getStandardPriceId(env) const proPriceId = getProPriceId(env) let stripePlan: PlanName | null = null let soonestCancelAt: number | null = null @@ -138,7 +175,7 @@ export function resolveSubscriptionPlan( if (!activeSubscriptionStatuses.has(subscription.status)) continue stripePlan = pickHigherPlan( stripePlan, - planFromSubscription(subscription, proPriceId), + planFromSubscription(subscription, standardPriceId, proPriceId), ) if ( typeof subscription.cancel_at === 'number' && diff --git a/packages/worker/src/blog/posts/zero-inference-calls.md b/packages/worker/src/blog/posts/zero-inference-calls.md index 744dcf78f5..f34b26ae35 100644 --- a/packages/worker/src/blog/posts/zero-inference-calls.md +++ b/packages/worker/src/blog/posts/zero-inference-calls.md @@ -69,10 +69,10 @@ to keep alive. A frontier model loop does not. That's why the pricing looks the way it does: a free $0 tier with tight limits, -and a Pro tier at $5/month with higher limits. Kody -doesn't need to charge like a model company because it doesn't spend like one. -When your costs are Workers primitives instead of GPU time, you can price like a -utility instead of an oracle. +Standard at $5/month, and Pro at $20/month for heavier +daily automation. Kody doesn't need to charge like a model company because it +doesn't spend like one. When your costs are Workers primitives instead of GPU +time, you can price like a utility instead of an oracle. ## The free upgrades nobody had to build diff --git a/packages/worker/src/entitlements/entitlements.node.test.ts b/packages/worker/src/entitlements/entitlements.node.test.ts index 9167f80f73..6c8a22c75b 100644 --- a/packages/worker/src/entitlements/entitlements.node.test.ts +++ b/packages/worker/src/entitlements/entitlements.node.test.ts @@ -6,7 +6,13 @@ import { buildEntitlementUpgradeHint, parseEntitlementLimitMessage, } from './errors.ts' -import { planLimits, resolvePlanLimit } from './plans.ts' +import { + getPlanRank, + parseStripePlanName, + planLimits, + planNames, + resolvePlanLimit, +} from './plans.ts' import { assertWithinEntitlement, assertWithinStorageBytesEntitlement, @@ -592,10 +598,95 @@ test('assertWithinEntitlement enforces concurrent workflow limits without email }) }) -test('persistent package services are gated as a 0/1 limit', async () => { +test('plan registry, ranks, and limits match the published tier contract', () => { + expect(planNames).toEqual(['free', 'standard', 'pro', 'max']) + expect(planNames.map(getPlanRank)).toEqual([0, 1, 2, 3]) + expect(planLimits).toEqual({ + free: { + maxRepos: 20, + maxSavedPackages: 25, + maxScheduledJobs: 10, + maxPackageServices: 1, + maxPersistentPackageServices: 0, + maxRepoSessions: 50, + maxEmailSendsPerDay: 10, + maxEmailReceivesPerDay: 50, + maxStoredEmailMessages: 500, + maxEmailMessageBytes: 256 * 1024, + maxSecrets: 25, + maxStorageBytes: 64 * 1024 * 1024, + maxConcurrentWorkflows: 3, + maxExecuteCallsPerDay: 500, + maxOutboundFetchesPerDay: 2_000, + }, + standard: { + maxRepos: 200, + maxSavedPackages: 100, + maxScheduledJobs: 50, + maxPackageServices: 10, + maxPersistentPackageServices: 1, + maxRepoSessions: 200, + maxEmailSendsPerDay: 200, + maxEmailReceivesPerDay: 1_000, + maxStoredEmailMessages: 10_000, + maxEmailMessageBytes: 768 * 1024, + maxSecrets: 100, + maxStorageBytes: 1024 * 1024 * 1024, + maxConcurrentWorkflows: 50, + maxExecuteCallsPerDay: 5_000, + maxOutboundFetchesPerDay: 20_000, + }, + pro: { + maxRepos: 400, + maxSavedPackages: 200, + maxScheduledJobs: 150, + maxPackageServices: 20, + maxPersistentPackageServices: 3, + maxRepoSessions: 400, + maxEmailSendsPerDay: 500, + maxEmailReceivesPerDay: 2_000, + maxStoredEmailMessages: 25_000, + maxEmailMessageBytes: 768 * 1024, + maxSecrets: 200, + maxStorageBytes: 5 * 1024 * 1024 * 1024, + maxConcurrentWorkflows: 100, + maxExecuteCallsPerDay: 10_000, + maxOutboundFetchesPerDay: 40_000, + }, + max: { + maxRepos: 10_000, + maxSavedPackages: 10_000, + maxScheduledJobs: 5_000, + maxPackageServices: 1_000, + maxPersistentPackageServices: 10, + maxRepoSessions: 20_000, + maxEmailSendsPerDay: 10_000, + maxEmailReceivesPerDay: 20_000, + maxStoredEmailMessages: 100_000, + maxEmailMessageBytes: 768 * 1024, + maxSecrets: 10_000, + maxStorageBytes: 100 * 1024 * 1024 * 1024, + maxConcurrentWorkflows: 5_000, + maxExecuteCallsPerDay: 500_000, + maxOutboundFetchesPerDay: 2_000_000, + }, + }) +}) + +test('Stripe plan parsing separates current stored values from legacy metadata', () => { + expect(parseStripePlanName('standard')).toBe('standard') + expect(parseStripePlanName('pro')).toBe('pro') + expect(parseStripePlanName('partner')).toBeNull() + expect(parseStripePlanName('max')).toBeNull() + expect(parseStripePlanName('partner', { legacyMetadata: true })).toBe('pro') + expect(parseStripePlanName('pro', { legacyMetadata: true })).toBe('standard') +}) + +test('persistent package services use finite concurrent counts', async () => { const userId = await createStableUserIdFromEmail(plannedEmail) expect(resolvePlanLimit('free', 'persistent_package_services')).toBe(0) - expect(resolvePlanLimit('pro', 'persistent_package_services')).toBe(1) + expect(resolvePlanLimit('standard', 'persistent_package_services')).toBe(1) + expect(resolvePlanLimit('pro', 'persistent_package_services')).toBe(3) const { db } = createEntitlementsTestDb({ users: [{ email: plannedEmail, plan: 'free', stable_user_id: userId }], @@ -605,6 +696,7 @@ test('persistent package services are gated as a 0/1 limit', async () => { userId, email: plannedEmail, resource: 'persistent_package_services', + getCurrent: async () => 0, }).then( () => null, (thrown: unknown) => thrown, @@ -617,14 +709,15 @@ test('persistent package services are gated as a 0/1 limit', async () => { limit: 0, }) - const proDb = createEntitlementsTestDb({ - users: [{ email: plannedEmail, plan: 'pro', stable_user_id: userId }], + const standardDb = createEntitlementsTestDb({ + users: [{ email: plannedEmail, plan: 'standard', stable_user_id: userId }], }) await assertWithinEntitlement({ - db: proDb.db, + db: standardDb.db, userId, email: plannedEmail, resource: 'persistent_package_services', + getCurrent: async () => 0, }) }) @@ -1256,28 +1349,30 @@ test('entitlement enforcement stops when a stored plan violates the schema contr }) test('getUserPlan resolves effective plan from manual plan and stripe_plan', async () => { - const freePlusProEmail = 'manual-free-stripe-pro@example.com' - const proPlusPartnerEmail = 'manual-pro-stripe-partner@example.com' + const freePlusStandardEmail = 'manual-free-stripe-standard@example.com' + const standardPlusProEmail = 'manual-standard-stripe-pro@example.com' const unlimitedPlusProEmail = 'manual-unlimited-stripe-pro@example.com' - const freePlusProUserId = await createStableUserIdFromEmail(freePlusProEmail) - const proPlusPartnerUserId = - await createStableUserIdFromEmail(proPlusPartnerEmail) + const freePlusStandardUserId = await createStableUserIdFromEmail( + freePlusStandardEmail, + ) + const standardPlusProUserId = + await createStableUserIdFromEmail(standardPlusProEmail) const unlimitedPlusProUserId = await createStableUserIdFromEmail( unlimitedPlusProEmail, ) const { db } = createEntitlementsTestDb({ users: [ { - email: freePlusProEmail, + email: freePlusStandardEmail, plan: 'free', - stripe_plan: 'pro', - stable_user_id: freePlusProUserId, + stripe_plan: 'standard', + stable_user_id: freePlusStandardUserId, }, { - email: proPlusPartnerEmail, - plan: 'pro', - stripe_plan: 'partner', - stable_user_id: proPlusPartnerUserId, + email: standardPlusProEmail, + plan: 'standard', + stripe_plan: 'pro', + stable_user_id: standardPlusProUserId, }, { email: unlimitedPlusProEmail, @@ -1290,16 +1385,16 @@ test('getUserPlan resolves effective plan from manual plan and stripe_plan', asy expect( await getUserPlan(db, { - userId: freePlusProUserId, - email: freePlusProEmail, + userId: freePlusStandardUserId, + email: freePlusStandardEmail, }), - ).toBe('pro') + ).toBe('standard') expect( await getUserPlan(db, { - userId: proPlusPartnerUserId, - email: proPlusPartnerEmail, + userId: standardPlusProUserId, + email: standardPlusProEmail, }), - ).toBe('partner') + ).toBe('pro') expect( await getUserPlan(db, { userId: unlimitedPlusProUserId, diff --git a/packages/worker/src/entitlements/plan-tier-migration.node.test.ts b/packages/worker/src/entitlements/plan-tier-migration.node.test.ts new file mode 100644 index 0000000000..56dca10105 --- /dev/null +++ b/packages/worker/src/entitlements/plan-tier-migration.node.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { expect, test } from 'vitest' + +const migrationsDirectory = new URL('../../migrations/', import.meta.url) +const baselineSql = readFileSync( + new URL('0001-squashed-init.sql', migrationsDirectory), + 'utf8', +) +const tierMigrationSql = readFileSync( + new URL('0002-restructure-plan-tiers.sql', migrationsDirectory), + 'utf8', +) + +function insertUser( + db: DatabaseSync, + input: { id: number; plan: string; stripePlan: string | null }, +) { + db.prepare( + `INSERT INTO users ( + id, username, email, password_hash, plan, stable_user_id, stripe_plan + ) VALUES (?, ?, ?, 'hash', ?, ?, ?)`, + ).run( + input.id, + `user-${input.id}`, + `user-${input.id}@example.com`, + input.plan, + `stable-${input.id}`, + input.stripePlan, + ) +} + +test('tier migration renames stored plans, rebuilds checks, and preserves references', () => { + const db = new DatabaseSync(':memory:') + db.exec(baselineSql) + insertUser(db, { id: 10, plan: 'pro', stripePlan: 'partner' }) + insertUser(db, { id: 20, plan: 'partner', stripePlan: 'pro' }) + db.prepare( + `INSERT INTO invites (code, created_by, plan) + VALUES ('old-pro', 10, 'pro'), ('old-partner', 20, 'partner')`, + ).run() + db.prepare(`INSERT INTO user_roles (user_id, role_id) VALUES (10, 1)`).run() + + db.exec(tierMigrationSql) + + expect( + db.prepare(`SELECT id, plan, stripe_plan FROM users ORDER BY id`).all(), + ).toEqual([ + { id: 10, plan: 'standard', stripe_plan: 'pro' }, + { id: 20, plan: 'pro', stripe_plan: 'standard' }, + ]) + expect( + db.prepare(`SELECT code, plan FROM invites ORDER BY code`).all(), + ).toEqual([ + { code: 'old-partner', plan: 'pro' }, + { code: 'old-pro', plan: 'standard' }, + ]) + expect(db.prepare(`SELECT user_id, role_id FROM user_roles`).get()).toEqual({ + user_id: 10, + role_id: 1, + }) + expect( + String( + db.prepare(`SELECT sql FROM sqlite_master WHERE name = 'users'`).get() + ?.sql, + ), + ).toContain(`plan IN ('free', 'standard', 'pro', 'max')`) + expect(() => + insertUser(db, { id: 30, plan: 'partner', stripePlan: null }), + ).toThrow(/CHECK constraint failed/) +}) + +test('tier migration fails closed before destructive work on unknown values', () => { + const db = new DatabaseSync(':memory:') + db.exec(baselineSql) + db.exec(`PRAGMA ignore_check_constraints = ON`) + insertUser(db, { + id: 10, + plan: 'enterprise-2099', + stripePlan: null, + }) + db.exec(`PRAGMA ignore_check_constraints = OFF`) + + expect(() => db.exec(tierMigrationSql)).toThrow(/CHECK constraint failed/) + expect(db.prepare(`SELECT plan FROM users WHERE id = 10`).get()).toEqual({ + plan: 'enterprise-2099', + }) +}) diff --git a/packages/worker/src/entitlements/plans.ts b/packages/worker/src/entitlements/plans.ts index 46b4451e7d..f24d4cf3c4 100644 --- a/packages/worker/src/entitlements/plans.ts +++ b/packages/worker/src/entitlements/plans.ts @@ -14,7 +14,7 @@ * registry is finite `max` only. */ -export const planNames = ['free', 'partner', 'pro', 'max'] as const +export const planNames = ['free', 'standard', 'pro', 'max'] as const export type PlanName = (typeof planNames)[number] @@ -44,13 +44,24 @@ export function parseStoredPlanName(value: unknown): PlanName { } /** - * Parse a plan name that may come from Stripe metadata or `users.stripe_plan`. - * `max` is manual-only (never purchasable or Stripe-sourced). Residual - * `'unlimited'` is also rejected. `stripe_plan` stays nullable; unknown / - * null / `max` / `'unlimited'` values contribute nothing. + * Parse a current Stripe plan name or legacy `kody_plan` subscription + * metadata. `max` is manual-only (never purchasable or Stripe-sourced). + * Legacy metadata predates the tier rename, so old `partner` maps to new + * `pro` and old `pro` maps to `standard`. Current `users.stripe_plan` values + * are parsed without that compatibility mapping. */ -export function parseStripePlanName(value: unknown): PlanName | null { - const plan = parsePlanName(value) +export function parseStripePlanName( + value: unknown, + options: { legacyMetadata?: boolean } = {}, +): PlanName | null { + const normalizedValue = options.legacyMetadata + ? value === 'partner' + ? 'pro' + : value === 'pro' + ? 'standard' + : value + : value + const plan = parsePlanName(normalizedValue) return plan === 'max' ? null : plan } @@ -66,15 +77,15 @@ export function resolvePlanWrite(plan: PlanName | null | undefined): PlanName { /** * Rank order for comparing manual grants vs Stripe subscription plans. - * Higher rank wins. free(0) < pro(1) < partner(2) < max(3). + * Higher rank wins. free(0) < standard(1) < pro(2) < max(3). */ export function getPlanRank(plan: PlanName): number { switch (plan) { case 'free': return 0 - case 'pro': + case 'standard': return 1 - case 'partner': + case 'pro': return 2 case 'max': return 3 @@ -113,11 +124,8 @@ export type PlanLimits = { maxScheduledJobs: number /** Maximum concurrently running package services. */ maxPackageServices: number - /** - * Finite 0/1 gate for services declared with mode `persistent`. - * 0 = not allowed; 1 = allowed. - */ - packageServicePersistentAllowed: number + /** Maximum concurrently running services declared with mode `persistent`. */ + maxPersistentPackageServices: number /** Maximum active repo sessions (repo_sessions with status 'active'). */ maxRepoSessions: number /** Maximum outbound email send attempts per UTC day. */ @@ -198,7 +206,7 @@ export const entitlementResourceLabels: Record = { * intentional abuse backstops (not the ordinary 100×-pro derivation used * for other max ceilings), but they dominate every other plan's email * limits so granting `max` never reduces a user's email capacity. - * `email_message_bytes` is pinned to pro/partner parity because the + * `email_message_bytes` is pinned to standard/pro parity because the * per-message ceiling is a platform bound (see the PlanLimits field doc), * not a scalable quota. */ @@ -218,10 +226,10 @@ export type MaxPlanEmailResource = keyof typeof maxPlanEmailLimits * subscriptions onto these plan names but the limit numbers stay * independent of pricing. * - * Ordinary `max` ceilings are an explicit product choice: 100× the - * corresponding `pro` limit (exact product for integer counters; storage - * uses 100× of pro's 1 GiB → 100 GiB). Email resources intentionally use - * {@link maxPlanEmailLimits} abuse caps instead. + * Ordinary `max` ceilings are explicit product choices based on the new + * `pro` plan. Most retain the previous ceilings even where that is 25× or + * 50× pro rather than a uniform multiplier. Email resources intentionally + * use {@link maxPlanEmailLimits} abuse caps instead. */ export const planLimits: Record = { // Free is deliberately generous on *counts* and unchanged on *rates*. @@ -242,9 +250,9 @@ export const planLimits: Record = { maxRepos: 20, maxSavedPackages: 25, maxScheduledJobs: 10, - // Long-lived compute. Unchanged, and persistent services stay off. + // Long-lived compute. Persistent services stay off. maxPackageServices: 1, - packageServicePersistentAllowed: 0, + maxPersistentPackageServices: 0, // Sessions are cheap (D1 row + dormant DO workspace + Artifacts // branch) and the 7-day abandoned-session sweep keeps idle ones from // accumulating, so the caps are sized for agent workflows that open @@ -257,7 +265,7 @@ export const planLimits: Record = { maxEmailReceivesPerDay: 50, maxStoredEmailMessages: 500, maxEmailMessageBytes: 256 * 1024, - maxSecrets: 15, + maxSecrets: 25, maxStorageBytes: 64 * 1024 * 1024, // Compute. Unchanged. maxConcurrentWorkflows: 3, @@ -265,15 +273,15 @@ export const planLimits: Record = { maxExecuteCallsPerDay: 500, maxOutboundFetchesPerDay: 2_000, }, - pro: { + standard: { maxRepos: 200, maxSavedPackages: 100, maxScheduledJobs: 50, maxPackageServices: 10, - packageServicePersistentAllowed: 1, + maxPersistentPackageServices: 1, maxRepoSessions: 200, maxEmailSendsPerDay: 200, - maxEmailReceivesPerDay: 1000, + maxEmailReceivesPerDay: 1_000, maxStoredEmailMessages: 10_000, maxEmailMessageBytes: 768 * 1024, maxSecrets: 100, @@ -282,15 +290,15 @@ export const planLimits: Record = { maxExecuteCallsPerDay: 5_000, maxOutboundFetchesPerDay: 20_000, }, - partner: { + pro: { maxRepos: 400, maxSavedPackages: 200, - maxScheduledJobs: 100, + maxScheduledJobs: 150, maxPackageServices: 20, - packageServicePersistentAllowed: 1, + maxPersistentPackageServices: 3, maxRepoSessions: 400, maxEmailSendsPerDay: 500, - maxEmailReceivesPerDay: 2000, + maxEmailReceivesPerDay: 2_000, maxStoredEmailMessages: 25_000, maxEmailMessageBytes: 768 * 1024, maxSecrets: 200, @@ -300,32 +308,32 @@ export const planLimits: Record = { maxOutboundFetchesPerDay: 40_000, }, max: { - // 100× pro (200) → 20_000; product placeholder uses 10_000 per spec. + // 25× pro (400) → 10_000. maxRepos: 10_000, - // 100× pro (100) → 10_000. + // 50× pro (200) → 10_000. maxSavedPackages: 10_000, - // 100× pro (50) → 5_000. + // Product ceiling (about 33× pro). maxScheduledJobs: 5_000, - // 100× pro (10) → 1_000. + // 50× pro (20) → 1_000. maxPackageServices: 1_000, - // Same finite 0/1 gate as pro (1 = persistent services allowed). - packageServicePersistentAllowed: 1, - // 100× pro (200) → 20_000. + // Product ceiling for no-duration-cap service runs. + maxPersistentPackageServices: 10, + // 50× pro (400) → 20_000. maxRepoSessions: 20_000, // Inherited abuse caps (not 100× pro); see maxPlanEmailLimits. maxEmailSendsPerDay: maxPlanEmailLimits.email_sends_per_day, maxEmailReceivesPerDay: maxPlanEmailLimits.email_receives_per_day, maxStoredEmailMessages: maxPlanEmailLimits.stored_email_messages, maxEmailMessageBytes: maxPlanEmailLimits.email_message_bytes, - // 100× pro (100) → 10_000. + // 50× pro (200) → 10_000. maxSecrets: 10_000, - // 100× pro (1 GiB) → 100 GiB. + // 20× pro (5 GiB) → 100 GiB. maxStorageBytes: 100 * 1024 * 1024 * 1024, - // 100× pro (50) → 5_000 (explicit product choice). + // 50× pro (100) → 5_000. maxConcurrentWorkflows: 5_000, - // 100× pro (5_000) → 500_000. + // 50× pro (10_000) → 500_000. maxExecuteCallsPerDay: 500_000, - // 100× pro (20_000) → 2_000_000. + // 50× pro (40_000) → 2_000_000. maxOutboundFetchesPerDay: 2_000_000, }, } @@ -350,8 +358,7 @@ export function resolveEmailResourceLimit( /** * Resolve the numeric limit for a resource under a plan. Every plan limit - * is finite. Boolean-style allowances (persistent services) are expressed - * as a 0/1 gate so every enforcement point uses the same numeric contract. + * is finite, including persistent-service concurrency counts. */ export function resolvePlanLimit( plan: PlanName, @@ -368,7 +375,7 @@ export function resolvePlanLimit( case 'package_services': return limits.maxPackageServices case 'persistent_package_services': - return limits.packageServicePersistentAllowed + return limits.maxPersistentPackageServices case 'repo_sessions': return limits.maxRepoSessions case 'email_sends_per_day': diff --git a/packages/worker/src/entitlements/service.ts b/packages/worker/src/entitlements/service.ts index 11f09d5595..d42fddbf51 100644 --- a/packages/worker/src/entitlements/service.ts +++ b/packages/worker/src/entitlements/service.ts @@ -702,6 +702,7 @@ export async function countRunningPackageServices(input: { env: UserMeterEnv userId: string excludeService?: { packageId: string; serviceName: string } + mode?: 'bounded' | 'persistent' now?: Date }): Promise { const now = input.now ?? new Date() @@ -709,6 +710,7 @@ export async function countRunningPackageServices(input: { const { count } = await meter.countRunningPackageServices({ staleAfterMs: packageServiceStateStaleMs, excludeService: input.excludeService, + mode: input.mode, now: now.toISOString(), }) return count @@ -748,9 +750,9 @@ export async function readEntitlementResourceUsage(input: { 'package_services usage must be read from UserMeter (use readCurrentEntitlementResourceUsage or pass getCurrent with countRunningPackageServices).', ) case 'persistent_package_services': - // Finite 0/1 gate: the limit is 0 (not allowed) or 1 (allowed), - // so the current count never changes the outcome. - return 0 + throw new Error( + 'persistent_package_services usage must be read from UserMeter (use readCurrentEntitlementResourceUsage or pass getCurrent with countRunningPackageServices).', + ) case 'repo_sessions': return await countRows( db, @@ -886,6 +888,14 @@ export async function readCurrentEntitlementResourceUsage(input: { now: input.now, }) } + if (input.resource === 'persistent_package_services') { + return await countRunningPackageServices({ + env: input.env, + userId: input.userId, + mode: 'persistent', + now: input.now, + }) + } if (input.resource === 'stored_email_messages') { return await countInternalUserEmailMessages({ env: input.env, diff --git a/packages/worker/src/entitlements/user-meter-do.ts b/packages/worker/src/entitlements/user-meter-do.ts index 33405bf4ce..40e018b0aa 100644 --- a/packages/worker/src/entitlements/user-meter-do.ts +++ b/packages/worker/src/entitlements/user-meter-do.ts @@ -31,7 +31,7 @@ export const userMeterDailyCounterRetentionDays = 7 const metaSchemaVersionKey = 'schema_version' /** Bump when initializeSchema DDL changes; warm objects skip DDL. */ -const userMeterSchemaVersion = 8 +const userMeterSchemaVersion = 9 /** Singleton row id for authoritative storage-byte state (schema v4). */ const storageBytesStateRowId = 1 /** Singleton row id for deletion fence / write leases (schema v6+). */ @@ -55,6 +55,8 @@ const packageServiceSourceUpdatedAtPattern = const packageServiceStatuses = ['running', 'idle', 'stopped', 'error'] as const export type UserMeterPackageServiceStatus = (typeof packageServiceStatuses)[number] +const packageServiceModes = ['bounded', 'persistent'] as const +export type UserMeterPackageServiceMode = (typeof packageServiceModes)[number] export function isUserMeterPackageServiceStatus( status: string, @@ -148,6 +150,7 @@ export type UserMeterPackageServiceState = { packageId: string serviceName: string status: UserMeterPackageServiceStatus + mode: UserMeterPackageServiceMode startedAt: string | null sourceUpdatedAt: string revision: number @@ -297,6 +300,7 @@ type PackageServiceSqlRow = { package_id: string service_name: string status: string + mode: string started_at: string | null source_updated_at: string revision: number @@ -377,6 +381,15 @@ function assertPackageServiceStatus( return status } +function assertPackageServiceMode(mode: string): UserMeterPackageServiceMode { + if (!(packageServiceModes as ReadonlyArray).includes(mode)) { + throw new Error( + `UserMeter package service mode must be one of ${packageServiceModes.join(', ')}; got ${JSON.stringify(mode)}.`, + ) + } + return mode as UserMeterPackageServiceMode +} + function assertSourceUpdatedAt(sourceUpdatedAt: string): string { if ( typeof sourceUpdatedAt !== 'string' || @@ -625,12 +638,13 @@ class UserMeterBase extends DurableObject { updated_at TEXT NOT NULL ) `) - // Authoritative package-service liveness state (schema v5). + // Authoritative package-service liveness state (schema v5; mode at v9). this.ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS package_service_states ( package_id TEXT NOT NULL, service_name TEXT NOT NULL, status TEXT NOT NULL, + mode TEXT NOT NULL DEFAULT 'persistent', started_at TEXT, source_updated_at TEXT NOT NULL, revision INTEGER NOT NULL, @@ -638,6 +652,19 @@ class UserMeterBase extends DurableObject { PRIMARY KEY (package_id, service_name) ) `) + if (version != null && version < 9) { + try { + // Treat warm legacy rows as persistent until the owning service + // heartbeat records its exact mode. Temporary over-counting is + // fail-closed for the no-duration-cap concurrency limit. + this.ctx.storage.sql.exec( + `ALTER TABLE package_service_states + ADD COLUMN mode TEXT NOT NULL DEFAULT 'persistent'`, + ) + } catch { + // Column already present on a partially migrated object. + } + } this.ctx.storage.sql.exec( `CREATE INDEX IF NOT EXISTS idx_package_service_states_status_source ON package_service_states (status, source_updated_at)`, @@ -1223,10 +1250,12 @@ class UserMeterBase extends DurableObject { ): UserMeterPackageServiceState { const revision = Math.max(0, Number(row.revision ?? 0)) const status = assertPackageServiceStatus(String(row.status)) + const mode = assertPackageServiceMode(String(row.mode)) return { packageId: String(row.package_id), serviceName: String(row.service_name), status, + mode, startedAt: status === 'running' && row.started_at != null ? String(row.started_at) @@ -1244,7 +1273,7 @@ class UserMeterBase extends DurableObject { ): UserMeterPackageServiceState | null { const row = this.ctx.storage.sql .exec( - `SELECT package_id, service_name, status, started_at, + `SELECT package_id, service_name, status, mode, started_at, source_updated_at, revision, updated_at FROM package_service_states WHERE package_id = ? AND service_name = ?`, @@ -1259,7 +1288,7 @@ class UserMeterBase extends DurableObject { private listAllPackageServiceRows(): Array { const rows = this.ctx.storage.sql .exec( - `SELECT package_id, service_name, status, started_at, + `SELECT package_id, service_name, status, mode, started_at, source_updated_at, revision, updated_at FROM package_service_states ORDER BY package_id ASC, service_name ASC`, @@ -1276,6 +1305,7 @@ class UserMeterBase extends DurableObject { packageId: string serviceName: string status: string + mode?: string startedAt?: string | null sourceUpdatedAt: string updatedAt?: string @@ -1283,6 +1313,9 @@ class UserMeterBase extends DurableObject { const packageId = assertPackageServiceId('packageId', input.packageId) const serviceName = assertPackageServiceId('serviceName', input.serviceName) const status = assertPackageServiceStatus(input.status) + // Repair callers and pre-v9 RPC clients omit mode. Counting them as + // persistent is conservative until a lifecycle heartbeat corrects it. + const mode = assertPackageServiceMode(input.mode ?? 'persistent') const sourceUpdatedAt = assertSourceUpdatedAt(input.sourceUpdatedAt) const updatedAt = typeof input.updatedAt === 'string' && input.updatedAt.length > 0 @@ -1306,12 +1339,13 @@ class UserMeterBase extends DurableObject { if (!existing) { this.ctx.storage.sql.exec( `INSERT INTO package_service_states ( - package_id, service_name, status, started_at, + package_id, service_name, status, mode, started_at, source_updated_at, revision, updated_at - ) VALUES (?, ?, ?, ?, ?, 1, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, 1, ?)`, packageId, serviceName, status, + mode, startedAt, sourceUpdatedAt, updatedAt, @@ -1329,12 +1363,14 @@ class UserMeterBase extends DurableObject { this.ctx.storage.sql.exec( `UPDATE package_service_states SET status = ?, + mode = ?, started_at = ?, source_updated_at = ?, revision = ?, updated_at = ? WHERE package_id = ? AND service_name = ? AND revision = ?`, status, + mode, startedAt, sourceUpdatedAt, nextRevision, @@ -1381,7 +1417,7 @@ class UserMeterBase extends DurableObject { const rows = ( cursor ? this.ctx.storage.sql.exec( - `SELECT package_id, service_name, status, started_at, + `SELECT package_id, service_name, status, mode, started_at, source_updated_at, revision, updated_at FROM package_service_states WHERE package_id > ? @@ -1394,7 +1430,7 @@ class UserMeterBase extends DurableObject { pageSize + 1, ) : this.ctx.storage.sql.exec( - `SELECT package_id, service_name, status, started_at, + `SELECT package_id, service_name, status, mode, started_at, source_updated_at, revision, updated_at FROM package_service_states ORDER BY package_id ASC, service_name ASC @@ -1423,6 +1459,7 @@ class UserMeterBase extends DurableObject { async countRunningPackageServices(input: { staleAfterMs?: number excludeService?: { packageId: string; serviceName: string } + mode?: string now?: string }): Promise { const now = input.now ? new Date(input.now) : new Date() @@ -1441,6 +1478,8 @@ class UserMeterBase extends DurableObject { const serviceName = exclusion ? assertPackageServiceId('serviceName', exclusion.serviceName) : null + const mode = + input.mode === undefined ? null : assertPackageServiceMode(input.mode) const row = ( packageId && serviceName ? this.ctx.storage.sql.exec<{ count: number }>( @@ -1448,8 +1487,11 @@ class UserMeterBase extends DurableObject { FROM package_service_states WHERE status = 'running' AND source_updated_at >= ? + AND (? IS NULL OR mode = ?) AND NOT (package_id = ? AND service_name = ?)`, freshAfter, + mode, + mode, packageId, serviceName, ) @@ -1457,8 +1499,11 @@ class UserMeterBase extends DurableObject { `SELECT COUNT(*) AS count FROM package_service_states WHERE status = 'running' - AND source_updated_at >= ?`, + AND source_updated_at >= ? + AND (? IS NULL OR mode = ?)`, freshAfter, + mode, + mode, ) ).toArray()[0] return { count: Math.max(0, Number(row?.count ?? 0)) } @@ -1470,6 +1515,7 @@ class UserMeterBase extends DurableObject { packageId: string serviceName: string status: string + mode?: string startedAt?: string | null sourceUpdatedAt: string updatedAt?: string @@ -1993,6 +2039,7 @@ export type UserMeterRpc = DurableObjectPitrRpc & { packageId: string serviceName: string status: string + mode?: string startedAt?: string | null sourceUpdatedAt: string updatedAt?: string @@ -2011,6 +2058,7 @@ export type UserMeterRpc = DurableObjectPitrRpc & { countRunningPackageServices: (input: { staleAfterMs?: number excludeService?: { packageId: string; serviceName: string } + mode?: string now?: string }) => Promise /** @@ -2024,6 +2072,7 @@ export type UserMeterRpc = DurableObjectPitrRpc & { packageId: string serviceName: string status: string + mode?: string startedAt?: string | null sourceUpdatedAt: string updatedAt?: string diff --git a/packages/worker/src/entitlements/user-meter.workers.test.ts b/packages/worker/src/entitlements/user-meter.workers.test.ts index 9a12d93077..111eb62e0c 100644 --- a/packages/worker/src/entitlements/user-meter.workers.test.ts +++ b/packages/worker/src/entitlements/user-meter.workers.test.ts @@ -753,6 +753,7 @@ test('UserMeter package-service states are monotonic, isolated, exportable, and packageId: 'pkg-1', serviceName: 'worker', status: 'running', + mode: 'bounded', startedAt: '2026-08-01T10:00:00.000Z', sourceUpdatedAt: '2026-08-01T10:00:00.000Z', }) @@ -763,6 +764,7 @@ test('UserMeter package-service states are monotonic, isolated, exportable, and packageId: 'pkg-1', serviceName: 'worker', status: 'running', + mode: 'bounded', startedAt: '2026-08-01T10:00:00.000Z', sourceUpdatedAt: '2026-08-01T10:00:00.000Z', revision: 1, @@ -773,6 +775,7 @@ test('UserMeter package-service states are monotonic, isolated, exportable, and packageId: 'pkg-1', serviceName: 'worker', status: 'running', + mode: 'bounded', startedAt: '2026-08-01T10:00:00.000Z', sourceUpdatedAt: '2026-08-01T11:00:00.000Z', }) @@ -822,6 +825,12 @@ test('UserMeter package-service states are monotonic, isolated, exportable, and now: '2026-08-01T11:30:00.000Z', }), ).toEqual({ count: 1 }) + expect( + await meterA.countRunningPackageServices({ + now: '2026-08-01T11:30:00.000Z', + mode: 'persistent', + }), + ).toEqual({ count: 0 }) expect( await meterA.countRunningPackageServices({ now: '2026-08-01T11:30:00.000Z', diff --git a/packages/worker/src/env-schema.ts b/packages/worker/src/env-schema.ts index 6234b1574d..22d1ca6ba0 100644 --- a/packages/worker/src/env-schema.ts +++ b/packages/worker/src/env-schema.ts @@ -245,6 +245,7 @@ export const EnvSchema = object({ STRIPE_WEBHOOK_SECRET: optionalNonEmptyStringSchema, // Override for tests/mocks; defaults to https://api.stripe.com. STRIPE_API_BASE_URL: optionalUrlStringSchema, + STRIPE_STANDARD_PRICE_ID: optionalNonEmptyStringSchema, STRIPE_PRO_PRICE_ID: optionalNonEmptyStringSchema, // Disaster-recovery exporter → DR-account R2 bucket (S3 API). Disabled // unless DR_EXPORT_ENABLED is the literal string "true" and credentials diff --git a/packages/worker/src/mcp/capabilities/services/service-start.node.test.ts b/packages/worker/src/mcp/capabilities/services/service-start.node.test.ts index 32b9c79bcf..2b7889ad7a 100644 --- a/packages/worker/src/mcp/capabilities/services/service-start.node.test.ts +++ b/packages/worker/src/mcp/capabilities/services/service-start.node.test.ts @@ -155,13 +155,18 @@ function buildRunningServices(count: number) { return Array.from({ length: count }, (_, index) => ({ packageId: `other-package-${index}`, serviceName: `service-${index}`, + mode: 'bounded' as const, })) } async function seedRunningServicesInMeter( meterEnv: { USER_METER?: DurableObjectNamespace }, userId: string, - services: ReadonlyArray<{ packageId: string; serviceName: string }>, + services: ReadonlyArray<{ + packageId: string + serviceName: string + mode?: 'bounded' | 'persistent' + }>, ) { const freshAt = new Date().toISOString() const meter = meterEnv.USER_METER!.get( @@ -172,6 +177,7 @@ async function seedRunningServicesInMeter( packageId: service.packageId, serviceName: service.serviceName, status: 'running', + mode: service.mode ?? 'bounded', startedAt: freshAt, sourceUpdatedAt: freshAt, }) @@ -237,6 +243,7 @@ test('service_start entitlement gating covers plan limits, running skips, and st ) mockDeclaredServices('persistent') mockServiceRpc({ status: 'stopped' }) + const { env: freeMeterEnv } = createInMemoryUserMeterEnv() const freeError = await serviceStartCapability .handler( { service_name: 'realtime-supervisor' }, @@ -245,6 +252,7 @@ test('service_start entitlement gating covers plan limits, running skips, and st APP_DB: createEntitlementsTestDb({ users: [{ email, plan: 'free' }], }), + ...freeMeterEnv, } as Env, callerContext, }, @@ -264,6 +272,47 @@ test('service_start entitlement gating covers plan limits, running skips, and st current: 0, }) + const standardPersistentLimit = + planLimits.standard.maxPersistentPackageServices + const { env: persistentMeterEnv } = createInMemoryUserMeterEnv() + await seedRunningServicesInMeter( + persistentMeterEnv, + userId, + buildRunningServices(standardPersistentLimit).map((service) => ({ + ...service, + mode: 'persistent', + })), + ) + const persistentError = await serviceStartCapability + .handler( + { service_name: 'realtime-supervisor' }, + { + env: { + APP_DB: createEntitlementsTestDb({ + users: [{ email, plan: 'standard' }], + }), + ...persistentMeterEnv, + } as Env, + callerContext, + }, + ) + .then( + () => null, + (thrown: unknown) => thrown, + ) + if (!isEntitlementLimitError(persistentError)) { + throw new Error( + 'Expected an EntitlementLimitError at the persistent service ceiling.', + ) + } + expect(persistentError.details).toMatchObject({ + code: 'entitlement_limit_exceeded', + resource: 'persistent_package_services', + plan: 'standard', + limit: standardPersistentLimit, + current: standardPersistentLimit, + }) + const { env: boundedMeterEnv } = createInMemoryUserMeterEnv() await seedRunningServicesInMeter( boundedMeterEnv, diff --git a/packages/worker/src/mcp/capabilities/services/service-start.ts b/packages/worker/src/mcp/capabilities/services/service-start.ts index 23220e844e..d21618735d 100644 --- a/packages/worker/src/mcp/capabilities/services/service-start.ts +++ b/packages/worker/src/mcp/capabilities/services/service-start.ts @@ -66,6 +66,16 @@ export const serviceStartCapability = defineDomainCapability( userId: serviceContext.user.userId, email: serviceContext.user.email, resource: 'persistent_package_services', + getCurrent: async () => + await countRunningPackageServices({ + env: ctx.env, + userId: serviceContext.user.userId, + mode: 'persistent', + excludeService: { + packageId: serviceContext.savedPackage.id, + serviceName: args.service_name, + }, + }), }) } await assertWithinEntitlement({ diff --git a/packages/worker/src/og/pages.ts b/packages/worker/src/og/pages.ts index 96301d2d45..ce641d4786 100644 --- a/packages/worker/src/og/pages.ts +++ b/packages/worker/src/og/pages.ts @@ -62,9 +62,11 @@ export const publicOgPages = { }, pricing: { imageTitle: 'Simple Kody pricing', - imageSubtitle: 'Start free. Pro is $5 per month when you need more room.', + imageSubtitle: + 'Start free. Standard is $5/month; Pro is $20/month for heavier use.', ogTitle: 'Pricing — Kody', - ogDescription: 'Compare the Free and Pro plans and their finite limits.', + ogDescription: + 'Compare the Free, Standard, and Pro plans and their finite limits.', path: '/pricing', }, privacy: { diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 658daeebe6..9d02ce2aed 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -471,6 +471,7 @@ class PackageServiceInstanceBase extends DurableObject { const shadowInput = { binding, status, + mode: this.stateSnapshot.mode, startedAt, sourceUpdatedAt: updatedAt, } @@ -490,6 +491,7 @@ class PackageServiceInstanceBase extends DurableObject { private schedulePackageServiceStateShadow(input: { binding: PackageServiceBindingState status: PackageServiceProjectedStatus + mode: PackageServiceState['mode'] startedAt: string | null sourceUpdatedAt: string }) { @@ -507,6 +509,7 @@ class PackageServiceInstanceBase extends DurableObject { private async requirePackageServiceStateInUserMeter(input: { binding: PackageServiceBindingState status: PackageServiceProjectedStatus + mode: PackageServiceState['mode'] startedAt: string | null sourceUpdatedAt: string }) { @@ -542,6 +545,7 @@ class PackageServiceInstanceBase extends DurableObject { private async shadowPackageServiceStateToUserMeter(input: { binding: PackageServiceBindingState status: PackageServiceProjectedStatus + mode: PackageServiceState['mode'] startedAt: string | null sourceUpdatedAt: string }) { @@ -555,6 +559,7 @@ class PackageServiceInstanceBase extends DurableObject { private async upsertPackageServiceStateInUserMeter(input: { binding: PackageServiceBindingState status: PackageServiceProjectedStatus + mode: PackageServiceState['mode'] startedAt: string | null sourceUpdatedAt: string }) { @@ -566,6 +571,7 @@ class PackageServiceInstanceBase extends DurableObject { packageId: input.binding.packageId, serviceName: input.binding.serviceName, status: input.status, + mode: input.mode, startedAt: input.startedAt, sourceUpdatedAt: input.sourceUpdatedAt, }) diff --git a/packages/worker/worker-configuration.d.ts b/packages/worker/worker-configuration.d.ts index 3af361f79c..e51c4968e2 100644 --- a/packages/worker/worker-configuration.d.ts +++ b/packages/worker/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --config=packages/worker/wrangler.jsonc --env=production ./packages/worker/worker-configuration.d.ts` (hash: 8be293561e7f31f9f513d88c67c3b61a) +// Generated by Wrangler by running `wrangler types --config=packages/worker/wrangler.jsonc --env=production ./packages/worker/worker-configuration.d.ts` (hash: 2613ef7169f1fa83e5eb0a2bf6a487ce) // Runtime types generated with workerd@1.20260730.1 2026-04-16 global_fetch_strictly_public,nodejs_compat interface __BaseEnv_Env { OAUTH_KV: KVNamespace; @@ -29,19 +29,11 @@ interface __BaseEnv_Env { ARTIFACTS_NAMESPACE: "production"; PACKAGE_APP_BASE_URL: "https://kodyapps.dev"; FATHOM_SITE_ID: "WKKSDJGN"; - STRIPE_PRO_PRICE_ID: "price_1Tv3W2LAQpAnsYszSr4PGBkE"; DR_EXPORT_ENABLED: "true"; DR_BACKUP_ACCOUNT_ID: "a41d50ecaf0ae0f86dd1824ef6729cb2"; DR_BACKUP_BUCKET_NAME: "kody-production-backups"; COOKIE_SECRET: string; SECRET_STORE_KEY: string; - APP_BASE_URL: string; - GITHUB_CLIENT_ID: string; - GITHUB_CLIENT_SECRET: string; - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; - X_CLIENT_ID: string; - X_CLIENT_SECRET: string; MCP_OBJECT: DurableObjectNamespace; REMOTE_CONNECTOR_SESSION: DurableObjectNamespace; MCP_CLIENT_HUB: DurableObjectNamespace; @@ -69,7 +61,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } declare module "*.md" { const value: string; diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index ff755b13bc..958bd3864b 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -404,14 +404,6 @@ // Fathom Analytics site id (public, non-secret). Production // only so local dev, preview, and tests never send pageviews. "FATHOM_SITE_ID": "WKKSDJGN", - // Public (non-secret) Stripe Price id for production Pro - // subscriptions. Coupled to the live Stripe account: if the - // price is regenerated in Stripe, update this value in the - // same change or subscription plan matching silently degrades - // to the kody_plan metadata fallback. Checkout sessions are - // created server-side for authenticated users (no public - // Payment Link). - "STRIPE_PRO_PRICE_ID": "price_1Tv3W2LAQpAnsYszSr4PGBkE", // Disaster-recovery exporter → locked R2 bucket in the KCD // (DR) account. The companion S3 credentials are Worker // secrets set out-of-band via the Cloudflare API. @@ -724,6 +716,7 @@ "X_CLIENT_ID": "MOCK_X_CLIENT_ID", "X_CLIENT_SECRET": "MOCK_X_CLIENT_SECRET", "STRIPE_SECRET_KEY": "sk_test_local", + "STRIPE_STANDARD_PRICE_ID": "price_standard", "STRIPE_PRO_PRICE_ID": "price_pro", "STRIPE_API_BASE_URL": "https://stripe.mock", "DR_EXPORT_ENABLED": "", diff --git a/tools/migration-ledger.json b/tools/migration-ledger.json index 59dcf30bc8..048cfe7cec 100644 --- a/tools/migration-ledger.json +++ b/tools/migration-ledger.json @@ -7,6 +7,10 @@ { "filename": "0001-squashed-init.sql", "sha256": "5e1c8b352b5cd61f9cdc80f0512891b3bee92ee316929b5417471c0319d2d423" + }, + { + "filename": "0002-restructure-plan-tiers.sql", + "sha256": "f39eba4f5e3be38bb5122251961a0cf18a53e7bf1a74f4a8cc210ea0c9d2ff5a" } ] } From 74898e72ff22a326288dd86e28e09be3863b9a37 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 16:48:50 +0000 Subject: [PATCH 2/8] test: align tier and service meter fixtures --- packages/worker/src/admin/user-usage-data.node.test.ts | 2 +- .../worker/src/entitlements/entitlements.node.test.ts | 4 ++-- .../worker/src/entitlements/user-meter.workers.test.ts | 10 +++++----- packages/worker/src/test-support/user-meter.ts | 10 ++++++++++ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/worker/src/admin/user-usage-data.node.test.ts b/packages/worker/src/admin/user-usage-data.node.test.ts index 7697c47720..46959adbd4 100644 --- a/packages/worker/src/admin/user-usage-data.node.test.ts +++ b/packages/worker/src/admin/user-usage-data.node.test.ts @@ -189,7 +189,7 @@ test('loadAdminUserUsageData returns null for unknown users and zeroed usage for id: 1, username: 'empty', email, - plan: 'pro', + plan: 'standard', stable_user_id: usageUserId, }, ], diff --git a/packages/worker/src/entitlements/entitlements.node.test.ts b/packages/worker/src/entitlements/entitlements.node.test.ts index 6c8a22c75b..f4c59c8465 100644 --- a/packages/worker/src/entitlements/entitlements.node.test.ts +++ b/packages/worker/src/entitlements/entitlements.node.test.ts @@ -961,9 +961,9 @@ test('missing-email lookups fail closed and honor free email caps', async () => test('requested units and getCurrent overrides are honored', async () => { const userId = await createStableUserIdFromEmail(plannedEmail) const { db } = createEntitlementsTestDb({ - users: [{ email: plannedEmail, plan: 'pro', stable_user_id: userId }], + users: [{ email: plannedEmail, plan: 'standard', stable_user_id: userId }], }) - const maxBytes = planLimits.pro.maxEmailMessageBytes + const maxBytes = planLimits.standard.maxEmailMessageBytes if (maxBytes === null) throw new Error('Expected a numeric size cap.') const oversized = await assertWithinEntitlement({ diff --git a/packages/worker/src/entitlements/user-meter.workers.test.ts b/packages/worker/src/entitlements/user-meter.workers.test.ts index 111eb62e0c..e7a7788f78 100644 --- a/packages/worker/src/entitlements/user-meter.workers.test.ts +++ b/packages/worker/src/entitlements/user-meter.workers.test.ts @@ -60,8 +60,8 @@ function accountWriteLeaseColumnNames(state: DurableObjectState) { .map((row) => String(row.name)) } -test('fresh UserMeter schema v8 creates the final write-lease shape', async () => { - const user = await seedFreeUser('meter-schema-v8-fresh') +test('fresh UserMeter schema v9 creates the final write-lease shape', async () => { + const user = await seedFreeUser('meter-schema-v9-fresh') const stub = env.USER_METER.get( env.USER_METER.idFromName(userMeterDurableObjectName(user.userId)), ) @@ -73,7 +73,7 @@ test('fresh UserMeter schema v8 creates the final write-lease shape', async () = WHERE key = 'schema_version' LIMIT 1`, ) .toArray()[0] - expect(Number(version?.value)).toBe(8) + expect(Number(version?.value)).toBe(9) expect(accountWriteLeaseColumnNames(state)).toEqual([ 'token', 'holder', @@ -83,7 +83,7 @@ test('fresh UserMeter schema v8 creates the final write-lease shape', async () = }) }, 30_000) -test('warm UserMeter schema v7 drops the authority column and preserves leases', async () => { +test('warm UserMeter schema v7 upgrades to v9 and preserves leases', async () => { const user = await seedFreeUser('meter-schema-v7-upgrade') const stub = env.USER_METER.get( env.USER_METER.idFromName(userMeterDurableObjectName(user.userId)), @@ -125,7 +125,7 @@ test('warm UserMeter schema v7 drops the authority column and preserves leases', WHERE key = 'schema_version' LIMIT 1`, ) .toArray()[0] - expect(Number(version?.value)).toBe(8) + expect(Number(version?.value)).toBe(9) expect(accountWriteLeaseColumnNames(state)).toEqual([ 'token', 'holder', diff --git a/packages/worker/src/test-support/user-meter.ts b/packages/worker/src/test-support/user-meter.ts index 8d15fb1e07..f182d40926 100644 --- a/packages/worker/src/test-support/user-meter.ts +++ b/packages/worker/src/test-support/user-meter.ts @@ -6,6 +6,7 @@ import { type DailyEntitlementResource, type UserMeterDeletionStateExport, type UserMeterPackageServiceState, + type UserMeterPackageServiceMode, type UserMeterPackageServiceStatus, type UserMeterStorageBytesState, type UserMeterWriteLeaseEntry, @@ -16,6 +17,7 @@ type MeterRow = { count: number; revision: number } type StorageRow = { bytes: number; revision: number; updatedAt: string } type PackageServiceRow = { status: UserMeterPackageServiceStatus + mode: UserMeterPackageServiceMode startedAt: string | null sourceUpdatedAt: string revision: number @@ -108,6 +110,7 @@ export function createInMemoryUserMeterEnv() { packageId, serviceName, status: row.status, + mode: row.mode, startedAt: row.status === 'running' ? row.startedAt : null, sourceUpdatedAt: row.sourceUpdatedAt, revision: row.revision, @@ -357,6 +360,7 @@ export function createInMemoryUserMeterEnv() { packageId: string serviceName: string status: string + mode?: string startedAt?: string | null sourceUpdatedAt: string updatedAt?: string @@ -367,6 +371,8 @@ export function createInMemoryUserMeterEnv() { ) } const status = input.status + const mode: UserMeterPackageServiceMode = + input.mode === 'bounded' ? 'bounded' : 'persistent' const key = packageServiceKey(input.packageId, input.serviceName) const existing = packageServices.get(key) if (existing && existing.sourceUpdatedAt > input.sourceUpdatedAt) { @@ -392,6 +398,7 @@ export function createInMemoryUserMeterEnv() { : input.sourceUpdatedAt const row: PackageServiceRow = { status, + mode, startedAt, sourceUpdatedAt: input.sourceUpdatedAt, revision: existing ? existing.revision + 1 : 1, @@ -466,6 +473,7 @@ export function createInMemoryUserMeterEnv() { input: { staleAfterMs?: number excludeService?: { packageId: string; serviceName: string } + mode?: string now?: string } = {}, ) { @@ -480,6 +488,7 @@ export function createInMemoryUserMeterEnv() { let count = 0 for (const [entryKey, row] of packageServices) { if (row.status !== 'running') continue + if (input.mode && row.mode !== input.mode) continue if (row.sourceUpdatedAt < freshAfter) continue if (input.excludeService) { const [packageId, serviceName] = entryKey.split('\0') @@ -499,6 +508,7 @@ export function createInMemoryUserMeterEnv() { packageId: string serviceName: string status: string + mode?: string startedAt?: string | null sourceUpdatedAt: string updatedAt?: string From 73fd652e065931ec1e06c3e97d075914a398a679 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 16:53:28 +0000 Subject: [PATCH 3/8] test: preserve standard usage warning thresholds --- packages/worker/src/admin/user-usage-data.node.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/worker/src/admin/user-usage-data.node.test.ts b/packages/worker/src/admin/user-usage-data.node.test.ts index 46959adbd4..014fc72362 100644 --- a/packages/worker/src/admin/user-usage-data.node.test.ts +++ b/packages/worker/src/admin/user-usage-data.node.test.ts @@ -230,7 +230,7 @@ test('loadAdminUserUsageData warns above eighty percent of plan limits', async ( id: 2, username: 'member', email, - plan: 'pro', + plan: 'standard', stable_user_id: usageUserId, }, ], From 63e4c748c67e74293e2c892ab8a0e62e6eb03788 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 17:41:22 +0000 Subject: [PATCH 4/8] Merge main and align usage snapshot test with renamed plans Co-authored-by: Kent C. Dodds --- packages/worker/src/entitlements/usage-snapshot.node.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/worker/src/entitlements/usage-snapshot.node.test.ts b/packages/worker/src/entitlements/usage-snapshot.node.test.ts index 2e744dfe06..1121e3f126 100644 --- a/packages/worker/src/entitlements/usage-snapshot.node.test.ts +++ b/packages/worker/src/entitlements/usage-snapshot.node.test.ts @@ -109,7 +109,7 @@ test('readEntitlementUsageSnapshot includes all registered usage resources', asy db, env: env as Env, usageUserId: stableUserId, - plan: 'partner', + plan: 'pro', }) expect(snapshot.resources.map((row) => row.resource)).toEqual( accountUsageEntitlementResources, From 5af94fbae4099b83909ee42805a6fb27498dd803 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 17:53:31 +0000 Subject: [PATCH 5/8] Address review: commit price ids as public wrangler vars, per-plan checkout pending Co-authored-by: Kent C. Dodds --- docs/contributing/environment-variables.md | 15 ++++++++------- docs/contributing/setup-manifest.md | 17 +++++++++-------- .../worker/client/routes/account-billing.tsx | 14 +++++++------- packages/worker/wrangler.jsonc | 12 ++++++++++++ 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/docs/contributing/environment-variables.md b/docs/contributing/environment-variables.md index 7b6802e2f7..900ce611e8 100644 --- a/docs/contributing/environment-variables.md +++ b/docs/contributing/environment-variables.md @@ -168,17 +168,18 @@ safely. Manual `users.plan` grants and invite-assigned plans apply regardless. - `STRIPE_API_BASE_URL` — optional API base URL; defaults to `https://api.stripe.com` when unset. Override for tests/mocks. - `STRIPE_STANDARD_PRICE_ID` — Stripe Price id for the $5/month `standard` plan. - This must receive the price id that `STRIPE_PRO_PRICE_ID` used before the tier - rename. + Carries the price id that `STRIPE_PRO_PRICE_ID` used before the tier rename + (already committed for production in `packages/worker/wrangler.jsonc`). - `STRIPE_PRO_PRICE_ID` — Stripe Price id for the $20/month `pro` plan. Each price id independently enables authenticated Checkout and subscription matching for its tier; leaving either unset makes only that tier unavailable for -purchase. Store both as production Worker secrets. For the tier-rename deploy, -create the -$20/month Pro recurring price in Stripe, set -`STRIPE_PRO_PRICE_ID` to that new id, and move the existing $5/month -price id to `STRIPE_STANDARD_PRICE_ID` before deploying. +purchase. Price ids are public (non-secret) values committed as production +Wrangler vars in `packages/worker/wrangler.jsonc`, not Worker secrets. The +existing $5/month price id already ships as `STRIPE_STANDARD_PRICE_ID`; to +activate Pro checkout, create the $20/month Pro recurring price in Stripe and +add `STRIPE_PRO_PRICE_ID` with that id to the production env block in the same +change. See [`architecture/entitlements.md`](./architecture/entitlements.md) (Billing). diff --git a/docs/contributing/setup-manifest.md b/docs/contributing/setup-manifest.md index 60d07773d2..0d67ca1c8b 100644 --- a/docs/contributing/setup-manifest.md +++ b/docs/contributing/setup-manifest.md @@ -359,11 +359,12 @@ automatically: for `POST /webhooks/stripe`. When unset, the webhook endpoint returns 503.) - `STRIPE_API_BASE_URL` (optional; defaults to `https://api.stripe.com`. Override for tests/mocks.) -- `STRIPE_STANDARD_PRICE_ID` (optional Worker secret; Stripe Price id mapped to - the $5/month `standard` plan and used for authenticated Checkout Sessions.) -- `STRIPE_PRO_PRICE_ID` (optional Worker secret; Stripe Price id mapped to the - $20/month `pro` plan and used for authenticated Checkout Sessions.) Each price - id is independent; an unset value only disables checkout for that tier. +- `STRIPE_STANDARD_PRICE_ID` (optional public Wrangler var committed in + `packages/worker/wrangler.jsonc`; Stripe Price id mapped to the $5/month + `standard` plan and used for authenticated Checkout Sessions.) +- `STRIPE_PRO_PRICE_ID` (optional public Wrangler var, added to the production + env block once the $20/month `pro` price exists in Stripe.) Each price id is + independent; an unset value only disables checkout for that tier. Tests run with `CLOUDFLARE_ENV=test` (set by Playwright) and read local secrets from `packages/worker/.env`. @@ -431,9 +432,9 @@ Configure these GitHub Actions secrets and variables for workflows: https://app.kit.com/account_settings/developer_settings and use the same value as the Kody user secret `kitApiKey` when convenient.) - `STRIPE_SECRET_KEY` (optional GitHub / Worker secret; Stripe secret API key - for account billing. Production deploy syncs it when set. The Pro price id is - a Worker var committed in `packages/worker/wrangler.jsonc`, not a GitHub - secret.) + for account billing. Production deploy syncs it when set. The Standard and + Pro price ids are public Wrangler vars committed in + `packages/worker/wrangler.jsonc`, not GitHub secrets.) - `STRIPE_WEBHOOK_SECRET` (optional GitHub / Worker secret; Stripe endpoint signing secret (`whsec_...`) for platform billing webhooks at `POST /webhooks/stripe`. Production deploy syncs it when set.) diff --git a/packages/worker/client/routes/account-billing.tsx b/packages/worker/client/routes/account-billing.tsx index 2b10669aa2..99dfb105a1 100644 --- a/packages/worker/client/routes/account-billing.tsx +++ b/packages/worker/client/routes/account-billing.tsx @@ -233,7 +233,7 @@ export function AccountBillingRoute(handle: Handle) { let data: AccountBillingLoaderData | null = null let message: string | null = null let messageTone: 'error' | 'info' = 'info' - let checkoutPending = false + let checkoutPendingPlan: PaidTier | null = null const loadLatch = createRouteLoadLatch() function applyPayload(payload: AccountBillingLoaderData) { @@ -245,8 +245,8 @@ export function AccountBillingRoute(handle: Handle) { async function startCheckout(plan: PlanTier) { if (plan === 'free') return - if (checkoutPending) return - checkoutPending = true + if (checkoutPendingPlan) return + checkoutPendingPlan = plan message = null handle.update() try { @@ -273,7 +273,7 @@ export function AccountBillingRoute(handle: Handle) { ? payload.error : 'Unable to start checkout. Try again shortly.' messageTone = 'error' - checkoutPending = false + checkoutPendingPlan = null handle.update() } catch (error) { message = @@ -281,7 +281,7 @@ export function AccountBillingRoute(handle: Handle) { ? error.message : 'Unable to start checkout. Try again shortly.' messageTone = 'error' - checkoutPending = false + checkoutPendingPlan = null handle.update() } } @@ -577,13 +577,13 @@ export function AccountBillingRoute(handle: Handle) {
diff --git a/packages/worker/wrangler.jsonc b/packages/worker/wrangler.jsonc index 958bd3864b..35626bfcfe 100644 --- a/packages/worker/wrangler.jsonc +++ b/packages/worker/wrangler.jsonc @@ -404,6 +404,18 @@ // Fathom Analytics site id (public, non-secret). Production // only so local dev, preview, and tests never send pageviews. "FATHOM_SITE_ID": "WKKSDJGN", + // Public (non-secret) Stripe Price ids for production + // subscriptions. Coupled to the live Stripe account: if a + // price is regenerated in Stripe, update the value in the + // same change or subscription plan matching silently degrades + // to the kody_plan metadata fallback. Checkout sessions are + // created server-side for authenticated users (no public + // Payment Link). STRIPE_STANDARD_PRICE_ID carries the + // original $5/month price id (pre-rename Pro). Add + // STRIPE_PRO_PRICE_ID here once the $20/month Pro price + // exists in Stripe; until then the Pro tier renders but is + // not purchasable. + "STRIPE_STANDARD_PRICE_ID": "price_1Tv3W2LAQpAnsYszSr4PGBkE", // Disaster-recovery exporter → locked R2 bucket in the KCD // (DR) account. The companion S3 credentials are Worker // secrets set out-of-band via the Cloudflare API. From adea4a4047d24d93f2ce4fbc8965054a527bfa26 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 17:56:49 +0000 Subject: [PATCH 6/8] Accept post-squash migrations in bookkeeping guard steady state Co-authored-by: Kent C. Dodds --- .../reset-migration-bookkeeping.node.test.ts | 21 ++++++++++++++++ tools/ci/reset-migration-bookkeeping.ts | 25 +++++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/tools/ci/reset-migration-bookkeeping.node.test.ts b/tools/ci/reset-migration-bookkeeping.node.test.ts index 9952885f7a..91d27dee17 100644 --- a/tools/ci/reset-migration-bookkeeping.node.test.ts +++ b/tools/ci/reset-migration-bookkeeping.node.test.ts @@ -22,6 +22,27 @@ test('migration bookkeeping classifies fresh, squashed, pre-squash, and unexpect expect(classifyMigrationBookkeeping([squashedInitMigrationName])).toEqual({ state: 'squashed', }) + // Post-squash steady state: baseline plus ordinary later migrations. + expect( + classifyMigrationBookkeeping([ + squashedInitMigrationName, + '0002-restructure-plan-tiers.sql', + ]), + ).toEqual({ state: 'squashed' }) + expect( + classifyMigrationBookkeeping([ + '0003-later.sql', + squashedInitMigrationName, + '0002-restructure-plan-tiers.sql', + ]), + ).toEqual({ state: 'squashed' }) + // Baseline mixed with frozen pre-squash names still fails. + expect( + classifyMigrationBookkeeping([ + squashedInitMigrationName, + '0090-webhook-endpoints.sql', + ]).state, + ).toBe('unexpected') expect( classifyMigrationBookkeeping([...preSquashMigrationNames].reverse()), ).toEqual({ state: 'pre-squash' }) diff --git a/tools/ci/reset-migration-bookkeeping.ts b/tools/ci/reset-migration-bookkeeping.ts index 552d2cf937..c4c06fb837 100644 --- a/tools/ci/reset-migration-bookkeeping.ts +++ b/tools/ci/reset-migration-bookkeeping.ts @@ -13,8 +13,9 @@ * * 1. fresh — no `d1_migrations` table or zero rows: nothing to do; the * regular apply bootstraps the squashed baseline. - * 2. squashed — bookkeeping already holds exactly the squashed baseline row: - * nothing to do. + * 2. squashed — bookkeeping holds the squashed baseline row plus only + * post-squash migrations (0002+ applied by the ordinary apply step after + * the squash): nothing to do. * 3. pre-squash — bookkeeping holds exactly the full frozen pre-squash * migration name set: reset to the single squashed baseline row. * @@ -218,11 +219,21 @@ export function classifyMigrationBookkeeping( if (appliedNames.length === 0) { return { state: 'fresh' } } - if ( - appliedNames.length === 1 && - appliedNames[0] === squashedInitMigrationName - ) { - return { state: 'squashed' } + // Post-squash steady state: the squashed baseline plus only migrations + // that are not part of the frozen pre-squash history (ordinary 0002+ + // migrations applied after the squash). Any mixture of the baseline with + // frozen pre-squash names still fails below. + if (appliedNames.includes(squashedInitMigrationName)) { + const preSquash = new Set([ + ...preSquashMigrationNames, + ...preSquashGhostMigrationNames, + ]) + const onlyPostSquashExtras = appliedNames.every( + (name) => name === squashedInitMigrationName || !preSquash.has(name), + ) + if (onlyPostSquashExtras) { + return { state: 'squashed' } + } } const applied = new Set(appliedNames) const expected = new Set(preSquashMigrationNames) From 16d409d20b2324c16f64a1432e9c8e69f11c0d05 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 17:59:31 +0000 Subject: [PATCH 7/8] Format docs Co-authored-by: Kent C. Dodds --- docs/contributing/environment-variables.md | 9 +++++---- docs/contributing/setup-manifest.md | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/contributing/environment-variables.md b/docs/contributing/environment-variables.md index 900ce611e8..6d4181c24b 100644 --- a/docs/contributing/environment-variables.md +++ b/docs/contributing/environment-variables.md @@ -176,10 +176,11 @@ Each price id independently enables authenticated Checkout and subscription matching for its tier; leaving either unset makes only that tier unavailable for purchase. Price ids are public (non-secret) values committed as production Wrangler vars in `packages/worker/wrangler.jsonc`, not Worker secrets. The -existing $5/month price id already ships as `STRIPE_STANDARD_PRICE_ID`; to -activate Pro checkout, create the $20/month Pro recurring price in Stripe and -add `STRIPE_PRO_PRICE_ID` with that id to the production env block in the same -change. +existing +$5/month price id already ships as `STRIPE_STANDARD_PRICE_ID`; to +activate Pro checkout, create the $20/month +Pro recurring price in Stripe and add `STRIPE_PRO_PRICE_ID` with that id to the +production env block in the same change. See [`architecture/entitlements.md`](./architecture/entitlements.md) (Billing). diff --git a/docs/contributing/setup-manifest.md b/docs/contributing/setup-manifest.md index 0d67ca1c8b..8d4485ecea 100644 --- a/docs/contributing/setup-manifest.md +++ b/docs/contributing/setup-manifest.md @@ -432,8 +432,8 @@ Configure these GitHub Actions secrets and variables for workflows: https://app.kit.com/account_settings/developer_settings and use the same value as the Kody user secret `kitApiKey` when convenient.) - `STRIPE_SECRET_KEY` (optional GitHub / Worker secret; Stripe secret API key - for account billing. Production deploy syncs it when set. The Standard and - Pro price ids are public Wrangler vars committed in + for account billing. Production deploy syncs it when set. The Standard and Pro + price ids are public Wrangler vars committed in `packages/worker/wrangler.jsonc`, not GitHub secrets.) - `STRIPE_WEBHOOK_SECRET` (optional GitHub / Worker secret; Stripe endpoint signing secret (`whsec_...`) for platform billing webhooks at From 28f481d61963dd6ab9cf80fb67a5c18a939ccbe9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 5 Aug 2026 18:08:16 +0000 Subject: [PATCH 8/8] Render persistent services as a counter now that the gate is a count Co-authored-by: Kent C. Dodds --- packages/worker/client/routes/account-usage.tsx | 6 ------ packages/worker/src/app/loader-data.ts | 2 +- .../worker/src/entitlements/resource-visibility.ts | 12 ++++-------- packages/worker/src/entitlements/usage-snapshot.ts | 8 +++----- .../worker/src/mcp/capabilities/account/usage-get.ts | 2 +- 5 files changed, 9 insertions(+), 21 deletions(-) diff --git a/packages/worker/client/routes/account-usage.tsx b/packages/worker/client/routes/account-usage.tsx index 08e4381496..68c0fb700f 100644 --- a/packages/worker/client/routes/account-usage.tsx +++ b/packages/worker/client/routes/account-usage.tsx @@ -84,16 +84,10 @@ function formatUsageValue(resource: string, value: number) { function formatCurrentValue(item: AccountUsageEntitlementConsumption) { if (item.kind === 'per_unit_max') return '—' - if (item.kind === 'boolean_allowance') { - return item.limit > 0 ? 'Allowed' : 'Not on plan' - } return formatUsageValue(item.resource, item.current) } function formatLimitValue(item: AccountUsageEntitlementConsumption) { - if (item.kind === 'boolean_allowance') { - return item.limit > 0 ? 'Yes' : 'No' - } return formatUsageValue(item.resource, item.limit) } diff --git a/packages/worker/src/app/loader-data.ts b/packages/worker/src/app/loader-data.ts index fa1ac310aa..6ef3cacfca 100644 --- a/packages/worker/src/app/loader-data.ts +++ b/packages/worker/src/app/loader-data.ts @@ -1307,7 +1307,7 @@ export type AccountUsageEntitlementConsumption = { resource: string label: string group: 'daily' | 'counts' | 'storage' | 'limits' - kind: 'counter' | 'boolean_allowance' | 'per_unit_max' + kind: 'counter' | 'per_unit_max' whatCounts: string howToReduce: string current: number diff --git a/packages/worker/src/entitlements/resource-visibility.ts b/packages/worker/src/entitlements/resource-visibility.ts index bd8c68e920..b3d52bf265 100644 --- a/packages/worker/src/entitlements/resource-visibility.ts +++ b/packages/worker/src/entitlements/resource-visibility.ts @@ -2,10 +2,7 @@ import type { EntitlementResource } from './plans.ts' export type EntitlementResourceGroup = 'daily' | 'counts' | 'storage' | 'limits' -export type EntitlementResourceVisibilityKind = - | 'counter' - | 'boolean_allowance' - | 'per_unit_max' +export type EntitlementResourceVisibilityKind = 'counter' | 'per_unit_max' export const entitlementResourceGroupLabels: Record< EntitlementResourceGroup, @@ -64,11 +61,10 @@ export const entitlementResourceVisibility: Record< }, persistent_package_services: { group: 'counts', - kind: 'boolean_allowance', - whatCounts: - 'Whether your plan allows persistent (long-lived) package services.', + kind: 'counter', + whatCounts: 'Persistent (long-lived) package services currently running.', howToReduce: - 'Stop persistent services when you do not need them, or upgrade for allowance.', + 'Stop persistent services you do not need, or upgrade for more concurrent slots.', }, repo_sessions: { group: 'counts', diff --git a/packages/worker/src/entitlements/usage-snapshot.ts b/packages/worker/src/entitlements/usage-snapshot.ts index 0488084d43..b7ca4344e0 100644 --- a/packages/worker/src/entitlements/usage-snapshot.ts +++ b/packages/worker/src/entitlements/usage-snapshot.ts @@ -57,13 +57,11 @@ export async function readEntitlementUsageSnapshot(input: { now, }) const limit = resolvePlanLimit(input.plan, resource) - // per_unit_max compares one candidate value (no accumulating usage) - // and boolean_allowance limits are 0/1 allowance flags, so a + // per_unit_max compares one candidate value (no accumulating + // usage) and a zero limit means the plan has no allowance, so a // current/limit ratio is meaningless for both. const percentOfLimit = - visibility.kind === 'per_unit_max' || - visibility.kind === 'boolean_allowance' || - limit === 0 + visibility.kind === 'per_unit_max' || limit === 0 ? null : current / limit return { diff --git a/packages/worker/src/mcp/capabilities/account/usage-get.ts b/packages/worker/src/mcp/capabilities/account/usage-get.ts index cfc87c3a15..64f6160e89 100644 --- a/packages/worker/src/mcp/capabilities/account/usage-get.ts +++ b/packages/worker/src/mcp/capabilities/account/usage-get.ts @@ -11,7 +11,7 @@ const usageResourceSchema = z.object({ resource: z.string(), label: z.string(), group: z.enum(['daily', 'counts', 'storage', 'limits']), - kind: z.enum(['counter', 'boolean_allowance', 'per_unit_max']), + kind: z.enum(['counter', 'per_unit_max']), whatCounts: z.string(), howToReduce: z.string(), current: z.number().int().nonnegative(),