+ For Apple projects, an uploaded project-level App Review screenshot
+ opts eligible draft products into the current version-based review
+ workflow. Push Sync creates product-version metadata, uploads the
+ private PNG/JPEG through Apple's reserved asset operations, and
+ submits the version through a review submission. If no screenshot is
+ configured, the product stops at Ready to Submit as before. Apple
+ requirements that need a new app version—including the first
+ consumable, non-consumable, auto-renewable subscription, or
+ non-renewing subscription—are returned as manualActions,
+ not transient sync failures. Removing the project file only stops
+ future reuse; it does not remove screenshots already uploaded to App
+ Store Connect.
+
Sync is asynchronous —{' '}
@@ -811,8 +825,9 @@ var clientPayload = payloadResponse.ClientPayload;`}
POST
/v1/products/{apiKey}/sync/jobs/{jobId}/cancel
{' '}
- — request a cancel; the worker checks at phase boundaries (PULL.iaps
- → PULL.subscriptions → PUSH.drafts) and stops within seconds.
+ — request a cancel; the worker checks at phase, product-chunk,
+ request, upload-operation, and asset-poll boundaries, then performs
+ bounded cleanup for any IAPKit-owned review draft.
diff --git a/packages/kit/convex.json b/packages/kit/convex.json
index 496b4ddaf..265d42522 100644
--- a/packages/kit/convex.json
+++ b/packages/kit/convex.json
@@ -1,5 +1,6 @@
{
"node": {
- "nodeVersion": "22"
+ "nodeVersion": "22",
+ "externalPackages": ["sharp"]
}
}
diff --git a/packages/kit/convex/files/action.ts b/packages/kit/convex/files/action.ts
index 6616daf8d..125b5046e 100644
--- a/packages/kit/convex/files/action.ts
+++ b/packages/kit/convex/files/action.ts
@@ -3,7 +3,178 @@ import { action } from "../_generated/server";
import { v, ConvexError } from "convex/values";
import { internal } from "../_generated/api";
import { getAuthUserId } from "@convex-dev/auth/server";
+import sharp from "sharp";
import type { Id } from "../_generated/dataModel";
+import {
+ validateAppleReviewScreenshotContent,
+ validateFileUpload,
+} from "./validation";
+
+const SCREENSHOT_FETCH_TIMEOUT_MS = 30_000;
+const SCREENSHOT_MAX_INPUT_PIXELS = 25_000_000;
+
+/** Force a real image decode before a blob can receive the validation marker. */
+export async function decodeAppleReviewScreenshot(
+ bytes: Uint8Array,
+ declaredMimeType: string,
+): Promise {
+ validateAppleReviewScreenshotContent(bytes, declaredMimeType);
+ let metadata: Awaited["metadata"]>>;
+ try {
+ const decoder = sharp(bytes, {
+ failOn: "error",
+ limitInputPixels: SCREENSHOT_MAX_INPUT_PIXELS,
+ });
+ metadata = await decoder.metadata();
+ // metadata() alone can succeed for a truncated payload. Decode every pixel
+ // so malformed chunks/scan data are rejected before the blob reaches ASC.
+ await decoder.clone().raw().toBuffer();
+ } catch {
+ throw new ConvexError(
+ "App Review screenshot is truncated, corrupt, or too large to decode",
+ );
+ }
+ const expectedFormat =
+ declaredMimeType === "image/png"
+ ? "png"
+ : declaredMimeType === "image/jpeg"
+ ? "jpeg"
+ : null;
+ if (
+ metadata.format !== expectedFormat ||
+ !metadata.width ||
+ !metadata.height
+ ) {
+ throw new ConvexError(
+ "App Review screenshot decoded format does not match its MIME type",
+ );
+ }
+ if (metadata.hasAlpha) {
+ throw new ConvexError(
+ "App Review PNG screenshots cannot contain an alpha channel",
+ );
+ }
+}
+
+export const validateAppleReviewScreenshotUpload = action({
+ args: {
+ organizationId: v.id("organizations"),
+ projectId: v.id("projects"),
+ uploadReservationId: v.id("fileUploadReservations"),
+ storageId: v.id("_storage"),
+ fileName: v.string(),
+ fileType: v.string(),
+ fileSize: v.number(),
+ },
+ returns: v.object({ valid: v.literal(true) }),
+ handler: async (ctx, args): Promise<{ valid: true }> => {
+ const userId = await getAuthUserId(ctx);
+ const { reservation, storage } = await ctx.runQuery(
+ internal.files.internal.getUploadReservationForScreenshotValidation,
+ {
+ uploadReservationId: args.uploadReservationId,
+ storageId: args.storageId,
+ },
+ );
+ if (
+ !reservation ||
+ reservation.organizationId !== args.organizationId ||
+ reservation.projectId !== args.projectId
+ ) {
+ throw new ConvexError("Invalid upload reservation");
+ }
+ // A signed-in user must never consume somebody else's leaked capability.
+ // A missing session is different: the target-bound one-time reservation
+ // still authorizes cleanup of the just-uploaded unclaimed blob.
+ if (userId && reservation.createdBy !== userId) {
+ throw new ConvexError("Invalid upload reservation");
+ }
+
+ try {
+ if (!userId) throw new ConvexError("Not authenticated");
+ if (reservation.expiresAt <= Date.now()) {
+ throw new ConvexError("Upload reservation expired");
+ }
+ const membership = await ctx.runQuery(
+ internal.organizations.internal.getMembership,
+ { userId, organizationId: args.organizationId },
+ );
+ if (!membership || membership.role === "member") {
+ throw new ConvexError("Insufficient permissions");
+ }
+ validateFileUpload(
+ args.fileName,
+ args.fileType,
+ args.fileSize,
+ "apple_iap_review_screenshot",
+ );
+ if (!storage || storage.size !== args.fileSize) {
+ throw new ConvexError(
+ "App Review screenshot size does not match the uploaded blob",
+ );
+ }
+ await ctx.runMutation(
+ internal.files.mutation.markAppleReviewScreenshotValidationPending,
+ {
+ uploadReservationId: args.uploadReservationId,
+ userId,
+ storageId: args.storageId,
+ fileSize: args.fileSize,
+ },
+ );
+ const storageUrl = await ctx.storage.getUrl(args.storageId);
+ if (!storageUrl) {
+ throw new ConvexError("App Review screenshot content not found");
+ }
+ const controller = new AbortController();
+ const timeout = setTimeout(
+ () => controller.abort(),
+ SCREENSHOT_FETCH_TIMEOUT_MS,
+ );
+ let response: Response;
+ try {
+ response = await fetch(storageUrl, { signal: controller.signal });
+ } finally {
+ clearTimeout(timeout);
+ }
+ if (!response.ok) {
+ throw new ConvexError(
+ `App Review screenshot download returned HTTP ${response.status}`,
+ );
+ }
+ const bytes = new Uint8Array(await response.arrayBuffer());
+ if (bytes.byteLength !== args.fileSize) {
+ throw new ConvexError(
+ "App Review screenshot size changed while validating",
+ );
+ }
+ await decodeAppleReviewScreenshot(bytes, args.fileType);
+ await ctx.runMutation(
+ internal.files.mutation.markAppleReviewScreenshotValidated,
+ {
+ uploadReservationId: args.uploadReservationId,
+ userId,
+ storageId: args.storageId,
+ fileName: args.fileName,
+ fileType: args.fileType,
+ fileSize: args.fileSize,
+ },
+ );
+ return { valid: true };
+ } catch (error) {
+ await ctx.runMutation(
+ internal.files.mutation.rejectAppleReviewScreenshotValidation,
+ {
+ uploadReservationId: args.uploadReservationId,
+ organizationId: args.organizationId,
+ projectId: args.projectId,
+ storageId: args.storageId,
+ },
+ );
+ throw error;
+ }
+ },
+});
// Public action to download an uploaded credential file (Apple .p8 or
// Google service-account JSON). The dashboard's Settings page calls
diff --git a/packages/kit/convex/files/internal.ts b/packages/kit/convex/files/internal.ts
index 959e97687..c2b4eb068 100644
--- a/packages/kit/convex/files/internal.ts
+++ b/packages/kit/convex/files/internal.ts
@@ -7,7 +7,10 @@ import type { Doc, Id } from "../_generated/dataModel";
import { v } from "convex/values";
import { ConvexError } from "convex/values";
import { internal } from "../_generated/api";
-import { deleteFileAndStorageIfUnreferenced } from "./storage";
+import {
+ deleteFileAndStorageIfUnreferenced,
+ deleteStorageIfUnreferenced,
+} from "./storage";
export const UPLOAD_RESERVATION_PRUNE_BATCH_SIZE = 200;
@@ -25,6 +28,17 @@ export const getFileRecord = internalQuery({
},
});
+export const getUploadReservationForScreenshotValidation = internalQuery({
+ args: {
+ uploadReservationId: v.id("fileUploadReservations"),
+ storageId: v.id("_storage"),
+ },
+ handler: async (ctx, args) => ({
+ reservation: await ctx.db.get(args.uploadReservationId),
+ storage: await ctx.db.system.get("_storage", args.storageId),
+ }),
+});
+
// Internal mutation to update file access tracking
export const updateFileAccess = internalMutation({
args: {
@@ -214,6 +228,9 @@ export const readFileAsBase64 = internalAction({
return {
fileId: file._id,
fileName: file.fileName,
+ fileType: file.fileType,
+ fileSize: file.fileSize,
+ purpose: file.purpose,
content: base64,
metadata: file.metadata,
};
@@ -237,6 +254,7 @@ export const findFilesByPurpose = internalQuery({
v.literal("apple_p8_key"),
v.literal("apple_p8_asc_api_key"),
v.literal("android_service_account"),
+ v.literal("apple_iap_review_screenshot"),
),
},
handler: async (ctx, args): Promise => {
@@ -265,6 +283,36 @@ export const findFilesByPurpose = internalQuery({
},
});
+// Exact-project lookup for the private App Review screenshot. The temporary
+// storage URL is returned only from this internal query so the Node ASC worker
+// can stream/fetch the blob directly instead of expanding a 10 MB image into a
+// binary string plus base64 inside the smaller V8 isolate. It is never exposed
+// by a public query or action.
+export const getAppleReviewScreenshotByProjectInternal = internalQuery({
+ args: { projectId: v.id("projects") },
+ handler: async (ctx, args) => {
+ const file = await ctx.db
+ .query("files")
+ .withIndex("by_project", (q) => q.eq("projectId", args.projectId))
+ .order("desc")
+ .filter((q) => q.eq(q.field("purpose"), "apple_iap_review_screenshot"))
+ .first();
+ if (!file) return null;
+ const storageUrl = await ctx.storage.getUrl(file.storageId);
+ if (!storageUrl) {
+ throw new ConvexError("App Review screenshot content not found");
+ }
+ return {
+ fileId: file._id,
+ fileName: file.fileName,
+ fileType: file.fileType,
+ fileSize: file.fileSize,
+ createdAt: file.createdAt,
+ storageUrl,
+ };
+ },
+});
+
// Internal query to get Google Play service account file by project.
// Uses the `by_project` index on `files` and filters by purpose through
// the query builder so we only read rows that could match — no full
@@ -397,11 +445,14 @@ export const cleanupOldFiles = internalMutation({
let deletedCount = 0;
for (const file of files) {
- // Don't delete internal files or keys (both Apple .p8 kinds).
+ // Don't delete internal files, keys (both Apple .p8 kinds), or review
+ // screenshots. The purpose guard protects legacy/malformed screenshot
+ // rows even if isInternal was false.
if (
file.isInternal ||
file.purpose === "apple_p8_key" ||
- file.purpose === "apple_p8_asc_api_key"
+ file.purpose === "apple_p8_asc_api_key" ||
+ file.purpose === "apple_iap_review_screenshot"
) {
continue;
}
@@ -426,11 +477,10 @@ export const cleanupOldFiles = internalMutation({
},
});
-// Expired upload reservations carry no storageId: the storage service assigns
-// it only after the client POSTs to the signed URL. A client that completes the
-// POST immediately presents the reservation to `saveFile`, which consumes it
-// while saving or safely reclaiming the blob. This bounded sweep removes only
-// unused/expired capabilities so the temporary table cannot grow forever.
+// Most expired upload reservations carry no storageId because the storage
+// service assigns it only after the client POSTs to the signed URL. Screenshot
+// validation deliberately claims that id before its Node action downloads the
+// blob, so the bounded sweep must also reclaim a claimed-but-unsaved object.
export const pruneUploadReservations = internalMutation({
args: {
batchSize: v.optional(v.number()),
@@ -452,6 +502,12 @@ export const pruneUploadReservations = internalMutation({
.take(batchSize);
for (const reservation of expired) {
+ const screenshotStorageId =
+ reservation.pendingAppleReviewScreenshotStorageId ??
+ reservation.validatedAppleReviewScreenshot?.storageId;
+ if (screenshotStorageId) {
+ await deleteStorageIfUnreferenced(ctx, screenshotStorageId);
+ }
await ctx.db.delete(reservation._id);
}
diff --git a/packages/kit/convex/files/mutation.ts b/packages/kit/convex/files/mutation.ts
index 7d754b231..6343ae0d3 100644
--- a/packages/kit/convex/files/mutation.ts
+++ b/packages/kit/convex/files/mutation.ts
@@ -1,4 +1,4 @@
-import { mutation } from "../_generated/server";
+import { internalMutation, mutation } from "../_generated/server";
import type { MutationCtx } from "../_generated/server";
import type { Id } from "../_generated/dataModel";
import { v } from "convex/values";
@@ -10,17 +10,18 @@ import {
deleteStorageIfUnreferenced,
isStorageReferenced,
} from "./storage";
+import { validateFileUpload } from "./validation";
export const FILE_UPLOAD_RESERVATION_TTL_MS = 15 * 60 * 1000;
// Convex upload URLs last one hour and an upload POST may run for two minutes.
// Keep a small buffer beyond both limits so every successfully uploaded blob
// can still be reclaimed by the terminal save call.
export const FILE_UPLOAD_RESERVATION_CLEANUP_TTL_MS = 75 * 60 * 1000;
-// Three credential kinds can be uploaded from project settings. Keep room for
-// one retry of each while still bounding reservation-table growth per user and
+// Four project file kinds can be uploaded from settings. Keep room for one
+// retry of each while still bounding reservation-table growth per user and
// target. The indexed range read makes concurrent issuance respect the cap via
// Convex OCC.
-export const MAX_ACTIVE_FILE_UPLOAD_RESERVATIONS_PER_TARGET = 6;
+export const MAX_ACTIVE_FILE_UPLOAD_RESERVATIONS_PER_TARGET = 8;
async function deleteUnclaimedUpload(
ctx: MutationCtx,
@@ -49,6 +50,7 @@ export const saveFile = mutation({
v.literal("apple_p8_key"),
v.literal("apple_p8_asc_api_key"),
v.literal("android_service_account"),
+ v.literal("apple_iap_review_screenshot"),
),
description: v.optional(v.string()),
metadata: v.optional(v.any()),
@@ -149,6 +151,18 @@ export const saveFile = mutation({
};
}
+ if (
+ args.purpose === "apple_iap_review_screenshot" &&
+ membership.role === "member"
+ ) {
+ await deleteUnclaimedUpload(ctx, args.storageId);
+ await ctx.db.delete(reservation._id);
+ return {
+ success: false as const,
+ code: "INSUFFICIENT_PERMISSIONS" as const,
+ };
+ }
+
// Bind each upload to exactly one application reference. Organization
// avatars also reference `_storage` directly, so the shared indexed check
// must protect both active claims and cleanup paths. Its range reads give
@@ -175,6 +189,64 @@ export const saveFile = mutation({
};
}
+ if (args.purpose === "apple_iap_review_screenshot") {
+ try {
+ if (!args.projectId) {
+ throw new ConvexError(
+ "App Review screenshots must belong to a project",
+ );
+ }
+ validateFileUpload(
+ args.fileName,
+ args.fileType,
+ args.fileSize,
+ args.purpose,
+ );
+ // Trust the system storage record, not browser-supplied metadata. This
+ // catches a client that reserves a small file but uploads a larger one.
+ if (uploadedFile.size !== args.fileSize) {
+ throw new ConvexError(
+ "App Review screenshot size does not match the uploaded blob",
+ );
+ }
+ const validated = reservation.validatedAppleReviewScreenshot;
+ if (
+ !validated ||
+ validated.storageId !== args.storageId ||
+ validated.fileName !== args.fileName ||
+ validated.fileType !== args.fileType ||
+ validated.fileSize !== args.fileSize
+ ) {
+ throw new ConvexError(
+ "App Review screenshot binary was not validated by the server",
+ );
+ }
+ } catch (error) {
+ await deleteUnclaimedUpload(ctx, args.storageId);
+ await ctx.db.delete(reservation._id);
+ return {
+ success: false as const,
+ code: "INVALID_FILE" as const,
+ message: error instanceof Error ? error.message : String(error),
+ };
+ }
+ }
+
+ // The screenshot is a single project-level slot. Reading the indexed
+ // range before the insert makes concurrent uploads conflict under Convex
+ // OCC; after retry, the later successful save atomically replaces the
+ // earlier row and reclaims its private blob.
+ const screenshotsToReplace =
+ args.purpose === "apple_iap_review_screenshot" && args.projectId
+ ? await ctx.db
+ .query("files")
+ .withIndex("by_project", (q) => q.eq("projectId", args.projectId))
+ .filter((q) =>
+ q.eq(q.field("purpose"), "apple_iap_review_screenshot"),
+ )
+ .collect()
+ : [];
+
const fileId = await ctx.db.insert("files", {
organizationId: args.organizationId,
projectId: args.projectId,
@@ -186,12 +258,22 @@ export const saveFile = mutation({
purpose: args.purpose,
description: args.description,
metadata: args.metadata,
- isInternal: args.isInternal ?? true,
+ // App Review screenshots are private project data regardless of what a
+ // public caller sends. Other purposes retain the existing opt-out for
+ // backwards compatibility.
+ isInternal:
+ args.purpose === "apple_iap_review_screenshot"
+ ? true
+ : (args.isInternal ?? true),
accessCount: 0,
createdAt: now,
updatedAt: now,
});
+ for (const priorScreenshot of screenshotsToReplace) {
+ await deleteFileAndStorageIfUnreferenced(ctx, priorScreenshot);
+ }
+
// Consume the capability in the same transaction as the file insert so a
// retry can never register or reclaim a second storage object with it.
await ctx.db.delete(reservation._id);
@@ -209,6 +291,121 @@ export const saveFile = mutation({
},
});
+export const markAppleReviewScreenshotValidated = internalMutation({
+ args: {
+ uploadReservationId: v.id("fileUploadReservations"),
+ userId: v.id("users"),
+ storageId: v.id("_storage"),
+ fileName: v.string(),
+ fileType: v.string(),
+ fileSize: v.number(),
+ },
+ handler: async (ctx, args) => {
+ const reservation = await ctx.db.get(args.uploadReservationId);
+ const alreadyValidated = reservation?.validatedAppleReviewScreenshot;
+ if (
+ reservation &&
+ reservation.createdBy === args.userId &&
+ reservation.expiresAt > Date.now() &&
+ alreadyValidated?.storageId === args.storageId &&
+ alreadyValidated.fileName === args.fileName &&
+ alreadyValidated.fileType === args.fileType &&
+ alreadyValidated.fileSize === args.fileSize
+ ) {
+ return;
+ }
+ if (
+ !reservation ||
+ reservation.createdBy !== args.userId ||
+ reservation.expiresAt <= Date.now() ||
+ reservation.pendingAppleReviewScreenshotStorageId !== args.storageId
+ ) {
+ throw new ConvexError("Invalid upload reservation");
+ }
+ const uploadedFile = await ctx.db.system.get("_storage", args.storageId);
+ if (!uploadedFile || uploadedFile.size !== args.fileSize) {
+ throw new ConvexError("Uploaded screenshot size does not match storage");
+ }
+ await ctx.db.patch(reservation._id, {
+ pendingAppleReviewScreenshotStorageId: undefined,
+ validatedAppleReviewScreenshot: {
+ storageId: args.storageId,
+ fileName: args.fileName,
+ fileType: args.fileType,
+ fileSize: args.fileSize,
+ },
+ });
+ },
+});
+
+export const markAppleReviewScreenshotValidationPending = internalMutation({
+ args: {
+ uploadReservationId: v.id("fileUploadReservations"),
+ userId: v.id("users"),
+ storageId: v.id("_storage"),
+ fileSize: v.number(),
+ },
+ handler: async (ctx, args) => {
+ const reservation = await ctx.db.get(args.uploadReservationId);
+ if (
+ !reservation ||
+ reservation.createdBy !== args.userId ||
+ reservation.expiresAt <= Date.now()
+ ) {
+ throw new ConvexError("Invalid upload reservation");
+ }
+ const existingStorageId =
+ reservation.pendingAppleReviewScreenshotStorageId ??
+ reservation.validatedAppleReviewScreenshot?.storageId;
+ if (existingStorageId && existingStorageId !== args.storageId) {
+ throw new ConvexError("Upload reservation is already bound to a file");
+ }
+ const uploadedFile = await ctx.db.system.get("_storage", args.storageId);
+ if (!uploadedFile || uploadedFile.size !== args.fileSize) {
+ throw new ConvexError("Uploaded screenshot size does not match storage");
+ }
+ await ctx.db.patch(reservation._id, {
+ pendingAppleReviewScreenshotStorageId: args.storageId,
+ });
+ },
+});
+
+export const rejectAppleReviewScreenshotValidation = internalMutation({
+ args: {
+ uploadReservationId: v.id("fileUploadReservations"),
+ organizationId: v.id("organizations"),
+ projectId: v.id("projects"),
+ storageId: v.id("_storage"),
+ },
+ handler: async (ctx, args) => {
+ const reservation = await ctx.db.get(args.uploadReservationId);
+ if (
+ !reservation ||
+ reservation.organizationId !== args.organizationId ||
+ reservation.projectId !== args.projectId
+ ) {
+ return;
+ }
+ if (
+ reservation.validatedAppleReviewScreenshot?.storageId === args.storageId
+ ) {
+ // Another validation of the same immutable blob already completed. A
+ // slower duplicate attempt must not erase the successful marker/blob.
+ return;
+ }
+ await deleteUnclaimedUpload(ctx, args.storageId);
+ const boundStorageId =
+ reservation.pendingAppleReviewScreenshotStorageId ??
+ reservation.validatedAppleReviewScreenshot?.storageId;
+ // A concurrent validation may already have bound this capability to a
+ // different blob. Reclaim this failed caller's unclaimed object without
+ // consuming the other in-flight operation's reservation.
+ if (!boundStorageId || boundStorageId === args.storageId) {
+ await ctx.db.delete(reservation._id);
+ }
+ },
+});
+
export const remove = mutation({
args: {
fileId: v.id("files"),
diff --git a/packages/kit/convex/files/query.ts b/packages/kit/convex/files/query.ts
index e6547d5bc..4367fdd6f 100644
--- a/packages/kit/convex/files/query.ts
+++ b/packages/kit/convex/files/query.ts
@@ -35,6 +35,7 @@ export const list = query({
v.literal("apple_p8_key"),
v.literal("apple_p8_asc_api_key"),
v.literal("android_service_account"),
+ v.literal("apple_iap_review_screenshot"),
),
),
},
@@ -216,6 +217,7 @@ export const count = query({
v.literal("apple_p8_key"),
v.literal("apple_p8_asc_api_key"),
v.literal("android_service_account"),
+ v.literal("apple_iap_review_screenshot"),
),
),
},
diff --git a/packages/kit/convex/files/review-screenshot.test.ts b/packages/kit/convex/files/review-screenshot.test.ts
new file mode 100644
index 000000000..d474f1578
--- /dev/null
+++ b/packages/kit/convex/files/review-screenshot.test.ts
@@ -0,0 +1,643 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const authMocks = vi.hoisted(() => ({ getAuthUserId: vi.fn() }));
+
+vi.mock("@convex-dev/auth/server", () => ({
+ getAuthUserId: authMocks.getAuthUserId,
+}));
+
+import {
+ getAppleReviewScreenshotByProjectInternal as registeredGetScreenshot,
+ readFileAsBase64 as registeredReadFileAsBase64,
+} from "./internal";
+import {
+ rejectAppleReviewScreenshotValidation as registeredRejectValidation,
+ saveFile as registeredSaveFile,
+} from "./mutation";
+import {
+ decodeAppleReviewScreenshot,
+ validateAppleReviewScreenshotUpload as registeredValidateUpload,
+} from "./action";
+import { testableFunction } from "../test.setup";
+
+const getScreenshot = testableFunction(registeredGetScreenshot);
+const readFileAsBase64 = testableFunction(
+ registeredReadFileAsBase64,
+) as unknown as {
+ _handler: (ctx: unknown, args: unknown) => Promise>;
+};
+const saveFile = testableFunction(registeredSaveFile);
+const rejectValidation = testableFunction(registeredRejectValidation);
+const validateUpload = testableFunction(
+ registeredValidateUpload,
+) as unknown as {
+ _handler: (ctx: unknown, args: unknown) => Promise<{ valid: true }>;
+};
+
+const FLAT_PNG_BYTES = Uint8Array.from(
+ Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAEElEQVR4nGP8wwACLGCSAQANBAECv1AVswAAAABJRU5ErkJggg==",
+ "base64",
+ ),
+);
+const JPEG_BYTES = Uint8Array.from(
+ Buffer.from(
+ "/9j/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAKAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAABwn/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdAAYqm//Z",
+ "base64",
+ ),
+);
+
+type Row = Record & { _id: string };
+type RowPredicate = (row: Row) => boolean;
+
+class IndexBuilder {
+ readonly predicates: RowPredicate[] = [];
+
+ eq(field: string, value: unknown): this {
+ this.predicates.push((row) => row[field] === value);
+ return this;
+ }
+}
+
+class FilterBuilder {
+ field(name: string): string {
+ return name;
+ }
+
+ eq(field: string, value: unknown): RowPredicate {
+ return (row) => row[field] === value;
+ }
+}
+
+class TestQuery {
+ constructor(private readonly rows: Row[]) {}
+
+ withIndex(
+ _name: string,
+ build: (builder: IndexBuilder) => IndexBuilder,
+ ): TestQuery {
+ const builder = build(new IndexBuilder());
+ return new TestQuery(
+ this.rows.filter((row) =>
+ builder.predicates.every((predicate) => predicate(row)),
+ ),
+ );
+ }
+
+ filter(build: (builder: FilterBuilder) => RowPredicate): TestQuery {
+ const predicate = build(new FilterBuilder());
+ return new TestQuery(this.rows.filter(predicate));
+ }
+
+ order(direction: "asc" | "desc"): TestQuery {
+ return new TestQuery(
+ direction === "desc" ? [...this.rows].reverse() : [...this.rows],
+ );
+ }
+
+ async first(): Promise {
+ return this.rows[0] ?? null;
+ }
+
+ async collect(): Promise {
+ return [...this.rows];
+ }
+}
+
+class TestDb {
+ readonly system: {
+ get: ReturnType;
+ };
+ private insertCounter = 0;
+
+ constructor(
+ readonly tables: Record,
+ storageSizes: Record,
+ ) {
+ this.system = {
+ get: vi.fn(async (_table: string, id: string) => {
+ const size = storageSizes[id];
+ return size === undefined ? null : { _id: id, size };
+ }),
+ };
+ }
+
+ async get(id: string): Promise {
+ return (
+ Object.values(this.tables)
+ .flat()
+ .find((row) => row._id === id) ?? null
+ );
+ }
+
+ query(table: string): TestQuery {
+ return new TestQuery(this.tables[table] ?? []);
+ }
+
+ async insert(table: string, value: Record): Promise {
+ this.insertCounter += 1;
+ const id = `${table}_new_${this.insertCounter}`;
+ (this.tables[table] ??= []).push({ _id: id, ...value });
+ return id;
+ }
+
+ async delete(id: string): Promise {
+ for (const rows of Object.values(this.tables)) {
+ const index = rows.findIndex((row) => row._id === id);
+ if (index >= 0) {
+ rows.splice(index, 1);
+ return;
+ }
+ }
+ }
+}
+
+function makeSaveCtx(args: {
+ fileType?: string;
+ newSize?: number;
+ declaredSize?: number;
+}) {
+ const now = Date.now();
+ const tables: Record = {
+ organizations: [{ _id: "organizations_a" }],
+ projects: [{ _id: "projects_a", organizationId: "organizations_a" }],
+ organizationMembers: [
+ {
+ _id: "members_a",
+ organizationId: "organizations_a",
+ userId: "users_a",
+ role: "admin",
+ },
+ ],
+ fileUploadReservations: [
+ {
+ _id: "reservation_a",
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ createdBy: "users_a",
+ expiresAt: now + 60_000,
+ cleanupExpiresAt: now + 120_000,
+ validatedAppleReviewScreenshot: {
+ storageId: "storage_new",
+ fileName: "new.png",
+ fileType: args.fileType ?? "image/png",
+ fileSize: args.declaredSize ?? 256,
+ },
+ },
+ ],
+ files: [
+ {
+ _id: "files_old",
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ uploadedBy: "users_a",
+ storageId: "storage_old",
+ fileName: "old.png",
+ fileType: "image/png",
+ fileSize: 128,
+ purpose: "apple_iap_review_screenshot",
+ isInternal: true,
+ createdAt: now - 1_000,
+ updatedAt: now - 1_000,
+ },
+ ],
+ };
+ const declaredSize = args.declaredSize ?? 256;
+ const db = new TestDb(tables, {
+ storage_old: 128,
+ storage_new: args.newSize ?? declaredSize,
+ });
+ const storage = { delete: vi.fn(async () => undefined) };
+ return {
+ ctx: { db, storage },
+ db,
+ storage,
+ tables,
+ saveArgs: {
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ uploadReservationId: "reservation_a",
+ storageId: "storage_new",
+ fileName: "new.png",
+ fileType: args.fileType ?? "image/png",
+ fileSize: declaredSize,
+ purpose: "apple_iap_review_screenshot" as const,
+ isInternal: true,
+ },
+ };
+}
+
+describe("App Review screenshot private storage", () => {
+ beforeEach(() => {
+ authMocks.getAuthUserId.mockReset();
+ authMocks.getAuthUserId.mockResolvedValue("users_a");
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("requires a complete image decode instead of accepting a spoofed header", async () => {
+ await expect(
+ decodeAppleReviewScreenshot(FLAT_PNG_BYTES, "image/png"),
+ ).resolves.toBeUndefined();
+
+ const headerOnly = new Uint8Array(26);
+ headerOnly.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ headerOnly.set([0x49, 0x48, 0x44, 0x52], 12);
+ headerOnly[25] = 2;
+ await expect(
+ decodeAppleReviewScreenshot(headerOnly, "image/png"),
+ ).rejects.toThrow(/truncated|corrupt|decode/);
+
+ await expect(
+ decodeAppleReviewScreenshot(JPEG_BYTES, "image/jpeg"),
+ ).resolves.toBeUndefined();
+ await expect(
+ decodeAppleReviewScreenshot(
+ Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]),
+ "image/jpeg",
+ ),
+ ).rejects.toThrow(/truncated|corrupt|decode/);
+ });
+
+ it("marks a server-decoded upload pending and then validated", async () => {
+ const mutations: Array> = [];
+ const ctx = {
+ runQuery: vi
+ .fn()
+ .mockResolvedValueOnce({
+ reservation: {
+ _id: "reservation_a",
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ createdBy: "users_a",
+ expiresAt: Date.now() + 60_000,
+ },
+ storage: { size: FLAT_PNG_BYTES.byteLength },
+ })
+ .mockResolvedValueOnce({ role: "admin" }),
+ runMutation: vi.fn(async (_reference, args) => {
+ mutations.push(args as Record);
+ }),
+ storage: {
+ getUrl: vi.fn(async () => "https://storage.example.test/review.png"),
+ },
+ };
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () =>
+ Promise.resolve(
+ new Response(FLAT_PNG_BYTES, {
+ status: 200,
+ headers: { "content-type": "image/png" },
+ }),
+ ),
+ ),
+ );
+
+ await expect(
+ validateUpload._handler(ctx, {
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ uploadReservationId: "reservation_a",
+ storageId: "storage_new",
+ fileName: "review.png",
+ fileType: "image/png",
+ fileSize: FLAT_PNG_BYTES.byteLength,
+ }),
+ ).resolves.toEqual({ valid: true });
+ expect(mutations).toEqual([
+ expect.objectContaining({
+ uploadReservationId: "reservation_a",
+ storageId: "storage_new",
+ fileSize: FLAT_PNG_BYTES.byteLength,
+ }),
+ expect.objectContaining({
+ uploadReservationId: "reservation_a",
+ storageId: "storage_new",
+ fileName: "review.png",
+ fileType: "image/png",
+ }),
+ ]);
+ });
+
+ it.each([
+ ["member access", "users_a", { role: "member" }, Date.now() + 60_000],
+ ["expired reservation", "users_a", { role: "admin" }, Date.now() - 1],
+ ["lost session", null, { role: "admin" }, Date.now() + 60_000],
+ ])(
+ "reclaims the blob after %s",
+ async (_label, userId, membership, expiresAt) => {
+ authMocks.getAuthUserId.mockResolvedValueOnce(userId);
+ const runMutation = vi.fn(async () => undefined);
+ const ctx = {
+ runQuery: vi
+ .fn()
+ .mockResolvedValueOnce({
+ reservation: {
+ _id: "reservation_a",
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ createdBy: "users_a",
+ expiresAt,
+ },
+ storage: { size: FLAT_PNG_BYTES.byteLength },
+ })
+ .mockResolvedValueOnce(membership),
+ runMutation,
+ storage: { getUrl: vi.fn() },
+ };
+
+ await expect(
+ validateUpload._handler(ctx, {
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ uploadReservationId: "reservation_a",
+ storageId: "storage_new",
+ fileName: "review.png",
+ fileType: "image/png",
+ fileSize: FLAT_PNG_BYTES.byteLength,
+ }),
+ ).rejects.toThrow();
+ expect(runMutation).toHaveBeenCalledOnce();
+ expect(runMutation).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ uploadReservationId: "reservation_a",
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ storageId: "storage_new",
+ }),
+ );
+ },
+ );
+
+ it("does not clean up another user's reservation", async () => {
+ authMocks.getAuthUserId.mockResolvedValueOnce("users_b");
+ const runMutation = vi.fn();
+ const ctx = {
+ runQuery: vi.fn(async () => ({
+ reservation: {
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ createdBy: "users_a",
+ expiresAt: Date.now() + 60_000,
+ },
+ storage: { size: FLAT_PNG_BYTES.byteLength },
+ })),
+ runMutation,
+ storage: { getUrl: vi.fn() },
+ };
+
+ await expect(
+ validateUpload._handler(ctx, {
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ uploadReservationId: "reservation_a",
+ storageId: "storage_new",
+ fileName: "review.png",
+ fileType: "image/png",
+ fileSize: FLAT_PNG_BYTES.byteLength,
+ }),
+ ).rejects.toThrow("Invalid upload reservation");
+ expect(runMutation).not.toHaveBeenCalled();
+ });
+
+ it("preserves an exact blob that a concurrent validation already accepted", async () => {
+ const now = Date.now();
+ const tables: Record = {
+ fileUploadReservations: [
+ {
+ _id: "reservation_a",
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ createdBy: "users_a",
+ expiresAt: now + 60_000,
+ cleanupExpiresAt: now + 120_000,
+ validatedAppleReviewScreenshot: {
+ storageId: "storage_new",
+ fileName: "review.png",
+ fileType: "image/png",
+ fileSize: FLAT_PNG_BYTES.byteLength,
+ },
+ },
+ ],
+ };
+ const db = new TestDb(tables, {
+ storage_new: FLAT_PNG_BYTES.byteLength,
+ });
+ const storage = { delete: vi.fn(async () => undefined) };
+
+ await rejectValidation._handler({ db, storage }, {
+ uploadReservationId: "reservation_a",
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ storageId: "storage_new",
+ } as never);
+ expect(storage.delete).not.toHaveBeenCalled();
+ expect(tables.fileUploadReservations).toHaveLength(1);
+ });
+
+ it("atomically replaces the project slot and reclaims the old blob", async () => {
+ const { ctx, storage, tables, saveArgs } = makeSaveCtx({});
+
+ await expect(
+ saveFile._handler(ctx as never, saveArgs as never),
+ ).resolves.toMatchObject({
+ success: true,
+ purpose: "apple_iap_review_screenshot",
+ });
+
+ expect(tables.files).toHaveLength(1);
+ expect(tables.files?.[0]).toMatchObject({
+ storageId: "storage_new",
+ fileName: "new.png",
+ });
+ expect(storage.delete).toHaveBeenCalledWith("storage_old");
+ expect(tables.fileUploadReservations).toHaveLength(0);
+ });
+
+ it("forces the screenshot slot to internal even when a caller sends false", async () => {
+ const { ctx, tables, saveArgs } = makeSaveCtx({});
+
+ await expect(
+ saveFile._handler(
+ ctx as never,
+ {
+ ...saveArgs,
+ isInternal: false,
+ } as never,
+ ),
+ ).resolves.toMatchObject({ success: true });
+
+ expect(tables.files).toHaveLength(1);
+ expect(tables.files?.[0]?.isInternal).toBe(true);
+ });
+
+ it("rejects invalid metadata, deletes the unclaimed blob, and preserves the old slot", async () => {
+ const { ctx, storage, tables, saveArgs } = makeSaveCtx({
+ fileType: "application/octet-stream",
+ });
+
+ await expect(
+ saveFile._handler(ctx as never, saveArgs as never),
+ ).resolves.toMatchObject({
+ success: false,
+ code: "INVALID_FILE",
+ });
+ expect(tables.files).toHaveLength(1);
+ expect(tables.files?.[0]?._id).toBe("files_old");
+ expect(storage.delete).toHaveBeenCalledWith("storage_new");
+ });
+
+ it("rejects a declared size that differs from Convex storage metadata", async () => {
+ const { ctx, storage, tables, saveArgs } = makeSaveCtx({
+ declaredSize: 256,
+ newSize: 300,
+ });
+
+ await expect(
+ saveFile._handler(ctx as never, saveArgs as never),
+ ).resolves.toMatchObject({
+ success: false,
+ code: "INVALID_FILE",
+ message: expect.stringMatching(/size does not match/),
+ });
+ expect(tables.files).toHaveLength(1);
+ expect(storage.delete).toHaveBeenCalledWith("storage_new");
+ });
+
+ it("rejects a screenshot that skipped server-side binary validation", async () => {
+ const { ctx, storage, tables, saveArgs } = makeSaveCtx({});
+ const reservation = tables.fileUploadReservations?.[0];
+ if (!reservation) throw new Error("reservation fixture missing");
+ delete reservation.validatedAppleReviewScreenshot;
+
+ await expect(
+ saveFile._handler(ctx as never, saveArgs as never),
+ ).resolves.toMatchObject({
+ success: false,
+ code: "INVALID_FILE",
+ message: expect.stringMatching(/not validated/),
+ });
+ expect(tables.files).toHaveLength(1);
+ expect(tables.files?.[0]?._id).toBe("files_old");
+ expect(storage.delete).toHaveBeenCalledWith("storage_new");
+ expect(tables.fileUploadReservations).toHaveLength(0);
+ });
+
+ it("does not let a member replace the admin-managed screenshot slot", async () => {
+ const { ctx, storage, tables, saveArgs } = makeSaveCtx({});
+ const membership = tables.organizationMembers?.[0];
+ if (!membership) throw new Error("membership fixture missing");
+ membership.role = "member";
+
+ await expect(
+ saveFile._handler(ctx as never, saveArgs as never),
+ ).resolves.toMatchObject({
+ success: false,
+ code: "INSUFFICIENT_PERMISSIONS",
+ });
+ expect(tables.files).toHaveLength(1);
+ expect(tables.files?.[0]?._id).toBe("files_old");
+ expect(storage.delete).toHaveBeenCalledWith("storage_new");
+ expect(tables.fileUploadReservations).toHaveLength(0);
+ });
+
+ it("looks up only the newest screenshot for the exact project", async () => {
+ const ctx = {
+ db: new TestDb(
+ {
+ files: [
+ {
+ _id: "org_default",
+ purpose: "apple_iap_review_screenshot",
+ fileName: "org.png",
+ },
+ {
+ _id: "project_a_old",
+ storageId: "storage_old",
+ projectId: "projects_a",
+ purpose: "apple_iap_review_screenshot",
+ fileName: "old.png",
+ fileType: "image/png",
+ fileSize: 10,
+ createdAt: 1,
+ },
+ {
+ _id: "project_b",
+ projectId: "projects_b",
+ purpose: "apple_iap_review_screenshot",
+ fileName: "other.png",
+ },
+ {
+ _id: "project_a_new",
+ storageId: "storage_new",
+ projectId: "projects_a",
+ purpose: "apple_iap_review_screenshot",
+ fileName: "new.png",
+ fileType: "image/png",
+ fileSize: 20,
+ createdAt: 2,
+ },
+ ],
+ },
+ {},
+ ),
+ storage: {
+ getUrl: vi.fn(async (storageId: string) =>
+ storageId === "storage_new"
+ ? "https://storage.example.test/private-new"
+ : null,
+ ),
+ },
+ };
+
+ await expect(
+ getScreenshot._handler(
+ ctx as never,
+ { projectId: "projects_a" } as never,
+ ),
+ ).resolves.toEqual({
+ fileId: "project_a_new",
+ fileName: "new.png",
+ fileType: "image/png",
+ fileSize: 20,
+ createdAt: 2,
+ storageUrl: "https://storage.example.test/private-new",
+ });
+ });
+
+ it("returns binary content through an internal action without a storage id or URL", async () => {
+ const ctx = {
+ runQuery: vi.fn(async () => ({
+ _id: "files_a",
+ storageId: "storage_private",
+ fileName: "review.jpg",
+ fileType: "image/jpeg",
+ fileSize: 4,
+ purpose: "apple_iap_review_screenshot",
+ })),
+ storage: {
+ get: vi.fn(
+ async () => new Blob([Uint8Array.from([0xff, 0xd8, 0xff, 0xd9])]),
+ ),
+ },
+ };
+
+ const result = await readFileAsBase64._handler(ctx, {
+ fileId: "files_a",
+ });
+ expect(result).toMatchObject({
+ fileId: "files_a",
+ fileName: "review.jpg",
+ fileType: "image/jpeg",
+ fileSize: 4,
+ purpose: "apple_iap_review_screenshot",
+ content: "/9j/2Q==",
+ });
+ expect(result).not.toHaveProperty("storageId");
+ expect(result).not.toHaveProperty("url");
+ });
+});
diff --git a/packages/kit/convex/files/validation.test.ts b/packages/kit/convex/files/validation.test.ts
new file mode 100644
index 000000000..e4acca8ea
--- /dev/null
+++ b/packages/kit/convex/files/validation.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ validateAppleReviewScreenshotContent,
+ validateFileUpload,
+} from "./validation";
+
+function pngBytes(colorType: number): Uint8Array {
+ const bytes = new Uint8Array(26);
+ bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ bytes.set([0x49, 0x48, 0x44, 0x52], 12);
+ bytes[25] = colorType;
+ return bytes;
+}
+
+function pngBytesWithTransparencyChunk(): Uint8Array {
+ const bytes = new Uint8Array(46);
+ bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ new DataView(bytes.buffer).setUint32(8, 13, false);
+ bytes.set([0x49, 0x48, 0x44, 0x52], 12);
+ bytes[25] = 3;
+ new DataView(bytes.buffer).setUint32(33, 1, false);
+ bytes.set([0x74, 0x52, 0x4e, 0x53], 37);
+ return bytes;
+}
+
+describe("Apple App Review screenshot validation", () => {
+ it.each([
+ ["review.png", "image/png"],
+ ["review.jpg", "image/jpeg"],
+ ["review.jpeg", "image/jpeg"],
+ ])("accepts supported metadata for %s", (fileName, fileType) => {
+ expect(() =>
+ validateFileUpload(
+ fileName,
+ fileType,
+ 1024,
+ "apple_iap_review_screenshot",
+ ),
+ ).not.toThrow();
+ });
+
+ it("strictly rejects spoofed MIME, unsupported extensions, empty, and oversized files", () => {
+ expect(() =>
+ validateFileUpload(
+ "review.png",
+ "application/octet-stream",
+ 1024,
+ "apple_iap_review_screenshot",
+ ),
+ ).toThrow(/Invalid MIME type/);
+ expect(() =>
+ validateFileUpload("review.png", "", 1024, "apple_iap_review_screenshot"),
+ ).toThrow(/Invalid MIME type/);
+ expect(() =>
+ validateFileUpload(
+ "review.png",
+ "image/jpeg",
+ 1024,
+ "apple_iap_review_screenshot",
+ ),
+ ).toThrow(/extension must match/);
+ expect(() =>
+ validateFileUpload(
+ "review.gif",
+ "image/png",
+ 1024,
+ "apple_iap_review_screenshot",
+ ),
+ ).toThrow(/Invalid file extension/);
+ expect(() =>
+ validateFileUpload(
+ "review.png",
+ "image/png",
+ 0,
+ "apple_iap_review_screenshot",
+ ),
+ ).toThrow(/cannot be empty/);
+ expect(() =>
+ validateFileUpload(
+ "review.jpg",
+ "image/jpeg",
+ 10 * 1024 * 1024 + 1,
+ "apple_iap_review_screenshot",
+ ),
+ ).toThrow(/too large/);
+ });
+
+ it("checks PNG/JPEG magic and MIME at private-blob read time", () => {
+ expect(() =>
+ validateAppleReviewScreenshotContent(pngBytes(2), "image/png"),
+ ).not.toThrow();
+ expect(() =>
+ validateAppleReviewScreenshotContent(
+ Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]),
+ "image/jpeg",
+ ),
+ ).not.toThrow();
+ expect(() =>
+ validateAppleReviewScreenshotContent(pngBytes(2), "image/jpeg"),
+ ).toThrow(/MIME type does not match/);
+ expect(() =>
+ validateAppleReviewScreenshotContent(
+ Uint8Array.from([1, 2, 3, 4]),
+ "image/png",
+ ),
+ ).toThrow(/valid PNG or JPEG/);
+ });
+
+ it("rejects PNG alpha channels that ASC cannot process", () => {
+ expect(() =>
+ validateAppleReviewScreenshotContent(pngBytes(6), "image/png"),
+ ).toThrow(/alpha channel/);
+ expect(() =>
+ validateAppleReviewScreenshotContent(pngBytes(4), "image/png"),
+ ).toThrow(/alpha channel/);
+ expect(() =>
+ validateAppleReviewScreenshotContent(
+ pngBytesWithTransparencyChunk(),
+ "image/png",
+ ),
+ ).toThrow(/transparency metadata/);
+ });
+});
+
+describe("existing file validation", () => {
+ it("keeps an empty MIME type optional for non-screenshot uploads", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
+
+ try {
+ expect(() =>
+ validateFileUpload("config.json", "", 1024, "config"),
+ ).not.toThrow();
+ expect(warn).not.toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ }
+ });
+});
diff --git a/packages/kit/convex/files/validation.ts b/packages/kit/convex/files/validation.ts
index f32881c3e..71f4fd46f 100644
--- a/packages/kit/convex/files/validation.ts
+++ b/packages/kit/convex/files/validation.ts
@@ -50,6 +50,12 @@ const FILE_VALIDATIONS = {
maxSize: 500 * 1024, // 500KB max for credentials (service accounts can be larger)
description: "API credential or key",
},
+ apple_iap_review_screenshot: {
+ extensions: [".png", ".jpg", ".jpeg"],
+ mimeTypes: ["image/png", "image/jpeg"],
+ maxSize: 10 * 1024 * 1024,
+ description: "Apple in-app purchase App Review screenshot",
+ },
other: {
extensions: [], // No restriction for "other" type
mimeTypes: [],
@@ -68,10 +74,10 @@ export function validateFile(
purpose: FilePurpose,
): void {
const validation = FILE_VALIDATIONS[purpose];
+ const fileExtension = getFileExtension(fileName).toLowerCase();
// Check file extension
if (validation.extensions.length > 0) {
- const fileExtension = getFileExtension(fileName).toLowerCase();
const extensionsList = validation.extensions as readonly string[];
if (!extensionsList.includes(fileExtension)) {
throw new ConvexError(
@@ -83,15 +89,27 @@ export function validateFile(
}
// Check MIME type (more lenient since browsers can be inconsistent)
- if (validation.mimeTypes.length > 0 && fileType) {
- // Allow if MIME type matches OR if it's a generic binary/text type
+ if (
+ validation.mimeTypes.length > 0 &&
+ (fileType !== "" || purpose === "apple_iap_review_screenshot")
+ ) {
+ // Credentials are frequently labelled as generic bytes by browsers.
+ // Review screenshots are different: ASC only accepts PNG/JPEG and we
+ // must not persist a spoofed content type for a later binary upload.
const mimeTypesList = validation.mimeTypes as readonly string[];
const isValidMime =
- mimeTypesList.includes(fileType) ||
- fileType === "application/octet-stream" ||
- fileType === "text/plain";
+ (fileType !== "" && mimeTypesList.includes(fileType)) ||
+ (purpose !== "apple_iap_review_screenshot" &&
+ (fileType === "application/octet-stream" || fileType === "text/plain"));
if (!isValidMime) {
+ if (purpose === "apple_iap_review_screenshot") {
+ throw new ConvexError(
+ `Invalid MIME type for ${purpose}. ` +
+ `Expected one of: ${validation.mimeTypes.join(", ")}. ` +
+ `Got: ${fileType || "(empty)"}`,
+ );
+ }
console.warn(
`Unexpected MIME type for ${purpose}: ${fileType}. ` +
`Expected one of: ${validation.mimeTypes.join(", ")}`,
@@ -101,6 +119,17 @@ export function validateFile(
}
}
+ if (
+ purpose === "apple_iap_review_screenshot" &&
+ ((fileExtension === ".png" && fileType !== "image/png") ||
+ ((fileExtension === ".jpg" || fileExtension === ".jpeg") &&
+ fileType !== "image/jpeg"))
+ ) {
+ throw new ConvexError(
+ "App Review screenshot extension must match its PNG or JPEG MIME type",
+ );
+ }
+
// Check file size
if (fileSize > validation.maxSize) {
throw new ConvexError(
@@ -283,5 +312,102 @@ export function validateFileUpload(
validateJsonConfig(fileName, fileType, fileSize);
}
break;
+ case "apple_iap_review_screenshot":
+ // Extension, strict MIME, non-empty size, and the 10 MB cap are all
+ // enforced above. Binary magic is checked again immediately before ASC
+ // upload, after reading the private blob from Convex storage.
+ break;
+ }
+}
+
+/**
+ * Validate the private blob immediately before it is uploaded to ASC.
+ *
+ * Browser-provided names and MIME types are metadata only. This lightweight
+ * signature/transparency check runs in both runtimes; the upload reservation
+ * receives its trusted marker only after `files/action.ts` also performs a
+ * full Sharp decode. PNG screenshots with alpha are rejected because App
+ * Store Connect rejects them after upload processing.
+ */
+export function validateAppleReviewScreenshotContent(
+ content: Uint8Array,
+ declaredMimeType: string,
+): void {
+ if (content.byteLength === 0) {
+ throw new ConvexError("App Review screenshot cannot be empty");
+ }
+ if (content.byteLength > 10 * 1024 * 1024) {
+ throw new ConvexError("App Review screenshot must be 10 MB or smaller");
+ }
+
+ const isPng =
+ content.byteLength >= 26 &&
+ content[0] === 0x89 &&
+ content[1] === 0x50 &&
+ content[2] === 0x4e &&
+ content[3] === 0x47 &&
+ content[4] === 0x0d &&
+ content[5] === 0x0a &&
+ content[6] === 0x1a &&
+ content[7] === 0x0a &&
+ String.fromCharCode(...content.subarray(12, 16)) === "IHDR";
+ const isJpeg =
+ content.byteLength >= 4 &&
+ content[0] === 0xff &&
+ content[1] === 0xd8 &&
+ content[content.byteLength - 2] === 0xff &&
+ content[content.byteLength - 1] === 0xd9;
+
+ if (!isPng && !isJpeg) {
+ throw new ConvexError(
+ "App Review screenshot content must be a valid PNG or JPEG",
+ );
+ }
+ if (isPng && declaredMimeType !== "image/png") {
+ throw new ConvexError(
+ "App Review screenshot MIME type does not match its PNG content",
+ );
+ }
+ if (isJpeg && declaredMimeType !== "image/jpeg") {
+ throw new ConvexError(
+ "App Review screenshot MIME type does not match its JPEG content",
+ );
+ }
+
+ // PNG IHDR byte 25 is the color type: 4 and 6 include alpha.
+ if (isPng && (content[25] === 4 || content[25] === 6)) {
+ throw new ConvexError(
+ "App Review PNG screenshots cannot contain an alpha channel",
+ );
+ }
+ if (isPng && pngContainsTransparencyChunk(content)) {
+ throw new ConvexError(
+ "App Review PNG screenshots cannot contain transparency metadata",
+ );
+ }
+}
+
+function pngContainsTransparencyChunk(content: Uint8Array): boolean {
+ const view = new DataView(
+ content.buffer,
+ content.byteOffset,
+ content.byteLength,
+ );
+ let offset = 8;
+ while (offset + 12 <= content.byteLength) {
+ const length = view.getUint32(offset, false);
+ const typeOffset = offset + 4;
+ if (
+ content[typeOffset] === 0x74 &&
+ content[typeOffset + 1] === 0x52 &&
+ content[typeOffset + 2] === 0x4e &&
+ content[typeOffset + 3] === 0x53
+ ) {
+ return true;
+ }
+ const nextOffset = offset + 12 + length;
+ if (nextOffset <= offset || nextOffset > content.byteLength) return false;
+ offset = nextOffset;
}
+ return false;
}
diff --git a/packages/kit/convex/products/asc.test.ts b/packages/kit/convex/products/asc.test.ts
index 16d986ff1..4740b173b 100644
--- a/packages/kit/convex/products/asc.test.ts
+++ b/packages/kit/convex/products/asc.test.ts
@@ -1,14 +1,395 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import {
ascCustomerPriceToMicros,
+ createAscReviewEligibilityLoader,
+ getAscReviewFinalizeDisposition,
+ mapAscReviewProductType,
mapAscOfferDurationToIso,
mapAscOfferKind,
mapBillingPeriodToAsc,
parseIntroOffers,
pickActivePriceRow,
pickPricePointIdMatching,
+ shouldMarkAscReviewSubmissionOutcomePushed,
} from "./asc";
+import type { AscReviewVersionItem } from "./ascReview";
+
+type EligibilityClient = Parameters<
+ typeof createAscReviewEligibilityLoader
+>[0]["client"];
+
+function createEligibilityClient() {
+ return {
+ listInAppPurchases: vi
+ .fn()
+ .mockResolvedValue({ data: [] }),
+ listInAppPurchaseVersions: vi
+ .fn()
+ .mockResolvedValue({ data: [] }),
+ listSubscriptionGroups: vi
+ .fn()
+ .mockResolvedValue({ data: [] }),
+ listSubscriptionsInGroup: vi
+ .fn()
+ .mockResolvedValue({ data: [] }),
+ listSubscriptionGroupVersions: vi
+ .fn()
+ .mockResolvedValue({ data: [] }),
+ listSubscriptionVersions: vi
+ .fn()
+ .mockResolvedValue({ data: [] }),
+ };
+}
+
+function reviewItem(
+ productType: AscReviewVersionItem["productType"],
+ overrides: Partial = {},
+): AscReviewVersionItem {
+ return {
+ productId: `local-${productType}`,
+ storeRef: `store-${productType}`,
+ kind: productType === "Subscription" ? "subscription" : "iap",
+ productType,
+ versionId: `version-${productType}`,
+ ...overrides,
+ };
+}
+
+describe("createAscReviewEligibilityLoader", () => {
+ it("checks only matching IAP histories for a Consumable candidate", async () => {
+ const client = createEligibilityClient();
+ client.listInAppPurchases.mockResolvedValue({
+ data: [
+ {
+ id: "consumable-history",
+ type: "inAppPurchases",
+ attributes: {
+ inAppPurchaseType: "CONSUMABLE",
+ state: "READY_TO_SUBMIT",
+ },
+ },
+ {
+ id: "unrelated-non-consumable",
+ type: "inAppPurchases",
+ attributes: {
+ inAppPurchaseType: "NON_CONSUMABLE",
+ state: "READY_TO_SUBMIT",
+ },
+ },
+ ],
+ });
+ client.listInAppPurchaseVersions.mockResolvedValue({
+ data: [
+ {
+ id: "consumable-approved-version",
+ attributes: { state: "APPROVED" },
+ },
+ ],
+ });
+ const loader = createAscReviewEligibilityLoader({
+ client,
+ appId: "app-1",
+ checkCancelled: vi.fn(async () => undefined),
+ });
+
+ await expect(loader.getActions(reviewItem("Consumable"))).resolves.toEqual(
+ [],
+ );
+ expect(client.listSubscriptionGroups).not.toHaveBeenCalled();
+ expect(client.listSubscriptionsInGroup).not.toHaveBeenCalled();
+ expect(client.listSubscriptionGroupVersions).not.toHaveBeenCalled();
+ expect(client.listSubscriptionVersions).not.toHaveBeenCalled();
+ expect(client.listInAppPurchaseVersions).toHaveBeenCalledTimes(1);
+ expect(client.listInAppPurchaseVersions).toHaveBeenCalledWith(
+ "consumable-history",
+ );
+ expect(client.listInAppPurchaseVersions).not.toHaveBeenCalledWith(
+ "unrelated-non-consumable",
+ );
+ });
+
+ it("uses an approved parent without loading any IAP version history", async () => {
+ const client = createEligibilityClient();
+ client.listInAppPurchases.mockResolvedValue({
+ data: [
+ {
+ id: "approved-consumable",
+ type: "inAppPurchases",
+ attributes: {
+ inAppPurchaseType: "CONSUMABLE",
+ state: "APPROVED",
+ },
+ },
+ {
+ id: "draft-consumable",
+ type: "inAppPurchases",
+ attributes: {
+ inAppPurchaseType: "CONSUMABLE",
+ state: "READY_TO_SUBMIT",
+ },
+ },
+ ],
+ });
+ const loader = createAscReviewEligibilityLoader({
+ client,
+ appId: "app-1",
+ checkCancelled: vi.fn(async () => undefined),
+ });
+
+ await expect(loader.getActions(reviewItem("Consumable"))).resolves.toEqual(
+ [],
+ );
+ expect(client.listInAppPurchaseVersions).not.toHaveBeenCalled();
+ });
+
+ it("stops scheduling later history candidates after a bounded concurrent match", async () => {
+ const client = createEligibilityClient();
+ client.listInAppPurchases.mockResolvedValue({
+ data: Array.from({ length: 6 }, (_, index) => ({
+ id: `consumable-${index + 1}`,
+ type: "inAppPurchases" as const,
+ attributes: {
+ inAppPurchaseType: "CONSUMABLE",
+ state: "READY_TO_SUBMIT",
+ },
+ })),
+ });
+ client.listInAppPurchaseVersions.mockImplementation(async (id) => ({
+ data:
+ id === "consumable-1"
+ ? [{ id: "approved-history", attributes: { state: "APPROVED" } }]
+ : [],
+ }));
+ const loader = createAscReviewEligibilityLoader({
+ client,
+ appId: "app-1",
+ checkCancelled: vi.fn(async () => undefined),
+ });
+
+ await expect(loader.getActions(reviewItem("Consumable"))).resolves.toEqual(
+ [],
+ );
+ expect(client.listInAppPurchaseVersions).toHaveBeenCalledTimes(3);
+ expect(
+ client.listInAppPurchaseVersions.mock.calls.map(([id]) => id),
+ ).toEqual(["consumable-1", "consumable-2", "consumable-3"]);
+ });
+
+ it("reuses type, group, subscription, and history caches across repeated checks", async () => {
+ const client = createEligibilityClient();
+ client.listSubscriptionGroups.mockResolvedValue({
+ data: [
+ {
+ id: "group-pro",
+ type: "subscriptionGroups",
+ attributes: { referenceName: "Pro" },
+ },
+ ],
+ });
+ client.listSubscriptionsInGroup.mockResolvedValue({
+ data: [
+ {
+ id: "approved-subscription",
+ type: "subscriptions",
+ attributes: { state: "APPROVED" },
+ },
+ ],
+ });
+ client.listSubscriptionGroupVersions.mockResolvedValue({
+ data: [
+ {
+ id: "approved-group-version",
+ type: "subscriptionGroupVersions",
+ attributes: { state: "APPROVED" },
+ },
+ ],
+ });
+ const loader = createAscReviewEligibilityLoader({
+ client,
+ appId: "app-1",
+ checkCancelled: vi.fn(async () => undefined),
+ });
+
+ await expect(
+ loader.getActions(
+ reviewItem("Subscription", {
+ productId: "local-sub-one",
+ subscriptionGroupId: "group-pro",
+ }),
+ ),
+ ).resolves.toEqual([]);
+ await expect(
+ loader.getActions(
+ reviewItem("Subscription", {
+ productId: "local-sub-two",
+ subscriptionGroupId: "group-pro",
+ }),
+ ),
+ ).resolves.toEqual([]);
+
+ expect(client.listSubscriptionGroups).toHaveBeenCalledTimes(1);
+ expect(client.listSubscriptionsInGroup).toHaveBeenCalledTimes(1);
+ expect(client.listSubscriptionsInGroup).toHaveBeenCalledWith("group-pro");
+ expect(client.listSubscriptionGroupVersions).toHaveBeenCalledTimes(1);
+ expect(client.listSubscriptionGroupVersions).toHaveBeenCalledWith(
+ "group-pro",
+ );
+ expect(client.listSubscriptionVersions).not.toHaveBeenCalled();
+ });
+
+ it("checks the target subscription group exactly without loading unrelated group versions", async () => {
+ const client = createEligibilityClient();
+ client.listSubscriptionGroups.mockResolvedValue({
+ data: [
+ {
+ id: "group-approved-elsewhere",
+ type: "subscriptionGroups",
+ attributes: { referenceName: "Elsewhere" },
+ },
+ {
+ id: "group-target",
+ type: "subscriptionGroups",
+ attributes: { referenceName: "Target" },
+ },
+ ],
+ });
+ client.listSubscriptionsInGroup.mockImplementation(async (groupId) => ({
+ data:
+ groupId === "group-approved-elsewhere"
+ ? [
+ {
+ id: "approved-subscription",
+ type: "subscriptions" as const,
+ attributes: { state: "APPROVED" },
+ },
+ ]
+ : [
+ {
+ id: "target-draft-subscription",
+ type: "subscriptions" as const,
+ attributes: { state: "READY_TO_SUBMIT" },
+ },
+ ],
+ }));
+ client.listSubscriptionGroupVersions.mockImplementation(
+ async (groupId) => ({
+ data:
+ groupId === "group-approved-elsewhere"
+ ? [
+ {
+ id: "unrelated-approved-group-version",
+ type: "subscriptionGroupVersions" as const,
+ attributes: { state: "APPROVED" },
+ },
+ ]
+ : [],
+ }),
+ );
+ const loader = createAscReviewEligibilityLoader({
+ client,
+ appId: "app-1",
+ checkCancelled: vi.fn(async () => undefined),
+ });
+
+ await expect(
+ loader.getActions(
+ reviewItem("Subscription", {
+ subscriptionGroupId: "group-target",
+ }),
+ ),
+ ).resolves.toMatchObject([
+ {
+ code: "subscription_group_required",
+ productId: "local-Subscription",
+ },
+ ]);
+ expect(client.listSubscriptionGroupVersions).toHaveBeenCalledTimes(1);
+ expect(client.listSubscriptionGroupVersions).toHaveBeenCalledWith(
+ "group-target",
+ );
+ expect(client.listSubscriptionGroupVersions).not.toHaveBeenCalledWith(
+ "group-approved-elsewhere",
+ );
+ });
+});
+
+describe("getAscReviewFinalizeDisposition", () => {
+ it("never attaches a version that already belongs to another review submission", () => {
+ expect(
+ getAscReviewFinalizeDisposition({
+ alreadySubmitted: false,
+ attachedToSubmission: true,
+ screenshotConfigured: false,
+ }),
+ ).toBe("attached");
+ });
+
+ it("does not create a second submission even when a screenshot is configured", () => {
+ expect(
+ getAscReviewFinalizeDisposition({
+ alreadySubmitted: false,
+ attachedToSubmission: true,
+ screenshotConfigured: true,
+ }),
+ ).toBe("attached");
+ });
+});
+
+describe("shouldMarkAscReviewSubmissionOutcomePushed", () => {
+ it("keeps every manual and failed outcome retryable", () => {
+ const item = reviewItem("Consumable");
+ expect(
+ shouldMarkAscReviewSubmissionOutcomePushed({
+ item,
+ status: "manual",
+ action: {
+ productId: item.productId,
+ code: "app_version_required",
+ message: "Submit with an app version",
+ },
+ }),
+ ).toBe(false);
+ expect(
+ shouldMarkAscReviewSubmissionOutcomePushed({
+ item,
+ status: "manual",
+ action: {
+ productId: item.productId,
+ code: "review_submission_status_unknown",
+ message: "Inspect App Store Connect",
+ },
+ }),
+ ).toBe(false);
+ expect(
+ shouldMarkAscReviewSubmissionOutcomePushed({
+ item,
+ status: "failed",
+ reason: "ASC unavailable",
+ }),
+ ).toBe(false);
+ });
+
+ it("marks only a confirmed submitted outcome", () => {
+ expect(
+ shouldMarkAscReviewSubmissionOutcomePushed({
+ item: reviewItem("Consumable"),
+ status: "submitted",
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("mapAscReviewProductType", () => {
+ it("preserves Apple's non-renewing subscription type for manual gates", () => {
+ expect(
+ mapAscReviewProductType("NON_RENEWING_SUBSCRIPTION", "NonConsumable"),
+ ).toBe("NonRenewingSubscription");
+ expect(mapAscReviewProductType("NON_CONSUMABLE", "Consumable")).toBe(
+ "NonConsumable",
+ );
+ });
+});
describe("ascCustomerPriceToMicros", () => {
it("converts ASC customerPrice strings to micros", () => {
diff --git a/packages/kit/convex/products/asc.ts b/packages/kit/convex/products/asc.ts
index 8a316ff1a..fe382317e 100644
--- a/packages/kit/convex/products/asc.ts
+++ b/packages/kit/convex/products/asc.ts
@@ -7,13 +7,38 @@ import { internal } from "../_generated/api";
import type { Doc, Id } from "../_generated/dataModel";
import { getProjectByApiKey } from "../purchases/shared";
import { mapWithConcurrency } from "../utils/concurrency";
+import { validateAppleReviewScreenshotContent } from "../files/validation";
import { mintAscJwt } from "./jwt";
import { coerceBillingPeriod } from "./sync";
-
-// Cancel-check at phase boundaries. The worker reads
-// `cancelRequested` between PULL.iaps → PULL.subgroups → PUSH.drafts.
-// Granularity is per-phase, not per-product, but that's enough to
-// stop a runaway sync within seconds on most paths.
+import {
+ isProductSyncDeadlineReached,
+ truncateManualActions,
+ truncatePlannedWrites,
+} from "./syncResult";
+import {
+ ascReviewLocalizationMatches,
+ ASC_REVIEW_SUBMISSION_ITEM_LIMIT,
+ ASC_REVIEW_SYNC_BATCH_LIMIT,
+ ensureAscReviewVersion,
+ getAscReviewEligibilityActions,
+ isAscApprovedReviewHistoryState,
+ inspectAscReviewVersion,
+ planAscReviewVersion,
+ partitionAscReviewSubmissionItems,
+ submitAscReviewVersions,
+ uploadAscReviewScreenshot,
+ upsertAscReviewLocalization,
+ type AscJsonRequest,
+ type AscReviewEligibilitySnapshot,
+ type AscManualReviewAction,
+ type AscReviewScreenshot,
+ type AscReviewSubmissionOutcome,
+ type AscReviewVersionItem,
+} from "./ascReview";
+
+// Shared cancellation/deadline signal. The worker checks at phase and chunk
+// boundaries, AscClient checks before every API request, and the review helper
+// checks between upload operations and asset-delivery polls.
class ProductSyncCancelledError extends Error {
constructor() {
super("Sync cancelled by operator");
@@ -21,6 +46,20 @@ class ProductSyncCancelledError extends Error {
}
}
+class ProductSyncDeadlineError extends Error {
+ constructor() {
+ super("Product sync reached its runtime deadline; retry to continue");
+ this.name = "ProductSyncDeadlineError";
+ }
+}
+
+function isProductSyncAbortError(error: unknown): boolean {
+ return (
+ error instanceof ProductSyncCancelledError ||
+ error instanceof ProductSyncDeadlineError
+ );
+}
+
// Resolve App Store Connect API credentials (issuer ID + key ID + .p8
// key content) for a project. Centralized so the two action handlers
// (pushSyncProductsAppleIOS and listSubscriptionGroupsAppleIOS) share
@@ -242,6 +281,7 @@ class AscClient {
private readonly issuerId: string | undefined,
private readonly keyId: string,
private readonly privateKey: string,
+ private readonly beforeRequest: () => Promise = async () => undefined,
) {}
private async token(): Promise {
@@ -262,7 +302,9 @@ class AscClient {
private async call(
path: string,
init: RequestInit & { body?: string } = {},
+ skipBoundaryCheck = false,
): Promise {
+ if (!skipBoundaryCheck) await this.beforeRequest();
// Per-request timeout. ASC's REST surface is generally responsive
// (<1s for reads, 1-3s for writes), so 30s is a generous bound
// that catches a hung upstream long before the surrounding
@@ -328,6 +370,23 @@ class AscClient {
return parsed as T;
}
+ // Version-based App Review helpers live in ascReview.ts so their binary
+ // upload and submission workflow can be tested with a mocked transport.
+ // Keep the authenticated JSON transport here as the single JWT boundary.
+ request(path: string, init?: RequestInit & { body?: string }): Promise {
+ return this.call(path, init);
+ }
+
+ // Cleanup must still be able to cancel an IAPKit-owned remote draft after
+ // the normal request guard detects operator cancellation or the job safety
+ // deadline. Callers expose this transport only to bounded cleanup paths.
+ requestForCleanup(
+ path: string,
+ init?: RequestInit & { body?: string },
+ ): Promise {
+ return this.call(path, init, true);
+ }
+
// ASC list endpoints cap at 200 items per page. For accounts with
// larger catalogs we have to follow `links.next` until absent or
// pages > 200 (= 40k items, more than ASC actually allows per app
@@ -340,6 +399,20 @@ class AscClient {
);
}
+ getInAppPurchase(id: string): Promise {
+ return this.call(
+ `/v2/inAppPurchases/${encodeURIComponent(id)}`,
+ );
+ }
+
+ listInAppPurchaseVersions(
+ id: string,
+ ): Promise {
+ return this.call(
+ `/v2/inAppPurchases/${encodeURIComponent(id)}/versions?limit=200`,
+ );
+ }
+
async listSubscriptionGroups(
appId: string,
): Promise {
@@ -354,6 +427,22 @@ class AscClient {
);
}
+ async listSubscriptionGroupVersions(
+ groupId: string,
+ ): Promise {
+ return this.collectAllPages(
+ `/v1/subscriptionGroups/${encodeURIComponent(groupId)}/versions?limit=200`,
+ );
+ }
+
+ listSubscriptionVersions(
+ id: string,
+ ): Promise {
+ return this.call(
+ `/v1/subscriptions/${encodeURIComponent(id)}/versions?limit=200`,
+ );
+ }
+
// Generic JSON:API paginator. ASC returns `{ data: [...],
// links: { self, next? } }` — we follow `next` (the cursor URL is
// absolute, so we hand it straight back to fetch via `call`'s base
@@ -401,6 +490,7 @@ class AscClient {
`/v1/subscriptions/${encodeURIComponent(subId)}/introductoryOffers?filter[territory]=USA&include=subscriptionPricePoint&limit=10`,
);
} catch (error) {
+ if (isProductSyncAbortError(error)) throw error;
return error instanceof Error ? error : new Error(String(error));
}
}
@@ -453,6 +543,7 @@ class AscClient {
}
return manual;
} catch (error) {
+ if (isProductSyncAbortError(error)) throw error;
return error instanceof Error ? error : new Error(String(error));
}
}
@@ -464,6 +555,7 @@ class AscClient {
`/v1/subscriptions/${encodeURIComponent(subId)}/prices?filter[territory]=USA&include=subscriptionPricePoint`,
);
} catch (error) {
+ if (isProductSyncAbortError(error)) throw error;
return error instanceof Error ? error : new Error(String(error));
}
}
@@ -601,133 +693,6 @@ class AscClient {
});
}
- // Attach an English (US) localization so reviewers and the
- // dashboard see something other than the bare productId. Apple
- // requires at least one locale before the IAP can be submitted; we
- // always create en-US so first-submission isn't blocked.
- createIapLocalization(args: {
- iapId: string;
- name: string;
- description: string;
- locale?: string;
- }) {
- return this.call<{ data: { id: string } }>(
- `/v1/inAppPurchaseLocalizations`,
- {
- method: "POST",
- body: JSON.stringify({
- data: {
- type: "inAppPurchaseLocalizations",
- attributes: {
- name: args.name,
- description: args.description,
- locale: args.locale ?? "en-US",
- },
- relationships: {
- inAppPurchaseV2: {
- data: { type: "inAppPurchases", id: args.iapId },
- },
- },
- },
- }),
- },
- );
- }
- async upsertIapLocalization(args: {
- iapId: string;
- name: string;
- description: string;
- locale?: string;
- }) {
- const locale = args.locale ?? "en-US";
- const existing = await this.call(
- `/v2/inAppPurchases/${encodeURIComponent(args.iapId)}/inAppPurchaseLocalizations?limit=200`,
- );
- const match = existing.data.find(
- (item) => item.attributes.locale === locale,
- );
- if (!match) {
- return await this.createIapLocalization(args);
- }
- return this.call<{ data: { id: string } }>(
- `/v1/inAppPurchaseLocalizations/${encodeURIComponent(match.id)}`,
- {
- method: "PATCH",
- body: JSON.stringify({
- data: {
- type: "inAppPurchaseLocalizations",
- id: match.id,
- attributes: {
- name: args.name,
- description: args.description,
- },
- },
- }),
- },
- );
- }
- createSubLocalization(args: {
- subId: string;
- name: string;
- description: string;
- locale?: string;
- }) {
- return this.call<{ data: { id: string } }>(
- `/v1/subscriptionLocalizations`,
- {
- method: "POST",
- body: JSON.stringify({
- data: {
- type: "subscriptionLocalizations",
- attributes: {
- name: args.name,
- description: args.description,
- locale: args.locale ?? "en-US",
- },
- relationships: {
- subscription: {
- data: { type: "subscriptions", id: args.subId },
- },
- },
- },
- }),
- },
- );
- }
- async upsertSubLocalization(args: {
- subId: string;
- name: string;
- description: string;
- locale?: string;
- }) {
- const locale = args.locale ?? "en-US";
- const existing = await this.call(
- `/v1/subscriptions/${encodeURIComponent(args.subId)}/subscriptionLocalizations?limit=200`,
- );
- const match = existing.data.find(
- (item) => item.attributes.locale === locale,
- );
- if (!match) {
- return await this.createSubLocalization(args);
- }
- return this.call<{ data: { id: string } }>(
- `/v1/subscriptionLocalizations/${encodeURIComponent(match.id)}`,
- {
- method: "PATCH",
- body: JSON.stringify({
- data: {
- type: "subscriptionLocalizations",
- id: match.id,
- attributes: {
- name: args.name,
- description: args.description,
- },
- },
- }),
- },
- );
- }
-
// Look up an existing subscription group by referenceName, or
// create one. Used by the Add Product flow when the operator types
// a group name on a Subscription draft — kit then resolves it to
@@ -899,23 +864,230 @@ type AscSubListResponse = {
data: AscSubResource["data"][];
};
-type AscLocalizationListResponse = {
+type AscSubGroupListResponse = {
data: Array<{
id: string;
- attributes: {
- locale?: string;
- };
+ type: "subscriptionGroups";
+ attributes: { referenceName?: string };
}>;
};
-type AscSubGroupListResponse = {
+type AscSubGroupVersionListResponse = {
data: Array<{
id: string;
- type: "subscriptionGroups";
- attributes: { referenceName?: string };
+ type: "subscriptionGroupVersions";
+ attributes?: { state?: string; version?: string };
}>;
};
+type AscReviewVersionHistoryListResponse = {
+ data: Array<{
+ id: string;
+ attributes?: { state?: string; version?: string };
+ }>;
+};
+
+interface AscReviewEligibilityClient {
+ listInAppPurchases(appId: string): Promise;
+ listInAppPurchaseVersions(
+ id: string,
+ ): Promise;
+ listSubscriptionGroups(appId: string): Promise;
+ listSubscriptionsInGroup(groupId: string): Promise;
+ listSubscriptionGroupVersions(
+ groupId: string,
+ ): Promise;
+ listSubscriptionVersions(
+ id: string,
+ ): Promise;
+}
+
+async function someWithConcurrency(
+ values: readonly T[],
+ concurrency: number,
+ predicate: (value: T) => Promise,
+): Promise {
+ if (values.length === 0) return false;
+ let nextIndex = 0;
+ let found = false;
+ const workers = Array.from(
+ { length: Math.min(Math.max(1, concurrency), values.length) },
+ async () => {
+ while (!found) {
+ const index = nextIndex;
+ nextIndex += 1;
+ if (index >= values.length) return;
+ if (await predicate(values[index])) {
+ found = true;
+ return;
+ }
+ }
+ },
+ );
+ await Promise.all(workers);
+ return found;
+}
+
+interface AscReviewEligibilityLoader {
+ resolveSubscriptionGroupId(referenceName: string): Promise;
+ getActions(item: AscReviewVersionItem): Promise;
+}
+
+// Resolve only the history needed by the current bounded candidate batch.
+// The previous eager scan fetched every version of every IAP, subscription,
+// and group before preparing even one row. Large catalogs could exhaust the
+// worker deadline and repeat the same scan forever. These promise caches make
+// type/group checks lazy, exact, shared by concurrent rows, and reusable by
+// later dry-run batches while stopping new history requests as soon as an
+// approved predecessor is found.
+export function createAscReviewEligibilityLoader(args: {
+ client: AscReviewEligibilityClient;
+ appId: string;
+ checkCancelled: () => Promise;
+}): AscReviewEligibilityLoader {
+ const { client, appId, checkCancelled } = args;
+ let iapsPromise: Promise | null = null;
+ let groupsPromise: Promise | null = null;
+ const subscriptionLists = new Map>();
+ const subscriptionApprovals = new Map>();
+ const groupApprovals = new Map>();
+ const productTypeApprovals = new Map<
+ AscReviewVersionItem["productType"],
+ Promise
+ >();
+
+ const listIaps = () => {
+ iapsPromise ??= client.listInAppPurchases(appId);
+ return iapsPromise;
+ };
+ const listGroups = () => {
+ groupsPromise ??= client.listSubscriptionGroups(appId);
+ return groupsPromise;
+ };
+ const listSubscriptions = (groupId: string) => {
+ let pending = subscriptionLists.get(groupId);
+ if (!pending) {
+ pending = client.listSubscriptionsInGroup(groupId);
+ subscriptionLists.set(groupId, pending);
+ }
+ return pending;
+ };
+ const hasApprovedSubscription = (subscription: AscSubResource["data"]) => {
+ let pending = subscriptionApprovals.get(subscription.id);
+ if (!pending) {
+ pending = (async () => {
+ await checkCancelled();
+ if (isAscApprovedReviewHistoryState(subscription.attributes.state)) {
+ return true;
+ }
+ const versions = await client.listSubscriptionVersions(subscription.id);
+ return versions.data.some((version) =>
+ isAscApprovedReviewHistoryState(version.attributes?.state),
+ );
+ })();
+ subscriptionApprovals.set(subscription.id, pending);
+ }
+ return pending;
+ };
+ const groupHasApprovedSubscription = async (groupId: string) => {
+ await checkCancelled();
+ const subscriptions = await listSubscriptions(groupId);
+ return someWithConcurrency(subscriptions.data, 3, hasApprovedSubscription);
+ };
+ const hasApprovedGroup = (groupId: string) => {
+ let pending = groupApprovals.get(groupId);
+ if (!pending) {
+ pending = (async () => {
+ await checkCancelled();
+ const groups = await listGroups();
+ if (!groups.data.some((group) => group.id === groupId)) return false;
+ const [hasApprovedSubscription, versions] = await Promise.all([
+ groupHasApprovedSubscription(groupId),
+ client.listSubscriptionGroupVersions(groupId),
+ ]);
+ return (
+ hasApprovedSubscription ||
+ versions.data.some((version) =>
+ isAscApprovedReviewHistoryState(version.attributes?.state),
+ )
+ );
+ })();
+ groupApprovals.set(groupId, pending);
+ }
+ return pending;
+ };
+ const hasApprovedProductType = (
+ productType: AscReviewVersionItem["productType"],
+ ) => {
+ let pending = productTypeApprovals.get(productType);
+ if (!pending) {
+ pending = (async () => {
+ await checkCancelled();
+ if (productType === "Subscription") {
+ const groups = await listGroups();
+ return someWithConcurrency(groups.data, 2, (group) =>
+ groupHasApprovedSubscription(group.id),
+ );
+ }
+ const iaps = await listIaps();
+ const candidates = iaps.data.filter((iap) => {
+ const mapped = mapAscReviewProductType(
+ iap.attributes.inAppPurchaseType,
+ mapAscIapType(iap.attributes.inAppPurchaseType),
+ );
+ return mapped === productType;
+ });
+ if (
+ candidates.some((iap) =>
+ isAscApprovedReviewHistoryState(iap.attributes.state),
+ )
+ ) {
+ return true;
+ }
+ return someWithConcurrency(candidates, 3, async (iap) => {
+ await checkCancelled();
+ const versions = await client.listInAppPurchaseVersions(iap.id);
+ return versions.data.some((version) =>
+ isAscApprovedReviewHistoryState(version.attributes?.state),
+ );
+ });
+ })();
+ productTypeApprovals.set(productType, pending);
+ }
+ return pending;
+ };
+
+ return {
+ async resolveSubscriptionGroupId(referenceName) {
+ await checkCancelled();
+ const groups = await listGroups();
+ return (
+ groups.data.find(
+ (group) => group.attributes.referenceName === referenceName,
+ )?.id ?? null
+ );
+ },
+ async getActions(item) {
+ const [typeApproved, groupApproved] = await Promise.all([
+ hasApprovedProductType(item.productType),
+ item.productType === "Subscription" && item.subscriptionGroupId
+ ? hasApprovedGroup(item.subscriptionGroupId)
+ : Promise.resolve(false),
+ ]);
+ const snapshot: AscReviewEligibilitySnapshot = {
+ approvedProductTypes: typeApproved
+ ? new Set([item.productType])
+ : new Set(),
+ approvedSubscriptionGroupIds:
+ groupApproved && item.subscriptionGroupId
+ ? new Set([item.subscriptionGroupId])
+ : new Set(),
+ };
+ return getAscReviewEligibilityActions({ item, snapshot });
+ },
+ };
+}
+
// Reference catalog response: every USA price point Apple publishes
// for a given IAP / sub. Used at push-time to translate a USD amount
// into the corresponding opaque price-point id (`eyJ...`) Apple's
@@ -1132,12 +1304,10 @@ function extractAscError(parsed: unknown): string {
// directly by the dashboard / HTTP / SDK paths so the long fetch
// can never hold a browser connection open.
//
-// Convex actions cap at ~10 minutes; we set the job's expected
-// deadline at 9 minutes and rely on `reapStaleProductSyncJobs` to
-// flip anything still running 1 minute past that to failed. Within
-// the action body we also poll `isCancelRequested` at phase
-// boundaries (PULL.iaps → PULL.subgroups → PUSH.drafts) so an
-// operator-initiated cancel takes effect within one phase.
+// Convex actions cap at ~10 minutes. The job deadline is 9 minutes, remote
+// work stops 45 seconds before it for cleanup + terminal persistence, and the
+// reaper remains a crash fallback. Cancellation/deadline checks run at phase,
+// chunk, request, upload-operation, and asset-poll boundaries.
export const runProductSyncIOS = internalAction({
args: { jobId: v.id("productSyncJobs") },
handler: async (ctx, args): Promise => {
@@ -1146,10 +1316,17 @@ export const runProductSyncIOS = internalAction({
});
if (!job) return;
if (job.status !== "queued") return;
- await ctx.runMutation(internal.products.jobs.markJobRunning, {
- jobId: args.jobId,
- });
+ const workerDeadline = await ctx.runMutation(
+ internal.products.jobs.markJobRunning,
+ {
+ jobId: args.jobId,
+ },
+ );
+ if (workerDeadline === null) return;
const checkCancelled = async () => {
+ if (isProductSyncDeadlineReached(Date.now(), workerDeadline)) {
+ throw new ProductSyncDeadlineError();
+ }
const cancelled = await ctx.runQuery(
internal.products.jobs.isCancelRequested,
{ jobId: args.jobId },
@@ -1190,13 +1367,30 @@ export const runProductSyncIOS = internalAction({
checkCancelled,
reportPhase,
});
+ // Bound before crossing the action→mutation boundary; the mutation also
+ // applies the cap defensively before persisting the job document.
+ const boundedManualActions = truncateManualActions(
+ result.manualActions ?? [],
+ );
+ const boundedPlannedWrites = truncatePlannedWrites(
+ result.plannedWrites ?? [],
+ );
await ctx.runMutation(internal.products.jobs.markJobSucceeded, {
jobId: args.jobId,
pulled: result.pulled,
pushed: result.pushed,
deleted: result.deleted,
failures: result.failures,
- plannedWrites: result.plannedWrites,
+ plannedWrites:
+ boundedPlannedWrites.items.length > 0
+ ? boundedPlannedWrites.items
+ : undefined,
+ plannedWritesTruncated: boundedPlannedWrites.truncated || undefined,
+ manualActions:
+ boundedManualActions.items.length > 0
+ ? boundedManualActions.items
+ : undefined,
+ manualActionsTruncated: boundedManualActions.truncated || undefined,
});
} catch (error) {
const cancelled = error instanceof ProductSyncCancelledError;
@@ -1241,6 +1435,28 @@ interface SyncResult {
deleted?: number;
failures: Array<{ productId: string; reason: string }>;
plannedWrites?: Array<{ productId: string; step: string; detail?: string }>;
+ manualActions?: AscManualReviewAction[];
+}
+
+export function getAscReviewFinalizeDisposition(args: {
+ alreadySubmitted: boolean;
+ attachedToSubmission: boolean;
+ screenshotConfigured: boolean;
+}): "already-submitted" | "attached" | "ready" | "submit" {
+ if (args.alreadySubmitted) return "already-submitted";
+ if (args.attachedToSubmission) return "attached";
+ if (!args.screenshotConfigured) return "ready";
+ return "submit";
+}
+
+// Only a confirmed submission is terminal for the local row. Manual outcomes
+// must remain Draft even after their metadata/screenshot was prepared: the
+// worker still has to persist the in-memory operator instruction, and a crash
+// before that terminal mutation must let the next run surface it again.
+export function shouldMarkAscReviewSubmissionOutcomePushed(
+ outcome: AscReviewSubmissionOutcome,
+): boolean {
+ return outcome.status === "submitted";
}
async function performIosSync(
@@ -1282,10 +1498,11 @@ async function performIosSync(
project,
{ detailedErrors: true },
);
- const client = new AscClient(issuerId, keyId, keyContent);
+ const client = new AscClient(issuerId, keyId, keyContent, checkCancelled);
const direction = args.direction ?? "both";
const failures: Array<{ productId: string; reason: string }> = [];
+ const manualActions: AscManualReviewAction[] = [];
let pulled = 0;
let pushed = 0;
const dryRun = args.dryRun ?? false;
@@ -1297,12 +1514,26 @@ async function performIosSync(
const appIdStr = String(project.iosAppAppleId);
let deleted = 0;
-
+ const ascReviewProductTypeByStoreRef = new Map<
+ string,
+ AscReviewVersionItem["productType"]
+ >();
+ // Capture the screenshot identity before a direction="both" pull. Product
+ // rows persist the last handled file id, so pull-side timestamp changes do
+ // not affect deterministic Ready-row resumption.
+ const prePullScreenshotMetadata =
+ direction === "push" || direction === "both"
+ ? await ctx.runQuery(
+ internal.files.internal.getAppleReviewScreenshotByProjectInternal,
+ { projectId: project._id },
+ )
+ : null;
// ── PULL: ASC → kit catalog ────────────────────────────────────
if (direction === "pull" || direction === "both") {
await checkCancelled();
await reportPhase("pull-iaps");
const iaps = await client.listInAppPurchases(appIdStr).catch((error) => {
+ if (isProductSyncAbortError(error)) throw error;
failures.push({
productId: "(asc list iaps)",
reason: error instanceof Error ? error.message : String(error),
@@ -1323,12 +1554,17 @@ async function performIosSync(
if (!productId) return null;
const type = mapAscIapType(item.attributes.inAppPurchaseType);
const pricePoint = await client.iapCurrentPrice(item.id);
- return { item, productId, type, pricePoint };
+ const reviewProductType = mapAscReviewProductType(
+ item.attributes.inAppPurchaseType,
+ type,
+ );
+ return { item, productId, type, pricePoint, reviewProductType };
},
);
for (const result of iapResults) {
if (!result) continue;
- const { item, productId, type, pricePoint } = result;
+ const { item, productId, type, pricePoint, reviewProductType } = result;
+ ascReviewProductTypeByStoreRef.set(item.id, reviewProductType);
if (pricePoint instanceof Error) {
failures.push({
productId: `${productId} (price lookup)`,
@@ -1342,17 +1578,19 @@ async function performIosSync(
// upsertFromStore runs serially — Convex coalesces writes
// anyway and parallel mutations on the same row would race
// on the (projectId, platform, productId) lookup.
- await ctx.runMutation(internal.products.sync.upsertFromStore, {
- projectId: project._id,
- productId,
- platform: "IOS",
- type,
- title: item.attributes.name ?? productId,
- priceAmountMicros,
- currency,
- storeRef: item.id,
- state: mapAscState(item.attributes.state),
- });
+ if (!dryRun) {
+ await ctx.runMutation(internal.products.sync.upsertFromStore, {
+ projectId: project._id,
+ productId,
+ platform: "IOS",
+ type,
+ title: item.attributes.name ?? productId,
+ priceAmountMicros,
+ currency,
+ storeRef: item.id,
+ state: mapAscState(item.attributes.state),
+ });
+ }
pulled += 1;
}
}
@@ -1365,6 +1603,7 @@ async function performIosSync(
const groups = await client
.listSubscriptionGroups(appIdStr)
.catch((error) => {
+ if (isProductSyncAbortError(error)) throw error;
failures.push({
productId: "(asc list groups)",
reason: error instanceof Error ? error.message : String(error),
@@ -1376,6 +1615,7 @@ async function performIosSync(
const subs = await client
.listSubscriptionsInGroup(group.id)
.catch((error) => {
+ if (isProductSyncAbortError(error)) throw error;
failures.push({
productId: `(asc list subs in group ${group.id})`,
reason: error instanceof Error ? error.message : String(error),
@@ -1422,25 +1662,27 @@ async function performIosSync(
const offers = parseIntroOffers(
introOffers instanceof Error ? null : introOffers,
);
- await ctx.runMutation(internal.products.sync.upsertFromStore, {
- projectId: project._id,
- productId,
- platform: "IOS",
- type: "Subscription",
- title: sub.attributes.name ?? productId,
- priceAmountMicros,
- currency,
- storeRef: sub.id,
- state: mapAscState(sub.attributes.state),
- billingPeriod: coerceBillingPeriod(
- mapAscOfferDurationToIso(
- sub.attributes.subscriptionPeriod ?? undefined,
+ if (!dryRun) {
+ await ctx.runMutation(internal.products.sync.upsertFromStore, {
+ projectId: project._id,
+ productId,
+ platform: "IOS",
+ type: "Subscription",
+ title: sub.attributes.name ?? productId,
+ priceAmountMicros,
+ currency,
+ storeRef: sub.id,
+ state: mapAscState(sub.attributes.state),
+ billingPeriod: coerceBillingPeriod(
+ mapAscOfferDurationToIso(
+ sub.attributes.subscriptionPeriod ?? undefined,
+ ),
),
- ),
- subscriptionGroupId: group.id,
- subscriptionGroupName: group.attributes.referenceName,
- offers: offers.length ? offers : undefined,
- });
+ subscriptionGroupId: group.id,
+ subscriptionGroupName: group.attributes.referenceName,
+ offers: offers.length ? offers : undefined,
+ });
+ }
pulled += 1;
}
}
@@ -1448,16 +1690,14 @@ async function performIosSync(
}
// ── PUSH: kit → ASC for Draft rows ─────────────────────────────
- // Each draft becomes a multi-step flow: create → localize → set
- // price. The first step alone leaves the IAP/sub in an unsubmittable
+ // Each draft becomes a multi-step flow: create → create/reuse review
+ // version → localize → set price → optional screenshot upload → review
+ // submission. The first step alone leaves the IAP/sub in an unsubmittable
// state because Apple requires both an en-US localization and a
// USA price schedule before the row can move past Draft. We do
// the whole chain here so a single Sync click takes the catalog
- // from "kit-only" to "Ready to Submit" in App Store Connect.
- // Submission itself (screenshot upload + inAppPurchaseSubmissions
- // POST) is a follow-up because it needs a screenshot file and a
- // dashboard upload slot we haven't built yet — see the
- // DEFERRED(review-submit) note below.
+ // from "kit-only" to App Review. When no project screenshot is configured,
+ // preserve the prior Ready-to-Submit behaviour without failing the sync.
if (direction === "push" || direction === "both") {
await checkCancelled();
await reportPhase("push-removals", {
@@ -1504,6 +1744,7 @@ async function performIosSync(
);
if (didDelete) deleted += 1;
} catch (error) {
+ if (isProductSyncAbortError(error)) throw error;
if (error instanceof AscApiError && error.status === 404) {
const didDelete = await ctx.runMutation(
internal.products.sync.deleteRemovedProductRow,
@@ -1528,10 +1769,82 @@ async function performIosSync(
current: pulled,
failuresCount: failures.length,
});
+ const reviewRequest: AscJsonRequest = (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => client.request(path, init);
+ const reviewCleanupRequest: AscJsonRequest = (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => client.requestForCleanup(path, init);
+ const screenshotMetadata = prePullScreenshotMetadata;
const drafts = await ctx.runQuery(
internal.products.sync.listDraftIosProducts,
- { projectId: project._id },
+ {
+ projectId: project._id,
+ includeReadyForReview: screenshotMetadata !== null,
+ reviewScreenshotFileId: screenshotMetadata?.fileId,
+ },
);
+ const reviewEligibility = screenshotMetadata
+ ? createAscReviewEligibilityLoader({
+ client,
+ appId: appIdStr,
+ checkCancelled,
+ })
+ : null;
+ let reviewScreenshot: AscReviewScreenshot | null = null;
+ let reviewScreenshotError: Error | null = null;
+ if (screenshotMetadata) {
+ try {
+ await checkCancelled();
+ const controller = new AbortController();
+ const timeout = setTimeout(
+ () => controller.abort(),
+ ASC_FETCH_TIMEOUT_MS,
+ );
+ let response: Response;
+ try {
+ response = await fetch(screenshotMetadata.storageUrl, {
+ signal: controller.signal,
+ });
+ } finally {
+ clearTimeout(timeout);
+ }
+ if (!response.ok) {
+ throw new Error(
+ `Stored App Review screenshot returned HTTP ${response.status}`,
+ );
+ }
+ const bytes = new Uint8Array(await response.arrayBuffer());
+ if (bytes.byteLength !== screenshotMetadata.fileSize) {
+ throw new Error(
+ "Stored App Review screenshot size no longer matches its file record",
+ );
+ }
+ validateAppleReviewScreenshotContent(
+ bytes,
+ screenshotMetadata.fileType,
+ );
+ if (
+ screenshotMetadata.fileType !== "image/png" &&
+ screenshotMetadata.fileType !== "image/jpeg"
+ ) {
+ throw new Error(
+ "Stored App Review screenshot must be image/png or image/jpeg",
+ );
+ }
+ reviewScreenshot = {
+ fileName: screenshotMetadata.fileName,
+ fileType: screenshotMetadata.fileType,
+ bytes,
+ };
+ } catch (error) {
+ if (isProductSyncAbortError(error)) throw error;
+ reviewScreenshotError =
+ error instanceof Error ? error : new Error(String(error));
+ }
+ }
// Cache subscriptionGroup find-or-create results across the
// entire push pass so a project with multiple drafts in the
// same group (Premium Monthly + Premium Yearly + Premium
@@ -1587,7 +1900,8 @@ async function performIosSync(
const PUSH_CONCURRENCY = 4;
const processOneDraft = async (
row: (typeof drafts)[number],
- ): Promise => {
+ ): Promise => {
+ await checkCancelled();
// Track failures pushed *for this row* via a row-local flag.
// The previous `failuresAtStart = failures.length` snapshot
// worked when this loop was sequential, but with
@@ -1611,6 +1925,314 @@ async function performIosSync(
rowHadFailure = true;
failures.push(failure);
};
+ const loadEligibilityActions = async (
+ item: AscReviewVersionItem,
+ ): Promise => {
+ if (!reviewEligibility) {
+ recordFailure({
+ productId: `${row.productId} (review eligibility)`,
+ reason: "ASC review eligibility could not be determined",
+ });
+ return null;
+ }
+ try {
+ return await reviewEligibility.getActions(item);
+ } catch (error) {
+ if (isProductSyncAbortError(error)) throw error;
+ recordFailure({
+ productId: `${row.productId} (review eligibility)`,
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ }
+ };
+ const resolveReviewVersion = async (
+ kind: "iap" | "subscription",
+ storeRef: string,
+ ): Promise<{
+ versionId: string;
+ alreadySubmitted: boolean;
+ attachedToSubmission: boolean;
+ } | null> => {
+ if (!dryRun) {
+ return await ensureAscReviewVersion({
+ request: reviewRequest,
+ kind,
+ parentId: storeRef,
+ allowCreate: true,
+ reuseApproved: row.state === "Ready",
+ checkCancelled,
+ });
+ }
+ if (!row.storeRef) {
+ plannedWrites.push({
+ productId: row.productId,
+ step: `create ${kind === "iap" ? "in-app purchase" : "subscription"} review version`,
+ detail: "version-based App Store Connect 4.4.1 workflow",
+ });
+ return {
+ versionId: "(would-create)",
+ alreadySubmitted: false,
+ attachedToSubmission: false,
+ };
+ }
+ const current = await inspectAscReviewVersion({
+ request: reviewRequest,
+ kind,
+ parentId: storeRef,
+ checkCancelled,
+ });
+ const plan = planAscReviewVersion({
+ localState: row.state,
+ current,
+ });
+ plannedWrites.push({
+ productId: row.productId,
+ step:
+ plan.action === "create"
+ ? `create ${kind === "iap" ? "in-app purchase" : "subscription"} review version`
+ : "reuse current ASC review version",
+ detail:
+ plan.action === "create"
+ ? "The latest historical version is complete; a new editable version would be created."
+ : `version=${plan.reviewVersion.versionId}`,
+ });
+ return plan.reviewVersion;
+ };
+ const syncReviewLocalization = async (
+ kind: "iap" | "subscription",
+ reviewVersion: {
+ versionId: string;
+ alreadySubmitted: boolean;
+ attachedToSubmission: boolean;
+ },
+ ): Promise => {
+ try {
+ if (
+ reviewVersion.alreadySubmitted ||
+ reviewVersion.attachedToSubmission
+ ) {
+ const matches = await ascReviewLocalizationMatches({
+ request: reviewRequest,
+ kind,
+ versionId: reviewVersion.versionId,
+ name: row.title,
+ description: row.description ?? row.title,
+ checkCancelled,
+ });
+ if (!matches) {
+ recordFailure({
+ productId: `${row.productId} (review version)`,
+ reason:
+ "The current ASC review version is already attached or submitted and its en-US metadata differs from this Draft. Finish or cancel that review in App Store Connect, then run Push Sync again to create an editable version.",
+ });
+ }
+ return;
+ }
+ await upsertAscReviewLocalization({
+ request: reviewRequest,
+ kind,
+ versionId: reviewVersion.versionId,
+ name: row.title,
+ description: row.description ?? row.title,
+ checkCancelled,
+ });
+ } catch (error) {
+ // A 409 on an editable version is a benign replay from a partial
+ // prior sync. Reads/comparisons against attached versions are never
+ // treated as replay success.
+ if (
+ reviewVersion.alreadySubmitted ||
+ reviewVersion.attachedToSubmission ||
+ !isBenignAscRetryConflict(error)
+ ) {
+ recordFailure({
+ productId: `${row.productId} (localization)`,
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+ };
+ const finalizeReview = async (
+ kind: "iap" | "subscription",
+ storeRef: string,
+ productType: AscReviewVersionItem["productType"],
+ reviewVersion: {
+ versionId: string;
+ alreadySubmitted: boolean;
+ attachedToSubmission: boolean;
+ } | null,
+ subscriptionGroupId?: string,
+ ): Promise => {
+ if (!dryRun) await checkCancelled();
+ if (rowHadFailure) return null;
+ if (!reviewVersion) {
+ recordFailure({
+ productId: `${row.productId} (review version)`,
+ reason: "ASC review version was not prepared",
+ });
+ return null;
+ }
+ const disposition = getAscReviewFinalizeDisposition({
+ alreadySubmitted: reviewVersion.alreadySubmitted,
+ attachedToSubmission: reviewVersion.attachedToSubmission,
+ screenshotConfigured: screenshotMetadata !== null,
+ });
+ if (dryRun) {
+ if (disposition === "already-submitted") {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "no App Review write required",
+ detail:
+ "The current review version is already submitted or approved.",
+ });
+ pushed += 1;
+ return null;
+ }
+ if (disposition === "attached") {
+ const action: AscManualReviewAction = {
+ productId: row.productId,
+ code: "review_submission_conflict",
+ message:
+ "This product version is already attached to an existing App Store Connect review submission. Complete or discard that draft there; IAPKit will not attach it to a second submission.",
+ };
+ manualActions.push(action);
+ plannedWrites.push({
+ productId: row.productId,
+ step: "manual App Store review submission required",
+ detail: action.message,
+ });
+ return null;
+ }
+ if (disposition === "ready") {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "skip automatic App Review submission",
+ detail:
+ "No optional project App Review screenshot is configured; product will stop at Ready.",
+ });
+ pushed += 1;
+ return null;
+ }
+ if (reviewScreenshotError || !reviewScreenshot) {
+ recordFailure({
+ productId: `${row.productId} (review screenshot)`,
+ reason:
+ reviewScreenshotError?.message ??
+ "Configured App Review screenshot could not be read",
+ });
+ return null;
+ }
+ plannedWrites.push({
+ productId: row.productId,
+ step: "upload App Review screenshot",
+ detail: `${screenshotMetadata!.fileName} (${kind})`,
+ });
+ const eligibilityActions = await loadEligibilityActions({
+ productId: row.productId,
+ storeRef,
+ kind,
+ productType,
+ versionId: reviewVersion.versionId,
+ ...(subscriptionGroupId ? { subscriptionGroupId } : {}),
+ });
+ if (!eligibilityActions) return null;
+ if (eligibilityActions.length > 0) {
+ manualActions.push(...eligibilityActions);
+ plannedWrites.push({
+ productId: row.productId,
+ step: "manual App Store review submission required",
+ detail: eligibilityActions
+ .map((action) => action.message)
+ .join(" "),
+ });
+ } else {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "submit review version",
+ detail:
+ "Create a review submission item and submit the eligible version.",
+ });
+ pushed += 1;
+ }
+ return null;
+ }
+ if (disposition === "already-submitted") {
+ await ctx.runMutation(internal.products.sync.markPushed, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "IOS",
+ storeRef,
+ reviewScreenshotFileId: screenshotMetadata?.fileId,
+ });
+ pushed += 1;
+ return null;
+ }
+ if (disposition === "attached") {
+ manualActions.push({
+ productId: row.productId,
+ code: "review_submission_conflict",
+ message:
+ "This product version is already attached to an existing App " +
+ "Store Connect review submission. Complete or discard that " +
+ "draft there; IAPKit will not attach it to a second submission.",
+ });
+ return null;
+ }
+ if (disposition === "ready") {
+ await ctx.runMutation(internal.products.sync.markPushed, {
+ projectId: project._id,
+ productId: row.productId,
+ platform: "IOS",
+ storeRef,
+ });
+ pushed += 1;
+ return null;
+ }
+ const reviewItem: AscReviewVersionItem = {
+ productId: row.productId,
+ storeRef,
+ kind,
+ productType,
+ versionId: reviewVersion.versionId,
+ ...(subscriptionGroupId ? { subscriptionGroupId } : {}),
+ };
+ if (reviewScreenshotError || !reviewScreenshot) {
+ recordFailure({
+ productId: `${row.productId} (review screenshot)`,
+ reason:
+ reviewScreenshotError?.message ??
+ "Configured App Review screenshot could not be read",
+ });
+ return null;
+ }
+ try {
+ await uploadAscReviewScreenshot({
+ request: reviewRequest,
+ kind,
+ parentId: storeRef,
+ screenshot: reviewScreenshot,
+ checkCancelled,
+ });
+ } catch (error) {
+ if (isProductSyncAbortError(error)) throw error;
+ recordFailure({
+ productId: `${row.productId} (review screenshot)`,
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ }
+ const eligibilityActions = await loadEligibilityActions(reviewItem);
+ if (!eligibilityActions) return null;
+ if (eligibilityActions.length > 0) {
+ manualActions.push(...eligibilityActions);
+ // Keep this row retryable. If another concurrent worker aborts the
+ // job, the in-memory manual action is lost; leaving the row Draft
+ // guarantees the next run surfaces the operator action again.
+ return null;
+ }
+ return reviewItem;
+ };
try {
if (row.type === "Subscription") {
// Resolve the ASC subscriptionGroup from the operator-typed
@@ -1629,6 +2251,12 @@ async function performIosSync(
// has a storeRef from a prior partially-successful sync —
// re-creating would either duplicate or 409 against ASC.
const groupName = row.subscriptionGroupName ?? row.productId;
+ let reviewGroupId = row.subscriptionGroupId;
+ if (!reviewGroupId && row.storeRef && reviewEligibility) {
+ reviewGroupId =
+ (await reviewEligibility.resolveSubscriptionGroupId(groupName)) ??
+ undefined;
+ }
if (!row.subscriptionGroupName && !row.storeRef && dryRun) {
// Surface the per-product-group warning in dry-run only
// so operators see the recommendation while previewing
@@ -1671,6 +2299,7 @@ async function performIosSync(
(g) => g.attributes.referenceName === groupName,
);
groupId = existing?.id ?? "(would-create)";
+ reviewGroupId = existing?.id;
plannedWrites.push({
productId: row.productId,
step: existing
@@ -1702,6 +2331,7 @@ async function performIosSync(
});
}
groupId = await cached;
+ reviewGroupId = groupId;
const result = await client.createSubscription({
groupId,
productId: row.productId,
@@ -1722,40 +2352,53 @@ async function performIosSync(
});
}
}
+ const reviewVersion = await resolveReviewVersion(
+ "subscription",
+ storeRef,
+ );
// Localize so reviewers see the human-readable name +
// description instead of just the productId. ASC requires
// at least one locale before submission — failing here
// doesn't unwind the create (Apple has no rollback) so we
// record a failure and let the operator retry / fix in
// ASC web.
- if (dryRun) {
- plannedWrites.push({
- productId: row.productId,
- step: row.storeRef
- ? "patch en-US localization"
- : "create en-US localization",
- detail: row.description ?? row.title,
- });
- } else {
- try {
- await client.upsertSubLocalization({
- subId: storeRef,
+ if (dryRun && reviewVersion) {
+ if (
+ reviewVersion.alreadySubmitted ||
+ reviewVersion.attachedToSubmission
+ ) {
+ const matches = await ascReviewLocalizationMatches({
+ request: reviewRequest,
+ kind: "subscription",
+ versionId: reviewVersion.versionId,
name: row.title,
description: row.description ?? row.title,
+ checkCancelled,
});
- } catch (error) {
- // 409 Conflict means the en-US localization already
- // exists from a prior partial sync. That's a benign
- // retry — fall through to the price-setting step
- // instead of marking the whole product failed.
- if (!(error instanceof AscApiError && error.status === 409)) {
+ if (!matches) {
recordFailure({
- productId: `${row.productId} (localization)`,
+ productId: `${row.productId} (review version)`,
reason:
- error instanceof Error ? error.message : String(error),
+ "The current ASC review version is already attached or submitted and its en-US metadata differs from this Draft.",
+ });
+ } else {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "keep locked en-US version localization",
+ detail: "Current ASC metadata already matches.",
});
}
+ } else {
+ plannedWrites.push({
+ productId: row.productId,
+ step: row.storeRef
+ ? "patch en-US version localization"
+ : "create en-US version localization",
+ detail: row.description ?? row.title,
+ });
}
+ } else if (reviewVersion) {
+ await syncReviewLocalization("subscription", reviewVersion);
}
// Set the USA price by resolving the operator's USD amount
// → Apple's nearest price-point id. We require currency =
@@ -1803,6 +2446,7 @@ async function performIosSync(
}
}
} catch (error) {
+ if (isProductSyncAbortError(error)) throw error;
// Treat only duplicate/existing conflicts as benign
// retries. ASC also reports malformed price payloads
// as 409 ENTITY_ERROR, and those must stay visible.
@@ -1821,20 +2465,16 @@ async function performIosSync(
reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`,
});
}
- // Only flip state to Ready when every follow-up step
- // succeeded. Partial setups stay in Draft (with storeRef
- // populated) so the next sync resumes the missing pieces.
- if (!dryRun && !rowHadFailure) {
- await ctx.runMutation(internal.products.sync.markPushed, {
- projectId: project._id,
- productId: row.productId,
- platform: "IOS",
- storeRef,
- });
- }
- pushed += 1;
+ return await finalizeReview(
+ "subscription",
+ storeRef,
+ "Subscription",
+ reviewVersion,
+ reviewGroupId,
+ );
} else {
let storeRef: string;
+ let reviewProductType: AscReviewVersionItem["productType"] = row.type;
if (row.storeRef) {
storeRef = row.storeRef;
if (dryRun) {
@@ -1854,6 +2494,19 @@ async function performIosSync(
reviewNote: row.reviewNote,
});
}
+ if (screenshotMetadata) {
+ const cached = ascReviewProductTypeByStoreRef.get(storeRef);
+ if (cached) {
+ reviewProductType = cached;
+ } else {
+ const current = await client.getInAppPurchase(storeRef);
+ reviewProductType = mapAscReviewProductType(
+ current.data.attributes.inAppPurchaseType,
+ row.type,
+ );
+ ascReviewProductTypeByStoreRef.set(storeRef, reviewProductType);
+ }
+ }
} else if (dryRun) {
storeRef = "(would-create)";
plannedWrites.push({
@@ -1870,6 +2523,10 @@ async function performIosSync(
reviewNote: row.reviewNote,
});
storeRef = result.data.id;
+ reviewProductType = mapAscReviewProductType(
+ result.data.attributes.inAppPurchaseType,
+ row.type,
+ );
// Same partial-sync resilience as the Subscription
// branch — persist the upstream id before the
// localization / price steps that may fail.
@@ -1880,33 +2537,44 @@ async function performIosSync(
storeRef,
});
}
- if (dryRun) {
- plannedWrites.push({
- productId: row.productId,
- step: row.storeRef
- ? "patch en-US localization"
- : "create en-US localization",
- detail: row.description ?? row.title,
- });
- } else {
- try {
- await client.upsertIapLocalization({
- iapId: storeRef,
+ const reviewVersion = await resolveReviewVersion("iap", storeRef);
+ if (dryRun && reviewVersion) {
+ if (
+ reviewVersion.alreadySubmitted ||
+ reviewVersion.attachedToSubmission
+ ) {
+ const matches = await ascReviewLocalizationMatches({
+ request: reviewRequest,
+ kind: "iap",
+ versionId: reviewVersion.versionId,
name: row.title,
description: row.description ?? row.title,
+ checkCancelled,
});
- } catch (error) {
- // Same 409-is-benign rationale as the subscription
- // localization path — see PR #124
- // (https://github.com/hyodotdev/openiap/pull/124) review.
- if (!(error instanceof AscApiError && error.status === 409)) {
+ if (!matches) {
recordFailure({
- productId: `${row.productId} (localization)`,
+ productId: `${row.productId} (review version)`,
reason:
- error instanceof Error ? error.message : String(error),
+ "The current ASC review version is already attached or submitted and its en-US metadata differs from this Draft.",
+ });
+ } else {
+ plannedWrites.push({
+ productId: row.productId,
+ step: "keep locked en-US version localization",
+ detail: "Current ASC metadata already matches.",
});
}
+ } else {
+ plannedWrites.push({
+ productId: row.productId,
+ step: row.storeRef
+ ? "patch en-US version localization"
+ : "create en-US version localization",
+ detail: row.description ?? row.title,
+ });
}
+ } else if (reviewVersion) {
+ await syncReviewLocalization("iap", reviewVersion);
}
if (
row.priceAmountMicros !== undefined &&
@@ -1946,6 +2614,7 @@ async function performIosSync(
}
}
} catch (error) {
+ if (isProductSyncAbortError(error)) throw error;
// Treat only duplicate/existing conflicts as benign
// retries. ASC also reports malformed price payloads
// as 409 ENTITY_ERROR, and those must stay visible.
@@ -1964,35 +2633,120 @@ async function performIosSync(
reason: `Non-USD pricing (${row.currency}) not supported in push yet — set USD on the catalog row or configure other territories in ASC web.`,
});
}
- // Same gate as the Subscription branch — only flip Ready
- // when no follow-up step recorded a failure for this row.
- if (!dryRun && !rowHadFailure) {
- await ctx.runMutation(internal.products.sync.markPushed, {
- projectId: project._id,
- productId: row.productId,
- platform: "IOS",
- storeRef,
- });
- }
- pushed += 1;
+ return await finalizeReview(
+ "iap",
+ storeRef,
+ reviewProductType,
+ reviewVersion,
+ );
}
- // DEFERRED(review-submit): once Settings has an upload slot for a
- // project-level App Review screenshot
- // (`apple_iap_review_screenshot` purpose), add a step here:
- // 1. POST /v1/inAppPurchaseAppStoreReviewScreenshots (reserve)
- // 2. PUT to the returned upload URL (binary)
- // 3. PATCH ...screenshots/{id} with sourceFileChecksum
- // 4. POST /v1/inAppPurchaseSubmissions
- // Until then, the row stops at "Ready to Submit" in ASC and
- // the operator hits Submit manually (or via next app version).
} catch (error) {
+ if (isProductSyncAbortError(error)) throw error;
recordFailure({
productId: row.productId,
reason: error instanceof Error ? error.message : String(error),
});
+ return null;
}
};
- await mapWithConcurrency(drafts, PUSH_CONCURRENCY, processOneDraft);
+ let processedDrafts = 0;
+ let stoppedAfterSubmission = false;
+ for (
+ let offset = 0;
+ offset < drafts.length;
+ offset += ASC_REVIEW_SYNC_BATCH_LIMIT
+ ) {
+ await checkCancelled();
+ const chunk = drafts.slice(offset, offset + ASC_REVIEW_SYNC_BATCH_LIMIT);
+ const reviewItems = (
+ await mapWithConcurrency(chunk, PUSH_CONCURRENCY, processOneDraft)
+ ).filter((item): item is AscReviewVersionItem => item !== null);
+ processedDrafts += chunk.length;
+ await reportPhase("push-drafts", {
+ current: processedDrafts,
+ total: drafts.length,
+ failuresCount: failures.length,
+ });
+
+ // Dry-run never returns submission items; continue so its read-only plan
+ // covers the full candidate set. Batches containing only failures/manual
+ // gates also continue, preventing one bad prefix from starving later rows.
+ if (reviewItems.length === 0) continue;
+
+ await checkCancelled();
+ await reportPhase("submit-review", {
+ current: processedDrafts,
+ total: drafts.length,
+ failuresCount: failures.length,
+ });
+ try {
+ const { selected: submissionItems, deferred: preparedDeferred } =
+ partitionAscReviewSubmissionItems(reviewItems);
+ if (preparedDeferred.length > 0) {
+ failures.push({
+ productId: "(review submission capacity)",
+ reason:
+ `Apple limits one review submission to ${ASC_REVIEW_SUBMISSION_ITEM_LIMIT} items. ` +
+ `${preparedDeferred.length} prepared product(s) remain Draft.`,
+ });
+ }
+ const submission = await submitAscReviewVersions({
+ request: reviewRequest,
+ cleanupRequest: reviewCleanupRequest,
+ appId: appIdStr,
+ items: submissionItems,
+ checkCancelled,
+ isAbortError: isProductSyncAbortError,
+ });
+ for (const outcome of submission.outcomes) {
+ if (outcome.status === "failed") {
+ failures.push({
+ productId: `${outcome.item.productId} (review submission)`,
+ reason: outcome.reason,
+ });
+ continue;
+ }
+ if (outcome.status === "manual") {
+ manualActions.push(outcome.action);
+ }
+ if (!shouldMarkAscReviewSubmissionOutcomePushed(outcome)) continue;
+ await ctx.runMutation(internal.products.sync.markPushed, {
+ projectId: project._id,
+ productId: outcome.item.productId,
+ platform: "IOS",
+ storeRef: outcome.item.storeRef,
+ reviewScreenshotFileId: screenshotMetadata?.fileId,
+ });
+ pushed += 1;
+ }
+ if (submission.globalFailure) {
+ failures.push({
+ productId: "(review submission)",
+ reason: submission.globalFailure,
+ });
+ }
+ } catch (error) {
+ if (isProductSyncAbortError(error)) throw error;
+ failures.push({
+ productId: "(review submission)",
+ reason: error instanceof Error ? error.message : String(error),
+ });
+ }
+ // ASC permits one active review submission. Finish this deterministic
+ // prepare→submit unit and leave the remaining Draft rows for a later job
+ // rather than preparing resources that cannot be submitted this run.
+ stoppedAfterSubmission = true;
+ break;
+ }
+ if (stoppedAfterSubmission && processedDrafts < drafts.length) {
+ failures.push({
+ productId: "(review submission batch)",
+ reason:
+ `${drafts.length - processedDrafts} product(s) remain Draft after this bounded ` +
+ `batch of ${ASC_REVIEW_SYNC_BATCH_LIMIT}. Run Push Sync again after the current ` +
+ "App Store Connect review submission is no longer active.",
+ });
+ }
}
return {
@@ -2001,6 +2755,7 @@ async function performIosSync(
...(deleted > 0 ? { deleted } : {}),
failures,
plannedWrites: dryRun ? plannedWrites : undefined,
+ manualActions: manualActions.length > 0 ? manualActions : undefined,
};
}
@@ -2098,6 +2853,22 @@ function mapAscIapType(
}
}
+export function mapAscReviewProductType(
+ raw: string | undefined,
+ fallback: "Subscription" | "NonConsumable" | "Consumable",
+): AscReviewVersionItem["productType"] {
+ switch (raw) {
+ case "CONSUMABLE":
+ return "Consumable";
+ case "NON_CONSUMABLE":
+ return "NonConsumable";
+ case "NON_RENEWING_SUBSCRIPTION":
+ return "NonRenewingSubscription";
+ default:
+ return fallback;
+ }
+}
+
// Apple represents introductory-offer durations as enum strings
// rather than ISO-8601 like the subscriptionPeriod field. Translate
// to ISO so kit's `offers[].duration` is uniform across stores
@@ -2192,8 +2963,10 @@ function mapAscState(
): "Draft" | "Ready" | "Active" | "Removed" {
switch (raw) {
case "WAITING_FOR_REVIEW":
+ case "IN_REVIEW":
case "PENDING_DEVELOPER_RELEASE":
case "READY_TO_SUBMIT":
+ case "READY_FOR_REVIEW":
return "Ready";
case "APPROVED":
case "REPLACED":
diff --git a/packages/kit/convex/products/ascReview.test.ts b/packages/kit/convex/products/ascReview.test.ts
new file mode 100644
index 000000000..2b02f0b25
--- /dev/null
+++ b/packages/kit/convex/products/ascReview.test.ts
@@ -0,0 +1,1825 @@
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ ascReviewLocalizationMatches,
+ classifyAscManualReviewAction,
+ ensureAscReviewVersion,
+ getAscReviewEligibilityActions,
+ isAscApprovedReviewHistoryState,
+ md5Hex,
+ partitionAscReviewSubmissionItems,
+ planAscReviewVersion,
+ submitAscReviewVersions,
+ uploadAscReviewScreenshot,
+ upsertAscReviewLocalization,
+ type AscJsonRequest,
+} from "./ascReview";
+
+class MockAscError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+describe("uploadAscReviewScreenshot", () => {
+ it("honors every IAP upload operation without forwarding ASC auth", async () => {
+ const bytes = Uint8Array.from([1, 2, 3, 4, 5]);
+ const requests: Array<{
+ path: string;
+ init?: RequestInit & { body?: string };
+ }> = [];
+ let poll = 0;
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ requests.push({ path, init });
+ if (path.endsWith("/appStoreReviewScreenshot")) {
+ throw new MockAscError(404, "not found");
+ }
+ if (path === "/v1/inAppPurchaseAppStoreReviewScreenshots") {
+ return {
+ data: {
+ id: "shot-1",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ // Returned out of order to verify offset ordering/coverage.
+ uploadOperations: [
+ {
+ method: "PUT",
+ url: "https://upload.example/part-2",
+ offset: 2,
+ length: 3,
+ requestHeaders: [{ name: "x-apple-part", value: "second" }],
+ },
+ {
+ method: "POST",
+ url: "https://upload.example/part-1",
+ offset: 0,
+ length: 2,
+ requestHeaders: [
+ { name: "content-type", value: "image/png" },
+ ],
+ },
+ ],
+ },
+ },
+ } as T;
+ }
+ if (init?.method === "PATCH") return { data: { id: "shot-1" } } as T;
+ poll += 1;
+ return {
+ data: {
+ id: "shot-1",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ assetDeliveryState: {
+ state: poll === 1 ? "PROCESSING" : "COMPLETE",
+ },
+ },
+ },
+ } as T;
+ };
+ const uploads: Array<{
+ url: string;
+ init?: RequestInit;
+ body: number[];
+ }> = [];
+ const fetchImpl = vi.fn(async (url: string | URL, init?: RequestInit) => {
+ uploads.push({
+ url: String(url),
+ init,
+ body: Array.from(
+ new Uint8Array(await new Response(init?.body).arrayBuffer()),
+ ),
+ });
+ return new Response("", { status: 200 });
+ }) as unknown as typeof fetch;
+ const sleep = vi.fn(async () => undefined);
+
+ await expect(
+ uploadAscReviewScreenshot({
+ request,
+ kind: "iap",
+ parentId: "iap/unsafe",
+ screenshot: { fileName: "review.png", fileType: "image/png", bytes },
+ fetchImpl,
+ sleep,
+ }),
+ ).resolves.toEqual({
+ screenshotId: "shot-1",
+ checksum: md5Hex(bytes),
+ reused: false,
+ });
+
+ expect(requests[0]?.path).toBe(
+ "/v2/inAppPurchases/iap%2Funsafe/appStoreReviewScreenshot",
+ );
+ const reserve = JSON.parse(String(requests[1]?.init?.body));
+ expect(reserve.data.relationships.inAppPurchaseV2.data).toEqual({
+ type: "inAppPurchases",
+ id: "iap/unsafe",
+ });
+ expect(uploads).toEqual([
+ {
+ url: "https://upload.example/part-1",
+ init: expect.objectContaining({
+ method: "POST",
+ headers: { "content-type": "image/png" },
+ }),
+ body: [1, 2],
+ },
+ {
+ url: "https://upload.example/part-2",
+ init: expect.objectContaining({
+ method: "PUT",
+ headers: { "x-apple-part": "second" },
+ }),
+ body: [3, 4, 5],
+ },
+ ]);
+ for (const upload of uploads) {
+ expect(upload.init?.headers).not.toHaveProperty("authorization");
+ }
+ const commit = JSON.parse(
+ String(
+ requests.find((entry) => entry.init?.method === "PATCH")?.init?.body,
+ ),
+ );
+ expect(commit.data.attributes).toEqual({
+ uploaded: true,
+ sourceFileChecksum: md5Hex(bytes),
+ });
+ expect(sleep).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses subscription-specific parent relationship and endpoints", async () => {
+ const calls: string[] = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push(path);
+ if (path.endsWith("/appStoreReviewScreenshot")) {
+ throw new MockAscError(404, "missing");
+ }
+ if (path === "/v1/subscriptionAppStoreReviewScreenshots") {
+ const body = JSON.parse(String(init?.body));
+ expect(body.data.relationships.subscription.data).toEqual({
+ type: "subscriptions",
+ id: "sub-1",
+ });
+ return {
+ data: {
+ id: "sub-shot",
+ type: "subscriptionAppStoreReviewScreenshots",
+ attributes: {
+ uploadOperations: [
+ {
+ method: "PUT",
+ url: "https://upload.example/sub",
+ offset: 0,
+ length: 4,
+ },
+ ],
+ },
+ },
+ } as T;
+ }
+ if (init?.method === "PATCH") return { data: { id: "sub-shot" } } as T;
+ return {
+ data: {
+ id: "sub-shot",
+ type: "subscriptionAppStoreReviewScreenshots",
+ attributes: { assetDeliveryState: { state: "COMPLETE" } },
+ },
+ } as T;
+ };
+
+ await uploadAscReviewScreenshot({
+ request,
+ kind: "subscription",
+ parentId: "sub-1",
+ screenshot: {
+ fileName: "review.jpg",
+ fileType: "image/jpeg",
+ bytes: Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]),
+ },
+ fetchImpl: vi.fn(
+ async () => new Response("", { status: 200 }),
+ ) as unknown as typeof fetch,
+ });
+
+ expect(calls[0]).toBe("/v1/subscriptions/sub-1/appStoreReviewScreenshot");
+ expect(calls).toContain(
+ "/v1/subscriptionAppStoreReviewScreenshots/sub-shot",
+ );
+ });
+
+ it("rejects gapped upload operations before sending bytes", async () => {
+ const fetchImpl = vi.fn();
+ const request: AscJsonRequest = async (path: string) => {
+ if (path.endsWith("/appStoreReviewScreenshot")) {
+ throw new MockAscError(404, "missing");
+ }
+ return {
+ data: {
+ id: "bad-shot",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ uploadOperations: [
+ {
+ method: "PUT",
+ url: "https://upload.example/bad",
+ offset: 1,
+ length: 3,
+ },
+ ],
+ },
+ },
+ } as T;
+ };
+ await expect(
+ uploadAscReviewScreenshot({
+ request,
+ kind: "iap",
+ parentId: "iap-1",
+ screenshot: {
+ fileName: "review.jpg",
+ fileType: "image/jpeg",
+ bytes: Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]),
+ },
+ fetchImpl: fetchImpl as unknown as typeof fetch,
+ }),
+ ).rejects.toThrow(/invalid upload operation ranges/);
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it("reuses a complete screenshot with the same whole-file checksum", async () => {
+ const bytes = Uint8Array.from([1, 2, 3]);
+ const request = vi.fn(async () => ({
+ data: {
+ id: "existing-shot",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ sourceFileChecksum: md5Hex(bytes),
+ assetDeliveryState: { state: "COMPLETE" },
+ },
+ },
+ })) as unknown as AscJsonRequest;
+ const fetchImpl = vi.fn();
+
+ await expect(
+ uploadAscReviewScreenshot({
+ request,
+ kind: "iap",
+ parentId: "iap-1",
+ screenshot: {
+ fileName: "review.png",
+ fileType: "image/png",
+ bytes,
+ },
+ fetchImpl: fetchImpl as unknown as typeof fetch,
+ }),
+ ).resolves.toEqual({
+ screenshotId: "existing-shot",
+ checksum: md5Hex(bytes),
+ reused: true,
+ });
+ expect(request).toHaveBeenCalledTimes(1);
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it("resumes a same-checksum processing screenshot without replacing it", async () => {
+ const bytes = Uint8Array.from([1, 2, 3]);
+ const calls: Array<{ path: string; method?: string }> = [];
+ let reads = 0;
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({ path, method: init?.method });
+ reads += 1;
+ return {
+ data: {
+ id: "processing-shot",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ sourceFileChecksum: md5Hex(bytes),
+ assetDeliveryState: {
+ state: reads < 3 ? "PROCESSING" : "COMPLETE",
+ },
+ },
+ },
+ } as T;
+ };
+ const fetchImpl = vi.fn();
+
+ await expect(
+ uploadAscReviewScreenshot({
+ request,
+ kind: "iap",
+ parentId: "iap-1",
+ screenshot: {
+ fileName: "review.png",
+ fileType: "image/png",
+ bytes,
+ },
+ fetchImpl: fetchImpl as unknown as typeof fetch,
+ sleep: async () => undefined,
+ }),
+ ).resolves.toEqual({
+ screenshotId: "processing-shot",
+ checksum: md5Hex(bytes),
+ reused: true,
+ });
+ expect(calls.every((call) => call.method === undefined)).toBe(true);
+ expect(fetchImpl).not.toHaveBeenCalled();
+ });
+
+ it("preserves a checksum-committed screenshot when bounded polling expires", async () => {
+ const bytes = Uint8Array.from([1, 2, 3]);
+ const calls: Array<{ path: string; method?: string }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({ path, method: init?.method });
+ if (path.endsWith("/appStoreReviewScreenshot")) {
+ throw new MockAscError(404, "missing");
+ }
+ if (
+ path === "/v1/inAppPurchaseAppStoreReviewScreenshots" &&
+ init?.method === "POST"
+ ) {
+ return {
+ data: {
+ id: "slow-shot",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ uploadOperations: [
+ {
+ method: "PUT",
+ url: "https://upload.example/slow",
+ offset: 0,
+ length: bytes.byteLength,
+ },
+ ],
+ },
+ },
+ } as T;
+ }
+ if (init?.method === "PATCH") return { data: {} } as T;
+ return {
+ data: {
+ id: "slow-shot",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: { assetDeliveryState: { state: "PROCESSING" } },
+ },
+ } as T;
+ };
+
+ await expect(
+ uploadAscReviewScreenshot({
+ request,
+ kind: "iap",
+ parentId: "iap-1",
+ screenshot: {
+ fileName: "review.png",
+ fileType: "image/png",
+ bytes,
+ },
+ fetchImpl: vi.fn(
+ async () => new Response("", { status: 200 }),
+ ) as unknown as typeof fetch,
+ sleep: async () => undefined,
+ maxPollAttempts: 2,
+ }),
+ ).rejects.toThrow(/still processing after 2 polls/);
+ expect(calls.some((call) => call.method === "DELETE")).toBe(false);
+ });
+
+ it("preserves a committed screenshot when cancellation interrupts polling", async () => {
+ const bytes = Uint8Array.from([1, 2, 3]);
+ const calls: Array<{ path: string; method?: string }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({ path, method: init?.method });
+ if (path.endsWith("/appStoreReviewScreenshot")) {
+ throw new MockAscError(404, "missing");
+ }
+ if (
+ path === "/v1/inAppPurchaseAppStoreReviewScreenshots" &&
+ init?.method === "POST"
+ ) {
+ return {
+ data: {
+ id: "cancel-after-commit",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ uploadOperations: [
+ {
+ method: "PUT",
+ url: "https://upload.example/cancel-after-commit",
+ offset: 0,
+ length: bytes.byteLength,
+ },
+ ],
+ },
+ },
+ } as T;
+ }
+ if (init?.method === "PATCH") return { data: {} } as T;
+ return { data: {} } as T;
+ };
+ let checks = 0;
+ const checkCancelled = async () => {
+ checks += 1;
+ if (checks === 5) throw new Error("cancel after checksum commit");
+ };
+
+ await expect(
+ uploadAscReviewScreenshot({
+ request,
+ kind: "iap",
+ parentId: "iap-1",
+ screenshot: {
+ fileName: "review.png",
+ fileType: "image/png",
+ bytes,
+ },
+ fetchImpl: vi.fn(
+ async () => new Response("", { status: 200 }),
+ ) as unknown as typeof fetch,
+ checkCancelled,
+ }),
+ ).rejects.toThrow("cancel after checksum commit");
+ expect(calls.some((call) => call.method === "DELETE")).toBe(false);
+ });
+
+ it("reserves a screenshot when the parent relationship returns null data", async () => {
+ const bytes = Uint8Array.from([1, 2, 3]);
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ if (path.endsWith("/appStoreReviewScreenshot")) {
+ return { data: null } as T;
+ }
+ if (
+ path === "/v1/inAppPurchaseAppStoreReviewScreenshots" &&
+ init?.method === "POST"
+ ) {
+ return {
+ data: {
+ id: "new-shot",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ uploadOperations: [
+ {
+ method: "PUT",
+ url: "https://upload.example/new",
+ offset: 0,
+ length: bytes.byteLength,
+ },
+ ],
+ },
+ },
+ } as T;
+ }
+ if (init?.method === "PATCH") return { data: { id: "new-shot" } } as T;
+ return {
+ data: {
+ id: "new-shot",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: { assetDeliveryState: { state: "COMPLETE" } },
+ },
+ } as T;
+ };
+
+ await expect(
+ uploadAscReviewScreenshot({
+ request,
+ kind: "iap",
+ parentId: "iap-1",
+ screenshot: {
+ fileName: "review.png",
+ fileType: "image/png",
+ bytes,
+ },
+ fetchImpl: vi.fn(
+ async () => new Response("", { status: 200 }),
+ ) as unknown as typeof fetch,
+ }),
+ ).resolves.toMatchObject({ screenshotId: "new-shot", reused: false });
+ });
+
+ it("times out a stalled upload operation and deletes the reservation", async () => {
+ const calls: Array<{ path: string; method?: string }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({ path, method: init?.method });
+ if (path.endsWith("/appStoreReviewScreenshot")) {
+ throw new MockAscError(404, "missing");
+ }
+ return {
+ data: {
+ id: "timed-out-shot",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ uploadOperations: [
+ {
+ method: "PUT",
+ url: "https://upload.example/stalled",
+ offset: 0,
+ length: 3,
+ },
+ ],
+ },
+ },
+ } as T;
+ };
+ const fetchImpl = vi.fn(
+ async (_url: string | URL, init?: RequestInit) =>
+ await new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener(
+ "abort",
+ () => reject(new Error("aborted")),
+ { once: true },
+ );
+ }),
+ ) as unknown as typeof fetch;
+
+ await expect(
+ uploadAscReviewScreenshot({
+ request,
+ kind: "iap",
+ parentId: "iap-1",
+ screenshot: {
+ fileName: "review.png",
+ fileType: "image/png",
+ bytes: Uint8Array.from([1, 2, 3]),
+ },
+ fetchImpl,
+ uploadTimeoutMs: 1,
+ }),
+ ).rejects.toThrow(/timed out after 1ms/);
+ expect(calls.at(-1)).toEqual({
+ path: "/v1/inAppPurchaseAppStoreReviewScreenshots/timed-out-shot",
+ method: "DELETE",
+ });
+ });
+
+ it("deletes the reservation when asset delivery fails", async () => {
+ const calls: Array<{ path: string; method?: string }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({ path, method: init?.method });
+ if (path.endsWith("/appStoreReviewScreenshot")) {
+ throw new MockAscError(404, "missing");
+ }
+ if (path === "/v1/inAppPurchaseAppStoreReviewScreenshots") {
+ return {
+ data: {
+ id: "failed-shot",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ uploadOperations: [
+ {
+ method: "PUT",
+ url: "https://upload.example/fail",
+ offset: 0,
+ length: 4,
+ },
+ ],
+ },
+ },
+ } as T;
+ }
+ if (init?.method === "PATCH") return { data: {} } as T;
+ return {
+ data: {
+ id: "failed-shot",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ assetDeliveryState: {
+ state: "FAILED",
+ errors: [{ description: "Invalid image" }],
+ },
+ },
+ },
+ } as T;
+ };
+
+ await expect(
+ uploadAscReviewScreenshot({
+ request,
+ kind: "iap",
+ parentId: "iap-1",
+ screenshot: {
+ fileName: "review.jpg",
+ fileType: "image/jpeg",
+ bytes: Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]),
+ },
+ fetchImpl: vi.fn(
+ async () => new Response("", { status: 200 }),
+ ) as unknown as typeof fetch,
+ }),
+ ).rejects.toThrow(/delivery failed: Invalid image/);
+ expect(calls.at(-1)).toEqual({
+ path: "/v1/inAppPurchaseAppStoreReviewScreenshots/failed-shot",
+ method: "DELETE",
+ });
+ });
+
+ it("deletes the reservation when cancellation interrupts multipart upload", async () => {
+ const calls: Array<{ path: string; method?: string }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({ path, method: init?.method });
+ if (path.endsWith("/appStoreReviewScreenshot")) {
+ throw new MockAscError(404, "missing");
+ }
+ return {
+ data: {
+ id: "cancelled-shot",
+ type: "inAppPurchaseAppStoreReviewScreenshots",
+ attributes: {
+ uploadOperations: [
+ {
+ method: "PUT",
+ url: "https://upload.example/cancel",
+ offset: 0,
+ length: 4,
+ },
+ ],
+ },
+ },
+ } as T;
+ };
+ let checks = 0;
+ const checkCancelled = async () => {
+ checks += 1;
+ if (checks === 3) throw new Error("cancelled");
+ };
+
+ await expect(
+ uploadAscReviewScreenshot({
+ request,
+ kind: "iap",
+ parentId: "iap-1",
+ screenshot: {
+ fileName: "review.jpg",
+ fileType: "image/jpeg",
+ bytes: Uint8Array.from([0xff, 0xd8, 0xff, 0xd9]),
+ },
+ checkCancelled,
+ fetchImpl: vi.fn() as unknown as typeof fetch,
+ }),
+ ).rejects.toThrow("cancelled");
+ expect(calls.at(-1)).toEqual({
+ path: "/v1/inAppPurchaseAppStoreReviewScreenshots/cancelled-shot",
+ method: "DELETE",
+ });
+ });
+});
+
+describe("ASC version and submission workflow", () => {
+ it("rejects an oversized submission before creating a remote draft", async () => {
+ const request = vi.fn() as unknown as AscJsonRequest;
+ const items = Array.from({ length: 201 }, (_, index) => ({
+ productId: `product-${index}`,
+ storeRef: `iap-${index}`,
+ kind: "iap" as const,
+ productType: "Consumable" as const,
+ versionId: `version-${index}`,
+ }));
+
+ await expect(
+ submitAscReviewVersions({ request, appId: "app-1", items }),
+ ).resolves.toEqual({
+ outcomes: [],
+ globalFailure: expect.stringMatching(/at most 200 items/),
+ });
+ expect(request).not.toHaveBeenCalled();
+ });
+
+ it("preclassifies first product types and new subscription groups", () => {
+ const emptySnapshot = {
+ approvedProductTypes: new Set<
+ | "Subscription"
+ | "NonRenewingSubscription"
+ | "NonConsumable"
+ | "Consumable"
+ >(),
+ approvedSubscriptionGroupIds: new Set(),
+ };
+ const subscription = {
+ productId: "premium-monthly",
+ storeRef: "sub-1",
+ kind: "subscription" as const,
+ productType: "Subscription" as const,
+ versionId: "sub-version",
+ subscriptionGroupId: "group-new",
+ };
+
+ expect(
+ getAscReviewEligibilityActions({
+ item: subscription,
+ snapshot: emptySnapshot,
+ }).map((action) => action.code),
+ ).toEqual(["app_version_required", "subscription_group_required"]);
+
+ const approvedTypeOnly = {
+ ...emptySnapshot,
+ approvedProductTypes: new Set(["Subscription" as const]),
+ };
+ expect(
+ getAscReviewEligibilityActions({
+ item: subscription,
+ snapshot: approvedTypeOnly,
+ }).map((action) => action.code),
+ ).toEqual(["subscription_group_required"]);
+
+ expect(
+ getAscReviewEligibilityActions({
+ item: subscription,
+ snapshot: {
+ approvedProductTypes: new Set(["Subscription" as const]),
+ approvedSubscriptionGroupIds: new Set(["group-new"]),
+ },
+ }),
+ ).toEqual([]);
+ });
+
+ it("keeps approval history distinct from pending review states", () => {
+ for (const state of [
+ "APPROVED",
+ "READY_FOR_SALE",
+ "ACCEPTED",
+ "REPLACED_WITH_NEW_VERSION",
+ "DEVELOPER_REMOVED_FROM_SALE",
+ ]) {
+ expect(isAscApprovedReviewHistoryState(state)).toBe(true);
+ }
+ for (const state of [
+ "PREPARE_FOR_SUBMISSION",
+ "READY_FOR_REVIEW",
+ "WAITING_FOR_REVIEW",
+ "IN_REVIEW",
+ ]) {
+ expect(isAscApprovedReviewHistoryState(state)).toBe(false);
+ }
+ });
+
+ it("does not let one approved product type unlock another", () => {
+ const item = {
+ productId: "lifetime",
+ storeRef: "iap-1",
+ kind: "iap" as const,
+ productType: "NonConsumable" as const,
+ versionId: "iap-version",
+ };
+ expect(
+ getAscReviewEligibilityActions({
+ item,
+ snapshot: {
+ approvedProductTypes: new Set(["Consumable" as const]),
+ approvedSubscriptionGroupIds: new Set(),
+ },
+ }).map((action) => action.code),
+ ).toEqual(["app_version_required"]);
+ });
+
+ it("creates IAP versions and v2 localizations against the version", async () => {
+ const calls: Array<{ path: string; body?: unknown }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({
+ path,
+ body: init?.body ? JSON.parse(init.body) : undefined,
+ });
+ if (path.includes("/versions?")) return { data: [] } as T;
+ if (path === "/v1/inAppPurchaseVersions") {
+ return {
+ data: { id: "iap-version", type: "inAppPurchaseVersions" },
+ } as T;
+ }
+ if (path.includes("/localizations?")) return { data: [] } as T;
+ return { data: { id: "loc-1" } } as T;
+ };
+
+ const version = await ensureAscReviewVersion({
+ request,
+ kind: "iap",
+ parentId: "iap-1",
+ });
+ await upsertAscReviewLocalization({
+ request,
+ kind: "iap",
+ versionId: version.versionId,
+ name: "Coins",
+ description: "100 coins",
+ });
+
+ expect(version).toEqual({
+ versionId: "iap-version",
+ alreadySubmitted: false,
+ attachedToSubmission: false,
+ });
+ expect(calls[1]).toEqual({
+ path: "/v1/inAppPurchaseVersions",
+ body: {
+ data: {
+ type: "inAppPurchaseVersions",
+ relationships: {
+ inAppPurchase: {
+ data: { type: "inAppPurchases", id: "iap-1" },
+ },
+ },
+ },
+ },
+ });
+ expect(calls[3]).toEqual({
+ path: "/v2/inAppPurchaseLocalizations",
+ body: {
+ data: {
+ type: "inAppPurchaseLocalizations",
+ attributes: {
+ name: "Coins",
+ description: "100 coins",
+ locale: "en-US",
+ },
+ relationships: {
+ version: {
+ data: { type: "inAppPurchaseVersions", id: "iap-version" },
+ },
+ },
+ },
+ },
+ });
+ });
+
+ it("treats READY_FOR_REVIEW as attached and does not create a mutable version", async () => {
+ const request = vi.fn(async () => ({
+ data: [
+ {
+ id: "attached-version",
+ type: "subscriptionVersions",
+ attributes: { state: "READY_FOR_REVIEW" },
+ },
+ ],
+ })) as unknown as AscJsonRequest;
+
+ await expect(
+ ensureAscReviewVersion({
+ request,
+ kind: "subscription",
+ parentId: "sub-1",
+ }),
+ ).resolves.toEqual({
+ versionId: "attached-version",
+ alreadySubmitted: false,
+ attachedToSubmission: true,
+ });
+ expect(request).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not create a new version for a previously completed Ready row", async () => {
+ const request = vi.fn(async () => ({
+ data: [
+ {
+ id: "approved-version",
+ type: "inAppPurchaseVersions",
+ attributes: { state: "APPROVED" },
+ },
+ ],
+ })) as unknown as AscJsonRequest;
+
+ await expect(
+ ensureAscReviewVersion({
+ request,
+ kind: "iap",
+ parentId: "iap-1",
+ allowCreate: false,
+ }),
+ ).resolves.toEqual({
+ versionId: "approved-version",
+ alreadySubmitted: true,
+ attachedToSubmission: true,
+ });
+ expect(request).toHaveBeenCalledTimes(1);
+ });
+
+ it("creates the first review version when a legacy Ready row has none", async () => {
+ const request = vi
+ .fn()
+ .mockResolvedValueOnce({ data: [] })
+ .mockResolvedValueOnce({
+ data: { id: "new-version", type: "inAppPurchaseVersions" },
+ }) as unknown as AscJsonRequest;
+
+ await expect(
+ ensureAscReviewVersion({
+ request,
+ kind: "iap",
+ parentId: "iap-legacy-ready",
+ allowCreate: true,
+ reuseApproved: true,
+ }),
+ ).resolves.toEqual({
+ versionId: "new-version",
+ alreadySubmitted: false,
+ attachedToSubmission: false,
+ });
+ expect(request).toHaveBeenCalledTimes(2);
+ });
+
+ it("plans dry-run version handling from the actual remote state", () => {
+ expect(
+ planAscReviewVersion({ localState: "Ready", current: null }),
+ ).toMatchObject({ action: "create" });
+ expect(
+ planAscReviewVersion({
+ localState: "Ready",
+ current: { versionId: "approved", state: "approved" },
+ }),
+ ).toEqual({
+ action: "reuse",
+ reviewVersion: {
+ versionId: "approved",
+ alreadySubmitted: true,
+ attachedToSubmission: true,
+ },
+ });
+ expect(
+ planAscReviewVersion({
+ localState: "Draft",
+ current: { versionId: "approved", state: "approved" },
+ }),
+ ).toMatchObject({ action: "create" });
+ expect(
+ planAscReviewVersion({
+ localState: "Draft",
+ current: { versionId: "attached", state: "attached" },
+ }),
+ ).toEqual({
+ action: "reuse",
+ reviewVersion: {
+ versionId: "attached",
+ alreadySubmitted: false,
+ attachedToSubmission: true,
+ },
+ });
+ });
+
+ it("partitions an oversized submission deterministically at Apple's limit", () => {
+ const items = Array.from({ length: 201 }, (_, index) => index);
+ const partition = partitionAscReviewSubmissionItems(items);
+ expect(partition.selected).toHaveLength(200);
+ expect(partition.selected.at(-1)).toBe(199);
+ expect(partition.deferred).toEqual([200]);
+ });
+
+ it("compares immutable attached-version metadata before treating a retry as success", async () => {
+ const request = vi.fn(async () => ({
+ data: [
+ {
+ id: "loc-1",
+ type: "inAppPurchaseLocalizations",
+ attributes: {
+ locale: "en-US",
+ name: "Coins",
+ description: "100 coins",
+ },
+ },
+ ],
+ })) as unknown as AscJsonRequest;
+
+ await expect(
+ ascReviewLocalizationMatches({
+ request,
+ kind: "iap",
+ versionId: "attached-version",
+ name: "Coins",
+ description: "100 coins",
+ }),
+ ).resolves.toBe(true);
+ await expect(
+ ascReviewLocalizationMatches({
+ request,
+ kind: "iap",
+ versionId: "attached-version",
+ name: "Coins Plus",
+ description: "200 coins",
+ }),
+ ).resolves.toBe(false);
+ });
+
+ it("creates one review submission with IAP and subscription version items", async () => {
+ const calls: Array<{ path: string; body?: any }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({
+ path,
+ body: init?.body ? JSON.parse(init.body) : undefined,
+ });
+ if (path === "/v1/reviewSubmissions" && init?.method === "POST") {
+ return {
+ data: { id: "submission-1", type: "reviewSubmissions" },
+ } as T;
+ }
+ return { data: { id: "created" } } as T;
+ };
+
+ const result = await submitAscReviewVersions({
+ request,
+ appId: "app-1",
+ items: [
+ {
+ productId: "coins",
+ storeRef: "iap-1",
+ kind: "iap",
+ productType: "Consumable",
+ versionId: "iap-version",
+ },
+ {
+ productId: "premium",
+ storeRef: "sub-1",
+ kind: "subscription",
+ productType: "Subscription",
+ versionId: "sub-version",
+ },
+ ],
+ });
+
+ expect(calls[0]?.body).toEqual({
+ data: {
+ type: "reviewSubmissions",
+ attributes: { platform: "IOS" },
+ relationships: { app: { data: { type: "apps", id: "app-1" } } },
+ },
+ });
+ expect(calls[1]?.body.data.relationships.inAppPurchaseVersion.data).toEqual(
+ { type: "inAppPurchaseVersions", id: "iap-version" },
+ );
+ expect(calls[2]?.body.data.relationships.subscriptionVersion.data).toEqual({
+ type: "subscriptionVersions",
+ id: "sub-version",
+ });
+ expect(calls[3]).toEqual({
+ path: "/v1/reviewSubmissions/submission-1",
+ body: {
+ data: {
+ type: "reviewSubmissions",
+ id: "submission-1",
+ attributes: { submitted: true },
+ },
+ },
+ });
+ expect(result.outcomes.map((outcome) => outcome.status)).toEqual([
+ "submitted",
+ "submitted",
+ ]);
+ expect(calls.map((call) => call.path).join(" ")).not.toMatch(
+ /inAppPurchaseSubmissions|subscriptionSubmissions/,
+ );
+ });
+
+ it("keeps review-item failures product-specific and submits added items", async () => {
+ let itemCount = 0;
+ const calls: Array<{ path: string; body?: any }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({
+ path,
+ body: init?.body ? JSON.parse(init.body) : undefined,
+ });
+ if (path === "/v1/reviewSubmissions" && init?.method === "POST") {
+ return {
+ data: { id: "submission-1", type: "reviewSubmissions" },
+ } as T;
+ }
+ if (path === "/v1/reviewSubmissionItems") {
+ itemCount += 1;
+ if (itemCount === 1) {
+ throw new MockAscError(
+ 422,
+ "The first consumable in-app purchase must be submitted with a new app version",
+ );
+ }
+ return { data: { id: "item-2" } } as T;
+ }
+ return { data: { id: "updated" } } as T;
+ };
+ const first = {
+ productId: "coins",
+ storeRef: "iap-1",
+ kind: "iap" as const,
+ productType: "Consumable" as const,
+ versionId: "iap-version",
+ };
+ const second = {
+ productId: "premium",
+ storeRef: "sub-1",
+ kind: "subscription" as const,
+ productType: "Subscription" as const,
+ versionId: "sub-version",
+ };
+
+ const result = await submitAscReviewVersions({
+ request,
+ appId: "app-1",
+ items: [first, second],
+ });
+
+ expect(result).toEqual({
+ outcomes: [
+ {
+ item: first,
+ status: "manual",
+ action: expect.objectContaining({
+ productId: "coins",
+ code: "app_version_required",
+ }),
+ },
+ { item: second, status: "submitted" },
+ ],
+ });
+ expect(calls.at(-1)).toMatchObject({
+ path: "/v1/reviewSubmissions/submission-1",
+ body: {
+ data: { attributes: { submitted: true } },
+ },
+ });
+ });
+
+ it("removes only the first-of-type item and retries unrelated items", async () => {
+ let createdItems = 0;
+ let submitAttempts = 0;
+ const calls: Array<{ path: string; method?: string }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({ path, method: init?.method });
+ if (path === "/v1/reviewSubmissions" && init?.method === "POST") {
+ return {
+ data: { id: "submission-typed", type: "reviewSubmissions" },
+ } as T;
+ }
+ if (path === "/v1/reviewSubmissionItems") {
+ createdItems += 1;
+ return { data: { id: `item-${createdItems}` } } as T;
+ }
+ if (
+ path === "/v1/reviewSubmissions/submission-typed" &&
+ init?.method === "PATCH"
+ ) {
+ submitAttempts += 1;
+ if (submitAttempts === 1) {
+ throw new MockAscError(
+ 422,
+ "The first consumable in-app purchase must be submitted with a new app version",
+ );
+ }
+ }
+ return { data: {} } as T;
+ };
+ const consumable = {
+ productId: "coins",
+ storeRef: "iap-1",
+ kind: "iap" as const,
+ productType: "Consumable" as const,
+ versionId: "iap-version",
+ };
+ const subscription = {
+ productId: "premium",
+ storeRef: "sub-1",
+ kind: "subscription" as const,
+ productType: "Subscription" as const,
+ versionId: "sub-version",
+ };
+
+ const result = await submitAscReviewVersions({
+ request,
+ appId: "app-1",
+ items: [consumable, subscription],
+ });
+
+ expect(result.outcomes).toEqual([
+ {
+ item: consumable,
+ status: "manual",
+ action: expect.objectContaining({ code: "app_version_required" }),
+ },
+ { item: subscription, status: "submitted" },
+ ]);
+ expect(calls).toContainEqual({
+ path: "/v1/reviewSubmissionItems/item-1",
+ method: "DELETE",
+ });
+ expect(submitAttempts).toBe(2);
+ });
+
+ it("cancels the whole draft when a gated item deletion is not confirmed", async () => {
+ let createdItems = 0;
+ const calls: Array<{ path: string; method?: string; body?: unknown }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ const body = init?.body ? JSON.parse(init.body) : undefined;
+ calls.push({ path, method: init?.method, body });
+ if (path === "/v1/reviewSubmissions" && init?.method === "POST") {
+ return {
+ data: { id: "submission-delete-failed", type: "reviewSubmissions" },
+ } as T;
+ }
+ if (path === "/v1/reviewSubmissionItems") {
+ createdItems += 1;
+ return { data: { id: `item-${createdItems}` } } as T;
+ }
+ if (
+ path.startsWith("/v1/reviewSubmissionItems/") &&
+ init?.method === "DELETE"
+ ) {
+ throw new Error("delete response lost");
+ }
+ if (
+ path === "/v1/reviewSubmissions/submission-delete-failed" &&
+ init?.method === "PATCH" &&
+ body?.data?.attributes?.submitted === true
+ ) {
+ throw new MockAscError(
+ 422,
+ "The first consumable in-app purchase must be submitted with a new app version",
+ );
+ }
+ return { data: {} } as T;
+ };
+ const items = [
+ {
+ productId: "coins",
+ storeRef: "iap-1",
+ kind: "iap" as const,
+ productType: "Consumable" as const,
+ versionId: "iap-version",
+ },
+ {
+ productId: "premium",
+ storeRef: "sub-1",
+ kind: "subscription" as const,
+ productType: "Subscription" as const,
+ versionId: "sub-version",
+ },
+ ];
+
+ const result = await submitAscReviewVersions({
+ request,
+ appId: "app-1",
+ items,
+ });
+ expect(result.outcomes).toEqual([]);
+ expect(result.globalFailure).toMatch(/could not confirm removal/);
+ expect(calls.at(-1)).toMatchObject({
+ path: "/v1/reviewSubmissions/submission-delete-failed",
+ method: "PATCH",
+ body: { data: { attributes: { canceled: true } } },
+ });
+ });
+
+ it("retries after sequential first-product manual gates", async () => {
+ let createdItems = 0;
+ let submitAttempts = 0;
+ const calls: Array<{ path: string; method?: string }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({ path, method: init?.method });
+ if (path === "/v1/reviewSubmissions" && init?.method === "POST") {
+ return {
+ data: { id: "submission-sequential", type: "reviewSubmissions" },
+ } as T;
+ }
+ if (path === "/v1/reviewSubmissionItems") {
+ createdItems += 1;
+ return { data: { id: `sequential-item-${createdItems}` } } as T;
+ }
+ if (
+ path === "/v1/reviewSubmissions/submission-sequential" &&
+ init?.method === "PATCH"
+ ) {
+ submitAttempts += 1;
+ if (submitAttempts === 1) {
+ throw new MockAscError(
+ 422,
+ "The first consumable in-app purchase must be submitted with a new app version",
+ );
+ }
+ if (submitAttempts === 2) {
+ throw new MockAscError(
+ 422,
+ "The subscription group must be submitted for review before this subscription",
+ );
+ }
+ }
+ return { data: {} } as T;
+ };
+ const consumable = {
+ productId: "coins",
+ storeRef: "iap-consumable",
+ kind: "iap" as const,
+ productType: "Consumable" as const,
+ versionId: "version-consumable",
+ };
+ const subscription = {
+ productId: "premium",
+ storeRef: "sub-1",
+ kind: "subscription" as const,
+ productType: "Subscription" as const,
+ versionId: "version-subscription",
+ };
+ const nonConsumable = {
+ productId: "lifetime",
+ storeRef: "iap-nonconsumable",
+ kind: "iap" as const,
+ productType: "NonConsumable" as const,
+ versionId: "version-nonconsumable",
+ };
+
+ const result = await submitAscReviewVersions({
+ request,
+ appId: "app-1",
+ items: [consumable, subscription, nonConsumable],
+ });
+
+ expect(result.outcomes).toEqual([
+ {
+ item: consumable,
+ status: "manual",
+ action: expect.objectContaining({ code: "app_version_required" }),
+ },
+ {
+ item: subscription,
+ status: "manual",
+ action: expect.objectContaining({
+ code: "subscription_group_required",
+ }),
+ },
+ { item: nonConsumable, status: "submitted" },
+ ]);
+ expect(calls).toContainEqual({
+ path: "/v1/reviewSubmissionItems/sequential-item-1",
+ method: "DELETE",
+ });
+ expect(calls).toContainEqual({
+ path: "/v1/reviewSubmissionItems/sequential-item-2",
+ method: "DELETE",
+ });
+ expect(submitAttempts).toBe(3);
+ });
+
+ it("isolates a first non-renewing subscription from regular non-consumables", async () => {
+ let createdItems = 0;
+ let submitAttempts = 0;
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ if (path === "/v1/reviewSubmissions" && init?.method === "POST") {
+ return {
+ data: { id: "submission-non-renewing", type: "reviewSubmissions" },
+ } as T;
+ }
+ if (path === "/v1/reviewSubmissionItems") {
+ createdItems += 1;
+ return { data: { id: `non-renewing-item-${createdItems}` } } as T;
+ }
+ if (
+ path === "/v1/reviewSubmissions/submission-non-renewing" &&
+ init?.method === "PATCH"
+ ) {
+ submitAttempts += 1;
+ if (submitAttempts === 1) {
+ throw new MockAscError(
+ 422,
+ "The first non-renewing subscription must be submitted with a new app version",
+ );
+ }
+ }
+ return { data: {} } as T;
+ };
+ const nonRenewing = {
+ productId: "season-pass",
+ storeRef: "iap-non-renewing",
+ kind: "iap" as const,
+ productType: "NonRenewingSubscription" as const,
+ versionId: "version-non-renewing",
+ };
+ const nonConsumable = {
+ productId: "lifetime",
+ storeRef: "iap-non-consumable",
+ kind: "iap" as const,
+ productType: "NonConsumable" as const,
+ versionId: "version-non-consumable",
+ };
+
+ const result = await submitAscReviewVersions({
+ request,
+ appId: "app-1",
+ items: [nonRenewing, nonConsumable],
+ });
+
+ expect(result.outcomes).toEqual([
+ {
+ item: nonRenewing,
+ status: "manual",
+ action: expect.objectContaining({ code: "app_version_required" }),
+ },
+ { item: nonConsumable, status: "submitted" },
+ ]);
+ expect(submitAttempts).toBe(2);
+ });
+
+ it("never commandeers an existing App Store Connect review draft", async () => {
+ const calls: Array<{ path: string; method?: string }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({ path, method: init?.method });
+ if (path === "/v1/reviewSubmissions" && init?.method === "POST") {
+ throw new MockAscError(
+ 409,
+ "An active review submission already exists",
+ );
+ }
+ return { data: {} } as T;
+ };
+ const consumable = {
+ productId: "coins",
+ storeRef: "iap-1",
+ kind: "iap" as const,
+ productType: "Consumable" as const,
+ versionId: "iap-version",
+ };
+ const subscription = {
+ productId: "premium",
+ storeRef: "sub-1",
+ kind: "subscription" as const,
+ productType: "Subscription" as const,
+ versionId: "sub-version",
+ };
+
+ const result = await submitAscReviewVersions({
+ request,
+ appId: "app-1",
+ items: [consumable, subscription],
+ });
+
+ expect(result.outcomes).toEqual([
+ {
+ item: consumable,
+ status: "manual",
+ action: expect.objectContaining({ code: "review_submission_conflict" }),
+ },
+ {
+ item: subscription,
+ status: "manual",
+ action: expect.objectContaining({ code: "review_submission_conflict" }),
+ },
+ ]);
+ expect(calls).not.toContainEqual({
+ path: "/v1/reviewSubmissionItems",
+ method: "POST",
+ });
+ expect(calls).toHaveLength(1);
+ });
+
+ it("reports a statusless create response as an explicit manual ambiguity", async () => {
+ const calls: Array<{ path: string; method?: string }> = [];
+ const request: AscJsonRequest = async <_T>(
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({ path, method: init?.method });
+ throw new Error("connection closed after request");
+ };
+ const item = {
+ productId: "coins",
+ storeRef: "iap-1",
+ kind: "iap" as const,
+ productType: "Consumable" as const,
+ versionId: "iap-version",
+ };
+
+ await expect(
+ submitAscReviewVersions({ request, appId: "app-1", items: [item] }),
+ ).resolves.toEqual({
+ outcomes: [
+ {
+ item,
+ status: "manual",
+ action: expect.objectContaining({
+ code: "review_submission_status_unknown",
+ }),
+ },
+ ],
+ });
+ expect(calls).toEqual([{ path: "/v1/reviewSubmissions", method: "POST" }]);
+ });
+
+ it("does not attribute a generic group constraint to multiple subscriptions", async () => {
+ let itemCount = 0;
+ const calls: Array<{ path: string; method?: string }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({ path, method: init?.method });
+ if (path === "/v1/reviewSubmissions" && init?.method === "POST") {
+ return {
+ data: { id: "submission-groups", type: "reviewSubmissions" },
+ } as T;
+ }
+ if (path === "/v1/reviewSubmissionItems") {
+ itemCount += 1;
+ return { data: { id: `group-item-${itemCount}` } } as T;
+ }
+ if (
+ path === "/v1/reviewSubmissions/submission-groups" &&
+ init?.method === "PATCH" &&
+ JSON.parse(String(init.body)).data.attributes.submitted === true
+ ) {
+ throw new MockAscError(
+ 422,
+ "The subscription group must be submitted for review first",
+ );
+ }
+ return { data: {} } as T;
+ };
+ const items = ["monthly", "yearly"].map((productId, index) => ({
+ productId,
+ storeRef: `sub-${index}`,
+ kind: "subscription" as const,
+ productType: "Subscription" as const,
+ versionId: `sub-version-${index}`,
+ }));
+
+ const result = await submitAscReviewVersions({
+ request,
+ appId: "app-1",
+ items,
+ });
+
+ expect(result.outcomes).toEqual([]);
+ expect(result.globalFailure).toMatch(/could not be attributed/);
+ expect(calls.filter((call) => call.method === "DELETE")).toHaveLength(0);
+ expect(calls.at(-1)).toEqual({
+ path: "/v1/reviewSubmissions/submission-groups",
+ method: "PATCH",
+ });
+ });
+
+ it("cancels the enclosing draft in O(1) when cancellation interrupts submission", async () => {
+ const calls: Array<{ path: string; method?: string; body?: any }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ calls.push({
+ path,
+ method: init?.method,
+ body: init?.body ? JSON.parse(init.body) : undefined,
+ });
+ if (path === "/v1/reviewSubmissions" && init?.method === "POST") {
+ return {
+ data: { id: "submission-cancel", type: "reviewSubmissions" },
+ } as T;
+ }
+ if (path === "/v1/reviewSubmissionItems") {
+ return { data: { id: "item-added" } } as T;
+ }
+ return { data: {} } as T;
+ };
+ let checks = 0;
+ const checkCancelled = async () => {
+ checks += 1;
+ if (checks === 3) throw new Error("operator cancelled");
+ };
+
+ await expect(
+ submitAscReviewVersions({
+ request,
+ appId: "app-1",
+ items: [
+ {
+ productId: "coins",
+ storeRef: "iap-1",
+ kind: "iap",
+ productType: "Consumable",
+ versionId: "iap-version",
+ },
+ {
+ productId: "premium",
+ storeRef: "sub-1",
+ kind: "subscription",
+ productType: "Subscription",
+ versionId: "sub-version",
+ },
+ ],
+ checkCancelled,
+ }),
+ ).rejects.toThrow("operator cancelled");
+
+ expect(calls.at(-1)).toEqual({
+ path: "/v1/reviewSubmissions/submission-cancel",
+ method: "PATCH",
+ body: {
+ data: {
+ type: "reviewSubmissions",
+ id: "submission-cancel",
+ attributes: { canceled: true },
+ },
+ },
+ });
+ expect(calls.filter((call) => call.method === "DELETE")).toHaveLength(0);
+ });
+
+ it("rethrows a transport-boundary abort during item creation after canceling the owned draft", async () => {
+ const abort = new Error("deadline reached inside request guard");
+ const cleanupCalls: Array<{ path: string; body?: unknown }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ if (path === "/v1/reviewSubmissions" && init?.method === "POST") {
+ return {
+ data: { id: "submission-item-abort", type: "reviewSubmissions" },
+ } as T;
+ }
+ if (path === "/v1/reviewSubmissionItems") throw abort;
+ return { data: {} } as T;
+ };
+ const cleanupRequest: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ cleanupCalls.push({
+ path,
+ body: init?.body ? JSON.parse(init.body) : undefined,
+ });
+ return { data: {} } as T;
+ };
+
+ await expect(
+ submitAscReviewVersions({
+ request,
+ cleanupRequest,
+ appId: "app-1",
+ items: [
+ {
+ productId: "coins",
+ storeRef: "iap-1",
+ kind: "iap",
+ productType: "Consumable",
+ versionId: "iap-version",
+ },
+ ],
+ isAbortError: (error) => error === abort,
+ }),
+ ).rejects.toBe(abort);
+ expect(cleanupCalls).toEqual([
+ {
+ path: "/v1/reviewSubmissions/submission-item-abort",
+ body: {
+ data: {
+ type: "reviewSubmissions",
+ id: "submission-item-abort",
+ attributes: { canceled: true },
+ },
+ },
+ },
+ ]);
+ });
+
+ it("rethrows a transport-boundary abort during final submission after canceling the owned draft", async () => {
+ const abort = new Error("operator cancelled inside request guard");
+ const cleanupCalls: Array<{ path: string; body?: unknown }> = [];
+ const request: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ if (path === "/v1/reviewSubmissions" && init?.method === "POST") {
+ return {
+ data: { id: "submission-submit-abort", type: "reviewSubmissions" },
+ } as T;
+ }
+ if (path === "/v1/reviewSubmissionItems") {
+ return { data: { id: "submission-item" } } as T;
+ }
+ if (
+ path === "/v1/reviewSubmissions/submission-submit-abort" &&
+ init?.method === "PATCH"
+ ) {
+ throw abort;
+ }
+ return { data: {} } as T;
+ };
+ const cleanupRequest: AscJsonRequest = async (
+ path: string,
+ init?: RequestInit & { body?: string },
+ ) => {
+ cleanupCalls.push({
+ path,
+ body: init?.body ? JSON.parse(init.body) : undefined,
+ });
+ return { data: {} } as T;
+ };
+
+ await expect(
+ submitAscReviewVersions({
+ request,
+ cleanupRequest,
+ appId: "app-1",
+ items: [
+ {
+ productId: "coins",
+ storeRef: "iap-1",
+ kind: "iap",
+ productType: "Consumable",
+ versionId: "iap-version",
+ },
+ ],
+ isAbortError: (error) => error === abort,
+ }),
+ ).rejects.toBe(abort);
+ expect(cleanupCalls).toEqual([
+ {
+ path: "/v1/reviewSubmissions/submission-submit-abort",
+ body: {
+ data: {
+ type: "reviewSubmissions",
+ id: "submission-submit-abort",
+ attributes: { canceled: true },
+ },
+ },
+ },
+ ]);
+ });
+
+ it.each([
+ [
+ "The first consumable in-app purchase must be submitted with a new app version",
+ "app_version_required",
+ ],
+ [
+ "The first non-consumable in-app purchase requires an app version",
+ "app_version_required",
+ ],
+ [
+ "The first auto-renewable subscription must be submitted with an app version",
+ "app_version_required",
+ ],
+ [
+ "The first non-renewing subscription must be submitted with an app version",
+ "app_version_required",
+ ],
+ [
+ "The subscription group must be submitted for review before this subscription",
+ "subscription_group_required",
+ ],
+ ])("classifies manual ASC constraint: %s", (message, code) => {
+ expect(
+ classifyAscManualReviewAction(new MockAscError(422, message), "sku-1"),
+ ).toMatchObject({ productId: "sku-1", code });
+ });
+
+ it("does not classify transient server errors as manual follow-up", () => {
+ expect(
+ classifyAscManualReviewAction(
+ new MockAscError(503, "Service unavailable"),
+ "sku-1",
+ ),
+ ).toBeNull();
+ });
+});
diff --git a/packages/kit/convex/products/ascReview.ts b/packages/kit/convex/products/ascReview.ts
new file mode 100644
index 000000000..6064ddaee
--- /dev/null
+++ b/packages/kit/convex/products/ascReview.ts
@@ -0,0 +1,1161 @@
+"use node";
+
+import { createHash } from "node:crypto";
+
+export type AscReviewKind = "iap" | "subscription";
+export const ASC_REVIEW_SUBMISSION_ITEM_LIMIT = 200;
+// Keep one worker's prepare→submit unit comfortably below Convex's action
+// limit. Later rows remain Draft and are picked up after this ASC submission
+// is no longer active.
+export const ASC_REVIEW_SYNC_BATCH_LIMIT = 8;
+
+export function partitionAscReviewSubmissionItems(items: readonly T[]): {
+ selected: T[];
+ deferred: T[];
+} {
+ return {
+ selected: items.slice(0, ASC_REVIEW_SUBMISSION_ITEM_LIMIT),
+ deferred: items.slice(ASC_REVIEW_SUBMISSION_ITEM_LIMIT),
+ };
+}
+
+export type AscJsonRequest = (
+ path: string,
+ init?: RequestInit & { body?: string },
+) => Promise;
+
+export interface AscReviewScreenshot {
+ fileName: string;
+ fileType: "image/png" | "image/jpeg";
+ bytes: Uint8Array;
+}
+
+export interface AscReviewVersionItem {
+ productId: string;
+ storeRef: string;
+ kind: AscReviewKind;
+ productType:
+ | "Subscription"
+ | "NonRenewingSubscription"
+ | "NonConsumable"
+ | "Consumable";
+ versionId: string;
+ subscriptionGroupId?: string;
+}
+
+export interface AscReviewEligibilitySnapshot {
+ approvedProductTypes: ReadonlySet;
+ approvedSubscriptionGroupIds: ReadonlySet;
+}
+
+export type AscReviewSubmissionOutcome =
+ | { item: AscReviewVersionItem; status: "submitted" }
+ | {
+ item: AscReviewVersionItem;
+ status: "manual";
+ action: AscManualReviewAction;
+ }
+ | { item: AscReviewVersionItem; status: "failed"; reason: string };
+
+export interface AscReviewSubmissionResult {
+ outcomes: AscReviewSubmissionOutcome[];
+ globalFailure?: string;
+}
+
+export interface AscManualReviewAction {
+ productId: string;
+ code:
+ | "app_version_required"
+ | "subscription_group_required"
+ | "review_submission_conflict"
+ | "review_submission_status_unknown";
+ message: string;
+}
+
+interface AscUploadOperation {
+ method: string;
+ url: string;
+ offset: number;
+ length: number;
+ requestHeaders?: Array<{ name: string; value: string }>;
+}
+
+interface AscReviewScreenshotResource {
+ data: {
+ id: string;
+ type:
+ | "inAppPurchaseAppStoreReviewScreenshots"
+ | "subscriptionAppStoreReviewScreenshots";
+ attributes?: {
+ fileName?: string;
+ fileSize?: number;
+ sourceFileChecksum?: string;
+ uploadOperations?: AscUploadOperation[];
+ assetDeliveryState?: {
+ state?: string;
+ errors?: Array<{ code?: string; description?: string }>;
+ };
+ };
+ } | null;
+}
+
+interface AscVersionResource {
+ id: string;
+ type: "inAppPurchaseVersions" | "subscriptionVersions";
+ attributes?: { state?: string; version?: string };
+}
+
+interface AscVersionResponse {
+ data: AscVersionResource;
+}
+
+interface AscVersionsResponse {
+ data: AscVersionResource[];
+}
+
+interface AscLocalizationResponse {
+ data: Array<{
+ id: string;
+ type: "inAppPurchaseLocalizations" | "subscriptionLocalizations";
+ attributes?: {
+ locale?: string;
+ name?: string;
+ description?: string;
+ };
+ }>;
+}
+
+interface AscReviewSubmissionResponse {
+ data: { id: string; type: "reviewSubmissions" };
+}
+
+const SCREENSHOT_CONFIG = {
+ iap: {
+ type: "inAppPurchaseAppStoreReviewScreenshots" as const,
+ collection: "/v1/inAppPurchaseAppStoreReviewScreenshots",
+ relationship: "inAppPurchaseV2",
+ parentType: "inAppPurchases",
+ existingPath: (id: string) =>
+ `/v2/inAppPurchases/${encodeURIComponent(id)}/appStoreReviewScreenshot`,
+ },
+ subscription: {
+ type: "subscriptionAppStoreReviewScreenshots" as const,
+ collection: "/v1/subscriptionAppStoreReviewScreenshots",
+ relationship: "subscription",
+ parentType: "subscriptions",
+ existingPath: (id: string) =>
+ `/v1/subscriptions/${encodeURIComponent(id)}/appStoreReviewScreenshot`,
+ },
+};
+
+const VERSION_CONFIG = {
+ iap: {
+ type: "inAppPurchaseVersions" as const,
+ collection: "/v1/inAppPurchaseVersions",
+ relationship: "inAppPurchase",
+ parentType: "inAppPurchases",
+ listPath: (id: string) =>
+ `/v2/inAppPurchases/${encodeURIComponent(id)}/versions?limit=200`,
+ localizationType: "inAppPurchaseLocalizations" as const,
+ localizationCollection: "/v2/inAppPurchaseLocalizations",
+ localizationListPath: (versionId: string) =>
+ `/v1/inAppPurchaseVersions/${encodeURIComponent(versionId)}/localizations?limit=200`,
+ itemRelationship: "inAppPurchaseVersion",
+ },
+ subscription: {
+ type: "subscriptionVersions" as const,
+ collection: "/v1/subscriptionVersions",
+ relationship: "subscription",
+ parentType: "subscriptions",
+ listPath: (id: string) =>
+ `/v1/subscriptions/${encodeURIComponent(id)}/versions?limit=200`,
+ localizationType: "subscriptionLocalizations" as const,
+ localizationCollection: "/v2/subscriptionLocalizations",
+ localizationListPath: (versionId: string) =>
+ `/v1/subscriptionVersions/${encodeURIComponent(versionId)}/localizations?limit=200`,
+ itemRelationship: "subscriptionVersion",
+ },
+};
+
+function statusOf(error: unknown): number | undefined {
+ if (
+ typeof error === "object" &&
+ error !== null &&
+ "status" in error &&
+ typeof error.status === "number"
+ ) {
+ return error.status;
+ }
+ return undefined;
+}
+
+function messageOf(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+function isNotFound(error: unknown): boolean {
+ return statusOf(error) === 404;
+}
+
+export function isAscApprovedReviewHistoryState(
+ state: string | undefined,
+): boolean {
+ switch (state?.toUpperCase()) {
+ case "APPROVED":
+ case "ACCEPTED":
+ case "READY_FOR_SALE":
+ case "REPLACED":
+ case "REPLACED_WITH_NEW_VERSION":
+ case "DEVELOPER_REMOVED_FROM_SALE":
+ case "REMOVED_FROM_SALE":
+ return true;
+ default:
+ return false;
+ }
+}
+
+export function getAscReviewEligibilityActions(args: {
+ item: AscReviewVersionItem;
+ snapshot: AscReviewEligibilitySnapshot;
+}): AscManualReviewAction[] {
+ const actions: AscManualReviewAction[] = [];
+ if (!args.snapshot.approvedProductTypes.has(args.item.productType)) {
+ actions.push({
+ productId: args.item.productId,
+ code: "app_version_required",
+ message:
+ `Apple requires the first ${args.item.productType} product to be ` +
+ "submitted with a new app version in App Store Connect. The product " +
+ "metadata and review screenshot are prepared; add it to that app-version submission.",
+ });
+ }
+ if (
+ args.item.productType === "Subscription" &&
+ (!args.item.subscriptionGroupId ||
+ !args.snapshot.approvedSubscriptionGroupIds.has(
+ args.item.subscriptionGroupId,
+ ))
+ ) {
+ actions.push({
+ productId: args.item.productId,
+ code: "subscription_group_required",
+ message:
+ "Apple requires each new subscription group to be submitted with at " +
+ "least one subscription. The subscription metadata and review " +
+ "screenshot are prepared; create/attach the group version and finish " +
+ "the combined submission in App Store Connect. Include an app version " +
+ "only when the separate first-subscription action also requires it.",
+ });
+ }
+ return actions;
+}
+
+/** Map Apple's non-retryable first-product constraints to operator follow-up. */
+export function classifyAscManualReviewAction(
+ error: unknown,
+ productId: string,
+): AscManualReviewAction | null {
+ const status = statusOf(error);
+ if (status !== 409 && status !== 422) return null;
+
+ const message = messageOf(error);
+ const lower = message.toLowerCase();
+ if (
+ lower.includes("subscription group") &&
+ (lower.includes("review") || lower.includes("submit"))
+ ) {
+ return {
+ productId,
+ code: "subscription_group_required",
+ message:
+ `${message} Finish the combined subscription-group and subscription ` +
+ "submission in App Store Connect, then run Push Sync again. Include " +
+ "an app version only when Apple separately requires the first " +
+ "auto-renewable subscription to travel with one.",
+ };
+ }
+ if (
+ (lower.includes("app version") || lower.includes("new version")) &&
+ (lower.includes("first") ||
+ lower.includes("in-app purchase") ||
+ lower.includes("subscription"))
+ ) {
+ return {
+ productId,
+ code: "app_version_required",
+ message:
+ `${message} Apple requires the first product of this type to be ` +
+ "submitted with a new app version in App Store Connect.",
+ };
+ }
+ if (
+ lower.includes("review submission") &&
+ (lower.includes("already") || lower.includes("active"))
+ ) {
+ return {
+ productId,
+ code: "review_submission_conflict",
+ message:
+ `${message} Complete or discard the existing draft review ` +
+ "submission in App Store Connect, then run Push Sync again.",
+ };
+ }
+ return null;
+}
+
+function matchesManualConstraintProduct(
+ error: unknown,
+ item: AscReviewVersionItem,
+): boolean {
+ if (!classifyAscManualReviewAction(error, item.productId)) return false;
+ const lower = messageOf(error).toLowerCase();
+ if (
+ lower.includes("subscription group") ||
+ lower.includes("auto-renewable")
+ ) {
+ return item.productType === "Subscription";
+ }
+ if (lower.includes("non-renewing")) {
+ return item.productType === "NonRenewingSubscription";
+ }
+ if (lower.includes("non-consumable")) {
+ return item.productType === "NonConsumable";
+ }
+ if (lower.includes("consumable")) {
+ return item.productType === "Consumable";
+ }
+ if (lower.includes("subscription")) {
+ return item.productType === "Subscription";
+ }
+ return false;
+}
+
+export function md5Hex(bytes: Uint8Array): string {
+ return createHash("md5").update(bytes).digest("hex");
+}
+
+function validateUploadOperations(
+ operations: AscUploadOperation[],
+ fileSize: number,
+): AscUploadOperation[] {
+ if (operations.length === 0) {
+ throw new Error("ASC screenshot reservation returned no upload operations");
+ }
+ const sorted = [...operations].sort((a, b) => a.offset - b.offset);
+ let nextOffset = 0;
+ for (const operation of sorted) {
+ if (
+ !operation.method ||
+ !operation.url ||
+ !Number.isSafeInteger(operation.offset) ||
+ !Number.isSafeInteger(operation.length) ||
+ operation.offset !== nextOffset ||
+ operation.length <= 0 ||
+ operation.offset + operation.length > fileSize
+ ) {
+ throw new Error(
+ "ASC screenshot reservation returned invalid upload operation ranges",
+ );
+ }
+ nextOffset += operation.length;
+ }
+ if (nextOffset !== fileSize) {
+ throw new Error(
+ `ASC screenshot upload operations cover ${nextOffset} of ${fileSize} bytes`,
+ );
+ }
+ return sorted;
+}
+
+export class AscReviewScreenshotPendingError extends Error {
+ constructor(attempts: number) {
+ super(
+ `ASC screenshot delivery is still processing after ${attempts} polls; run Push Sync again to resume`,
+ );
+ this.name = "AscReviewScreenshotPendingError";
+ }
+}
+
+class AscReviewScreenshotDeliveryFailedError extends Error {
+ constructor(detail: string) {
+ super(`ASC screenshot delivery failed${detail ? `: ${detail}` : ""}`);
+ this.name = "AscReviewScreenshotDeliveryFailedError";
+ }
+}
+
+async function pollAscReviewScreenshotDelivery(args: {
+ request: AscJsonRequest;
+ resourcePath: string;
+ checksum: string;
+ reused: boolean;
+ checkCancelled: () => Promise;
+ sleep: (milliseconds: number) => Promise;
+ maxPollAttempts: number;
+}): Promise<{ screenshotId: string; checksum: string; reused: boolean }> {
+ const attempts = Math.max(1, args.maxPollAttempts);
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ await args.checkCancelled();
+ const current = await args.request(
+ args.resourcePath,
+ );
+ const currentData = current.data;
+ const state =
+ currentData?.attributes?.assetDeliveryState?.state?.toUpperCase();
+ if (state === "COMPLETE" && currentData) {
+ return {
+ screenshotId: currentData.id,
+ checksum: args.checksum,
+ reused: args.reused,
+ };
+ }
+ if (state === "FAILED") {
+ const errors = currentData?.attributes?.assetDeliveryState?.errors ?? [];
+ const detail = errors
+ .map((error) => error.description ?? error.code)
+ .filter(Boolean)
+ .join("; ");
+ throw new AscReviewScreenshotDeliveryFailedError(detail);
+ }
+ if (attempt + 1 < attempts) {
+ await args.sleep(Math.min(1_000 * 2 ** attempt, 5_000));
+ }
+ }
+ throw new AscReviewScreenshotPendingError(attempts);
+}
+
+export async function uploadAscReviewScreenshot(args: {
+ request: AscJsonRequest;
+ kind: AscReviewKind;
+ parentId: string;
+ screenshot: AscReviewScreenshot;
+ fetchImpl?: typeof fetch;
+ sleep?: (milliseconds: number) => Promise;
+ checkCancelled?: () => Promise;
+ maxPollAttempts?: number;
+ uploadTimeoutMs?: number;
+}): Promise<{ screenshotId: string; checksum: string; reused: boolean }> {
+ const config = SCREENSHOT_CONFIG[args.kind];
+ const checksum = md5Hex(args.screenshot.bytes);
+ const checkCancelled = args.checkCancelled ?? (async () => undefined);
+ const fetchImpl = args.fetchImpl ?? fetch;
+ const sleep =
+ args.sleep ??
+ ((milliseconds: number) =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds)));
+
+ await checkCancelled();
+ let existing: AscReviewScreenshotResource | null = null;
+ try {
+ existing = await args.request(
+ config.existingPath(args.parentId),
+ );
+ } catch (error) {
+ if (!isNotFound(error)) throw error;
+ }
+
+ const existingData = existing?.data ?? null;
+ const existingState =
+ existingData?.attributes?.assetDeliveryState?.state?.toUpperCase();
+ const existingHasSameChecksum =
+ existingData?.attributes?.sourceFileChecksum?.toLowerCase() === checksum;
+ if (existingData && existingHasSameChecksum) {
+ if (existingState === "COMPLETE") {
+ return { screenshotId: existingData.id, checksum, reused: true };
+ }
+ if (existingState !== "FAILED") {
+ // A prior run committed this exact file and stopped while Apple was
+ // processing it. Resume polling the same asset; deleting/re-uploading on
+ // every retry can keep a healthy asset from ever reaching COMPLETE.
+ try {
+ return await pollAscReviewScreenshotDelivery({
+ request: args.request,
+ resourcePath: `${config.collection}/${encodeURIComponent(existingData.id)}`,
+ checksum,
+ reused: true,
+ checkCancelled,
+ sleep,
+ maxPollAttempts: args.maxPollAttempts ?? 8,
+ });
+ } catch (error) {
+ if (!(error instanceof AscReviewScreenshotDeliveryFailedError)) {
+ // Pending, cancellation, deadline, and transport errors all leave a
+ // checksum-committed asset that a later sync can safely resume.
+ throw error;
+ }
+ await checkCancelled();
+ await args.request(
+ `${config.collection}/${encodeURIComponent(existingData.id)}`,
+ { method: "DELETE" },
+ );
+ throw error;
+ }
+ }
+ }
+ if (existingData) {
+ // The GET above can be slow. Re-check immediately before the destructive
+ // replacement so a cancellation never deletes the existing ASC asset and
+ // exits without installing its replacement.
+ await checkCancelled();
+ await args.request(
+ `${config.collection}/${encodeURIComponent(existingData.id)}`,
+ {
+ method: "DELETE",
+ },
+ );
+ }
+
+ await checkCancelled();
+ const reserved = await args.request(
+ config.collection,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: config.type,
+ attributes: {
+ fileName: args.screenshot.fileName,
+ fileSize: args.screenshot.bytes.byteLength,
+ },
+ relationships: {
+ [config.relationship]: {
+ data: { type: config.parentType, id: args.parentId },
+ },
+ },
+ },
+ }),
+ },
+ );
+ if (!reserved.data) {
+ throw new Error("ASC screenshot reservation returned no resource");
+ }
+ const reservedData = reserved.data;
+ const resourcePath = `${config.collection}/${encodeURIComponent(reservedData.id)}`;
+ let checksumCommitAttempted = false;
+ let checksumCommitConfirmed = false;
+ try {
+ const operations = validateUploadOperations(
+ reservedData.attributes?.uploadOperations ?? [],
+ args.screenshot.bytes.byteLength,
+ );
+
+ // These are Apple-provided pre-signed URLs. Deliberately use bare fetch
+ // instead of request(): adding the ASC bearer token changes the signature.
+ for (const operation of operations) {
+ await checkCancelled();
+ const headers = Object.fromEntries(
+ (operation.requestHeaders ?? []).map(({ name, value }) => [
+ name,
+ value,
+ ]),
+ );
+ const body = args.screenshot.bytes.slice(
+ operation.offset,
+ operation.offset + operation.length,
+ );
+ const controller = new AbortController();
+ const timeoutMs = args.uploadTimeoutMs ?? 30_000;
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
+ let response: Response;
+ try {
+ response = await fetchImpl(operation.url, {
+ method: operation.method,
+ headers,
+ body: body as BodyInit,
+ signal: controller.signal,
+ });
+ } catch (error) {
+ if (controller.signal.aborted) {
+ throw new Error(
+ `ASC screenshot upload operation timed out after ${timeoutMs}ms`,
+ );
+ }
+ throw error;
+ } finally {
+ clearTimeout(timeout);
+ }
+ if (!response.ok) {
+ throw new Error(
+ `ASC screenshot upload operation returned ${response.status}: ${await response.text()}`,
+ );
+ }
+ }
+
+ await checkCancelled();
+ // Set before the request: a statusless transport error may mean Apple
+ // accepted the commit but its response was lost. Preserve the asset in
+ // that ambiguous case so a retry can discover it by checksum.
+ checksumCommitAttempted = true;
+ await args.request(resourcePath, {
+ method: "PATCH",
+ body: JSON.stringify({
+ data: {
+ type: config.type,
+ id: reservedData.id,
+ attributes: { uploaded: true, sourceFileChecksum: checksum },
+ },
+ }),
+ });
+ checksumCommitConfirmed = true;
+
+ // Keep the per-run wait bounded. A later sync resumes this same
+ // checksum-committed asset if Apple needs longer than this short window.
+ return await pollAscReviewScreenshotDelivery({
+ request: args.request,
+ resourcePath,
+ checksum,
+ reused: false,
+ checkCancelled,
+ sleep,
+ maxPollAttempts: args.maxPollAttempts ?? 8,
+ });
+ } catch (error) {
+ const preserveCommittedAsset =
+ error instanceof AscReviewScreenshotPendingError ||
+ (checksumCommitConfirmed &&
+ !(error instanceof AscReviewScreenshotDeliveryFailedError)) ||
+ (checksumCommitAttempted &&
+ !checksumCommitConfirmed &&
+ !(error instanceof AscReviewScreenshotDeliveryFailedError) &&
+ statusOf(error) === undefined);
+ if (!preserveCommittedAsset) {
+ // Pre-commit failures and confirmed FAILED delivery states cannot be
+ // resumed. Remove the reservation so the next run can replace it.
+ try {
+ await args.request(resourcePath, { method: "DELETE" });
+ } catch {
+ // Preserve the original failure. The next run also removes any stale
+ // parent screenshot before reserving a replacement.
+ }
+ }
+ throw error;
+ }
+}
+
+export type AscReviewVersionInspection = {
+ versionId: string;
+ state: "editable" | "attached" | "submitted" | "approved";
+};
+
+export type AscReviewVersionPlan = {
+ action: "create" | "reuse";
+ reviewVersion: {
+ versionId: string;
+ alreadySubmitted: boolean;
+ attachedToSubmission: boolean;
+ };
+};
+
+/** Apply local Draft/Ready semantics to a read-only ASC version snapshot. */
+export function planAscReviewVersion(args: {
+ localState: "Draft" | "Ready";
+ current: AscReviewVersionInspection | null;
+}): AscReviewVersionPlan {
+ const { current } = args;
+ if (
+ current === null ||
+ (current.state === "approved" && args.localState === "Draft")
+ ) {
+ return {
+ action: "create",
+ reviewVersion: {
+ versionId: "(would-create)",
+ alreadySubmitted: false,
+ attachedToSubmission: false,
+ },
+ };
+ }
+ return {
+ action: "reuse",
+ reviewVersion: {
+ versionId: current.versionId,
+ alreadySubmitted:
+ current.state === "submitted" || current.state === "approved",
+ attachedToSubmission:
+ current.state === "attached" ||
+ current.state === "submitted" ||
+ current.state === "approved",
+ },
+ };
+}
+
+/** Read the current version disposition without creating or mutating it. */
+export async function inspectAscReviewVersion(args: {
+ request: AscJsonRequest;
+ kind: AscReviewKind;
+ parentId: string;
+ checkCancelled?: () => Promise;
+}): Promise {
+ const checkCancelled = args.checkCancelled ?? (async () => undefined);
+ await checkCancelled();
+ const config = VERSION_CONFIG[args.kind];
+ const versions = await args.request(
+ config.listPath(args.parentId),
+ );
+ const draft = versions.data.find(
+ (version) =>
+ version.attributes?.state?.toUpperCase() === "PREPARE_FOR_SUBMISSION",
+ );
+ if (draft) return { versionId: draft.id, state: "editable" };
+ const attached = versions.data.find(
+ (version) =>
+ version.attributes?.state?.toUpperCase() === "READY_FOR_REVIEW",
+ );
+ if (attached) return { versionId: attached.id, state: "attached" };
+ const submitted = versions.data.find((version) => {
+ const state = version.attributes?.state?.toUpperCase();
+ return state === "WAITING_FOR_REVIEW" || state === "IN_REVIEW";
+ });
+ if (submitted) return { versionId: submitted.id, state: "submitted" };
+ const approved = versions.data.find((version) =>
+ isAscApprovedReviewHistoryState(version.attributes?.state),
+ );
+ return approved ? { versionId: approved.id, state: "approved" } : null;
+}
+
+export async function ensureAscReviewVersion(args: {
+ request: AscJsonRequest;
+ kind: AscReviewKind;
+ parentId: string;
+ allowCreate?: boolean;
+ reuseApproved?: boolean;
+ checkCancelled?: () => Promise;
+}): Promise<{
+ versionId: string;
+ alreadySubmitted: boolean;
+ attachedToSubmission: boolean;
+}> {
+ const config = VERSION_CONFIG[args.kind];
+ const checkCancelled = args.checkCancelled ?? (async () => undefined);
+ const current = await inspectAscReviewVersion({
+ request: args.request,
+ kind: args.kind,
+ parentId: args.parentId,
+ checkCancelled,
+ });
+ if (current?.state === "editable") {
+ return {
+ versionId: current.versionId,
+ alreadySubmitted: false,
+ attachedToSubmission: false,
+ };
+ }
+ if (current?.state === "attached") {
+ return {
+ versionId: current.versionId,
+ alreadySubmitted: false,
+ attachedToSubmission: true,
+ };
+ }
+ if (current?.state === "submitted") {
+ return {
+ versionId: current.versionId,
+ alreadySubmitted: true,
+ attachedToSubmission: true,
+ };
+ }
+ if (
+ current?.state === "approved" &&
+ (args.allowCreate === false || args.reuseApproved === true)
+ ) {
+ return {
+ versionId: current.versionId,
+ alreadySubmitted: true,
+ attachedToSubmission: true,
+ };
+ }
+ if (args.allowCreate === false) {
+ throw new Error(
+ "Ready product has no editable, attached, submitted, or approved review version to resume",
+ );
+ }
+
+ await checkCancelled();
+ const created = await args.request(config.collection, {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: config.type,
+ relationships: {
+ [config.relationship]: {
+ data: { type: config.parentType, id: args.parentId },
+ },
+ },
+ },
+ }),
+ });
+ return {
+ versionId: created.data.id,
+ alreadySubmitted: false,
+ attachedToSubmission: false,
+ };
+}
+
+export async function upsertAscReviewLocalization(args: {
+ request: AscJsonRequest;
+ kind: AscReviewKind;
+ versionId: string;
+ name: string;
+ description: string;
+ locale?: string;
+ checkCancelled?: () => Promise;
+}): Promise {
+ const config = VERSION_CONFIG[args.kind];
+ const locale = args.locale ?? "en-US";
+ const checkCancelled = args.checkCancelled ?? (async () => undefined);
+ await checkCancelled();
+ const localizations = await args.request(
+ config.localizationListPath(args.versionId),
+ );
+ const existing = localizations.data.find(
+ (localization) => localization.attributes?.locale === locale,
+ );
+ const attributes = {
+ name: args.name,
+ description: args.description,
+ ...(existing ? {} : { locale }),
+ };
+ if (existing) {
+ await checkCancelled();
+ await args.request(
+ `${config.localizationCollection}/${encodeURIComponent(existing.id)}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({
+ data: {
+ type: config.localizationType,
+ id: existing.id,
+ attributes,
+ },
+ }),
+ },
+ );
+ return;
+ }
+ await checkCancelled();
+ await args.request(config.localizationCollection, {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: config.localizationType,
+ attributes,
+ relationships: {
+ version: {
+ data: { type: config.type, id: args.versionId },
+ },
+ },
+ },
+ }),
+ });
+}
+
+export async function ascReviewLocalizationMatches(args: {
+ request: AscJsonRequest;
+ kind: AscReviewKind;
+ versionId: string;
+ name: string;
+ description: string;
+ locale?: string;
+ checkCancelled?: () => Promise;
+}): Promise {
+ const config = VERSION_CONFIG[args.kind];
+ const locale = args.locale ?? "en-US";
+ await (args.checkCancelled ?? (async () => undefined))();
+ const localizations = await args.request(
+ config.localizationListPath(args.versionId),
+ );
+ const existing = localizations.data.find(
+ (localization) => localization.attributes?.locale === locale,
+ );
+ return (
+ existing?.attributes?.name === args.name &&
+ existing.attributes.description === args.description
+ );
+}
+
+export async function submitAscReviewVersions(args: {
+ request: AscJsonRequest;
+ cleanupRequest?: AscJsonRequest;
+ appId: string;
+ items: AscReviewVersionItem[];
+ checkCancelled?: () => Promise;
+ isAbortError?: (error: unknown) => boolean;
+}): Promise {
+ if (args.items.length === 0) return { outcomes: [] };
+ if (args.items.length > ASC_REVIEW_SUBMISSION_ITEM_LIMIT) {
+ return {
+ outcomes: [],
+ globalFailure:
+ `ASC review submissions accept at most ${ASC_REVIEW_SUBMISSION_ITEM_LIMIT} items; ` +
+ `received ${args.items.length}`,
+ };
+ }
+ const checkCancelled = args.checkCancelled ?? (async () => undefined);
+ const isAbortError = args.isAbortError ?? (() => false);
+ const cleanupRequest = args.cleanupRequest ?? args.request;
+ await checkCancelled();
+
+ const outcomeForError = (
+ item: AscReviewVersionItem,
+ error: unknown,
+ ): AscReviewSubmissionOutcome => {
+ const action = classifyAscManualReviewAction(error, item.productId);
+ return action
+ ? { item, status: "manual", action }
+ : { item, status: "failed", reason: messageOf(error) };
+ };
+ const unknownStatusOutcome = (
+ item: AscReviewVersionItem,
+ error: unknown,
+ ): AscReviewSubmissionOutcome => ({
+ item,
+ status: "manual",
+ action: {
+ productId: item.productId,
+ code: "review_submission_status_unknown",
+ message:
+ `${messageOf(error)} App Store Connect may have accepted the write ` +
+ "even though IAPKit did not receive its resource ID. Check App Store " +
+ "Connect and complete or discard that draft before running Push Sync again.",
+ },
+ });
+ const cancelSubmission = async (submissionId: string): Promise => {
+ try {
+ await cleanupRequest(
+ `/v1/reviewSubmissions/${encodeURIComponent(submissionId)}`,
+ {
+ method: "PATCH",
+ body: JSON.stringify({
+ data: {
+ type: "reviewSubmissions",
+ id: submissionId,
+ attributes: { canceled: true },
+ },
+ }),
+ },
+ );
+ } catch {
+ // Best effort. A later retry never adopts an unidentified ASC draft.
+ }
+ };
+
+ let submissionId: string;
+ try {
+ const created = await args.request(
+ "/v1/reviewSubmissions",
+ {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: "reviewSubmissions",
+ attributes: { platform: "IOS" },
+ relationships: {
+ app: { data: { type: "apps", id: args.appId } },
+ },
+ },
+ }),
+ },
+ );
+ submissionId = created.data.id;
+ } catch (error) {
+ if (isAbortError(error)) throw error;
+ if (statusOf(error) === undefined) {
+ // Apple has no idempotency key/client reference on this endpoint. A
+ // statusless transport failure can mean the draft was created but the
+ // response was lost; an empty remote draft is indistinguishable from a
+ // human-created one, so fail safely and ask the operator to inspect it.
+ return {
+ outcomes: args.items.map((item) => unknownStatusOutcome(item, error)),
+ };
+ }
+ const action = classifyAscManualReviewAction(
+ error,
+ args.items[0].productId,
+ );
+ if (action?.code === "review_submission_conflict") {
+ // A 409 does not prove the active draft belongs to IAPKit. Never add
+ // items to or submit a user-created App Store Connect draft; keep every
+ // product in Draft and ask the operator to finish/discard it first.
+ return {
+ outcomes: args.items.map((item) => outcomeForError(item, error)),
+ };
+ }
+ if (args.items.length === 1) {
+ return { outcomes: [outcomeForError(args.items[0], error)] };
+ }
+ return { outcomes: [], globalFailure: messageOf(error) };
+ }
+
+ const outcomes: AscReviewSubmissionOutcome[] = [];
+ const added: Array<{
+ item: AscReviewVersionItem;
+ submissionItemId?: string;
+ }> = [];
+ let activeEntries = added;
+ const cleanupDraft = async (): Promise => {
+ // Canceling the owned draft is O(1), regardless of how many items were
+ // added. Sequential per-item cleanup can itself overrun the job deadline.
+ await cancelSubmission(submissionId);
+ };
+ const removeEntry = async (
+ entry: (typeof added)[number],
+ ): Promise => {
+ if (!entry.submissionItemId) return false;
+ try {
+ await cleanupRequest(
+ `/v1/reviewSubmissionItems/${encodeURIComponent(entry.submissionItemId)}`,
+ { method: "DELETE" },
+ );
+ return true;
+ } catch {
+ return false;
+ }
+ };
+ const checkCancelledAndCleanup = async (): Promise => {
+ try {
+ await checkCancelled();
+ } catch (error) {
+ await cleanupDraft();
+ throw error;
+ }
+ };
+ for (const item of args.items) {
+ await checkCancelledAndCleanup();
+ const config = VERSION_CONFIG[item.kind];
+ try {
+ const created = await args.request<{ data: { id: string } }>(
+ "/v1/reviewSubmissionItems",
+ {
+ method: "POST",
+ body: JSON.stringify({
+ data: {
+ type: "reviewSubmissionItems",
+ relationships: {
+ reviewSubmission: {
+ data: { type: "reviewSubmissions", id: submissionId },
+ },
+ [config.itemRelationship]: {
+ data: { type: config.type, id: item.versionId },
+ },
+ },
+ },
+ }),
+ },
+ );
+ added.push({ item, submissionItemId: created.data.id });
+ } catch (error) {
+ if (isAbortError(error)) {
+ await cleanupDraft();
+ throw error;
+ }
+ if (statusOf(error) === undefined) {
+ // The item may exist remotely even though its ID was lost. Cancel the
+ // IAPKit-owned submission and stop; submitting the remaining tracked
+ // items could create a remote/local state mismatch.
+ await cleanupDraft();
+ return {
+ outcomes: args.items.map((candidate) =>
+ unknownStatusOutcome(candidate, error),
+ ),
+ };
+ }
+ outcomes.push(outcomeForError(item, error));
+ }
+ }
+
+ if (added.length === 0) {
+ await cleanupDraft();
+ return { outcomes };
+ }
+
+ const submitDraft = () =>
+ args.request(`/v1/reviewSubmissions/${encodeURIComponent(submissionId)}`, {
+ method: "PATCH",
+ body: JSON.stringify({
+ data: {
+ type: "reviewSubmissions",
+ id: submissionId,
+ attributes: { submitted: true },
+ },
+ }),
+ });
+ for (let attempt = 0; attempt <= added.length; attempt += 1) {
+ await checkCancelledAndCleanup();
+ try {
+ await submitDraft();
+ outcomes.push(
+ ...activeEntries.map(
+ ({ item }): AscReviewSubmissionOutcome => ({
+ item,
+ status: "submitted",
+ }),
+ ),
+ );
+ return { outcomes };
+ } catch (error) {
+ if (isAbortError(error)) {
+ await cleanupDraft();
+ throw error;
+ }
+ const manualEntries = activeEntries.filter(({ item }) =>
+ matchesManualConstraintProduct(error, item),
+ );
+ if (manualEntries.length === 1) {
+ if (!(await removeEntry(manualEntries[0]))) {
+ await cleanupDraft();
+ return {
+ outcomes,
+ globalFailure:
+ "ASC could not confirm removal of the manually gated item; " +
+ "the IAPKit-owned review draft was canceled instead",
+ };
+ }
+ outcomes.push(
+ ...manualEntries.map(
+ ({ item }): AscReviewSubmissionOutcome => ({
+ item,
+ status: "manual",
+ action: classifyAscManualReviewAction(error, item.productId)!,
+ }),
+ ),
+ );
+ activeEntries = activeEntries.filter(
+ (entry) => !manualEntries.includes(entry),
+ );
+ if (activeEntries.length === 0) {
+ await cancelSubmission(submissionId);
+ return { outcomes };
+ }
+ continue;
+ }
+ if (manualEntries.length > 1) {
+ // Apple's generic wording does not identify a product/group. Never
+ // mark every subscription of the same broad type Ready: that could
+ // silently misclassify unrelated groups in the same batch.
+ await cleanupDraft();
+ return {
+ outcomes,
+ globalFailure: `ASC review constraint could not be attributed to one product: ${messageOf(error)}`,
+ };
+ }
+
+ // An unattributable final error must not be copied onto every product.
+ // Keep the rows Draft and surface one batch-level failure instead.
+ if (activeEntries.length === 1) {
+ outcomes.push(outcomeForError(activeEntries[0].item, error));
+ }
+ await cleanupDraft();
+ return {
+ outcomes,
+ ...(activeEntries.length > 1
+ ? { globalFailure: messageOf(error) }
+ : {}),
+ };
+ }
+ }
+
+ await cleanupDraft();
+ return {
+ outcomes,
+ globalFailure: "ASC review submission exceeded the manual-gate retry bound",
+ };
+}
diff --git a/packages/kit/convex/products/jobs.test.ts b/packages/kit/convex/products/jobs.test.ts
index 583106c06..a3889ddf1 100644
--- a/packages/kit/convex/products/jobs.test.ts
+++ b/packages/kit/convex/products/jobs.test.ts
@@ -2,19 +2,28 @@ import { describe, expect, it, vi } from "vitest";
import {
PRODUCT_SYNC_FAILURES_CAP,
+ PRODUCT_SYNC_MANUAL_ACTIONS_CAP,
PRODUCT_SYNC_FAILED_RETENTION_MS,
PRODUCT_SYNC_JOB_DEADLINE_MS,
PRODUCT_SYNC_REAPER_GRACE_MS,
PRODUCT_SYNC_SUCCEEDED_RETENTION_MS,
getJobForWorker as registeredGetJobForWorker,
isCancelRequested as registeredIsCancelRequested,
+ markJobRunning as registeredMarkJobRunning,
markJobSucceeded as registeredMarkJobSucceeded,
truncateFailures,
+ truncateManualActions,
} from "./jobs";
+import {
+ PRODUCT_SYNC_DEADLINE_SAFETY_MS,
+ isProductSyncDeadlineReached,
+ truncatePlannedWrites,
+} from "./syncResult";
import { testableFunction } from "../test.setup";
const getJobForWorker = testableFunction(registeredGetJobForWorker);
const isCancelRequested = testableFunction(registeredIsCancelRequested);
+const markJobRunning = testableFunction(registeredMarkJobRunning);
const markJobSucceeded = testableFunction(registeredMarkJobSucceeded);
describe("truncateFailures", () => {
@@ -51,6 +60,55 @@ describe("truncateFailures", () => {
});
});
+describe("truncateManualActions", () => {
+ it("caps action count and individual upstream messages", () => {
+ const actions = Array.from(
+ { length: PRODUCT_SYNC_MANUAL_ACTIONS_CAP + 1 },
+ (_, index) => ({
+ productId: `product-${index}`,
+ code: "app_version_required",
+ message: "x".repeat(2_000),
+ }),
+ );
+
+ const { items, truncated } = truncateManualActions(actions);
+
+ expect(items).toHaveLength(PRODUCT_SYNC_MANUAL_ACTIONS_CAP);
+ expect(items[0]?.message.length).toBeLessThanOrEqual(1_000);
+ expect(items[0]?.message.endsWith("…")).toBe(true);
+ expect(truncated).toBe(true);
+ });
+
+ it("preserves an already bounded action array", () => {
+ const actions = [
+ {
+ productId: "coins",
+ code: "app_version_required",
+ message: "Submit with an app version",
+ },
+ ];
+
+ expect(truncateManualActions(actions)).toEqual({
+ items: actions,
+ truncated: false,
+ });
+ });
+});
+
+describe("truncatePlannedWrites", () => {
+ it("bounds count and verbose dry-run details", () => {
+ const writes = Array.from({ length: 400 }, (_, index) => ({
+ productId: `product-${index}`,
+ step: "create",
+ detail: "x".repeat(1_000),
+ }));
+ const { items, truncated } = truncatePlannedWrites(writes);
+ expect(items).toHaveLength(300);
+ expect(items[0]?.detail?.length).toBeLessThanOrEqual(512);
+ expect(truncated).toBe(true);
+ });
+});
+
describe("retention constants", () => {
// Sanity-check the bounds the reaper / pruner crons rely on.
// Without these the worker timeout is meaningless and the pruner
@@ -66,6 +124,61 @@ describe("retention constants", () => {
PRODUCT_SYNC_SUCCEEDED_RETENTION_MS,
);
});
+
+ it("reserves cleanup time before the action deadline", () => {
+ const deadline = 1_000_000;
+ expect(
+ isProductSyncDeadlineReached(
+ deadline - PRODUCT_SYNC_DEADLINE_SAFETY_MS - 1,
+ deadline,
+ ),
+ ).toBe(false);
+ expect(
+ isProductSyncDeadlineReached(
+ deadline - PRODUCT_SYNC_DEADLINE_SAFETY_MS,
+ deadline,
+ ),
+ ).toBe(true);
+ });
+});
+
+describe("worker deadline persistence", () => {
+ it("returns the same deadline written to the job row", async () => {
+ const rows = new Map>([
+ [
+ "job_a",
+ {
+ _id: "job_a",
+ projectId: "project_a",
+ status: "queued",
+ },
+ ],
+ [
+ "project_a",
+ {
+ _id: "project_a",
+ organizationId: "organization_a",
+ },
+ ],
+ ["organization_a", { _id: "organization_a" }],
+ ]);
+ const patch = vi.fn(async (_id: string, value: Record) =>
+ Object.assign(rows.get("job_a")!, value),
+ );
+ const ctx = {
+ db: {
+ get: vi.fn(async (id: string) => rows.get(id) ?? null),
+ patch,
+ },
+ };
+
+ const deadline = await markJobRunning._handler(ctx, {
+ jobId: "job_a" as never,
+ });
+
+ expect(deadline).toBe(rows.get("job_a")?.expectedDeadline);
+ expect(deadline).toBeGreaterThan(Date.now());
+ });
});
describe("pending-deletion worker guards", () => {
diff --git a/packages/kit/convex/products/jobs.ts b/packages/kit/convex/products/jobs.ts
index 3ad5085f0..22d6f31fa 100644
--- a/packages/kit/convex/products/jobs.ts
+++ b/packages/kit/convex/products/jobs.ts
@@ -18,11 +18,20 @@ import {
} from "../projects/helpers";
import { ErrorCode, createError } from "../utils/errors";
import { getWritableProject } from "../projects/writable";
+import {
+ PRODUCT_SYNC_JOB_DEADLINE_MS,
+ PRODUCT_SYNC_MANUAL_ACTIONS_CAP,
+ truncateManualActions,
+ truncatePlannedWrites,
+} from "./syncResult";
+
+export {
+ PRODUCT_SYNC_JOB_DEADLINE_MS,
+ PRODUCT_SYNC_MANUAL_ACTIONS_CAP,
+ truncateManualActions,
+ truncatePlannedWrites,
+};
-// Per-job hard ceiling. Convex actions cap at ~10min; we allow 9min
-// for the worker and rely on the reaper to mark anything still
-// running 1min past that as failed.
-export const PRODUCT_SYNC_JOB_DEADLINE_MS = 9 * 60 * 1_000;
export const PRODUCT_SYNC_REAPER_GRACE_MS = 60 * 1_000;
export const PRODUCT_SYNC_SUCCEEDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
export const PRODUCT_SYNC_FAILED_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
@@ -310,8 +319,8 @@ export const enqueueProductSync = mutation({
// batches; never touches App Store Connect or Play Console. The
// next regular sync re-pulls from the upstream store, so this is
// the recovery hatch when kit's cache drifts (manual store edits,
-// failed partial pushes, stale prices). Cancel checks between
-// pages so an operator can stop a runaway wipe within seconds.
+// failed partial pushes, stale prices). Cancel checks run between
+// pages so the next bounded delete batch does not start.
export const runProductSyncPurgeLocal = internalAction({
args: { jobId: v.id("productSyncJobs") },
handler: async (ctx, args): Promise => {
@@ -375,9 +384,9 @@ export const runProductSyncPurgeLocal = internalAction({
},
});
-// Operator-initiated cancel. The worker checks `cancelRequested` at
-// phase boundaries — granularity is per-phase, not per-product, but
-// that's enough to stop a runaway sync within seconds on most paths.
+// Operator-initiated cancel. Workers check the flag at phase/chunk boundaries;
+// remote clients and review helpers also check before requests, multipart
+// upload operations, and asset-delivery polls.
export const cancelProductSync = mutation({
args: {
apiKey: v.optional(v.string()),
@@ -436,24 +445,30 @@ export const isCancelRequested = internalQuery({
const job = await ctx.db.get(args.jobId);
if (!job) return true;
if (!(await getWritableProject(ctx, job.projectId))) return true;
- return job.cancelRequested === true;
+ return (
+ job.cancelRequested === true ||
+ (job.status !== "queued" && job.status !== "running")
+ );
},
});
export const markJobRunning = internalMutation({
args: { jobId: v.id("productSyncJobs") },
+ returns: v.union(v.number(), v.null()),
handler: async (ctx, args) => {
const job = await ctx.db.get(args.jobId);
- if (!job) return;
- if (!(await getWritableProject(ctx, job.projectId))) return;
- if (job.status !== "queued") return;
+ if (!job) return null;
+ if (!(await getWritableProject(ctx, job.projectId))) return null;
+ if (job.status !== "queued") return null;
const now = Date.now();
+ const expectedDeadline = now + PRODUCT_SYNC_JOB_DEADLINE_MS;
await ctx.db.patch(args.jobId, {
status: "running",
startedAt: now,
- expectedDeadline: now + PRODUCT_SYNC_JOB_DEADLINE_MS,
+ expectedDeadline,
progress: { phase: "starting" },
});
+ return expectedDeadline;
},
});
@@ -495,11 +510,28 @@ export const markJobSucceeded = internalMutation({
}),
),
),
+ plannedWritesTruncated: v.optional(v.boolean()),
+ manualActions: v.optional(
+ v.array(
+ v.object({
+ productId: v.string(),
+ code: v.string(),
+ message: v.string(),
+ }),
+ ),
+ ),
+ manualActionsTruncated: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const job = await ctx.db.get(args.jobId);
if (!job || !(await getWritableProject(ctx, job.projectId))) return;
const { items: failures, truncated } = truncateFailures(args.failures);
+ const boundedManualActions = truncateManualActions(
+ args.manualActions ?? [],
+ );
+ const boundedPlannedWrites = truncatePlannedWrites(
+ args.plannedWrites ?? [],
+ );
await ctx.db.patch(args.jobId, {
status: "succeeded",
completedAt: Date.now(),
@@ -513,7 +545,18 @@ export const markJobSucceeded = internalMutation({
...(args.deleted !== undefined ? { deleted: args.deleted } : {}),
failures,
...(truncated ? { failuresTruncated: true } : {}),
- ...(args.plannedWrites ? { plannedWrites: args.plannedWrites } : {}),
+ ...(boundedPlannedWrites.items.length > 0
+ ? { plannedWrites: boundedPlannedWrites.items }
+ : {}),
+ ...(args.plannedWritesTruncated || boundedPlannedWrites.truncated
+ ? { plannedWritesTruncated: true }
+ : {}),
+ ...(boundedManualActions.items.length > 0
+ ? { manualActions: boundedManualActions.items }
+ : {}),
+ ...(args.manualActionsTruncated || boundedManualActions.truncated
+ ? { manualActionsTruncated: true }
+ : {}),
},
});
// Clear the project's lock so the next enqueue can claim the
diff --git a/packages/kit/convex/products/sync.test.ts b/packages/kit/convex/products/sync.test.ts
index 40f818caa..b2cc606b0 100644
--- a/packages/kit/convex/products/sync.test.ts
+++ b/packages/kit/convex/products/sync.test.ts
@@ -5,6 +5,8 @@ import {
deletePlatformCatalog as registeredDeletePlatformCatalog,
deleteRemovedProductRow as registeredDeleteRemovedProductRow,
isSafePriceAmountMicros,
+ listDraftIosProducts as registeredListDraftIosProducts,
+ markPushed as registeredMarkPushed,
shouldPreserveKitRemovedDuringPull,
upsertFromStore as registeredUpsertFromStore,
} from "./sync";
@@ -15,6 +17,8 @@ const deleteRemovedProductRow = testableFunction(
registeredDeleteRemovedProductRow,
);
const upsertFromStore = testableFunction(registeredUpsertFromStore);
+const listDraftIosProducts = testableFunction(registeredListDraftIosProducts);
+const markPushed = testableFunction(registeredMarkPushed);
type Row = Record & { _id: string };
@@ -45,6 +49,10 @@ class TestQuery {
async take(limit: number) {
return this.rows.slice(0, limit);
}
+
+ async collect() {
+ return [...this.rows];
+ }
}
class TestDb {
@@ -72,6 +80,12 @@ class TestDb {
}
throw new Error(`Unknown row: ${id}`);
}
+
+ async patch(id: string, value: Record) {
+ const row = await this.get(id);
+ if (!row) throw new Error(`Unknown row: ${id}`);
+ Object.assign(row, value);
+ }
}
describe("isSafePriceAmountMicros", () => {
@@ -114,6 +128,178 @@ describe("shouldPreserveKitRemovedDuringPull", () => {
});
});
+describe("listDraftIosProducts review resumption", () => {
+ it("includes Ready rows that have not handled the configured screenshot", async () => {
+ const base = {
+ projectId: "project_a",
+ platform: "IOS",
+ type: "Consumable",
+ title: "Title",
+ origin: "kit",
+ };
+ const db = new TestDb({
+ products: [
+ {
+ _id: "ready_current",
+ ...base,
+ productId: "ready.current",
+ state: "Ready",
+ lastAppleReviewScreenshotFileId: "file_current",
+ updatedAt: 300,
+ },
+ {
+ _id: "draft_b",
+ ...base,
+ productId: "draft.b",
+ state: "Draft",
+ updatedAt: 300,
+ },
+ {
+ _id: "ready_legacy",
+ ...base,
+ productId: "ready.legacy",
+ state: "Ready",
+ storeRef: "iap-ready",
+ updatedAt: 100,
+ },
+ {
+ _id: "ready_old",
+ ...base,
+ productId: "ready.old",
+ state: "Ready",
+ storeRef: "iap-old",
+ lastAppleReviewScreenshotFileId: "file_old",
+ updatedAt: 400,
+ },
+ {
+ _id: "pulled",
+ ...base,
+ origin: "store",
+ productId: "pulled",
+ state: "Draft",
+ storeRef: "iap-pulled",
+ updatedAt: 100,
+ },
+ ],
+ });
+
+ await expect(
+ listDraftIosProducts._handler(
+ { db },
+ {
+ projectId: "project_a" as never,
+ includeReadyForReview: true,
+ reviewScreenshotFileId: "file_current" as never,
+ },
+ ),
+ ).resolves.toEqual([
+ expect.objectContaining({
+ productId: "ready.legacy",
+ state: "Ready",
+ }),
+ expect.objectContaining({
+ productId: "ready.old",
+ state: "Ready",
+ }),
+ expect.objectContaining({ productId: "draft.b", state: "Draft" }),
+ ]);
+ });
+
+ it("uses the persistent file id instead of pull-updated timestamps", async () => {
+ const ready = {
+ _id: "ready_before_pull",
+ projectId: "project_a",
+ platform: "IOS",
+ productId: "ready.before.pull",
+ state: "Ready",
+ type: "Consumable",
+ title: "Ready",
+ origin: "kit",
+ storeRef: "iap-ready",
+ lastAppleReviewScreenshotFileId: "file_current",
+ updatedAt: 100,
+ };
+ const db = new TestDb({ products: [ready] });
+ await expect(
+ listDraftIosProducts._handler(
+ { db },
+ {
+ projectId: "project_a" as never,
+ includeReadyForReview: true,
+ reviewScreenshotFileId: "file_current" as never,
+ },
+ ),
+ ).resolves.toEqual([]);
+
+ // `upsertFromStore` in the pull phase refreshes this timestamp.
+ ready.updatedAt = 300;
+ await expect(
+ listDraftIosProducts._handler(
+ { db },
+ {
+ projectId: "project_a" as never,
+ includeReadyForReview: true,
+ reviewScreenshotFileId: "file_current" as never,
+ },
+ ),
+ ).resolves.toEqual([]);
+
+ await expect(
+ listDraftIosProducts._handler(
+ { db },
+ {
+ projectId: "project_a" as never,
+ includeReadyForReview: true,
+ reviewScreenshotFileId: "file_replacement" as never,
+ },
+ ),
+ ).resolves.toEqual([
+ expect.objectContaining({
+ productId: "ready.before.pull",
+ state: "Ready",
+ }),
+ ]);
+ });
+
+ it("records the handled screenshot identity when a push completes", async () => {
+ const product = {
+ _id: "product_a",
+ projectId: "project_a",
+ platform: "IOS",
+ productId: "premium",
+ state: "Draft",
+ type: "Consumable",
+ title: "Premium",
+ updatedAt: 100,
+ };
+ const db = new TestDb({
+ organizations: [{ _id: "organization_a" }],
+ projects: [{ _id: "project_a", organizationId: "organization_a" }],
+ products: [product],
+ });
+
+ await expect(
+ markPushed._handler(
+ { db },
+ {
+ projectId: "project_a" as never,
+ productId: "premium",
+ platform: "IOS",
+ storeRef: "iap_1",
+ reviewScreenshotFileId: "file_current" as never,
+ },
+ ),
+ ).resolves.toBe("product_a");
+ expect(product).toEqual(
+ expect.objectContaining({
+ state: "Ready",
+ storeRef: "iap_1",
+ lastAppleReviewScreenshotFileId: "file_current",
+ }),
+ );
+ });
+});
+
describe("catalog deletion client-payload retention", () => {
it("keeps client metadata after a pushed Removed row is hard-deleted", async () => {
const db = new TestDb({
diff --git a/packages/kit/convex/products/sync.ts b/packages/kit/convex/products/sync.ts
index e370e551e..6a0f1e4e2 100644
--- a/packages/kit/convex/products/sync.ts
+++ b/packages/kit/convex/products/sync.ts
@@ -266,6 +266,7 @@ export const markPushed = internalMutation({
productId: v.string(),
platform: platformValidator,
storeRef: v.string(),
+ reviewScreenshotFileId: v.optional(v.id("files")),
},
returns: v.union(v.id("products"), v.null()),
handler: async (ctx, args) => {
@@ -283,6 +284,11 @@ export const markPushed = internalMutation({
await ctx.db.patch(existing._id, {
storeRef: args.storeRef,
state: "Ready",
+ ...(args.reviewScreenshotFileId
+ ? {
+ lastAppleReviewScreenshotFileId: args.reviewScreenshotFileId,
+ }
+ : {}),
syncedAt: Date.now(),
updatedAt: Date.now(),
});
@@ -345,11 +351,19 @@ export const listExistingProductTypes = internalQuery({
// retry only the failed steps. The push branch handles the
// "skip create when storeRef already set" decision.
export const listDraftIosProducts = internalQuery({
- args: { projectId: v.id("projects") },
+ args: {
+ projectId: v.id("projects"),
+ // A kit-created row previously promoted to Ready because no review
+ // screenshot was configured must become eligible again once the operator
+ // adds the project screenshot.
+ includeReadyForReview: v.optional(v.boolean()),
+ reviewScreenshotFileId: v.optional(v.id("files")),
+ },
returns: v.array(
v.object({
productId: v.string(),
platform: platformValidator,
+ state: v.union(v.literal("Draft"), v.literal("Ready")),
type: typeValidator,
title: v.string(),
description: v.optional(v.string()),
@@ -366,6 +380,7 @@ export const listDraftIosProducts = internalQuery({
),
),
subscriptionGroupName: v.optional(v.string()),
+ subscriptionGroupId: v.optional(v.string()),
reviewNote: v.optional(v.string()),
storeRef: v.optional(v.string()),
}),
@@ -380,7 +395,12 @@ export const listDraftIosProducts = internalQuery({
return all
.filter(
(row) =>
- row.state === "Draft" &&
+ (row.state === "Draft" ||
+ (args.includeReadyForReview === true &&
+ row.state === "Ready" &&
+ (args.reviewScreenshotFileId === undefined ||
+ row.lastAppleReviewScreenshotFileId !==
+ args.reviewScreenshotFileId))) &&
// Skip rows that were imported from the upstream store —
// ASC's "PREPARE_FOR_SUBMISSION" / "MISSING_METADATA" /
// similar states map to kit `Draft`, and re-pushing them on
@@ -394,9 +414,16 @@ export const listDraftIosProducts = internalQuery({
// first insert.
(row.origin === "kit" || row.storeRef === undefined),
)
+ .sort((left, right) => {
+ // Resume legacy Ready rows first, then use productId for deterministic
+ // bounded batches across retries and workers.
+ if (left.state !== right.state) return left.state === "Ready" ? -1 : 1;
+ return left.productId.localeCompare(right.productId);
+ })
.map((row) => ({
productId: row.productId,
platform: row.platform,
+ state: row.state as "Draft" | "Ready",
type: row.type,
title: row.title,
description: row.description,
@@ -408,6 +435,7 @@ export const listDraftIosProducts = internalQuery({
// `row.subscriptionGroupName ?? row.productId` and treats
// `undefined` correctly; null would slip past the `??`.
subscriptionGroupName: row.subscriptionGroupName ?? undefined,
+ subscriptionGroupId: row.subscriptionGroupId ?? undefined,
reviewNote: row.reviewNote,
storeRef: row.storeRef,
}));
diff --git a/packages/kit/convex/products/syncResult.ts b/packages/kit/convex/products/syncResult.ts
new file mode 100644
index 000000000..0df67c43b
--- /dev/null
+++ b/packages/kit/convex/products/syncResult.ts
@@ -0,0 +1,85 @@
+export const PRODUCT_SYNC_MANUAL_ACTIONS_CAP = 100;
+export const PRODUCT_SYNC_MANUAL_ACTION_MESSAGE_CAP = 1_000;
+export const PRODUCT_SYNC_PLANNED_WRITES_CAP = 300;
+// Convex actions cap at roughly 10 minutes. Stop starting remote work with a
+// safety window left for in-flight cleanup and the terminal mutation.
+export const PRODUCT_SYNC_JOB_DEADLINE_MS = 9 * 60 * 1_000;
+export const PRODUCT_SYNC_DEADLINE_SAFETY_MS = 45 * 1_000;
+
+export function isProductSyncDeadlineReached(
+ now: number,
+ deadline: number,
+): boolean {
+ return now >= deadline - PRODUCT_SYNC_DEADLINE_SAFETY_MS;
+}
+
+export interface BoundedManualAction {
+ productId: string;
+ code: string;
+ message: string;
+}
+
+export interface BoundedPlannedWrite {
+ productId: string;
+ step: string;
+ detail?: string;
+}
+
+function truncateText(value: string, cap: number): string {
+ if (value.length <= cap) return value;
+ return `${value.slice(0, Math.max(0, cap - 1))}…`;
+}
+
+/** Keep terminal job documents and mutation arguments below Convex limits. */
+export function truncateManualActions(
+ actions: T[],
+): { items: T[]; truncated: boolean } {
+ const items = actions
+ .slice(0, PRODUCT_SYNC_MANUAL_ACTIONS_CAP)
+ .map((action) => ({
+ ...action,
+ productId: truncateText(action.productId, 256),
+ code: truncateText(action.code, 128),
+ message: truncateText(
+ action.message,
+ PRODUCT_SYNC_MANUAL_ACTION_MESSAGE_CAP,
+ ),
+ })) as T[];
+ return {
+ items,
+ truncated:
+ actions.length > PRODUCT_SYNC_MANUAL_ACTIONS_CAP ||
+ items.some(
+ (item, index) =>
+ item.productId !== actions[index]?.productId ||
+ item.code !== actions[index]?.code ||
+ item.message !== actions[index]?.message,
+ ),
+ };
+}
+
+export function truncatePlannedWrites(
+ writes: T[],
+): { items: T[]; truncated: boolean } {
+ const items = writes
+ .slice(0, PRODUCT_SYNC_PLANNED_WRITES_CAP)
+ .map((write) => ({
+ ...write,
+ productId: truncateText(write.productId, 256),
+ step: truncateText(write.step, 256),
+ ...(write.detail === undefined
+ ? {}
+ : { detail: truncateText(write.detail, 512) }),
+ }));
+ return {
+ items,
+ truncated:
+ writes.length > PRODUCT_SYNC_PLANNED_WRITES_CAP ||
+ items.some(
+ (item, index) =>
+ item.productId !== writes[index]?.productId ||
+ item.step !== writes[index]?.step ||
+ item.detail !== writes[index]?.detail,
+ ),
+ };
+}
diff --git a/packages/kit/convex/projects/helpers.test.ts b/packages/kit/convex/projects/helpers.test.ts
index ef3ce9b9b..b894dbcb1 100644
--- a/packages/kit/convex/projects/helpers.test.ts
+++ b/packages/kit/convex/projects/helpers.test.ts
@@ -126,6 +126,10 @@ describe("deleteProjectWithData", () => {
files: rows("file").map((row, index) => ({
...row,
storageId: `storage_${index}`,
+ purpose:
+ index === 0
+ ? "apple_iap_review_screenshot"
+ : "android_service_account",
})),
webhookIdempotencyKeys: [
...rows("webhook_key"),
@@ -163,6 +167,7 @@ describe("deleteProjectWithData", () => {
}
expect(db.tables.projects).toEqual([]);
expect(storage.delete).toHaveBeenCalledTimes(11);
+ expect(storage.delete).toHaveBeenCalledWith("storage_0");
});
it("recovers a pending deletion and tolerates a duplicate continuation", async () => {
diff --git a/packages/kit/convex/projects/project-child-pending-deletion.test.ts b/packages/kit/convex/projects/project-child-pending-deletion.test.ts
index 47c54feb7..94c6cd072 100644
--- a/packages/kit/convex/projects/project-child-pending-deletion.test.ts
+++ b/packages/kit/convex/projects/project-child-pending-deletion.test.ts
@@ -762,6 +762,61 @@ describe("pending-deletion project child write guards", () => {
]);
});
+ it("reclaims a validated screenshot blob when its save is abandoned", async () => {
+ const ctx = makeCtx({});
+ ctx.db.tables.fileUploadReservations = [
+ {
+ _id: "fileUploadReservations_validated_expired",
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ createdBy: "users_a",
+ expiresAt: Date.now() - 1,
+ cleanupExpiresAt: Date.now() - 1,
+ validatedAppleReviewScreenshot: {
+ storageId: "storage_abandoned",
+ fileName: "review.png",
+ fileType: "image/png",
+ fileSize: 128,
+ },
+ createdAt: Date.now() - 60_000,
+ },
+ ];
+
+ await expect(
+ pruneUploadReservations._handler(ctx as never, { batchSize: 10 }),
+ ).resolves.toEqual({ deletedCount: 1 });
+ expect(ctx.storage.delete).toHaveBeenCalledWith("storage_abandoned");
+ expect(ctx.db.tables.fileUploadReservations).toEqual([]);
+ });
+
+ it("preserves a claimed screenshot blob that gained a live file reference", async () => {
+ const ctx = makeCtx({});
+ ctx.db.tables.files = [
+ {
+ _id: "files_review",
+ storageId: "storage_claimed",
+ },
+ ];
+ ctx.db.tables.fileUploadReservations = [
+ {
+ _id: "fileUploadReservations_pending_expired",
+ organizationId: "organizations_a",
+ projectId: "projects_a",
+ createdBy: "users_a",
+ expiresAt: Date.now() - 1,
+ cleanupExpiresAt: Date.now() - 1,
+ pendingAppleReviewScreenshotStorageId: "storage_claimed",
+ createdAt: Date.now() - 60_000,
+ },
+ ];
+
+ await expect(
+ pruneUploadReservations._handler(ctx as never, { batchSize: 10 }),
+ ).resolves.toEqual({ deletedCount: 1 });
+ expect(ctx.storage.delete).not.toHaveBeenCalled();
+ expect(ctx.db.tables.fileUploadReservations).toEqual([]);
+ });
+
it("immediately chains another bounded prune when expired backlog exceeds one batch", async () => {
const ctx = makeCtx({});
ctx.db.tables.fileUploadReservations = Array.from(
diff --git a/packages/kit/convex/schema.ts b/packages/kit/convex/schema.ts
index 575257431..c57e52dea 100644
--- a/packages/kit/convex/schema.ts
+++ b/packages/kit/convex/schema.ts
@@ -331,10 +331,13 @@ const schema = defineSchema({
// Key" / "Individual Key"). Used for ASC REST endpoints
// (catalog list / create / patch). Push-sync calls these.
// Uploading the wrong kind for either purpose returns 401.
+ // `apple_iap_review_screenshot` is a single project-level PNG/JPEG
+ // forwarded privately to ASC during iOS push-sync.
purpose: v.union(
v.literal("apple_p8_key"),
v.literal("apple_p8_asc_api_key"),
v.literal("android_service_account"),
+ v.literal("apple_iap_review_screenshot"),
),
description: v.optional(v.string()),
@@ -372,6 +375,20 @@ const schema = defineSchema({
// invalid for every operation and the cron removes it.
expiresAt: v.number(),
cleanupExpiresAt: v.number(),
+ // Set only by the server-side binary validator after it fetches the
+ // immutable storage object and verifies PNG/JPEG signature/transparency.
+ validatedAppleReviewScreenshot: v.optional(
+ v.object({
+ storageId: v.id("_storage"),
+ fileName: v.string(),
+ fileType: v.string(),
+ fileSize: v.number(),
+ }),
+ ),
+ // Claimed before the Node action downloads the private blob. This closes
+ // the action-crash gap: the expiry pruner can still reclaim an uploaded
+ // object even if validation never reaches its terminal mutation.
+ pendingAppleReviewScreenshotStorageId: v.optional(v.id("_storage")),
createdAt: v.number(),
})
.index("by_cleanup_expires_at", ["cleanupExpiresAt"])
@@ -946,6 +963,11 @@ const schema = defineSchema({
// own validation message if they exceed it.
reviewNote: v.optional(v.string()),
storeRef: v.optional(v.string()),
+ // Tracks the exact project screenshot that was handled for this product's
+ // latest ASC review attempt. Ready rows are eligible again only when the
+ // operator replaces the project screenshot, which makes resumption stable
+ // across pull-sync timestamp updates and bounded multi-run batches.
+ lastAppleReviewScreenshotFileId: v.optional(v.id("files")),
syncedAt: v.optional(v.number()),
// Where this row was first inserted from. Set on insert and
// never modified afterwards (so a kit-edited pull-imported row
@@ -1091,6 +1113,21 @@ const schema = defineSchema({
}),
),
),
+ plannedWritesTruncated: v.optional(v.boolean()),
+ // Non-retryable ASC constraints that need an operator to finish in
+ // App Store Connect (for example, a first-of-type product that Apple
+ // requires to travel with a new app version). These are intentionally
+ // distinct from transient per-item failures.
+ manualActions: v.optional(
+ v.array(
+ v.object({
+ productId: v.string(),
+ code: v.string(),
+ message: v.string(),
+ }),
+ ),
+ ),
+ manualActionsTruncated: v.optional(v.boolean()),
}),
),
error: v.optional(v.string()),
diff --git a/packages/kit/convex/utils/concurrency.test.ts b/packages/kit/convex/utils/concurrency.test.ts
new file mode 100644
index 000000000..8a4d37063
--- /dev/null
+++ b/packages/kit/convex/utils/concurrency.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from "vitest";
+
+import { mapWithConcurrency } from "./concurrency";
+
+describe("mapWithConcurrency", () => {
+ it("waits for in-flight cleanup and starts no new work after a failure", async () => {
+ const events: string[] = [];
+ let releaseCleanup!: () => void;
+ const cleanup = new Promise((resolve) => {
+ releaseCleanup = resolve;
+ });
+
+ const running = mapWithConcurrency([0, 1, 2, 3], 2, async (item) => {
+ events.push(`start:${item}`);
+ if (item === 0) throw new Error("stop");
+ await cleanup;
+ events.push(`cleanup:${item}`);
+ return item;
+ });
+
+ await Promise.resolve();
+ expect(events).toEqual(["start:0", "start:1"]);
+ releaseCleanup();
+ await expect(running).rejects.toThrow("stop");
+ expect(events).toEqual(["start:0", "start:1", "cleanup:1"]);
+ });
+
+ it("rejects when a worker rejects with undefined", async () => {
+ const running = mapWithConcurrency([0], 1, () =>
+ // Deliberately exercise a malformed third-party rejection value.
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
+ Promise.reject(undefined),
+ );
+
+ await expect(running).rejects.toThrow(
+ "Concurrent worker failed with a non-Error rejection",
+ );
+ });
+});
diff --git a/packages/kit/convex/utils/concurrency.ts b/packages/kit/convex/utils/concurrency.ts
index 49a7bacbe..f77831157 100644
--- a/packages/kit/convex/utils/concurrency.ts
+++ b/packages/kit/convex/utils/concurrency.ts
@@ -14,16 +14,35 @@ export async function mapWithConcurrency(
): Promise {
const out: R[] = new Array(items.length);
let cursor = 0;
+ let stopped = false;
+ let hasError = false;
+ let firstError: unknown;
const workers = Array.from(
{ length: Math.max(1, Math.min(concurrency, items.length)) },
async () => {
- while (true) {
+ while (!stopped) {
const idx = cursor++;
if (idx >= items.length) return;
- out[idx] = await fn(items[idx], idx);
+ try {
+ out[idx] = await fn(items[idx], idx);
+ } catch (error) {
+ if (!hasError) {
+ hasError = true;
+ firstError = error;
+ }
+ // Do not start more work, but let every already-running worker reach
+ // its own cleanup before this mapper rejects.
+ stopped = true;
+ return;
+ }
}
},
);
await Promise.all(workers);
+ if (hasError) {
+ throw firstError instanceof Error
+ ? firstError
+ : new Error("Concurrent worker failed with a non-Error rejection");
+ }
return out;
}
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 5b26aedcf..26811a152 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -59,6 +59,7 @@
"recharts": "^2.13.3",
"remark-gfm": "^4.0.1",
"resend": "^4.8.0",
+ "sharp": "^0.35.1",
"smol-toml": "^1.7.0",
"sonner": "^2.0.3",
"tailwind-merge": "^3.1.0",
@@ -90,7 +91,6 @@
"npm-run-all": "^4.1.5",
"postcss": "~8",
"prettier": "^3.5.3",
- "sharp": "^0.35.1",
"tailwindcss": "~4",
"typescript": "~5.9.3",
"typescript-eslint": "^8.24.1",
diff --git a/packages/kit/server/api/v1/products.ts b/packages/kit/server/api/v1/products.ts
index 6707e9342..b581361d4 100644
--- a/packages/kit/server/api/v1/products.ts
+++ b/packages/kit/server/api/v1/products.ts
@@ -412,8 +412,8 @@ products.get("/:apiKey/sync/jobs/:jobId", async (c) => {
}
});
-// Operator-initiated cancel. The worker checks `cancelRequested`
-// at phase boundaries.
+// Operator-initiated cancel. Workers observe it at phase/chunk boundaries and
+// before remote requests, upload operations, and asset-delivery polls.
products.post("/:apiKey/sync/jobs/:jobId/cancel", async (c) => {
const apiKey = c.req.param("apiKey");
const jobId = c.req.param("jobId");
diff --git a/packages/kit/src/pages/auth/organization/project/product-sync-failure-list.test.tsx b/packages/kit/src/pages/auth/organization/project/product-sync-failure-list.test.tsx
new file mode 100644
index 000000000..450e808e0
--- /dev/null
+++ b/packages/kit/src/pages/auth/organization/project/product-sync-failure-list.test.tsx
@@ -0,0 +1,37 @@
+/** @vitest-environment jsdom */
+
+import { cleanup, render, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { ProductSyncFailureList } from "./product-sync-failure-list";
+
+afterEach(cleanup);
+
+describe("ProductSyncFailureList", () => {
+ it("renders every persisted product failure", () => {
+ render(
+ ,
+ );
+
+ const failures = within(
+ screen.getByRole("list", { name: "Sync failures" }),
+ );
+ const items = failures.getAllByRole("listitem");
+ expect(items).toHaveLength(2);
+ expect(items[0]?.textContent).toBe(
+ "coins.100: App Store Connect rejected it",
+ );
+ expect(items[1]?.textContent).toBe("premium.monthly: Price is missing");
+ });
+
+ it("omits the list when the completed job has no failures", () => {
+ render();
+
+ expect(screen.queryByRole("list", { name: "Sync failures" })).toBeNull();
+ });
+});
diff --git a/packages/kit/src/pages/auth/organization/project/product-sync-failure-list.tsx b/packages/kit/src/pages/auth/organization/project/product-sync-failure-list.tsx
new file mode 100644
index 000000000..28dd2c3df
--- /dev/null
+++ b/packages/kit/src/pages/auth/organization/project/product-sync-failure-list.tsx
@@ -0,0 +1,32 @@
+import type { ReactElement } from "react";
+
+export interface ProductSyncFailure {
+ productId: string;
+ reason: string;
+}
+
+interface ProductSyncFailureListProps {
+ failures: readonly ProductSyncFailure[];
+}
+
+/** Render persisted sync failures so completed jobs remain diagnosable. */
+export function ProductSyncFailureList({
+ failures,
+}: ProductSyncFailureListProps): ReactElement | null {
+ if (failures.length === 0) return null;
+
+ return (
+
+ {
+ "Upload one flattened PNG (no alpha) or JPEG (up to 10 MB) whose dimensions match a screenshot size your app supports. IAPKit reuses this project-level image for each eligible iOS in-app purchase and subscription during Push Sync; Apple validates the app-specific dimensions while processing the asset. Apple still requires first-of-type products to be submitted with an app version; those rows are reported as manual follow-up instead of a failed sync."
+ }
+
)}
diff --git a/packages/kit/src/pages/docs/sections/products.tsx b/packages/kit/src/pages/docs/sections/products.tsx
index 0f668e11b..320980ad8 100644
--- a/packages/kit/src/pages/docs/sections/products.tsx
+++ b/packages/kit/src/pages/docs/sections/products.tsx
@@ -27,6 +27,73 @@ export default function ProductsPage() {
backend for secrets.
+
+
+ App Store Connect does not accept an idempotency key when IAPKit
+ creates a review submission. If the network closes after Apple may
+ have created a draft but before its ID reaches IAPKit, the affected
+ products stay Draft and the result asks you to inspect App Store
+ Connect. IAPKit never adopts or submits an unidentified existing
+ draft.
+
+
+
+
+ Submit Apple products for App Review
+
+
+ iOS Push Sync can prepare and submit eligible in-app purchases and
+ auto-renewable subscriptions through App Store Connect. In project
+ Settings, configure the App Store Connect API key and
+ upload one App Review screenshot (flattened PNG without
+ alpha, or JPEG, up to 10 MB). Use a screenshot size supported by the
+ app; Apple validates those app-specific dimensions during asset
+ processing. The screenshot is private project data: only an
+ authenticated organization admin or owner can download it, and IAPKit
+ never exposes a public storage URL.
+
+
+
+ Run Dry-run first. It lists the product-version,
+ screenshot-upload, and review-submission writes without changing App
+ Store Connect.
+
+
+ Run Sync with App Store Connect. IAPKit creates the
+ current version metadata, uploads every byte range Apple reserves,
+ waits for asset delivery, and then submits the eligible version for
+ review.
+
+
+ Check the result banner. Upstream errors stay in the failure list;
+ Apple requirements that need an operator appear separately as manual
+ actions.
+
+
+
+ The upload slot is intentionally project-level: IAPKit reuses the same
+ screenshot for every eligible IAPKit-managed iOS product in that
+ project. Products imported from App Store Connect remain read-only to
+ this review workflow until you edit them into an IAPKit Draft. If
+ products need different review screenshots, submit those products
+ manually in App Store Connect instead of configuring this slot. Without
+ a stored screenshot, Push Sync keeps its previous behavior and stops at
+ Ready to Submit; adding the screenshot later makes those IAPKit-managed
+ Ready rows resumable. Removing the file in IAPKit only stops future
+ reuse; it does not remove copies already uploaded to App Store Connect.
+ Manage or delete those ASC copies separately in App Store Connect.
+
+
+
+ Apple requires the first consumable, first non-consumable, first
+ auto-renewable subscription, and first non-renewing subscription to
+ travel with a new app version. A new subscription group must also be
+ reviewed with a subscription from that group. IAPKit does not treat
+ these constraints as sync failures and does not create an app
+ submission implicitly; it reports a manual action so an operator can
+ finish the combined submission in App Store Connect.
+