diff --git a/docs/content/docs/api/configuration/upload-config.mdx b/docs/content/docs/api/configuration/upload-config.mdx
index ff6c683c..77cb327f 100644
--- a/docs/content/docs/api/configuration/upload-config.mdx
+++ b/docs/content/docs/api/configuration/upload-config.mdx
@@ -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"
@@ -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()
diff --git a/docs/content/docs/providers/aws-s3.mdx b/docs/content/docs/providers/aws-s3.mdx
index a36ca47f..f5c1f743 100644
--- a/docs/content/docs/providers/aws-s3.mdx
+++ b/docs/content/docs/providers/aws-s3.mdx
@@ -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();
```
- **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.
diff --git a/docs/content/docs/providers/cloudflare-r2.mdx b/docs/content/docs/providers/cloudflare-r2.mdx
index 81e5a467..a852de3c 100644
--- a/docs/content/docs/providers/cloudflare-r2.mdx
+++ b/docs/content/docs/providers/cloudflare-r2.mdx
@@ -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
@@ -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();
```
+> **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
diff --git a/packages/pushduck/src/__tests__/download-url-visibility.test.ts b/packages/pushduck/src/__tests__/download-url-visibility.test.ts
new file mode 100644
index 00000000..8efe31b5
--- /dev/null
+++ b/packages/pushduck/src/__tests__/download-url-visibility.test.ts
@@ -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" })
+ .build()
+ ).toThrow("R2 requires customDomain when visibility is 'public'");
+ });
+});
diff --git a/packages/pushduck/src/__tests__/s3-fallback.test.ts b/packages/pushduck/src/__tests__/s3-fallback.test.ts
index f752e656..79db5645 100644
--- a/packages/pushduck/src/__tests__/s3-fallback.test.ts
+++ b/packages/pushduck/src/__tests__/s3-fallback.test.ts
@@ -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";
@@ -66,7 +66,7 @@ describe("S3 Fallback Behavior", () => {
})
.build();
- const presignedUrl = await generatePresignedDownloadUrl(
+ const presignedUrl = await generateDownloadUrl(
config,
"uploads/test-image.jpg",
3600
diff --git a/packages/pushduck/src/core/providers/providers.ts b/packages/pushduck/src/core/providers/providers.ts
index 9de8d5cb..fe85187b 100644
--- a/packages/pushduck/src/core/providers/providers.ts
+++ b/packages/pushduck/src/core/providers/providers.ts
@@ -91,6 +91,24 @@ export interface BaseProviderConfig {
customDomain?: string;
/** Force path-style URLs instead of virtual-hosted style */
forcePathStyle?: boolean;
+ /**
+ * Controls what kind of download URL the library returns after a file is uploaded.
+ *
+ * - `'private'` (default) — generates a presigned GET URL signed against the
+ * provider's S3 API endpoint. Works for private buckets. The URL expires
+ * after `expiresIn` seconds (default 3600).
+ * - `'public'` — returns the plain public URL (custom domain if configured,
+ * otherwise the provider's public URL). No signing. Use this when your
+ * bucket/objects are already publicly accessible.
+ *
+ * **Important:** This setting must match your actual bucket configuration.
+ * Setting `visibility: 'public'` on a private bucket will result in 403 errors
+ * when clients try to access the returned URLs. pushduck does not verify or
+ * enforce your bucket's access settings.
+ *
+ * @default 'private'
+ */
+ visibility?: "public" | "private";
}
// ========================================
@@ -140,14 +158,34 @@ export interface AWSProviderConfig extends BaseProviderConfig {
sessionToken?: string;
}
+/**
+ * Base fields for Cloudflare R2 object storage (without visibility/customDomain).
+ * Use the exported `CloudflareR2Config` type for the full discriminated union.
+ */
+export interface CloudflareR2BaseConfig extends Omit {
+ provider: "cloudflare-r2";
+ /** Cloudflare Account ID */
+ accountId: string;
+ /** R2 Access Key ID */
+ accessKeyId: string;
+ /** R2 Secret Access Key */
+ secretAccessKey: string;
+ /** Region (typically 'auto' for R2) */
+ region?: "auto";
+ /** Custom endpoint (auto-generated from accountId if not provided) */
+ endpoint?: string;
+}
+
/**
* Configuration for Cloudflare R2 object storage.
* S3-compatible storage with zero egress fees and global distribution.
*
- * @interface CloudflareR2Config
- * @extends BaseProviderConfig
+ * R2 presigned URLs only work with the R2 S3 API endpoint — they cannot be
+ * used with custom domains. Therefore `visibility: 'public'` requires a
+ * `customDomain` to be set (R2 public access requires a custom domain or
+ * the r2.dev subdomain; the API endpoint does not serve public content).
*
- * @example Basic Configuration
+ * @example Basic Configuration (private bucket)
* ```typescript
* const r2Config: CloudflareR2Config = {
* provider: 'cloudflare-r2',
@@ -158,7 +196,7 @@ export interface AWSProviderConfig extends BaseProviderConfig {
* };
* ```
*
- * @example With Custom Domain
+ * @example Public bucket with custom domain
* ```typescript
* const r2WithDomain: CloudflareR2Config = {
* provider: 'cloudflare-r2',
@@ -166,23 +204,16 @@ export interface AWSProviderConfig extends BaseProviderConfig {
* accountId: 'abc123',
* accessKeyId: 'key123',
* secretAccessKey: 'secret123',
- * customDomain: 'assets.myapp.com',
+ * customDomain: 'https://assets.myapp.com', // required when visibility is 'public'
+ * visibility: 'public',
* };
* ```
*/
-export interface CloudflareR2Config extends BaseProviderConfig {
- provider: "cloudflare-r2";
- /** Cloudflare Account ID */
- accountId: string;
- /** R2 Access Key ID */
- accessKeyId: string;
- /** R2 Secret Access Key */
- secretAccessKey: string;
- /** Region (typically 'auto' for R2) */
- region?: "auto";
- /** Custom endpoint (auto-generated from accountId if not provided) */
- endpoint?: string;
-}
+export type CloudflareR2Config = CloudflareR2BaseConfig &
+ (
+ | { visibility: "public"; customDomain: string }
+ | { visibility?: "private"; customDomain?: string }
+ );
/**
* Configuration for DigitalOcean Spaces object storage.
@@ -399,46 +430,67 @@ export type ProviderConfig =
// DRY Provider Factory System
// ========================================
-interface ConfigKeyMapping {
- readonly [key: string]: readonly string[];
-}
-
interface ProviderSpec {
readonly provider: string;
- readonly configKeys: ConfigKeyMapping;
- readonly defaults: Record;
- readonly customLogic?: (config: any, computed: any) => any;
+ readonly configKeys: { readonly [key: string]: readonly string[] };
+ readonly defaults: { readonly [key: string]: unknown };
+ readonly customLogic?: (
+ config: Record,
+ computed: Record
+ ) => Record;
}
/**
- * Generic provider configuration builder
- * Validates required configuration fields
+ * Generic provider configuration builder.
+ *
+ * The function signature is fully typed — `T` flows from the provider config
+ * type so callers retain type information. Internally, we widen `config` to
+ * `Record` only for dynamic key-based access (the spec keys
+ * are runtime strings, not statically known). The single `as unknown as T`
+ * cast at the return boundary is the only escape hatch; it is justified
+ * because `validateProviderConfig` runs immediately before, ensuring the
+ * object has all required fields for T.
*/
function createProviderBuilder(
spec: ProviderSpec
-): (config?: Partial) => T {
- return (config: Partial = {}): T => {
- const result: any = { provider: spec.provider };
+): (config?: Partial>) => T {
+ return (config: Partial> = {} as Partial>): T => {
+ // Widen to Record for dynamic key access — widening only, no data loss
+ const configRecord = config as Record;
+ const result: Record = { provider: spec.provider };
+
+ // Apply spec-declared keys with config values or fallback defaults
+ for (const key of Object.keys(spec.configKeys)) {
+ result[key] = configRecord[key] ?? spec.defaults[key] ?? "";
+ }
- // Only use explicit config and defaults (no env vars)
- for (const [key] of Object.entries(spec.configKeys)) {
- result[key] = config[key as keyof T] || spec.defaults[key] || "";
+ // Pass through any config keys not covered by spec.configKeys
+ // (e.g. visibility, forcePathStyle, future BaseProviderConfig fields)
+ for (const [key, value] of Object.entries(configRecord)) {
+ if (!(key in result) && value !== undefined) {
+ result[key] = value;
+ }
}
- // Apply custom logic if provided
+ // Apply provider-specific derived fields (e.g. auto-compute endpoint)
if (spec.customLogic) {
- Object.assign(result, spec.customLogic(config, result));
+ Object.assign(result, spec.customLogic(configRecord, result));
}
- // Validate the final configuration
- const validation = validateProviderConfig(result);
+ // Single boundary cast: dynamic Record → typed T.
+ // unknown intermediate required because Record and the
+ // ProviderConfig union don't share enough structure for a direct assertion.
+ const built = result as unknown as T;
+
+ // Validate before returning — throws if required fields are missing
+ const validation = validateProviderConfig(built);
if (!validation.valid) {
throw new Error(
`Provider validation failed: ${validation.errors.join(", ")}`
);
}
- return result as T;
+ return built;
};
}
@@ -483,7 +535,7 @@ const PROVIDER_SPECS = {
region: "auto",
acl: "private",
},
- customLogic: (config: any, computed: any) => ({
+ customLogic: (config, computed) => ({
endpoint:
computed.endpoint ||
(computed.accountId
@@ -513,7 +565,7 @@ const PROVIDER_SPECS = {
region: "nyc3",
acl: "private",
},
- customLogic: (config: any, computed: any) => ({
+ customLogic: (config, computed) => ({
endpoint:
computed.endpoint ||
`https://${computed.region}.digitaloceanspaces.com`,
@@ -536,7 +588,7 @@ const PROVIDER_SPECS = {
region: "us-east-1",
acl: "private",
},
- customLogic: (config: any, computed: any) => ({
+ customLogic: (config, computed) => ({
useSSL: config.useSSL ?? false,
port: config.port ? Number(config.port) : undefined,
}),
@@ -556,7 +608,7 @@ const PROVIDER_SPECS = {
region: "us-central1",
acl: "private",
},
- customLogic: (config: any) => ({
+ customLogic: (config, _computed) => ({
credentials: config.credentials,
}),
},
@@ -578,7 +630,7 @@ const PROVIDER_SPECS = {
forcePathStyle: true, // Most S3-compatible providers need path-style access
},
},
-} as const;
+} as const satisfies Record;
// ========================================
// Generic Provider Creator (New API)
@@ -593,12 +645,33 @@ export type ProviderType = keyof ProviderSpecsType;
// ========================================
/**
- * Maps each provider type to its corresponding configuration interface
- * This enables type-safe provider configuration in createUploadConfig().provider()
+ * Maps each provider key to its fully-resolved output config type.
+ * Used to constrain createProviderBuilder so the generic T is the
+ * concrete ProviderConfig subtype, not the loose ProviderConfig union.
+ */
+export type ProviderOutputMap = {
+ aws: AWSProviderConfig;
+ cloudflareR2: CloudflareR2Config;
+ digitalOceanSpaces: DigitalOceanSpacesConfig;
+ minio: MinIOConfig;
+ gcs: GoogleCloudStorageConfig;
+ s3Compatible: S3CompatibleConfig;
+};
+
+/**
+ * Maps each provider type to its user-facing input configuration.
+ * Partial so callers only supply the fields they know; missing values are
+ * filled from environment variables or defaults inside createProviderBuilder.
+ * R2 carries its discriminated union so TypeScript enforces the
+ * visibility: 'public' → customDomain required constraint at the call site.
*/
export type ProviderConfigMap = {
aws: Partial>;
- cloudflareR2: Partial>;
+ cloudflareR2: Partial> &
+ (
+ | { visibility: "public"; customDomain: string }
+ | { visibility?: "private"; customDomain?: string }
+ );
digitalOceanSpaces: Partial>;
minio: Partial>;
gcs: Partial>;
@@ -661,14 +734,20 @@ export type ProviderConfigMap = {
*/
export function createProvider(
type: T,
- config: ProviderConfigMap[T] = {} as ProviderConfigMap[T]
-): ProviderConfig {
+ config?: ProviderConfigMap[T]
+): ProviderOutputMap[T] {
const spec = PROVIDER_SPECS[type];
if (!spec) {
throw new Error(`Unknown provider type: ${type}`);
}
- return createProviderBuilder(spec)(config as any);
+ // ProviderConfigMap[T] is always a structural subtype of
+ // Partial> for each concrete T —
+ // the only difference is the Partial wrapping and R2's discriminated union.
+ // The cast narrows from the user-facing input shape to the builder's param.
+ return createProviderBuilder(spec)(
+ config as Partial>
+ );
}
// ========================================
@@ -758,6 +837,10 @@ export function validateProviderConfig(config: ProviderConfig): {
if (!config.accessKeyId) errors.push("R2 Access Key ID is required");
if (!config.secretAccessKey)
errors.push("R2 Secret Access Key is required");
+ if (config.visibility === "public" && !config.customDomain)
+ errors.push(
+ "R2 requires customDomain when visibility is 'public' — R2 presigned URLs cannot be used with custom domains"
+ );
break;
case "digitalocean-spaces":
diff --git a/packages/pushduck/src/core/router/router-v2.ts b/packages/pushduck/src/core/router/router-v2.ts
index f0ffdc49..866ed6db 100644
--- a/packages/pushduck/src/core/router/router-v2.ts
+++ b/packages/pushduck/src/core/router/router-v2.ts
@@ -61,7 +61,7 @@ import { createUniversalHandler } from "../handler/universal-handler";
import { InferS3Input, InferS3Output, S3Schema } from "../schema";
import {
generateFileKey,
- generatePresignedDownloadUrl,
+ generateDownloadUrl,
generatePresignedUploadUrl,
getFileUrl,
} from "../storage/client";
@@ -986,8 +986,10 @@ export class S3Router {
// Get file URL
const url = getFileUrl(this.config, completion.key);
- // Generate presigned download URL (expires in 1 hour by default)
- const presignedUrl = await generatePresignedDownloadUrl(
+ // Generate download URL — respects provider visibility setting.
+ // Returns a presigned GET URL for private buckets, or a plain/CDN URL
+ // for public buckets (visibility: 'public').
+ const downloadUrl = await generateDownloadUrl(
this.config,
completion.key,
3600
@@ -1007,7 +1009,7 @@ export class S3Router {
success: true,
key: completion.key,
url,
- presignedUrl,
+ presignedUrl: downloadUrl,
file: completion.file,
});
} catch (error) {
@@ -1060,7 +1062,7 @@ export interface CompletionResponse {
success: boolean;
key: string;
url?: string;
- presignedUrl?: string; // Temporary download URL (expires in 1 hour)
+ presignedUrl?: string; // Download URL — presigned for private buckets, plain/CDN URL for public buckets
file?: S3FileMetadata;
error?: string;
}
diff --git a/packages/pushduck/src/core/storage/client.ts b/packages/pushduck/src/core/storage/client.ts
index d006ffd7..809837f1 100644
--- a/packages/pushduck/src/core/storage/client.ts
+++ b/packages/pushduck/src/core/storage/client.ts
@@ -89,6 +89,8 @@ interface S3CompatibleConfig {
customDomain?: string;
/** Enable debug logging */
debug?: boolean;
+ /** Whether the bucket is public or private (controls download URL format) */
+ visibility?: "public" | "private";
}
/**
@@ -131,6 +133,7 @@ function getS3CompatibleConfig(
acl: config.acl,
customDomain: config.customDomain,
forcePathStyle: config.forcePathStyle,
+ visibility: config.visibility,
debug: options.debug ?? false,
};
@@ -605,7 +608,16 @@ export interface PresignedUrlResult {
}
/**
- * Generates a presigned URL for downloading/viewing a file from S3
+ * Always generates a presigned GET URL for a file, regardless of the provider's
+ * `visibility` setting. Signs against the S3 API endpoint (never the custom domain).
+ *
+ * Use this when the caller explicitly wants a time-limited signed URL — for example,
+ * `storage.download.presignedUrl(key)` where the user is explicitly requesting a
+ * presigned URL even if the bucket is public.
+ *
+ * Note: The `host` header is part of the SigV4 canonical request and must match
+ * the endpoint being called. Custom domains (CDNs, CloudFront, R2 custom domains)
+ * cannot be used as the signing base for presigned URLs.
*/
export async function generatePresignedDownloadUrl(
uploadConfig: UploadConfig,
@@ -616,27 +628,19 @@ export async function generatePresignedDownloadUrl(
const config = getS3CompatibleConfig(uploadConfig.provider);
try {
- const s3Url = buildPublicUrl(key, config);
+ // Always sign against the S3 API endpoint, never the custom domain.
+ const s3Url = buildS3Url(key, config);
const url = new URL(s3Url);
- // Add expiration as query parameter
url.searchParams.set("X-Amz-Expires", expiresIn.toString());
- // Create a signed request for GET operation (download/view)
const signedRequest = await awsClient.sign(
- new Request(url.toString(), {
- method: "GET",
- }),
- {
- aws: { signQuery: true },
- }
+ new Request(url.toString(), { method: "GET" }),
+ { aws: { signQuery: true } }
);
if (config.debug) {
- logger.presignedUrl(key, {
- signedUrl: signedRequest.url,
- expiresIn,
- });
+ logger.presignedUrl(key, { signedUrl: signedRequest.url, expiresIn });
}
return signedRequest.url;
@@ -658,6 +662,35 @@ export async function generatePresignedDownloadUrl(
}
}
+/**
+ * Generates a download URL for a file, respecting the provider's `visibility` setting.
+ *
+ * - `visibility: 'public'` — returns the plain public URL (custom domain if configured,
+ * otherwise the S3 URL). No signing. Bucket/objects must already be publicly accessible.
+ * - `visibility: 'private'` (default) — returns a presigned GET URL signed against the
+ * S3 API endpoint. Expires after `expiresIn` seconds (default 3600).
+ *
+ * Used internally by the router to generate the download URL returned after upload.
+ * For explicit presigned URL generation regardless of visibility, use
+ * `generatePresignedDownloadUrl` instead.
+ */
+export async function generateDownloadUrl(
+ uploadConfig: UploadConfig,
+ key: string,
+ expiresIn: number = 3600
+): Promise {
+ const config = getS3CompatibleConfig(uploadConfig.provider);
+
+ // Public bucket: return the plain URL (custom domain if set, otherwise S3 URL).
+ // No signing needed — objects are already publicly accessible.
+ if (config.visibility === "public") {
+ return buildPublicUrl(key, config);
+ }
+
+ // Private bucket (default): generate a presigned GET URL.
+ return generatePresignedDownloadUrl(uploadConfig, key, expiresIn);
+}
+
/**
* Generates a presigned URL for uploading a file to S3
*/
diff --git a/packages/pushduck/src/core/storage/storage-api.ts b/packages/pushduck/src/core/storage/storage-api.ts
index a88eb07f..3bcf1303 100644
--- a/packages/pushduck/src/core/storage/storage-api.ts
+++ b/packages/pushduck/src/core/storage/storage-api.ts
@@ -101,6 +101,8 @@ export class StorageInstance {
// Download operations - grouped under 'download' namespace
download = {
+ // Always generates a presigned GET URL regardless of the provider's
+ // visibility setting — the caller is explicitly asking for a presigned URL.
presignedUrl: (key: string, expiresIn?: number) =>
client.generatePresignedDownloadUrl(this.config, key, expiresIn),