← Provider replacement walkthrough

IAPKit · server/api/commerce/handlers.ts

Source snapshot · 2026-10-21

SHA-256 ac3d7c6b6a4b9e9f7fc0aa0eb4ee965b3a4da22ef4239e40ee3bb3bd4cd63c1e
1import { withVerificationSource } from "../../../convex/purchases/verificationSource";2// Transport-independent handlers for the OpenIAP Commerce Protocol operation3// surface. The REST routes and the GraphQL resolvers both call these and only4// these; business logic stays below, in the same Convex functions the5// published /v1 and /v2 surfaces already use.6 7import capabilitiesExample from "@hyodotdev/openiap-commerce-protocol/examples/provider-capabilities.json";8 9import { api } from "@/convex";10import { client, handleConvexError } from "../../convex";11import { isValidSubscriptionUserId } from "../../../convex/subscriptions/limits";12import { normalizeBindUserPurchaseToken } from "../v1/subscriptions";13import { ProtocolOperationError, protocolCodeForConvexError } from "./errors";14import { admitVerification } from "./verificationAdmission";15import { storeOf } from "../../../convex/commerce/internal";16import {17  buildAmazonRemoteId,18  buildHorizonRemoteId,19} from "../../../convex/purchases/identity";20 21export interface ProtocolContext {22  apiKey: string;23  requestIp?: string;24}25 26type SubscriptionRowV2 = {27  productId: string;28  platform: "IOS" | "Android";29  store?: string;30  state: string;31  expiresAt?: number;32  renewsAt?: number;33  willRenew?: boolean;34  cancellationReason?: string;35  startedAt: number;36  updatedAt: number;37};38 39export interface SubscriptionStatusSnapshot {40  productId: string;41  state: string;42  active: boolean;43  store?: string;44  expiresAt?: number;45  renewsAt?: number;46  willRenew?: boolean;47  cancellationReason?: string;48  startedAt?: number;49  updatedAt?: number;50}51 52interface StoreEvidenceInput {53  store: string;54  apple?: { jws: string };55  google?: { purchaseToken: string };56  horizon?: { userId: string; sku: string };57  amazon?: { userId: string; receiptId: string; sandbox?: boolean };58}59 60// Fixed, safe messages per protocol code. A provider/Convex error message can61// carry diagnostic detail (identifiers, internal state), so it never crosses62// the trust boundary — the raw message is logged server-side instead.63const SAFE_MESSAGE: Record<string, string> = {64  UNAUTHORIZED: "A valid credential is required",65  FORBIDDEN: "This operation requires the server role",66  INVALID_REQUEST: "The request is invalid",67  RATE_LIMITED: "Too many requests. Retry after the indicated delay.",68  VERIFICATION_FAILED: "The provider could not obtain a verdict from the store",69  INTERNAL_ERROR: "The operation failed",70};71 72function safeMessage(code: string): string {73  return SAFE_MESSAGE[code] ?? "The operation failed";74}75 76function rethrowAsProtocolError(error: unknown, fallbackCode: string): never {77  const convexError = handleConvexError(error);78  const code =79    (convexError && protocolCodeForConvexError(convexError.code)) ??80    fallbackCode;81  // Log only the protocol code, the Convex error CODE (an enum, never the free82  // text), and the JS error class. The raw provider message can carry a83  // receipt, token, JWS, userId, upstream URL, or source path, so it never84  // reaches the log — nor the response.85  console.error(86    "[commerce] operation failed protocolCode=%s convexCode=%s errorClass=%s",87    code,88    convexError?.code ?? "none",89    error instanceof Error ? error.name : typeof error,90  );91  throw new ProtocolOperationError(92    code,93    safeMessage(code),94    code === "RATE_LIMITED" ? convexError?.retryAfterSec : undefined,95  );96}97 98function requireUserId(userId: unknown): string {99  if (typeof userId !== "string" || !isValidSubscriptionUserId(userId)) {100    throw new ProtocolOperationError("INVALID_REQUEST", "userId is invalid");101  }102  return userId;103}104 105function requireEvidence<T>(106  input: StoreEvidenceInput,107  member: T | undefined,108): T {109  if (member === undefined || member === null) {110    throw new ProtocolOperationError(111      "INVALID_REQUEST",112      `${input.store} evidence is required`,113    );114  }115  return member;116}117 118// The entitlement decision is not recomputed here: Convex already applied the119// SPEC.md 2.3 predicate (subscriptions/query.ts isActive), returning the120// active flag for status and only entitled rows for entitlements. Re-deriving121// it at the transport layer is exactly the drift the caller passes `active`122// in to avoid.123function toSnapshot(124  row: SubscriptionRowV2,125  active: boolean,126): SubscriptionStatusSnapshot {127  return {128    productId: row.productId,129    state: row.state,130    active,131    store: storeOf(row),132    ...(row.expiresAt === undefined ? {} : { expiresAt: row.expiresAt }),133    ...(row.renewsAt === undefined ? {} : { renewsAt: row.renewsAt }),134    ...(row.willRenew === undefined ? {} : { willRenew: row.willRenew }),135    ...(row.cancellationReason === undefined136      ? {}137      : { cancellationReason: row.cancellationReason }),138    startedAt: row.startedAt,139    updatedAt: row.updatedAt,140  };141}142 143export function providerCapabilities(): Record<string, unknown> {144  // The published example is this implementation's own descriptor — SPEC.md145  // 10 says so — and kit's conformance suite pins it to the internal146  // capability map, so serving it cannot drift from either side.147  const { $comment: _comment, ...descriptor } = capabilitiesExample as Record<148    string,149    unknown150  >;151  return descriptor;152}153 154interface StoreVerdict {155  isValid: boolean;156  state: string;157  productId?: string;158  environment?: string;159  stableRejection?: boolean;160}161 162// The store verification itself, without admission control. Store errors pass163// through unconverted so admission can see an outage.164async function verifyPurchaseVerdict(165  context: ProtocolContext,166  input: StoreEvidenceInput,167): Promise<StoreVerdict> {168  const common = { apiKey: context.apiKey, requestIp: context.requestIp };169  switch (input.store) {170    case "apple": {171      const apple = requireEvidence(input, input.apple);172      return await client.action(173        api.purchases.ios.verifyAppStoreReceiptInternalV1,174        withVerificationSource("apple", { ...common, jws: apple.jws }),175      );176    }177    case "google": {178      const google = requireEvidence(input, input.google);179      return await client.action(180        api.purchases.android.verifyGooglePlayReceiptInternalV1,181        withVerificationSource("google", {182          ...common,183          purchaseToken: google.purchaseToken,184        }),185      );186    }187    case "horizon": {188      const horizon = requireEvidence(input, input.horizon);189      return await client.action(190        api.purchases.horizon.verifyMetaHorizonReceiptInternalV1,191        withVerificationSource("horizon", {192          ...common,193          userId: horizon.userId,194          sku: horizon.sku,195        }),196      );197    }198    case "amazon": {199      const amazon = requireEvidence(input, input.amazon);200      return await client.action(201        api.purchases.amazon.verifyAmazonReceiptInternalV1,202        withVerificationSource("amazon", {203          ...common,204          userId: amazon.userId,205          receiptId: amazon.receiptId,206          sandbox: amazon.sandbox,207        }),208      );209    }210    default:211      throw new ProtocolOperationError(212        "UNSUPPORTED_STORE",213        "This provider does not integrate the named store",214      );215  }216}217 218export async function verifyPurchase(219  context: ProtocolContext,220  input: StoreEvidenceInput,221): Promise<{222  store: string;223  isValid: boolean;224  state: string;225  productId?: string;226  environment?: string;227}> {228  // Admission is shared by both bindings: the replay guard, the process-wide229  // in-flight cap, and the stable-failure cooldown apply before and after the230  // store call, exactly as the /v1 verify pipeline does.231  const admission = admitVerification({232    apiKey: context.apiKey,233    requestIp: context.requestIp,234    input,235  });236  if (!admission.admitted) {237    throw new ProtocolOperationError(238      admission.code,239      "Too many verifications. Retry after the indicated delay.",240      admission.retryAfterSec,241    );242  }243  let verdict: StoreVerdict;244  try {245    verdict = await admission.run(() => verifyPurchaseVerdict(context, input));246  } catch (error) {247    if (error instanceof ProtocolOperationError) throw error;248    rethrowAsProtocolError(error, "VERIFICATION_FAILED");249  }250 251  return {252    store: input.store,253    isValid: verdict.isValid,254    state: verdict.state,255    ...(verdict.productId === undefined256      ? {}257      : { productId: verdict.productId }),258    // The protocol's environment space uses lowercase tokens; the store259    // verdict reports the capitalized /v1 spelling of the same values.260    ...(verdict.environment === undefined261      ? {}262      : { environment: verdict.environment.toLowerCase() }),263  };264}265 266export async function subscriptionStatus(267  context: ProtocolContext,268  input: { userId: string },269): Promise<{ active: boolean; subscription?: SubscriptionStatusSnapshot }> {270  const userId = requireUserId(input.userId);271  try {272    const result = await client.query(273      api.subscriptions.query.subscriptionStatusV2,274      { apiKey: context.apiKey, userId, now: Date.now() },275    );276    return {277      active: result.active,278      // Convex returns the entitling row when active, otherwise a context row;279      // the snapshot's own gate is exactly the top-level decision.280      ...(result.subscription === null281        ? {}282        : { subscription: toSnapshot(result.subscription, result.active) }),283    };284  } catch (error) {285    rethrowAsProtocolError(error, "INTERNAL_ERROR");286  }287}288 289export async function entitlements(290  context: ProtocolContext,291  input: { userId: string },292): Promise<{293  userId: string;294  productIds: string[];295  subscriptions: SubscriptionStatusSnapshot[];296}> {297  const userId = requireUserId(input.userId);298  // This read rechecks bound Amazon and Horizon purchases with the store, so a299  // store fault is VERIFICATION_FAILED (SPEC.md 4.3). Auth, rate-limit, and300  // internal faults keep their own codes.301  let purchases: { productIds: string[] };302  try {303    purchases = await client.action(304      api.purchases.action.readBoundPurchaseEntitlements,305      { apiKey: context.apiKey, userId },306    );307  } catch (error) {308    rethrowAsProtocolError(error, "VERIFICATION_FAILED");309  }310  try {311    const result = await client.query(api.subscriptions.query.entitlementsV2, {312      apiKey: context.apiKey,313      userId,314      now: Date.now(),315    });316    return {317      userId: result.userId,318      productIds: [...new Set([...result.productIds, ...purchases.productIds])],319      // entitlementsV2 returns only entitled rows, so every snapshot is active.320      subscriptions: result.subscriptions.map((row: SubscriptionRowV2) =>321        toSnapshot(row, true),322      ),323    };324  } catch (error) {325    rethrowAsProtocolError(error, "INTERNAL_ERROR");326  }327}328 329/**330 * SPEC.md 5: server-role authorization precedes input validation. The edge331 * prefix check cannot classify an unknown or legacy key, so both transport332 * adapters call this before evaluating the operation input — an invalid333 * credential gets UNAUTHORIZED / FORBIDDEN and learns nothing about the334 * privileged surface (evidence shapes, member bounds, supported stores).335 */336export async function assertServerCredential(337  context: ProtocolContext,338): Promise<void> {339  try {340    await client.query(api.subscriptions.query.assertServerAccess, {341      apiKey: context.apiKey,342    });343  } catch (error) {344    rethrowAsProtocolError(error, "INTERNAL_ERROR");345  }346}347 348export async function bindPurchase(349  context: ProtocolContext,350  input: StoreEvidenceInput & { userId: string },351): Promise<{ bound: boolean }> {352  const userId = requireUserId(input.userId);353  if (input.store === "amazon" || input.store === "horizon") {354    let remoteId: string;355    if (input.store === "amazon") {356      const amazon = requireEvidence(input, input.amazon);357      remoteId = buildAmazonRemoteId({358        ...amazon,359        sandbox: amazon.sandbox === true,360      });361    } else {362      const horizon = requireEvidence(input, input.horizon);363      remoteId = buildHorizonRemoteId(horizon.userId, horizon.sku);364    }365    try {366      return await client.mutation(367        api.purchases.mutation.bindVerifiedPurchaseAsServer,368        { apiKey: context.apiKey, userId, store: input.store, remoteId },369      );370    } catch (error) {371      rethrowAsProtocolError(error, "INTERNAL_ERROR");372    }373  }374  let rawToken: string;375  switch (input.store) {376    case "apple":377      rawToken = requireEvidence(input, input.apple).jws;378      break;379    case "google":380      rawToken = requireEvidence(input, input.google).purchaseToken;381      break;382    default:383      throw new ProtocolOperationError(384        "UNSUPPORTED_STORE",385        "This provider does not integrate the named store",386      );387  }388 389  const normalized = normalizeBindUserPurchaseToken(rawToken);390  if (!normalized.ok) {391    throw new ProtocolOperationError("INVALID_REQUEST", normalized.message);392  }393 394  try {395    // bindUserAsServer asserts admin access in Convex, where the stored key396    // type is authoritative; the edge prefix check cannot classify a legacy397    // no-prefix key. A publishable or legacy key is rejected with398    // INSUFFICIENT_SCOPE, mapped to FORBIDDEN.399    const result = await client.mutation(400      api.subscriptions.mutation.bindUserAsServer,401      {402        apiKey: context.apiKey,403        purchaseToken: normalized.purchaseToken,404        userId,405      },406    );407    return { bound: result.bound };408  } catch (error) {409    if (error instanceof ProtocolOperationError) throw error;410    rethrowAsProtocolError(error, "INTERNAL_ERROR");411  }412}413 414export async function eraseUser(415  context: ProtocolContext,416  input: { userId: string },417): Promise<{ accepted: boolean; jobId?: string; status?: string }> {418  const userId = requireUserId(input.userId);419  try {420    const result = await client.mutation(421      api.subscriptions.mutation.requestUserErasure,422      { apiKey: context.apiKey, userId },423    );424    return { accepted: result.ok, jobId: result.jobId, status: result.status };425  } catch (error) {426    rethrowAsProtocolError(error, "INTERNAL_ERROR");427  }428}429