diff --git a/packages/auth/package.json b/packages/auth/package.json index 2e802ab..5e43495 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/auth", - "version": "0.6.0", + "version": "0.7.0", "description": "UI-free Supabase Auth and entitlements wrapper for Pickforge apps.", "license": "MIT", "repository": { diff --git a/packages/billing/package.json b/packages/billing/package.json index 71c4f6e..7151142 100644 --- a/packages/billing/package.json +++ b/packages/billing/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/billing", - "version": "0.6.0", + "version": "0.7.0", "description": "UI-free Stripe billing and credit-ledger helpers for Pickforge apps.", "license": "MIT", "repository": { diff --git a/packages/brand/package.json b/packages/brand/package.json index d38ebd1..8f2f202 100644 --- a/packages/brand/package.json +++ b/packages/brand/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/brand", - "version": "0.6.0", + "version": "0.7.0", "description": "Pickforge CSS tokens, fonts, reset, and primitives.", "license": "MIT", "repository": { diff --git a/packages/edge-shared/README.md b/packages/edge-shared/README.md index e394a60..bc617a6 100644 --- a/packages/edge-shared/README.md +++ b/packages/edge-shared/README.md @@ -2,6 +2,12 @@ Deno-compatible helpers for Pickforge Edge Functions. Clients are injected structurally, so app functions can use Supabase service clients without coupling this package to a specific SDK import. +`assertRouteRequest` accepts only the hosted-router wire shape: `commandText` plus optional `context.projectName`, `context.chatNames`, and `context.widgetLabels`. Command text is user-authored and passed through unchanged; attached context is guarded against paths, host identifiers, tailnet IPs, and long serial-like tokens with `boundary_violation`. + +`createOperatorRouterHandler` composes bearer-token authentication, request boundary validation, idempotent credit debiting, and an injected chat completion. A replayed idempotency key returns its stored proposal without another model call. + +Hosted routing is limited to 10 uncached attempts per user in each 10-second window. Its responses are `200` with a proposal, `402` for insufficient credits, `429` for `rate_limited`, boundary/auth `4xx`, and `5xx` for server or provider failures. + ## Usage ```ts @@ -13,7 +19,7 @@ import { jsonResponse, newIdempotencyKey, requireEntitlement, -} from "npm:@pickforge/edge-shared@0.4.0"; +} from "npm:@pickforge/edge-shared@0.7.0"; Deno.serve(async (req) => { const supabase = createClient( @@ -57,6 +63,14 @@ Deno.serve(async (req) => { - Use `SUPABASE_SERVICE_ROLE_KEY` only in server-side Edge Functions. - Deploy app Edge Functions with access to `SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY`. +## Hosted function environment + +`operator-router` uses `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, and `OPENAI_API_KEY`. Optional settings are `OPENAI_ROUTER_MODEL` (default `gpt-5.4-mini`), `OPENAI_BASE_URL` (default `https://api.openai.com/v1`), and `ROUTER_CREDIT_COST_CENTS` (default `2`). It sets `verify_jwt = false` because `getUserFromRequest` verifies the caller bearer token in the handler; the service-role client is used only for the debit RPC. + +`create-credit-checkout` uses `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `STRIPE_SECRET_KEY`, `STRIPE_PRICE_PACK_10`, `STRIPE_PRICE_PACK_25`, `STRIPE_PRICE_PACK_50`, `CHECKOUT_SUCCESS_URL`, and `CHECKOUT_CANCEL_URL`. Its accepted body is `{ "pack": "p10" | "p25" | "p50" }`. + +The new edge-shared exports require a `0.7.0` package publish before deploying functions that import them. + ## Cross-repo imports Supabase `_shared/` relative imports only work inside one functions tree. Pickforge app-specific Edge Functions live in other repos, so shared Edge utilities ship as `@pickforge/edge-shared` and are imported with `npm:` specifiers from each repo. diff --git a/packages/edge-shared/package.json b/packages/edge-shared/package.json index a671ff2..ea5df3b 100644 --- a/packages/edge-shared/package.json +++ b/packages/edge-shared/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/edge-shared", - "version": "0.6.0", + "version": "0.7.0", "description": "Deno-compatible shared helpers for Pickforge Edge Functions.", "license": "MIT", "repository": { diff --git a/packages/edge-shared/src/index.ts b/packages/edge-shared/src/index.ts index 36ad41d..3fb7814 100644 --- a/packages/edge-shared/src/index.ts +++ b/packages/edge-shared/src/index.ts @@ -5,6 +5,7 @@ export type EdgeSharedErrorCode = | "invalid_debit_amount" | "invalid_idempotency_key" | "invalid_rpc_result" + | "boundary_violation" | "invalid_string" | "invalid_stripe_webhook_config" | "unauthorized"; @@ -87,6 +88,7 @@ export interface DebitCreditsOptions { amountCents: number; reason: string; idempotencyKey: string; + metadata?: { [key: string]: EdgeSharedJson }; } export type DebitCreditsResult = @@ -133,6 +135,52 @@ export interface CreateStripeWebhookHandlerOptions): Promise; } +export interface RouteRequestContext { + projectName?: string; + chatNames?: string[]; + widgetLabels?: string[]; +} + +export interface RouteRequest { + commandText: string; + context?: RouteRequestContext; +} + +export interface ChatCompletionUsage { + input: number; + output: number; +} + +export interface ChatCompletionResult { + text: string; + usage: ChatCompletionUsage; +} + +export interface CreateOperatorRouterHandlerOptions { + supabase: Pick; + findCompletedRoute(userId: string, idempotencyKey: string): Promise; + getCreditBalance(userId: string): Promise; + consumeRateLimit(userId: string): Promise; + debit(options: Omit): Promise; + chatComplete(options: { + model: string; + apiKey: string; + baseUrl: string; + systemPrompt: string; + userPrompt: string; + }): Promise; + model: string; + apiKey: string; + baseUrl: string; + creditCostCents: number; + systemPrompt?: string; +} + +export interface StoredRouteProposal { + proposalJson: string; + usage: ChatCompletionUsage; +} + interface EntitlementRow { value?: EdgeSharedJson; expires_at: string | null; @@ -212,6 +260,7 @@ export async function debitCredits({ amountCents, reason, idempotencyKey, + metadata, }: DebitCreditsOptions): Promise { const validUserId = validateNonEmptyString(userId, "userId", "invalid_string"); const validAmount = validatePositiveInteger(amountCents, "amountCents"); @@ -221,12 +270,14 @@ export async function debitCredits({ "idempotencyKey", "invalid_idempotency_key", ); + const validMetadata = metadata === undefined ? {} : validateMetadata(metadata); const { data, error } = await supabase.rpc("debit_credits", { target_user: validUserId, debit_cents: validAmount, reason: validReason, idem_key: validIdempotencyKey, + usage_metadata: validMetadata, }); if (error !== null) { throw databaseError("Failed to debit credits", error); @@ -260,15 +311,158 @@ export function newIdempotencyKey(scope: string, uniquePart: string): string { )}`; } -export function jsonResponse(status: number, body: unknown): Response { +export const operatorRouterSystemPrompt = [ + "Return one JSON object for one PickForge developer command.", + "Use only these v2 action payloads:", + "- openProject: {\"action\":\"openProject\"}; put the natural project name in projectRef.", + "- openChat: {\"action\":\"openChat\",\"chat\":string|null}; optional projectRef may disambiguate.", + "- createChat: {\"action\":\"createChat\",\"provider\":\"claude\"|\"codex\",\"model\":string|null}.", + "- sendPrompt: {\"action\":\"sendPrompt\",\"prompt\":string,\"chat\":string|null}; never invent project paths.", + "- startSwarm: {\"action\":\"startSwarm\",\"mode\":\"scout\"|\"review\",\"count\":1-5,\"goal\":string,\"provider\":\"claude\"|\"codex\"|\"mixed\"}.", + "- swarmStatus: {\"action\":\"swarmStatus\"}.", + "- interruptRun: {\"action\":\"interruptRun\",\"run\":string|null}.", + "- steerRun: {\"action\":\"steerRun\",\"run\":string|null,\"instruction\":string}.", + "- launchEmulator: {\"action\":\"launchEmulator\",\"device\":string|null}.", + "- launchRun: {\"action\":\"launchRun\",\"target\":string|null}.", + "- reloadRun: {\"action\":\"reloadRun\"}.", + "- stopRun: {\"action\":\"stopRun\"}.", + "- hotRestart: {\"action\":\"hotRestart\"}.", + "- enterSelectMode: {\"action\":\"enterSelectMode\"}.", + "- takeScreenshot: {\"action\":\"takeScreenshot\"}.", + "- selectWidget: {\"action\":\"selectWidget\",\"description\":string}.", + "The router only proposes; local code handles ids, provenance, approval, cost, audit, and execution.", + "Confidence is 0..1 and advisory. Use projectRef only as an opaque natural-language hint.", + "If the command cannot map safely to one action, return {\"unclear\":true,\"reason\":\"short reason\"}.", + "Output only JSON, no markdown.", +].join("\n"); + +export function assertRouteRequest(body: unknown): RouteRequest { + if (!isRecord(body) || !hasOnlyKeys(body, ["commandText", "context"])) { + throw boundaryViolation(); + } + + const commandText = readCommandText(body.commandText); + const context = body.context === undefined ? undefined : readRouteContext(body.context); + + return context === undefined ? { commandText } : { commandText, context }; +} + +export function createOperatorRouterHandler({ + supabase, + findCompletedRoute, + getCreditBalance, + consumeRateLimit, + debit, + chatComplete, + model, + apiKey, + baseUrl, + creditCostCents, + systemPrompt = operatorRouterSystemPrompt, +}: CreateOperatorRouterHandlerOptions): (req: Request) => Promise { + const validModel = validateNonEmptyString(model, "model", "invalid_string"); + const validApiKey = validateNonEmptyString(apiKey, "apiKey", "invalid_string"); + const validBaseUrl = validateNonEmptyString(baseUrl, "baseUrl", "invalid_string"); + const validCreditCostCents = validatePositiveInteger(creditCostCents, "creditCostCents"); + + return async (req: Request): Promise => { + try { + const { userId } = await getUserFromRequest({ supabase, req }); + const routeRequest = await readRouteRequest(req); + const idempotencyKey = validateNonEmptyString( + req.headers.get("x-idempotency-key"), + "x-idempotency-key", + "invalid_idempotency_key", + ); + const scopedIdempotencyKey = newIdempotencyKey("router", `${userId}:${idempotencyKey}`); + const completedRoute = await findCompletedRoute(userId, scopedIdempotencyKey); + if (completedRoute !== null) { + return routeResponse(completedRoute, validCreditCostCents); + } + if (!(await consumeRateLimit(userId))) { + return jsonResponse(429, { error: "rate_limited" }); + } + const balance = await getCreditBalance(userId); + if (!Number.isSafeInteger(balance)) { + throw new EdgeSharedError("invalid_rpc_result", "Supabase returned an invalid credit balance"); + } + if (balance < validCreditCostCents) { + throw new EdgeSharedError("insufficient_credits", "Not enough credits", { balance }); + } + + const completion = await chatComplete({ + model: validModel, + apiKey: validApiKey, + baseUrl: validBaseUrl, + systemPrompt, + userPrompt: JSON.stringify(routeRequest), + }); + const route = { + proposalJson: completion.text, + usage: { input: completion.usage.input, output: completion.usage.output }, + }; + let debitResult: DebitCreditsResult; + try { + debitResult = await debit({ + userId, + amountCents: validCreditCostCents, + reason: "Operator routing", + idempotencyKey: scopedIdempotencyKey, + metadata: route, + }); + } catch (error) { + const storedRoute = await findCompletedRoute(userId, scopedIdempotencyKey); + if (storedRoute !== null) { + return routeResponse(storedRoute, validCreditCostCents); + } + + throw error; + } + if (debitResult.duplicate) { + const storedRoute = await findCompletedRoute(userId, scopedIdempotencyKey); + if (storedRoute === null) { + throw new EdgeSharedError("invalid_rpc_result", "Stored router result is missing"); + } + + return routeResponse(storedRoute, validCreditCostCents); + } + + return routeResponse(route, validCreditCostCents); + } catch (error) { + if (error instanceof EdgeSharedError) { + return jsonResponse(routerErrorStatus(error.code), { + error: error.code, + ...(error.balance === undefined ? {} : { balance: error.balance }), + }); + } + + return jsonResponse(500, { error: "internal_error" }); + } + }; +} + +export function jsonResponse(status: number, body: unknown, headers: HeadersInit = {}): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", + ...headers, }, }); } +export function corsHeaders(): HeadersInit { + return { + "access-control-allow-origin": "*", + "access-control-allow-methods": "POST, OPTIONS", + "access-control-allow-headers": "apikey, x-client-info, authorization, content-type, x-idempotency-key", + }; +} + +export function corsPreflightResponse(): Response { + return new Response(null, { status: 204, headers: corsHeaders() }); +} + export function createStripeWebhookHandler({ stripe, supabase, @@ -320,6 +514,121 @@ function validateNonEmptyString( return value; } +function readRouteContext(value: unknown): RouteRequestContext { + if (!isRecord(value) || !hasOnlyKeys(value, ["projectName", "chatNames", "widgetLabels"])) { + throw boundaryViolation(); + } + + const projectName = value.projectName === undefined ? undefined : readRouteString(value.projectName, 200); + const chatNames = value.chatNames === undefined ? undefined : readRouteStringList(value.chatNames); + const widgetLabels = value.widgetLabels === undefined ? undefined : readRouteStringList(value.widgetLabels); + + return { + ...(projectName === undefined ? {} : { projectName }), + ...(chatNames === undefined ? {} : { chatNames }), + ...(widgetLabels === undefined ? {} : { widgetLabels }), + }; +} + +function readRouteStringList(value: unknown): string[] { + if (!Array.isArray(value) || value.length > 100) { + throw boundaryViolation(); + } + + return value.map((item) => readRouteString(item, 200)); +} + +function readRouteString(value: unknown, maxLength = 4000): string { + if ( + typeof value !== "string" || + value.trim().length === 0 || + value.length > maxLength || + containsForbiddenIdentifier(value) + ) { + throw boundaryViolation(); + } + + return value; +} + +function readCommandText(value: unknown): string { + // operator.md permits user-authored command text; only attached local context is a hard boundary. + if (typeof value !== "string" || value.length > 4000) { + throw boundaryViolation(); + } + + return value; +} + +function hasOnlyKeys(value: Record, allowed: string[]): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function containsForbiddenIdentifier(value: string): boolean { + return ( + /\b[a-z][a-z0-9+.-]*:\/\//i.test(value) || + /(?:^|[\s"'])~\//.test(value) || + /(?:^|[\s"'])\.\//.test(value) || + /(?:^|[^\w/])\/[a-z0-9_.-]+/i.test(value) || + /\.\.\//.test(value) || + /\b(?:home|etc|usr|var|tmp|opt|root|mnt|workspace|projects)\/[a-z0-9_.-]+/i.test(value) || + /\b(?!and\/or\b)[a-z0-9_.-]+\/[a-z0-9_.-]+\b/i.test(value) || + /(?:^|\/)\.[^/\s]+/.test(value) || + /(?:^|[^a-z0-9_])\.(?:env|git|ssh|aws|npmrc|netrc)\b/i.test(value) || + /[a-z]:[\\/]/i.test(value) || + /\\\\/.test(value) || + /\b[a-z0-9_.-]+\\[a-z0-9_.-]+\\/i.test(value) || + /\b[a-z0-9-]+(?::\d{1,5})\b/i.test(value) || + /\blocalhost\b/i.test(value) || + /\[[0-9a-f:.]+\]/i.test(value) || + /\b[a-z0-9-]+(?:\.[a-z0-9-]+)+\b/i.test(value) || + /\b100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}\b/.test(value) || + /\b[a-z0-9][a-z0-9_-]{31,}\b/i.test(value) + ); +} + +function validateMetadata(value: unknown): { [key: string]: EdgeSharedJson } { + if (!isRecord(value)) { + throw new EdgeSharedError("invalid_string", "metadata must be an object"); + } + + return value as { [key: string]: EdgeSharedJson }; +} + +function routeResponse(route: StoredRouteProposal | ChatCompletionResult, costCents: number): Response { + const proposalJson = "proposalJson" in route ? route.proposalJson : route.text; + + return jsonResponse(200, { proposalJson, usage: route.usage, costCents }); +} + +async function readRouteRequest(req: Request): Promise { + try { + return assertRouteRequest(await req.json()); + } catch (error) { + if (error instanceof EdgeSharedError) { + throw error; + } + + throw boundaryViolation(); + } +} + +function boundaryViolation(): EdgeSharedError { + return new EdgeSharedError("boundary_violation", "Request contains data outside the routing boundary"); +} + +function routerErrorStatus(code: EdgeSharedErrorCode): number { + if (code === "unauthorized") { + return 401; + } + + if (code === "insufficient_credits") { + return 402; + } + + return code === "database_error" ? 500 : 400; +} + function validatePositiveInteger(value: unknown, field: string): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) { throw new EdgeSharedError("invalid_debit_amount", `${field} must be a positive integer`); diff --git a/packages/edge-shared/test/edge-shared.test.ts b/packages/edge-shared/test/edge-shared.test.ts index e4d2260..194c7ab 100644 --- a/packages/edge-shared/test/edge-shared.test.ts +++ b/packages/edge-shared/test/edge-shared.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import { EdgeSharedError, + assertRouteRequest, + corsPreflightResponse, + createOperatorRouterHandler, createStripeWebhookHandler, debitCredits, getBearerToken, @@ -110,6 +113,7 @@ describe("@pickforge/edge-shared", () => { debit_cents: 150, reason: "render", idem_key: "render:job_123", + usage_metadata: {}, }, }, ]); @@ -223,6 +227,16 @@ describe("@pickforge/edge-shared", () => { await expect(response.json()).resolves.toEqual({ ok: true }); }); + it("creates CORS preflight responses", () => { + const response = corsPreflightResponse(); + + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + expect(response.headers.get("access-control-allow-headers")).toBe( + "apikey, x-client-info, authorization, content-type, x-idempotency-key", + ); + }); + it("handles verified Stripe webhook requests", async () => { const stripe = {}; const supabase = {}; @@ -310,6 +324,331 @@ describe("@pickforge/edge-shared", () => { expect(response.status).toBe(500); await expect(response.json()).resolves.toEqual({ error: "webhook_processing_failed" }); }); + + it.each([ + "open /home/dev/project", + "open \"/home/dev/x\"", + "open C:\\Users\\dev\\project", + "open \\\\server\\share", + "open foo\\bar\\baz", + "open https://db.internal.corp/path", + "connect localhost:3000", + "connect buildserver:8080", + "connect db.internal.corp", + "open build.local", + "connect 100.64.1.2", + "use abcdefghijklmnopqrstuvwxyz0123456789", + "connect ssh://buildserver", + "connect localhost", + "connect [fd00::1]", + ])("rejects forbidden attached context identifiers: %s", (projectName) => { + expectBoundaryViolation(() => assertRouteRequest({ commandText: "open Billing", context: { projectName } })); + }); + + it("allows user-authored command identifiers", () => { + expect( + assertRouteRequest({ commandText: "create codex chat using gpt-5.4-mini about api.example.com from /home/dev" }), + ).toEqual({ commandText: "create codex chat using gpt-5.4-mini about api.example.com from /home/dev" }); + }); + + it("strictly parses the route request allowlist", () => { + expect( + assertRouteRequest({ + commandText: "open project Billing", + context: { projectName: "Billing", chatNames: ["CI"], widgetLabels: ["Run"] }, + }), + ).toEqual({ + commandText: "open project Billing", + context: { projectName: "Billing", chatNames: ["CI"], widgetLabels: ["Run"] }, + }); + expectBoundaryViolation(() => assertRouteRequest({ commandText: "open Billing", source: "secret" })); + expectBoundaryViolation(() => assertRouteRequest({ commandText: "open Billing", context: { path: "Billing" } })); + expectBoundaryViolation(() => + assertRouteRequest({ commandText: "open Billing", context: { chatNames: ["buildserver:8080"] } }), + ); + expect(assertRouteRequest({ commandText: "start a review swarm for the auth diff" })).toEqual({ + commandText: "start a review swarm for the auth diff", + }); + }); + + it("routes a valid command then debits with a namespaced key", async () => { + const supabase = new MemorySupabase(); + const debit = vi.fn(async () => ({ duplicate: false as const, balance: 98 })); + const chatComplete = vi.fn(async () => ({ + text: "{\"action\":{\"action\":\"openProject\"},\"confidence\":0.9}", + usage: { input: 42, output: 13 }, + })); + const handler = createOperatorRouterHandler({ + supabase, + findCompletedRoute: vi.fn(async () => null), + consumeRateLimit: vi.fn(async () => true), + getCreditBalance: vi.fn(async () => 100), + debit, + chatComplete, + model: "gpt-5.4-mini", + apiKey: "key", + baseUrl: "https://api.openai.com/v1", + creditCostCents: 2, + }); + + const response = await handler( + new Request("https://edge.test", { + method: "POST", + headers: { Authorization: "Bearer token", "x-idempotency-key": "router:attempt-1" }, + body: JSON.stringify({ commandText: "open project Billing", context: { projectName: "Billing" } }), + }), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + proposalJson: "{\"action\":{\"action\":\"openProject\"},\"confidence\":0.9}", + usage: { input: 42, output: 13 }, + costCents: 2, + }); + expect(debit).toHaveBeenCalledWith({ + userId: USER_ID, + amountCents: 2, + reason: "Operator routing", + idempotencyKey: `router:${USER_ID}:router:attempt-1`, + metadata: { + proposalJson: "{\"action\":{\"action\":\"openProject\"},\"confidence\":0.9}", + usage: { input: 42, output: 13 }, + }, + }); + expect(chatComplete).toHaveBeenCalledOnce(); + }); + + it("returns insufficient credits without calling the model", async () => { + const supabase = new MemorySupabase(); + const chatComplete = vi.fn(); + const handler = createOperatorRouterHandler({ + supabase, + findCompletedRoute: vi.fn(async () => null), + consumeRateLimit: vi.fn(async () => true), + getCreditBalance: vi.fn(async () => 1), + debit: vi.fn(), + chatComplete, + model: "gpt-5.4-mini", + apiKey: "key", + baseUrl: "https://api.openai.com/v1", + creditCostCents: 2, + }); + + const response = await handler(routeRequest("open project Billing")); + expect(response.status).toBe(402); + await expect(response.json()).resolves.toEqual({ error: "insufficient_credits", balance: 1 }); + expect(chatComplete).not.toHaveBeenCalled(); + }); + + it("returns a stored proposal without calling the model for a replay", async () => { + const supabase = new MemorySupabase(); + const debit = vi.fn(async () => ({ duplicate: false as const, balance: 98 })); + const chatComplete = vi.fn(async () => ({ text: "{}", usage: { input: 1, output: 1 } })); + const handler = createOperatorRouterHandler({ + supabase, + findCompletedRoute: vi.fn(async () => ({ proposalJson: "{\"stored\":true}", usage: { input: 3, output: 2 } })), + consumeRateLimit: vi.fn(async () => true), + getCreditBalance: vi.fn(async () => 100), + debit, + chatComplete, + model: "gpt-5.4-mini", + apiKey: "key", + baseUrl: "https://api.openai.com/v1", + creditCostCents: 2, + }); + + const response = await handler(routeRequest("open project Billing")); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + proposalJson: "{\"stored\":true}", + usage: { input: 3, output: 2 }, + costCents: 2, + }); + expect(chatComplete).not.toHaveBeenCalled(); + expect(debit).not.toHaveBeenCalled(); + }); + + it("rejects forbidden attached context before debiting or calling the model", async () => { + const supabase = new MemorySupabase(); + const debit = vi.fn(); + const chatComplete = vi.fn(); + const handler = createOperatorRouterHandler({ + supabase, + findCompletedRoute: vi.fn(async () => null), + consumeRateLimit: vi.fn(async () => true), + getCreditBalance: vi.fn(async () => 100), + debit, + chatComplete, + model: "gpt-5.4-mini", + apiKey: "key", + baseUrl: "https://api.openai.com/v1", + creditCostCents: 2, + }); + + const response = await handler( + routeRequest("open project Billing", { projectName: "/home/dev/project" }), + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "boundary_violation" }); + expect(debit).not.toHaveBeenCalled(); + expect(chatComplete).not.toHaveBeenCalled(); + }); + + it("does not debit a failed route and allows a retry", async () => { + const supabase = new MemorySupabase(); + const debit = vi.fn(async () => ({ duplicate: false as const, balance: 98 })); + const chatComplete = vi + .fn() + .mockRejectedValueOnce(new Error("provider unavailable")) + .mockResolvedValueOnce({ text: "{}", usage: { input: 1, output: 1 } }); + const handler = createOperatorRouterHandler({ + supabase, + findCompletedRoute: vi.fn(async () => null), + consumeRateLimit: vi.fn(async () => true), + getCreditBalance: vi.fn(async () => 100), + debit, + chatComplete, + model: "gpt-5.4-mini", + apiKey: "key", + baseUrl: "https://api.openai.com/v1", + creditCostCents: 2, + }); + + await expect(handler(routeRequest("open project Billing"))).resolves.toMatchObject({ status: 500 }); + expect(debit).not.toHaveBeenCalled(); + await expect(handler(routeRequest("open project Billing"))).resolves.toMatchObject({ status: 200 }); + expect(debit).toHaveBeenCalledOnce(); + }); + + it("returns the stored result after a concurrent duplicate debit", async () => { + const stored = { proposalJson: "{\"stored\":true}", usage: { input: 5, output: 3 } }; + const findCompletedRoute = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(stored); + const handler = createOperatorRouterHandler({ + supabase: new MemorySupabase(), + findCompletedRoute, + consumeRateLimit: vi.fn(async () => true), + getCreditBalance: vi.fn(async () => 100), + debit: vi.fn(async () => ({ duplicate: true as const })), + chatComplete: vi.fn(async () => ({ text: "{\"fresh\":true}", usage: { input: 1, output: 1 } })), + model: "gpt-5.4-mini", + apiKey: "key", + baseUrl: "https://api.openai.com/v1", + creditCostCents: 2, + }); + + const response = await handler(routeRequest("open project Billing")); + await expect(response.json()).resolves.toEqual({ ...stored, costCents: 2 }); + expect(findCompletedRoute).toHaveBeenCalledTimes(2); + }); + + it("returns the stored proposal when a committed debit loses its response", async () => { + const stored = { proposalJson: "{\"stored\":true}", usage: { input: 5, output: 3 } }; + const handler = createOperatorRouterHandler({ + supabase: new MemorySupabase(), + findCompletedRoute: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(stored), + consumeRateLimit: vi.fn(async () => true), + getCreditBalance: vi.fn(async () => 100), + debit: vi.fn(async () => { + throw new Error("response lost"); + }), + chatComplete: vi.fn(async () => ({ text: "{\"fresh\":true}", usage: { input: 1, output: 1 } })), + model: "gpt-5.4-mini", + apiKey: "key", + baseUrl: "https://api.openai.com/v1", + creditCostCents: 2, + }); + + const response = await handler(routeRequest("open project Billing")); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ ...stored, costCents: 2 }); + }); + + it.each([ + { commandText: "x".repeat(4001) }, + { commandText: "open Billing", context: { projectName: "x".repeat(201) } }, + { commandText: "open Billing", context: { chatNames: Array.from({ length: 101 }, () => "CI") } }, + { commandText: "open Billing", context: { widgetLabels: ["/etc"] } }, + { commandText: "open Billing", context: { chatNames: ["../secrets"] } }, + { commandText: "open Billing", context: { projectName: "./.env" } }, + { commandText: "open Billing", context: { projectName: "~/.env" } }, + { commandText: "open Billing", context: { chatNames: ["../.ssh/id_rsa"] } }, + { commandText: "open Billing", context: { widgetLabels: ["/home/u/.aws/credentials"] } }, + { commandText: "open Billing", context: { projectName: "projects/app" } }, + { commandText: "open Billing", context: { projectName: "foo/bar" } }, + { commandText: "open Billing", context: { projectName: "Edit .env" } }, + { commandText: "open Billing", context: { projectName: "deploy .git" } }, + ])("rejects oversized or forbidden attached context data", (body) => { + expectBoundaryViolation(() => assertRouteRequest(body)); + }); + + it("allows ordinary attached context", () => { + expect(assertRouteRequest({ commandText: "start a review and/or a scout", context: { projectName: "Billing" } })).toEqual({ + commandText: "start a review and/or a scout", + context: { projectName: "Billing" }, + }); + }); + + it("allows a normal attached label with punctuation", () => { + expect(assertRouteRequest({ commandText: "open Billing", context: { projectName: "Sprint 3. Final" } })).toEqual({ + commandText: "open Billing", + context: { projectName: "Sprint 3. Final" }, + }); + }); + + it("returns rate_limited after ten routed attempts", async () => { + const debit = vi.fn(async () => ({ duplicate: false as const, balance: 98 })); + const chatComplete = vi.fn(async () => ({ text: "{}", usage: { input: 1, output: 1 } })); + let attempts = 0; + const handler = createOperatorRouterHandler({ + supabase: new MemorySupabase(), + findCompletedRoute: vi.fn(async () => null), + consumeRateLimit: vi.fn(async () => ++attempts <= 10), + getCreditBalance: vi.fn(async () => 100), + debit, + chatComplete, + model: "gpt-5.4-mini", + apiKey: "key", + baseUrl: "https://api.openai.com/v1", + creditCostCents: 2, + }); + + for (let attempt = 0; attempt < 10; attempt += 1) { + await expect(handler(routeRequest("open project Billing", undefined, `attempt-${attempt}`))).resolves.toMatchObject({ + status: 200, + }); + } + + const response = await handler(routeRequest("open project Billing", undefined, "attempt-10")); + expect(response.status).toBe(429); + await expect(response.json()).resolves.toEqual({ error: "rate_limited" }); + expect(debit).toHaveBeenCalledTimes(10); + expect(chatComplete).toHaveBeenCalledTimes(10); + }); + + it("maps invalid JSON to a boundary violation", async () => { + const handler = createOperatorRouterHandler({ + supabase: new MemorySupabase(), + findCompletedRoute: vi.fn(async () => null), + consumeRateLimit: vi.fn(async () => true), + getCreditBalance: vi.fn(async () => 100), + debit: vi.fn(), + chatComplete: vi.fn(), + model: "gpt-5.4-mini", + apiKey: "key", + baseUrl: "https://api.openai.com/v1", + creditCostCents: 2, + }); + + const response = await handler( + new Request("https://edge.test", { + method: "POST", + headers: { Authorization: "Bearer token", "x-idempotency-key": "attempt-1" }, + body: "{", + }), + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "boundary_violation" }); + }); }); interface EntitlementRow { @@ -424,3 +763,26 @@ function scopedDebitSupabase(initialBalances: Record): Pick unknown): void { + try { + action(); + } catch (error) { + expect(error).toMatchObject({ code: "boundary_violation" }); + return; + } + + throw new Error("Expected a boundary violation"); +} diff --git a/packages/edge-shared/test/migration.test.ts b/packages/edge-shared/test/migration.test.ts index 04106e2..c27d9d7 100644 --- a/packages/edge-shared/test/migration.test.ts +++ b/packages/edge-shared/test/migration.test.ts @@ -8,6 +8,14 @@ const migration = readFileSync( ); const supabaseConfig = readFileSync(join(import.meta.dirname, "../../../supabase/config.toml"), "utf8"); +const metadataMigration = readFileSync( + join(import.meta.dirname, "../../../supabase/migrations/20260709050000_debit_credits_metadata.sql"), + "utf8", +); +const routerRateMigration = readFileSync( + join(import.meta.dirname, "../../../supabase/migrations/20260709060000_router_rate_limit.sql"), + "utf8", +); describe("debit credits migration", () => { it("adds a user-scoped ledger idempotency key", () => { @@ -44,6 +52,26 @@ describe("debit credits migration", () => { }); }); +describe("debit credits metadata migration", () => { + it("stores usage metadata and restricts the new RPC signature", () => { + expect(metadataMigration).toContain("usage_metadata jsonb default '{}'::jsonb"); + expect(metadataMigration).toContain("metadata"); + expect(metadataMigration).toContain("usage_metadata"); + expect(metadataMigration).toContain( + "revoke execute on function public.debit_credits(uuid, integer, text, text, jsonb)", + ); + }); +}); + +describe("router rate limit migration", () => { + it("limits each user to ten requests per ten seconds", () => { + expect(routerRateMigration).toContain("create table if not exists public.router_rate_limits"); + expect(routerRateMigration).toContain("create function public.consume_router_rate_limit(target_user uuid)"); + expect(routerRateMigration).toContain("current_count >= 10"); + expect(routerRateMigration).toContain("interval '10 seconds'"); + }); +}); + describe("stripe webhook function config", () => { it("disables Supabase JWT verification for Stripe calls", () => { expect(supabaseConfig).toContain("[functions.stripe-webhook]"); diff --git a/packages/flags/package.json b/packages/flags/package.json index 60ca48e..139775b 100644 --- a/packages/flags/package.json +++ b/packages/flags/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/flags", - "version": "0.6.0", + "version": "0.7.0", "description": "UI-free feature-flag registry for release gating in Pickforge apps.", "license": "MIT", "repository": { diff --git a/packages/sync/package.json b/packages/sync/package.json index dcaa8e4..30cc18f 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/sync", - "version": "0.6.0", + "version": "0.7.0", "description": "UI-free settings sync helpers for Pickforge apps.", "license": "MIT", "repository": { diff --git a/packages/tauri-release/package.json b/packages/tauri-release/package.json index ea355bf..4965975 100644 --- a/packages/tauri-release/package.json +++ b/packages/tauri-release/package.json @@ -1,6 +1,6 @@ { "name": "@pickforge/tauri-release", - "version": "0.6.0", + "version": "0.7.0", "description": "Signed Tauri release and updater-feed automation for Pickforge apps.", "license": "MIT", "repository": { diff --git a/supabase/config.toml b/supabase/config.toml index a4729c9..fe948c7 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1,2 +1,8 @@ [functions.stripe-webhook] verify_jwt = false + +[functions.operator-router] +verify_jwt = false + +[functions.create-credit-checkout] +verify_jwt = false diff --git a/supabase/functions/create-credit-checkout/deno.json b/supabase/functions/create-credit-checkout/deno.json new file mode 100644 index 0000000..53690b0 --- /dev/null +++ b/supabase/functions/create-credit-checkout/deno.json @@ -0,0 +1,8 @@ +{ + "imports": { + "stripe": "npm:stripe@19.1.0", + "@supabase/supabase-js": "npm:@supabase/supabase-js@2.110.0", + "@pickforge/billing": "npm:@pickforge/billing@0.7.0", + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.7.0" + } +} diff --git a/supabase/functions/create-credit-checkout/index.ts b/supabase/functions/create-credit-checkout/index.ts new file mode 100644 index 0000000..597c0ed --- /dev/null +++ b/supabase/functions/create-credit-checkout/index.ts @@ -0,0 +1,89 @@ +import Stripe from "stripe"; +import { createClient } from "@supabase/supabase-js"; +import { createCreditCheckoutSession } from "@pickforge/billing"; +import { + corsHeaders, + corsPreflightResponse, + EdgeSharedError, + getUserFromRequest, + jsonResponse, +} from "@pickforge/edge-shared"; + +const supabaseUrl = requiredEnv("SUPABASE_URL"); +const supabaseAnonKey = requiredEnv("SUPABASE_ANON_KEY"); +const stripe = new Stripe(requiredEnv("STRIPE_SECRET_KEY")); + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return corsPreflightResponse(); + } + + try { + const { userId } = await getUserFromRequest({ supabase: createCallerSupabase(req), req }); + const { pack } = await readCheckoutRequest(req); + const session = await createCreditCheckoutSession<{ url: string | null }>({ + stripe, + userId, + priceId: priceIdForPack(pack), + successUrl: requiredEnv("CHECKOUT_SUCCESS_URL"), + cancelUrl: requiredEnv("CHECKOUT_CANCEL_URL"), + }); + if (typeof session.url !== "string" || session.url.length === 0) { + throw new Error("Stripe checkout session did not include a URL"); + } + + return respond(200, { url: session.url }); + } catch (error) { + if (error instanceof EdgeSharedError) { + return respond(error.code === "unauthorized" ? 401 : 400, { error: error.code }); + } + + if (error instanceof SyntaxError) { + return respond(400, { error: "invalid_string" }); + } + + return respond(500, { error: "internal_error" }); + } +}); + +function createCallerSupabase(req: Request) { + return createClient(supabaseUrl, supabaseAnonKey, { + auth: { autoRefreshToken: false, persistSession: false }, + global: { headers: { Authorization: req.headers.get("authorization") ?? "" } }, + }); +} + +function respond(status: number, body: unknown): Response { + return jsonResponse(status, body, corsHeaders()); +} + +function priceIdForPack(pack: unknown): string { + if (pack === "p10") return requiredEnv("STRIPE_PRICE_PACK_10"); + if (pack === "p25") return requiredEnv("STRIPE_PRICE_PACK_25"); + if (pack === "p50") return requiredEnv("STRIPE_PRICE_PACK_50"); + + throw new EdgeSharedError("invalid_string", "pack must be p10, p25, or p50"); +} + +async function readCheckoutRequest(req: Request): Promise<{ pack: unknown }> { + const body = await req.json(); + if ( + typeof body !== "object" || + body === null || + Array.isArray(body) || + Object.keys(body).some((key) => key !== "pack") + ) { + throw new EdgeSharedError("invalid_string", "Request body must contain only pack"); + } + + return { pack: body.pack }; +} + +function requiredEnv(name: string): string { + const value = Deno.env.get(name); + if (value === undefined || value.length === 0) { + throw new Error(`${name} is required`); + } + + return value; +} diff --git a/supabase/functions/operator-router/deno.json b/supabase/functions/operator-router/deno.json new file mode 100644 index 0000000..ab61a94 --- /dev/null +++ b/supabase/functions/operator-router/deno.json @@ -0,0 +1,7 @@ +{ + "imports": { + "@supabase/supabase-js": "npm:@supabase/supabase-js@2.110.0", + "@pickforge/billing": "npm:@pickforge/billing@0.7.0", + "@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.7.0" + } +} diff --git a/supabase/functions/operator-router/index.ts b/supabase/functions/operator-router/index.ts new file mode 100644 index 0000000..d918daa --- /dev/null +++ b/supabase/functions/operator-router/index.ts @@ -0,0 +1,166 @@ +import { createClient } from "@supabase/supabase-js"; +import { getCreditBalanceCents } from "@pickforge/billing"; +import { + createOperatorRouterHandler, + corsHeaders, + corsPreflightResponse, + debitCredits, + EdgeSharedError, + jsonResponse, +} from "@pickforge/edge-shared"; + +const supabaseUrl = requiredEnv("SUPABASE_URL"); +const supabaseAnonKey = requiredEnv("SUPABASE_ANON_KEY"); +const serviceSupabase = createClient(supabaseUrl, requiredEnv("SUPABASE_SERVICE_ROLE_KEY"), { + auth: { autoRefreshToken: false, persistSession: false }, +}); + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return corsPreflightResponse(); + } + + const apiKey = Deno.env.get("OPENAI_API_KEY"); + if (apiKey === undefined || apiKey.length === 0) { + return respond(503, { + error: "hosted_routing_not_configured", + message: "Hosted routing is not configured", + }); + } + + const handler = createOperatorRouterHandler({ + supabase: createCallerSupabase(req), + findCompletedRoute, + getCreditBalance: (userId) => getCreditBalanceCents({ supabase: serviceSupabase, userId }), + consumeRateLimit, + debit: (options) => debitCredits({ supabase: serviceSupabase, ...options }), + chatComplete, + model: Deno.env.get("OPENAI_ROUTER_MODEL") ?? "gpt-5.4-mini", + apiKey, + baseUrl: Deno.env.get("OPENAI_BASE_URL") ?? "https://api.openai.com/v1", + creditCostCents: readPositiveIntegerEnv("ROUTER_CREDIT_COST_CENTS", 2), + }); + + const response = await handler(req); + const headers = new Headers(response.headers); + for (const [name, value] of Object.entries(corsHeaders())) { + headers.set(name, value); + } + return new Response(response.body, { status: response.status, headers }); +}); + +async function findCompletedRoute(userId: string, idempotencyKey: string) { + const { data, error } = await serviceSupabase + .from<{ metadata: unknown }>("credit_ledger") + .select("metadata") + .eq("user_id", userId) + .eq("idempotency_key", idempotencyKey) + .maybeSingle(); + if (error !== null) { + throw new EdgeSharedError("database_error", "Failed to read stored router result", { cause: error }); + } + if (data === null) { + return null; + } + + const proposalJson = (data.metadata as { proposalJson?: unknown }).proposalJson; + const usage = (data.metadata as { usage?: { input?: unknown; output?: unknown } }).usage; + if ( + typeof proposalJson !== "string" || + !Number.isSafeInteger(usage?.input) || + !Number.isSafeInteger(usage?.output) + ) { + return null; + } + + return { proposalJson, usage: { input: usage.input, output: usage.output } }; +} + +async function consumeRateLimit(userId: string): Promise { + const { data, error } = await serviceSupabase.rpc("consume_router_rate_limit", { + target_user: userId, + }); + if (error !== null) { + throw new EdgeSharedError("database_error", "Failed to consume router rate limit", { cause: error }); + } + if (typeof data !== "boolean") { + throw new EdgeSharedError("invalid_rpc_result", "Router rate limit returned an invalid result"); + } + + return data; +} + +function respond(status: number, body: unknown): Response { + return jsonResponse(status, body, corsHeaders()); +} + +function createCallerSupabase(req: Request) { + return createClient(supabaseUrl, supabaseAnonKey, { + auth: { autoRefreshToken: false, persistSession: false }, + global: { headers: { Authorization: req.headers.get("authorization") ?? "" } }, + }); +} + +async function chatComplete({ + model, + apiKey, + baseUrl, + systemPrompt, + userPrompt, +}: { + model: string; + apiKey: string; + baseUrl: string; + systemPrompt: string; + userPrompt: string; +}) { + const response = await fetch(`${baseUrl.replace(/\/$/, "")}/chat/completions`, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + response_format: { type: "json_object" }, + temperature: 0, + }), + }); + if (!response.ok) { + throw new Error(`OpenAI chat completion failed with ${response.status}`); + } + + const body = await response.json(); + const text = body?.choices?.[0]?.message?.content; + const input = body?.usage?.prompt_tokens; + const output = body?.usage?.completion_tokens; + if (typeof text !== "string" || !Number.isSafeInteger(input) || !Number.isSafeInteger(output)) { + throw new Error("OpenAI chat completion returned an invalid response"); + } + + return { text, usage: { input, output } }; +} + +function readPositiveIntegerEnv(name: string, fallback: number): number { + const value = Deno.env.get(name); + if (value === undefined || value.length === 0) { + return fallback; + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`); + } + + return parsed; +} + +function requiredEnv(name: string): string { + const value = Deno.env.get(name); + if (value === undefined || value.length === 0) { + throw new Error(`${name} is required`); + } + + return value; +} diff --git a/supabase/functions/stripe-webhook/index.ts b/supabase/functions/stripe-webhook/index.ts index d5af1fd..072ab4c 100644 --- a/supabase/functions/stripe-webhook/index.ts +++ b/supabase/functions/stripe-webhook/index.ts @@ -1,7 +1,7 @@ import Stripe from "npm:stripe@19.1.0"; import { createClient } from "npm:@supabase/supabase-js@2.110.0"; -import { processStripeEvent, verifyStripeEvent } from "npm:@pickforge/billing@0.5.0"; -import { createStripeWebhookHandler } from "npm:@pickforge/edge-shared@0.5.0"; +import { processStripeEvent, verifyStripeEvent } from "npm:@pickforge/billing@0.7.0"; +import { createStripeWebhookHandler } from "npm:@pickforge/edge-shared@0.7.0"; const stripe = new Stripe(requiredEnv("STRIPE_SECRET_KEY")); const supabase = createClient(requiredEnv("SUPABASE_URL"), requiredEnv("SUPABASE_SERVICE_ROLE_KEY"), { diff --git a/supabase/migrations/20260709050000_debit_credits_metadata.sql b/supabase/migrations/20260709050000_debit_credits_metadata.sql new file mode 100644 index 0000000..e782a11 --- /dev/null +++ b/supabase/migrations/20260709050000_debit_credits_metadata.sql @@ -0,0 +1,83 @@ +drop function if exists public.debit_credits(uuid, integer, text, text); + +create function public.debit_credits( + target_user uuid, + debit_cents integer, + reason text, + idem_key text, + usage_metadata jsonb default '{}'::jsonb +) +returns jsonb +language plpgsql +security invoker +set search_path = public +as $$ +declare + current_balance integer; + next_balance integer; +begin + if debit_cents is null or debit_cents <= 0 then + raise exception 'debit_cents must be a positive integer' + using errcode = '22023'; + end if; + + if idem_key is null or btrim(idem_key) = '' then + raise exception 'idem_key must be a non-empty string' + using errcode = '22023'; + end if; + + if usage_metadata is null or jsonb_typeof(usage_metadata) <> 'object' then + raise exception 'usage_metadata must be an object' + using errcode = '22023'; + end if; + + perform pg_advisory_xact_lock(hashtext(target_user::text)); + + if exists ( + select 1 + from public.credit_ledger + where user_id = target_user + and idempotency_key = idem_key + ) then + return jsonb_build_object('status', 'duplicate'); + end if; + + select coalesce(sum(amount_cents), 0)::integer + into current_balance + from public.credit_ledger + where user_id = target_user; + + if current_balance < debit_cents then + return jsonb_build_object('status', 'insufficient', 'balance', current_balance); + end if; + + next_balance := current_balance - debit_cents; + + begin + insert into public.credit_ledger ( + user_id, + amount_cents, + kind, + description, + idempotency_key, + metadata + ) + values ( + target_user, + -debit_cents, + 'usage', + reason, + idem_key, + usage_metadata + ); + exception + when unique_violation then + return jsonb_build_object('status', 'duplicate'); + end; + + return jsonb_build_object('status', 'ok', 'balance', next_balance); +end; +$$; + +revoke execute on function public.debit_credits(uuid, integer, text, text, jsonb) from public, anon, authenticated; +grant execute on function public.debit_credits(uuid, integer, text, text, jsonb) to service_role; diff --git a/supabase/migrations/20260709060000_router_rate_limit.sql b/supabase/migrations/20260709060000_router_rate_limit.sql new file mode 100644 index 0000000..e62f2a3 --- /dev/null +++ b/supabase/migrations/20260709060000_router_rate_limit.sql @@ -0,0 +1,51 @@ +create table if not exists public.router_rate_limits ( + user_id uuid primary key references auth.users(id) on delete cascade, + window_started_at timestamptz not null, + request_count integer not null check (request_count >= 0) +); + +alter table public.router_rate_limits enable row level security; + +create function public.consume_router_rate_limit(target_user uuid) +returns boolean +language plpgsql +security invoker +set search_path = public +as $$ +declare + current_window timestamptz; + current_count integer; +begin + perform pg_advisory_xact_lock(hashtext(target_user::text)); + + select window_started_at, request_count + into current_window, current_count + from public.router_rate_limits + where user_id = target_user; + + if not found then + insert into public.router_rate_limits (user_id, window_started_at, request_count) + values (target_user, now(), 1); + return true; + end if; + + if current_window <= now() - interval '10 seconds' then + update public.router_rate_limits + set window_started_at = now(), request_count = 1 + where user_id = target_user; + return true; + end if; + + if current_count >= 10 then + return false; + end if; + + update public.router_rate_limits + set request_count = request_count + 1 + where user_id = target_user; + return true; +end; +$$; + +revoke execute on function public.consume_router_rate_limit(uuid) from public, anon, authenticated; +grant execute on function public.consume_router_rate_limit(uuid) to service_role;