Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/content/docs/api/configuration/upload-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ createUploadConfig().provider("cloudflareR2",{ // [!code highlight]
})
```

**Public R2 bucket with custom domain:**

```typescript title="R2 Public Bucket"
createUploadConfig().provider("cloudflareR2", {
// ...credentials
customDomain: 'https://cdn.yourdomain.com', // [!code highlight]
visibility: 'public', // [!code highlight]
})
```

When `visibility: 'public'` is set, pushduck returns the custom domain URL directly after upload instead of a presigned URL. For R2, `customDomain` is **required** when `visibility: 'public'` — R2 presigned URLs cannot be used with custom domains (TypeScript will enforce this).

### AWS S3

```typescript title="AWS S3 Configuration"
Expand Down Expand Up @@ -112,6 +124,35 @@ createUploadConfig().provider("s3Compatible",{
})
```

## Bucket Visibility

The `visibility` option controls what kind of download URL pushduck returns after a file is uploaded.

```typescript
createUploadConfig().provider("aws", {
// ...
visibility: 'private', // default
})
```

| Value | Download URL returned | When to use |
|-------|----------------------|-------------|
| `'private'` (default) | Presigned GET URL (time-limited, signed against S3 API endpoint) | Private buckets — most setups |
| `'public'` | Plain URL (`customDomain/key` or S3 URL) | Publicly accessible buckets |

**Important:** `visibility` must match your actual bucket configuration. Setting `visibility: 'public'` on a private bucket will return URLs that result in 403 errors. pushduck does not verify your bucket's access settings.

| Bucket type | `customDomain` | `visibility` | URL returned |
|------------|---------------|-------------|-------------|
| Private | — | `'private'` | Presigned GET (S3 API endpoint) |
| Private | CDN | `'private'` | Presigned GET (S3 API endpoint — CDN bypassed) |
| Public | — | `'public'` | Plain S3 URL |
| Public | CDN | `'public'` | `https://cdn.yourdomain.com/key` |

> **R2 note:** `visibility: 'public'` requires `customDomain` to be set. R2 presigned URLs cannot be used with custom domains — TypeScript will show an error if you omit `customDomain` when setting `visibility: 'public'` on an R2 provider.

`storage.download.presignedUrl(key, expiresIn?)` always generates a presigned URL regardless of `visibility` — useful when you explicitly need a time-limited signed URL for a specific file.

## Configuration Methods

### .defaults()
Expand Down
29 changes: 20 additions & 9 deletions docs/content/docs/providers/aws-s3.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -300,30 +300,41 @@ If your bucket is public (not recommended for production):

Set up pushduck with your AWS S3 configuration:

**Private bucket (default — recommended):**

```typescript
// lib/upload.ts
import { createUploadConfig } from "pushduck/server";

export const { s3, config } = createUploadConfig()
export const { s3 } = createUploadConfig()
.provider("aws", {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
region: process.env.AWS_REGION!,
bucket: process.env.AWS_S3_BUCKET_NAME!,
// Optional: Custom domain for public files
customDomain: process.env.S3_CUSTOM_DOMAIN,
})
.defaults({
maxFileSize: "10MB",
acl: "private", // Use 'public-read' for public files
})
.defaults({ maxFileSize: "10MB" })
.build();
```

export { s3 };
**Public bucket with CloudFront CDN:**

```typescript
export const { s3 } = createUploadConfig()
.provider("aws", {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
region: process.env.AWS_REGION!,
bucket: process.env.AWS_S3_BUCKET_NAME!,
customDomain: process.env.S3_CUSTOM_DOMAIN, // e.g. https://cdn.yourdomain.com // [!code highlight]
visibility: 'public', // returns CDN URLs after upload instead of presigned URLs // [!code highlight]
})
.defaults({ maxFileSize: "10MB" })
.build();
```

<Callout type="info">
**Custom Domain**: When `customDomain` is configured, public URLs will use your custom domain instead of the S3 URL. Internal operations (upload, delete) still use S3 endpoints.
**`visibility`**: Controls what download URL pushduck returns after upload. `'private'` (default) generates a presigned GET URL valid for 1 hour. `'public'` returns the plain CDN/S3 URL — use this only when your bucket or objects are publicly accessible. Upload signing always uses the S3 API endpoint regardless of this setting.
</Callout>

</Step>
Expand Down
34 changes: 24 additions & 10 deletions docs/content/docs/providers/cloudflare-r2.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ Add to your `.env.local`:
# Cloudflare R2 Configuration
AWS_ACCESS_KEY_ID=your_r2_access_key_id
AWS_SECRET_ACCESS_KEY=your_r2_secret_access_key
AWS_ENDPOINT_URL=https://account-id.r2.cloudflarestorage.com
AWS_REGION=auto
R2_ACCOUNT_ID=your_cloudflare_account_id
S3_BUCKET_NAME=your-bucket-name

