Docs / Accounts, plans & billing
Accounts, Plans & Billing
This document describes the implementation in this repository. Items called out as pending are not production guarantees.
Accounts and workspaces
- Sign-up uses an email address and an 8–72 character password. The server creates the user, their personal workspace, and their first web session in one atomic store transaction (bbolt or Postgres — see below). No card is required.
- When SMTP is configured, sign-up attempts to send an email-verification link and the forgot-password flow can send a reset link. Without SMTP, account creation and password authentication still work, but email cannot be sent.
- Magic-link sign-in and Google/GitHub OAuth are not implemented.
- Every account currently owns one personal workspace. A user can also join other workspaces through a team invitation and switch among those workspaces in the console.
- A workspace contains members, API keys, encrypted vault secrets, metered usage, artifact metadata, webhooks, and scheduled jobs.
- API keys are the agent credential for the control plane.
*retains full access for compatibility. Least-privilege keys use exact scopes:sessions:read|write,recordings:read|write,webhooks:read|write,jobs:read|write,usage:write, andsecret:<name>. Unknown scopes are rejected when a key is created; read scopes never imply mutation rights. A member-attributed key authenticates only while its creator remains an owner or admin, so removing the creator also removes that long-lived access path. - Password change/reset commits compare the durable password hash authenticated by the request and revoke the user's web sessions in the same transaction. Concurrent stale password changes are rejected.
Current persistence
The control plane's durable store is an interface (internal/platform.Store), not D1 or Durable Objects: an embedded bbolt file for local/self-hosted deployments, or Postgres for the live hosted deployment (production completed this migration and cut over its real data — see Platform architecture). Either backend durably stores:
- users and the email index;
- workspaces, Stripe identifiers, plan status, and renewal/cancellation display state;
- memberships and pending invitations;
- hashed API keys and envelope-encrypted vault material;
- deduplicated usage events and web login sessions;
- device registrations, artifact metadata, webhooks, scheduled-job summaries, and server-managed configuration secrets.
Hosted browser routing and close checkpoints are still held in the running control-plane process. They are not durable hosted-session records in the store. API-key spend caps are also not implemented. Additive durable lifecycle state requires a separate schema decision; do not infer it from the stored web-session bucket.
Plan catalog and enforced limits
New personal workspaces start on Free. /api/plans exposes the catalog used by the console:
| Plan | Display price/month | Included instance-hours | Storage shown | Concurrent hosted sessions |
|---|---|---|---|---|
| Free | $0 | 5 | 2 GB | 1 |
| Pro | $49 | 100 | 50 GB | 5 |
| Scale | $299 | 750 | 500 GB | 25 |
The current enforcement boundary is narrower than the catalog:
- Both hosted-session creation paths enforce the workspace plan's concurrent session ceiling.
- Free is non-metered. Once its recorded
instance_secondsreach five hours in the current UTC calendar month, a new hosted-session request returns HTTP 402. Closing a session records its held time, so the check applies to later acquisitions rather than interrupting a running browser at the threshold. - Pro and Scale are marked metered and therefore do not receive the Free instance-hours refusal. The included-hours values are displayed, but this repository does not yet publish overage usage to Stripe.
- The storage values are catalog/display metadata. Recording-storage accounting, storage-quota enforcement, automatic seven-day expiry, and a separate live-stream concurrency cap are not implemented.
- Local self-hosted software does not incur a hosted-browser charge. The hosted session API schedules only eligible hosted fleet nodes and applies the limits above before allocation.
Usage and metering
Usage records are stored in the durable store (bbolt or Postgres) and deduplicated per workspace by idempotency_key.
Implemented production paths:
- A successfully allocated hosted session records
instance_secondsduring its retryable close sequence. - Cookie-authenticated
GET /api/usagereturns all-time totals plus the current UTC month's plan view. - API-key-authenticated
POST /api/usage/ingestaccepts only the non-billable diagnostic dimensionapi_calls. Customer keys cannot submitinstance_secondsor invent infrastructure totals.
Reserved dimensions such as pool_slot_seconds, concurrent_peak, storage_gb_hours, stream_seconds, and egress_gb have no production emitter yet. There is no hourly Worker/D1 aggregation job and no Stripe usage-record or meter-event publisher. Actions, steps, session creation, and AI tokens are not metered; AI calls are BYOK.
Stripe behavior
Stripe is optional at runtime. The current backend provides:
- public
GET /api/plans, including whether a Stripe secret key is configured; - signed-in
POST /api/billing/checkout, which creates a subscription-mode Checkout Session for Pro or Scale when that plan's Stripe Price ID is set; - signed-in
POST /api/billing/portal, which creates a Billing Portal session for a workspace with a recorded Stripe customer; POST /api/billing/webhook, authenticated by theStripe-SignatureHMAC with a five-minute timestamp tolerance. Store failures return 500 so Stripe retries.
Checkout and Portal return HTTP 503 without STRIPE_SECRET_KEY; Checkout also returns 503 when the selected plan's STRIPE_PRICE_PRO or STRIPE_PRICE_SCALE value is absent. Webhook delivery requires STRIPE_WEBHOOK_SECRET independently of those API settings.
The webhook records Checkout customer/subscription identifiers, applies customer.subscription.created and .updated plan/status/renewal fields, and drops canceled, unpaid, incomplete-expired, or deleted subscriptions back to Free. Unknown event types are acknowledged without changing plan state. If an active or past-due subscription ID is already recorded locally, a second Checkout is refused with guidance to use the portal instead.
This is not yet the final production billing safety boundary. TASK-88 is integrated as 13496fc: checkout reserves a workspace billing generation atomically, prevents concurrent active/pending reservations, and uses a stable generation-bound idempotency key.
TASK-94 is integrated (re-landed 2026-08-14 after a first attempt was reverted): local time never rolls a checkout generation. A Checkout can complete just before Stripe's deadline while its webhook arrives later, so only a signed, matching checkout.session.expired releases an abandoned reservation.
The first attempt was reverted because it introduced two worse defects; the re-landing carries the two properties that close them, and both are covered by mutation-controlled tests:
- The terminal tombstone is durable, and is a SET.
Workspace.TerminalSubIDsrecords every subscription id that reachedcanceled/incomplete_expired(bounded, oldest dropped past 64 entries — Stripe never reuses subscription ids, so in practice the cap is never reached). The tombstone used to live inStripeSubIDitself, which a checkout reservation overwrites and its release clears — after which a redeliveredcustomer.subscription.updatedfor the canceled subscription bound again and re-granted a paid plan for free. A first fix made this a single scalar field, which fixed the one-cycle case but was itself evicted by a SECOND cancel-then-resubscribe cycle (the second terminal id silently overwrote the first, reopening the exact resurrection bug for the older subscription). A terminal subscription id is now never re-bindable, whatever the current binding holds or however many subscribe/cancel cycles a workspace has been through. - Only a reservation's own creator releases it, and Stripe's 409 is never treated as refusal.
ReserveWorkspaceCheckoutlets concurrent or retried requests for the same workspace+plan JOIN one generation and share one Stripe idempotency key. If Stripe returns a 4xx (bad Price id, deleted customer) no Checkout Session exists, so the signed expiry can never arrive; the reservation is released in the same request — but only by the request that originally created it, never by a caller that merely joined an existing one, since a joined caller cannot tell a genuine refusal apart from a race against its own reservation's creator. Stripe's 409 (idempotency_key_in_use, when two requests race on the same key) is excluded from "definitive" entirely, for both callers: it means something may already exist under that key, not that Stripe refused the request. Ambiguous failures — timeout, 5xx, transport error, 409 — all stay fail-closed, because a session may exist and releasing would permit a second subscription. That distinction is whybilling.APIErrorcarries the status code. - ExpiresAt sent to Stripe is
PlanSince + reservation TTL + 30 minutes, never derived from the calling request's own clock. Every caller sharing a generation — the creator and every later joiner — must send Stripe an identical idempotency-key body, or Stripe rejects the reused key as a parameter mismatch. A per-call floor (e.g. "at least 30 minutes from right now") satisfies Stripe's own minimum for a single caller but breaks that determinism for two joined callers separated in time; a fixed offset from the reservation's own creation time satisfies both, since the join window itself guarantees the result is always more than 30 minutes ahead of any call within it.
If Stripe exhausts expiry-webhook delivery on a session that DID get created, the reservation still needs operator reconciliation: recover the generation from GET /api/account/export (it is the second half of the agentbrowser-checkout:<ws>:<gen> idempotency key) and resend the matching signed event. Local elapsed time is never sufficient evidence that no paid Checkout completed.
TASK-98 is integrated: a signed event is routed to its workspace before any Price validation. Workspace metadata and the durable customer index are resolved independently — stale or deleted metadata falls back to the known customer owner, so a signed cancellation can no longer be acknowledged against a ghost workspace while the real owner stays on a paid plan. An event with neither a live metadata target nor a customer binding is an acknowledged no-op rather than a multi-day Stripe retry caused by irrelevant local Price configuration; an event whose two live hints identify different workspaces fails without mutation, so Stripe retries while the tenant conflict is investigated. A known workspace with an unknown Price still fails closed.
TASK-88, TASK-94, and TASK-98 are all integrated with their controls green. Self-serve billing's structural gate is closed; the remaining work is configuration: the two recurring prices and the signed webhook, then proving checkout, portal, delayed expiry/completion and subscription events against the intended Stripe account.
Export and account deletion
GET /api/account/export downloads account metadata containing the user, owned workspaces, API-key metadata, vault-secret names/origins, complete device registrations (including push-registration tokens), and usage totals. It excludes passwords, password hashes, API-key hashes, vault values, artifact records and contents, webhook definitions, and scheduled-job definitions. Treat the downloaded file as sensitive account data; it is not a secret-free archive.
POST /api/account/delete requires the current password and the exact confirmation string DELETE.
- With the filesystem artifact store, deletion inventories owned active and quarantined artifacts, removes indexed and attributable unindexed objects, then cascades the user's account data in the durable store. If artifact cleanup cannot be confirmed, the account is retained for a retry instead of reporting success.
- With R2, account deletion currently fails closed before deleting account data. TASK-99 is required to inventory crash-cut R2 objects safely.
- Account deletion does not yet obtain authoritative Stripe subscription terminal state, cancel an active subscription, drain every workspace session from the render fleet, or fence all concurrent workspace writers. Those are TASK-89 prerequisites, sequenced after TASK-99 and after TASK-94 is re-landed (TASK-98 is already integrated).
Accordingly, the current implementation provides a filesystem-backed local cascade, not a complete production right-to-erasure guarantee for every storage, billing, and fleet configuration.