Skip to main content
  1. Code/

A Gated Claude BFF at the Edge

Download all (.zip)

This is the working code behind Your API Key Doesn’t Belong in the App. A Backend for Frontend holds the Anthropic key, decides who may spend it and what a call may cost, and proxies the Messages API. It runs as a single Cloudflare Worker, so the credential injection happens a few milliseconds from the caller wherever they are.

The Worker does three jobs. It gates: StoreKit 2 proves the caller is entitled, App Attest proves the caller is a genuine instance of your app. It revokes: a webhook records refunds the moment Apple reports them, so a stale token stops working. It meters: a model allowlist, a max_tokens cap, and a per-caller daily token budget bound what any one caller can spend. Each job is small, so the value is in how it splits: one type per file, pure decisions kept apart from crypto and storage, and every verifier and store injectable so the tests run offline.

The configuration surface #

env.ts is the contract with wrangler.toml. Two gates can be turned on independently. StoreKit answers is this caller entitled? and needs a bundle allowlist plus the pinned Apple root. App Attest answers is this even my app talking? and needs your team and bundle identifiers plus the App Attestation root certificate. Spend controls stack on top of whichever gates you run: the model allowlist, the per-request max_tokens cap, and the daily token budget. A path allowlist bounds which upstream endpoints an authorized caller can reach.

env.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
/**
 * Env: the Worker's configuration surface.
 *
 * Two independent gates can be turned on by configuration:
 *
 *   StoreKit  (entitlement): set ALLOWED_BUNDLE_IDS + APPLE_ROOT_CA_SHA256.
 *   App Attest (authenticity): set APP_ATTEST_TEAM_ID + APP_ATTEST_BUNDLE_ID.
 *
 * If neither is configured the Worker refuses to run (500). If both are
 * configured a request must clear both.
 *
 * On top of the gates, spend controls decide what an entitled caller may
 * spend: a model allowlist, a per-request max_tokens cap, and a per-caller
 * daily token budget. Each is optional and configuration-driven too.
 */
export interface Env {
  /** Anthropic API key. Set as a secret: `wrangler secret put ANTHROPIC_API_KEY`. */
  ANTHROPIC_API_KEY: string;

  // --- StoreKit entitlement gate (active when both are set) ---

  /** Comma-separated allowlist of app bundle IDs, e.g. "com.example.app". */
  ALLOWED_BUNDLE_IDS?: string;
  /** Comma-separated allowlist of product IDs. Empty/unset = accept any product. */
  ALLOWED_PRODUCT_IDS?: string;
  /** Hex SHA-256 of the DER-encoded "Apple Root CA - G3" certificate.
   *  Obtain and verify from https://www.apple.com/certificateauthority/ */
  APPLE_ROOT_CA_SHA256?: string;

  // --- App Attest authenticity gate (active when both are set) ---

  /** Apple Developer Team ID the app ships under, e.g. "ABCDE12345". */
  APP_ATTEST_TEAM_ID?: string;
  /** Bundle ID the attestation must be bound to, e.g. "com.example.app". */
  APP_ATTEST_BUNDLE_ID?: string;
  /** The "Apple App Attestation Root CA" certificate (PEM or base64 DER).
   *  The attestation's x5c omits the root, so a fingerprint cannot anchor the
   *  chain; the verifier needs the certificate itself. Obtain and verify from
   *  https://www.apple.com/certificateauthority/ */
  APP_ATTEST_ROOT_CA?: string;

  // --- Spend controls (each optional) ---

  /** Comma-separated allowlist of model IDs the proxy will forward. */
  ALLOWED_MODELS?: string;
  /** Upper bound on max_tokens per request, e.g. "4096". */
  MAX_TOKENS_LIMIT?: string;
  /** Tokens each caller may request per UTC day, e.g. "200000". Debited with
   *  the request's max_tokens before forwarding. */
  DAILY_TOKEN_BUDGET?: string;

  // --- Proxy configuration ---

  /** Comma-separated allowlist of upstream paths. Defaults to "/v1/messages". */
  ALLOWED_UPSTREAM_PATHS?: string;
  /** Anthropic API version header. Defaults to "2023-06-01". */
  ANTHROPIC_VERSION?: string;

  // --- Durable Object bindings (declared in wrangler.toml) ---

  /** Per-caller daily token budget. */
  TOKEN_BUDGET?: DurableObjectNamespaceLike;
  /** Registered App Attest public keys and assertion counters, per keyId. */
  ATTEST_KEYS?: DurableObjectNamespaceLike;
  /** One-time App Attest registration challenges. */
  ATTEST_CHALLENGES?: DurableObjectNamespaceLike;
  /** Revocation record per originalTransactionId, written by the webhook. */
  REVOCATIONS?: DurableObjectNamespaceLike;
}

/**
 * Minimal structural type for a Durable Object namespace binding, so the
 * bundle type-checks and tests run with zero installs instead of depending on
 * `@cloudflare/workers-types`. The real binding satisfies this shape.
 */
export interface DurableObjectNamespaceLike {
  idFromName(name: string): unknown;
  get(id: unknown): {
    fetch(input: string, init?: RequestInit): Promise<Response>;
  };
}

export const ANTHROPIC_BASE = "https://api.anthropic.com";
export const DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
export const DEFAULT_UPSTREAM_PATHS = "/v1/messages";

/** Header carrying the StoreKit 2 signed transaction (JWS). */
export const TRANSACTION_HEADER = "X-IAP-Transaction";
/** Header carrying the App Attest assertion (base64). */
export const ASSERTION_HEADER = "X-App-Attest-Assertion";
/** Header carrying the App Attest key identifier the assertion was signed with. */
export const KEY_ID_HEADER = "X-App-Attest-Key-Id";

/** Route Apple's App Store Server Notifications V2 POSTs here. */
export const NOTIFICATIONS_PATH = "/apple/notifications";
/** Route the app's one-time App Attest key registration here. */
export const REGISTER_PATH = "/app-attest/register";
/** Route the app's registration-challenge requests here. */
export const CHALLENGE_PATH = "/app-attest/challenge";

The configuration is the operational story: a handful of public variables plus one secret that never appears in source control. ALLOWED_BUNDLE_IDS lists the apps you trust, and APPLE_ROOT_CA_SHA256 pins the fingerprint of Apple Root CA - G3, taken from Apple’s certificate authority page. The secret is set out of band:

wrangler secret put ANTHROPIC_API_KEY

The matching wrangler.toml carries the variables and the bindings: four Durable Object classes, one each for token budgets, registered App Attest keys, registration challenges, and revocations. Durable Objects give each identity its own strongly consistent instance with great distributed performance, which is exactly what budgets, counters, and one-time challenges need. The migration block at the bottom is what deploys the classes.

wrangler.toml Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# Example Wrangler config for the gated Claude BFF Worker.

name = "claude-bff-example"
main = "worker.ts"
compatibility_date = "2026-06-01"

# Public, non-secret configuration:
[vars]
# --- StoreKit entitlement gate (active when both are set) ---
# Comma-separated bundle IDs your app(s) ship under.
ALLOWED_BUNDLE_IDS = "com.example.app"
# Optional: restrict to specific product IDs. Empty = accept any product.
ALLOWED_PRODUCT_IDS = ""
# Hex SHA-256 of the DER-encoded "Apple Root CA - G3" certificate.
# Obtain and verify from https://www.apple.com/certificateauthority/
APPLE_ROOT_CA_SHA256 = "PUT_THE_PINNED_FINGERPRINT_HERE"

# --- App Attest authenticity gate (active when both are set) ---
# Leave blank to run StoreKit only. Set both to require a genuine app instance.
APP_ATTEST_TEAM_ID = ""
APP_ATTEST_BUNDLE_ID = ""
# The "Apple App Attestation Root CA" certificate (PEM or base64 DER). The
# attestation chain omits its root, so the verifier needs the certificate
# itself. Obtain and verify from https://www.apple.com/certificateauthority/
APP_ATTEST_ROOT_CA = ""

# --- Spend controls (each optional; set to bound what entitled callers spend) ---
# Comma-separated allowlist of model IDs the proxy will forward.
ALLOWED_MODELS = "claude-opus-4-8"
# Upper bound on max_tokens per request.
MAX_TOKENS_LIMIT = "4096"
# Tokens each caller may request per UTC day (max_tokens, debited upfront).
DAILY_TOKEN_BUDGET = "200000"

# --- Proxy configuration ---
# Opt in to each upstream path the app may reach. Default is the Messages API.
ALLOWED_UPSTREAM_PATHS = "/v1/messages"
# Anthropic API version header.
ANTHROPIC_VERSION = "2023-06-01"

# --- Durable Object bindings ---
# One TokenBudget instance per caller identity.
[[durable_objects.bindings]]
name = "TOKEN_BUDGET"
class_name = "TokenBudget"

# One AttestKey instance per App Attest keyId (public key + counter).
[[durable_objects.bindings]]
name = "ATTEST_KEYS"
class_name = "AttestKey"

# One AttestChallenge instance per issued registration challenge.
[[durable_objects.bindings]]
name = "ATTEST_CHALLENGES"
class_name = "AttestChallenge"

# One RevocationRecord instance per originalTransactionId.
[[durable_objects.bindings]]
name = "REVOCATIONS"
class_name = "RevocationRecord"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["TokenBudget", "AttestKey", "AttestChallenge", "RevocationRecord"]

# Secret, never commit this; set it out of band:
#   wrangler secret put ANTHROPIC_API_KEY

The transaction the gate reads #

A StoreKit 2 signed transaction carries far more than the gate needs. transaction.ts names only the fields the decision turns on, plus the two that do heavy lifting elsewhere: environment, and originalTransactionId, the identifier that stays stable across renewals and restores, which makes it the identity the revocation record and the token budget key on.

transaction.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * JWSTransaction: the decoded StoreKit 2 transaction fields this gate cares
 * about. The full payload carries far more; we read only what the entitlement
 * decision needs.
 */
export interface JWSTransaction {
  bundleId?: string;
  productId?: string;
  /**
   * Stable across renewals and restores, so it is the identity everything
   * server-side keys on: the revocation record and the per-caller budget.
   */
  originalTransactionId?: string;
  /**
   * "Sandbox" or "Production". StoreKit 2 signs sandbox transactions with the
   * same certificate chain as production, and a sandbox Apple ID is free, so a
   * Production check is the only thing that stops anyone minting a valid
   * entitlement for nothing. The gate requires "Production".
   */
  environment?: "Sandbox" | "Production";
  /** Subscription expiry, ms since epoch. Absent for non-subscription products. */
  expiresDate?: number;
  /** Set (ms since epoch) when Apple revokes the purchase (refund, dispute). */
  revocationDate?: number;
}

A StoreKit 2 JWS is tagged Sandbox or Production, and Apple signs sandbox transactions with the same certificate chain as production. A sandbox Apple ID is free, so without an environment check anyone can mint a valid entitlement and clear the gate without paying. The decision treats anything other than Production as a failure.

The entitlement decision #

entitlement.ts is a pure function: a decoded transaction and a config in, a verdict out. No crypto, no network, which keeps it testable in isolation. It checks the bundle, then the optional product allowlist, then environment === "Production", then revocation, then expiry. Trial and paid collapse into one check, because a free trial is a subscription whose expiresDate is in the future.

entitlement.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
 * The entitlement decision: a pure function, no crypto and no network. Given a
 * decoded StoreKit 2 transaction and the gate config, decide whether the caller
 * is entitled. Keeping it pure makes it testable in isolation.
 */
import type { JWSTransaction } from "./transaction.ts";

export interface GateConfig {
  allowedBundleIds: Set<string>;
  allowedProductIds: Set<string> | null; // null = any product
  now: number; // ms since epoch
}

export type GateResult =
  | { ok: true }
  | { ok: false; status: 401 | 403; reason: string };

/** Parse a comma-separated env var into a trimmed, non-empty Set. */
export function parseList(value: string | undefined): Set<string> {
  return new Set(
    (value ?? "")
      .split(",")
      .map((s) => s.trim())
      .filter((s) => s.length > 0),
  );
}

export function evaluateEntitlement(
  txn: JWSTransaction,
  cfg: GateConfig,
): GateResult {
  if (!txn.bundleId || !cfg.allowedBundleIds.has(txn.bundleId)) {
    return { ok: false, status: 403, reason: "bundle_id_not_allowed" };
  }
  if (
    cfg.allowedProductIds &&
    (!txn.productId || !cfg.allowedProductIds.has(txn.productId))
  ) {
    return { ok: false, status: 403, reason: "product_id_not_allowed" };
  }
  // A sandbox transaction is signed by the same chain as production and costs
  // nothing to mint, so it must never clear the gate.
  if (txn.environment !== "Production") {
    return { ok: false, status: 403, reason: "environment_not_production" };
  }
  if (txn.revocationDate != null) {
    return { ok: false, status: 403, reason: "entitlement_revoked" };
  }
  // Trial and paid are the same check: a subscription is valid while its
  // expiresDate is in the future. Non-subscription products omit expiresDate.
  if (txn.expiresDate != null && txn.expiresDate <= cfg.now) {
    return { ok: false, status: 403, reason: "entitlement_expired" };
  }
  return { ok: true };
}

Verifying Apple’s signatures #

