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 "@hyodotdev/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("*", sourceRateLimitMiddleware({ respond: respondRateLimited }));133 134const keyedRateLimit = multiAxisRateLimitMiddleware({135 respond: respondRateLimited,136});137const keyedRateLimitIfAuthed = async (c: CommerceContext, next: Next) =>138 c.var.apiKey ? keyedRateLimit(c, next) : next();139 140function commerceAuth(auth: string) {141 return async (c: CommerceContext, next: Next) => {142 const apiKey = bearerApiKey(c);143 if (auth !== "none") {144 // Fail close in the protocol envelope. The prefix check only145 // fast-fails an explicitly publishable key on a server operation;146 // the stored key classification stays authoritative in Convex, where147 // unknown and legacy formats fail closed as publishable.148 if (apiKey === null) {149 return protocolError(c, "UNAUTHORIZED", "A credential is required");150 }151 if (auth === "server" && isPublishableApiKey(apiKey)) {152 return protocolError(153 c,154 "FORBIDDEN",155 "This operation requires the server role",156 );157 }158 }159 if (apiKey !== null) {160 c.set("apiKey", apiKey);161 if (isSecretApiKey(apiKey)) {162 c.header("Cache-Control", "private, no-store");163 }164 }165 await next();166 };167}168 169// SPEC.md 5: server-role authorization precedes input validation. Runs after170// the rate limiters (an unauthenticated caller must not buy a Convex round171// trip per request unmetered) and before the body is parsed or validated.172const authoritativeServerAuth = async (c: CommerceContext, next: Next) => {173 try {174 await handlers.assertServerCredential({175 apiKey: c.var.apiKey ?? "",176 requestIp: getRequestIp(c),177 });178 } catch (error) {179 if (error instanceof ProtocolOperationError) {180 return protocolError(181 c,182 error.code,183 error.message,184 protocolErrorStatus(error.code),185 error.retryAfterSec,186 );187 }188 return protocolError(c, "INTERNAL_ERROR", "The operation failed");189 }190 await next();191};192 193for (const operation of HTTP_BINDING.operations) {194 const path = operation.path.slice(MOUNT_PREFIX.length);195 const run = operationHandlers[operation.name];196 if (!run) throw new Error(`No handler for operation ${operation.name}`);197 198 // Parse, size-cap, and schema-validate the input, then stash it so the199 // verify guards (which run after this) and the handler read one parsed body.200 const parseInput = async (c: CommerceContext, next: Next) => {201 let input: unknown = null;202 if (operation.input && operation.method === "GET") {203 input = Object.fromEntries(204 Object.entries(c.req.query()).filter(([, value]) => value !== ""),205 );206 } else if (operation.input) {207 try {208 input = await readJsonBodyWithLimit(209 c.req.raw,210 MAX_COMMERCE_BODY_BYTES,211 "Request body is too large",212 );213 } catch (error) {214 if (error instanceof JsonBodyTooLargeError) {215 return protocolError(216 c,217 "INVALID_REQUEST",218 "Request body is too large",219 );220 }221 return protocolError(c, "INVALID_REQUEST", "Body is not JSON");222 }223 }224 if (operation.input) {225 const invalid = validateOperationInput(operation.name, input);226 if (invalid) return protocolError(c, "INVALID_REQUEST", invalid);227 }228 c.set("commerceInput", input);229 await next();230 };231 232 const handler = async (c: CommerceContext) => {233 try {234 const context: ProtocolContext = {235 apiKey: c.var.apiKey ?? "",236 requestIp: getRequestIp(c),237 };238 const result = await run(context, c.get("commerceInput") as never);239 return c.json(result as object, operation.successStatus as 200);240 } catch (error) {241 if (error instanceof ProtocolOperationError) {242 return protocolError(243 c,244 error.code,245 error.message,246 protocolErrorStatus(error.code),247 error.retryAfterSec,248 );249 }250 console.error(251 "[commerce] %s failed errorClass=%s",252 operation.name,253 error instanceof Error ? error.name : typeof error,254 );255 return protocolError(c, "INTERNAL_ERROR", "The operation failed");256 }257 };258 259 if (operation.auth === "server") {260 app.on(261 operation.method,262 path,263 commerceAuth(operation.auth),264 keyedRateLimitIfAuthed,265 authoritativeServerAuth,266 parseInput,267 handler,268 );269 } else {270 app.on(271 operation.method,272 path,273 commerceAuth(operation.auth),274 keyedRateLimitIfAuthed,275 parseInput,276 handler,277 );278 }279}280 281// The GraphQL binding shares the same handlers and the same role rules;282// SPEC.md 7 keeps operation failures inside the GraphQL errors array, so the283// only role decision made here is which credential kind was presented.284app.post(285 "/graphql",286 commerceAuth("none"),287 keyedRateLimitIfAuthed,288 async (c) => {289 let payload: unknown;290 try {291 payload = await readJsonBodyWithLimit(292 c.req.raw,293 MAX_COMMERCE_BODY_BYTES,294 "Request body is too large",295 );296 } catch (error) {297 if (error instanceof JsonBodyTooLargeError) {298 return protocolError(c, "INVALID_REQUEST", "Request body is too large");299 }300 return protocolError(c, "INVALID_REQUEST", "Body is not JSON");301 }302 303 const apiKey = c.var.apiKey;304 const { status, body } = await executeCommerceGraphql(payload, {305 role:306 apiKey === undefined307 ? null308 : isPublishableApiKey(apiKey)309 ? "verification"310 : "server",311 apiKey,312 requestIp: getRequestIp(c),313 });314 return c.json(body as object, status);315 },316);317 318// Terminal: an unknown /commerce/v1 path is a protocol 404, never an SPA319// fallthrough.320app.all("*", (c) =>321 c.json({ error: { code: "NOT_FOUND", message: "Unknown operation" } }, 404),322);323 324export { app as commerceRoutes };325