Skip to content

fix(storage): Allow custom R2 endpoints for jurisdictional buckets#14848

Open
remihuigen wants to merge 5 commits intotoeverything:canaryfrom
remihuigen:fix-r2-storage-config
Open

fix(storage): Allow custom R2 endpoints for jurisdictional buckets#14848
remihuigen wants to merge 5 commits intotoeverything:canaryfrom
remihuigen:fix-r2-storage-config

Conversation

@remihuigen
Copy link
Copy Markdown

@remihuigen remihuigen commented Apr 17, 2026

Summary

This PR fixes cloudflare-r2 storage configuration so jurisdictional R2 endpoints (for example EU buckets) work correctly.

Closes #14847

Problem

cloudflare-r2 currently ignores config.endpoint and always uses:

https://<accountId>.r2.cloudflarestorage.com

That breaks uploads for jurisdictional buckets that require endpoints like:

https://<accountId>.eu.r2.cloudflarestorage.com

Changes

  • Updated R2StorageProvider endpoint resolution:
    • use config.endpoint when provided
    • otherwise fall back to https://${accountId}.r2.cloudflarestorage.com
  • Kept forcePathStyle: true behavior unchanged
  • Updated validation to require accountId or endpoint
  • Improved storage schema descriptions to mention jurisdiction endpoints
  • Added focused unit tests for:
    • default account endpoint behavior
    • custom jurisdiction endpoint behavior

Backward Compatibility

  • Existing R2 configs that only provide accountId continue to work exactly as before.
  • New behavior only applies when a custom config.endpoint is explicitly set.

Tests

  • Added: packages/backend/server/src/base/storage/__tests__/r2.spec.ts
  • Verifies both default and custom endpoint selection paths.

Disclaimer: parts of this PR were implemented with AI assistance.

Summary by CodeRabbit

  • New Features

    • Cloudflare R2 config adds an optional "jurisdiction" (EU) option and consistent endpoint derivation for S3-compatible providers.
  • Documentation

    • Storage configuration schemas clarified: S3 endpoint is optional/derived from region; R2 endpoint removed from schema and jurisdiction documented.
  • Tests

    • Added tests validating R2 endpoint selection for default, EU-jurisdiction, undefined-jurisdiction, and missing-account scenarios.

@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 17, 2026

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions Bot added app:server test Related to test cases labels Apr 17, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

R2 storage provider now omits public endpoint/forcePathStyle in config, adds optional jurisdiction?: 'eu', and computes the final R2 endpoint from accountId (appending .eu when jurisdiction is 'eu') before constructing the S3 provider. New AVA tests validate the resolved endpoint URLs.

Changes

Cohort / File(s) Summary
R2 provider implementation
packages/backend/server/src/base/storage/providers/r2.ts
Changed R2StorageConfig to `Omit<S3StorageConfig, 'endpoint'
S3 provider endpoint handling
packages/backend/server/src/base/storage/providers/s3.ts
Added URL composition helpers, stored composed endpoint in a private field, and exposed it via a new public getter endpointUrl to return the final composed endpoint (handles virtual-host vs path-style bucket placement).
Provider schema & self-host manifest
packages/backend/server/src/base/storage/providers/index.ts, .docker/selfhost/schema.json
Removed endpoint from cloudflare-r2 schema entries, updated aws-s3 endpoint description to indicate it is optional/derived from region, and added jurisdiction enum (["eu"]) to Cloudflare R2 schema blocks across storage sections.
Tests
packages/backend/server/src/base/storage/__tests__/r2.spec.ts
Added AVA tests asserting provider construction/validation: default endpoint uses {accountId}.r2.cloudflarestorage.com/{bucket}, jurisdiction: 'eu' yields {accountId}.eu.r2.cloudflarestorage.com/{bucket}, throws when accountId missing, and jurisdiction: undefined falls back to default account host form.

Sequence Diagram(s)

sequenceDiagram
    participant Test
    participant R2Provider
    participant EndpointBuilder as "Endpoint Builder"
    participant S3Provider

    Test->>R2Provider: construct(accountId, bucket, jurisdiction?)
    R2Provider->>EndpointBuilder: derive account host (accountId[ + ".eu" if jurisdiction==='eu'])
    EndpointBuilder-->>R2Provider: computed host
    R2Provider->>S3Provider: instantiate with endpoint=https://{host}/ and forcePathStyle=true
    Test->>S3Provider: inspect endpointUrl -> contains bucket path
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~18 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main change: introducing jurisdiction support for R2 endpoints to fix storage configuration issues.
Linked Issues check ✅ Passed All objectives from issue #14847 are addressed: jurisdiction endpoints are supported via a new jurisdiction option, forcePathStyle is preserved, and fallback to default endpoint is maintained.
Out of Scope Changes check ✅ Passed All changes are directly related to R2 storage configuration and jurisdiction endpoint support as specified in the linked issue.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/backend/server/src/base/storage/providers/r2.ts (1)

18-25: ⚠️ Potential issue | 🟠 Major

Make accountId optional in R2StorageConfig to match the new runtime contract.

The constructor now accepts configs that provide only endpoint (no accountId), but the type still declares accountId: string as required. TypeScript consumers configuring a jurisdictional bucket with just endpoint — the main use case of this PR — will hit a type error, defeating the fix. The runtime assert on line 35-38 already enforces the "at least one" invariant, so the static type should be loosened accordingly.

🔧 Proposed fix
 export interface R2StorageConfig extends S3StorageConfig {
-  accountId: string;
+  accountId?: string;
   usePresignedURL?: {
     enabled: boolean;
     urlPrefix?: string;
     signKey?: string;
   };
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/base/storage/providers/r2.ts` around lines 18 -
25, The R2StorageConfig type incorrectly requires accountId while the runtime
constructor accepts configs with only endpoint; change accountId to optional in
the R2StorageConfig interface so the static type matches the runtime invariant
enforced by the constructor's assert (refer to R2StorageConfig and the
constructor/assert that checks endpoint/accountId). Update the interface
definition to make accountId?: string and leave the existing runtime assert
(lines that validate endpoint or accountId) unchanged so callers can pass only
endpoint without a TypeScript error.
🧹 Nitpick comments (1)
packages/backend/server/src/base/storage/__tests__/r2.spec.ts (1)