Three Apple credentials flow through this Worker: the StoreKit transaction, the App Store server notification, and the App Attest attestation. All three carry an x5c certificate chain, so applejws.ts owns the chain walk once. It refuses chains of surprising length, checks every certificate’s validity window, verifies each link’s signature, and accepts only ES256 when verifying a JWS, so a weaker algorithm cannot be smuggled in through the header. Two anchoring modes cover Apple’s two chain shapes: StoreKit transactions and server notifications include their root, so pinning its SHA-256 is enough, while the App Attest chain stops at the intermediate, so that verifier takes the root certificate itself as the trust anchor.

applejws.ts Raw Download
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
/**
 * Shared Apple certificate-chain and JWS verification.
 *
 * Three Apple credentials flow through this Worker, and all of them are signed
 * the same way: a StoreKit 2 transaction, an App Store Server Notifications V2
 * payload, and an App Attest attestation each carry an x5c certificate chain.
 * This file owns the chain walk so every caller gets the same checks: every
 * certificate inside its validity window, every link signed by the next, and
 * the trust anchor pinned rather than assumed.
 *
 * Two anchoring modes, because Apple ships two chain shapes. StoreKit and the
 * notifications payload include the root in the chain, so pinning its SHA-256
 * is enough. The App Attest attestation omits the root, so the verifier needs
 * the root certificate itself to check the last link's signature.
 *
 * The crypto dependencies are imported lazily so the pure logic elsewhere
 * stays unit-testable with zero installs. Production dependencies: `jose`,
 * `@peculiar/x509`.
 */
import type { X509Certificate } from "@peculiar/x509";

/** Apple chains are leaf, intermediate, root. Refuse anything outlandish. */
const MAX_CHAIN_LENGTH = 5;

type X509Module = typeof import("@peculiar/x509");

async function chainCerts(
  x509: X509Module,
  x5c: Array<string | Uint8Array>,
): Promise<X509Certificate[]> {
  if (x5c.length < 2 || x5c.length > MAX_CHAIN_LENGTH) {
    throw new Error("unexpected certificate chain length");
  }
  const certs = x5c.map((der) => new x509.X509Certificate(der));
  const now = new Date();
  for (const cert of certs) {
    if (now < cert.notBefore || now > cert.notAfter) {
      throw new Error("certificate outside its validity window");
    }
  }
  // Verify each link in the chain (leaf first, root last).
  for (let i = 0; i < certs.length - 1; i++) {
    const issuerKey = await certs[i + 1].publicKey.export();
    if (!(await certs[i].verify({ publicKey: issuerKey }))) {
      throw new Error("broken certificate chain");
    }
  }
  return certs;
}

/**
 * Verify a chain that carries its own root, and pin that root by SHA-256.
 * Used for StoreKit 2 transactions and server notifications, whose x5c ends
 * at Apple Root CA - G3.
 */
export async function verifyChainAndPin(
  x5c: Array<string | Uint8Array>,
  pinnedSha256: string,
): Promise<X509Certificate[]> {
  const x509 = await import("@peculiar/x509");
  const certs = await chainCerts(x509, x5c);
  const root = certs[certs.length - 1];
  const rootSha256 = toHex(await crypto.subtle.digest("SHA-256", root.rawData));
  if (rootSha256 !== normalizeHex(pinnedSha256)) {
    throw new Error("untrusted root certificate");
  }
  return certs;
}

/**
 * Verify a chain that omits its root, anchoring the last certificate to a
 * configured trust anchor. Used for App Attest attestations, whose x5c stops
 * at the intermediate; `anchor` is the Apple App Attestation Root CA
 * certificate (PEM or base64 DER).
 */
export async function verifyChainToAnchor(
  x5c: Array<string | Uint8Array>,
  anchor: string,
): Promise<X509Certificate[]> {
  const x509 = await import("@peculiar/x509");
  const certs = await chainCerts(x509, x5c);
  const root = new x509.X509Certificate(anchor);
  const rootKey = await root.publicKey.export();
  const last = certs[certs.length - 1];
  if (!(await last.verify({ publicKey: rootKey }))) {
    throw new Error("chain does not anchor to the pinned root");
  }
  return certs;
}

/**
 * Verify an Apple JWS end to end: walk and pin the x5c chain, then verify the
 * signature with the leaf key, accepting only ES256. Returns the payload.
 */
export async function verifyAppleJWS(
  jws: string,
  pinnedRootSha256: string,
): Promise<unknown> {
  const { decodeProtectedHeader, importX509, jwtVerify } = await import("jose");
  const header = decodeProtectedHeader(jws);
  if (!Array.isArray(header.x5c)) {
    throw new Error("JWS is missing an x5c certificate chain");
  }
  const certs = await verifyChainAndPin(header.x5c, pinnedRootSha256);
  const leafKey = await importX509(certs[0].toString("pem"), "ES256");
  const { payload } = await jwtVerify(jws, leafKey, { algorithms: ["ES256"] });
  return payload;
}

export function toHex(buf: ArrayBuffer): string {
  return [...new Uint8Array(buf)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

export function normalizeHex(s: string): string {
  return s.toLowerCase().replace(/[^0-9a-f]/g, "");
}

The crypto leans on jose and @peculiar/x509 rather than hand-rolled primitives. A certificate chain and a JWS are the kind of code you do not want to invent. Both load lazily, so the pure decisions in this bundle need zero installs to test. The byte helpers every verifier shares, base64, constant-time comparison, and concatenation, live in one small file.

bytes.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
 * Byte helpers shared by the verifiers: base64, constant-time comparison, and
 * concatenation. No crypto and no dependencies.
 */

export function base64ToBytes(b64: string): Uint8Array {
  const bin = atob(b64);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}

export function bytesToBase64(bytes: Uint8Array): string {
  let bin = "";
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin);
}

/** Constant-time equality, so a comparison never leaks how far it matched. */
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
  return diff === 0;
}

export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
  const out = new Uint8Array(a.length + b.length);
  out.set(a, 0);
  out.set(b, a.length);
  return out;
}

Verifying the StoreKit transaction #

With the chain walk shared, storekit.ts is a typed seam: verify the JWS against the pinned Apple Root CA - G3, return the payload as the transaction shape. The verifier is a typed function, so tests inject a fake and skip the certificate chain entirely.

storekit.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
 * StoreKit 2 signed-transaction verification.
 *
 * A StoreKit 2 transaction is an Apple JWS whose x5c chain ends at Apple Root
 * CA - G3, so the whole job lives in applejws.ts: walk the chain, check every
 * validity window, pin the root, verify the ES256 signature with the leaf key.
 * This file keeps the typed seam, so tests can swap in a fake and skip real
 * certificates.
 */
import type { JWSTransaction } from "./transaction.ts";
import { verifyAppleJWS } from "./applejws.ts";

/** Verifier signature, so tests can swap in a fake and skip real certificates. */
export type TransactionVerifier = (
  jws: string,
  appleRootSha256: string,
) => Promise<JWSTransaction>;

export const verifyTransactionJWS: TransactionVerifier = async (
  jws,
  appleRootSha256,
) => (await verifyAppleJWS(jws, appleRootSha256)) as JWSTransaction;

Server-side revocation #

The entitlement gate reads revocationDate from the token the client sends, so on its own it catches only a refund the client honestly reports. A refunded user can replay a pre-refund JWS forever. The durable fix is a record on your side of the boundary holding what Apple has told you since that token was minted. revocation.ts keeps that record in a Durable Object per originalTransactionId, so a refund recorded once holds everywhere at once, plus the pure mapping from notification types to actions: REFUND and REVOKE revoke, REFUND_REVERSED restores, and everything else, renewals, subscribes, test pings, is none of this record’s business.

revocation.ts Raw Download
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
/**
 * The server-side revocation record: what Apple has told us since the JWS in
 * the caller's hand was minted.
 *
 * The entitlement gate reads revocationDate from the token the client sends,
 * so on its own it only catches a refund the client honestly reports. This
 * record closes that: the notifications webhook writes refunds and
 * revocations here as Apple reports them, and the worker checks it on every
 * request. One Durable Object per originalTransactionId keeps the record
 * strongly consistent everywhere with great distributed performance.
 *
 * The notification-to-action mapping is a pure function, kept apart from the
 * store so it tests in isolation.
 */
import type { DurableObjectNamespaceLike, Env } from "./env.ts";

/** Injectable seam over the REVOCATIONS binding. */
export interface RevocationStore {
  isRevoked(originalTransactionId: string): Promise<boolean>;
  revoke(originalTransactionId: string, reason: string): Promise<void>;
  unrevoke(originalTransactionId: string): Promise<void>;
}

interface StorageLike {
  get(key: string): Promise<unknown>;
  put(key: string, value: unknown): Promise<void>;
}

interface StateLike {
  storage: StorageLike;
}

type RevocationRequest =
  | { action: "get" }
  | { action: "revoke"; reason: string }
  | { action: "unrevoke" };

/**
 * One instance per originalTransactionId (idFromName). Classic fetch-based
 * Durable Object.
 */
export class RevocationRecord {
  storage: StorageLike;

  constructor(state: StateLike) {
    this.storage = state.storage;
  }

  async fetch(request: Request): Promise<Response> {
    const body = (await request.json()) as RevocationRequest;
    if (body.action === "revoke") {
      await this.storage.put("revoked", { reason: body.reason });
      return Response.json({ revoked: true });
    }
    if (body.action === "unrevoke") {
      await this.storage.put("revoked", null);
      return Response.json({ revoked: false });
    }
    const revoked = ((await this.storage.get("revoked")) ?? null) != null;
    return Response.json({ revoked });
  }
}

/** Wrap the REVOCATIONS binding, or null when it is not configured. */
export function revocationStoreFromEnv(env: Env): RevocationStore | null {
  const ns: DurableObjectNamespaceLike | undefined = env.REVOCATIONS;
  if (!ns) return null;
  const call = async (id: string, body: RevocationRequest) => {
    const stub = ns.get(ns.idFromName(id));
    const res = await stub.fetch("https://revocation/", {
      method: "POST",
      body: JSON.stringify(body),
    });
    const { revoked } = (await res.json()) as { revoked: boolean };
    return revoked;
  };
  return {
    isRevoked: (id) => call(id, { action: "get" }),
    async revoke(id, reason) {
      await call(id, { action: "revoke", reason });
    },
    async unrevoke(id) {
      await call(id, { action: "unrevoke" });
    },
  };
}

export type RevocationAction =
  | { kind: "revoke"; originalTransactionId: string; reason: string }
  | { kind: "unrevoke"; originalTransactionId: string }
  | null;

/**
 * Map a verified notification to a revocation action. REFUND and REVOKE
 * (family sharing loss) revoke; REFUND_REVERSED restores; every other type
 * (renewals, subscribes, test notifications) is none of this record's
 * business and maps to null.
 */
export function revocationFromNotification(
  notificationType: string | undefined,
  txn: { originalTransactionId?: string },
): RevocationAction {
  const id = txn.originalTransactionId;
  if (!id) return null;
  if (notificationType === "REFUND" || notificationType === "REVOKE") {
    return { kind: "revoke", originalTransactionId: id, reason: notificationType };
  }
  if (notificationType === "REFUND_REVERSED") {
    return { kind: "unrevoke", originalTransactionId: id };
  }
  return null;
}

notifications.ts is how the record fills. App Store Server Notifications V2 POSTs a signed payload whenever a purchase changes state. The route is unauthenticated by design: Apple shares no webhook secret, it signs the payload itself, one JWS wrapping another, both verified against the same pinned root as a StoreKit transaction. A forged POST fails the signature check and gets a 401.

notifications.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
/**
 * The App Store Server Notifications V2 webhook: Apple pushing the truth
 * about refunds and revocations to the Worker.
 *
 * The route is unauthenticated by design. Apple does not sign requests with a
 * shared secret; it signs the payload itself. The body is one JWS
 * (signedPayload) wrapping another (data.signedTransactionInfo), both
 * verified against the same pinned Apple Root CA - G3 as a StoreKit
 * transaction. A forged POST fails the signature and gets a 401; the
 * signature is the authentication.
 */
import { verifyAppleJWS } from "./applejws.ts";
import {
  revocationFromNotification,
  type RevocationStore,
} from "./revocation.ts";

export interface DecodedNotification {
  notificationType?: string;
  transaction: { originalTransactionId?: string };
}

/** Verifier signature, so tests can swap in a fake and skip real certificates. */
export type NotificationVerifier = (
  signedPayload: string,
  appleRootSha256: string,
) => Promise<DecodedNotification>;

export const verifyNotification: NotificationVerifier = async (
  signedPayload,
  appleRootSha256,
) => {
  const payload = (await verifyAppleJWS(signedPayload, appleRootSha256)) as {
    notificationType?: string;
    data?: { signedTransactionInfo?: string };
  };
  const inner = payload.data?.signedTransactionInfo;
  const transaction = inner
    ? ((await verifyAppleJWS(inner, appleRootSha256)) as {
        originalTransactionId?: string;
      })
    : {};
  return { notificationType: payload.notificationType, transaction };
};

export async function handleNotification(
  request: Request,
  appleRootSha256: string,
  verifier: NotificationVerifier,
  store: RevocationStore,
): Promise<Response> {
  let signedPayload: unknown;
  try {
    ({ signedPayload } = (await request.json()) as {
      signedPayload?: unknown;
    });
  } catch {
    return new Response("malformed notification", { status: 400 });
  }
  if (typeof signedPayload !== "string") {
    return new Response("malformed notification", { status: 400 });
  }

  let decoded: DecodedNotification;
  try {
    decoded = await verifier(signedPayload, appleRootSha256);
  } catch {
    // A failed signature is a 401 so App Store Connect retries a transient
    // problem; a forged payload never reaches the store.
    return new Response("invalid signature", { status: 401 });
  }

  const action = revocationFromNotification(
    decoded.notificationType,
    decoded.transaction,
  );
  if (action?.kind === "revoke") {
    await store.revoke(action.originalTransactionId, action.reason);
  } else if (action?.kind === "unrevoke") {
    await store.unrevoke(action.originalTransactionId);
  }

  // 200 for processed and ignored alike: Apple only needs delivery confirmed.
  return new Response(null, { status: 200 });
}

