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