Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 41 additions & 32 deletions docs/contributing/architecture/entitlements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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.

Expand Down Expand Up @@ -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'`).
Expand All @@ -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

Expand Down Expand Up @@ -385,7 +389,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

Expand Down Expand Up @@ -660,7 +666,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`) |
Expand All @@ -675,17 +681,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)

Expand Down Expand Up @@ -728,8 +736,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

Expand Down
2 changes: 1 addition & 1 deletion docs/contributing/architecture/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 14 additions & 4 deletions docs/contributing/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,20 @@ 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.
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. 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).

Expand Down
15 changes: 9 additions & 6 deletions docs/contributing/setup-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,9 +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_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 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`.
Expand Down Expand Up @@ -429,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.)
Expand Down
3 changes: 3 additions & 0 deletions docs/use/email-primitives.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/use/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 2 additions & 2 deletions docs/use/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/worker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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_...

Loading
Loading