Point App Store Connect at /apple/notifications and the loop closes: an extracted JWS stops working the moment Apple reports the refund. That same record is what caps replay, because the one token an attacker lifted is the one token the record can kill.

App Attest: registering the key #

App Attest is a two-act protocol. In the first act the app generates a hardware-backed key and attests it once; the server verifies that attestation and stores the public key. Registration starts with a challenge the Worker issues: the app asks /app-attest/challenge, attests its key over the SHA-256 of the answer, and the registration endpoint consumes that challenge exactly once. challenge.ts makes “exactly once” atomic with one Durable Object per challenge value, a single-threaded test-and-set with a ten-minute window, so a captured registration cannot be replayed.

challenge.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/**
 * The App Attest registration challenge: the Worker issues it, a Durable
 * Object remembers it, and registration consumes it exactly once.
 *
 * The challenge is what binds an attestation to one registration attempt: the
 * app attests its key over the SHA-256 of a value the server chose, so a
 * captured registration cannot be replayed. One Durable Object per challenge
 * value makes "exactly once" atomic: consume is a single-threaded
 * test-and-set, and a spent or expired challenge never passes again.
 */
import type { DurableObjectNamespaceLike, Env } from "./env.ts";

/** A challenge is good for one registration attempt within this window. */
export const CHALLENGE_TTL_MS = 10 * 60 * 1000;

/** Injectable seam: issue a fresh challenge, consume it exactly once. */
export interface ChallengeStore {
  issue(): Promise<string>;
  consume(challenge: string): Promise<boolean>;
}

interface StorageLike {
  get(key: string): Promise<unknown>;
  put(key: string, value: unknown): Promise<void>;
}

interface StateLike {
  storage: StorageLike;
}

type ChallengeRequest =
  | { action: "issue"; now: number }
  | { action: "consume"; now: number };

/** One instance per challenge value (idFromName). Classic fetch-based DO. */
export class AttestChallenge {
  storage: StorageLike;

  constructor(state: StateLike) {
    this.storage = state.storage;
  }

  async fetch(request: Request): Promise<Response> {
    const { action, now } = (await request.json()) as ChallengeRequest;
    if (action === "issue") {
      await this.storage.put("issuedAt", now);
      return Response.json({ ok: true });
    }
    const issuedAt = (await this.storage.get("issuedAt")) as number | undefined;
    const used = ((await this.storage.get("used")) ?? false) as boolean;
    if (issuedAt == null || used || now - issuedAt > CHALLENGE_TTL_MS) {
      return Response.json({ ok: false });
    }
    await this.storage.put("used", true);
    return Response.json({ ok: true });
  }
}

/** Wrap the ATTEST_CHALLENGES binding, or null when it is not configured. */
export function challengeStoreFromEnv(env: Env): ChallengeStore | null {
  const ns: DurableObjectNamespaceLike | undefined = env.ATTEST_CHALLENGES;
  if (!ns) return null;
  const call = async (challenge: string, action: "issue" | "consume") => {
    const stub = ns.get(ns.idFromName(challenge));
    const res = await stub.fetch("https://challenge/", {
      method: "POST",
      body: JSON.stringify({ action, now: Date.now() }),
    });
    const { ok } = (await res.json()) as { ok: boolean };
    return ok;
  };
  return {
    async issue() {
      const challenge = crypto.randomUUID();
      await call(challenge, "issue");
      return challenge;
    },
    consume: (challenge) => call(challenge, "consume"),
  };
}

attestation.ts then follows Apple’s documented procedure: verify the certificate chain to the Apple App Attestation Root CA, recompute the nonce as the SHA-256 of the authenticator data and the client data hash and find it in the credential certificate’s reserved extension (OID 1.2.840.113635.100.8.2), check that the key identifier equals the SHA-256 of the credential public key, then check the authenticator data binds to your App ID with a counter of zero and the App Attest aaguid. Only after all of that does the public key land in the key record.

attestation.ts Raw Download
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
/**
 * App Attest one-time registration: verify the attestation object and store
 * the hardware-backed public key.
 *
 * The app asks the Worker for a challenge, calls
 * DCAppAttestService.attestKey over its SHA-256, and POSTs the result here.
 * The handler consumes the challenge exactly once (challenge.ts), then the
 * verifier follows Apple's documented procedure: check the certificate chain
 * to the Apple App Attestation Root CA, the nonce Apple embeds in the
 * credential certificate, the key identifier, and the authenticator data.
 * Only then does the public key land in the key record (attestkey.ts), where
 * appattest.ts looks it up on every request.
 */
import type { AppAttestConfig } from "./appattest.ts";
import type { AttestKeyStore } from "./attestkey.ts";
import type { ChallengeStore } from "./challenge.ts";
import { verifyChainToAnchor } from "./applejws.ts";
import {
  base64ToBytes,
  bytesEqual,
  bytesToBase64,
  concatBytes,
} from "./bytes.ts";

export interface AttestationInput {
  keyId: string;
  /** Base64 CBOR attestation object from DCAppAttestService.attestKey. */
  attestation: string;
  /** SHA-256 of the challenge the key was attested over. */
  clientDataHash: ArrayBuffer;
}

export type AttestationConfig = AppAttestConfig & {
  /** The Apple App Attestation Root CA certificate (PEM or base64 DER). */
  attestRootCa: string;
};

/** Resolves with the verified public key (SPKI base64); throws otherwise. */
export type AttestationVerifier = (
  input: AttestationInput,
  cfg: AttestationConfig,
) => Promise<{ spkiBase64: string }>;

/** Apple's marker for the App Attest authenticator in authData. */
const AAGUIDS = ["appattest\0\0\0\0\0\0\0", "appattestdevelop"];
/** The credential-certificate extension Apple reserves for the nonce. */
const NONCE_EXTENSION_OID = "1.2.840.113635.100.8.2";

export const verifyAttestation: AttestationVerifier = async (input, cfg) => {
  const cbor = await import("cbor-x");
  const decoded = cbor.decode(base64ToBytes(input.attestation)) as {
    fmt?: string;
    attStmt?: { x5c?: Uint8Array[] };
    authData?: Uint8Array;
  };
  if (decoded.fmt !== "apple-appattest") {
    throw new Error("not an App Attest attestation");
  }
  const x5c = decoded.attStmt?.x5c;
  const authData = decoded.authData;
  if (!Array.isArray(x5c) || !authData || authData.length < 55) {
    throw new Error("malformed attestation object");
  }

  // 1. The chain: credCert <- intermediate, anchored to the configured Apple
  //    App Attestation Root CA. The x5c omits the root, which is why the
  //    anchor is a certificate rather than a fingerprint.
  const certs = await verifyChainToAnchor(x5c, cfg.attestRootCa);
  const credCert = certs[0];

  // 2. The nonce: SHA-256(authData || clientDataHash) must appear in the
  //    credential certificate's reserved extension. This binds the
  //    certificate Apple issued to this exact attestation.
  const nonce = new Uint8Array(
    await crypto.subtle.digest(
      "SHA-256",
      concatBytes(authData, new Uint8Array(input.clientDataHash)),
    ),
  );
  const ext = credCert.getExtension(NONCE_EXTENSION_OID);
  if (!ext || !containsOctetString(new Uint8Array(ext.value), nonce)) {
    throw new Error("attestation nonce mismatch");
  }

  // 3. The key identifier: SHA-256 of the credential public key (the
  //    uncompressed P-256 point) must equal the keyId the app submitted.
  const spki = new Uint8Array(credCert.publicKey.rawData);
  const point = spki.slice(spki.length - 65);
  if (spki.length < 65 || point[0] !== 0x04) {
    throw new Error("unexpected public key format");
  }
  const keyIdHash = new Uint8Array(await crypto.subtle.digest("SHA-256", point));
  if (!bytesEqual(keyIdHash, base64ToBytes(input.keyId))) {
    throw new Error("key identifier does not match the attested key");
  }

  // 4. The authenticator data: bound to this App ID, counter at zero, the
  //    App Attest aaguid, and a credentialId equal to the key identifier.
  const appId = `${cfg.teamId}.${cfg.bundleId}`;
  const expectedRpId = new Uint8Array(
    await crypto.subtle.digest("SHA-256", new TextEncoder().encode(appId)),
  );
  if (!bytesEqual(authData.slice(0, 32), expectedRpId)) {
    throw new Error("attestation rpId does not match this app");
  }
  const counter = new DataView(
    authData.buffer,
    authData.byteOffset + 33,
    4,
  ).getUint32(0);
  if (counter !== 0) {
    throw new Error("attestation counter must be zero");
  }
  const aaguid = new TextDecoder().decode(authData.slice(37, 53));
  if (!AAGUIDS.includes(aaguid)) {
    throw new Error("unexpected attestation aaguid");
  }
  const credIdLen = (authData[53] << 8) | authData[54];
  if (authData.length < 55 + credIdLen) {
    throw new Error("malformed attestation object");
  }
  const credentialId = authData.slice(55, 55 + credIdLen);
  if (!bytesEqual(credentialId, base64ToBytes(input.keyId))) {
    throw new Error("credentialId does not match the key identifier");
  }

  return { spkiBase64: bytesToBase64(spki) };
};

export async function handleRegistration(
  request: Request,
  cfg: AttestationConfig,
  verifier: AttestationVerifier,
  keys: AttestKeyStore,
  challenges: ChallengeStore,
): Promise<Response> {
  let body: { keyId?: unknown; attestation?: unknown; challenge?: unknown };
  try {
    body = (await request.json()) as typeof body;
  } catch {
    return new Response("malformed registration", { status: 400 });
  }
  const { keyId, attestation, challenge } = body;
  if (
    typeof keyId !== "string" ||
    typeof attestation !== "string" ||
    typeof challenge !== "string"
  ) {
    return new Response("malformed registration", { status: 400 });
  }

  // The challenge is consumed before verification, so one issued challenge
  // buys one attempt; a failed attempt starts over with a fresh challenge.
  if (!(await challenges.consume(challenge))) {
    return new Response("unknown or used challenge", { status: 401 });
  }

  const clientDataHash = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(challenge),
  );
  try {
    const { spkiBase64 } = await verifier(
      { keyId, attestation, clientDataHash },
      cfg,
    );
    await keys.putPublicKey(keyId, spkiBase64);
  } catch {
    return new Response("invalid attestation", { status: 401 });
  }
  return new Response(null, { status: 204 });
}

/**
 * Scan DER for an OCTET STRING holding exactly `needle`. Apple wraps the
 * nonce as a single tagged octet string inside a small sequence; scanning for
 * the tagged value avoids a full ASN.1 parser for one field.
 */
function containsOctetString(der: Uint8Array, needle: Uint8Array): boolean {
  for (let i = 0; i + 2 + needle.length <= der.length; i++) {
    if (
      der[i] === 0x04 &&
      der[i + 1] === needle.length &&
      bytesEqual(der.slice(i + 2, i + 2 + needle.length), needle)
    ) {
      return true;
    }
  }
  return false;
}

App Attest: verifying every request #

appattest.ts is the second act, run on every call. It looks up the registered public key by keyId, decodes the assertion, confirms rpIdHash binds to your App ID, verifies the P-256 signature over the authenticator data and the client data hash, and advances the signature counter. Two details earn a callout. Apple emits DER-encoded ECDSA signatures while WebCrypto verifies the raw form, so the verifier converts with derToRaw; skip that conversion and every honest assertion fails. And the counter moves only after a valid signature, so a forgery can never burn a counter value.

appattest.ts Raw Download
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/**
 * App Attest assertion verification, the per-request half.
 *
 * StoreKit answers "is this user entitled?" App Attest answers "is this even
 * my app talking?" The app attests a hardware-backed key once (attestation.ts
 * verifies it and stores the public key), then signs an assertion over each
 * request. This file verifies that per-request assertion:
 *
 *   1. look up the registered public key for keyId,
 *   2. CBOR-decode the assertion into { signature, authenticatorData },
 *   3. check rpIdHash === SHA-256("<teamId>.<bundleId>"),
 *   4. verify the ES256 signature over (authenticatorData || clientDataHash),
 *   5. check the signature counter is strictly increasing (replay defense),
 *      and only after a valid signature, so forgeries never burn a counter.
 *
 * The verifier is injectable, mirroring storekit.ts, so tests run offline
 * with fakes in place of a real device assertion and a stored public key.
 */
import type { Env } from "./env.ts";
import type { AttestKeyStore } from "./attestkey.ts";
import { base64ToBytes, bytesEqual, concatBytes } from "./bytes.ts";

export interface AppAttestConfig {
  teamId: string;
  bundleId: string;
}

export interface AppAttestAssertion {
  keyId: string;
  /** Base64 CBOR assertion produced by DCAppAttestService.generateAssertion. */
  assertion: string;
  /** SHA-256 of the exact request payload the client signed over. */
  clientDataHash: ArrayBuffer;
}

