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