Instead of combining browserCost + proxyCost into a single cloudSpend
counter, track them separately with independent budgets. This lets
operators set different limits for proxy bandwidth vs VM runtime.
Schema (org table):
- Restore proxySpendCents / proxyBudgetCents (proxy bandwidth)
- Add browserSpendCents / browserBudgetCents (VM runtime)
- Rename proxySpendPeriodStart → spendPeriodStart (shared period marker)
- Keep lastCloudCreateAt for rate limiting
Schema (cloudSession table):
- Restore lastProxyCostCents (proxy cost baseline)
- Add lastBrowserCostCents (browser cost baseline)
cloud-api.ts connect handler:
- Check both budgets independently before allowing new sessions
- Reset both counters on billing period rollover
scheduled.ts cron:
- Compute proxy and browser deltas separately per session
- Atomic increment both spend counters independently
- Kill sessions if either budget is exceeded
- Conditional baseline update uses WHERE on both previous values
Migration adds new columns alongside existing ones (no renames needed
since proxy columns were never dropped from D1).
Session: ses_107161964ffefCyMno6mi5LtMr
**Problem:** Several cost vulnerabilities in cloud browser management:
1. Budget only tracked proxyCost, not browserCost ($0.02/hr VM runtime).
Users with proxyRegion=null bypassed the budget entirely.
2. No rate limiting on /connect: rapid connect/disconnect loops waste
VM costs (Browser Use charges minimum 1 minute per VM).
3. Timeout allowed up to 240 minutes (4 hours) with no server cap.
4. customProxy accepted from users without validation.
5. Sessions with cancelled subscriptions kept running until timeout.
6. Concurrent flood: VM created before slot claim meant N concurrent
requests could create N paid VMs when only 1 slot was available.
**Fixes:**
Schema changes (db/src/schema.ts + migration):
- Rename proxy-only columns to cloud-wide:
proxySpendCents → cloudSpendCents
proxyBudgetCents → cloudBudgetCents
proxySpendPeriodStart → cloudSpendPeriodStart
lastProxyCostCents → lastTotalCostCents
- Add org.lastCloudCreateAt for per-org creation rate limiting
- Migration copies existing data to new columns
cloud-api.ts:
- Restore slot-claim-before-VM-creation pattern to prevent
concurrent flood (slot claim is cheap D1 write; VM creation
is expensive BU API call that costs money)
- Track browserCost + proxyCost as total cost in budget checks
- Add 10-second per-org creation cooldown (MIN_CREATE_INTERVAL_MS)
to prevent rapid connect/disconnect loops
- Cap timeout at 60 minutes (MAX_TIMEOUT_MINUTES) instead of 240
- Remove customProxy from connect schema (silently ignored if sent)
- Parallelize rate limit timestamp update with VM creation
scheduled.ts:
- Track total cost (browserCost + proxyCost) instead of proxy only
- Kill sessions for orgs without active subscription (cancelled/expired)
- Rename all proxy* references to cloud* for consistency
Session: ses_107161964ffefCyMno6mi5LtMr
toLocaleDateString() produces different output on Cloudflare Workers (server)
vs browser (client) due to locale differences. Use a stable UTC YYYY-MM-DD
format instead.
Session: ses_10bef183cffeBNdpywc27HbcLi
**Security:** API key auth (`x-api-key` header) is now restricted to cloud
API routes only via `getSessionWithApiKey()`. Dashboard, device flow, and
billing routes use `getSession()` which does not accept API keys. This
prevents a leaked API key from accessing the full dashboard or billing actions.
**Performance:** added index on `apikey.key` column. The plugin validates
keys by their hash, so without this index every API request would do a
full table scan.
**Cleanup:**
- `createApiKey` now returns only `{ key }`, not the full plugin result
- Removed unused `orm` and `schema` imports from actions.tsx and db.ts
- Removed accidental `scheduled.ts` import from server.tsx (untracked file)
Session: ses_10bef183cffeBNdpywc27HbcLi
Add `@better-auth/api-key` plugin with `enableSessionForAPIKeys: true` so
API keys resolve to sessions transparently via the `x-api-key` header.
All existing `requireSession`/`requireOrgSession` calls work without changes.
**Server (website):**
- `apikey` table added to D1 schema with migration
- `apiKey()` plugin in `getAuth()` config, rate limiting disabled because
`better-auth-drizzle-adapter` lacks `incrementOne` support
- `getSession()` guard updated to check `x-api-key` header
- Server actions: `createApiKey` and `revokeApiKey` in actions.tsx
- `listUserApiKeys` helper in db.ts
- `ApiKeyPanel` client component on dashboard (create, copy, revoke)
- `apiKeyClient()` plugin added to auth-client.ts
**CLI (playwriter):**
- `PLAYWRITER_API_KEY` env var is now highest-priority auth method
- API keys sent via `x-api-key` header; session tokens via `Authorization: Bearer`
- Auth priority: PLAYWRITER_API_KEY > PLAYWRITER_CLOUD_TOKEN > ~/.playwriter/auth.json
- Error messages updated to mention API key as alternative to `cloud login`
**Docs:**
- skill.md cloud section updated with API key auth option
- cloud-browsers.mdx getting started section adds Option B for API keys
Session: ses_10bef183cffeBNdpywc27HbcLi
Add durable per-org cloud session slots so quota claims are enforced by SQLite uniqueness instead of a timing-sensitive count-and-recheck flow.
The cloud connect API now inserts a pending row into the first available slot before creating a Browser Use VM. Concurrent requests race on the unique `(org_id, slot_index)` constraint, so only one request can own each subscribed slot and the loser tries the next slot or receives the quota error.
Stripe Checkout now checks Stripe directly for existing active, trialing, past_due, or incomplete subscriptions before creating a new Checkout session. This closes the gap where the subscription exists in Stripe but the webhook has not mirrored it into D1 yet.
Also align cloud access checks with the shared active subscription status list so dashboard billing state and cloud-connect authorization use the same definition.
Session: ses_10c501577ffeCjsqBQlaEqba64
Replace the two-table cloud model (cloud_browser + cloud_session with profiles,
regions, media blocking, status enums) with a minimal cloud_session table that
maps org -> Browser Use session ID directly. VM status, cdpUrl, cost are queried
from the Browser Use API on demand (source of truth), not duplicated in D1.
Add subscription table for Stripe webhook state mirroring:
- Primary key: subscription_id (Stripe sub_xxx)
- orgId FK to org table
- quantity field determines max concurrent cloud browser sessions
- Tracks status, price, product, billing period, cancel_at_period_end
- Upserted idempotently by webhook on subscription lifecycle events
Update relations: remove cloudBrowser, add subscription + simplified cloudSession.
Migration drops old cloud_browser/cloud_session tables and creates the new
subscription + cloud_session tables with indexes.
Session: ses_10d035e28ffe5cVA8SzrKoNgAP
Create the `db` package at the repo root as a pnpm workspace member.
Contains the full database schema for Playwriter cloud browser support:
BetterAuth core tables:
- user, session, account, verification (Google OAuth + cookie sessions)
- deviceCode (CLI device authorization flow)
Organization tables:
- org (with stripeCustomerId placeholder for future billing)
- orgMember (admin/member roles, unique constraint on userId for
race-safe ensureOrg, unique on orgId+userId for membership)
Cloud browser tables:
- cloudBrowser (Browser Use profiles tied to an org)
- cloudSession (active sessions, org derived via cloudBrowser FK
to avoid denormalization drift)
All timestamp columns use the epochMs custom type which converts
Date objects to epoch milliseconds in toDriver, required because
BetterAuth passes Date params but D1 .bind() only accepts
string | number | null | ArrayBuffer.
Package exports:
- workerd condition: drizzle-orm/d1 via cloudflare:workers env.DB
- default condition: drizzle-orm/sqlite-proxy via D1 HTTP API
for Node.js scripts querying remote D1 outside Workers
Relations use defineRelations v2 API with many-to-many user<->org
through orgMember junction table.
Initial migration generated with drizzle-kit for all tables and indexes.