/** Resolves if the assertion is authentic; throws otherwise. */
export type AppAttestVerifier = (
  assertion: AppAttestAssertion,
  cfg: AppAttestConfig,
  keys: AttestKeyStore,
) => Promise<void>;

/** App Attest is "configured" only when both identity values are present. */
export function appAttestConfig(env: Env): AppAttestConfig | null {
  if (!env.APP_ATTEST_TEAM_ID || !env.APP_ATTEST_BUNDLE_ID) return null;
  return {
    teamId: env.APP_ATTEST_TEAM_ID,
    bundleId: env.APP_ATTEST_BUNDLE_ID,
  };
}

export const verifyAppAttestAssertion: AppAttestVerifier = async (
  assertion,
  cfg,
  keys,
) => {
  if (!assertion.keyId || !assertion.assertion) {
    throw new Error("missing App Attest assertion");
  }

  // 1. The registered key. An unregistered keyId is untrusted, full stop.
  const spkiBase64 = await keys.getPublicKey(assertion.keyId);
  if (!spkiBase64) {
    throw new Error("unregistered App Attest key");
  }

  // 2. Decode. The bytes are untrusted, so guard lengths before slicing.
  const cbor = await import("cbor-x");
  const decoded = cbor.decode(base64ToBytes(assertion.assertion)) as {
    authenticatorData?: Uint8Array;
    signature?: Uint8Array;
  };
  const authData = decoded.authenticatorData;
  const signature = decoded.signature;
  if (!authData || !signature || authData.length < 37) {
    throw new Error("malformed App Attest assertion");
  }

  // 3. The assertion must bind to this App ID.
  const rpIdHash = authData.slice(0, 32);
  const appId = `${cfg.teamId}.${cfg.bundleId}`;
  const expected = new Uint8Array(
    await crypto.subtle.digest("SHA-256", new TextEncoder().encode(appId)),
  );
  if (!bytesEqual(rpIdHash, expected)) {
    throw new Error("App Attest rpId does not match this app");
  }

  // 4. The signature: ES256 over (authenticatorData || clientDataHash) with
  //    the registered key. Apple emits DER-encoded ECDSA signatures and
  //    WebCrypto verifies the raw r||s form, so convert first; skipping that
  //    step makes every verification fail and is a classic landmine.
  const key = await crypto.subtle.importKey(
    "spki",
    base64ToBytes(spkiBase64),
    { name: "ECDSA", namedCurve: "P-256" },
    false,
    ["verify"],
  );
  const message = concatBytes(authData, new Uint8Array(assertion.clientDataHash));
  const ok = await crypto.subtle.verify(
    { name: "ECDSA", hash: "SHA-256" },
    key,
    derToRaw(signature),
    message,
  );
  if (!ok) {
    throw new Error("App Attest signature verification failed");
  }

  // 5. The counter, last: only a valid signature may advance it.
  const counter = new DataView(
    authData.buffer,
    authData.byteOffset + 33,
    4,
  ).getUint32(0);
  if (!(await keys.bump(assertion.keyId, counter))) {
    throw new Error("App Attest assertion replayed");
  }
};

/**
 * Convert a DER ECDSA-Sig-Value (SEQUENCE of two INTEGERs) to the raw 64-byte
 * r||s form WebCrypto expects for P-256.
 */
function derToRaw(der: Uint8Array): Uint8Array {
  if (der[0] !== 0x30) throw new Error("not a DER signature");
  let offset = der[1] & 0x80 ? 2 + (der[1] & 0x7f) : 2;
  const readInteger = (): Uint8Array => {
    if (der[offset] !== 0x02) throw new Error("not a DER signature");
    const len = der[offset + 1];
    const start = offset + 2;
    if (!Number.isInteger(len) || start + len > der.length) {
      throw new Error("not a DER signature");
    }
    offset = start + len;
    let bytes = der.slice(start, offset);
    // Strip the sign padding, then left-pad back to 32 bytes.
    while (bytes.length > 32 && bytes[0] === 0) bytes = bytes.slice(1);
    if (bytes.length > 32) throw new Error("not a P-256 signature");
    const out = new Uint8Array(32);
    out.set(bytes, 32 - bytes.length);
    return out;
  };
  const r = readInteger();
  const s = readInteger();
  const raw = new Uint8Array(64);
  raw.set(r, 0);
  raw.set(s, 32);
  return raw;
}

The key and its counter live together in attestkey.ts: one Durable Object per keyId holds the registered public key and the last accepted counter, a strongly consistent, single-threaded instance with great distributed performance, so strictly increasing means what it says.

attestkey.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/**
 * The App Attest key record: one Durable Object per keyId holding the
 * registered public key and its assertion counter, plus the injectable store
 * the verifiers talk to.
 *
 * The key and the counter belong together: registration writes the public key
 * and starts the counter at zero, and every verified assertion advances the
 * counter through the same single-threaded instance. Durable Objects give
 * each keyId its own strongly consistent instance with great distributed
 * performance, so "strictly increasing" holds everywhere at once.
 */
import type { DurableObjectNamespaceLike, Env } from "./env.ts";

/** Injectable seam: registered keys and their assertion counters. */
export interface AttestKeyStore {
  getPublicKey(keyId: string): Promise<string | null>;
  putPublicKey(keyId: string, spkiBase64: string): Promise<void>;
  /** True only when `counter` is strictly greater than the stored value. */
  bump(keyId: string, counter: number): Promise<boolean>;
}

interface StorageLike {
  get(key: string): Promise<unknown>;
  put(key: string, value: unknown): Promise<void>;
}

interface StateLike {
  storage: StorageLike;
}

type AttestKeyRequest =
  | { action: "get" }
  | { action: "put"; spkiBase64: string }
  | { action: "bump"; counter: number };

/** One instance per keyId (idFromName). Classic fetch-based Durable Object. */
export class AttestKey {
  storage: StorageLike;

  constructor(state: StateLike) {
    this.storage = state.storage;
  }

  async fetch(request: Request): Promise<Response> {
    const body = (await request.json()) as AttestKeyRequest;
    if (body.action === "put") {
      await this.storage.put("spki", body.spkiBase64);
      await this.storage.put("counter", 0);
      return Response.json({ ok: true });
    }
    if (body.action === "get") {
      const spki = (await this.storage.get("spki")) ?? null;
      return Response.json({ spki });
    }
    const last = ((await this.storage.get("counter")) ?? 0) as number;
    if (!Number.isInteger(body.counter) || body.counter <= last) {
      return Response.json({ ok: false });
    }
    await this.storage.put("counter", body.counter);
    return Response.json({ ok: true });
  }
}

/** Wrap the ATTEST_KEYS binding, or null when it is not configured. */
export function attestKeyStoreFromEnv(env: Env): AttestKeyStore | null {
  const ns: DurableObjectNamespaceLike | undefined = env.ATTEST_KEYS;
  if (!ns) return null;
  const call = async (keyId: string, body: AttestKeyRequest) => {
    const stub = ns.get(ns.idFromName(keyId));
    const res = await stub.fetch("https://attest-key/", {
      method: "POST",
      body: JSON.stringify(body),
    });
    return res.json();
  };
  return {
    async getPublicKey(keyId) {
      const { spki } = (await call(keyId, { action: "get" })) as {
        spki: string | null;
      };
      return spki;
    },
    async putPublicKey(keyId, spkiBase64) {
      await call(keyId, { action: "put", spkiBase64 });
    },
    async bump(keyId, counter) {
      const { ok } = (await call(keyId, { action: "bump", counter })) as {
        ok: boolean;
      };
      return ok;
    },
  };
}

Spend controls #

The gates decide who may call. limits.ts decides what a call may cost, and it mirrors entitlement.ts: a pure function, no crypto, no storage. It allowlists model, requires and caps max_tokens, and hands the worker the number to debit. A request with a malformed shape is a 400; a request for a model you did not approve is a 403.

limits.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/**
 * The spend decision: a pure function, no crypto and no network, mirroring
 * entitlement.ts. The gates decide who may call; this decides what a call may
 * cost: which model, how many tokens per request, and (via the worker and the
 * budget store) how many tokens per caller per day.
 */
import { parseList } from "./entitlement.ts";
import type { Env } from "./env.ts";

export interface SpendConfig {
  allowedModels: Set<string> | null; // null = any model
  maxTokensLimit: number | null; // per-request cap
  dailyTokenBudget: number | null; // per-caller daily budget
}

/** Spend controls are active when any of the three knobs is set. */
export function spendConfig(env: Env): SpendConfig | null {
  const models = parseList(env.ALLOWED_MODELS);
  const allowedModels = models.size > 0 ? models : null;
  const maxTokensLimit = parsePositiveInt(env.MAX_TOKENS_LIMIT);
  const dailyTokenBudget = parsePositiveInt(env.DAILY_TOKEN_BUDGET);
  if (!allowedModels && maxTokensLimit == null && dailyTokenBudget == null) {
    return null;
  }
  return { allowedModels, maxTokensLimit, dailyTokenBudget };
}

export type SpendResult =
  | { ok: true; maxTokens: number }
  | { ok: false; status: 400 | 403; reason: string };

/** Reject oversized bodies before parsing JSON. */
export const MAX_BODY_BYTES = 1_048_576;

/**
 * Decide whether the parsed request body may be forwarded. A malformed shape
 * is 400; a policy denial is 403. Budget exhaustion is 429, decided in the
 * worker, because it needs the store.
 */
export function evaluateSpend(body: unknown, cfg: SpendConfig): SpendResult {
  if (typeof body !== "object" || body === null || Array.isArray(body)) {
    return { ok: false, status: 400, reason: "body_not_json_object" };
  }
  const { model, max_tokens: maxTokens } = body as {
    model?: unknown;
    max_tokens?: unknown;
  };
  if (cfg.allowedModels) {
    if (typeof model !== "string" || !cfg.allowedModels.has(model)) {
      return { ok: false, status: 403, reason: "model_not_allowed" };
    }
  }
  // A cap can only be enforced, and a budget can only debit, what the request
  // declares, so max_tokens is mandatory whenever either is configured.
  if (cfg.maxTokensLimit != null || cfg.dailyTokenBudget != null) {
    if (
      typeof maxTokens !== "number" ||
      !Number.isInteger(maxTokens) ||
      maxTokens <= 0
    ) {
      return { ok: false, status: 400, reason: "max_tokens_required" };
    }
    if (cfg.maxTokensLimit != null && maxTokens > cfg.maxTokensLimit) {
      return { ok: false, status: 400, reason: "max_tokens_exceeds_limit" };
    }
    return { ok: true, maxTokens };
  }
  return { ok: true, maxTokens: 0 };
}

function parsePositiveInt(value: string | undefined): number | null {
  if (!value) return null;
  const n = Number(value);
  return Number.isInteger(n) && n > 0 ? n : null;
}

budget.ts is the meter. Each caller identity, the purchase’s originalTransactionId or the assertion’s keyId, gets one Durable Object holding a UTC-day window, so the debit is atomic and the budget never over-deducts. The debit is the request’s declared max_tokens, taken before forwarding. That choice is deliberate: it bounds the worst case with no stream parsing, because no response can produce more output than was debited.

budget.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/**
 * The per-caller daily token budget: a Durable Object plus the injectable
 * store the worker talks to.
 *
 * A Durable Object gives each caller identity one strongly consistent,
 * single-threaded instance with great distributed performance, so
 * debit-then-store is atomic and the budget holds everywhere at once.
 *
 * The debit is the request's declared max_tokens, taken before forwarding.
 * Simple and worst-case-safe: the caller can never be billed more output than
 * was debited. The honest trade is in the walkthrough's closing section.
 */
import type { DurableObjectNamespaceLike, Env } from "./env.ts";

/** Injectable seam: the worker depends on this, tests inject an in-memory fake. */
export interface BudgetStore {
  debit(
    callerId: string,
    tokens: number,
    limit: number,
  ): Promise<{ ok: boolean; remaining: number }>;
}

/** The slice of DurableObjectState the class uses; tests fake it with a Map. */
interface StorageLike {
  get(key: string): Promise<unknown>;
  put(key: string, value: unknown): Promise<void>;
}

interface StateLike {
  storage: StorageLike;
}

interface DebitRequest {
  tokens: number;
  limit: number;
  day: string; // UTC date, "YYYY-MM-DD"; a new day resets the window
}

/**
 * One instance per caller identity (idFromName). Classic fetch-based Durable
 * Object: no cloudflare:workers import, so node:test can instantiate it
 * directly with a fake storage.
 */
export class TokenBudget {
  storage: StorageLike;

  constructor(state: StateLike) {
    this.storage = state.storage;
  }

  async fetch(request: Request): Promise<Response> {
    const { tokens, limit, day } = (await request.json()) as DebitRequest;
    const window = ((await this.storage.get("window")) ?? {
      day,
      used: 0,
    }) as { day: string; used: number };
    const used = window.day === day ? window.used : 0;
    if (used + tokens > limit) {
      // Refused requests are not debited; the caller keeps what remains.
      return Response.json({ ok: false, remaining: Math.max(0, limit - used) });
    }
    await this.storage.put("window", { day, used: used + tokens });
    return Response.json({ ok: true, remaining: limit - used - tokens });
  }
}

