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