← Provider replacement walkthrough

IAPKit · server/api/commerce/graphql.ts

Source snapshot · 2026-09-09

SHA-256 8c45733a958c875d5a123e4fd0b1fa56981f9adb0fc6c80104fb4ce6727448e2
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