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