# Optional: Custom domain for public files
Expand Down Expand Up @@ -123,26 +122,41 @@ For better performance and branding, you can use a custom domain:

## 6. Update Your Upload Configuration

**Private bucket (default):**

```typescript
// lib/upload.ts
import { createUploadConfig } from "pushduck/server";

export const { s3, } = createUploadConfig()
.provider("cloudflareR2",{
export const { s3 } = createUploadConfig()
.provider("cloudflareR2", {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
accountId: process.env.R2_ACCOUNT_ID!, // Found in R2 dashboard
accountId: process.env.R2_ACCOUNT_ID!,
bucket: process.env.S3_BUCKET_NAME!,
// Optional: Custom domain for faster access
customDomain: process.env.CLOUDFLARE_R2_CUSTOM_DOMAIN,
})
.defaults({
maxFileSize: "10MB",
acl: "public-read", // For public access
.defaults({ maxFileSize: "10MB" })
.build();
```

**Public bucket with custom domain:**

```typescript
export const { s3 } = createUploadConfig()
.provider("cloudflareR2", {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
accountId: process.env.R2_ACCOUNT_ID!,
bucket: process.env.S3_BUCKET_NAME!,
customDomain: process.env.CLOUDFLARE_R2_CUSTOM_DOMAIN!, // e.g. https://cdn.yourdomain.com // [!code highlight]
visibility: 'public', // returns CDN URLs after upload instead of presigned URLs // [!code highlight]
})
.defaults({ maxFileSize: "10MB" })
.build();
Comment on lines +131 to 155
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Align this example with the env vars documented above.

This snippet now depends on process.env.R2_ACCOUNT_ID!, but the setup section still tells users to define AWS_ENDPOINT_URL instead. As written, the guide no longer gives readers the env var they need to make this example work.

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

In `@docs/content/docs/providers/cloudflare-r2.mdx` around lines 132 - 156, The
example under provider("cloudflareR2") is inconsistent with the earlier setup:
it uses process.env.R2_ACCOUNT_ID! but the docs instruct readers to set
AWS_ENDPOINT_URL; either change the example to use the documented env var
(replace R2_ACCOUNT_ID with AWS_ENDPOINT_URL and keep accountId/endpoint mapping
consistent) or update the setup section to require R2_ACCOUNT_ID instead; adjust
the provider(...) block (accountId/endpoint/customDomain fields) and any
explanatory text so the env var names (AWS_ENDPOINT_URL or R2_ACCOUNT_ID) match
across createUploadConfig(), provider("cloudflareR2"), and the setup
instructions.

```

> **Note:** For R2, `visibility: 'public'` requires `customDomain` to be set. R2 presigned URLs cannot be used with custom domains — TypeScript enforces this at compile time.

## 7. Test Your Setup

```bash
Expand Down
213 changes: 213 additions & 0 deletions packages/pushduck/src/__tests__/download-url-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* Tests for download URL generation with visibility config
*
* Covers:
* - Private bucket (default): presigned GET signed against S3 API endpoint
* - Private bucket + custom domain: presigned GET still against S3 API, not CDN
* - Public bucket + no custom domain: plain S3 URL, no signing
* - Public bucket + custom domain: custom domain URL, no signing
* - R2 private: presigned GET against R2 API endpoint
* - R2 public + custom domain: custom domain URL, no signing
* - storage.download.presignedUrl(): always presigns regardless of visibility
*/

import { describe, expect, it } from "vitest";
import { createUploadConfig } from "../core/config/upload-config";
import {
generateDownloadUrl,
generatePresignedDownloadUrl,
} from "../core/storage/client";

// ─── Helpers ────────────────────────────────────────────────────────────────

type AWSOverrides = {
visibility?: "public" | "private";
customDomain?: string;
};

function makeAWS(overrides: AWSOverrides = {}) {
return createUploadConfig()
.provider("aws", {
accessKeyId: "test-key",
secretAccessKey: "test-secret",
region: "us-east-1",
bucket: "test-bucket",
...overrides,
})
.build().config;
}

/** Private R2 bucket — customDomain is optional */
function makeR2Private(customDomain?: string) {
return createUploadConfig()
.provider("cloudflareR2", {
accessKeyId: "test-key",
secretAccessKey: "test-secret",
accountId: "abc123",
bucket: "test-bucket",
customDomain,
})
.build().config;
}

/** Public R2 bucket — customDomain is required */
function makeR2Public(customDomain: string) {
return createUploadConfig()
.provider("cloudflareR2", {
accessKeyId: "test-key",
secretAccessKey: "test-secret",
accountId: "abc123",
bucket: "test-bucket",
visibility: "public",
customDomain,
})
.build().config;
}

const KEY = "uploads/photo.jpg";
const S3_URL = `https://test-bucket.s3.us-east-1.amazonaws.com/${KEY}`;
const R2_API_URL = `https://abc123.r2.cloudflarestorage.com/test-bucket/${KEY}`;
// customDomain must include the protocol — buildPublicUrl uses the value as-is
const CUSTOM_DOMAIN = "https://cdn.example.com";
const CDN_URL = `${CUSTOM_DOMAIN}/${KEY}`;

