diff --git a/.github/pr-previews/iapkit-asc-review-submission.jpg b/.github/pr-previews/iapkit-asc-review-submission.jpg new file mode 100644 index 000000000..38b599ede Binary files /dev/null and b/.github/pr-previews/iapkit-asc-review-submission.jpg differ diff --git a/bun.lock b/bun.lock index af14b2595..6b791c686 100644 --- a/bun.lock +++ b/bun.lock @@ -109,6 +109,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", @@ -140,7 +141,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/docs/src/pages/docs/kit-backend.tsx b/packages/docs/src/pages/docs/kit-backend.tsx index ce0d6f68c..804a60025 100644 --- a/packages/docs/src/pages/docs/kit-backend.tsx +++ b/packages/docs/src/pages/docs/kit-backend.tsx @@ -778,6 +778,20 @@ var clientPayload = payloadResponse.ClientPayload;`} converges. +

+ 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 ( +

+ ); +} diff --git a/packages/kit/src/pages/auth/organization/project/product-sync-result.test.ts b/packages/kit/src/pages/auth/organization/project/product-sync-result.test.ts new file mode 100644 index 000000000..671b80c7c --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/product-sync-result.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { + formatProductSyncSummary, + shouldShowProductSyncResult, +} from "./product-sync-result"; + +describe("shouldShowProductSyncResult", () => { + it("shows a completed result on the first render after a page reload", () => { + expect( + shouldShowProductSyncResult({ + status: "succeeded", + progress: { phase: "done" }, + }), + ).toBe(true); + }); + + it("hides active and explicitly dismissed results", () => { + expect( + shouldShowProductSyncResult({ + status: "running", + progress: { phase: "push-drafts" }, + }), + ).toBe(false); + expect( + shouldShowProductSyncResult({ + status: "succeeded", + progress: { phase: "dismissed" }, + }), + ).toBe(false); + }); +}); + +describe("formatProductSyncSummary", () => { + it("labels every dry-run count as prospective and explicitly read-only", () => { + expect( + formatProductSyncSummary({ + dryRun: true, + direction: "both", + result: { pulled: 3, pushed: 2 }, + }), + ).toBe("Dry-run — would pull 3, would push 2 (no writes performed)"); + }); + + it("keeps actual sync and reset summaries in past tense", () => { + expect( + formatProductSyncSummary({ + dryRun: false, + direction: "both", + result: { pulled: 3, pushed: 2, deleted: 1 }, + }), + ).toBe("Last sync — pulled 3, pushed 2, deleted 1"); + expect( + formatProductSyncSummary({ + dryRun: false, + direction: "purge-local", + result: { pulled: 0, pushed: 0, deleted: 2 }, + }), + ).toBe("Reset — deleted 2 rows"); + }); +}); diff --git a/packages/kit/src/pages/auth/organization/project/product-sync-result.ts b/packages/kit/src/pages/auth/organization/project/product-sync-result.ts new file mode 100644 index 000000000..7c9d832c7 --- /dev/null +++ b/packages/kit/src/pages/auth/organization/project/product-sync-result.ts @@ -0,0 +1,45 @@ +export interface ProductSyncResultJob { + status: string; + progress: { phase: string }; +} + +export interface ProductSyncSummaryJob { + dryRun: boolean; + direction: string; + result: { + pulled: number; + pushed: number; + deleted?: number; + }; +} + +/** Describe actual and dry-run counts without implying previewed writes ran. */ +export function formatProductSyncSummary(job: ProductSyncSummaryJob): string { + if (job.dryRun) { + return ( + `Dry-run — would pull ${job.result.pulled}, would push ${job.result.pushed}` + + (job.result.deleted !== undefined + ? `, would delete ${job.result.deleted}` + : "") + + " (no writes performed)" + ); + } + if (job.direction === "purge-local" && job.result.deleted !== undefined) { + return `Reset — deleted ${job.result.deleted} row${ + job.result.deleted === 1 ? "" : "s" + }`; + } + return ( + `Last sync — pulled ${job.result.pulled}, pushed ${job.result.pushed}` + + (job.result.deleted !== undefined ? `, deleted ${job.result.deleted}` : "") + ); +} + +/** Keep the latest terminal result visible across reloads until dismissal. */ +export function shouldShowProductSyncResult( + job: ProductSyncResultJob | null, +): boolean { + if (!job) return false; + const terminal = job.status === "succeeded" || job.status === "failed"; + return terminal && job.progress.phase !== "dismissed"; +} diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index 71eca3787..407af1e8b 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -25,6 +25,11 @@ import { Badge, PlatformBadge } from "../../../../components/Badge"; import { usdPriceToMicros } from "./productPrice"; import type { ProductClientPayloadSummary } from "./clientPayload"; import { openProductClientPayloadEditor } from "@/lib/signals"; +import { + formatProductSyncSummary, + shouldShowProductSyncResult, +} from "./product-sync-result"; +import { ProductSyncFailureList } from "./product-sync-failure-list"; type DashboardProject = Omit< Doc<"projects">, @@ -91,8 +96,8 @@ export default function ProjectProducts() { Android: null, }); // Job ids the operator triggered FROM THIS MOUNT (Sync / Dry-run / - // Reset clicks). Result banner + completion toast both gate on - // this so a stale terminal job from a previous session — left + // Reset clicks). Completion toasts gate on this so a terminal job from a + // previous session — left // over after a code edit / HMR reload / page revisit — doesn't // re-surface as if a sync had just happened. Reset on remount so // the gate is automatic and never sticky. @@ -171,14 +176,11 @@ export default function ProjectProducts() { const label = platform === "IOS" ? "App Store Connect" : "Play Console"; const result = job.result; if (job.status === "succeeded" && result) { - const summary = - job.direction === "purge-local" && result.deleted !== undefined - ? `Deleted ${result.deleted} row${result.deleted === 1 ? "" : "s"}` - : `Pulled ${result.pulled}, pushed ${result.pushed}${ - result.deleted !== undefined - ? `, deleted ${result.deleted}` - : "" - }`; + const summary = formatProductSyncSummary({ + dryRun: job.dryRun, + direction: job.direction, + result, + }); const plannedLines = result.plannedWrites?.length ? result.plannedWrites .map( @@ -187,27 +189,41 @@ export default function ProjectProducts() { ) .join("\n") : undefined; + const manualLines = result.manualActions?.length + ? result.manualActions + .map((action) => `${action.productId}: ${action.message}`) + .join("\n") + : undefined; if (result.failures.length) { - toast.error(`${label} sync — ${summary}`, { + toast.error(`${label}: ${summary}`, { description: (plannedLines ? `Planned writes:\n${plannedLines}\n\n` : "") + + (manualLines ? `Manual actions:\n${manualLines}\n\n` : "") + result.failures .map((f) => `${f.productId}: ${f.reason}`) .join("\n"), duration: 12_000, }); + } else if (manualLines) { + toast.warning(`${label}: ${summary}`, { + description: manualLines, + duration: 12_000, + }); } else if (plannedLines) { - toast.success(`${label} dry-run — ${summary} (no writes performed)`, { + toast.success(`${label}: ${summary}`, { description: plannedLines, duration: 12_000, }); } else { - toast.success(`${label} sync — ${summary}`); + toast.success(`${label}: ${summary}`); } } else if (job.status === "failed") { - toast.error(`${label} sync failed: ${job.error ?? "Unknown error"}`, { - duration: 12_000, - }); + toast.error( + `${label} ${job.dryRun ? "dry-run" : "sync"} failed: ${ + job.error ?? "Unknown error" + }`, + { duration: 12_000 }, + ); } } }, [iosJob, androidJob]); @@ -538,9 +554,6 @@ export default function ProjectProducts() { platform="IOS" rows={grouped.ios} job={iosJob ?? null} - triggeredInSession={ - !!iosJob?._id && sessionTriggeredJobIdsRef.current.has(iosJob._id) - } onSync={() => { void onSync("IOS"); }} @@ -569,10 +582,6 @@ export default function ProjectProducts() { platform="Android" rows={grouped.android} job={androidJob ?? null} - triggeredInSession={ - !!androidJob?._id && - sessionTriggeredJobIdsRef.current.has(androidJob._id) - } onSync={() => { void onSync("Android"); }} @@ -1039,7 +1048,6 @@ function ProductGroup({ platform, rows, job, - triggeredInSession, onSync, onDryRun, onPurge, @@ -1050,7 +1058,6 @@ function ProductGroup({ platform: "IOS" | "Android"; rows: Array; job: SyncJob | null; - triggeredInSession: boolean; onSync: () => void; onDryRun?: () => void; onPurge: () => void; @@ -1060,13 +1067,11 @@ function ProductGroup({ }) { const storeLabel = platform === "IOS" ? "App Store Connect" : "Play Console"; const isActive = job?.status === "queued" || job?.status === "running"; - const isTerminal = job?.status === "succeeded" || job?.status === "failed"; - const dismissed = job?.progress.phase === "dismissed"; - // Result banner only surfaces for jobs the operator triggered - // FROM THIS MOUNT — stale terminal jobs from prior sessions - // (HMR reload, page revisit) stay hidden so the operator can't - // mistake them for a sync that just ran. - const showResult = isTerminal && !dismissed && triggeredInSession; + // The latest terminal result remains visible across reloads until dismissed. + // This is especially important for ASC manualActions: an operator may leave + // while the background job runs and still needs the follow-up instructions + // on return. `triggeredInSession` intentionally gates only completion toasts. + const showResult = shouldShowProductSyncResult(job); const [purgeOpen, setPurgeOpen] = useState(false); return (
@@ -1135,7 +1140,7 @@ function ProductGroup({ className={`px-4 py-2 border-b border-border flex items-start gap-2 text-xs ${ job.status === "failed" ? "bg-rose-500/10 text-rose-700 dark:text-rose-200" - : job.result?.failures.length + : job.result?.failures.length || job.result?.manualActions?.length ? "bg-amber-500/10 text-amber-700 dark:text-amber-200" : "bg-emerald-500/10 text-emerald-700 dark:text-emerald-200" }`} @@ -1143,24 +1148,48 @@ function ProductGroup({
{job.status === "succeeded" && job.result ? (
- {job.result.deleted !== undefined - ? job.direction === "purge-local" - ? `Reset — deleted ${job.result.deleted} row${ - job.result.deleted === 1 ? "" : "s" +
+ {formatProductSyncSummary({ + dryRun: job.dryRun, + direction: job.direction, + result: job.result, + })} + {job.result.failures.length + ? `, ${job.result.failures.length} failure${ + job.result.failures.length === 1 ? "" : "s" }` - : `Last sync — pulled ${job.result.pulled}, pushed ${ - job.result.pushed - }, deleted ${job.result.deleted}` - : `Last sync — pulled ${job.result.pulled}, pushed ${job.result.pushed}`} - {job.result.failures.length - ? `, ${job.result.failures.length} failure${ - job.result.failures.length === 1 ? "" : "s" - }` - : ""} - {job.result.failuresTruncated ? " (truncated)" : ""} + : ""} + {job.result.failuresTruncated ? " (truncated)" : ""} + {job.result.plannedWritesTruncated + ? ", planned writes truncated" + : ""} + {job.result.manualActions?.length + ? `, ${job.result.manualActions.length} manual action${ + job.result.manualActions.length === 1 ? "" : "s" + }` + : ""} + {job.result.manualActionsTruncated + ? " (manual actions truncated)" + : ""} +
+ + {job.result.manualActions?.length ? ( +
    + {job.result.manualActions.map((action, index) => ( +
  • + {action.productId} + {": "} + {action.message} +
  • + ))} +
+ ) : null}
) : ( -
Last sync failed — {job.error ?? "Unknown error"}
+
+ {job.dryRun ? "Dry-run failed" : "Last sync failed"} —{" "} + {job.error ?? "Unknown error"} +
)}
)} + +
+ +

+ { + "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." + } +

+ + {hasIosReviewScreenshot ? ( +
+
+ +
+ + {"App Review screenshot configured"} + + {iosReviewScreenshot && ( +

+ {iosReviewScreenshot.fileName} •{" "} + {( + iosReviewScreenshot.fileSize / + (1024 * 1024) + ).toFixed(2)}{" "} + MB +

+ )} +
+
+
+ {iosReviewScreenshot && ( + + )} + +
+
+ ) : null} + + void handleIosReviewScreenshotUpload(event) + } + className="sr-only" + id="ios-review-screenshot-upload" + disabled={uploadingIosReviewScreenshot} + /> + +
)} 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. +

+
    +
  1. + Run Dry-run first. It lists the product-version, + screenshot-upload, and review-submission writes without changing App + Store Connect. +
  2. +
  3. + 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. +
  4. +
  5. + Check the result banner. Upstream errors stay in the failure list; + Apple requirements that need an operator appear separately as manual + actions. +
  6. +
+

+ 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. +

+

Two ways to request client payloads