/** Wrap the TOKEN_BUDGET binding, or null when it is not configured. */
export function budgetStoreFromEnv(env: Env): BudgetStore | null {
  const ns: DurableObjectNamespaceLike | undefined = env.TOKEN_BUDGET;
  if (!ns) return null;
  return {
    async debit(callerId, tokens, limit) {
      const stub = ns.get(ns.idFromName(callerId));
      const day = new Date().toISOString().slice(0, 10);
      const res = await stub.fetch("https://budget/debit", {
        method: "POST",
        body: JSON.stringify({ tokens, limit, day }),
      });
      return (await res.json()) as { ok: boolean; remaining: number };
    },
  };
}

The proxy #

proxy.ts holds two deliberate narrowings. resolveUpstreamPath rejects any path you did not opt into, so an authorized caller reaches the Messages API and nothing else your key can touch. buildUpstreamHeaders constructs a fresh header set with exactly three entries, the content type, the Anthropic version, and the injected key, so cookies and arbitrary anthropic-beta flags the client sent never ride along. When the spend gate has read the body, the same text forwards verbatim with the content type pinned to the JSON that was inspected; otherwise the body streams through untouched. Both paths drop the query string, because the allowlist matched only the pathname.

proxy.ts Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
 * The proxy: decide which upstream path is allowed, build a clean header set,
 * and forward to Anthropic.
 *
 * Two deliberate narrowings live here. First, an entitled caller may reach only
 * the upstream paths you opt into (default "/v1/messages"), not any Anthropic
 * endpoint your key can touch. Second, the upstream request carries only the
 * three headers it needs, not whatever the client sent (cookies, arbitrary
 * anthropic-beta flags, the app token).
 */
import {
  ANTHROPIC_BASE,
  DEFAULT_ANTHROPIC_VERSION,
  type Env,
} from "./env.ts";

/** Resolve the upstream path, or null when the requested path is not allowed. */
export function resolveUpstreamPath(
  pathname: string,
  allowed: Set<string>,
): string | null {
  return allowed.has(pathname) ? pathname : null;
}

/**
 * Build the only headers the upstream call needs. Nothing the client sent is
 * forwarded by default: we copy content-type, set the Anthropic version, and
 * inject the key.
 */
export function buildUpstreamHeaders(request: Request, env: Env): Headers {
  const headers = new Headers();
  const contentType = request.headers.get("content-type");
  if (contentType) headers.set("content-type", contentType);
  headers.set(
    "anthropic-version",
    env.ANTHROPIC_VERSION ?? DEFAULT_ANTHROPIC_VERSION,
  );
  headers.set("x-api-key", env.ANTHROPIC_API_KEY);
  return headers;
}

/**
 * Forward the (already authorized) request to Anthropic. When the spend gate
 * has read the body, the same text is re-sent verbatim via `body`; otherwise
 * the request body streams through untouched. The query string is dropped on
 * purpose: resolveUpstreamPath matched only the pathname, so nothing unvetted
 * rides along.
 */
export function forwardToAnthropic(
  request: Request,
  env: Env,
  upstreamPath: string,
  body?: string,
): Promise<Response> {
  const headers = buildUpstreamHeaders(request, env);
  // The spend gate parsed the body as JSON, so pin the declared type to what
  // was actually inspected.
  if (body !== undefined) headers.set("content-type", "application/json");
  return fetch(ANTHROPIC_BASE + upstreamPath, {
    method: request.method,
    headers,
    body: body ?? request.body,
    // @ts-expect-error duplex is required when streaming a request body
    duplex: "half",
  });
}

The worker #

worker.ts wires it together, and the routing order is the security argument. The three service routes come first: Apple’s notifications authenticate by signature, and a registering app has no assertion yet when it asks for a challenge or submits an attestation, so none of them sits behind the gates, and each exists only when its configuration and stores do. Configuration then fails closed: no gate at all is a 500 rather than an open relay for your API bill, an App Attest gate without its key store is a 500, a budget without its Durable Object is a 500. The gates run next, the worker consults the revocation record, and only a fully authorized request gets its body parsed for the spend decision. The budget keys on a verified identity only, the transaction’s originalTransactionId or the keyId an assertion just proved; a client-chosen header is not an identity. Every failed check returns 401, 403, 413, or 429 and never reaches Anthropic.

worker.ts Raw Download
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
/**
 * Backend-for-Frontend (BFF) Cloudflare Worker, an in-app-purchase- and/or
 * App-Attest-gated Claude proxy with server-side revocation and spend
 * controls.
 *
 * Flow:
 *   1. Service routes first: Apple's server notifications land on
 *      /apple/notifications (writing the revocation record), the app fetches
 *      a one-time registration challenge from /app-attest/challenge, and
 *      registers its App Attest key on /app-attest/register. Each exists only
 *      when configured.
 *   2. For everything else, every configured gate must pass: StoreKit proves
 *      the caller is entitled (and the revocation record proves Apple has not
 *      taken it back), App Attest proves the caller is a genuine instance of
 *      your app. If neither gate is configured the Worker refuses to run
 *      (500).
 *   3. Spend controls run after the gates: model allowlist, max_tokens cap,
 *      and a per-caller daily token budget debited in a Durable Object.
 *   4. 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,
  CHALLENGE_PATH,
  DEFAULT_UPSTREAM_PATHS,
  KEY_ID_HEADER,
  NOTIFICATIONS_PATH,
  REGISTER_PATH,
  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 {
  handleRegistration,
  verifyAttestation,
  type AttestationVerifier,
} from "./attestation.ts";
import {
  attestKeyStoreFromEnv,
  type AttestKeyStore,
} from "./attestkey.ts";
import {
  challengeStoreFromEnv,
  type ChallengeStore,
} from "./challenge.ts";
import {
  handleNotification,
  verifyNotification,
  type NotificationVerifier,
} from "./notifications.ts";
import {
  revocationStoreFromEnv,
  type RevocationStore,
} from "./revocation.ts";
import { budgetStoreFromEnv, type BudgetStore } from "./budget.ts";
import { evaluateSpend, MAX_BODY_BYTES, spendConfig } from "./limits.ts";
import { forwardToAnthropic, resolveUpstreamPath } from "./proxy.ts";
import type { JWSTransaction } from "./transaction.ts";

// wrangler resolves the Durable Object classes from `main`.
export { TokenBudget } from "./budget.ts";
export { AttestKey } from "./attestkey.ts";
export { AttestChallenge } from "./challenge.ts";
export { RevocationRecord } from "./revocation.ts";

/**
 * Verifiers and stores are injectable so tests exercise the flow without real
 * crypto or Durable Objects.
 */
export interface Handlers {
  verifyTransaction: TransactionVerifier;
  verifyAppAttest: AppAttestVerifier;
  verifyAttestation: AttestationVerifier;
  verifyNotification: NotificationVerifier;
  budgetStore: (env: Env) => BudgetStore | null;
  attestKeyStore: (env: Env) => AttestKeyStore | null;
  challengeStore: (env: Env) => ChallengeStore | null;
  revocationStore: (env: Env) => RevocationStore | null;
}

const defaultHandlers: Handlers = {
  verifyTransaction: verifyTransactionJWS,
  verifyAppAttest: verifyAppAttestAssertion,
  verifyAttestation,
  verifyNotification,
  budgetStore: budgetStoreFromEnv,
  attestKeyStore: attestKeyStoreFromEnv,
  challengeStore: challengeStoreFromEnv,
  revocationStore: revocationStoreFromEnv,
};

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<Response> {
  if (request.method !== "POST") {
    return new Response("method not allowed", { status: 405 });
  }

  const incoming = new URL(request.url);

  // Service routes come before the gates: Apple authenticates by signature,
  // and a registering app has no assertion yet. Each route exists only when
  // its configuration and stores do.
  if (incoming.pathname === NOTIFICATIONS_PATH) {
    const revocations = handlers.revocationStore(env);
    if (!env.APPLE_ROOT_CA_SHA256 || !revocations) {
      return json(404, { error: "not found" });
    }
    return handleNotification(
      request,
      env.APPLE_ROOT_CA_SHA256,
      handlers.verifyNotification,
      revocations,
    );
  }
  if (incoming.pathname === CHALLENGE_PATH) {
    const cfg = appAttestConfig(env);
    const challenges = handlers.challengeStore(env);
    if (!cfg || !challenges) {
      return json(404, { error: "not found" });
    }
    return json(200, { challenge: await challenges.issue() });
  }
  if (incoming.pathname === REGISTER_PATH) {
    const cfg = appAttestConfig(env);
    const keys = handlers.attestKeyStore(env);
    const challenges = handlers.challengeStore(env);
    if (!cfg || !env.APP_ATTEST_ROOT_CA || !keys || !challenges) {
      return json(404, { error: "not found" });
    }
    return handleRegistration(
      request,
      { ...cfg, attestRootCa: env.APP_ATTEST_ROOT_CA },
      handlers.verifyAttestation,
      keys,
      challenges,
    );
  }

  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" });
  }

  // Partial configuration fails closed too: a gate or a budget that cannot
  // reach its store must refuse, not wave callers through.
  const keys = handlers.attestKeyStore(env);
  if (appAttest && !keys) {
    return json(500, { error: "App Attest key store not configured" });
  }
  const spend = spendConfig(env);
  const budget = handlers.budgetStore(env);
  if (spend?.dailyTokenBudget != null && !budget) {
    return json(500, { error: "token budget store not configured" });
  }

  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);
  let txn: JWSTransaction | null = null;

  // StoreKit gate (if configured): verify the signed transaction, then decide.
  if (storeKit) {
    if (!jws) {
      return json(401, { error: "missing in-app purchase transaction" });
    }
    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 });
    }

    // Server-side revocation: the JWS in hand can predate a refund. The
    // record holds what Apple has told us since, via the notifications webhook.
    const revocations = handlers.revocationStore(env);
    if (
      revocations &&
      txn.originalTransactionId &&
      (await revocations.isRevoked(txn.originalTransactionId))
    ) {
      return json(403, { error: "entitlement_revoked" });
    }
  }

  // App Attest gate (if configured): prove the caller is a genuine app instance.
  const keyId = request.headers.get(KEY_ID_HEADER);
  if (appAttest) {
    const assertion = request.headers.get(ASSERTION_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,
        keys!,
      );
    } catch {
      return json(401, { error: "invalid App Attest assertion" });
    }
  }

  // Spend controls, after every auth gate: an unauthorized caller never gets
  // its body parsed. The body is read once, inspected, then forwarded verbatim.
  if (spend) {
    const bodyText = await request.text();
    // A cheap guard before JSON.parse; code units approximate bytes well
    // enough to stop a deliberately huge body.
    if (bodyText.length > MAX_BODY_BYTES) {
      return json(413, { error: "body_too_large" });
    }
    let body: unknown;
    try {
      body = JSON.parse(bodyText);
    } catch {
      return json(400, { error: "body_not_json" });
    }
    const verdict = evaluateSpend(body, spend);
    if (!verdict.ok) {
      return json(verdict.status, { error: verdict.reason });
    }
    if (spend.dailyTokenBudget != null) {
      // The budget keys on a verified identity only: the purchase's
      // originalTransactionId, or the keyId an assertion just proved. A
      // client-chosen header is not an identity.
      const verifiedKeyId = appAttest ? keyId : null;
      const callerId = txn?.originalTransactionId ?? verifiedKeyId;
      if (!callerId) {
        return json(403, { error: "caller_identity_required" });
      }
      const debit = await budget!.debit(
        callerId,
        verdict.maxTokens,
        spend.dailyTokenBudget,
      );
      if (!debit.ok) {
        return json(429, {
          error: "daily_token_budget_exhausted",
          remaining: debit.remaining,
        });
      }
    }
    return forwardToAnthropic(request, env, upstreamPath, bodyText);
  }

  // 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<Response> {
    return handleRequest(request, env);
  },
};

The tests exercise the real decision-and-proxy path with fakes in place of Apple’s certificate chain, a real device, and Durable Objects. An allowed bundle in Production passes, a sandbox transaction is turned away, a revoked purchase is refused even when its pre-refund JWS still verifies, a registration without a server-issued challenge never reaches the verifier, a replayed counter is rejected, a disallowed model never leaves the building, the request past the budget gets a 429 while the upstream sees exactly one call, and an entitled caller’s request reaches Anthropic carrying only the three headers it should. The suite also drives all four Durable Object classes directly against a fake storage.

worker.test.ts Raw Download
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
/**
 * Tests for the IAP- and App-Attest-gated Claude BFF Worker.
 *
 * Run offline, zero installs:
 *   node --experimental-strip-types --test worker.test.ts
 *
 * The crypto verifiers and the stores are injectable, so these tests exercise
 * the real decision-and-proxy logic, the part that decides who gets through,
 * what they may spend, and what gets forwarded, without Apple's certificate
 * chain, a real device, or Durable Objects.
 */
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseList, evaluateEntitlement } from "./entitlement.ts";
import { evaluateSpend, spendConfig } from "./limits.ts";
import { TokenBudget, type BudgetStore } from "./budget.ts";
import { AttestKey, type AttestKeyStore } from "./attestkey.ts";
import {
  AttestChallenge,
  CHALLENGE_TTL_MS,
  type ChallengeStore,
} from "./challenge.ts";
import {
  RevocationRecord,
  revocationFromNotification,
  type RevocationStore,
} from "./revocation.ts";
import type { JWSTransaction } from "./transaction.ts";
import type { TransactionVerifier } from "./storekit.ts";
import type { AppAttestVerifier } from "./appattest.ts";
import { handleRequest, type Handlers } from "./worker.ts";
import type { Env } from "./env.ts";

const baseCfg = () => ({
  allowedBundleIds: new Set(["com.example.app"]),
  allowedProductIds: null as Set<string> | null,
  now: 1_000_000,
});

