Skip to main content
CDN is optional pluggable file storage: GraphQL uploads and backend code call tools.cdn to store files and resolve delivery URLs without importing a vendor SDK.

Why

Enable CDN when you ship upload features (profile pictures, attachments). Omit tools.cdn entirely when uploads are disabled — the backend does not load a CDN client and mutations return a friendly “Upload is not enabled” response.

How it works

Optional config

When tools.cdn is absent from configuration:
  • buildTools does not import or construct a CDN client (tools.cdn is undefined).
  • Upload mutations check if (cdn === undefined) and return { success: false, reason: 'Upload is not enabled' } — see apps/backend/src/gql/mutations/updateProfilePicture.ts.
When tools.cdn is present, the loader dynamic-imports the implementation package for config.client.

Per-environment defaults

Two URLs (object storage providers)

Object storage adapters use two unrelated URLs: See Cloudflare R2 for Cloudflare-specific setup.

Private buckets and organization files

Bucket visibility is public (default) or private. Public buckets require publicUrl; private buckets forbid it and getFileUrl fails. Signed download/upload, statFile, copyFile, and purgeFile live on CDNType. A public file’s stable URL is world-readable. Anyone with the URL can fetch the bytes with no session and no scope. Feature scopes (for example loop:attachment:view) only gate learning that URL from that feature’s resolver. organizationFiles (apps/backend/src/utilities/organizationFiles/) is an internal domain module. Features call it after their own scope checks. It enforces org, feature, resource id, access_scope (organization | uploader), locks, staging keys (pending/… only), TTLs, and content-type allowlists. It registers no scopes, no GraphQL, and no file-management HTTP route. access_scope=uploader is private only. Private org objects and all staging objects use organization-files-private. Public org files use organization-files-public. Profile, article, and blog buckets are unchanged. Signed GET TTL defaults to 300s (Cache-Control: private, no-store). Staging PUT TTL defaults to 120s. An already-issued signed URL stays valid until expiry after an access or visibility change (at most 300s). Private → public keeps the private object until residual_delete_after (300s); a minute schedule deletes residuals. Staging deletes run immediately and again after the upload TTL via a delayed queue job. Local /cdn serves public GET, signed private GET, and signed staging PUT. With Cloudflare R2, private signed downloads also use the HMAC /cdn proxy (not raw R2 presigned GETs) so the response can set X-Content-Type-Options: nosniff. Unsigned private GET is 403. Responses set nosniff and Content-Disposition (inline for jpeg/png/webp/gif; attachment otherwise).

Extensibility

Future work stays compatible with today’s contract:
  • AWS S3 — new CDNClientType + tooling-cdn-s3 package
  • Access audiences — additional access_scope enum labels (team, explicit user lists)

Adding a new bucket

  1. Add a constant in apps/backend/src/constants/cdn.ts (e.g. CDN_BUCKET_ATTACHMENTS).
  2. Create the bucket in your object-storage provider and configure public delivery (publicUrl).
  3. Add a buckets[] entry in production.ts / staging.ts (and env vars for publicUrl).
  4. Call tools.cdn.uploadFile(file, CDN_BUCKET_ATTACHMENTS) from the resolver or utility.
  5. Run make test module=backend.

Adding a new CDN provider

  1. Add CDNClientType value and Zod schema branch in apps/shared/src/cdn/.
  2. Create apps/tools/cdn/<impl>/ implementing CDNType.
  3. Add a case in apps/backend/src/tools/cdn/loader.ts with exhaustive never check.
  4. Add tooling-cdn-<impl> to apps/backend/package.json and Makefile ALL_FILTERS.
  5. Document under apps/documentation/tooling/cdn/.
  6. Run make deps-install and make test module=tooling-cdn-<impl>.

Object lifecycle (no in-place overwrite)

CDNType exposes uploadFile, deleteFile, plus signed URL / statFile / copyFile / purgeFile for private and org-file flows. There is no replaceFile or in-place update. That is intentional for public, cacheable buckets. Blog article saves write {articleId}/{timestamp}.mdx, update the DB content_path, then delete the previous object — see Blog system — CDN lifecycle. Persist bucket + id (object key) in the database, not the public URL. Delivery URLs are derived at read time via getFileUrl.

Gotchas

  • Local stub URLs (local-cdn://…) are not fetchable in <img> tags — use R2 in staging or accept broken previews in dev.
  • Do not use per-user dynamic bucket names — use fixed logical names from constants.
  • Omit tools.cdn in production only when upload features are intentionally disabled.
  • Blog editorial churn — each article content save costs two mutating CDN operations when replacing existing content; plan for R2 request billing if authors save frequently.

What’s next?