29-47: Consider an endpoint-only test case.

Both tests pass accountId; neither exercises the new "endpoint without accountId" path that this PR enables. Adding a case with only endpoint set would (a) guard against regressions of the assert branch, and (b) catch the R2StorageConfig type issue flagged in r2.ts (required accountId).

🧪 Suggested additional test
+test('R2 provider should accept endpoint without accountId', t => {
+  const provider = new R2StorageProvider(
+    {
+      endpoint: 'https://test-account.eu.r2.cloudflarestorage.com',
+      region: 'auto',
+      credentials: {
+        accessKeyId: 'test',
+        secretAccessKey: 'test',
+      },
+    },
+    'test-bucket'
+  );
+
+  t.is(
+    endpointOf(provider),
+    'https://test-account.eu.r2.cloudflarestorage.com/test-bucket'
+  );
+});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/base/storage/__tests__/r2.spec.ts` around lines
29 - 47, Add a new unit test that covers the "endpoint without accountId" path:
instantiate R2StorageProvider with a config that sets only endpoint (no
accountId) plus region/credentials and the same 'test-bucket', then assert
endpointOf(provider) equals the endpoint + '/test-bucket'; this will exercise
the assert branch and surface the R2StorageConfig typing issue flagged in r2.ts
(refer to R2StorageProvider, endpointOf, and R2StorageConfig).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/backend/server/src/base/storage/providers/r2.ts`:
- Around line 18-25: The R2StorageConfig type incorrectly requires accountId
while the runtime constructor accepts configs with only endpoint; change
accountId to optional in the R2StorageConfig interface so the static type
matches the runtime invariant enforced by the constructor's assert (refer to
R2StorageConfig and the constructor/assert that checks endpoint/accountId).
Update the interface definition to make accountId?: string and leave the
existing runtime assert (lines that validate endpoint or accountId) unchanged so
callers can pass only endpoint without a TypeScript error.

---

Nitpick comments:
In `@packages/backend/server/src/base/storage/__tests__/r2.spec.ts`:
- Around line 29-47: Add a new unit test that covers the "endpoint without
accountId" path: instantiate R2StorageProvider with a config that sets only
endpoint (no accountId) plus region/credentials and the same 'test-bucket', then
assert endpointOf(provider) equals the endpoint + '/test-bucket'; this will
exercise the assert branch and surface the R2StorageConfig typing issue flagged
in r2.ts (refer to R2StorageProvider, endpointOf, and R2StorageConfig).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7bc89415-a0d7-4ab7-b8f7-30368d00e9e8

📥 Commits

Reviewing files that changed from the base of the PR and between f7d0f1d and 91c352d.

📒 Files selected for processing (3)
  • packages/backend/server/src/base/storage/__tests__/r2.spec.ts
  • packages/backend/server/src/base/storage/providers/index.ts
  • packages/backend/server/src/base/storage/providers/r2.ts

@darkskygit
Copy link
Copy Markdown
Member

i prefer to add a dedicated option specifically to indicate the region but not the endpoint, e.g., jurisdictional: 'eu' | undefined

@remihuigen
Copy link
Copy Markdown
Author

