← Provider replacement walkthrough

IAPKit · server/api/commerce/handlers.ts

Source snapshot · 2026-09-09

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