Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
2 changes: 2 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ jobs:
X_CLIENT_ID: ${{ secrets.OAUTH_X_CLIENT_ID }}
X_CLIENT_SECRET: ${{ secrets.OAUTH_X_CLIENT_SECRET }}
KIT_API_KEY: ${{ secrets.KIT_API_KEY }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
run: >
node tools/ci/sync-worker-secrets.ts --env production --config
"$WRANGLER_CONFIG" --set-from-env COOKIE_SECRET
Expand All @@ -139,6 +140,7 @@ jobs:
GOOGLE_CLIENT_ID --set-from-env-optional GOOGLE_CLIENT_SECRET
--set-from-env-optional X_CLIENT_ID --set-from-env-optional
X_CLIENT_SECRET --set-from-env-optional KIT_API_KEY
--set-from-env-optional STRIPE_SECRET_KEY

- name: 🗄️ Apply D1 Migrations
env:
Expand Down
7 changes: 5 additions & 2 deletions docs/contributing/architecture/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ The `invites` table stores operator-created invite codes:
deletion does not strand invites)
- `note`, `max_uses`, `use_count`, `expires_at`, `revoked_at`, and `created_at`
describe current invite state
- `plan` (nullable, migration `0065-invite-plans.sql`) is an optional signup
plan name; password and social signup copy a known value onto `users.plan`.
NULL keeps legacy/unlimited behavior. See [Entitlements](./entitlements.md).

Production signup atomically consumes an invite with a single conditional
`UPDATE ... WHERE use_count < max_uses AND revoked_at IS NULL ...`; concurrent
Expand All @@ -102,8 +105,8 @@ open when no invite is supplied, but they still consume and validate an invite
when a code is provided so E2E coverage can exercise the same path.

Admins manage invites at `/admin/invites`. The route uses the RBAC `admin` role
guard, not an owner-scoped content bypass. Invite creation, use, and revocation
emit audit events.
guard, not an owner-scoped content bypass. Invite creation (including optional
plan), use, and revocation emit audit events.

The same admin page can create a user directly by email for manually invited
people. That flow calls `adminCreateUserWithPasswordSetup` in
Expand Down
76 changes: 62 additions & 14 deletions docs/contributing/architecture/entitlements.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

