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