R2 File Storage Spec β h.work
Status (2026-06-01): β Shipped. R2 avatar migration is complete via #922; in-repo avatar assets have been removed. The bucket-separation model below is live in all envs.
Overviewβ
Cloudflare R2 for file storage with bucket separation for security (#625):
- Assets bucket (
hwork-{env}-assets): Public CDN for avatars and system files - Client bucket (
hwork-{env}-clients): Private signed URLs for org-scoped attachments
This separation ensures client data isolation β Client A cannot access Client B's files, even with URL guessing, because attachments use separate storage with org-scoped access.
Buckets per environment:
| Environment | Assets Bucket | Client Bucket | Public CDN |
|---|---|---|---|
| dev | hwork-dev-assets | hwork-dev-clients | pub-...r2.dev |
| staging | hwork-staging-assets | hwork-staging-clients | pub-...r2.dev |
| production | hwork-assets | hwork-clients | pub-...r2.dev |
Folder Structureβ
hwork[-dev|-staging]/
βββ orgs/
β βββ {orgId}/
β βββ specialists/
β β βββ {specialistId}/
β β βββ avatar/
β β βββ {uuid}.{ext} # Specialist persona avatars
β βββ attachments/
β βββ {convId}/
β βββ {uuid}.{ext} # Message attachments
βββ users/
βββ {userId}/
βββ avatar/
βββ {uuid}.{ext} # Expert/AM profile photos
Key decisionsβ
- UUID filenames β original filenames discarded. Prevents enumeration, path traversal, and collisions.
- Separate buckets per env β dev/staging/production each have their own bucket and their own R2 API key. A dev credential gives zero access to staging or production data.
- Org-scoped folders β everything client-related lives under
orgs/{orgId}/. Easy to audit, easy to delete on org churn. - No per-user subfolders for attachments β conversations are shared within an org, so attachments live under
orgs/{orgId}/attachments/{convId}/.
Access Controlβ
Public read (via CDN): Specialist avatars, Expert profile photos β these are displayed to end users. Served via https://cdn.h852.work/{path}`` (or cdn.h.work in production).
Private (signed URLs): Message attachments β only accessible to members of the org. Backend generates a signed URL valid for 1 hour.
Write: Only the API writes to R2. Never direct browser-to-R2 uploads (prevents auth bypass). Flow:
- Client POSTs file to
POST /uploadwithAuthorization: Bearer{jwt}`` - API validates auth, generates UUID, uploads to R2
- API returns the CDN URL (public) or a path (private, to generate signed URLs later)
API Designβ
POST /uploadβ
- Auth: JWT required
- Body:
multipart/form-datawithfilefield - Query params:
type=avatar | attachment | specialist-avatar - Returns:
{ url: string, key: string }
The type param determines:
- Which folder the file goes in
- Whether it's public or private
- Max file size (avatars: 2MB, attachments: 25MB)
- Allowed MIME types (avatars: image/, attachments: image/ | application/pdf | text/*)
Org-scoped upload validationβ
- For
type=attachment: JWT must include orgId matching the convId's org (RLS check) - For
type=avatar: only the user themselves or superadmin can upload their own avatar - For
type=specialist-avatar: only superadmin or the specialist's assigned AM
Implementation Planβ
Phase 1 β Backend (MVP) β β Shipped via #922β
- Create R2 bucket in Cloudflare dashboard
- Set up Cloudflare CDN subdomain:
cdn.h852.workβ R2 bucket (public read for avatars folder) - Add env vars:
R2_ACCOUNT_ID,R2_ACCESS_KEY_ID,R2_SECRET_ACCESS_KEY,R2_BUCKET_NAME,CDN_BASE_URL - Install
@aws-sdk/client-s3(R2 is S3-compatible) - Create
api/src/upload/upload.service.tswithuploadFile(buffer, key, mimeType)method - Create
api/src/upload/upload.controller.tswithPOST /uploadendpoint - Add
UploadModuleto AppModule
Phase 2 β Avatar upload UIβ
- Expert/AM profile settings: file input β preview β upload β save URL
- Specialist management: avatar upload in SuperAdmin Specialists page
- Store returned CDN URL in
User.avatarUrlorSpecialist.avatarUrl
Phase 3 β Message attachments (future)β
- Attachment upload in MessageComposer
- Signed URL generation for download
- Display in MessageBubble
Env Varsβ
# Cloudflare R2 β values differ per Railway environment
R2_ACCOUNT_ID=a839d58d7ee4202937b2c70402531510
R2_ACCESS_KEY_ID=<env-specific key> # Create in CF dashboard
R2_SECRET_ACCESS_KEY=<env-specific> # Create in CF dashboard
# Bucket separation per #625
R2_ASSETS_BUCKET=hwork-dev-assets # Public CDN for avatars
R2_CLIENT_BUCKET=hwork-dev-clients # Private for attachments
R2_PUBLIC_URL=https://pub-19424a4a2ab14b6891cc2ed5007534c1.r2.dev # per-env
# Deprecated (for backwards compatibility only):
# R2_BUCKET_NAME β use R2_ASSETS_BUCKET instead
Security Notesβ
- Never expose R2 credentials to the frontend
- UUID filenames prevent guessing other users' files
- Org-scoped folders enable easy data deletion on client churn (delete
orgs/{orgId}/) - CDN caches public files β cache invalidation needed on avatar update (use cache-busting query param or versioned filenames)
- Attachment downloads always go through the API (signed URLs), never direct R2 access
β Per-Org Credential Isolation β Shipped (#625, PR #2030)β
2026-06-05 update: Per-org R2 bucket routing with validation shipped via PR #2030 (commit
bff6e5aa, closes #625). The "MVP risk accepted" framing below is now historical.
Was (pre-#2030): The API used a single global R2 credential pair (R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY) with access to ALL buckets, accepted as MVP risk:
- API compromise exposes all client data across all orgs
- Cannot rotate credentials for a single org without rotating for all
- Cannot revoke access for a compromised org without affecting platform
- No audit trail of which org's data was accessed by which credential
Now (PR #2030): Per-org R2 bucket routing with validation is live. Each client org has its own dedicated bucket, and the upload path validates the (org, bucket) pairing before any object operation. Credentials are scoped per-bucket where possible; per-org credential rotation and audit logging follow the integration_credentials pattern.
Historical: post-MVP recommendation that was implemented:
- Store encrypted
R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEYper org (where scoped tokens are used) inintegration_credentialstable - Create separate Cloudflare API tokens scoped to each org's bucket only
- Use per-org creds when uploading to that org's client bucket
- Enable per-org rotation and revocation without platform impact
- Add audit logging of credential usage per org
GitHub Issue Referenceβ
- Issue #156 β Avatar image upload
- Issue #625 β R2 bucket separation for client data isolation
Statusβ
Phase 1 backend code complete. UploadService implemented with bucket separation.
Ready to deploy once:
- Create
hwork-dev-assetsbucket in Cloudflare dashboard - Create
hwork-dev-clientsbucket in Cloudflare dashboard - Create S3 API tokens scoped to each bucket
- Add env vars to Railway:
R2_ASSETS_BUCKET,R2_CLIENT_BUCKET
Migration from old single-bucket setup:
- Create new bucket pair per environment
- Copy existing avatars from old bucket to
hwork-{env}-assets - Update env vars
- Restart API service
- (Optional) Delete old single bucket after migration cooldown