1// The portable GraphQL binding: one POST endpoint executing the generated2// schema projection. Resolvers only adapt transport — auth context, variable3// unpacking, and error mapping — and delegate to the shared handlers.4 5import {6 buildSchema,7 execute,8 GraphQLError,9 Kind,10 OperationTypeNode,11 parse,12 validate,13 type DocumentNode,14 type OperationDefinitionNode,15 type SelectionSetNode,16} from "graphql";17import HTTP_BINDING from "openiap-commerce-protocol/generated/bindings/http-binding.json";18import operationsSdl from "openiap-commerce-protocol/generated/bindings/operations-sdl.json";19 20import { ProtocolOperationError } from "./errors";21import * as handlers from "./handlers";22import type { ProtocolContext } from "./handlers";23import { validateOperationInput } from "./validation";24 25// The projection is generated from the SDL; serving anything else would fail26// the introspection-agreement rule in SPEC.md 7.27export const commerceGraphqlSchema = buildSchema(operationsSdl.sdl);28 29// Bounds chosen far above the canonical documents and the standard30// introspection query, far below anything abusive.31const MAX_QUERY_DEPTH = 20;32const MAX_FIELD_NODES = 1_000;33 34type CommerceRole = "none" | "verification" | "server";35 36export interface GraphqlRequestContext {37 role: CommerceRole | null;38 apiKey?: string;39 requestIp?: string;40}41 42const operationAuth = new Map(43 HTTP_BINDING.operations.map((operation) => [operation.name, operation.auth]),44);45 46function assertRole(operationName: string, context: GraphqlRequestContext) {47 const required = operationAuth.get(operationName);48 if (required === "none" || required === undefined) return;49 if (context.role === null) {50 throw new GraphQLError("A credential is required", {51 extensions: { code: "UNAUTHORIZED" },52 });53 }54 if (required === "server" && context.role !== "server") {55 throw new GraphQLError("This operation requires the server role", {56 extensions: { code: "FORBIDDEN" },57 });58 }59}60 61function protocolContext(context: GraphqlRequestContext): ProtocolContext {62 if (!context.apiKey) {63 throw new GraphQLError("A credential is required", {64 extensions: { code: "UNAUTHORIZED" },65 });66 }67 return { apiKey: context.apiKey, requestIp: context.requestIp };68}69 70async function resolve<T>(71 operationName: string,72 context: GraphqlRequestContext,73 input: unknown,74 run: (input: never) => Promise<T> | T,75): Promise<T> {76 assertRole(operationName, context);77 // Server-role authoritative auth already ran in executeCommerceGraphql,78 // BEFORE execute() — graphql-js coerces variables before any resolver, so79 // an auth check here would come after input validation (SPEC.md 5 forbids80 // that ordering for server operations).81 // The same generated JSON Schema the REST binding enforces. GraphQL's own82 // coercion does not check the custom scalars' patterns and bounds, so83 // without this the bindings would disagree on CONTENT bounds (SPEC.md 8).84 // Structural asymmetry is different and documented: REST ignores an unknown85 // input member (SPEC.md 6) while GraphQL rejects one at coercion (SPEC.md 7).86 if (input !== undefined) {87 const invalid = validateOperationInput(operationName, input);88 if (invalid) {89 throw new GraphQLError(invalid, {90 extensions: { code: "INVALID_REQUEST" },91 });92 }93 }94 try {95 return await run(input as never);96 } catch (error) {97 if (error instanceof GraphQLError) throw error;98 if (error instanceof ProtocolOperationError) {99 throw new GraphQLError(error.message, {100 extensions: {101 code: error.code,102 ...(error.retryAfterSec === undefined103 ? {}104 : { retryAfterSec: error.retryAfterSec }),105 },106 });107 }108 throw new GraphQLError("The operation failed", {109 extensions: { code: "INTERNAL_ERROR" },110 });111 }112}113 114// buildSchema attaches no resolvers, so the root value carries one function115// per operation; nested members resolve off plain handler results.116const rootValue = {117 providerCapabilities: (118 _args: unknown,119 context: GraphqlRequestContext,120 ): Promise<unknown> =>121 resolve("providerCapabilities", context, undefined, () =>122 handlers.providerCapabilities(),123 ),124 subscriptionStatus: (125 args: { input: { userId: string } },126 context: GraphqlRequestContext,127 ): Promise<unknown> =>128 resolve("subscriptionStatus", context, args.input, (input) =>129 handlers.subscriptionStatus(protocolContext(context), input),130 ),131 entitlements: (132 args: { input: { userId: string } },133 context: GraphqlRequestContext,134 ): Promise<unknown> =>135 resolve("entitlements", context, args.input, (input) =>136 handlers.entitlements(protocolContext(context), input),137 ),138 verifyPurchase: (139 args: { input: Parameters<typeof handlers.verifyPurchase>[1] },140 context: GraphqlRequestContext,141 ): Promise<unknown> =>142 resolve("verifyPurchase", context, args.input, (input) =>143 handlers.verifyPurchase(protocolContext(context), input),144 ),145 bindPurchase: (146 args: { input: Parameters<typeof handlers.bindPurchase>[1] },147 context: GraphqlRequestContext,148 ): Promise<unknown> =>149 resolve("bindPurchase", context, args.input, (input) =>150 handlers.bindPurchase(protocolContext(context), input),151 ),152 eraseUser: (153 args: { input: { userId: string } },154 context: GraphqlRequestContext,155 ): Promise<unknown> =>156 resolve("eraseUser", context, args.input, (input) =>157 handlers.eraseUser(protocolContext(context), input),158 ),159};160 161function boundsError(document: DocumentNode): string | null {162 const operations = document.definitions.filter(163 (definition): definition is OperationDefinitionNode =>164 definition.kind === Kind.OPERATION_DEFINITION,165 );166 if (operations.length !== 1) {167 return "Send exactly one operation per request";168 }169 const operation = operations[0];170 if (operation.operation === OperationTypeNode.SUBSCRIPTION) {171 return "The GraphQL binding serves no subscriptions";172 }173 174 const fragments = new Map<string, SelectionSetNode>();175 for (const definition of document.definitions) {176 if (definition.kind === Kind.FRAGMENT_DEFINITION) {177 fragments.set(definition.name.value, definition.selectionSet);178 }179 }180 181 // Count actual selections after expanding inline and named fragments, so an182 // aliased amplification hidden inside `... on Query { a b c }` or a fragment183 // spread is counted at the level it really executes. Crucially the walk184 // ABORTS the instant any bound is exceeded, and a total node-visit budget185 // hard-caps the work: a fragment DAG (f0 spreads f1 twice, f1 spreads f2186 // twice, …) expands exponentially, so fully expanding it before checking is187 // itself the DoS. Early exit plus the visit budget keep the cost linear in188 // the budget regardless of how the DAG is shaped.189 const MAX_VISITS = 10_000;190 let fields = 0;191 let rootFields = 0;192 let visits = 0;193 const seenFragments = new Set<string>();194 const walk = (195 selectionSet: SelectionSetNode | undefined,196 depth: number,197 ): string | null => {198 if (depth > MAX_QUERY_DEPTH) return "Query is too deep";199 if (!selectionSet?.selections) return null;200 for (const selection of selectionSet.selections) {201 if (++visits > MAX_VISITS) return "Query is too complex";202 if (selection.kind === Kind.FIELD) {203 fields += 1;204 if (fields > MAX_FIELD_NODES) return "Query selects too many fields";205 if (depth === 1) {206 rootFields += 1;207 if (rootFields > 1) {208 return "Send exactly one operation field per request";209 }210 }211 const nested = walk(selection.selectionSet, depth + 1);212 if (nested) return nested;213 } else if (selection.kind === Kind.INLINE_FRAGMENT) {214 // An inline fragment does not add a level: its members select on the215 // same object at the current depth.216 const nested = walk(selection.selectionSet, depth);217 if (nested) return nested;218 } else {219 const name = selection.name.value;220 if (seenFragments.has(name)) continue; // cycle guard221 seenFragments.add(name);222 const nested = walk(fragments.get(name), depth);223 seenFragments.delete(name);224 if (nested) return nested;225 }226 }227 return null;228 };229 const exceeded = walk(operation.selectionSet, 1);230 if (exceeded) return exceeded;231 232 // A field-free operation (only fragment spreads that resolve to nothing) is233 // not one operation field.234 if (rootFields !== 1) return "Send exactly one operation field per request";235 return null;236}237 238function errorPayload(code: string, message: string) {239 return { errors: [{ message, extensions: { code } }] };240}241 242// graphql-js parse/validation/coercion messages embed the submitted document243// and variable VALUES verbatim — a coercion error on VerifyPurchaseInput244// echoes the whole JWS back to an unauthenticated caller, which SPEC.md 8245// forbids (no store evidence or signed payloads in messages). Request-level246// failures therefore answer with fixed text; the caller reproduces the detail247// locally against the published projection.248const PARSE_MESSAGE = "The GraphQL document does not parse";249const VALIDATION_MESSAGE = "The GraphQL document is not valid for the schema";250const COERCION_MESSAGE =251 "The request variables are not valid for the operation";252 253/**254 * The single root operation field, resolved through fragment spreads. Only255 * called after boundsError enforced exactly one operation with exactly one256 * root field, so the first field found is the operation being invoked.257 */258function rootFieldName(document: DocumentNode): string | null {259 const operations = document.definitions.filter(260 (definition): definition is OperationDefinitionNode =>261 definition.kind === Kind.OPERATION_DEFINITION,262 );263 const operation = operations[0];264 if (!operation) return null;265 const fragments = new Map<string, SelectionSetNode>();266 for (const definition of document.definitions) {267 if (definition.kind === Kind.FRAGMENT_DEFINITION) {268 fragments.set(definition.name.value, definition.selectionSet);269 }270 }271 const seen = new Set<string>();272 const find = (selectionSet?: SelectionSetNode): string | null => {273 for (const selection of selectionSet?.selections ?? []) {274 if (selection.kind === Kind.FIELD) return selection.name.value;275 if (selection.kind === Kind.INLINE_FRAGMENT) {276 const found = find(selection.selectionSet);277 if (found) return found;278 } else if (selection.kind === Kind.FRAGMENT_SPREAD) {279 const name = selection.name.value;280 if (seen.has(name)) continue;281 seen.add(name);282 const found = find(fragments.get(name));283 if (found) return found;284 }285 }286 return null;287 };288 return find(operation.selectionSet);289}290 291/**292 * Executes one GraphQL request against the projection. Returns the JSON body293 * and status; operation failures are 200 responses whose errors carry the294 * protocol code, per SPEC.md 7.295 */296export async function executeCommerceGraphql(297 payload: unknown,298 context: GraphqlRequestContext,299): Promise<{ status: 200; body: unknown }> {300 // SPEC.md 7: the GraphQL binding answers with HTTP 200 and the code in301 // errors[].extensions.code for every failure, including request-level ones.302 if (payload === null || typeof payload !== "object") {303 return {304 status: 200,305 body: errorPayload("INVALID_REQUEST", "Request body is not JSON"),306 };307 }308 const { query, variables, operationName } = payload as {309 query?: unknown;310 variables?: unknown;311 operationName?: unknown;312 };313 if (typeof query !== "string" || query.length === 0) {314 return {315 status: 200,316 body: errorPayload("INVALID_REQUEST", "query is required"),317 };318 }319 320 let document: DocumentNode;321 try {322 document = parse(query);323 } catch {324 // A syntax message can echo document text, and evidence pasted as a325 // literal is document text — fixed message only.326 return {327 status: 200,328 body: errorPayload("INVALID_REQUEST", PARSE_MESSAGE),329 };330 }331 332 const bounds = boundsError(document);333 if (bounds) {334 return { status: 200, body: errorPayload("INVALID_REQUEST", bounds) };335 }336 337 const validationErrors = validate(commerceGraphqlSchema, document);338 if (validationErrors.length) {339 // Literal-coercion validation errors echo the submitted value.340 return {341 status: 200,342 body: errorPayload("INVALID_REQUEST", VALIDATION_MESSAGE),343 };344 }345 346 // SPEC.md 5: server-role authorization precedes input validation, and347 // graphql-js coerces variables BEFORE any resolver runs — so the resolver is348 // too late. Authorize here, after transport-shape checks (parse, bounds,349 // validate) and before execute(), where coercion happens.350 const operationField = rootFieldName(document);351 if (operationField && operationAuth.get(operationField) === "server") {352 if (context.role === null) {353 return {354 status: 200,355 body: errorPayload("UNAUTHORIZED", "A credential is required"),356 };357 }358 if (context.role !== "server") {359 return {360 status: 200,361 body: errorPayload(362 "FORBIDDEN",363 "This operation requires the server role",364 ),365 };366 }367 try {368 await handlers.assertServerCredential({369 apiKey: context.apiKey ?? "",370 requestIp: context.requestIp,371 });372 } catch (error) {373 if (error instanceof ProtocolOperationError) {374 return { status: 200, body: errorPayload(error.code, error.message) };375 }376 return {377 status: 200,378 body: errorPayload("INTERNAL_ERROR", "The operation failed"),379 };380 }381 }382 383 const result = await execute({384 schema: commerceGraphqlSchema,385 document,386 rootValue,387 contextValue: context,388 variableValues:389 variables && typeof variables === "object"390 ? (variables as Record<string, unknown>)391 : undefined,392 operationName: typeof operationName === "string" ? operationName : null,393 });394 395 return {396 status: 200,397 body: {398 ...(result.data === undefined ? {} : { data: result.data }),399 ...(result.errors?.length400 ? {401 errors: result.errors.map((error) => {402 const coded = typeof error.extensions?.code === "string";403 return {404 // A codeless error here is graphql-js variable coercion,405 // whose message echoes the submitted value — replace it.406 // Resolver-raised errors carry a code and a safe message.407 message: coded ? error.message : COERCION_MESSAGE,408 extensions: {409 code: coded410 ? (error.extensions.code as string)411 : "INVALID_REQUEST",412 ...(typeof error.extensions?.retryAfterSec === "number"413 ? { retryAfterSec: error.extensions.retryAfterSec }414 : {}),415 },416 };417 }),418 }419 : {}),420 },421 };422}423