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). Omittools.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
Whentools.cdn is absent from configuration:
buildToolsdoes not import or construct a CDN client (tools.cdnisundefined).- Upload mutations check
if (cdn === undefined)and return{ success: false, reason: 'Upload is not enabled' }— seeapps/backend/src/gql/mutations/updateProfilePicture.ts.
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
Bucketvisibility 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-s3package - Access audiences — additional
access_scopeenum labels (team, explicit user lists)
Adding a new bucket
- Add a constant in
apps/backend/src/constants/cdn.ts(e.g.CDN_BUCKET_ATTACHMENTS). - Create the bucket in your object-storage provider and configure public delivery (
publicUrl). - Add a
buckets[]entry inproduction.ts/staging.ts(and env vars forpublicUrl). - Call
tools.cdn.uploadFile(file, CDN_BUCKET_ATTACHMENTS)from the resolver or utility. - Run
make test module=backend.
Adding a new CDN provider
- Add
CDNClientTypevalue and Zod schema branch inapps/shared/src/cdn/. - Create
apps/tools/cdn/<impl>/implementingCDNType. - Add a
caseinapps/backend/src/tools/cdn/loader.tswith exhaustivenevercheck. - Add
tooling-cdn-<impl>toapps/backend/package.jsonandMakefileALL_FILTERS. - Document under
apps/documentation/tooling/cdn/. - Run
make deps-installandmake 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.cdnin 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?
- Local CDN — dev/test noop client
- Cloudflare R2 — production object storage
- Configuration — swapping pluggable tools
- Tooling system — loaders and workspace packages
- Cloudflare deployment — DNS, TLS, and CDN in front of your app
