Security

Security model

Trust boundaries, content sanitization, origin restrictions, and data lifecycle.

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 vetted href on <a>. href values starting with javascript:, data:, or vbscript: are removed, and links get rel="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_at timestamp 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: archived and returns 404 from 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.

Collections · SecurityEdit on GitHub