← Provider replacement walkthrough

IAPKit · convex/subscriptions/mutation.ts

Source snapshot · 2026-09-09

SHA-256 b780bab187d90d93eb4f7c5f9e8c5edde2ede313023a189b116a691b7506bade
1import { mutation } from "../_generated/server";2import { ConvexError, v } from "convex/values";3import { internal } from "../_generated/api";4 5import { resolveProjectByApiKeyFromDb } from "../projects/helpers";6import {7  bindSubscriptionToUserHandler,8  rebindSubscriptionToUserHandler,9} from "./internal";10import { isValidSubscriptionUserId } from "./limits";11import { hmacSha256Hex, sha256Hex } from "../utils/sha256";12 13function generateUserErasureHashKey(): string {14  const bytes = new Uint8Array(32);15  crypto.getRandomValues(bytes);16  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(17    "",18  );19}20 21// Public mutation called by SDKs after a successful receipt verification:22// they know who the host-app user is, so they tell kit which userId owns23// the verified purchaseToken. Idempotent — re-binding the same userId is24// a no-op.25export const bindUser = mutation({26  args: {27    apiKey: v.string(),28    purchaseToken: v.string(),29    userId: v.string(),30  },31  returns: v.object({ ok: v.boolean(), bound: v.boolean() }),32  handler: async (ctx, args) => {33    const resolved = await resolveProjectByApiKeyFromDb(ctx, args.apiKey);34    const project = resolved?.project ?? null;35    if (!project) return { ok: false, bound: false };36 37    const subscriptionId = await bindSubscriptionToUserHandler(ctx, {38      projectId: project._id,39      purchaseToken: args.purchaseToken,40      userId: args.userId,41    });42 43    return { ok: true, bound: subscriptionId !== null };44  },45});46 47// Commerce Protocol bindPurchase: same idempotent, never-move binding as48// bindUser, but server-role only. The protocol says a shipped app must not49// reach an account mutation, and the edge prefix check alone cannot classify50// a legacy no-prefix key, so the admin access is asserted here in Convex where51// the stored key type is authoritative.52export const bindUserAsServer = mutation({53  args: {54    apiKey: v.string(),55    purchaseToken: v.string(),56    userId: v.string(),57  },58  returns: v.object({ ok: v.boolean(), bound: v.boolean() }),59  handler: async (ctx, args) => {60    // resolveProjectByApiKeyFromDb throws INSUFFICIENT_SCOPE for a valid but61    // under-scoped (publishable/legacy) key, and returns null for an unknown62    // or inactive one. The two must map to different protocol codes: an63    // unknown key is UNAUTHORIZED (INVALID_API_KEY), only a real-but-wrong-role64    // key is FORBIDDEN.65    const resolved = await resolveProjectByApiKeyFromDb(66      ctx,67      args.apiKey,68      "admin",69    );70    if (!resolved) {71      throw new ConvexError({72        code: "INVALID_API_KEY",73        message: "API key is invalid or inactive",74      });75    }76 77    const subscriptionId = await bindSubscriptionToUserHandler(ctx, {78      projectId: resolved.project._id,79      purchaseToken: args.purchaseToken,80      userId: args.userId,81    });82 83    return { ok: true, bound: subscriptionId !== null };84  },85});86 87// Recovery for a subscription bound to the wrong user. `bindUser` deliberately88// refuses to move an existing binding — token possession is not proof of89// ownership — so without this an operator has no way to correct one.90// Secret key only: this reassigns who owns a purchase.91export const rebindUser = mutation({92  args: {93    apiKey: v.string(),94    purchaseToken: v.string(),95    userId: v.string(),96  },97  returns: v.object({98    ok: v.boolean(),99    rebound: v.boolean(),100    // False when the binding moved but no entitlement events could be101    // attributed, so the caller knows the developer backend still believes the102    // previous user owns the purchase.103    notified: v.boolean(),104  }),105  handler: async (ctx, args) => {106    const resolved = await resolveProjectByApiKeyFromDb(107      ctx,108      args.apiKey,109      "admin",110    );111    const project = resolved?.project ?? null;112    if (!project) return { ok: false, rebound: false, notified: false };113 114    const result = await rebindSubscriptionToUserHandler(ctx, {115      projectId: project._id,116      purchaseToken: args.purchaseToken,117      userId: args.userId,118    });119 120    return {121      ok: true,122      rebound: result !== null,123      notified: result?.notified ?? true,124    };125  },126});127 128export const requestUserErasure = mutation({129  args: { apiKey: v.string(), userId: v.string() },130  returns: v.object({131    ok: v.boolean(),132    jobId: v.id("subscriptionUserErasureJobs"),133    status: v.union(134      v.literal("queued"),135      v.literal("running"),136      v.literal("completed"),137    ),138  }),139  handler: async (ctx, args) => {140    const resolved = await resolveProjectByApiKeyFromDb(141      ctx,142      args.apiKey,143      "admin",144    );145    if (!resolved) {146      throw new ConvexError({147        code: "INVALID_API_KEY",148        message: "API key is invalid or inactive",149      });150    }151    if (!isValidSubscriptionUserId(args.userId)) {152      throw new ConvexError({153        code: "INVALID_INPUT",154        message: "userId is invalid",155      });156    }157 158    const hashKey =159      resolved.project.userErasureHashKey ?? generateUserErasureHashKey();160    if (resolved.project.userErasureHashKey === undefined) {161      await ctx.db.patch(resolved.project._id, { userErasureHashKey: hashKey });162    }163    const userIdHash = await hmacSha256Hex(hashKey, args.userId);164    let existing = await ctx.db165      .query("subscriptionUserErasureJobs")166      .withIndex("by_project_and_user_hash", (q) =>167        q.eq("projectId", resolved.project._id).eq("userIdHash", userIdHash),168      )169      .unique();170    if (!existing) {171      const legacyHash = await sha256Hex(args.userId);172      existing = await ctx.db173        .query("subscriptionUserErasureJobs")174        .withIndex("by_project_and_user_hash", (q) =>175          q.eq("projectId", resolved.project._id).eq("userIdHash", legacyHash),176        )177        .unique();178      if (existing) {179        await ctx.db.patch(existing._id, { userIdHash });180      }181    }182    if (existing) {183      return { ok: true, jobId: existing._id, status: existing.status };184    }185 186    const now = Date.now();187    const jobId = await ctx.db.insert("subscriptionUserErasureJobs", {188      projectId: resolved.project._id,189      userId: args.userId,190      userIdHash,191      status: "queued",192      subscriptionsErased: 0,193      commerceEventsErased: 0,194      createdAt: now,195      updatedAt: now,196    });197    await ctx.scheduler.runAfter(198      0,199      internal.subscriptions.internal.drainSubscriptionUserErasureJob,200      { jobId },201    );202    return { ok: true, jobId, status: "queued" as const };203  },204});205