Skip to main content

Extension File Storage

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"
  • appId
  • sessionId and lockId for runtime/session files
  • templateId for files claimed by a saved lock template
  • staged, draftId, and expiresAt for setup uploads that are not claimed yet
  • extensionKey
  • purpose

Admin Gate And Quotas

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 file
  • purpose: optional short identifier such as jigsaw-config-image
  • draftId: 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 case
  • draftId: 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, and urlExpiresAt fields 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 file
  • purpose: optional short identifier such as puzzle-image or preview

Example:

curl "https://chastify.net/api/extensions/sessions/SESSION_ID/files" \
-X POST \
-F "purpose=puzzle-image" \

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_disabled
  • extension_file_storage_requires_r2
  • file_too_large
  • invalid_file_type
  • extension_app_storage_quota_exceeded
  • extension_session_storage_quota_exceeded
  • extension_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 apply
  • files.list -> list files owned by the current extension session
  • files.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.

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 UserFile metadata for scope + appId, scope + sessionId, and scope + 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, and lockId.
  • Enforce locks:write for 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.