Skip to main content

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:

EnvironmentAssets BucketClient BucketPublic CDN
devhwork-dev-assetshwork-dev-clientspub-...r2.dev
staginghwork-staging-assetshwork-staging-clientspub-...r2.dev
productionhwork-assetshwork-clientspub-...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:

  1. Client POSTs file to POST /upload with Authorization: Bearer {jwt}``
  2. API validates auth, generates UUID, uploads to R2
  3. 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-data with file field
  • 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.ts with uploadFile(buffer, key, mimeType) method
  • Create api/src/upload/upload.controller.ts with POST /upload endpoint
  • Add UploadModule to 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.avatarUrl or Specialist.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:

  1. Store encrypted R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY per org (where scoped tokens are used) in integration_credentials table
  2. Create separate Cloudflare API tokens scoped to each org's bucket only
  3. Use per-org creds when uploading to that org's client bucket
  4. Enable per-org rotation and revocation without platform impact
  5. 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-assets bucket in Cloudflare dashboard
  • Create hwork-dev-clients bucket 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:

  1. Create new bucket pair per environment
  2. Copy existing avatars from old bucket to hwork-{env}-assets
  3. Update env vars
  4. Restart API service
  5. (Optional) Delete old single bucket after migration cooldown