const prod = (extra: Partial<JWSTransaction> = {}): JWSTransaction => ({
  bundleId: "com.example.app",
  environment: "Production",
  ...extra,
});

test("parseList trims and drops empties", () => {
  assert.deepEqual([...parseList(" a , b ,, c ")], ["a", "b", "c"]);
  assert.equal(parseList(undefined).size, 0);
});

test("entitlement: allowed bundle + Production + future expiry passes", () => {
  assert.deepEqual(
    evaluateEntitlement(prod({ expiresDate: 2_000_000 }), baseCfg()),
    { ok: true },
  );
});

test("entitlement: non-subscription (no expiresDate) passes", () => {
  assert.deepEqual(evaluateEntitlement(prod(), baseCfg()), { ok: true });
});

test("entitlement: sandbox environment is rejected (403)", () => {
  const txn = prod({ environment: "Sandbox", expiresDate: 2_000_000 });
  assert.deepEqual(evaluateEntitlement(txn, baseCfg()), {
    ok: false,
    status: 403,
    reason: "environment_not_production",
  });
});

test("entitlement: missing environment is rejected (403)", () => {
  const txn: JWSTransaction = { bundleId: "com.example.app" };
  assert.deepEqual(evaluateEntitlement(txn, baseCfg()), {
    ok: false,
    status: 403,
    reason: "environment_not_production",
  });
});

test("entitlement: wrong bundle is rejected (403)", () => {
  const txn = prod({ bundleId: "com.evil.app", expiresDate: 2_000_000 });
  assert.deepEqual(evaluateEntitlement(txn, baseCfg()), {
    ok: false,
    status: 403,
    reason: "bundle_id_not_allowed",
  });
});

test("entitlement: expired subscription is rejected (403)", () => {
  const txn = prod({ expiresDate: 500_000 });
  assert.deepEqual(evaluateEntitlement(txn, baseCfg()), {
    ok: false,
    status: 403,
    reason: "entitlement_expired",
  });
});

test("entitlement: revoked purchase is rejected (403)", () => {
  const txn = prod({ expiresDate: 2_000_000, revocationDate: 600_000 });
  assert.deepEqual(evaluateEntitlement(txn, baseCfg()), {
    ok: false,
    status: 403,
    reason: "entitlement_revoked",
  });
});

test("entitlement: product allowlist is enforced when set", () => {
  const cfg = { ...baseCfg(), allowedProductIds: new Set(["pro.monthly"]) };
  assert.equal(
    evaluateEntitlement(prod({ productId: "pro.monthly" }), cfg).ok,
    true,
  );
  assert.deepEqual(evaluateEntitlement(prod({ productId: "free.tier" }), cfg), {
    ok: false,
    status: 403,
    reason: "product_id_not_allowed",
  });
});

// --- spend decision (pure) ---

const spendCfg = (over = {}) => ({
  allowedModels: new Set(["claude-opus-4-8"]) as Set<string> | null,
  maxTokensLimit: 1024 as number | null,
  dailyTokenBudget: null as number | null,
  ...over,
});

test("spendConfig: null when no knob is set, active when any is", () => {
  assert.equal(spendConfig({ ANTHROPIC_API_KEY: "x" }), null);
  const cfg = spendConfig({ ANTHROPIC_API_KEY: "x", ALLOWED_MODELS: "m" });
  assert.deepEqual(cfg, {
    allowedModels: new Set(["m"]),
    maxTokensLimit: null,
    dailyTokenBudget: null,
  });
});

test("spend: allowed model under the cap passes", () => {
  assert.deepEqual(
    evaluateSpend({ model: "claude-opus-4-8", max_tokens: 256 }, spendCfg()),
    { ok: true, maxTokens: 256 },
  );
});

test("spend: model outside the allowlist is rejected (403)", () => {
  assert.deepEqual(
    evaluateSpend({ model: "claude-haiku-4-5", max_tokens: 256 }, spendCfg()),
    { ok: false, status: 403, reason: "model_not_allowed" },
  );
  assert.equal(evaluateSpend({ max_tokens: 256 }, spendCfg()).ok, false);
});

test("spend: max_tokens is required when a cap or budget is configured", () => {
  for (const max_tokens of [undefined, 0, -5, 1.5, "16"]) {
    assert.deepEqual(
      evaluateSpend({ model: "claude-opus-4-8", max_tokens }, spendCfg()),
      { ok: false, status: 400, reason: "max_tokens_required" },
    );
  }
  const budgetOnly = spendCfg({
    allowedModels: null,
    maxTokensLimit: null,
    dailyTokenBudget: 1000,
  });
  assert.equal(evaluateSpend({}, budgetOnly).ok, false);
});

test("spend: max_tokens over the cap is rejected (400)", () => {
  assert.deepEqual(
    evaluateSpend({ model: "claude-opus-4-8", max_tokens: 2048 }, spendCfg()),
    { ok: false, status: 400, reason: "max_tokens_exceeds_limit" },
  );
});

test("spend: non-object body is rejected (400)", () => {
  for (const body of [null, [], "x", 7]) {
    assert.deepEqual(evaluateSpend(body, spendCfg()), {
      ok: false,
      status: 400,
      reason: "body_not_json_object",
    });
  }
});

// --- Durable Objects, tested directly with a fake storage ---

function fakeState() {
  const map = new Map<string, unknown>();
  return {
    storage: {
      get: async (key: string) => map.get(key),
      put: async (key: string, value: unknown) => {
        map.set(key, value);
      },
    },
  };
}

test("TokenBudget: debits, refuses over-limit without debiting, resets daily", async () => {
  const dobj = new TokenBudget(fakeState());
  const debit = async (tokens: number, day: string) => {
    const res = await dobj.fetch(
      new Request("https://budget/debit", {
        method: "POST",
        body: JSON.stringify({ tokens, limit: 1000, day }),
      }),
    );
    return res.json();
  };
  assert.deepEqual(await debit(600, "2026-06-12"), { ok: true, remaining: 400 });
  assert.deepEqual(await debit(600, "2026-06-12"), { ok: false, remaining: 400 });
  // The refused request was not debited; a smaller one still fits.
  assert.deepEqual(await debit(400, "2026-06-12"), { ok: true, remaining: 0 });
  // A new day resets the window.
  assert.deepEqual(await debit(600, "2026-06-13"), { ok: true, remaining: 400 });
});

test("AttestKey: stores the key, accepts only strictly increasing counters", async () => {
  const dobj = new AttestKey(fakeState());
  const call = async (body: unknown) => {
    const res = await dobj.fetch(
      new Request("https://attest-key/", {
        method: "POST",
        body: JSON.stringify(body),
      }),
    );
    return res.json();
  };
  assert.deepEqual(await call({ action: "get" }), { spki: null });
  assert.deepEqual(await call({ action: "put", spkiBase64: "spki" }), {
    ok: true,
  });
  assert.deepEqual(await call({ action: "get" }), { spki: "spki" });
  assert.deepEqual(await call({ action: "bump", counter: 1 }), { ok: true });
  assert.deepEqual(await call({ action: "bump", counter: 2 }), { ok: true });
  assert.deepEqual(await call({ action: "bump", counter: 2 }), { ok: false }); // replay
  assert.deepEqual(await call({ action: "bump", counter: 1 }), { ok: false }); // rollback
});

test("AttestChallenge: consumed exactly once, within its window", async () => {
  const dobj = new AttestChallenge(fakeState());
  const call = async (action: "issue" | "consume", now: number) => {
    const res = await dobj.fetch(
      new Request("https://challenge/", {
        method: "POST",
        body: JSON.stringify({ action, now }),
      }),
    );
    return res.json();
  };
  // Consuming before issuing fails.
  assert.deepEqual(await call("consume", 1_000), { ok: false });
  await call("issue", 1_000);
  assert.deepEqual(await call("consume", 2_000), { ok: true });
  assert.deepEqual(await call("consume", 3_000), { ok: false }); // spent

  // A fresh challenge past its window fails.
  const stale = new AttestChallenge(fakeState());
  const staleCall = async (action: "issue" | "consume", now: number) => {
    const res = await stale.fetch(
      new Request("https://challenge/", {
        method: "POST",
        body: JSON.stringify({ action, now }),
      }),
    );
    return res.json();
  };
  await staleCall("issue", 1_000);
  assert.deepEqual(await staleCall("consume", 1_000 + CHALLENGE_TTL_MS + 1), {
    ok: false,
  });
});

test("RevocationRecord: revoke sets, unrevoke clears", async () => {
  const dobj = new RevocationRecord(fakeState());
  const call = async (body: unknown) => {
    const res = await dobj.fetch(
      new Request("https://revocation/", {
        method: "POST",
        body: JSON.stringify(body),
      }),
    );
    return res.json();
  };
  assert.deepEqual(await call({ action: "get" }), { revoked: false });
  assert.deepEqual(await call({ action: "revoke", reason: "REFUND" }), {
    revoked: true,
  });
  assert.deepEqual(await call({ action: "get" }), { revoked: true });
  assert.deepEqual(await call({ action: "unrevoke" }), { revoked: false });
  assert.deepEqual(await call({ action: "get" }), { revoked: false });
});

// --- revocation mapping (pure) ---

test("revocation: REFUND and REVOKE revoke, REFUND_REVERSED restores", () => {
  const txn = { originalTransactionId: "txn-1" };
  assert.deepEqual(revocationFromNotification("REFUND", txn), {
    kind: "revoke",
    originalTransactionId: "txn-1",
    reason: "REFUND",
  });
  assert.deepEqual(revocationFromNotification("REVOKE", txn), {
    kind: "revoke",
    originalTransactionId: "txn-1",
    reason: "REVOKE",
  });
  assert.deepEqual(revocationFromNotification("REFUND_REVERSED", txn), {
    kind: "unrevoke",
    originalTransactionId: "txn-1",
  });
  assert.equal(revocationFromNotification("SUBSCRIBED", txn), null);
  assert.equal(revocationFromNotification("TEST", txn), null);
  assert.equal(revocationFromNotification("REFUND", {}), null);
});

// --- handleRequest integration ---

const storeKitEnv: Env = {
  ANTHROPIC_API_KEY: "sk-ant-PLACEHOLDER",
  ALLOWED_BUNDLE_IDS: "com.example.app",
  APPLE_ROOT_CA_SHA256: "00",
};

function messagesRequest(
  headers: Record<string, string>,
  path = "/v1/messages",
  body: unknown = { model: "claude-opus-4-8", max_tokens: 16, messages: [] },
): Request {
  return new Request(`https://bff.example.com${path}`, {
    method: "POST",
    headers: { "content-type": "application/json", ...headers },
    body: JSON.stringify(body),
  });
}

const passTxn: TransactionVerifier = async () =>
  prod({ expiresDate: Date.now() + 60_000 });

function memoryKeyStore(
  initial: Record<string, string> = {},
): AttestKeyStore & { map: Map<string, string> } {
  const map = new Map(Object.entries(initial));
  const counters = new Map<string, number>();
  return {
    map,
    getPublicKey: async (keyId) => map.get(keyId) ?? null,
    putPublicKey: async (keyId, spkiBase64) => {
      map.set(keyId, spkiBase64);
      counters.set(keyId, 0);
    },
    bump: async (keyId, counter) => {
      const last = counters.get(keyId) ?? 0;
      if (counter <= last) return false;
      counters.set(keyId, counter);
      return true;
    },
  };
}

function memoryChallengeStore(): ChallengeStore & { issued: Set<string> } {
  const issued = new Set<string>();
  let n = 0;
  return {
    issued,
    issue: async () => {
      const challenge = `challenge-${++n}`;
      issued.add(challenge);
      return challenge;
    },
    consume: async (challenge) => issued.delete(challenge),
  };
}

function memoryRevocationStore(
  revoked: string[] = [],
): RevocationStore & { map: Map<string, string> } {
  const map = new Map(revoked.map((id) => [id, "REFUND"]));
  return {
    map,
    isRevoked: async (id) => map.has(id),
    revoke: async (id, reason) => {
      map.set(id, reason);
    },
    unrevoke: async (id) => {
      map.delete(id);
    },
  };
}

function memoryBudgetStore(): BudgetStore & { seen: string[] } {
  const used = new Map<string, number>();
  const seen: string[] = [];
  return {
    seen,
    debit: async (callerId, tokens, limit) => {
      seen.push(callerId);
      const u = used.get(callerId) ?? 0;
      if (u + tokens > limit) {
        return { ok: false, remaining: Math.max(0, limit - u) };
      }
      used.set(callerId, u + tokens);
      return { ok: true, remaining: limit - u - tokens };
    },
  };
}

function handlers(over: Partial<Handlers> = {}): Handlers {
  const keys = memoryKeyStore({ "key-1": "spki" });
  const challenges = memoryChallengeStore();
  return {
    verifyTransaction: passTxn,
    verifyAppAttest: async () => {},
    verifyAttestation: async () => ({ spkiBase64: "spki" }),
    verifyNotification: async () => ({
      notificationType: "TEST",
      transaction: {},
    }),
    budgetStore: () => null,
    attestKeyStore: () => keys,
    challengeStore: () => challenges,
    revocationStore: () => null,
    ...over,
  };
}

