Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 17 additions & 2 deletions packages/docs/src/pages/docs/kit-backend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,20 @@ var clientPayload = payloadResponse.ClientPayload;`}</CodeBlock>
converges.
</li>
</ul>
<p>
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&apos;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 <code>manualActions</code>,
not transient sync failures. Removing the project file only stops
future reuse; it does not remove screenshots already uploaded to App
Store Connect.
</p>
<p>
Sync is asynchronous —{' '}
<code>
Expand Down Expand Up @@ -811,8 +825,9 @@ var clientPayload = payloadResponse.ClientPayload;`}</CodeBlock>
POST
/v1/products/&#123;apiKey&#125;/sync/jobs/&#123;jobId&#125;/cancel
</code>{' '}
— 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.
</li>
</ul>
<p>
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/convex.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"node": {
"nodeVersion": "22"
"nodeVersion": "22",
"externalPackages": ["sharp"]
}
}
171 changes: 171 additions & 0 deletions packages/kit/convex/files/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
hyochan marked this conversation as resolved.
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<void> {
validateAppleReviewScreenshotContent(bytes, declaredMimeType);
let metadata: Awaited<ReturnType<ReturnType<typeof sharp>["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
Expand Down
72 changes: 64 additions & 8 deletions packages/kit/convex/files/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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: {
Expand Down Expand Up @@ -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,
};
Expand All @@ -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<FilePublicProjection[]> => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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()),
Expand All @@ -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);
}

Expand Down
Loading