@darkskygit I removed the custom endpoint override path for cloudflare-r2 and introduced a dedicated jurisdiction option instead ('eu' | undefined),a s suggested. The R2 endpoint is now always constructed from accountId (+ jurisdiction when set), while keeping path-style addressing.

I also updated the related schema/docs/tests so everything is aligned.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
.docker/selfhost/schema.json (1)

490-496: Jurisdiction enum is tightly coupled to the TS type — keep both in sync.

The enum: ["eu"] here (and in the duplicated blob/copilot blocks at lines 688–693 and 1335–1340) mirrors the jurisdiction?: 'eu' type in R2StorageProvider. Cloudflare R2 also exposes a fedramp jurisdiction; if/when that is added to R2StorageConfig, remember to extend all three schema occurrences as well. No action required now.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.docker/selfhost/schema.json around lines 490 - 496, The schema's
"jurisdiction" enum is duplicated in three places and must be kept in sync with
the TypeScript type R2StorageProvider / R2StorageConfig; when adding a new
jurisdiction value (e.g., "fedramp") to the TS type, update the "enum" arrays in
all three schema occurrences (the primary jurisdiction block and the duplicated
blob/copilot blocks) so they include the new value; ensure the enum strings
exactly match the TS literal values.
packages/backend/server/src/base/storage/__tests__/r2.spec.ts (2)

10-47: Consider covering the assert(config.accountId, ...) path.

The constructor in r2.ts asserts accountId is required, but there's no test for that failure mode, nor for the non-EU default path explicitly distinguishing "no jurisdiction field" from "jurisdiction: undefined". A single t.throws(() => new R2StorageProvider({ ... } as any, 'b')) case would lock in the current contract cheaply.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/base/storage/__tests__/r2.spec.ts` around lines
10 - 47, Add a test that asserts the constructor's required accountId path by
calling new R2StorageProvider with a config lacking accountId (e.g., { region:
'auto', credentials: {...} } as any) and expect it to throw (use t.throws(() =>
new R2StorageProvider(..., 'b'))); also add a short test that passes
jurisdiction: undefined explicitly to R2StorageProvider and assert
endpointOf(...) matches the non-EU default to distinguish "no jurisdiction
field" from "jurisdiction: undefined".

5-8: endpointOf helper couples tests to internal S3 Compat client shape.

The test accesses client.endpoint (a private field of the S3Compat instance) via an unknown cast, relying on undocumented internal structure. The endpoint is correctly composed with the bucket via buildEndpoint() in the s3-compat library, so the test assertions are accurate. However, this tight coupling to the client's internal shape means the test will break silently if the s3-compat implementation changes (e.g., if endpoint is removed or renamed).

