From 146dd29d323e7dfdd11a4f08fdfb2e8e8e9a5c16 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 23 Jul 2026 02:28:01 +0900 Subject: [PATCH 1/9] feat(kit): automate ASC review submissions Add a private project-level PNG/JPEG review screenshot slot with full server-side decoding, replacement, pruning, and cascade cleanup.\n\nPrepare current IAP and subscription review versions, honor multipart ASC asset uploads, and submit bounded eligible batches with exact first-product/group manual fallbacks. Preserve resumability across cancellation, deadlines, ambiguous responses, and multi-run Ready rows while keeping dry-run and dashboard results truthful. --- packages/kit/convex.json | 3 +- packages/kit/convex/files/action.ts | 171 ++ packages/kit/convex/files/internal.ts | 72 +- packages/kit/convex/files/mutation.ts | 207 +- packages/kit/convex/files/query.ts | 2 + .../convex/files/review-screenshot.test.ts | 643 ++++++ packages/kit/convex/files/validation.test.ts | 124 ++ packages/kit/convex/files/validation.ts | 135 +- packages/kit/convex/products/asc.test.ts | 383 +++- packages/kit/convex/products/asc.ts | 1299 +++++++++--- .../kit/convex/products/ascReview.test.ts | 1825 +++++++++++++++++ packages/kit/convex/products/ascReview.ts | 1161 +++++++++++ packages/kit/convex/products/jobs.test.ts | 113 + packages/kit/convex/products/jobs.ts | 72 +- packages/kit/convex/products/sync.test.ts | 186 ++ packages/kit/convex/products/sync.ts | 32 +- packages/kit/convex/products/syncResult.ts | 85 + packages/kit/convex/projects/helpers.test.ts | 5 + .../project-child-pending-deletion.test.ts | 55 + packages/kit/convex/schema.ts | 37 + packages/kit/convex/utils/concurrency.test.ts | 27 + packages/kit/convex/utils/concurrency.ts | 19 +- packages/kit/server/api/v1/products.ts | 4 +- .../project/product-sync-result.test.ts | 61 + .../project/product-sync-result.ts | 45 + .../auth/organization/project/products.tsx | 111 +- .../organization/project/settings.test.tsx | 265 ++- .../auth/organization/project/settings.tsx | 273 ++- 28 files changed, 7061 insertions(+), 354 deletions(-) create mode 100644 packages/kit/convex/files/review-screenshot.test.ts create mode 100644 packages/kit/convex/files/validation.test.ts create mode 100644 packages/kit/convex/products/ascReview.test.ts create mode 100644 packages/kit/convex/products/ascReview.ts create mode 100644 packages/kit/convex/products/syncResult.ts create mode 100644 packages/kit/convex/utils/concurrency.test.ts create mode 100644 packages/kit/src/pages/auth/organization/project/product-sync-result.test.ts create mode 100644 packages/kit/src/pages/auth/organization/project/product-sync-result.ts 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..42812d5cc --- /dev/null +++ b/packages/kit/convex/files/validation.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } 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/); + }); +}); diff --git a/packages/kit/convex/files/validation.ts b/packages/kit/convex/files/validation.ts index f32881c3e..7eb70c4cf 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,24 @@ 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) { + // 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 +116,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 +309,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..20fb67f43 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,7 +445,10 @@ 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") + ); }, }); @@ -444,16 +456,18 @@ export const markJobRunning = internalMutation({ args: { jobId: v.id("productSyncJobs") }, 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 +509,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 +544,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..bceeacf36 --- /dev/null +++ b/packages/kit/convex/utils/concurrency.test.ts @@ -0,0 +1,27 @@ +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"]); + }); +}); diff --git a/packages/kit/convex/utils/concurrency.ts b/packages/kit/convex/utils/concurrency.ts index 49a7bacbe..0d11d7e83 100644 --- a/packages/kit/convex/utils/concurrency.ts +++ b/packages/kit/convex/utils/concurrency.ts @@ -14,16 +14,31 @@ export async function mapWithConcurrency( ): Promise { const out: R[] = new Array(items.length); let cursor = 0; + let stopped = 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 (firstError === undefined) 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 (firstError !== undefined) { + throw firstError instanceof Error + ? firstError + : new Error("Concurrent worker failed with a non-Error rejection"); + } return out; } 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-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..1d0133422 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -25,6 +25,10 @@ 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"; type DashboardProject = Omit< Doc<"projects">, @@ -91,8 +95,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 +175,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 +188,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 +553,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 +581,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 +1047,6 @@ function ProductGroup({ platform, rows, job, - triggeredInSession, onSync, onDryRun, onPurge, @@ -1050,7 +1057,6 @@ function ProductGroup({ platform: "IOS" | "Android"; rows: Array; job: SyncJob | null; - triggeredInSession: boolean; onSync: () => void; onDryRun?: () => void; onPurge: () => void; @@ -1060,13 +1066,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 +1139,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 +1147,45 @@ 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" - }` - : `Last sync — pulled ${job.result.pulled}, pushed ${ - job.result.pushed - }, deleted ${job.result.deleted}` - : `Last sync — pulled ${job.result.pulled}, pushed ${job.result.pushed}`} + {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" }` : ""} {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) => ( +
  • + {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} + /> + +
)} From cbb79e47afe662c0303eeaa8a7e2352af5304d6a Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 23 Jul 2026 02:29:14 +0900 Subject: [PATCH 2/9] docs(kit): explain ASC review submissions Document the secure project-level screenshot reuse policy, version-based submission steps, dry-run behavior, first-product and group manual actions, ambiguous network fallback, and bounded cancellation semantics. --- packages/docs/src/pages/docs/kit-backend.tsx | 19 +++++- .../kit/src/pages/docs/sections/products.tsx | 67 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) 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/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 From 9f7a77094dc7e83071d634743b3f55445ed5dd3f Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 23 Jul 2026 02:30:30 +0900 Subject: [PATCH 3/9] chore(pr): add ASC review submission preview Add the compressed local IAPKit documentation preview fallback for the pull request attachment. --- .../iapkit-asc-review-submission.jpg | Bin 0 -> 166975 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/pr-previews/iapkit-asc-review-submission.jpg 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 0000000000000000000000000000000000000000..38b599edecb2249d8a5a9d6bb036b297b13bb196 GIT binary patch literal 166975 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%0DutZ+l)UW6BK@c7(q;21L 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 literal 0 HcmV?d00001 From 850d39a2fc868afd1e81fd9b27d12fc87e5a247f Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 23 Jul 2026 03:00:25 +0900 Subject: [PATCH 4/9] fix(kit): show persisted sync failures --- .../product-sync-failure-list.test.tsx | 37 ++++++++++++++ .../project/product-sync-failure-list.tsx | 32 +++++++++++++ .../auth/organization/project/products.tsx | 48 ++++++++++--------- 3 files changed, 95 insertions(+), 22 deletions(-) create mode 100644 packages/kit/src/pages/auth/organization/project/product-sync-failure-list.test.tsx create mode 100644 packages/kit/src/pages/auth/organization/project/product-sync-failure-list.tsx 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 ( +
    + {failures.map((failure, index) => ( +
  • + {failure.productId} + {": "} + {failure.reason} +
  • + ))} +
+ ); +} diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index 1d0133422..e07ba3c16 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -29,6 +29,7 @@ import { formatProductSyncSummary, shouldShowProductSyncResult, } from "./product-sync-result"; +import { ProductSyncFailureList } from "./product-sync-failure-list"; type DashboardProject = Omit< Doc<"projects">, @@ -1147,28 +1148,31 @@ function ProductGroup({
{job.status === "succeeded" && job.result ? (
- {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" - }` - : ""} - {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)" - : ""} +
+ {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" + }` + : ""} + {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) => ( From a16a37eb1348976e34651cd3c3e124ab0f19a705 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 23 Jul 2026 03:13:18 +0900 Subject: [PATCH 5/9] fix(kit): reject undefined concurrency failures --- packages/kit/convex/utils/concurrency.test.ts | 12 ++++++++++++ packages/kit/convex/utils/concurrency.ts | 8 ++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/kit/convex/utils/concurrency.test.ts b/packages/kit/convex/utils/concurrency.test.ts index bceeacf36..8a4d37063 100644 --- a/packages/kit/convex/utils/concurrency.test.ts +++ b/packages/kit/convex/utils/concurrency.test.ts @@ -24,4 +24,16 @@ describe("mapWithConcurrency", () => { 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 0d11d7e83..f77831157 100644 --- a/packages/kit/convex/utils/concurrency.ts +++ b/packages/kit/convex/utils/concurrency.ts @@ -15,6 +15,7 @@ export async function mapWithConcurrency( 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)) }, @@ -25,7 +26,10 @@ export async function mapWithConcurrency( try { out[idx] = await fn(items[idx], idx); } catch (error) { - if (firstError === undefined) firstError = 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; @@ -35,7 +39,7 @@ export async function mapWithConcurrency( }, ); await Promise.all(workers); - if (firstError !== undefined) { + if (hasError) { throw firstError instanceof Error ? firstError : new Error("Concurrent worker failed with a non-Error rejection"); From e71259cca4bc32dafa2398a2bf4c1251355b38c9 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 23 Jul 2026 03:24:18 +0900 Subject: [PATCH 6/9] fix(kit): stabilize manual action keys --- packages/kit/src/pages/auth/organization/project/products.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/pages/auth/organization/project/products.tsx b/packages/kit/src/pages/auth/organization/project/products.tsx index e07ba3c16..407af1e8b 100644 --- a/packages/kit/src/pages/auth/organization/project/products.tsx +++ b/packages/kit/src/pages/auth/organization/project/products.tsx @@ -1175,8 +1175,8 @@ function ProductGroup({ {job.result.manualActions?.length ? (
      - {job.result.manualActions.map((action) => ( -
    • + {job.result.manualActions.map((action, index) => ( +
    • {action.productId} {": "} {action.message} From ca8a13600b969616c1093b8873df02f40c6393b7 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 23 Jul 2026 03:37:11 +0900 Subject: [PATCH 7/9] fix(kit): declare sharp runtime dependency --- bun.lock | 2 +- packages/kit/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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", From f644e1220917d18410069e59eaf031736054e9c8 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 23 Jul 2026 03:49:13 +0900 Subject: [PATCH 8/9] fix(kit): validate job deadline result --- packages/kit/convex/products/jobs.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kit/convex/products/jobs.ts b/packages/kit/convex/products/jobs.ts index 20fb67f43..22d6f31fa 100644 --- a/packages/kit/convex/products/jobs.ts +++ b/packages/kit/convex/products/jobs.ts @@ -454,6 +454,7 @@ export const isCancelRequested = internalQuery({ 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 null; From 495105dabf1913d264e83cae53e3d3af7aa55089 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 23 Jul 2026 04:09:06 +0900 Subject: [PATCH 9/9] fix(kit): preserve optional file MIME types --- packages/kit/convex/files/validation.test.ts | 17 ++++++++++++++++- packages/kit/convex/files/validation.ts | 5 ++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/kit/convex/files/validation.test.ts b/packages/kit/convex/files/validation.test.ts index 42812d5cc..e4acca8ea 100644 --- a/packages/kit/convex/files/validation.test.ts +++ b/packages/kit/convex/files/validation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { validateAppleReviewScreenshotContent, @@ -122,3 +122,18 @@ describe("Apple App Review screenshot validation", () => { ).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 7eb70c4cf..71f4fd46f 100644 --- a/packages/kit/convex/files/validation.ts +++ b/packages/kit/convex/files/validation.ts @@ -89,7 +89,10 @@ export function validateFile( } // Check MIME type (more lenient since browsers can be inconsistent) - if (validation.mimeTypes.length > 0) { + 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.