← Provider replacement walkthrough

IAPKit · server/api/commerce/routes.ts

Source snapshot · 2026-09-09

SHA-256 e8383e409de76264dfe04d3aaec00c57b7ae2e0204a83744cd653b8c10e3b4b1
1// The portable REST binding plus the GraphQL endpoint, mounted under2// /commerce/v1. Routes are registered from the generated HTTP manifest, so a3// route cannot exist that the contract does not declare; every adapter only4// parses transport, builds the auth context, validates against the generated5// schemas, calls the shared handler, and maps the outcome back.6 7import { Hono, type Context, type Next } from "hono";8import HTTP_BINDING from "openiap-commerce-protocol/generated/bindings/http-binding.json";9 10import {11  apiKeyValidationError,12  isPublishableApiKey,13  isSecretApiKey,14} from "../v1/middleware";15import {16  getRequestIp,17  multiAxisRateLimitMiddleware,18  sourceRateLimitMiddleware,19} from "../v1/rate-limit";20import {21  JsonBodyTooLargeError,22  readJsonBodyWithLimit,23} from "../v1/request-body";24import { ProtocolOperationError, protocolErrorStatus } from "./errors";25import { executeCommerceGraphql } from "./graphql";26import * as handlers from "./handlers";27import type { ProtocolContext } from "./handlers";28import { validateOperationInput } from "./validation";29 30const MAX_COMMERCE_BODY_BYTES = 32 * 1024;31const MOUNT_PREFIX = "/commerce/v1";32 33type CommerceVariables = {34  apiKey?: string;35  apiKeyHash?: string;36  commerceInput?: unknown;37  verifyCapacityRejected?: boolean;38};39type CommerceContext = Context<{ Variables: CommerceVariables }>;40 41// verifyPurchase's replay/in-flight admission lives in the shared handler42// (verificationAdmission), so both the REST route and the GraphQL resolver run43// through it — see server/api/commerce/handlers.ts.44 45const operationHandlers: Record<46  string,47  (context: ProtocolContext, input: never) => unknown48> = {49  providerCapabilities: () => handlers.providerCapabilities(),50  subscriptionStatus: (context, input) =>51    handlers.subscriptionStatus(context, input),52  entitlements: (context, input) => handlers.entitlements(context, input),53  verifyPurchase: (context, input) => handlers.verifyPurchase(context, input),54  bindPurchase: (context, input) => handlers.bindPurchase(context, input),55  eraseUser: (context, input) => handlers.eraseUser(context, input),56};57 58const GRAPHQL_PATH = `${MOUNT_PREFIX}/graphql`;59 60// The two bindings wrap the same protocol code in different envelopes61// (SPEC.md 8): REST as `{error:{code,message}}`, GraphQL as62// `{errors:[{message,extensions:{code}}]}`. Transport failures — rate limit,63// oversized body, unparseable body — must use the envelope of the binding64// they hit, or a GraphQL client sees a body with neither data nor errors.65function isGraphqlRequest(c: Context): boolean {66  return c.req.path.endsWith(GRAPHQL_PATH) || c.req.path === "/graphql";67}68 69function protocolError(70  c: Context,71  code: string,72  message: string,73  status = protocolErrorStatus(code),74  retryAfterSec?: number,75): Response {76  // A retry hint reaches REST as the standard Retry-After header and GraphQL77  // as retryAfterSec in the error extensions, so neither binding drops it.78  if (retryAfterSec !== undefined) {79    c.header("Retry-After", String(retryAfterSec));80  }81  if (isGraphqlRequest(c)) {82    // SPEC.md 7: every operation failure on the GraphQL binding is HTTP 20083    // with the code in errors[].extensions.code. This is the single status84    // policy for the binding — the outer rate limiter and the handler85    // admission both surface RATE_LIMITED at 200, never 429, so the endpoint86    // does not split its own contract.87    return c.json(88      {89        errors: [90          {91            message,92            extensions: {93              code,94              ...(retryAfterSec === undefined ? {} : { retryAfterSec }),95            },96          },97        ],98      },99      200,100    );101  }102  return c.json({ error: { code, message } }, status as 400);103}104 105function bearerApiKey(c: Context): string | null {106  const header = c.req.header("Authorization");107  if (!header) return null;108  const parts = header.trim().split(/\s+/);109  if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") return null;110  return apiKeyValidationError(parts[1]) ? null : parts[1];111}112 113const respondRateLimited = (114  c: Context,115  result: { retryAfterSec: number },116): Response =>117  // Passing retryAfterSec sets the Retry-After header on REST and puts118  // retryAfterSec into the GraphQL error extensions — the outer limiter must119  // carry the same machine-readable hint the per-operation admission does.120  protocolError(121    c,122    "RATE_LIMITED",123    `Too many requests. Retry after ${result.retryAfterSec}s.`,124    429,125    result.retryAfterSec,126  );127 128const app = new Hono<{ Variables: CommerceVariables }>();129 130// Unauthenticated requests (capabilities, credential-less GraphQL) still ride131// the bounded source-IP and process buckets.132app.use(133  "*",134  sourceRateLimitMiddleware({ respond: respondRateLimited }) as never,135);136 137const keyedRateLimit = multiAxisRateLimitMiddleware({138  respond: respondRateLimited,139});140const keyedRateLimitIfAuthed = async (c: CommerceContext, next: Next) =>141  c.var.apiKey ? keyedRateLimit(c as never, next) : next();142 143function commerceAuth(auth: string) {144  return async (c: CommerceContext, next: Next) => {145    const apiKey = bearerApiKey(c);146    if (auth !== "none") {147      // Fail close in the protocol envelope. The prefix check only148      // fast-fails an explicitly publishable key on a server operation;149      // the stored key classification stays authoritative in Convex, where150      // unknown and legacy formats fail closed as publishable.151      if (apiKey === null) {152        return protocolError(c, "UNAUTHORIZED", "A credential is required");153      }154      if (auth === "server" && isPublishableApiKey(apiKey)) {155        return protocolError(156          c,157          "FORBIDDEN",158          "This operation requires the server role",159        );160      }161    }162    if (apiKey !== null) {163      c.set("apiKey", apiKey);164      if (isSecretApiKey(apiKey)) {165        c.header("Cache-Control", "private, no-store");166      }167    }168    await next();169  };170}171 172// SPEC.md 5: server-role authorization precedes input validation. Runs after173// the rate limiters (an unauthenticated caller must not buy a Convex round174// trip per request unmetered) and before the body is parsed or validated.175const authoritativeServerAuth = async (c: CommerceContext, next: Next) => {176  try {177    await handlers.assertServerCredential({178      apiKey: c.var.apiKey ?? "",179      requestIp: getRequestIp(c),180    });181  } catch (error) {182    if (error instanceof ProtocolOperationError) {183      return protocolError(184        c,185        error.code,186        error.message,187        protocolErrorStatus(error.code),188        error.retryAfterSec,189      );190    }191    return protocolError(c, "INTERNAL_ERROR", "The operation failed");192  }193  await next();194};195 196for (const operation of HTTP_BINDING.operations) {197  const path = operation.path.slice(MOUNT_PREFIX.length);198  const run = operationHandlers[operation.name];199  if (!run) throw new Error(`No handler for operation ${operation.name}`);200 201  // Parse, size-cap, and schema-validate the input, then stash it so the202  // verify guards (which run after this) and the handler read one parsed body.203  const parseInput = async (c: CommerceContext, next: Next) => {204    let input: unknown = null;205    if (operation.input && operation.method === "GET") {206      input = Object.fromEntries(207        Object.entries(c.req.query()).filter(([, value]) => value !== ""),208      );209    } else if (operation.input) {210      try {211        input = await readJsonBodyWithLimit(212          c.req.raw,213          MAX_COMMERCE_BODY_BYTES,214          "Request body is too large",215        );216      } catch (error) {217        if (error instanceof JsonBodyTooLargeError) {218          return protocolError(219            c,220            "INVALID_REQUEST",221            "Request body is too large",222          );223        }224        return protocolError(c, "INVALID_REQUEST", "Body is not JSON");225      }226    }227    if (operation.input) {228      const invalid = validateOperationInput(operation.name, input);229      if (invalid) return protocolError(c, "INVALID_REQUEST", invalid);230    }231    c.set("commerceInput", input);232    await next();233  };234 235  const handler = async (c: CommerceContext) => {236    try {237      const context: ProtocolContext = {238        apiKey: c.var.apiKey ?? "",239        requestIp: getRequestIp(c),240      };241      const result = await run(context, c.get("commerceInput") as never);242      return c.json(result as object, operation.successStatus as 200);243    } catch (error) {244      if (error instanceof ProtocolOperationError) {245        return protocolError(246          c,247          error.code,248          error.message,249          protocolErrorStatus(error.code),250          error.retryAfterSec,251        );252      }253      console.error(254        "[commerce] %s failed errorClass=%s",255        operation.name,256        error instanceof Error ? error.name : typeof error,257      );258      return protocolError(c, "INTERNAL_ERROR", "The operation failed");259    }260  };261 262  if (operation.auth === "server") {263    app.on(264      operation.method,265      path,266      commerceAuth(operation.auth),267      keyedRateLimitIfAuthed,268      authoritativeServerAuth,269      parseInput,270      handler,271    );272  } else {273    app.on(274      operation.method,275      path,276      commerceAuth(operation.auth),277      keyedRateLimitIfAuthed,278      parseInput,279      handler,280    );281  }282}283 284// The GraphQL binding shares the same handlers and the same role rules;285// SPEC.md 7 keeps operation failures inside the GraphQL errors array, so the286// only role decision made here is which credential kind was presented.287app.post(288  "/graphql",289  commerceAuth("none"),290  keyedRateLimitIfAuthed,291  async (c) => {292    let payload: unknown;293    try {294      payload = await readJsonBodyWithLimit(295        c.req.raw,296        MAX_COMMERCE_BODY_BYTES,297        "Request body is too large",298      );299    } catch (error) {300      if (error instanceof JsonBodyTooLargeError) {301        return protocolError(c, "INVALID_REQUEST", "Request body is too large");302      }303      return protocolError(c, "INVALID_REQUEST", "Body is not JSON");304    }305 306    const apiKey = c.var.apiKey;307    const { status, body } = await executeCommerceGraphql(payload, {308      role:309        apiKey === undefined310          ? null311          : isPublishableApiKey(apiKey)312            ? "verification"313            : "server",314      apiKey,315      requestIp: getRequestIp(c),316    });317    return c.json(body as object, status);318  },319);320 321// Terminal: an unknown /commerce/v1 path is a protocol 404, never an SPA322// fallthrough.323app.all("*", (c) =>324  c.json({ error: { code: "NOT_FOUND", message: "Unknown operation" } }, 404),325);326 327export { app as commerceRoutes };328