From 625cf30df12e4ccd1e777485ab5e8c7df0b17d76 Mon Sep 17 00:00:00 2001 From: iamjr15 Date: Fri, 31 Jul 2026 10:20:57 +0530 Subject: [PATCH] refactor: relocate quota tracker DO to agent worker behind capability entrypoints Move the QuotaTracker Durable Object from gateway-worker to agent-worker, where its consumers (run admission, Composio metering, accrual flush) live. Cross-worker access now goes through capability-scoped WorkerEntrypoints with strict-Zod ctx.props validation instead of full cross-script DO namespaces: - agent-worker: local QUOTA_TRACKER binding + appended new_sqlite_classes v3; GatewayQuotaEntrypoint {history, peek, setLimit} and QuotaDeletionEntrypoint {deleteAllState} exported as named entrypoints - gateway-worker: class export + local binding removed; service binding to GatewayQuotaEntrypoint; appended deleted_classes v4 (fires only after all bindings/code refs are gone) - webhooks-worker: cross-script DO binding replaced by QUOTA_DELETION service binding scoped to deleteAllState only - dependency-cruiser: quota-runtime imports restricted to the agent DO shell (rule proven firing via probe) Net privilege reduction: gateway/webhooks previously held full 8-method DO namespaces; each is now confined to its exact baseline call set. All 503 service_maintenance_unavailable translations preserved at every call site. Deploy is roll-forward-only per the approved rev-5.2 sequence (agent -> webhooks -> gateway). Pre-cutover quota counters are discarded at the gateway deploy; sanctioned pre-launch with disposable data. --- .dependency-cruiser.cjs | 5 +- apps/agent-worker/README.md | 20 +++++-- .../src/durable-objects/quota-tracker.ts | 5 +- .../src/gateway-quota-entrypoint.ts | 58 +++++++++++++++++++ apps/agent-worker/src/index.ts | 13 ++++- .../src/quota-deletion-entrypoint.ts | 30 ++++++++++ apps/agent-worker/wrangler.jsonc | 7 ++- apps/gateway-worker/README.md | 24 ++++---- apps/gateway-worker/src/activity-routes.ts | 11 ++-- apps/gateway-worker/src/gateway-env.ts | 4 +- apps/gateway-worker/src/index.ts | 3 +- apps/gateway-worker/src/limits.ts | 25 ++++---- apps/gateway-worker/src/usage-summary.ts | 3 +- apps/gateway-worker/wrangler.jsonc | 17 ++++-- apps/webhooks-worker/README.md | 17 +++--- apps/webhooks-worker/src/index.ts | 4 +- .../webhooks-worker/src/lifecycle-adapters.ts | 14 +---- .../src/quota-tracker-binding.ts | 5 -- apps/webhooks-worker/wrangler.jsonc | 14 +++-- packages/env/src/gateway-worker.ts | 2 +- packages/env/src/webhooks-worker.ts | 2 +- packages/types/README.md | 5 +- packages/types/src/quota.ts | 21 ++++++- 23 files changed, 222 insertions(+), 87 deletions(-) rename apps/{gateway-worker => agent-worker}/src/durable-objects/quota-tracker.ts (90%) create mode 100644 apps/agent-worker/src/gateway-quota-entrypoint.ts create mode 100644 apps/agent-worker/src/quota-deletion-entrypoint.ts delete mode 100644 apps/webhooks-worker/src/quota-tracker-binding.ts diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index b8dbfe06..de16a7e8 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -57,11 +57,10 @@ module.exports = { to: { path: "^packages/db/(src|dist)/schema(/|$)" }, }, { - name: "gateway-quota-runtime-only-through-do-shell", + name: "quota-runtime-only-through-agent-do-shell", severity: "error", from: { - path: "^apps/gateway-worker/src/", - pathNot: "^apps/gateway-worker/src/durable-objects/quota-tracker\\.ts$", + pathNot: "^apps/agent-worker/src/durable-objects/quota-tracker\\.ts$", }, to: { path: "^(@cheatcode/billing/quota-runtime|packages/billing/(src|dist)/quota-runtime\\.(js|ts|d\\.ts))$", diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index 81e2ed61..20e2ca43 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -1,7 +1,8 @@ # @cheatcode/agent-worker Agent loop Worker with `AgentRun`, its durable `AgentRunWorkflow` owner, -user-scoped `ProjectSandbox`, and the Daytona sandbox adapter. +user-scoped `ProjectSandbox`, agent-owned `QuotaTracker`, and the Daytona +sandbox adapter. Each run Durable Object is keyed by run UUID. Each sandbox Durable Object is keyed by a one-way digest of the internal user UUID, so every project for that user shares one isolated @@ -118,13 +119,21 @@ losslessly normalized to at most 64 KiB, and SQLite reads return at most 32 rows its persisted sequence cursor instead of growing isolate memory. Composio actions use the app-level `COMPOSIO_API_KEY`, active rows in -`v2_user_integrations`, and the gateway-owned `QuotaTracker` Durable Object before -executing against a user-connected OAuth account. +`v2_user_integrations`, and the local agent-owned `QuotaTracker` Durable Object +before executing against a user-connected OAuth account. ProjectSandbox records elapsed sandbox-hours to the same `QuotaTracker` as a soft meter so Settings can show real monthly sandbox consumption without blocking sandbox file/process work. +Agent code reaches `QuotaTracker` through its local namespace. External quota +access is split across named, property-validated WorkerEntrypoints: +`GatewayQuotaEntrypoint` exposes only `peek`, `history`, and `setLimit` to the +gateway, while `QuotaDeletionEntrypoint` exposes only `deleteAllState` to the +webhooks account-deletion workflow. The Durable Object shell composes +`@cheatcode/billing/quota-runtime`, which owns RPC input validation, SQLite +storage, retention, and alarm behavior. + Postgres is authoritative for user-authored skill metadata and R2 is authoritative for each versioned skill package. ProjectSandbox mirrors the complete selected package to `/workspace/.cheatcode/skills//` so users can inspect and edit its @@ -257,7 +266,10 @@ the same bound while streaming. - `AgentLifecycleEntrypoint` - `AgentRun` - `AgentRunWorkflow` +- `GatewayQuotaEntrypoint` - `ProjectSandbox` +- `QuotaDeletionEntrypoint` +- `QuotaTracker` ## Code Checks @@ -287,7 +299,7 @@ pnpm --filter @cheatcode/agent-worker typecheck - `OUTPUT_DOWNLOAD_SIGNING_SECRET` (Secrets Store binding) - `OUTPUT_DOWNLOAD_BASE_URL` (development override; production defaults to the gateway origin) - `PREVIEW_HOSTNAME` (development override; production derives the canonical app hostname) -- `QUOTA_TRACKER` +- `QUOTA_TRACKER` (local agent-owned Durable Object namespace) - `R2_AUDIT` - `R2_OUTPUTS` - `SANDBOX_STATE` diff --git a/apps/gateway-worker/src/durable-objects/quota-tracker.ts b/apps/agent-worker/src/durable-objects/quota-tracker.ts similarity index 90% rename from apps/gateway-worker/src/durable-objects/quota-tracker.ts rename to apps/agent-worker/src/durable-objects/quota-tracker.ts index c67eea80..d246549f 100644 --- a/apps/gateway-worker/src/durable-objects/quota-tracker.ts +++ b/apps/agent-worker/src/durable-objects/quota-tracker.ts @@ -4,12 +4,13 @@ import type { QuotaFeature, QuotaHistoryResult, QuotaSnapshotResult, + QuotaTrackerRpc, QuotaTryConsumeResponse, QuotaUsageResponse, } from "@cheatcode/types/quota"; -/** Gateway-owned Durable Object facade over the worker-only billing runtime. */ -export class QuotaTracker extends DurableObject { +/** Agent-owned Durable Object facade over the worker-only billing runtime. */ +export class QuotaTracker extends DurableObject implements QuotaTrackerRpc { private readonly runtime: QuotaTrackerRuntime; public constructor(ctx: DurableObjectState, env: unknown) { diff --git a/apps/agent-worker/src/gateway-quota-entrypoint.ts b/apps/agent-worker/src/gateway-quota-entrypoint.ts new file mode 100644 index 00000000..b1888d69 --- /dev/null +++ b/apps/agent-worker/src/gateway-quota-entrypoint.ts @@ -0,0 +1,58 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; +import { AgentWorkerEnvSchema } from "@cheatcode/env"; +import { toUserId, type UserId } from "@cheatcode/types"; +import type { + GatewayQuotaServiceBinding, + QuotaFeature, + QuotaHistoryResult, + QuotaUsageResponse, +} from "@cheatcode/types/quota"; +import { z } from "zod"; +import type { AgentEnv } from "./agent-env"; +import type { QuotaTrackerStub } from "./quota-tracker-binding"; + +const GatewayQuotaCallerSchema = z.strictObject({ + caller: z.literal("gateway"), + capability: z.literal("gateway-quota"), +}); + +const QuotaUserIdSchema = z.string().uuid().transform(toUserId); +type GatewayQuotaCaller = z.infer; + +/** Quota operations used by gateway usage, activity, and limit-sync routes. */ +export class GatewayQuotaEntrypoint + extends WorkerEntrypoint + implements GatewayQuotaServiceBinding +{ + public history(userId: UserId, feature: QuotaFeature, from: Date): Promise { + return gatewayQuotaStub(this.env, this.ctx.props, userId).history(feature, from); + } + + public peek(userId: UserId, feature: QuotaFeature, periodEnd: Date): Promise { + return gatewayQuotaStub(this.env, this.ctx.props, userId).peek(feature, periodEnd); + } + + public setLimit( + userId: UserId, + feature: QuotaFeature, + limit: number, + entitlementVersion: number, + ): Promise { + return gatewayQuotaStub(this.env, this.ctx.props, userId).setLimit( + feature, + limit, + entitlementVersion, + ); + } +} + +function gatewayQuotaStub( + env: AgentEnv, + props: GatewayQuotaCaller, + userId: UserId, +): QuotaTrackerStub { + AgentWorkerEnvSchema.parse(env); + GatewayQuotaCallerSchema.parse(props); + const parsedUserId = QuotaUserIdSchema.parse(userId); + return env.QUOTA_TRACKER.get(env.QUOTA_TRACKER.idFromName(`quota:${parsedUserId}`)); +} diff --git a/apps/agent-worker/src/index.ts b/apps/agent-worker/src/index.ts index 639b6bd0..10b904c4 100644 --- a/apps/agent-worker/src/index.ts +++ b/apps/agent-worker/src/index.ts @@ -15,15 +15,26 @@ import { AgentLifecycleEntrypoint } from "./agent-lifecycle-entrypoint"; import { AgentRun } from "./durable-objects/agent-run"; import { AgentRunWorkflow } from "./durable-objects/agent-run-workflow"; import { ProjectSandbox } from "./durable-objects/project-sandbox"; +import { QuotaTracker } from "./durable-objects/quota-tracker"; import { formatAgentRouteError, toAgentRouteError } from "./error-handling"; +import { GatewayQuotaEntrypoint } from "./gateway-quota-entrypoint"; import { registerProjectFileHttpRoutes } from "./project-file-http-routes"; +import { QuotaDeletionEntrypoint } from "./quota-deletion-entrypoint"; import { registerSandboxPreviewHttpRoutes } from "./sandbox-preview-http-routes"; import { registerSandboxTerminalHttpRoutes } from "./sandbox-terminal-http-routes"; import { registerSkillRuntimeExecutionRoutes } from "./skill-runtime-execution-routes"; import { registerSkillRuntimeManagedRoutes } from "./skill-runtime-managed-routes"; import { registerUserSkillHttpRoutes } from "./user-skill-http-routes"; -export { AgentLifecycleEntrypoint, AgentRun, AgentRunWorkflow, ProjectSandbox }; +export { + AgentLifecycleEntrypoint, + AgentRun, + AgentRunWorkflow, + GatewayQuotaEntrypoint, + ProjectSandbox, + QuotaDeletionEntrypoint, + QuotaTracker, +}; export const agentApp = new Hono<{ Bindings: AgentEnv }>(); diff --git a/apps/agent-worker/src/quota-deletion-entrypoint.ts b/apps/agent-worker/src/quota-deletion-entrypoint.ts new file mode 100644 index 00000000..fe16321b --- /dev/null +++ b/apps/agent-worker/src/quota-deletion-entrypoint.ts @@ -0,0 +1,30 @@ +import { WorkerEntrypoint } from "cloudflare:workers"; +import { AgentWorkerEnvSchema } from "@cheatcode/env"; +import { toUserId, type UserId } from "@cheatcode/types"; +import type { QuotaDeletionServiceBinding } from "@cheatcode/types/quota"; +import { z } from "zod"; +import type { AgentEnv } from "./agent-env"; + +const QuotaDeletionCallerSchema = z.strictObject({ + caller: z.literal("webhooks"), + capability: z.literal("quota-deletion"), +}); + +const QuotaUserIdSchema = z.string().uuid().transform(toUserId); +type QuotaDeletionCaller = z.infer; + +/** + * Destructive quota-state capability held only by the account-deletion worker. + */ +export class QuotaDeletionEntrypoint + extends WorkerEntrypoint + implements QuotaDeletionServiceBinding +{ + public deleteAllState(userId: UserId): Promise { + AgentWorkerEnvSchema.parse(this.env); + QuotaDeletionCallerSchema.parse(this.ctx.props); + const parsedUserId = QuotaUserIdSchema.parse(userId); + const namespace = this.env.QUOTA_TRACKER; + return namespace.get(namespace.idFromName(`quota:${parsedUserId}`)).deleteAllState(); + } +} diff --git a/apps/agent-worker/wrangler.jsonc b/apps/agent-worker/wrangler.jsonc index 1a0e9b09..b085f952 100644 --- a/apps/agent-worker/wrangler.jsonc +++ b/apps/agent-worker/wrangler.jsonc @@ -60,8 +60,7 @@ }, { "name": "QUOTA_TRACKER", - "class_name": "QuotaTracker", - "script_name": "cheatcode-gateway" + "class_name": "QuotaTracker" } ] }, @@ -73,6 +72,10 @@ { "tag": "v2", "new_sqlite_classes": ["ProjectSandbox"] + }, + { + "tag": "v3", + "new_sqlite_classes": ["QuotaTracker"] } ], "workflows": [ diff --git a/apps/gateway-worker/README.md b/apps/gateway-worker/README.md index 02395a58..00c194f2 100644 --- a/apps/gateway-worker/README.md +++ b/apps/gateway-worker/README.md @@ -29,11 +29,13 @@ entitlement cache outside Postgres, while project and BYOK writes read the authoritative entitlement row under the same per-user advisory-lock order as entitlement reconciliation. -`QuotaTracker` supports hard `try-consume` gates for connected-tool calls and -soft `record` metering for sandbox-hours. Limit synchronization carries the -entitlement row's `updatedAt` version, and the Durable Object ignores older -writes so a stale KV or Worker request cannot overwrite a newer plan. Request -rate-limit headers remain the canonical live rate-limit state. +The agent Worker owns `QuotaTracker`. Gateway usage, activity, and limit-sync +routes hold a named `GatewayQuotaEntrypoint` Service Binding that exposes only +`peek`, `history`, and `setLimit`; the gateway has no Durable Object namespace +or destructive quota capability. Limit synchronization carries the entitlement +row's `updatedAt` version, and the Durable Object ignores older writes so a +stale KV or Worker request cannot overwrite a newer plan. Request rate-limit +headers remain the canonical live rate-limit state. Gateway-native buckets use Hono's registered route path; the resulting key format replaces the former duplicated literals and old Durable Object buckets age out naturally. Forwarded-route costs remain owned by the shared manifest. @@ -107,10 +109,10 @@ converge. Deployments publish the gateway last so public traffic observes only a backend set built from the same reviewed revision. SQLite schema validation remains synchronous. -`IdempotencyStore`, `RateLimiter`, and `QuotaTracker` each own one exact SQLite -schema. New objects initialize that schema directly; existing objects must -already match it before an operation is admitted. Run creation is also durably -idempotent in Postgres, so request-cache state cannot create a duplicate run. +`IdempotencyStore` and `RateLimiter` each own one exact SQLite schema. New +objects initialize that schema directly; existing objects must already match it +before an operation is admitted. Run creation is also durably idempotent in +Postgres, so request-cache state cannot create a duplicate run. The shared framework-free tool capability catalog in `@cheatcode/types` statically constrains the Mastra tool registry to the same exact names. Each @@ -121,7 +123,6 @@ same traits instead of maintaining parallel tool-name lists. ## Public exports - `IdempotencyStore` -- `QuotaTracker` - `RateLimiter` ## Code Checks @@ -142,7 +143,8 @@ pnpm --filter @cheatcode/gateway-worker typecheck - `PREVIEW_PROXY` (generated local-only Service Binding; production preview traffic reaches the preview Worker through its wildcard route) - `RATE_LIMITER` -- `QUOTA_TRACKER` +- `QUOTA_TRACKER` (named `GatewayQuotaEntrypoint` Service Binding to + agent-worker; grants only `peek`, `history`, and `setLimit`) - `IDEMPOTENCY` - `ENTITLEMENTS_CACHE` - `HYPERDRIVE` (dedicated config whose database login is exactly `app_gateway`) diff --git a/apps/gateway-worker/src/activity-routes.ts b/apps/gateway-worker/src/activity-routes.ts index 661d8498..9f4e47b5 100644 --- a/apps/gateway-worker/src/activity-routes.ts +++ b/apps/gateway-worker/src/activity-routes.ts @@ -13,14 +13,13 @@ import { ActivityHistoryResponseSchema, ActivityQuerySchema, } from "@cheatcode/types/api"; -import { QUOTA_FEATURES } from "@cheatcode/types/quota"; +import { type GatewayQuotaServiceBinding, QUOTA_FEATURES } from "@cheatcode/types/quota"; import type { z } from "zod"; -import type { QuotaTracker } from "./durable-objects/quota-tracker"; export interface ActivityRouteEnv { DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY: WorkerSecret; HYPERDRIVE: Hyperdrive; - QUOTA_TRACKER: DurableObjectNamespace; + QUOTA_TRACKER: GatewayQuotaServiceBinding; } const MS_PER_DAY = 86_400_000; @@ -60,10 +59,10 @@ async function buildActivityResponse( } async function listSandboxHourHistory(env: ActivityRouteEnv, userId: UserId, days: number) { - const stub = env.QUOTA_TRACKER.get(env.QUOTA_TRACKER.idFromName(`quota:${userId}`)); - let history: Awaited>; + let history: Awaited>; try { - history = await stub.history( + history = await env.QUOTA_TRACKER.history( + userId, QUOTA_FEATURES.sandboxHours, new Date(Date.now() - days * MS_PER_DAY), ); diff --git a/apps/gateway-worker/src/gateway-env.ts b/apps/gateway-worker/src/gateway-env.ts index 4a66e006..9473d988 100644 --- a/apps/gateway-worker/src/gateway-env.ts +++ b/apps/gateway-worker/src/gateway-env.ts @@ -2,9 +2,9 @@ import type { DatabaseHandle } from "@cheatcode/db"; import type { CloudflareVersionMetadata, WorkerSecret } from "@cheatcode/env"; import type { AnalyticsBindings } from "@cheatcode/observability"; import type { ResourceDeletionServiceBinding } from "@cheatcode/types/internal"; +import type { GatewayQuotaServiceBinding } from "@cheatcode/types/quota"; import type { Context, Hono } from "hono"; import type { IdempotencyStore } from "./durable-objects/idempotency"; -import type { QuotaTracker } from "./durable-objects/quota-tracker"; import type { RateLimiter } from "./durable-objects/rate-limiter"; import type { IdempotencyBindings } from "./idempotency"; @@ -26,7 +26,7 @@ export interface GatewayEnv extends AnalyticsBindings, IdempotencyBindings { POLAR_PRODUCT_ID_PRO?: string; POLAR_SERVER?: "production" | "sandbox"; PREVIEW_PROXY?: Fetcher; - QUOTA_TRACKER: DurableObjectNamespace; + QUOTA_TRACKER: GatewayQuotaServiceBinding; RATE_LIMITER: DurableObjectNamespace; RESOURCE_DELETION: ResourceDeletionServiceBinding; WEBHOOKS: Fetcher; diff --git a/apps/gateway-worker/src/index.ts b/apps/gateway-worker/src/index.ts index 945089c2..a8ed0d3a 100644 --- a/apps/gateway-worker/src/index.ts +++ b/apps/gateway-worker/src/index.ts @@ -18,7 +18,6 @@ import { registerBillingHttpRoutes } from "./billing-http-routes"; import { registerCoreHttpRoutes } from "./core-http-routes"; import { resolveCorsOrigin } from "./cors"; import { IdempotencyStore } from "./durable-objects/idempotency"; -import { QuotaTracker } from "./durable-objects/quota-tracker"; import { RateLimiter } from "./durable-objects/rate-limiter"; import { formatGatewayRouteError } from "./error-handling"; import type { GatewayContext, GatewayEnv, GatewayHonoEnv } from "./gateway-env"; @@ -31,7 +30,7 @@ import { registerProviderHttpRoutes } from "./provider-http-routes"; import { withRateLimitErrorHeaders } from "./rate-limit"; import { registerSearchHttpRoutes } from "./search-http-routes"; -export { IdempotencyStore, QuotaTracker, RateLimiter }; +export { IdempotencyStore, RateLimiter }; const CORS_EXPOSED_HEADERS = [ "Content-Disposition", diff --git a/apps/gateway-worker/src/limits.ts b/apps/gateway-worker/src/limits.ts index 8abbc2ce..e4171ca9 100644 --- a/apps/gateway-worker/src/limits.ts +++ b/apps/gateway-worker/src/limits.ts @@ -13,12 +13,15 @@ import { } from "@cheatcode/db"; import { APIError, createLogger } from "@cheatcode/observability"; import type { UserId } from "@cheatcode/types"; -import { QUOTA_FEATURES, type QuotaFeature } from "@cheatcode/types/quota"; -import type { QuotaTracker } from "./durable-objects/quota-tracker"; +import { + type GatewayQuotaServiceBinding, + QUOTA_FEATURES, + type QuotaFeature, +} from "@cheatcode/types/quota"; export interface LimitBindings { ENTITLEMENTS_CACHE: KVNamespace; - QUOTA_TRACKER: DurableObjectNamespace; + QUOTA_TRACKER: GatewayQuotaServiceBinding; } const ENTITLEMENT_CACHE_TTL_SECONDS = 300; @@ -76,17 +79,18 @@ export async function syncQuotaLimits( userId: UserId, entitlement: EntitlementCache, ): Promise { - const stub = quotaStub(env, userId); const entitlementVersion = Date.parse(entitlement.updatedAt); await Promise.all([ setQuotaLimit( - stub, + env.QUOTA_TRACKER, + userId, QUOTA_FEATURES.sandboxHours, entitlement.quotaSandboxHours, entitlementVersion, ), setQuotaLimit( - stub, + env.QUOTA_TRACKER, + userId, QUOTA_FEATURES.composioCalls, entitlement.quotaComposioCalls, entitlementVersion, @@ -95,13 +99,14 @@ export async function syncQuotaLimits( } async function setQuotaLimit( - stub: DurableObjectStub, + quota: GatewayQuotaServiceBinding, + userId: UserId, feature: QuotaFeature, limit: number, entitlementVersion: number, ): Promise { try { - await stub.setLimit(feature, limit, entitlementVersion); + await quota.setLimit(userId, feature, limit, entitlementVersion); } catch (error) { throw new APIError(503, "service_maintenance_unavailable", "Quota tracker is unavailable", { cause: error, @@ -134,10 +139,6 @@ async function readCachedEntitlement( return null; } -function quotaStub(env: LimitBindings, userId: UserId): DurableObjectStub { - return env.QUOTA_TRACKER.get(env.QUOTA_TRACKER.idFromName(`quota:${userId}`)); -} - function entitlementCacheKey(userId: UserId): string { return `entitlement:${userId}`; } diff --git a/apps/gateway-worker/src/usage-summary.ts b/apps/gateway-worker/src/usage-summary.ts index 19043cd4..c7aad578 100644 --- a/apps/gateway-worker/src/usage-summary.ts +++ b/apps/gateway-worker/src/usage-summary.ts @@ -38,9 +38,8 @@ async function peekSandboxHoursUsed( userId: UserId, periodEnd: Date, ): Promise { - const stub = env.QUOTA_TRACKER.get(env.QUOTA_TRACKER.idFromName(`quota:${userId}`)); try { - return (await stub.peek(QUOTA_FEATURES.sandboxHours, periodEnd)).used; + return (await env.QUOTA_TRACKER.peek(userId, QUOTA_FEATURES.sandboxHours, periodEnd)).used; } catch (error) { throw new APIError(503, "service_maintenance_unavailable", "Quota tracker is unavailable", { cause: error, diff --git a/apps/gateway-worker/wrangler.jsonc b/apps/gateway-worker/wrangler.jsonc index 3abb26a7..e06c6c25 100644 --- a/apps/gateway-worker/wrangler.jsonc +++ b/apps/gateway-worker/wrangler.jsonc @@ -29,6 +29,15 @@ "binding": "WEBHOOKS", "service": "cheatcode-webhooks" }, + { + "binding": "QUOTA_TRACKER", + "service": "cheatcode-agent", + "entrypoint": "GatewayQuotaEntrypoint", + "props": { + "caller": "gateway", + "capability": "gateway-quota" + } + }, { "binding": "RESOURCE_DELETION", "service": "cheatcode-webhooks", @@ -72,10 +81,6 @@ "name": "RATE_LIMITER", "class_name": "RateLimiter" }, - { - "name": "QUOTA_TRACKER", - "class_name": "QuotaTracker" - }, { "name": "IDEMPOTENCY", "class_name": "IdempotencyStore" @@ -107,6 +112,10 @@ { "tag": "v3", "new_sqlite_classes": ["IdempotencyStore"] + }, + { + "tag": "v4", + "deleted_classes": ["QuotaTracker"] } ], "analytics_engine_datasets": [ diff --git a/apps/webhooks-worker/README.md b/apps/webhooks-worker/README.md index 0cb84d22..e52e2422 100644 --- a/apps/webhooks-worker/README.md +++ b/apps/webhooks-worker/README.md @@ -22,14 +22,14 @@ sets). Legacy payload versions and field aliases are rejected at ingress. `DailyMaintenanceWorkflow` removes abandoned uploads, while `UserDeletionWorkflow` owns Clerk-driven GDPR deletion lifecycle jobs. BYOK inventory runs directly from the five-minute scheduled handler because its database UUID leases provide retry -and continuation state. Account deletion jobs call the agent Worker through a -Service Binding and clear quota state through a direct cross-Worker Durable Object -binding before removing R2 and Postgres rows. These destructive agent calls use -the named `AgentLifecycleEntrypoint` Service Binding. The -binding itself grants the capability, while Cloudflare-authenticated properties -pin the `webhooks` caller and `agent-lifecycle` permission. The Agent Worker +and continuation state. Account deletion jobs call the agent Worker through +named Service Bindings before removing R2 and Postgres rows. +`AgentLifecycleEntrypoint` grants destructive agent-state operations, while the +separate `QuotaDeletionEntrypoint` grants only `deleteAllState` on the +agent-owned quota Durable Object. Cloudflare-authenticated properties pin both +bindings to the `webhooks` caller and their exact capabilities. The Agent Worker revalidates the authoritative database deletion generation before changing -state. +agent state. The gateway-only `ResourceDeletionEntrypoint` registers project and thread deletion jobs in `v2_resource_deletion_jobs`; the default HTTP handler exposes no @@ -160,7 +160,8 @@ pnpm --filter @cheatcode/webhooks-worker typecheck - `COMPOSIO_API_KEY` - `ENTITLEMENTS_CACHE` - `SANDBOX_STATE` -- `QUOTA_TRACKER` +- `QUOTA_DELETION` (named `QuotaDeletionEntrypoint` Service Binding to + agent-worker; grants only `deleteAllState`) - `HYPERDRIVE` (dedicated config whose database login is exactly `app_webhooks`) - `DATABASE_CONTEXT_SIGNING_SECRET_WEBHOOKS` (role-specific Secrets Store binding; must match the `app_webhooks` Supabase Vault HMAC secret) diff --git a/apps/webhooks-worker/src/index.ts b/apps/webhooks-worker/src/index.ts index 2300e753..4b3451ac 100644 --- a/apps/webhooks-worker/src/index.ts +++ b/apps/webhooks-worker/src/index.ts @@ -17,6 +17,7 @@ import { safeErrorTelemetry, } from "@cheatcode/observability"; import type { AgentLifecycleServiceBinding } from "@cheatcode/types/internal"; +import type { QuotaDeletionServiceBinding } from "@cheatcode/types/quota"; import { verifyWebhook } from "@clerk/backend/webhooks"; import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks"; import { type Context, Hono } from "hono"; @@ -28,7 +29,6 @@ import { enqueueDailyMaintenance, } from "./daily-maintenance-workflow"; import { DaytonaWebhookSchema, verifyDaytonaWebhook } from "./daytona"; -import type { QuotaTrackerNamespace } from "./quota-tracker-binding"; import { ResourceDeletionEntrypoint } from "./resource-deletion-entrypoint"; import { ResourceDeletionWorkflow, @@ -79,7 +79,7 @@ export interface WebhooksEnv POLAR_PRODUCT_ID_PRO?: string; POLAR_SERVER?: "production" | "sandbox"; POLAR_WEBHOOK_SECRET?: WorkerSecret; - QUOTA_TRACKER: QuotaTrackerNamespace; + QUOTA_DELETION: QuotaDeletionServiceBinding; R2_OUTPUTS: R2Bucket; // Webhook-fed sandbox lifecycle cache (Daytona sandbox.state.updated), read by agent-worker's // preview-status endpoint. Optional so the endpoint falls back to a live read when unbound. diff --git a/apps/webhooks-worker/src/lifecycle-adapters.ts b/apps/webhooks-worker/src/lifecycle-adapters.ts index 77ceae49..2ad3e5ba 100644 --- a/apps/webhooks-worker/src/lifecycle-adapters.ts +++ b/apps/webhooks-worker/src/lifecycle-adapters.ts @@ -8,7 +8,7 @@ import { type InternalAgentStateDeleteBody, InternalAgentStateDeleteBodySchema, } from "@cheatcode/types/internal"; -import type { QuotaTrackerNamespace } from "./quota-tracker-binding"; +import type { QuotaDeletionServiceBinding } from "@cheatcode/types/quota"; export interface AgentStateDeletionEnv { AGENT_LIFECYCLE: AgentLifecycleServiceBinding; @@ -18,7 +18,7 @@ export interface LifecycleEnv extends AgentStateDeletionEnv { COMPOSIO_API_KEY?: WorkerSecret; POLAR_ACCESS_TOKEN?: WorkerSecret; POLAR_SERVER?: "production" | "sandbox"; - QUOTA_TRACKER: QuotaTrackerNamespace; + QUOTA_DELETION: QuotaDeletionServiceBinding; R2_OUTPUTS: R2Bucket; } @@ -29,16 +29,8 @@ export async function deleteUserQuotaDurableState( env: LifecycleEnv, userId: UserId, ): Promise { - await deleteQuotaNamespaceState(env.QUOTA_TRACKER, userId); -} - -async function deleteQuotaNamespaceState( - namespace: QuotaTrackerNamespace, - userId: UserId, -): Promise { - const quota = namespace.get(namespace.idFromName(`quota:${userId}`)); try { - await quota.deleteAllState(); + await env.QUOTA_DELETION.deleteAllState(userId); } catch (error) { throw new APIError( 503, diff --git a/apps/webhooks-worker/src/quota-tracker-binding.ts b/apps/webhooks-worker/src/quota-tracker-binding.ts deleted file mode 100644 index 8bfd955e..00000000 --- a/apps/webhooks-worker/src/quota-tracker-binding.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { DurableObject } from "cloudflare:workers"; -import type { QuotaTrackerRpc } from "@cheatcode/types/quota"; - -type QuotaTrackerObject = DurableObject & QuotaTrackerRpc; -export type QuotaTrackerNamespace = DurableObjectNamespace; diff --git a/apps/webhooks-worker/wrangler.jsonc b/apps/webhooks-worker/wrangler.jsonc index 1d4439d3..95d7fd54 100644 --- a/apps/webhooks-worker/wrangler.jsonc +++ b/apps/webhooks-worker/wrangler.jsonc @@ -75,6 +75,15 @@ "caller": "webhooks", "capability": "agent-lifecycle" } + }, + { + "binding": "QUOTA_DELETION", + "service": "cheatcode-agent", + "entrypoint": "QuotaDeletionEntrypoint", + "props": { + "caller": "webhooks", + "capability": "quota-deletion" + } } ], "durable_objects": { @@ -82,11 +91,6 @@ { "name": "WEBHOOK_IDEMPOTENCY", "class_name": "WebhookIdempotencyStore" - }, - { - "name": "QUOTA_TRACKER", - "class_name": "QuotaTracker", - "script_name": "cheatcode-gateway" } ] }, diff --git a/packages/env/src/gateway-worker.ts b/packages/env/src/gateway-worker.ts index d59aca14..07b6f9f9 100644 --- a/packages/env/src/gateway-worker.ts +++ b/packages/env/src/gateway-worker.ts @@ -30,7 +30,7 @@ export const GatewayWorkerEnvSchema = z POLAR_PRODUCT_ID_PREMIUM: z.string().min(1).optional(), POLAR_SERVER: z.enum(["production", "sandbox"]).optional(), PREVIEW_PROXY: FetcherBindingSchema.optional(), - QUOTA_TRACKER: DurableObjectNamespaceBindingSchema, + QUOTA_TRACKER: FetcherBindingSchema, RATE_LIMITER: DurableObjectNamespaceBindingSchema, RESOURCE_DELETION: FetcherBindingSchema, WEBHOOKS: FetcherBindingSchema, diff --git a/packages/env/src/webhooks-worker.ts b/packages/env/src/webhooks-worker.ts index 8657110f..2e9d5090 100644 --- a/packages/env/src/webhooks-worker.ts +++ b/packages/env/src/webhooks-worker.ts @@ -31,7 +31,7 @@ export const WebhooksWorkerEnvSchema = z POLAR_PRODUCT_ID_PRO: z.string().min(1).optional(), POLAR_SERVER: z.enum(["production", "sandbox"]).optional(), POLAR_WEBHOOK_SECRET: OptionalWorkerSecretSchema, - QUOTA_TRACKER: DurableObjectNamespaceBindingSchema, + QUOTA_DELETION: FetcherBindingSchema, R2_OUTPUTS: R2BucketBindingSchema, RESOURCE_DELETION_WORKFLOW: WorkflowBindingSchema, SANDBOX_STATE: KvNamespaceBindingSchema.optional(), diff --git a/packages/types/README.md b/packages/types/README.md index 7dda1c51..b8fe0fcf 100644 --- a/packages/types/README.md +++ b/packages/types/README.md @@ -19,8 +19,9 @@ capability discovery contracts, error codes, and UI message types. - `@cheatcode/types/internal`: Worker-only Gateway-to-Agent route manifest, service-binding deletion contracts, and workspace/sandbox-transition evidence - `models.ts`: catalog IDs plus the open provider-prefixed logical-model schema -- `@cheatcode/types/quota`: strict cross-Worker QuotaTracker request/response - contracts and canonical quota feature identifiers +- `@cheatcode/types/quota`: the single QuotaTracker RPC contract, typed + capability-scoped WorkerEntrypoint projections, strict request/response + contracts, and canonical quota feature identifiers - `sandbox-wire.ts`: canonical sandbox file-entry fields and exec-result base used by API, runtime-port, and code-tool schemas - `skill-runtime.ts`: canonical skill-runtime capability scopes and schema diff --git a/packages/types/src/quota.ts b/packages/types/src/quota.ts index b797d3c2..a368bfa0 100644 --- a/packages/types/src/quota.ts +++ b/packages/types/src/quota.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { UserId } from "./ids"; export const QUOTA_FEATURES = { composioCalls: "composio_calls", @@ -43,7 +44,7 @@ export type QuotaSnapshotResult = z.infer; export type QuotaUsageResponse = z.infer; export type QuotaTryConsumeResponse = z.infer; -/** Cross-script public surface of the gateway-owned QuotaTracker Durable Object. */ +/** Public RPC surface of the agent-owned QuotaTracker Durable Object. */ export interface QuotaTrackerRpc { deleteAllState(): Promise; history(feature: QuotaFeature, from: Date): Promise; @@ -64,3 +65,21 @@ export interface QuotaTrackerRpc { eventId: string, ): Promise; } + +type UserScopedQuotaTrackerMethod = + QuotaTrackerRpc[Method] extends (...args: infer Args) => infer Result + ? (userId: UserId, ...args: Args) => Result + : never; + +/** + * A capability-scoped WorkerEntrypoint projection of selected QuotaTracker + * methods. The user id selects the owning Durable Object; method arguments and + * results remain derived from the single QuotaTrackerRpc contract. + */ +type UserScopedQuotaTrackerRpc = { + [Method in Methods]: UserScopedQuotaTrackerMethod; +}; + +export type GatewayQuotaServiceBinding = UserScopedQuotaTrackerRpc<"history" | "peek" | "setLimit">; + +export type QuotaDeletionServiceBinding = UserScopedQuotaTrackerRpc<"deleteAllState">;