// ─── generateDownloadUrl — private (default) ────────────────────────────────

describe("generateDownloadUrl — private bucket (default)", () => {
it("returns a presigned URL signed against the S3 API endpoint", async () => {
const config = makeAWS();
const url = await generateDownloadUrl(config, KEY);
expect(url).toContain(S3_URL);
expect(url).toContain("X-Amz-Signature");
expect(url).toContain("X-Amz-Expires=3600");
});

it("respects custom expiresIn", async () => {
const config = makeAWS();
const url = await generateDownloadUrl(config, KEY, 300);
expect(url).toContain("X-Amz-Expires=300");
});

it("signs against S3 API endpoint even when customDomain is set", async () => {
const config = makeAWS({ customDomain: CUSTOM_DOMAIN });
const url = await generateDownloadUrl(config, KEY);
// Must use the S3 API endpoint for signing, NOT the custom domain
expect(url).toContain("s3.us-east-1.amazonaws.com");
expect(url).not.toContain("cdn.example.com");
expect(url).toContain("X-Amz-Signature");
});

it("explicit visibility: 'private' behaves the same as default", async () => {
const config = makeAWS({ visibility: "private" });
const url = await generateDownloadUrl(config, KEY);
expect(url).toContain(S3_URL);
expect(url).toContain("X-Amz-Signature");
});
});

// ─── generateDownloadUrl — public bucket ────────────────────────────────────

describe("generateDownloadUrl — public bucket", () => {
it("returns plain S3 URL with no signing when no custom domain", async () => {
const config = makeAWS({ visibility: "public" });
const url = await generateDownloadUrl(config, KEY);
expect(url).toBe(S3_URL);
expect(url).not.toContain("X-Amz-Signature");
expect(url).not.toContain("X-Amz-Expires");
});

it("returns custom domain URL with no signing when customDomain is set", async () => {
const config = makeAWS({ visibility: "public", customDomain: CUSTOM_DOMAIN });
const url = await generateDownloadUrl(config, KEY);
expect(url).toBe(CDN_URL);
expect(url).not.toContain("X-Amz-Signature");
expect(url).not.toContain("amazonaws.com");
});

it("strips trailing slash from custom domain", async () => {
const config = makeAWS({ visibility: "public", customDomain: CUSTOM_DOMAIN + "/" });
const url = await generateDownloadUrl(config, KEY);
expect(url).toBe(CDN_URL);
});
});

// ─── generateDownloadUrl — R2 ───────────────────────────────────────────────

describe("generateDownloadUrl — Cloudflare R2", () => {
it("private (default): presigned GET against R2 API endpoint", async () => {
const config = makeR2Private();
const url = await generateDownloadUrl(config, KEY);
expect(url).toContain(R2_API_URL);
expect(url).toContain("X-Amz-Signature");
});

it("private + custom domain: still signs against R2 API, not custom domain", async () => {
const config = makeR2Private(CUSTOM_DOMAIN);
const url = await generateDownloadUrl(config, KEY);
// R2 presigned URLs must use the API endpoint, not custom domain
expect(url).toContain("r2.cloudflarestorage.com");
expect(url).not.toContain("cdn.example.com");
expect(url).toContain("X-Amz-Signature");
});

it("public + custom domain: returns custom domain URL with no signing", async () => {
const config = makeR2Public(CUSTOM_DOMAIN);
const url = await generateDownloadUrl(config, KEY);
expect(url).toBe(CDN_URL);
expect(url).not.toContain("X-Amz-Signature");
expect(url).not.toContain("r2.cloudflarestorage.com");
});
});

// ─── generatePresignedDownloadUrl — always presigns ─────────────────────────

