Collections content ends up unauthenticated on your public website (via the embed widget) and is also served verbatim through the public JSON API. The security model is built around what that actually means.
Trust boundary: owners write, the public reads
The public surface assumes the author is the workspace owner, but the rendered output is visible to anyone. So inputs the owner controls are still treated as untrusted for the purposes of what gets rendered on a stranger's page.
Rich-text sanitization
Every richtext field value is sanitized at write time, before it is stored — not trusted just
because the author is the owner. The sanitizer is a server-side allowlist (Workers-native
HTMLRewriter, no browser dependency):
- Dropped entirely:
script,style,iframe,object,embed,form,input,button,svg,math. - Allowed:
p,br,strong,b,em,i,ul,ol,li,a,span. - Unknown tags are unwrapped — their text is kept but the element is removed.
- Attributes are stripped on every tag (which also kills
on*event-handler attributes), except a vettedhrefon<a>.hrefvalues starting withjavascript:,data:, orvbscript:are removed, and links getrel="noopener noreferrer"+target="_blank".
You should still treat the stored HTML as trusted-ish but scope any innerHTML you inject to a
container you own; sanitization is a defense-in-depth layer, not a license to skip safe rendering.
Query isolation & tenant scoping
The administrative surface is tenant-isolated: every read and write filters on tenant_id
(plus deleted_at = null). Passing another tenant's collection or entry id yields 404, never
another tenant's data. The public path has no tenant scope because the collection_id itself is the
credential.
Allowed origins (public read)
The optional CORS allowlist restricts which websites may embed a collection. Empty = any origin.
Because embeddability is the point, the rule is deliberately permissive: a request without an
Origin header (curl, server-side fetches, build-time SSG) is always allowed — the allowlist is
about which websites can load it, not a general auth boundary.
Soft deletes & lifecycle
Deleting a collection or entry is a soft delete / archive:
- The row keeps a
deleted_attimestamp and is excluded from every query (public and admin). - Deleting a collection also soft-deletes its entries to avoid orphans.
- A soft-deleted collection is set
status: archivedand returns404from the public API. - Soft deletes are not reversible through the API (no restore endpoint).
Data is never hard-deleted by this service's API — permanent removal is handled at the database layer by the platform.
Data-at-rest & infrastructure
Collections runs on Cloudflare Workers with a D1 (SQLite) database, its own dedicated binding —
the only D1-backed service in the platform. There are no cross-service joins. Access to the worker
from outside travels only through the gateway, which rejects any request without a valid
gateway key (403 Direct access forbidden).
Observability
Service logs are emitted as structured JSON lines. Unhandled errors return a generic message to the
public and put the real detail (plus a request_id) in logs. The public error envelope never
leaks a raw exception string.