/** * Backend-for-Frontend (BFF) Cloudflare Worker, an in-app-purchase- and/or * App-Attest-gated Claude proxy. * * EXAMPLE CODE. Not deployed. Placeholders only. See README.md. * * Flow: * 1. The app sends a standard Anthropic Messages API request, plus a StoreKit * 2 signed transaction (X-IAP-Transaction) and/or an App Attest assertion * (X-App-Attest-Assertion + X-App-Attest-Key-Id). * 2. Each gate that is configured must pass: StoreKit proves the caller is * entitled, App Attest proves the caller is a genuine instance of your app. * If neither gate is configured the Worker refuses to run (500). * 3. On success it injects the Anthropic key server-side and forwards the * request to an allowlisted upstream path. The app ships no key. */ import { ASSERTION_HEADER, DEFAULT_UPSTREAM_PATHS, KEY_ID_HEADER, TRANSACTION_HEADER, type Env, } from "./env.ts"; import { evaluateEntitlement, parseList, type GateResult, } from "./entitlement.ts"; import { verifyTransactionJWS, type TransactionVerifier, } from "./storekit.ts"; import { appAttestConfig, verifyAppAttestAssertion, type AppAttestVerifier, } from "./appattest.ts"; import { forwardToAnthropic, resolveUpstreamPath } from "./proxy.ts"; import type { JWSTransaction } from "./transaction.ts"; /** Verifiers are injectable so tests exercise the flow without real crypto. */ export interface Handlers { verifyTransaction: TransactionVerifier; verifyAppAttest: AppAttestVerifier; } const defaultHandlers: Handlers = { verifyTransaction: verifyTransactionJWS, verifyAppAttest: verifyAppAttestAssertion, }; function storeKitConfig(env: Env) { if (!env.ALLOWED_BUNDLE_IDS || !env.APPLE_ROOT_CA_SHA256) return null; return { allowedBundleIds: parseList(env.ALLOWED_BUNDLE_IDS), allowedProductIds: env.ALLOWED_PRODUCT_IDS ? parseList(env.ALLOWED_PRODUCT_IDS) : null, rootSha256: env.APPLE_ROOT_CA_SHA256, }; } export async function handleRequest( request: Request, env: Env, handlers: Handlers = defaultHandlers, ): Promise { if (request.method !== "POST") { return new Response("method not allowed", { status: 405 }); } const storeKit = storeKitConfig(env); const appAttest = appAttestConfig(env); // A Worker with no gate configured is an open relay for your API bill. Refuse // to run rather than forward anything. if (!storeKit && !appAttest) { return json(500, { error: "no entitlement gate configured" }); } const incoming = new URL(request.url); const upstreamPath = resolveUpstreamPath( incoming.pathname, parseList(env.ALLOWED_UPSTREAM_PATHS ?? DEFAULT_UPSTREAM_PATHS), ); if (!upstreamPath) { return json(403, { error: "upstream path not allowed" }); } const jws = request.headers.get(TRANSACTION_HEADER); // StoreKit gate (if configured): verify the signed transaction, then decide. if (storeKit) { if (!jws) { return json(401, { error: "missing in-app purchase transaction" }); } let txn: JWSTransaction; try { txn = await handlers.verifyTransaction(jws, storeKit.rootSha256); } catch { return json(401, { error: "invalid in-app purchase transaction" }); } const gate: GateResult = evaluateEntitlement(txn, { allowedBundleIds: storeKit.allowedBundleIds, allowedProductIds: storeKit.allowedProductIds, now: Date.now(), }); if (!gate.ok) { return json(gate.status, { error: gate.reason }); } } // App Attest gate (if configured): prove the caller is a genuine app instance. if (appAttest) { const assertion = request.headers.get(ASSERTION_HEADER); const keyId = request.headers.get(KEY_ID_HEADER); if (!assertion || !keyId) { return json(401, { error: "missing App Attest assertion" }); } try { // Bind the assertion to the request line and the IAP token, so it cannot // be lifted onto a different call. The body is left untouched for forwarding. const clientData = `${request.method}\n${upstreamPath}\n${jws ?? ""}`; const clientDataHash = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(clientData), ); await handlers.verifyAppAttest( { keyId, assertion, clientDataHash }, appAttest, ); } catch { return json(401, { error: "invalid App Attest assertion" }); } } // Every configured gate passed. Inject the secret and forward upstream. return forwardToAnthropic(request, env, upstreamPath); } function json(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); } export default { fetch(request: Request, env: Env): Promise { return handleRequest(request, env); }, };