Appearance
File uploads — store.files.upload() + the _requestUploadUrl grant handler
User-file uploads (photos, attachments) into a store are app-controlled: nothing can be uploaded until the app's schema declares the reserved _requestUploadUrl action and the backend implements it. The platform runs that handler server-side before minting every upload ticket, so it is the app's policy gate — who may upload, what, and when. Platform quotas and rate limits apply on top.
1. Declare the reserved action in the schema
typescript
actions: {
_requestUploadUrl: {
description: "Approve one photo upload per player per day",
input: z.object({
fileId: z.string(),
sizeBytes: z.number(),
name: z.string().optional(),
}),
output: z.unknown(),
},
},The leading _ marks it platform-invoked: it is excluded from the client's store.action surface and cannot be dispatched by clients.
2. Implement the grant handler (backend actions)
The handler runs on a read-only action context: table reads and ctx.files.generateUploadUrl only — ctx.mutate, ctx.enqueueAction, external dispatch, and ctx.platform.call all throw. Approve by calling ctx.files.generateUploadUrl(...) exactly once; deny by throwing an ActionError whose bounded code the client receives as error.appCode:
typescript
import { ActionError } from "poe-tiles-sdk/v1/backend.js";
const actions = {
_requestUploadUrl: async (ctx, input) => {
if (input.sizeBytes > 5 * 1024 * 1024) {
throw new ActionError("photo_too_large");
}
await ctx.files.generateUploadUrl({ sizeBytes: input.sizeBytes });
return null;
},
};Enforce app rules in the CLAIMING MUTATOR, not here. The handler is read-only and records nothing, so it cannot serialize concurrent uploads — any grant-time rule (one photo per day, one entry per round) is advisory at best, and the mutator that writes the fileRefs claim is the authoritative gate anyway (a rejected claim just lets the unclaimed upload expire). Gate here only what must be decided BEFORE the bytes are uploaded: the size cap, or whether uploads are open at all.
ctx.userIdis the platform-verified uploading member.generateUploadUrltakes two DISTINCT optional size params:sizeBytes(as in the example above) re-asserts the request's declared size — a mismatch rejects the grant — whilemaxBytesis an approval CEILING the receiver enforces, not a size selector. Neither replaces size policy: enforce that by throwing before minting.- Returning without minting, or minting twice, invalidates the grant (
app_grant_invalidclient-side). - Handlers may read the clock (actions are server-only), unlike mutators.
3. Upload from the client and claim the file
typescript
const file = await store.files.upload({
data: photoBlob, // Blob | Uint8Array
name: "capture.jpg", // optional display metadata
contentType: "image/jpeg", // hint; bytes are inspected server-side
});
// file: { fileId, fileKey, url, sizeBytes, contentType, width, height }file.url is server-issued on the publish response — treat it as opaque rather than deriving it from fileKey, and do NOT persist it in your rows: store only fileIds and resolve the display URL at render time with store.files.url(fileId) (host-resolved from the current deployment's serving origin; waits briefly for the $files claim row to sync).
upload() resolves once the bytes are stored and published; file.url serves them. The file is retained only once a row CLAIMS it: write file.fileId into a field declared under the table's fileRefs (fileRefs: { strong: ["fileIds"] }, field shape string[]) — the platform then derives a $files claim row. An upload that no row ever claims expires; a claim of an unknown or expired fileId rejects the commit.
Failures reject with a FileUploadError (guard with isFileUploadError from poe-tiles-sdk/v1/client.js): uploads_not_enabled (no handler declared), upload_denied_by_app (+appCode), quota_exceeded, rate_limited, file_too_large, idempotency_conflict, uploads_disabled / upload_failed (deployment or transient; retryable marks safe re-invokes).
Testing
createPoeTileTestHarnesswithapiHarness: new ApiTestServer({ syncedStoreFileUploadsEnabled: true })runs the REAL pipeline (grant handler → receiver → publish → claim).- To fixture a claimable
fileIdwithout driving an upload, useseed.syncedStoreUploadedFile({ typeId, instanceId, fileId, uploaderUserId }).