/** Mock global fetch for one call, capturing the upstream request. */
async function withMockedFetch<T>(
  run: () => Promise<T>,
): Promise<{ result: T; calls: Array<{ url: string; init: RequestInit }> }> {
  const calls: Array<{ url: string; init: RequestInit }> = [];
  const orig = globalThis.fetch;
  globalThis.fetch = (async (url: unknown, init: RequestInit) => {
    calls.push({ url: String(url), init });
    return new Response(JSON.stringify({ ok: true }), { status: 200 });
  }) as typeof fetch;
  try {
    return { result: await run(), calls };
  } finally {
    globalThis.fetch = orig;
  }
}

test("handleRequest: no gate configured → 500, no upstream call", async () => {
  const res = await handleRequest(
    messagesRequest({ "X-IAP-Transaction": "ok" }),
    { ANTHROPIC_API_KEY: "sk-ant-PLACEHOLDER" },
    handlers(),
  );
  assert.equal(res.status, 500);
});

test("handleRequest: disallowed upstream path → 403", async () => {
  const res = await handleRequest(
    messagesRequest({ "X-IAP-Transaction": "ok" }, "/v1/models"),
    storeKitEnv,
    handlers(),
  );
  assert.equal(res.status, 403);
});

test("handleRequest: missing transaction header → 401, verifier never called", async () => {
  let called = false;
  const res = await handleRequest(
    messagesRequest({}),
    storeKitEnv,
    handlers({
      verifyTransaction: async () => {
        called = true;
        return prod();
      },
    }),
  );
  assert.equal(res.status, 401);
  assert.equal(called, false);
});

test("handleRequest: invalid JWS → 401, no upstream call", async () => {
  const { result, calls } = await withMockedFetch(() =>
    handleRequest(
      messagesRequest({ "X-IAP-Transaction": "bad" }),
      storeKitEnv,
      handlers({
        verifyTransaction: async () => {
          throw new Error("bad signature");
        },
      }),
    ),
  );
  assert.equal(result.status, 401);
  assert.equal(calls.length, 0);
});

test("handleRequest: unentitled caller → 403, no upstream call", async () => {
  const { result, calls } = await withMockedFetch(() =>
    handleRequest(
      messagesRequest({ "X-IAP-Transaction": "ok" }),
      storeKitEnv,
      handlers({
        verifyTransaction: async () => prod({ bundleId: "com.evil.app" }),
      }),
    ),
  );
  assert.equal(result.status, 403);
  assert.equal(calls.length, 0);
});

test("handleRequest: entitled caller → forwards with a clean, injected header set", async () => {
  const { result, calls } = await withMockedFetch(() =>
    handleRequest(
      messagesRequest({
        "X-IAP-Transaction": "ok",
        cookie: "session=abc",
        "anthropic-beta": "evil-flag",
      }),
      storeKitEnv,
      handlers(),
    ),
  );
  assert.equal(result.status, 200);
  assert.equal(calls[0].url, "https://api.anthropic.com/v1/messages");
  const h = new Headers(calls[0].init.headers);
  assert.equal(h.get("x-api-key"), "sk-ant-PLACEHOLDER");
  assert.equal(h.get("anthropic-version"), "2023-06-01");
  assert.equal(h.get("content-type"), "application/json");
  // Nothing the client sent leaks upstream.
  assert.equal(h.get("X-IAP-Transaction"), null);
  assert.equal(h.get("cookie"), null);
  assert.equal(h.get("anthropic-beta"), null);
});

test("handleRequest: revoked originalTransactionId → 403, no upstream call", async () => {
  const revocations = memoryRevocationStore(["txn-9"]);
  const { result, calls } = await withMockedFetch(() =>
    handleRequest(
      messagesRequest({ "X-IAP-Transaction": "ok" }),
      storeKitEnv,
      handlers({
        verifyTransaction: async () =>
          prod({
            expiresDate: Date.now() + 60_000,
            originalTransactionId: "txn-9",
          }),
        revocationStore: () => revocations,
      }),
    ),
  );
  assert.equal(result.status, 403);
  assert.equal(calls.length, 0);
});

// --- App Attest gate ---

const appAttestEnv: Env = {
  ANTHROPIC_API_KEY: "sk-ant-PLACEHOLDER",
  APP_ATTEST_TEAM_ID: "ABCDE12345",
  APP_ATTEST_BUNDLE_ID: "com.example.app",
};

test("handleRequest: App Attest only, missing assertion → 401", async () => {
  const res = await handleRequest(messagesRequest({}), appAttestEnv, handlers());
  assert.equal(res.status, 401);
});

test("handleRequest: App Attest only, valid assertion → forwards", async () => {
  const { result, calls } = await withMockedFetch(() =>
    handleRequest(
      messagesRequest({
        "X-App-Attest-Assertion": "ok",
        "X-App-Attest-Key-Id": "key-1",
      }),
      appAttestEnv,
      handlers(),
    ),
  );
  assert.equal(result.status, 200);
  assert.equal(calls[0].url, "https://api.anthropic.com/v1/messages");
});

test("handleRequest: App Attest failure → 401, no upstream call", async () => {
  const failAttest: AppAttestVerifier = async () => {
    throw new Error("bad assertion");
  };
  const { result, calls } = await withMockedFetch(() =>
    handleRequest(
      messagesRequest({
        "X-App-Attest-Assertion": "bad",
        "X-App-Attest-Key-Id": "key-1",
      }),
      appAttestEnv,
      handlers({ verifyAppAttest: failAttest }),
    ),
  );
  assert.equal(result.status, 401);
  assert.equal(calls.length, 0);
});

test("handleRequest: App Attest configured without stores → 500", async () => {
  const res = await handleRequest(
    messagesRequest({
      "X-App-Attest-Assertion": "ok",
      "X-App-Attest-Key-Id": "key-1",
    }),
    appAttestEnv,
    handlers({ attestKeyStore: () => null }),
  );
  assert.equal(res.status, 500);
});

test("handleRequest: replayed counter (via the injected key store) → 401", async () => {
  // The fake verifier exercises the store wiring: it consumes the counter the
  // way the real verifier does, after every other check.
  const verifyAppAttest: AppAttestVerifier = async (a, _cfg, keys) => {
    if (!(await keys.bump(a.keyId, 7))) {
      throw new Error("replayed");
    }
  };
  const h = handlers({ verifyAppAttest });
  const send = () =>
    handleRequest(
      messagesRequest({
        "X-App-Attest-Assertion": "ok",
        "X-App-Attest-Key-Id": "key-1",
      }),
      appAttestEnv,
      h,
    );
  const { result: first } = await withMockedFetch(send);
  assert.equal(first.status, 200);
  const { result: replay, calls } = await withMockedFetch(send);
  assert.equal(replay.status, 401);
  assert.equal(calls.length, 0);
});

test("handleRequest: both gates configured, request must clear both", async () => {
  const bothEnv: Env = { ...storeKitEnv, ...appAttestEnv };
  const failAttest: AppAttestVerifier = async () => {
    throw new Error("bad assertion");
  };
  const { result: rejected } = await withMockedFetch(() =>
    handleRequest(
      messagesRequest({
        "X-IAP-Transaction": "ok",
        "X-App-Attest-Assertion": "bad",
        "X-App-Attest-Key-Id": "key-1",
      }),
      bothEnv,
      handlers({ verifyAppAttest: failAttest }),
    ),
  );
  assert.equal(rejected.status, 401);

  const { result: ok } = await withMockedFetch(() =>
    handleRequest(
      messagesRequest({
        "X-IAP-Transaction": "ok",
        "X-App-Attest-Assertion": "ok",
        "X-App-Attest-Key-Id": "key-1",
      }),
      bothEnv,
      handlers(),
    ),
  );
  assert.equal(ok.status, 200);
});

// --- spend controls through the worker ---

const spendEnv: Env = {
  ...storeKitEnv,
  ALLOWED_MODELS: "claude-opus-4-8",
  MAX_TOKENS_LIMIT: "1024",
};

test("handleRequest: disallowed model → 403, no upstream call", async () => {
  const { result, calls } = await withMockedFetch(() =>
    handleRequest(
      messagesRequest({ "X-IAP-Transaction": "ok" }, "/v1/messages", {
        model: "claude-haiku-4-5",
        max_tokens: 16,
        messages: [],
      }),
      spendEnv,
      handlers(),
    ),
  );
  assert.equal(result.status, 403);
  assert.equal(calls.length, 0);
});

test("handleRequest: max_tokens over the cap or missing → 400", async () => {
  for (const body of [
    { model: "claude-opus-4-8", max_tokens: 4096, messages: [] },
    { model: "claude-opus-4-8", messages: [] },
  ]) {
    const { result, calls } = await withMockedFetch(() =>
      handleRequest(
        messagesRequest({ "X-IAP-Transaction": "ok" }, "/v1/messages", body),
        spendEnv,
        handlers(),
      ),
    );
    assert.equal(result.status, 400);
    assert.equal(calls.length, 0);
  }
});

test("handleRequest: spend gate forwards the body it inspected, verbatim", async () => {
  const body = { model: "claude-opus-4-8", max_tokens: 16, messages: [] };
  const { calls } = await withMockedFetch(() =>
    handleRequest(
      messagesRequest({ "X-IAP-Transaction": "ok" }, "/v1/messages", body),
      spendEnv,
      handlers(),
    ),
  );
  assert.equal(calls[0].init.body, JSON.stringify(body));
});

test("handleRequest: budget configured without a store → 500", async () => {
  const res = await handleRequest(
    messagesRequest({ "X-IAP-Transaction": "ok" }),
    { ...storeKitEnv, DAILY_TOKEN_BUDGET: "100" },
    handlers(),
  );
  assert.equal(res.status, 500);
});

test("handleRequest: daily budget exhausts → 200 then 429, one upstream call", async () => {
  const budget = memoryBudgetStore();
  const env: Env = { ...storeKitEnv, DAILY_TOKEN_BUDGET: "100" };
  const h = handlers({
    verifyTransaction: async () =>
      prod({
        expiresDate: Date.now() + 60_000,
        originalTransactionId: "txn-1",
      }),
    budgetStore: () => budget,
  });
  const send = () =>
    handleRequest(
      messagesRequest({ "X-IAP-Transaction": "ok" }, "/v1/messages", {
        model: "claude-opus-4-8",
        max_tokens: 80,
        messages: [],
      }),
      env,
      h,
    );
  const { result: first, calls: firstCalls } = await withMockedFetch(send);
  assert.equal(first.status, 200);
  assert.equal(firstCalls.length, 1);
  const { result: second, calls: secondCalls } = await withMockedFetch(send);
  assert.equal(second.status, 429);
  assert.equal(secondCalls.length, 0);
  assert.deepEqual(await second.json(), {
    error: "daily_token_budget_exhausted",
    remaining: 20,
  });
});

test("handleRequest: budget keys on originalTransactionId, or verified keyId", async () => {
  // StoreKit: the purchase identity wins.
  const viaStoreKit = memoryBudgetStore();
  await withMockedFetch(() =>
    handleRequest(
      messagesRequest({ "X-IAP-Transaction": "ok" }),
      { ...storeKitEnv, DAILY_TOKEN_BUDGET: "100" },
      handlers({
        verifyTransaction: async () =>
          prod({
            expiresDate: Date.now() + 60_000,
            originalTransactionId: "txn-1",
          }),
        budgetStore: () => viaStoreKit,
      }),
    ),
  );
  assert.deepEqual(viaStoreKit.seen, ["txn-1"]);

  // App Attest only: the device key the assertion just proved.
  const viaKeyId = memoryBudgetStore();
  await withMockedFetch(() =>
    handleRequest(
      messagesRequest({
        "X-App-Attest-Assertion": "ok",
        "X-App-Attest-Key-Id": "key-1",
      }),
      { ...appAttestEnv, DAILY_TOKEN_BUDGET: "100" },
      handlers({ budgetStore: () => viaKeyId }),
    ),
  );
  assert.deepEqual(viaKeyId.seen, ["key-1"]);

  // StoreKit without an originalTransactionId yields no verified identity.
  const noIdentity = memoryBudgetStore();
  const { result } = await withMockedFetch(() =>
    handleRequest(
      messagesRequest({ "X-IAP-Transaction": "ok" }),
      { ...storeKitEnv, DAILY_TOKEN_BUDGET: "100" },
      handlers({ budgetStore: () => noIdentity }),
    ),
  );
  assert.equal(result.status, 403);
  assert.deepEqual(noIdentity.seen, []);
});

// --- service routes ---

test("notifications route: verified REFUND lands in the revocation store", async () => {
  const revocations = memoryRevocationStore();
  const res = await handleRequest(
    messagesRequest({}, "/apple/notifications", { signedPayload: "jws" }),
    storeKitEnv,
    handlers({
      verifyNotification: async () => ({
        notificationType: "REFUND",
        transaction: { originalTransactionId: "txn-7" },
      }),
      revocationStore: () => revocations,
    }),
  );
  assert.equal(res.status, 200);
  assert.equal(await revocations.isRevoked("txn-7"), true);
});

test("notifications route: bad signature → 401, store untouched", async () => {
  const revocations = memoryRevocationStore();
  const res = await handleRequest(
    messagesRequest({}, "/apple/notifications", { signedPayload: "forged" }),
    storeKitEnv,
    handlers({
      verifyNotification: async () => {
        throw new Error("bad signature");
      },
      revocationStore: () => revocations,
    }),
  );
  assert.equal(res.status, 401);
  assert.equal(revocations.map.size, 0);
});

