Extension File Storage
TLDR: store the file id, render with a signed URL.
When an extension uploads a file, Chastify returns a stable id and a short-lived signedUrl. Store the id in your extension config, state, or own database.
When your extension page loads later, call bridge.request("files.get", { fileId }) or GET /api/extensions/sessions/:sessionId/files/:fileId with the stored id. Chastify verifies that the file belongs to the same extension app, session, and lock, then returns a fresh signed R2 URL.
Render the image with <img src={file.signedUrl} />. Do not store signed URLs long-term because they expire.
Extension file storage lets an enabled extension upload image files that belong to a lock extension session.
Use it when extension state JSON is not enough, for example:
- puzzle images
- generated previews
- challenge photos
- extension-specific media that should be cleaned up with the lock/session
This storage is separate from state.*. Use state.* for small JSON data. Use file storage only for binary media.
Relation To State Endpoints
Extension state is for small session-scoped JSON. From an iframe, use the bridge read command instead of calling REST directly:
state.get
That bridge command routes to:
GET /api/extensions/sessions/:sessionId/state
Direct backend calls to write state use the installed-extension API authentication model:
Authorization: Bearer YOUR_APP_SCOPED_DEVELOPER_KEY
x-chastify-main-token: MAIN_TOKEN_FROM_IFRAME_HASH
Do not send Developer API keys to iframe/browser code.
Use backend-written state for durable references and UI data, for example:
{
"puzzleImageFileId": "file_record_id",
"selectedImageIds": ["file_record_id"],
"lastOpenedTab": "images"
}
Do not store binary data, base64 images, browser blob: URLs, or long-lived copies of signedUrl in state. Signed URLs expire and should be refreshed with files.get when the image is rendered. State writes are size-limited by the extension app's stateMaxBytes setting, defaulting to 64 KiB.
Storage Model
Chastify stores extension files in Chastify-managed R2 storage.
Each uploaded file is tracked with:
scope: "extension"appIdsessionIdandlockIdfor runtime/session filestemplateIdfor files claimed by a saved lock templatestaged,draftId, andexpiresAtfor setup uploads that are not claimed yetextensionKeypurpose
Admin Gate And Quotas
Admin controlled
Extension file storage is disabled by default.
Admins can enable it and configure:
- maximum bytes per file
- maximum total bytes per extension app
- maximum total bytes per active extension session
- maximum staged bytes per user and extension app
- allowed MIME prefixes
The first implementation is image-focused and should use image/* uploads. SVG is not accepted by the storage service.
If storage is disabled or R2 is not configured, upload requests fail before the server reads the multipart file body.
Staged setup uploads count toward the current user's per-app staged quota until they are claimed. Claimed files count toward the per-extension app quota. Runtime files with a session id also count toward the per-session quota.
Setup Staging Endpoints
Use setup staging only when a setup UI runs before an extension session exists.
This is a temporary upload flow. It exists for setup/config screens where the user may upload a file, then close the modal or disable the extension before creating a lock/session. A staged file is not durable until it is claimed by saving extension config that references it.
Setup UIs can check availability and current staged usage before rendering upload controls:
GET /api/extensions/apps/:appId/files/capabilities
The response includes stagedQuota for the current user's unexpired staged files on that extension app:
{
"enabled": true,
"provider": "r2",
"r2Configured": true,
"supportsStagedSetupFiles": true,
"stagedQuota": {
"bytesUsed": 123456,
"fileCount": 1,
"maxBytes": 10485760,
"remainingBytes": 10362304
}
}
POST /api/extensions/apps/:appId/files/stage
Content-Type: multipart/form-data
Form fields:
file: required image filepurpose: optional short identifier such asjigsaw-config-imagedraftId: optional setup draft id; reuse the same value while one setup modal is open
The response contains a stable file.id and a short-lived signed URL for immediate preview.
Example response:
{
"file": {
"id": "file_record_id",
"signedUrl": "https://chastify.<account-id>.r2.cloudflarestorage.com/extensions/abc.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...",
"publicUrl": "https://chastify.<account-id>.r2.cloudflarestorage.com/extensions/abc.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...",
"urlExpiresAt": "2026-05-28T12:10:00.000Z",
"sizeBytes": 123456,
"originalName": "puzzle.jpg",
"mimeType": "image/webp",
"purpose": "jigsaw-config-image",
"uploadedAt": "2026-05-28T12:00:00.000Z"
},
"stagedQuota": {
"bytesUsed": 123456,
"fileCount": 1,
"maxBytes": 10485760,
"remainingBytes": 10362304
}
}
To restore setup uploads after a page refresh, list staged files for the current user and extension app:
GET /api/extensions/apps/:appId/files/staged?purpose=jigsaw-config-image
Optional query params:
purpose: return only staged files for one setup use casedraftId: return only files from a known setup draft
If draftId is omitted, Chastify returns the current user's unexpired staged files for that app. This is intentional for setup UIs: a user can upload files, reload the page, or switch devices, and the setup screen can restore those temporary uploads before the lock/template is saved.
The staged list response also includes stagedQuota, which reports the current user's unexpired staged usage for that app:
{
"items": [],
"stagedQuota": {
"bytesUsed": 123456,
"fileCount": 1,
"maxBytes": 10485760,
"remainingBytes": 10362304
}
}
Setup UIs can also refresh or delete one staged file:
GET /api/extensions/apps/:appId/files/:fileId
DELETE /api/extensions/apps/:appId/files/staged/:fileId
Claiming Staged Files Later
There is no separate browser-side "claim" endpoint. A staged file is claimed automatically when Chastify saves a lock or template extension config that references the staged file.
Those routes claim staged files after the lock/template document has an id, then save the extension config again with the claimed file references. If claiming fails, the newly created lock/template is rolled back so staged files are not left attached to a broken config.
Shared-template note: accepted shared locks keep the source template id on the cloned active lock. Template-claimed files are therefore kept while any cloned lock still references that template. If the source template is deleted, Chastify delays deleting its extension files until the last cloned lock that references the deleted template is deleted or archived.
To make a staged file claimable, store a reference like this in the extension config that is submitted by the setup UI:
{
"storageDraftId": "draft_123",
"images": [
{
"id": "file_record_id",
"provider": "chastify_storage",
"fileId": "file_record_id",
"url": "extension-file:file_record_id",
"title": "Puzzle image"
}
]
}
On save, Chastify scans the config for provider: "chastify_storage" records with a valid fileId, then:
- verifies the file belongs to the current user and extension app
- rejects expired staged files
- rejects files already claimed by another lock/template context
- marks referenced staged files as claimed
- binds claimed files to the saved lock or template
- strips temporary
signedUrl,publicUrl, andurlExpiresAtfields before config is persisted - deletes unreferenced staged files from the same
storageDraftId
Abandoned staged files that are never saved are deleted by cleanup after their expiry.
To refresh a setup preview for a file id owned by the current user and extension app:
GET /api/extensions/apps/:appId/files/:fileId
Session Endpoints
These endpoints require the normal extension session authorization and scopes.
Base path:
/api/extensions/sessions/:sessionId/files
Capabilities
Check this before rendering upload controls.
GET /api/extensions/sessions/:sessionId/files/capabilities
Requires locks:read.
Example response:
{
"enabled": true,
"provider": "r2",
"r2Configured": true,
"settings": {
"enabled": true,
"maxFileBytes": 10485760,
"maxBytesPerApp": 524288000,
"maxBytesPerSession": 52428800,
"maxStagedBytesPerUserPerApp": 10485760,
"allowedMimePrefixes": ["image/"]
}
}
List Files
GET /api/extensions/sessions/:sessionId/files
Requires locks:read.
Example response:
{
"items": [
{
"id": "file_record_id",
"signedUrl": "https://chastify.<account-id>.r2.cloudflarestorage.com/extensions/abc.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...",
"publicUrl": "https://chastify.<account-id>.r2.cloudflarestorage.com/extensions/abc.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...",
"urlExpiresAt": "2026-05-28T12:10:00.000Z",
"sizeBytes": 123456,
"originalName": "puzzle.jpg",
"mimeType": "image/webp",
"purpose": "puzzle-image",
"uploadedAt": "2026-05-28T12:00:00.000Z"
}
]
}
Get One File
Use this when your extension already has a stored file id and only needs a fresh signed R2 link.
GET /api/extensions/sessions/:sessionId/files/:fileId
Requires locks:read.
The file must belong to the same extension app, session, and lock.
Example response:
{
"file": {
"id": "file_record_id",
"signedUrl": "https://chastify.<account-id>.r2.cloudflarestorage.com/extensions/abc.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...",
"publicUrl": "https://chastify.<account-id>.r2.cloudflarestorage.com/extensions/abc.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...",
"urlExpiresAt": "2026-05-28T12:10:00.000Z",
"sizeBytes": 123456,
"originalName": "puzzle.jpg",
"mimeType": "image/webp",
"purpose": "puzzle-image",
"uploadedAt": "2026-05-28T12:00:00.000Z"
}
}
Upload File
POST /api/extensions/sessions/:sessionId/files
Content-Type: multipart/form-data
Requires locks:write.
Form fields:
file: required image filepurpose: optional short identifier such aspuzzle-imageorpreview
Example:
curl "https://chastify.net/api/extensions/sessions/SESSION_ID/files" \
-X POST \
-F "purpose=puzzle-image" \
-F "[email protected]"
Example response:
{
"file": {
"id": "file_record_id",
"signedUrl": "https://chastify.<account-id>.r2.cloudflarestorage.com/extensions/abc.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...",
"publicUrl": "https://chastify.<account-id>.r2.cloudflarestorage.com/extensions/abc.webp?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=...&X-Amz-Signature=...",
"urlExpiresAt": "2026-05-28T12:10:00.000Z",
"sizeBytes": 123456,
"originalName": "puzzle.jpg",
"mimeType": "image/webp",
"purpose": "puzzle-image",
"uploadedAt": "2026-05-28T12:00:00.000Z"
}
}
Common upload errors:
extension_file_storage_disabledextension_file_storage_requires_r2file_too_largeinvalid_file_typeextension_app_storage_quota_exceededextension_session_storage_quota_exceededextension_staged_user_app_storage_quota_exceeded
Delete File
DELETE /api/extensions/sessions/:sessionId/files/:fileId
Requires locks:write.
The file must belong to the same extension app, session, and lock.
Iframe Bridge Actions
Iframe extensions can use the parent web bridge for runtime file reads. Runtime file upload and delete are session mutations and should be performed by your backend with an app-scoped Developer API key plus x-chastify-main-token.
const capabilities = await bridge.request("files.capabilities", {});
const refreshed = await bridge.request("files.get", {
fileId: "file_record_id"
});
image.src = refreshed.file.signedUrl;
Supported bridge actions:
files.capabilities-> check if file storage is enabled and what quotas applyfiles.list-> list files owned by the current extension sessionfiles.get-> refresh one signed R2 URL from a stored file id
Setup iframes can use additional bridge names before a runtime session exists. In setup mode, files.upload creates staged files, files.list/files.staged.list lists unexpired staged files for the current user and app, and files.delete deletes a staged file. The setup init context includes storageDraftId; include that value in your saved config as storageDraftId if you want Chastify to clean unreferenced files from the same draft immediately on save.
Setup files.upload also accepts dataUrl for simple iframe clients:
await bridge.request("files.upload", {
dataUrl: canvas.toDataURL("image/webp", 0.9),
filename: "preview.webp",
purpose: "preview"
}, 60000);
Prefer File or Blob uploads when possible. Use dataUrl only for small generated images because base64 payloads are larger in memory.
Signed Links
The stable file identifier is id.
Use signedUrl to display or download the file. Signed links are short-lived R2 GetObject URLs generated by Chastify. publicUrl is kept as a compatibility alias and currently contains the same signed URL.
When a signed link expires, call GET /api/extensions/sessions/:sessionId/files/:fileId to receive a fresh link for one stored file, or GET /api/extensions/sessions/:sessionId/files to refresh all session file links.
Cleanup Lifecycle
Extension files are cleaned up through a BullMQ-backed cleanup queue.
Cleanup is scheduled when:
- an extension app is deleted
- lock/session extension documents are deleted during lock removal/archive cleanup
- a file is explicitly deleted through the session file endpoint
The queue keeps expensive R2 deletion and database cleanup out of the request path. If queue enqueue fails, the server falls back to in-process cleanup after the response where a request context exists, or direct best-effort cleanup in lower-level lifecycle cleanup.
Performance Notes
- Capability checks should happen before upload UI is shown.
- Uploads are bounded by per-file, per-session, and per-app limits.
- Quota checks use indexed
UserFilemetadata forscope + appId,scope + sessionId, andscope + lockId. - Cleanup streams matching file records instead of loading all URLs into memory.
- Uploaded raster images are processed and stored as optimized web images.
Security Notes
- Do not treat extension-provided image URLs as trusted completion proof.
- Store only Chastify-issued file records as trusted extension files.
- Bind file records to
appId,sessionId, andlockId. - Enforce
locks:writefor uploads and deletes. - Validate MIME type and reject SVG.
- Keep admin-controlled quotas enabled before allowing broad public usage.
- Use server-side validation for any workflow that depends on uploaded files.
Direct Routes vs Bridge
Use the iframe bridge when your extension is running inside Chastify. It keeps Chastify authentication in the parent page and avoids exposing route details to the iframe.
Use direct session routes only from first-party Chastify UI or trusted backend flows that already have valid extension session authorization.