Per-user plans with per-plan resource limits. This is Kody's denial-of-wallet
protection for open signup: it bounds how many billable resources a single
account can consume. It deliberately contains **no payment code** — billing
integration comes later, after usage metering informs the limit numbers.
account can consume. Stripe subscription billing lives in a separate module
(`packages/worker/src/billing/`); see [Billing](#billing) below. Limit numbers
in `planLimits` remain independently configured placeholders.

Module: `packages/worker/src/entitlements/`

- `plans.ts` — plan names (`partner`, `personal`, `pro`), the `PlanLimits`
config per plan, the `EntitlementResource` registry, and
`resolvePlanLimit(plan, resource)`.
- `plans.ts` — plan names (`free`, `pro`, `partner`), the `PlanLimits` config
per plan, the `EntitlementResource` registry,
`resolvePlanLimit(plan, resource)`, `getPlanRank`, and
`resolveEffectivePlan(manual, stripe)`.
- `errors.ts` — the one typed error (`EntitlementLimitError`) and the one
user-facing message builder every enforcement point uses.
- `service.ts` — `getUserPlan`, `assertWithinEntitlement`, built-in D1 usage
Expand All @@ -24,6 +26,11 @@ stored plan values are also treated as NULL so plan renames can never lock users
out. Enforcement activates only when a known plan name is set — existing
accounts keep working unchanged.

A Stripe subscription never downgrades a NULL manual plan into enforcement:
`resolveEffectivePlan` returns NULL whenever `users.plan` is NULL, regardless of
`users.stripe_plan`. Legacy/unlimited accounts stay unlimited until an admin or
invite assigns a manual plan.

Exception: the email resources are abuse-sensitive and bind plan-less users to
the `nullPlanEmailFallbackLimits` backstops from `plans.ts` instead of unlimited
— outbound sends (`email_sends_per_day`) because sending is an outreach-abuse
Expand All @@ -35,10 +42,16 @@ resources.

## Assigning plans

Signup and invites never set a plan — new accounts start with `plan = NULL`
(legacy/unlimited plus the email backstops above). Admins assign or clear plans
through two audited, admin-only surfaces, both backed by `updateAdminUserPlan`
in `packages/worker/src/app/admin-users-data.ts`:
New accounts start with `users.plan = NULL` (legacy/unlimited plus the email
backstops above) unless the consumed invite carries a plan. Migration
`0065-invite-plans.sql` adds nullable `invites.plan`. Password and social signup
apply that value to the new `users.plan` when present (validated with
`parsePlanName`); a NULL invite plan keeps today's legacy/unlimited behavior.
Admins set invite plans when creating codes at `/admin/invites`.

Admins also assign or clear plans on existing users through two audited,
admin-only surfaces, both backed by `updateAdminUserPlan` in
`packages/worker/src/app/admin-users-data.ts`:

- **Admin UI** — the "Manage plan" panel on `/admin/users` posts
`{ action: 'update_plan', userId, plan }` to `POST /admin/users.json` (guarded
Expand All @@ -52,6 +65,10 @@ and write an `admin`-category audit event with reason `target_user_id=…;plan=
Because daily counters accumulate even for plan-less users, assigning a plan
later binds immediately against the usage already counted that day.

Paid upgrades via Stripe write `users.stripe_plan` (not `users.plan`); see
[Billing](#billing). Effective entitlement uses the higher-ranked of the two
when a manual plan is set.

## Plan lookup

The MCP `userId` is the SHA-256 hash of the normalized account email
Expand All @@ -63,8 +80,10 @@ directly. `getUserPlan(db, { userId, email })`:
touching D1. This keeps the per-user-isolation invariant honest and makes
synthetic runtime contexts (package-scoped caller contexts with `email: ''`,
workflow-internal users, test fixtures) fail open.
3. Reads `SELECT plan FROM users WHERE email = ?` and validates the value
against the plan registry.
3. Reads `SELECT plan, stripe_plan FROM users WHERE email = ?` and returns
`resolveEffectivePlan(parsePlanName(plan), stripe_plan)`: NULL manual plan
stays unlimited; otherwise the higher-ranked of manual `users.plan` and
`users.stripe_plan` (rank: `free` < `pro` < `partner`).

Consequence: enforcement points must have the acting user's account email
available. Both auth surfaces provide it — app sessions expose
Expand Down Expand Up @@ -115,8 +134,8 @@ the stable programmatic contract:
The `message` is built by `buildEntitlementLimitMessage` and is the single
user-facing string across MCP and UI surfaces:

> Plan limit reached: your "personal" plan allows at most 10 scheduled jobs and
> you currently have 10. Remove or finish existing scheduled jobs you no longer
> Plan limit reached: your "pro" plan allows at most 50 scheduled jobs and you
> currently have 50. Remove or finish existing scheduled jobs you no longer
> need, or ask the operator of this Kody deployment to upgrade your plan.

Rules:
Expand Down Expand Up @@ -240,11 +259,40 @@ The exemplar is job scheduling: `createJob` in
| `concurrent_workflows` | `createDynamicCallableWorkflow` (plan limit, env-var backstop for NULL plan) |
| `storage_bytes` | D1 payload writes (values, secrets, memories, saved-package projections, email storage) and StorageRunner write tools/app RPCs |

## Billing

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.

Checkout uses Stripe Payment Links with `client_reference_id` set to the user's
stable id and `prefilled_email` set to their account email.
`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.

`users.stripe_plan` stays fresh via an hourly cron lane
(`refreshStaleStripePlans`: 25 users/sweep, 1h staleness) and an on-page refresh
when `/account/billing` loads with data older than 60s. Migration
`0066-stripe-billing.sql` adds `stripe_customer_id` (unique partial index),
`stripe_plan`, and `stripe_plan_refreshed_at`.

Published prices: Free $0, Pro $5/mo. Env vars and deploy wiring are documented
in [`../environment-variables.md`](../environment-variables.md).

## Related tables and coordination

- `users.plan` — added by the invite-signup migration
(`0046-invites-email-verification.sql`); the entitlements module is the
consumer of that column.
consumer of that column (manual / invite / admin grant).
- `invites.plan` — nullable signup plan from migration `0065-invite-plans.sql`;
applied to `users.plan` when the invite is consumed at signup.
- `users.stripe_customer_id`, `users.stripe_plan`,
`users.stripe_plan_refreshed_at` — Stripe billing columns from migration
`0066-stripe-billing.sql`; owned by `packages/worker/src/billing/`, read by
`getUserPlan` via `resolveEffectivePlan`.
- `entitlement_daily_counters` — rate counters, created by migration
`0048-user-plans-and-entitlement-counters.sql`; included in the
account-deletion cascade (`packages/worker/src/app/account-deletion.ts`).
10 changes: 10 additions & 0 deletions docs/contributing/architecture/primitives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ primitives:
docs:
- docs/contributing/architecture/entitlements.md

- id: billing
group: auth
name: Stripe billing
summary:
Payment-link checkout, customer linking, and subscription→plan sync.
code:
- packages/worker/src/billing/
docs:
- docs/contributing/architecture/entitlements.md

- id: feature-flags
group: auth
name: Feature flags
Expand Down
20 changes: 20 additions & 0 deletions docs/contributing/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,26 @@ Optional Worker secrets / vars for the public `/signup` waiting-list form

See [`architecture/authentication.md`](./architecture/authentication.md).

## Stripe billing

Optional Worker secret and vars for account subscription billing
(`packages/worker/src/billing/`, routes under `/account/billing`). When
`STRIPE_SECRET_KEY` is unset, billing is disabled: the account billing page
shows plan info plus a "not configured" notice, and success/portal/cron skip
safely. Manual `users.plan` grants and invite-assigned plans still work.

- `STRIPE_SECRET_KEY` — Stripe secret API key. Required for checkout linking,
portal sessions, and `stripe_plan` refresh. Synced as a Worker secret from the
GitHub Actions secret of the same name on 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.
- `STRIPE_PRO_PAYMENT_LINK` — Stripe Payment Link URL for checkout. Production
values are committed as Wrangler `vars` in `packages/worker/wrangler.jsonc`.

See [`architecture/entitlements.md`](./architecture/entitlements.md) (Billing).

## Saved-secret encryption (`SECRET_STORE_KEY`)

Required Worker secret used to derive the AES-GCM key for encrypting saved
Expand Down
14 changes: 14 additions & 0 deletions docs/contributing/setup-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,16 @@ automatically:
secrets; enable the "Sign in with GitHub / Google / X" login buttons. A
`MOCK_`-prefixed client id activates the in-worker mock flow on non-production
runtimes. See `docs/contributing/social-login.md`.)
- `STRIPE_SECRET_KEY` (optional Worker secret; enables Stripe checkout linking,
billing portal, and `users.stripe_plan` refresh. When unset, billing degrades
to manual plans.)
- `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. Production value is set in `packages/worker/wrangler.jsonc`.)
- `STRIPE_PRO_PAYMENT_LINK` (optional Worker var; Stripe Payment Link URL for
`/account/billing` checkout. Production value is set in
`packages/worker/wrangler.jsonc`.)

Tests run with `CLOUDFLARE_ENV=test` (set by Playwright) and read local secrets
from `packages/worker/.env`.
Expand Down Expand Up @@ -179,6 +189,10 @@ Configure these GitHub Actions secrets and variables for workflows:
production Kit audience. Create a Kit API key at
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. Price ids and
Payment Link URLs are Worker vars committed in
`packages/worker/wrangler.jsonc`, not GitHub secrets.)
- `SENTRY_AUTH_TOKEN` (optional GitHub **secret**; Sentry auth token with
`project:releases` / source map upload permissions — used only by CI to run
`npm run sentry:upload-sourcemaps` after deploy)
Expand Down
51 changes: 51 additions & 0 deletions e2e/invite-signup-verification.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,49 @@ import {
clearAuthRateLimitsInE2eDatabase,
setEmailVerificationTokenInE2eDatabase,
} from './d1-utils.ts'
import { quoteSqlString } from '@kody-internal/shared/sql-literals.ts'
import { spawnSync } from 'node:child_process'
import path from 'node:path'

const projectRoot = path.resolve(import.meta.dirname, '..')

function queryUserPlanFromE2eDatabase(email: string) {
const result = spawnSync(
process.execPath,
[
'--env-file=packages/worker/.env',
'./wrangler-env.ts',
'd1',
'execute',
'APP_DB',
'--local',
'--persist-to',
'.wrangler/state/e2e',
'--json',
'--command',
`SELECT plan FROM users WHERE email = ${quoteSqlString(email)};`,
],
{
cwd: projectRoot,
encoding: 'utf8',
stdio: 'pipe',
env: {
...process.env,
CLOUDFLARE_ENV: 'test',
},
},
)
if (result.status !== 0) {
throw new Error(
`Failed to query user plan from E2E D1:\n${result.stdout}\n${result.stderr}`,
)
}
const parsed = JSON.parse(result.stdout) as Array<{
results?: Array<{ plan: string | null }>
}>
const rows = parsed.flatMap((entry) => entry.results ?? [])
return rows[0]?.plan ?? null
}

test('admin invite signup and email verification happy path', async ({
page,
Expand Down Expand Up @@ -42,12 +85,18 @@ test('admin invite signup and email verification happy path', async ({
await page.getByLabel('Code').fill(inviteCode)
await page.getByLabel('Note').fill('E2E invite signup verification')
await page.getByLabel('Max uses').fill('1')
await page.getByLabel('Plan').selectOption('pro')
await page.getByRole('button', { name: 'Create', exact: true }).click()
await expect(async () => {
await expect(page.getByRole('heading', { name: inviteCode })).toBeVisible({
timeout: 1_000,
})
}).toPass({ timeout: 15_000 })
// Filter to visible matches: the create form's <select> also contains a
// hidden <option> with the same text.
await expect(
page.getByText('pro', { exact: true }).filter({ visible: true }).first(),
).toBeVisible()

await page.getByLabel('User email').fill(adminCreatedEmail)
await page.getByLabel('Username (optional)').fill(adminCreatedUsername)
Expand Down Expand Up @@ -157,4 +206,6 @@ test('admin invite signup and email verification happy path', async ({
await expect(
page.getByRole('heading', { name: 'Verify your email' }),
).toHaveCount(0)

expect(queryUserPlanFromE2eDatabase(invitedEmail)).toBe('pro')
})
8 changes: 8 additions & 0 deletions packages/worker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,11 @@ X_CLIENT_SECRET=MOCK_X_CLIENT_SECRET
# KIT_WAITLIST_SEQUENCE_ID=2823893
# KIT_SIGNED_UP_TAG_ID=21252175

# Stripe subscription billing (optional). When STRIPE_SECRET_KEY is unset,
# billing is disabled: the account billing page shows plan info plus a
# "Billing is not configured" notice, and success/portal/cron skip safely.
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_API_BASE_URL=https://api.stripe.com
# STRIPE_PRO_PRICE_ID=price_...
# STRIPE_PRO_PAYMENT_LINK=https://buy.stripe.com/...

Loading
Loading