describe("generatePresignedDownloadUrl — always presigns (ignores visibility)", () => {
it("presigns for private bucket", async () => {
const config = makeAWS({ visibility: "private" });
const url = await generatePresignedDownloadUrl(config, KEY);
expect(url).toContain("X-Amz-Signature");
expect(url).toContain(S3_URL);
});

it("presigns even when visibility is 'public'", async () => {
const config = makeAWS({ visibility: "public", customDomain: CUSTOM_DOMAIN });
const url = await generatePresignedDownloadUrl(config, KEY);
// Ignores visibility — always presigns against S3 API endpoint
expect(url).toContain("X-Amz-Signature");
expect(url).toContain("s3.us-east-1.amazonaws.com");
expect(url).not.toContain("cdn.example.com");
});

it("respects custom expiresIn", async () => {
const config = makeAWS();
const url = await generatePresignedDownloadUrl(config, KEY, 900);
expect(url).toContain("X-Amz-Expires=900");
});

it("defaults to 3600s expiry", async () => {
const config = makeAWS();
const url = await generatePresignedDownloadUrl(config, KEY);
expect(url).toContain("X-Amz-Expires=3600");
});
});

// ─── R2 TypeScript discriminated union ───────────────────────────────────────

describe("R2 visibility: 'public' TypeScript constraint", () => {
it("R2 private without customDomain is valid", () => {
expect(() => makeR2Private()).not.toThrow();
});

it("R2 public with customDomain is valid", () => {
expect(() => makeR2Public(CUSTOM_DOMAIN)).not.toThrow();
});

it("R2 public without customDomain throws at runtime", () => {
expect(() =>
createUploadConfig()
// @ts-expect-error: visibility: 'public' without customDomain is a type error
.provider("cloudflareR2", { accessKeyId: "k", secretAccessKey: "s", accountId: "id", bucket: "b", visibility: "public" })
Comment on lines +205 to +209
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== tsconfig files =="
fd -HI '^tsconfig.*\.json$' . | while read -r file; do
  echo "--- $file ---"
  jq '{include, exclude, files, references}' "$file" 2>/dev/null || cat "$file"
  echo
done

echo "== package.json scripts mentioning typecheck / tsc / vitest =="
fd -HI '^package\.json$' . | while read -r file; do
  echo "--- $file ---"
  jq '.scripts' "$file" 2>/dev/null || cat "$file"
  echo
done

echo "== CI / repo references to TypeScript checks =="
rg -n --hidden -g '!**/node_modules/**' '"typecheck"|\btsc\b|vitest' .github package.json packages

echo "== references to test-file inclusion patterns =="
rg -n --hidden -g '!**/node_modules/**' '__tests__|download-url-visibility\.test\.ts' .

Repository: abhay-ramesh/pushduck

Length of output: 50377


Move the negative TS assertion into a real typecheck path.

The @ts-expect-error at line 208 is ineffective: the repo's type-check script runs tsc --noEmit against packages/pushduck/tsconfig.json, which explicitly includes only ["src"] and excludes test files. This means the TypeScript constraint on visibility: "public" without customDomain is never validated—the test passes at runtime regardless of whether the type error regresses. Move this assertion to a dedicated type-test file that is included in the tsconfig, or create a dedicated type-checking step for test files.

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

In `@packages/pushduck/src/__tests__/download-url-visibility.test.ts` around lines
205 - 209, The test currently uses an in-test `@ts-expect-error` which isn't
checked by the repo's TypeScript build because tests are excluded from the
tsconfig; move the negative type assertion into a real typechecked path by
adding a new type-test file (e.g.,
packages/pushduck/src/__type_tests__/r2-visibility.test-d.ts) that imports
createUploadConfig() and attempts to call .provider("cloudflareR2", { ...,
visibility: "public" }) without customDomain so the compiler will catch the
error; ensure this new file is included by the package tsconfig (or add it to
the "include" array) so tsc --noEmit runs the check during CI.

.build()
).toThrow("R2 requires customDomain when visibility is 'public'");
});
});
4 changes: 2 additions & 2 deletions packages/pushduck/src/__tests__/s3-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { createUploadConfig } from "../core/config/upload-config";
import {
generatePresignedDownloadUrl,
generateDownloadUrl,
generatePresignedUploadUrl,
getFileUrl,
} from "../core/storage/client";
Expand Down Expand Up @@ -66,7 +66,7 @@ describe("S3 Fallback Behavior", () => {
})
.build();

const presignedUrl = await generatePresignedDownloadUrl(
const presignedUrl = await generateDownloadUrl(
config,
"uploads/test-image.jpg",
3600
Expand Down
Loading
Loading