diff --git a/.github/workflows/deploy-den-control-plane.yml b/.github/workflows/deploy-den-control-plane.yml index 203db90d..e5142760 100644 --- a/.github/workflows/deploy-den-control-plane.yml +++ b/.github/workflows/deploy-den-control-plane.yml @@ -29,6 +29,10 @@ jobs: RENDER_OWNER_ID: ${{ secrets.RENDER_OWNER_ID }} DEN_DATABASE_URL: ${{ secrets.DEN_DATABASE_URL }} DEN_BETTER_AUTH_SECRET: ${{ secrets.DEN_BETTER_AUTH_SECRET }} + POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_ACCESS_TOKEN }} + POLAR_PRODUCT_ID: ${{ secrets.POLAR_PRODUCT_ID }} + POLAR_BENEFIT_ID: ${{ secrets.POLAR_BENEFIT_ID }} + DEN_POLAR_FEATURE_GATE_ENABLED: ${{ vars.DEN_POLAR_FEATURE_GATE_ENABLED }} run: | missing=0 for key in RENDER_API_KEY RENDER_DEN_CONTROL_PLANE_SERVICE_ID RENDER_OWNER_ID DEN_DATABASE_URL DEN_BETTER_AUTH_SECRET; do @@ -37,6 +41,18 @@ jobs: missing=1 fi done + + feature_enabled="${DEN_POLAR_FEATURE_GATE_ENABLED:-false}" + feature_enabled="$(echo "$feature_enabled" | tr '[:upper:]' '[:lower:]')" + if [ "$feature_enabled" = "true" ]; then + for key in POLAR_ACCESS_TOKEN POLAR_PRODUCT_ID POLAR_BENEFIT_ID; do + if [ -z "${!key}" ]; then + echo "::error::Missing required paywall secret: $key" + missing=1 + fi + done + fi + if [ "$missing" -ne 0 ]; then exit 1 fi @@ -49,6 +65,11 @@ jobs: DEN_DATABASE_URL: ${{ secrets.DEN_DATABASE_URL }} DEN_BETTER_AUTH_SECRET: ${{ secrets.DEN_BETTER_AUTH_SECRET }} DEN_RENDER_WORKER_OPENWORK_VERSION: ${{ vars.DEN_RENDER_WORKER_OPENWORK_VERSION }} + DEN_POLAR_FEATURE_GATE_ENABLED: ${{ vars.DEN_POLAR_FEATURE_GATE_ENABLED }} + DEN_POLAR_API_BASE: ${{ vars.DEN_POLAR_API_BASE }} + POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_ACCESS_TOKEN }} + POLAR_PRODUCT_ID: ${{ secrets.POLAR_PRODUCT_ID }} + POLAR_BENEFIT_ID: ${{ secrets.POLAR_BENEFIT_ID }} run: | python3 <<'PY' import json @@ -61,6 +82,16 @@ jobs: service_id = os.environ["RENDER_DEN_CONTROL_PLANE_SERVICE_ID"] owner_id = os.environ["RENDER_OWNER_ID"] openwork_version = os.environ.get("DEN_RENDER_WORKER_OPENWORK_VERSION") or "0.11.113" + paywall_enabled = (os.environ.get("DEN_POLAR_FEATURE_GATE_ENABLED") or "false").lower() == "true" + polar_api_base = os.environ.get("DEN_POLAR_API_BASE") or "https://api.polar.sh" + polar_access_token = os.environ.get("POLAR_ACCESS_TOKEN") or "" + polar_product_id = os.environ.get("POLAR_PRODUCT_ID") or "" + polar_benefit_id = os.environ.get("POLAR_BENEFIT_ID") or "" + + if paywall_enabled and (not polar_access_token or not polar_product_id or not polar_benefit_id): + raise RuntimeError( + "DEN_POLAR_FEATURE_GATE_ENABLED=true requires POLAR_ACCESS_TOKEN, POLAR_PRODUCT_ID, and POLAR_BENEFIT_ID" + ) headers = { "Authorization": f"Bearer {api_key}", @@ -105,6 +136,13 @@ jobs: {"key": "RENDER_PROVISION_TIMEOUT_MS", "value": "900000"}, {"key": "RENDER_HEALTHCHECK_TIMEOUT_MS", "value": "180000"}, {"key": "RENDER_POLL_INTERVAL_MS", "value": "5000"}, + {"key": "POLAR_FEATURE_GATE_ENABLED", "value": "true" if paywall_enabled else "false"}, + {"key": "POLAR_API_BASE", "value": polar_api_base}, + {"key": "POLAR_ACCESS_TOKEN", "value": polar_access_token}, + {"key": "POLAR_PRODUCT_ID", "value": polar_product_id}, + {"key": "POLAR_BENEFIT_ID", "value": polar_benefit_id}, + {"key": "POLAR_SUCCESS_URL", "value": service_url}, + {"key": "POLAR_RETURN_URL", "value": service_url}, ] request("PUT", f"/services/{service_id}/env-vars", env_vars) diff --git a/services/den-control-plane/.env.example b/services/den-control-plane/.env.example index 519acc6b..ef499a7c 100644 --- a/services/den-control-plane/.env.example +++ b/services/den-control-plane/.env.example @@ -18,3 +18,10 @@ RENDER_WORKER_NAME_PREFIX=den-worker RENDER_PROVISION_TIMEOUT_MS=900000 RENDER_HEALTHCHECK_TIMEOUT_MS=180000 RENDER_POLL_INTERVAL_MS=5000 +POLAR_FEATURE_GATE_ENABLED=false +POLAR_API_BASE=https://api.polar.sh +POLAR_ACCESS_TOKEN= +POLAR_PRODUCT_ID= +POLAR_BENEFIT_ID= +POLAR_SUCCESS_URL=http://localhost:8788 +POLAR_RETURN_URL=http://localhost:8788 diff --git a/services/den-control-plane/AGENT_FOCUS.md b/services/den-control-plane/AGENT_FOCUS.md new file mode 100644 index 00000000..425e934e --- /dev/null +++ b/services/den-control-plane/AGENT_FOCUS.md @@ -0,0 +1,74 @@ +# Agent Focus: Den Control Plane + Polar Gate + +This guide explains how agents should operate, test, and troubleshoot the Den control plane. + +## What this service does + +- Handles auth (`/api/auth/*`) and session lookup (`/v1/me`). +- Creates workers (`/v1/workers`) and provisions cloud workers on Render. +- Optionally enforces a Polar paywall for cloud worker creation. + +## Core flows to test + +### 1) Auth flow + +1. `POST /api/auth/sign-up/email` +2. `GET /v1/me` using cookie session +3. `GET /v1/me` using Bearer token from sign-up response + +Expected: all succeed with `200`. + +### 2) Cloud worker flow (no paywall) + +Set `POLAR_FEATURE_GATE_ENABLED=false`. + +1. `POST /v1/workers` with `destination="cloud"` +2. Confirm `instance.provider="render"` +3. Poll `instance.url + "/health"` + +Expected: worker creation `201`, worker health `200`. + +### 3) Cloud worker flow (paywall enabled) + +Set all Polar env vars and `POLAR_FEATURE_GATE_ENABLED=true`. + +For a user without entitlement: + +1. `POST /v1/workers` with `destination="cloud"` + +Expected: + +- `402 payment_required` +- response contains `polar.checkoutUrl` + +For an entitled user (has the required Polar benefit): + +1. `POST /v1/workers` with `destination="cloud"` + +Expected: worker creation `201` with Render-backed instance. + +## Required env vars (summary) + +- Base: `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL` +- Render: `PROVISIONER_MODE=render`, `RENDER_API_KEY`, `RENDER_OWNER_ID`, and `RENDER_WORKER_*` +- Polar gate: + - `POLAR_FEATURE_GATE_ENABLED` + - `POLAR_ACCESS_TOKEN` + - `POLAR_PRODUCT_ID` + - `POLAR_BENEFIT_ID` + - `POLAR_SUCCESS_URL` + - `POLAR_RETURN_URL` + +## Deployment behavior + +`dev` is the production branch for this service. Workflow: + +- `.github/workflows/deploy-den-control-plane.yml` + +It updates Render env vars and triggers a deploy for the configured service ID. + +## Common failure modes + +- `provisioning_failed`: Render deploy failed or health check timed out. +- `payment_required`: Polar gate is enabled and user does not have the required benefit. +- startup error: paywall enabled but missing Polar env vars. diff --git a/services/den-control-plane/README.md b/services/den-control-plane/README.md index 5e129f2c..98423f72 100644 --- a/services/den-control-plane/README.md +++ b/services/den-control-plane/README.md @@ -32,6 +32,13 @@ pnpm dev - `RENDER_PROVISION_TIMEOUT_MS` max time to wait for deploy to become live - `RENDER_HEALTHCHECK_TIMEOUT_MS` max time to wait for worker health checks - `RENDER_POLL_INTERVAL_MS` polling interval for deploy + health checks +- `POLAR_FEATURE_GATE_ENABLED` enable cloud-worker paywall (`true` or `false`) +- `POLAR_API_BASE` Polar API base URL (default `https://api.polar.sh`) +- `POLAR_ACCESS_TOKEN` Polar organization access token (required when paywall enabled) +- `POLAR_PRODUCT_ID` Polar product ID used for checkout sessions (required when paywall enabled) +- `POLAR_BENEFIT_ID` Polar benefit ID required to unlock cloud workers (required when paywall enabled) +- `POLAR_SUCCESS_URL` redirect URL after successful checkout (required when paywall enabled) +- `POLAR_RETURN_URL` return URL shown in checkout (required when paywall enabled) ## Auth setup (Better Auth) @@ -54,6 +61,7 @@ pnpm db:migrate - `GET /` demo web app (sign-up + auth + worker launch) - `GET /v1/me` - `POST /v1/workers` + - Returns `402 payment_required` with Polar checkout URL when paywall is enabled and entitlement is missing. - `GET /v1/workers/:id` - `POST /v1/workers/:id/tokens` diff --git a/services/den-control-plane/src/billing/polar.ts b/services/den-control-plane/src/billing/polar.ts new file mode 100644 index 00000000..2f0a9081 --- /dev/null +++ b/services/den-control-plane/src/billing/polar.ts @@ -0,0 +1,135 @@ +import { env } from "../env.js" + +type PolarCustomerState = { + granted_benefits?: Array<{ + benefit_id?: string + }> +} + +type PolarCheckoutSession = { + url?: string +} + +export type CloudWorkerAccess = + | { + allowed: true + } + | { + allowed: false + checkoutUrl: string + } + +type CloudAccessInput = { + userId: string + email: string + name: string +} + +function sanitizeApiBase(value: string) { + return value.replace(/\/+$/, "") +} + +async function polarFetch(path: string, init: RequestInit = {}) { + const headers = new Headers(init.headers) + headers.set("Authorization", `Bearer ${env.polar.accessToken}`) + headers.set("Accept", "application/json") + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json") + } + + return fetch(`${sanitizeApiBase(env.polar.apiBase)}${path}`, { + ...init, + headers, + }) +} + +function assertPaywallConfig() { + if (!env.polar.accessToken) { + throw new Error("POLAR_ACCESS_TOKEN is required when POLAR_FEATURE_GATE_ENABLED=true") + } + if (!env.polar.productId) { + throw new Error("POLAR_PRODUCT_ID is required when POLAR_FEATURE_GATE_ENABLED=true") + } + if (!env.polar.benefitId) { + throw new Error("POLAR_BENEFIT_ID is required when POLAR_FEATURE_GATE_ENABLED=true") + } + if (!env.polar.successUrl) { + throw new Error("POLAR_SUCCESS_URL is required when POLAR_FEATURE_GATE_ENABLED=true") + } + if (!env.polar.returnUrl) { + throw new Error("POLAR_RETURN_URL is required when POLAR_FEATURE_GATE_ENABLED=true") + } +} + +async function getCustomerState(externalCustomerId: string): Promise { + const encodedExternalId = encodeURIComponent(externalCustomerId) + const response = await polarFetch(`/v1/customers/external/${encodedExternalId}/state`, { + method: "GET", + }) + + if (response.status === 404) { + return null + } + + const text = await response.text() + if (!response.ok) { + throw new Error(`Polar customer state lookup failed (${response.status}): ${text.slice(0, 400)}`) + } + + return text ? (JSON.parse(text) as PolarCustomerState) : null +} + +function hasRequiredBenefit(state: PolarCustomerState | null) { + if (!state?.granted_benefits || !env.polar.benefitId) { + return false + } + + return state.granted_benefits.some((grant) => grant.benefit_id === env.polar.benefitId) +} + +async function createCheckoutSession(input: CloudAccessInput): Promise { + const payload = { + products: [env.polar.productId], + success_url: env.polar.successUrl, + return_url: env.polar.returnUrl, + external_customer_id: input.userId, + customer_email: input.email, + customer_name: input.name, + } + + const response = await polarFetch("/v1/checkouts/", { + method: "POST", + body: JSON.stringify(payload), + }) + const text = await response.text() + + if (!response.ok) { + throw new Error(`Polar checkout creation failed (${response.status}): ${text.slice(0, 400)}`) + } + + const checkout = text ? (JSON.parse(text) as PolarCheckoutSession) : null + if (!checkout?.url) { + throw new Error("Polar checkout response missing URL") + } + + return checkout.url +} + +export async function requireCloudWorkerAccess(input: CloudAccessInput): Promise { + if (!env.polar.featureGateEnabled) { + return { allowed: true } + } + + assertPaywallConfig() + + const state = await getCustomerState(input.userId) + if (hasRequiredBenefit(state)) { + return { allowed: true } + } + + const checkoutUrl = await createCheckoutSession(input) + return { + allowed: false, + checkoutUrl, + } +} diff --git a/services/den-control-plane/src/env.ts b/services/den-control-plane/src/env.ts index bebd1825..128fa9f3 100644 --- a/services/den-control-plane/src/env.ts +++ b/services/den-control-plane/src/env.ts @@ -21,6 +21,13 @@ const schema = z.object({ RENDER_PROVISION_TIMEOUT_MS: z.string().optional(), RENDER_HEALTHCHECK_TIMEOUT_MS: z.string().optional(), RENDER_POLL_INTERVAL_MS: z.string().optional(), + POLAR_FEATURE_GATE_ENABLED: z.string().optional(), + POLAR_API_BASE: z.string().optional(), + POLAR_ACCESS_TOKEN: z.string().optional(), + POLAR_PRODUCT_ID: z.string().optional(), + POLAR_BENEFIT_ID: z.string().optional(), + POLAR_SUCCESS_URL: z.string().optional(), + POLAR_RETURN_URL: z.string().optional(), }) const parsed = schema.parse(process.env) @@ -29,6 +36,8 @@ const corsOrigins = parsed.CORS_ORIGINS?.split(",") .map((origin) => origin.trim()) .filter(Boolean) +const polarFeatureGateEnabled = (parsed.POLAR_FEATURE_GATE_ENABLED ?? "false").toLowerCase() === "true" + export const env = { databaseUrl: parsed.DATABASE_URL, betterAuthSecret: parsed.BETTER_AUTH_SECRET, @@ -52,4 +61,13 @@ export const env = { healthcheckTimeoutMs: Number(parsed.RENDER_HEALTHCHECK_TIMEOUT_MS ?? "180000"), pollIntervalMs: Number(parsed.RENDER_POLL_INTERVAL_MS ?? "5000"), }, + polar: { + featureGateEnabled: polarFeatureGateEnabled, + apiBase: parsed.POLAR_API_BASE ?? "https://api.polar.sh", + accessToken: parsed.POLAR_ACCESS_TOKEN, + productId: parsed.POLAR_PRODUCT_ID, + benefitId: parsed.POLAR_BENEFIT_ID, + successUrl: parsed.POLAR_SUCCESS_URL, + returnUrl: parsed.POLAR_RETURN_URL, + }, } diff --git a/services/den-control-plane/src/http/workers.ts b/services/den-control-plane/src/http/workers.ts index 94ecf8eb..de92bcdf 100644 --- a/services/den-control-plane/src/http/workers.ts +++ b/services/den-control-plane/src/http/workers.ts @@ -4,8 +4,10 @@ import { fromNodeHeaders } from "better-auth/node" import { eq } from "drizzle-orm" import { z } from "zod" import { auth } from "../auth.js" +import { requireCloudWorkerAccess } from "../billing/polar.js" import { db } from "../db/index.js" import { OrgMembershipTable, WorkerInstanceTable, WorkerTable, WorkerTokenTable } from "../db/schema.js" +import { env } from "../env.js" import { ensureDefaultOrg } from "../orgs.js" import { provisionWorker } from "../workers/provisioner.js" @@ -60,6 +62,27 @@ workersRouter.post("/", async (req, res) => { return } + if (parsed.data.destination === "cloud") { + const access = await requireCloudWorkerAccess({ + userId: session.user.id, + email: session.user.email ?? `${session.user.id}@placeholder.local`, + name: session.user.name ?? session.user.email ?? "OpenWork User", + }) + + if (!access.allowed) { + res.status(402).json({ + error: "payment_required", + message: "Cloud workers require an active Den Cloud plan.", + polar: { + checkoutUrl: access.checkoutUrl, + productId: env.polar.productId, + benefitId: env.polar.benefitId, + }, + }) + return + } + } + const orgId = (await getOrgId(session.user.id)) ?? (await ensureDefaultOrg(session.user.id, session.user.name ?? session.user.email ?? "Personal")) const workerId = randomUUID()