← Provider replacement walkthrough

IAPKit · convex/subscriptions/mutation.ts

Source snapshot · 2026-10-21

SHA-256 a5c0ba4bc29a56e5e6743303a043479cf4090548f9bca56e945455190f90d1d0
1import { mutation } from "../_generated/server";2import { ConvexError, v } from "convex/values";3import { internal } from "../_generated/api";4 5import { resolveProjectByApiKeyFromDb } from "../projects/helpers";6import {7  bindOneTimePurchaseToUser,8  rebindOneTimePurchaseToUser,9} from "../purchases/binding";10import { recordBindIssue } from "../purchaseIssues/internal";11import {12  bindSubscriptionToUserOutcome,13  findSubscriptionByToken,14  rebindSubscriptionToUserHandler,15  type BindOutcome,16} from "./internal";17import { isValidSubscriptionUserId } from "./limits";18import { hmacSha256Hex, sha256Hex } from "../utils/sha256";19import { randomHex } from "../utils/helpers";20import { bindReasonValidator } from "../utils/validation";21 22const bindArgs = {23  apiKey: v.string(),24  purchaseToken: v.string(),25  userId: v.string(),26  // Labels a secret-key bind issue; never read otherwise.27  correlationId: v.optional(v.string()),28};29 30function isBound(outcome: BindOutcome): boolean {31  return outcome.reason === "bound" || outcome.reason === "already_bound";32}33 34// An authenticated app backend chooses the owner; store evidence alone cannot.35export const bindUser = mutation({36  args: bindArgs,37  returns: v.object({38    ok: v.boolean(),39    bound: v.boolean(),40    reason: v.optional(bindReasonValidator),41  }),42  handler: async (ctx, args) => {43    const resolved = await resolveProjectByApiKeyFromDb(44      ctx,45      args.apiKey,46      "admin",47    );48    if (!resolved) return { ok: false, bound: false };49    const { project } = resolved;50    if (!isValidSubscriptionUserId(args.userId)) {51      throw new ConvexError(52        "userId must be nonblank and at most 256 characters",53      );54    }55 56    const sub = await findSubscriptionByToken(57      ctx,58      project._id,59      args.purchaseToken,60    );61    // Amazon store identity is linked by the authenticated developer backend.62    let outcome: BindOutcome =63      sub?.store === "amazon"64        ? { reason: "not_bindable" }65        : await bindSubscriptionToUserOutcome(ctx, {66            projectId: project._id,67            purchaseToken: args.purchaseToken,68            userId: args.userId,69          });70    if (outcome.reason === "not_found") {71      outcome = await bindOneTimePurchaseToUser(72        ctx,73        project,74        args.purchaseToken,75        args.userId,76      );77    }78    await recordBindIssue(ctx, project._id, outcome, {79      evidence: args.purchaseToken,80      userId: args.userId,81      correlationId: args.correlationId,82    });83    return { ok: true, bound: isBound(outcome), reason: outcome.reason };84  },85});86 87// Commerce Protocol bindPurchase: same idempotent, never-move binding as88// bindUser, but server-role only. The protocol says a shipped app must not89// reach an account mutation, and the edge prefix check alone cannot classify90// a legacy no-prefix key, so the admin access is asserted here in Convex where91// the stored key type is authoritative. Apple and Google stay subscription-only92// here: a bound one-time purchase would have to appear in Commerce93// entitlements, which IAPKit cannot recheck with those stores (SPEC §4.3).94export const bindUserAsServer = mutation({95  args: bindArgs,96  returns: v.object({ ok: v.boolean(), bound: v.boolean() }),97  handler: async (ctx, args) => {98    // resolveProjectByApiKeyFromDb throws INSUFFICIENT_SCOPE for a valid but99    // under-scoped (publishable/legacy) key, and returns null for an unknown100    // or inactive one. The two must map to different protocol codes: an101    // unknown key is UNAUTHORIZED (INVALID_API_KEY), only a real-but-wrong-role102    // key is FORBIDDEN.103    const resolved = await resolveProjectByApiKeyFromDb(104      ctx,105      args.apiKey,106      "admin",107    );108    if (!resolved) {109      throw new ConvexError({110        code: "INVALID_API_KEY",111        message: "API key is invalid or inactive",112      });113    }114 115    const outcome = await bindSubscriptionToUserOutcome(ctx, {116      projectId: resolved.project._id,117      purchaseToken: args.purchaseToken,118      userId: args.userId,119    });120    await recordBindIssue(ctx, resolved.project._id, outcome, {121      evidence: args.purchaseToken,122      userId: args.userId,123      correlationId: args.correlationId,124    });125 126    return { ok: true, bound: isBound(outcome) };127  },128});129 130// Recovery for a purchase bound to the wrong user. `bindUser` deliberately131// refuses to move an existing binding — token possession is not proof of132// ownership — so without this an operator has no way to correct one.133// Secret key only: this reassigns who owns a purchase.134export const rebindUser = mutation({135  args: {136    apiKey: v.string(),137    purchaseToken: v.string(),138    userId: v.string(),139  },140  returns: v.object({141    ok: v.boolean(),142    rebound: v.boolean(),143    // False when the binding moved but no entitlement events could be144    // attributed, so the caller knows the developer backend still believes the145    // previous user owns the purchase.146    notified: v.boolean(),147  }),148  handler: async (ctx, args) => {149    const resolved = await resolveProjectByApiKeyFromDb(150      ctx,151      args.apiKey,152      "admin",153    );154    const project = resolved?.project ?? null;155    if (!project) return { ok: false, rebound: false, notified: false };156 157    const result = await rebindSubscriptionToUserHandler(ctx, {158      projectId: project._id,159      purchaseToken: args.purchaseToken,160      userId: args.userId,161    });162    if (result) {163      return { ok: true, rebound: true, notified: result.notified };164    }165 166    // One-time purchases emit no commerce events, so a move is never notified.167    const oneTime = await rebindOneTimePurchaseToUser(168      ctx,169      project,170      args.purchaseToken,171      args.userId,172    );173    return {174      ok: true,175      rebound: oneTime !== null,176      notified: oneTime?.moved !== true,177    };178  },179});180 181export const requestUserErasure = mutation({182  args: { apiKey: v.string(), userId: v.string() },183  returns: v.object({184    ok: v.boolean(),185    jobId: v.id("subscriptionUserErasureJobs"),186    status: v.union(187      v.literal("queued"),188      v.literal("running"),189      v.literal("completed"),190    ),191  }),192  handler: async (ctx, args) => {193    const resolved = await resolveProjectByApiKeyFromDb(194      ctx,195      args.apiKey,196      "admin",197    );198    if (!resolved) {199      throw new ConvexError({200        code: "INVALID_API_KEY",201        message: "API key is invalid or inactive",202      });203    }204    if (!isValidSubscriptionUserId(args.userId)) {205      throw new ConvexError({206        code: "INVALID_INPUT",207        message: "userId is invalid",208      });209    }210 211    const hashKey = resolved.project.userErasureHashKey ?? randomHex();212    if (resolved.project.userErasureHashKey === undefined) {213      await ctx.db.patch(resolved.project._id, { userErasureHashKey: hashKey });214    }215    const userIdHash = await hmacSha256Hex(hashKey, args.userId);216    let existing = await ctx.db217      .query("subscriptionUserErasureJobs")218      .withIndex("by_project_and_user_hash", (q) =>219        q.eq("projectId", resolved.project._id).eq("userIdHash", userIdHash),220      )221      .unique();222    if (!existing) {223      const legacyHash = await sha256Hex(args.userId);224      existing = await ctx.db225        .query("subscriptionUserErasureJobs")226        .withIndex("by_project_and_user_hash", (q) =>227          q.eq("projectId", resolved.project._id).eq("userIdHash", legacyHash),228        )229        .unique();230      if (existing) {231        await ctx.db.patch(existing._id, { userIdHash });232      }233    }234    if (existing) {235      return { ok: true, jobId: existing._id, status: existing.status };236    }237 238    const now = Date.now();239    const jobId = await ctx.db.insert("subscriptionUserErasureJobs", {240      projectId: resolved.project._id,241      userId: args.userId,242      userIdHash,243      status: "queued",244      subscriptionsErased: 0,245      commerceEventsErased: 0,246      createdAt: now,247      updatedAt: now,248    });249    await ctx.scheduler.runAfter(250      0,251      internal.subscriptions.internal.drainSubscriptionUserErasureJob,252      { jobId },253    );254    return { ok: true, jobId, status: "queued" as const };255  },256});257