test("notifications route: ignored type and malformed body", async () => {
  const revocations = memoryRevocationStore();
  const h = handlers({ revocationStore: () => revocations });
  const ignored = await handleRequest(
    messagesRequest({}, "/apple/notifications", { signedPayload: "jws" }),
    storeKitEnv,
    h, // default fake verifier returns TEST
  );
  assert.equal(ignored.status, 200);
  assert.equal(revocations.map.size, 0);

  const malformed = await handleRequest(
    new Request("https://bff.example.com/apple/notifications", {
      method: "POST",
      body: "not json",
    }),
    storeKitEnv,
    h,
  );
  assert.equal(malformed.status, 400);
});

test("notifications route: 404 without a revocation store", async () => {
  const res = await handleRequest(
    messagesRequest({}, "/apple/notifications", { signedPayload: "jws" }),
    storeKitEnv,
    handlers(), // revocationStore: () => null
  );
  assert.equal(res.status, 404);
});

test("challenge route: issues a one-time challenge, 404 when unconfigured", async () => {
  const challenges = memoryChallengeStore();
  const issued = await handleRequest(
    messagesRequest({}, "/app-attest/challenge", {}),
    appAttestEnv,
    handlers({ challengeStore: () => challenges }),
  );
  assert.equal(issued.status, 200);
  const { challenge } = (await issued.json()) as { challenge: string };
  assert.equal(challenges.issued.has(challenge), true);

  const unconfigured = await handleRequest(
    messagesRequest({}, "/app-attest/challenge", {}),
    storeKitEnv, // App Attest not configured
    handlers(),
  );
  assert.equal(unconfigured.status, 404);
});

test("registration route: issued challenge + verified attestation → 204, once", async () => {
  const keys = memoryKeyStore();
  const challenges = memoryChallengeStore();
  const env: Env = { ...appAttestEnv, APP_ATTEST_ROOT_CA: "PEM_PLACEHOLDER" };
  const h = handlers({
    attestKeyStore: () => keys,
    challengeStore: () => challenges,
  });
  const challenge = await challenges.issue();
  const register = () =>
    handleRequest(
      messagesRequest({}, "/app-attest/register", {
        keyId: "key-2",
        attestation: "cbor",
        challenge,
      }),
      env,
      h,
    );
  const res = await register();
  assert.equal(res.status, 204);
  assert.equal(keys.map.get("key-2"), "spki");
  // The same challenge never registers twice.
  const replay = await register();
  assert.equal(replay.status, 401);
});

test("registration route: unissued challenge → 401, verifier never called", async () => {
  const keys = memoryKeyStore();
  let called = false;
  const env: Env = { ...appAttestEnv, APP_ATTEST_ROOT_CA: "PEM_PLACEHOLDER" };
  const res = await handleRequest(
    messagesRequest({}, "/app-attest/register", {
      keyId: "key-2",
      attestation: "cbor",
      challenge: "never-issued",
    }),
    env,
    handlers({
      attestKeyStore: () => keys,
      verifyAttestation: async () => {
        called = true;
        return { spkiBase64: "spki" };
      },
    }),
  );
  assert.equal(res.status, 401);
  assert.equal(called, false);
  assert.equal(keys.map.size, 0);
});

test("registration route: invalid attestation → 401, key not stored", async () => {
  const keys = memoryKeyStore();
  const challenges = memoryChallengeStore();
  const env: Env = { ...appAttestEnv, APP_ATTEST_ROOT_CA: "PEM_PLACEHOLDER" };
  const challenge = await challenges.issue();
  const res = await handleRequest(
    messagesRequest({}, "/app-attest/register", {
      keyId: "key-2",
      attestation: "forged",
      challenge,
    }),
    env,
    handlers({
      attestKeyStore: () => keys,
      challengeStore: () => challenges,
      verifyAttestation: async () => {
        throw new Error("bad attestation");
      },
    }),
  );
  assert.equal(res.status, 401);
  assert.equal(keys.map.size, 0);
});

test("registration route: missing fields → 400, unconfigured → 404", async () => {
  const env: Env = { ...appAttestEnv, APP_ATTEST_ROOT_CA: "PEM_PLACEHOLDER" };
  const missing = await handleRequest(
    messagesRequest({}, "/app-attest/register", { keyId: "key-2" }),
    env,
    handlers(),
  );
  assert.equal(missing.status, 400);

  const unconfigured = await handleRequest(
    messagesRequest({}, "/app-attest/register", {
      keyId: "key-2",
      attestation: "cbor",
      challenge: "uuid",
    }),
    appAttestEnv, // no APP_ATTEST_ROOT_CA
    handlers(),
  );
  assert.equal(unconfigured.status, 404);
});

The Swift client #

On the device, the app holds no key. It pulls the current entitlement’s signed representation, builds an App Attest assertion when the device supports it, and routes Claude through the BFF with .proxied auth. The client splits one type per file too: the namespace, the error, the entitlement fetch, the attestation helper, and the header assembly.

ClaudeBFF.swift Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Namespace for talking to Claude through a gated Backend-for-Frontend Worker.
//
// Point baseURL at your own Worker.
// Requires Xcode 27 / OS 27 betas and the ClaudeForFoundationModels package.
//
// A caseless enum is Swift's idiomatic namespace: it groups the static members
// across these files and cannot be instantiated (unlike a struct, which can).
// One concern per file: entitlement, App Attest, headers, and errors each live
// in their own extension.

import Foundation
import FoundationModels
import ClaudeForFoundationModels

enum ClaudeBFF {
    /// The BFF endpoint host. The Foundation Models client appends the Messages
    /// path, so the Worker sees "/v1/messages", the one upstream path it allows.
    static let baseURL = URL(string: "https://bff.example.com")!

    /// Headers the BFF reads each credential from.
    static let transactionHeader = "X-IAP-Transaction"
    static let assertionHeader = "X-App-Attest-Assertion"
    static let keyIdHeader = "X-App-Attest-Key-Id"

    /// The BFF's one-time App Attest key registration endpoints.
    static let challengePath = "app-attest/challenge"
    static let registerPath = "app-attest/register"

    /// Convenience: gather credentials, build a session, and respond.
    static func respond(to prompt: String) async throws -> String {
        let session = LanguageModelSession(model: try await model())
        let response = try await session.respond(to: prompt)
        return response.content
    }
}
Entitlement.swift Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// StoreKit entitlement: pull the current entitlement's signed representation.
//

import Foundation
import StoreKit

extension ClaudeBFF {
    /// The user's current entitlement as a signed JWS, if one is active.
    /// A free trial and a paid subscription both surface here while active, so
    /// the app does not distinguish them. The BFF makes the final decision and
    /// re-verifies the signature server-side.
    static func currentEntitlementJWS() async throws -> String {
        for await result in Transaction.currentEntitlements {
            // Forward the signed representation as-is. The device's own
            // verification is not the gate; the server's is.
            return result.jwsRepresentation
        }
        throw BFFError.noActiveEntitlement
    }
}
AppAttest.swift Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// App Attest: register a hardware-backed key once, then produce a per-request
// assertion proving this is a genuine, unmodified instance of the app on a
// real device.
//
// The key is generated and attested once; the attestation goes to the BFF's
// registration endpoint, which verifies it and stores the public key under
// keyId. Each request then carries an assertion the Worker verifies against
// that stored key.

import Foundation
import CryptoKit
import DeviceCheck

extension ClaudeBFF {
    struct Assertion {
        let keyId: String
        let assertion: String // base64
    }

    /// Produce an App Attest assertion over the given client data, or nil when
    /// App Attest is unavailable (Simulator, unsupported device).
    static func appAttestAssertion(clientData: String) async throws -> Assertion? {
        let service = DCAppAttestService.shared
        guard service.isSupported else { return nil }

        let keyId = try await attestedKeyId(service)
        let hash = Data(SHA256.hash(data: Data(clientData.utf8)))
        let assertion = try await service.generateAssertion(keyId, clientDataHash: hash)
        return Assertion(keyId: keyId, assertion: assertion.base64EncodedString())
    }

    private static let keyIdDefaultsKey = "ClaudeBFF.appAttestKeyId"

    private static func attestedKeyId(_ service: DCAppAttestService) async throws -> String {
        if let saved = UserDefaults.standard.string(forKey: keyIdDefaultsKey) {
            return saved
        }
        let keyId = try await service.generateKey()
        try await register(keyId, service)
        // Persist only after the BFF accepted the attestation, so a failed
        // registration retries with a fresh key on the next request.
        UserDefaults.standard.set(keyId, forKey: keyIdDefaultsKey)
        return keyId
    }

    /// One-time registration: fetch a server-issued challenge, attest the key
    /// over it, then hand the attestation to the BFF so it can verify the
    /// chain and store the public key. The BFF consumes each challenge
    /// exactly once.
    private static func register(_ keyId: String, _ service: DCAppAttestService) async throws {
        let challenge = try await fetchChallenge()
        let hash = Data(SHA256.hash(data: Data(challenge.utf8)))
        let attestation = try await service.attestKey(keyId, clientDataHash: hash)

        var request = URLRequest(url: baseURL.appending(path: registerPath))
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(
            registrationBody(
                keyId: keyId,
                attestation: attestation.base64EncodedString(),
                challenge: challenge
            )
        )
        let (_, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
            throw BFFError.registrationFailed
        }
    }

    /// Ask the BFF for a one-time registration challenge.
    private static func fetchChallenge() async throws -> String {
        var request = URLRequest(url: baseURL.appending(path: challengePath))
        request.httpMethod = "POST"
        let (data, response) = try await URLSession.shared.data(for: request)
        guard let http = response as? HTTPURLResponse, http.statusCode == 200,
              let body = try? JSONDecoder().decode([String: String].self, from: data),
              let challenge = body["challenge"] else {
            throw BFFError.registrationFailed
        }
        return challenge
    }

    /// Pure body assembly, testable without DeviceCheck or a network.
    static func registrationBody(
        keyId: String,
        attestation: String,
        challenge: String
    ) -> [String: String] {
        ["keyId": keyId, "attestation": attestation, "challenge": challenge]
    }
}

Registration is the one-time path inside that file: generate the key, fetch a challenge from the Worker, attest over its hash, POST the result to the registration endpoint, and persist the key identifier only after the BFF accepted it, so a failed registration retries with a fresh key instead of wedging the device on an unregistered one.

The header assembly is a pure function, so it is the one piece the client tests can exercise without StoreKit or a device. The assertion binds to the same request line the Worker reconstructs, so a lifted assertion cannot be replayed onto a different call. Note the client signs over a literal /v1/messages while the Worker binds whatever path it resolved; the two agree because the default allowlist holds that one path. Widen ALLOWED_UPSTREAM_PATHS and the client must sign the path it actually calls.

ProxyHeaders.swift Raw Download
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Header assembly and the proxied model. The app ships no API key; these
// headers are how the Worker authorizes the caller before injecting the real
// credential.
//

import Foundation
import ClaudeForFoundationModels

extension ClaudeBFF {
    /// Pure header assembly, so it can be tested without StoreKit or a device.
    /// The App Attest assertion is bound to the same request line the Worker
    /// reconstructs: "POST\n/v1/messages\n<jws>".
    static func headers(jws: String, attestation: Assertion?) -> [String: String] {
        var headers = [transactionHeader: jws]
        if let attestation {
            headers[assertionHeader] = attestation.assertion
            headers[keyIdHeader] = attestation.keyId
        }
        return headers
    }

    /// Gather the entitlement JWS and (when available) an App Attest assertion,
    /// then assemble the proxy headers.
    static func proxyHeaders() async throws -> [String: String] {
        let jws = try await currentEntitlementJWS()
        let clientData = "POST\n/v1/messages\n\(jws)"
        let attestation = try await appAttestAssertion(clientData: clientData)
        return headers(jws: jws, attestation: attestation)
    }

    /// A Claude model that routes through the BFF using `.proxied` auth.
    static func model(_ name: ClaudeModel = .opus4_8) async throws -> ClaudeLanguageModel {
        ClaudeLanguageModel(
            name: name,
            auth: .proxied(headers: try await proxyHeaders()),
            baseURL: baseURL
        )
    }
}
BFFError.swift Raw Download
1
2
3
4
5
6
7
8
9
// Errors surfaced by the ClaudeBFF helper.
//

import Foundation

enum BFFError: Error {
    case noActiveEntitlement
    case registrationFailed
}

What this does not do #

One trade is built in, and one ceiling no proxy can lift.

The meter counts requests, not usage. The budget debits the max_tokens a request declares. That bounds the worst case, but it counts no input tokens. Metering actual usage means reading the usage block from responses, including tailing streamed ones, and reconciling the difference. The account-level backstop still matters for the same reason: workspace spend limits and per-key budgets at Anthropic cap the blast radius no matter what the edge forwards.

Anonymous access has a ceiling. Everything this Worker authenticates is a pseudonym: a purchase receipt or a device key, never a person. That is the deal you accept when you expose an API you pay for without user accounts. You can prove the caller paid and prove the caller is your app, but you cannot tell two devices of one abuser apart, ban a person rather than a receipt, or answer who spent this with a name. The configured token budget is the right tool inside that deal, capping what any one pseudonym can cost you per day, and it is also the deal’s ceiling. When the feature is worth more than the ceiling, require authentication: Sign in with Apple or your own account system, so every request maps to an accountable user you can meter, throttle, and offboard by name.

The gate, the record, and the meter all run in one Worker at the edge. The moves beyond it, true-up metering and real accounts, change what you know about the caller, not where the secret lives.


Leave a comment

Preview

Comments are reviewed before publishing.