Consider exposing a protected getter on S3StorageProvider (e.g., get endpointUrl()) and asserting against that instead — it decouples the test from library internals while maintaining the same test coverage.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/base/storage/__tests__/r2.spec.ts` around lines 5
- 8, The test helper endpointOf currently reaches into the private S3Compat
shape via a cast; add a protected (or public for test access) getter on
S3StorageProvider named endpointUrl that returns the composed endpoint URL
string (delegating to the provider's existing endpoint-building logic), then
update endpointOf to call provider.endpointUrl (or cast to S3StorageProvider and
call get endpointUrl) instead of accessing client.endpoint; this removes
dependency on s3-compat internals while preserving existing assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In @.docker/selfhost/schema.json:
- Around line 490-496: The schema's "jurisdiction" enum is duplicated in three
places and must be kept in sync with the TypeScript type R2StorageProvider /
R2StorageConfig; when adding a new jurisdiction value (e.g., "fedramp") to the
TS type, update the "enum" arrays in all three schema occurrences (the primary
jurisdiction block and the duplicated blob/copilot blocks) so they include the
new value; ensure the enum strings exactly match the TS literal values.

In `@packages/backend/server/src/base/storage/__tests__/r2.spec.ts`:
- Around line 10-47: Add a test that asserts the constructor's required
accountId path by calling new R2StorageProvider with a config lacking accountId
(e.g., { region: 'auto', credentials: {...} } as any) and expect it to throw
(use t.throws(() => new R2StorageProvider(..., 'b'))); also add a short test
that passes jurisdiction: undefined explicitly to R2StorageProvider and assert
endpointOf(...) matches the non-EU default to distinguish "no jurisdiction
field" from "jurisdiction: undefined".
- Around line 5-8: The test helper endpointOf currently reaches into the private
S3Compat shape via a cast; add a protected (or public for test access) getter on
S3StorageProvider named endpointUrl that returns the composed endpoint URL
string (delegating to the provider's existing endpoint-building logic), then
update endpointOf to call provider.endpointUrl (or cast to S3StorageProvider and
call get endpointUrl) instead of accessing client.endpoint; this removes
dependency on s3-compat internals while preserving existing assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cf2e587e-af98-4fa2-a203-ad9bdb705ebb

📥 Commits

Reviewing files that changed from the base of the PR and between 91c352d and ae9e3e5.

📒 Files selected for processing (4)
  • .docker/selfhost/schema.json
  • packages/backend/server/src/base/storage/__tests__/r2.spec.ts
  • packages/backend/server/src/base/storage/providers/index.ts
  • packages/backend/server/src/base/storage/providers/r2.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/backend/server/src/base/storage/providers/r2.ts
  • packages/backend/server/src/base/storage/providers/index.ts

… handling and decoupling tests from S3 internals
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/backend/server/src/base/storage/providers/index.ts (1)

96-105: Filter forcePathStyle out of the R2 schema too.

R2StorageConfig omits both endpoint and forcePathStyle, and R2StorageProvider always forces forcePathStyle: true. This helper only removes endpoint, so the cloudflare-r2 JSON schema still advertises a no-op/unsupported option.

Suggested schema alignment
-const S3ConfigPropertiesWithoutEndpoint = Object.fromEntries(
+const R2ConfigPropertiesFromS3 = Object.fromEntries(
   Object.entries(
     (
       S3ConfigSchema as {
         type: 'object';
         properties?: Record<string, JSONSchema>;
       }
     ).properties ?? {}
-  ).filter(([key]) => key !== 'endpoint')
+  ).filter(([key]) => key !== 'endpoint' && key !== 'forcePathStyle')
 ) as Record<string, JSONSchema>;
-            ...S3ConfigPropertiesWithoutEndpoint,
+            ...R2ConfigPropertiesFromS3,

Also applies to: 152-155

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/backend/server/src/base/storage/providers/index.ts` around lines 96
- 105, The helper S3ConfigPropertiesWithoutEndpoint only filters out 'endpoint'
from S3ConfigSchema but must also exclude 'forcePathStyle' so the
R2StorageConfig/R2StorageProvider schema does not expose the unsupported option;
update the filter used when building S3ConfigPropertiesWithoutEndpoint (and the
other analogous helper later in the file) to remove both 'endpoint' and
'forcePathStyle' (e.g., change the filter predicate to exclude keys ===
'endpoint' || keys === 'forcePathStyle') so the cloudflare-r2 JSON schema
matches R2StorageConfig and R2StorageProvider behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/backend/server/src/base/storage/providers/index.ts`:
- Around line 96-105: The helper S3ConfigPropertiesWithoutEndpoint only filters
out 'endpoint' from S3ConfigSchema but must also exclude 'forcePathStyle' so the
R2StorageConfig/R2StorageProvider schema does not expose the unsupported option;
update the filter used when building S3ConfigPropertiesWithoutEndpoint (and the
other analogous helper later in the file) to remove both 'endpoint' and
'forcePathStyle' (e.g., change the filter predicate to exclude keys ===
'endpoint' || keys === 'forcePathStyle') so the cloudflare-r2 JSON schema
matches R2StorageConfig and R2StorageProvider behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 20d73c27-52ce-4a78-b792-b0a3f59af15a

📥 Commits

Reviewing files that changed from the base of the PR and between ae9e3e5 and e45a7b0.

📒 Files selected for processing (4)
  • packages/backend/server/src/base/storage/__tests__/r2.spec.ts
  • packages/backend/server/src/base/storage/providers/index.ts
  • packages/backend/server/src/base/storage/providers/r2.ts
  • packages/backend/server/src/base/storage/providers/s3.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/backend/server/src/base/storage/providers/r2.ts

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 21, 2026

Codecov Report

❌ Patch coverage is 88.00000% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 56.95%. Comparing base (cc79fa3) to head (e45a7b0).
⚠️ Report is 2 commits behind head on canary.

Files with missing lines Patch % Lines
...es/backend/server/src/base/storage/providers/s3.ts 78.37% 8 Missing ⚠️
...backend/server/src/base/storage/providers/index.ts 95.83% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           canary   #14848      +/-   ##
==========================================
- Coverage   57.41%   56.95%   -0.46%     
==========================================
  Files        3125     3125              
  Lines      169161   169231      +70     
  Branches    25032    24912     -120     
==========================================
- Hits        97120    96387     -733     
- Misses      68667    69577     +910     
+ Partials     3374     3267     -107     
Flag Coverage Δ
server-test 76.47% <88.00%> (-0.85%) ⬇️
unittest 34.24% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@darkskygit darkskygit force-pushed the canary branch 5 times, most recently from 3403237 to 78a9942 Compare April 29, 2026 11:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app:server test Related to test cases

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Bug]: cloudflare-r2 storage provider ignores custom endpoint, breaking EU jurisdiction R2 buckets

3 participants