@gilhrpenner/convex-files-control 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +243 -207
  2. package/dist/client/http.d.ts +4 -4
  3. package/dist/client/http.d.ts.map +1 -1
  4. package/dist/client/http.js +39 -10
  5. package/dist/client/http.js.map +1 -1
  6. package/dist/client/index.d.ts +66 -11
  7. package/dist/client/index.d.ts.map +1 -1
  8. package/dist/client/index.js +136 -41
  9. package/dist/client/index.js.map +1 -1
  10. package/dist/component/_generated/component.d.ts +2 -0
  11. package/dist/component/_generated/component.d.ts.map +1 -1
  12. package/dist/component/download.d.ts +2 -0
  13. package/dist/component/download.d.ts.map +1 -1
  14. package/dist/component/download.js +33 -12
  15. package/dist/component/download.js.map +1 -1
  16. package/dist/component/schema.d.ts +3 -1
  17. package/dist/component/schema.d.ts.map +1 -1
  18. package/dist/component/schema.js +1 -0
  19. package/dist/component/schema.js.map +1 -1
  20. package/dist/react/index.d.ts +2 -2
  21. package/dist/react/index.d.ts.map +1 -1
  22. package/dist/react/index.js +10 -6
  23. package/dist/react/index.js.map +1 -1
  24. package/package.json +4 -3
  25. package/src/__tests__/client-extra.test.ts +157 -4
  26. package/src/__tests__/client.test.ts +572 -46
  27. package/src/__tests__/download-core.test.ts +70 -0
  28. package/src/__tests__/entrypoints.test.ts +13 -0
  29. package/src/__tests__/http.test.ts +34 -0
  30. package/src/__tests__/react.test.ts +10 -16
  31. package/src/__tests__/shared.test.ts +0 -5
  32. package/src/__tests__/transfer.test.ts +103 -0
  33. package/src/client/http.ts +51 -10
  34. package/src/client/index.ts +242 -51
  35. package/src/component/_generated/component.ts +2 -0
  36. package/src/component/download.ts +35 -14
  37. package/src/component/schema.ts +1 -0
  38. package/src/react/index.ts +11 -10
package/README.md CHANGED
@@ -1,90 +1,111 @@
1
1
  # Convex Files Control
2
2
 
3
- A robust, secure file management component for Convex, featuring access control,
4
- temporary download links, and automatic cleanup.
3
+ A Convex component for secure file uploads, access control, download grants, and
4
+ lifecycle cleanup. Works with Convex storage and Cloudflare R2, and ships with
5
+ an optional HTTP upload/download router plus a React upload hook.
5
6
 
6
7
  ## Features
7
8
 
8
- - **Secure Uploads**: Support for both presigned URLs (client-side) and HTTP
9
- actions (server-side).
10
- - **Access Control**: Granular file access using "Access Keys" (e.g., User IDs,
11
- Tenant IDs).
12
- - **Secure Downloads**: Generate temporary, single-use, or limited-use download
13
- links.
14
- - **Expiration & Cleanup**: Built-in support for file expiration and automatic
15
- background cleanup.
16
- - **Metadata**: Computes and returns SHA-256 checksums, size, and MIME type.
9
+ - Two-step uploads (presigned URL) with access keys and optional expiration.
10
+ - Optional HTTP upload/download routes with auth hooks.
11
+ - Download grants with max uses, expiration, optional password, and shareable links.
12
+ - Access-key based authorization (user IDs, tenant IDs, etc.).
13
+ - Built-in cleanup for expired uploads, grants, and files.
14
+ - Transfer files between Convex and R2.
15
+ - React hook for presigned or HTTP uploads.
17
16
 
18
- ## Installation
17
+ ## Install
19
18
 
20
19
  ```bash
21
20
  npm install @gilhrpenner/convex-files-control
22
21
  ```
23
22
 
24
- ## Setup
23
+ ## Quick start
25
24
 
26
- ### 1. Configure Component
25
+ ### 1) Add the component
27
26
 
28
- Add the component to your `convex.config.ts`:
29
-
30
- ```typescript
27
+ ```ts
31
28
  // convex.config.ts
32
29
  import { defineApp } from "convex/server";
33
- import filesControl from "@gilhrpenner/convex-files-control/convex.config";
30
+ import convexFilesControl from "@gilhrpenner/convex-files-control/convex.config";
34
31
 
35
32
  const app = defineApp();
36
- app.use(filesControl);
33
+ app.use(convexFilesControl);
37
34
 
38
35
  export default app;
39
36
  ```
40
37
 
41
- ### 2. Expose the API
38
+ ### 2) Create wrapper functions in your app
42
39
 
43
- Create a file (e.g., `convex/files.ts`) with server-side wrappers that call into
44
- the component. Only export the functions you actually want callable from the client.
40
+ The component stores access control and download grants. Your app should store
41
+ its own file metadata (name, owner, etc.) and enforce auth. The wrappers below
42
+ mirror the example app in `example/convex/files.ts`.
45
43
 
46
- ```typescript
44
+ ```ts
47
45
  // convex/files.ts
48
- import { v } from "convex/values";
46
+ import { ConvexError, v } from "convex/values";
49
47
  import { mutation } from "./_generated/server";
50
48
  import { components } from "./_generated/api";
51
49
 
52
- // Expose only the client-facing download operation (with your auth rules)
53
- export const createDownloadGrant = mutation({
50
+ export const generateUploadUrl = mutation({
54
51
  args: {
55
- storageId: v.id("_storage"),
56
- maxUses: v.optional(v.union(v.null(), v.number())),
57
- expiresAt: v.optional(v.union(v.null(), v.number())),
52
+ provider: v.union(v.literal("convex"), v.literal("r2")),
58
53
  },
59
54
  handler: async (ctx, args) => {
60
- // Example auth: const identity = await ctx.auth.getUserIdentity();
61
- // if (!identity) throw new Error("Unauthorized");
55
+ const identity = await ctx.auth.getUserIdentity();
56
+ if (!identity) throw new ConvexError("Unauthorized");
57
+
62
58
  return await ctx.runMutation(
63
- components.convexFilesControl.download.createDownloadGrant,
64
- args,
59
+ components.convexFilesControl.upload.generateUploadUrl,
60
+ {
61
+ provider: args.provider,
62
+ // r2Config: { accountId, accessKeyId, secretAccessKey, bucketName },
63
+ },
65
64
  );
66
65
  },
67
66
  });
68
67
 
69
- // Wrap other component functions in server-side mutations/actions as needed.
70
- // For example, upload support via presigned URLs:
71
- export const generateUploadUrl = mutation({
72
- args: {},
73
- handler: async (ctx) => {
74
- return await ctx.runMutation(
75
- components.convexFilesControl.upload.generateUploadUrl,
76
- {},
68
+ export const finalizeUpload = mutation({
69
+ args: {
70
+ uploadToken: v.string(),
71
+ storageId: v.string(),
72
+ fileName: v.string(),
73
+ expiresAt: v.optional(v.union(v.null(), v.number())),
74
+ metadata: v.optional(
75
+ v.object({
76
+ size: v.number(),
77
+ sha256: v.string(),
78
+ contentType: v.union(v.string(), v.null()),
79
+ }),
80
+ ),
81
+ },
82
+ handler: async (ctx, args) => {
83
+ const identity = await ctx.auth.getUserIdentity();
84
+ if (!identity) throw new ConvexError("Unauthorized");
85
+
86
+ const { fileName, ...componentArgs } = args;
87
+ const result = await ctx.runMutation(
88
+ components.convexFilesControl.upload.finalizeUpload,
89
+ {
90
+ ...componentArgs,
91
+ accessKeys: [identity.subject],
92
+ },
77
93
  );
94
+
95
+ // Store your own file record (name, owner, etc.) here.
96
+ // await ctx.db.insert("files", { ... });
97
+
98
+ return result;
78
99
  },
79
100
  });
80
101
  ```
81
102
 
82
- ### 3. Setup HTTP Routes (Optional)
103
+ ### 3) Optional HTTP routes
83
104
 
84
- If you want to support direct HTTP uploads or downloads (proxied through
85
- Convex), register the routes in `convex/http.ts`.
105
+ If you want `/files/upload` and `/files/download`, register the router in
106
+ `convex/http.ts`. Access keys are provided by your hook (not via the form).
86
107
 
87
- ```typescript
108
+ ```ts
88
109
  // convex/http.ts
89
110
  import { httpRouter } from "convex/server";
90
111
  import { registerRoutes } from "@gilhrpenner/convex-files-control";
@@ -93,70 +114,78 @@ import { components } from "./_generated/api";
93
114
  const http = httpRouter();
94
115
 
95
116
  registerRoutes(http, components.convexFilesControl, {
96
- pathPrefix: "/files", // Routes will be /files/download (upload is opt-in)
97
- requireAccessKey: false, // Set to true to enforce accessKey param on downloads
98
- enableUploadRoute: true, // Opt-in to /files/upload
117
+ pathPrefix: "files",
118
+ enableUploadRoute: true,
119
+
120
+ // Required when enableUploadRoute is true
121
+ checkUploadRequest: async (ctx) => {
122
+ const identity = await ctx.auth.getUserIdentity();
123
+ if (!identity) {
124
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
125
+ status: 401,
126
+ headers: { "Content-Type": "application/json" },
127
+ });
128
+ }
129
+
130
+ return { accessKeys: [identity.subject] };
131
+ },
132
+
133
+ // Optional: persist file metadata after a successful HTTP upload
134
+ onUploadComplete: async (ctx, { result, file }) => {
135
+ const fileName = (file as File).name ?? "untitled";
136
+ // await ctx.runMutation(api.files.recordUpload, { ...result, fileName });
137
+ },
138
+
139
+ // Optional: provide accessKey for downloads
140
+ checkDownloadRequest: async (ctx) => {
141
+ const identity = await ctx.auth.getUserIdentity();
142
+ if (identity) return { accessKey: identity.subject };
143
+ },
99
144
  });
100
145
 
101
146
  export default http;
102
147
  ```
103
148
 
104
- ## Usage
149
+ HTTP upload requires `multipart/form-data` with fields:
150
+ - `file` (required)
151
+ - `provider` (optional, "convex" | "r2")
152
+ - `expiresAt` (optional, timestamp or `null`)
105
153
 
106
- ### Uploading Files
154
+ Access keys are not accepted via the form; they must come from
155
+ `checkUploadRequest`.
107
156
 
108
- #### Option A: Presigned URL (Recommended)
157
+ Useful route options:
158
+ - `pathPrefix` (default: `/files`)
159
+ - `defaultUploadProvider` (`\"convex\"` or `\"r2\"`)
160
+ - `enableDownloadRoute` (default: `true`)
161
+ - `requireAccessKey` (force `checkDownloadRequest` to return an access key)
162
+ - `passwordHeader` / `passwordQueryParam` (override or disable password inputs)
109
163
 
110
- This is the most efficient method, uploading directly from the client to Convex
111
- storage.
164
+ ## Uploading files
112
165
 
113
- 1. **Generate Upload URL**: Call your exposed `generateUploadUrl` mutation.
114
- 2. **Upload File**: POST the file to the returned `uploadUrl`.
115
- 3. **Finalize**: Call `finalizeUpload` with the `uploadToken` and `storageId`.
166
+ ### Presigned URL flow
116
167
 
117
- ```typescript
118
- // Client-side example
119
- const { uploadUrl, uploadToken } = await generateUploadUrl();
168
+ ```ts
169
+ // Client-side
170
+ const { uploadUrl, uploadToken } = await generateUploadUrl({ provider: "convex" });
120
171
 
121
- const result = await fetch(uploadUrl, {
172
+ const uploadResponse = await fetch(uploadUrl, {
122
173
  method: "POST",
123
- body: fileBlob, // your file object
124
- headers: { "Content-Type": fileBlob.type },
174
+ body: file,
175
+ headers: { "Content-Type": file.type || "application/octet-stream" },
125
176
  });
126
- const { storageId } = await result.json();
127
177
 
128
- const fileParams = {
178
+ const { storageId } = await uploadResponse.json();
179
+
180
+ const result = await finalizeUpload({
129
181
  uploadToken,
130
182
  storageId,
131
- accessKeys: ["user_123"], // Who can access this file?
132
- expiresAt: Date.now() + 24 * 60 * 60 * 1000, // Optional expiration
133
- };
134
-
135
- const metadata = await finalizeUpload(fileParams);
136
- console.log("File uploaded:", metadata);
137
- ```
138
-
139
- #### Option B: HTTP Action
140
-
141
- Upload directly via your configured HTTP endpoint.
142
-
143
- ```typescript
144
- const formData = new FormData();
145
- formData.append("file", fileBlob);
146
- formData.append("accessKeys", JSON.stringify(["user_123"]));
147
- // Optional: formData.append("expiresAt", timestamp);
148
-
149
- await fetch("https://<your-convex-site>/files/upload", {
150
- method: "POST",
151
- body: formData,
183
+ fileName: file.name,
184
+ expiresAt: Date.now() + 60 * 60 * 1000,
152
185
  });
153
186
  ```
154
187
 
155
- ### React Hook (Optional)
156
-
157
- If you're using React, the component exports a `useUploadFile` hook that supports
158
- both upload methods with a single API. The HTTP method requires `registerRoutes`
159
- to be set up in `convex/http.ts`.
188
+ ### React hook
160
189
 
161
190
  ```tsx
162
191
  import { useUploadFile } from "@gilhrpenner/convex-files-control/react";
@@ -165,164 +194,171 @@ import { api } from "../convex/_generated/api";
165
194
  const convexSiteUrl = import.meta.env.VITE_CONVEX_URL.replace(".cloud", ".site");
166
195
 
167
196
  const { uploadFile } = useUploadFile(api.files, {
197
+ method: "presigned",
168
198
  http: { baseUrl: convexSiteUrl },
169
199
  });
170
200
 
171
- // Presigned URL upload
172
- await uploadFile({
173
- file,
174
- accessKeys: ["user_123"],
175
- expiresAt: Date.now() + 60 * 60 * 1000,
176
- method: "presigned",
177
- });
201
+ // Presigned
202
+ await uploadFile({ file, provider: "convex" });
178
203
 
179
- // HTTP action upload
204
+ // HTTP route
180
205
  await uploadFile({
181
206
  file,
182
- accessKeys: ["user_123"],
183
207
  method: "http",
208
+ provider: "convex",
209
+ http: {
210
+ baseUrl: convexSiteUrl,
211
+ // authToken: useAuthToken() from @convex-dev/auth/react
212
+ },
184
213
  });
185
214
  ```
186
215
 
187
- The hook returns the same metadata you get from `finalizeUpload`/HTTP upload, so
188
- you can persist it in your own tables if needed.
189
-
190
- ### Downloading Files
216
+ `uploadFile` accepts:
217
+ - `file` (required)
218
+ - `provider` ("convex" | "r2")
219
+ - `expiresAt` (timestamp or `null`)
220
+ - `method` ("presigned" | "http")
191
221
 
192
- To download a file securely, you create a "Download Grant". This generates a
193
- token that can be exchanged for the file content.
222
+ ## Downloading files
194
223
 
195
- 1. **Create Grant**: Call `createDownloadGrant` with the `storageId`.
196
- 2. **Build URL**: Use the helper to construct the download link.
224
+ ### Create a grant + build a URL
197
225
 
198
- ```typescript
226
+ ```ts
199
227
  import { buildDownloadUrl } from "@gilhrpenner/convex-files-control";
200
228
 
201
- // Server-side (Mutation)
202
- export const generateLink = mutation({
203
- args: { storageId: v.id("_storage") },
204
- handler: async (ctx, args) => {
205
- // 1. Create a grant (e.g., valid for 1 use)
206
- const grant = await ctx.runMutation(api.files.createDownloadGrant, {
207
- storageId: args.storageId,
208
- maxUses: 1, // Optional: limit uses
209
- expiresAt: Date.now() + 60 * 1000, // Optional: limit time
210
- });
211
-
212
- // 2. Build the URL
213
- // You'll need your Convex HTTP site URL e.g. from process.env.CONVEX_SITE_URL
214
- return buildDownloadUrl({
215
- baseUrl: "https://<your-convex-site>",
216
- downloadToken: grant.downloadToken,
217
- filename: "report.pdf", // Optional: force filename
218
- // accessKey: "user_123" // Optional: if you want to embed the key (less secure)
219
- });
229
+ const grant = await ctx.runMutation(
230
+ components.convexFilesControl.download.createDownloadGrant,
231
+ {
232
+ storageId,
233
+ maxUses: 1,
234
+ expiresAt: Date.now() + 10 * 60 * 1000,
235
+ shareableLink: false,
220
236
  },
237
+ );
238
+
239
+ const url = buildDownloadUrl({
240
+ baseUrl: "https://<your-convex-site>",
241
+ downloadToken: grant.downloadToken,
242
+ filename: "report.pdf",
243
+ // pathPrefix: "/files", // Optional if you changed the HTTP route prefix
221
244
  });
222
245
  ```
223
246
 
224
- The user then visits this URL. The component validates the grant and redirects
225
- to the secure storage URL.
247
+ Access keys are not placed in the URL. For private grants, supply them via
248
+ `checkDownloadRequest` (HTTP route) or pass `accessKey` when calling
249
+ `consumeDownloadGrantForUrl`.
226
250
 
227
- #### Password-Protected Download Grants
251
+ ### Shareable links
228
252
 
229
- You can optionally protect a grant with a password. The component hashes the
230
- password using PBKDF2-SHA256 with a per-grant salt and only stores the hash.
231
-
232
- ```typescript
233
- // Server-side (Mutation)
234
- export const generatePasswordLink = mutation({
235
- args: { storageId: v.id("_storage") },
236
- handler: async (ctx, args) => {
237
- const grant = await ctx.runMutation(api.files.createDownloadGrant, {
238
- storageId: args.storageId,
239
- password: "secret-passphrase",
240
- });
241
-
242
- return buildDownloadUrl({
243
- baseUrl: "https://<your-convex-site>",
244
- downloadToken: grant.downloadToken,
245
- filename: "report.pdf",
246
- });
247
- },
248
- });
249
- ```
253
+ Set `shareableLink: true` to allow unauthenticated downloads (no access key
254
+ required). This is how the example app generates public links.
255
+ If you enable `requireAccessKey` on the HTTP route, shareable links will still
256
+ require `checkDownloadRequest` to return an access key.
250
257
 
251
- To consume a password-protected grant, pass the password to
252
- `consumeDownloadGrantForUrl`:
258
+ ### Password-protected grants
253
259
 
254
- ```typescript
255
- const result = await ctx.runMutation(
256
- components.convexFilesControl.download.consumeDownloadGrantForUrl,
257
- { downloadToken, accessKey, password: "secret-passphrase" },
260
+ ```ts
261
+ const grant = await ctx.runMutation(
262
+ components.convexFilesControl.download.createDownloadGrant,
263
+ { storageId, password: "secret-passphrase" },
258
264
  );
259
265
  ```
260
266
 
261
- If you use the HTTP GET download route, you can send the password via the
262
- `x-download-password` header (preferred) or a `password` query param. Note that
263
- query params can appear in logs and caches, so headers or POST flows are safer.
267
+ To consume a password-protected grant, pass `password` to
268
+ `consumeDownloadGrantForUrl`, or send it to the HTTP route via the
269
+ `x-download-password` header (preferred) or the `password` query param. Query
270
+ params can leak into logs, so headers or POST flows are safer.
264
271
 
265
- #### Rate Limiting Downloads
272
+ ## Access control & queries
266
273
 
267
- If you want to rate limit downloads, use the `checkDownloadRequest` hook when
268
- registering routes and call your preferred rate limiter there.
274
+ Access keys are normalized (trimmed) and must contain at least one non-empty
275
+ value.
269
276
 
270
- ```typescript
271
- registerRoutes(http, components.convexFilesControl, {
272
- checkDownloadRequest: async (_ctx, { request }) => {
273
- const ip =
274
- request.headers.get("x-forwarded-for") ??
275
- request.headers.get("cf-connecting-ip") ??
276
- "unknown";
277
-
278
- // Call your rate limiter here. Return a Response to short-circuit.
279
- if (ip === "blocked") {
280
- return new Response(JSON.stringify({ error: "Too many requests" }), {
281
- status: 429,
282
- headers: { "Content-Type": "application/json" },
283
- });
284
- }
285
- },
286
- });
277
+ - `accessControl.addAccessKey(storageId, accessKey)`
278
+ - `accessControl.removeAccessKey(storageId, accessKey)`
279
+ - `accessControl.updateFileExpiration(storageId, expiresAt)`
280
+ - `queries.hasAccessKey(storageId, accessKey)`
281
+ - `queries.listAccessKeysPage(storageId, paginationOpts)`
282
+ - `queries.listFilesPage(paginationOpts)`
283
+ - `queries.listFilesByAccessKeyPage(accessKey, paginationOpts)`
284
+ - `queries.listDownloadGrantsPage(paginationOpts)`
285
+ - `queries.getFile({ storageId })`
286
+
287
+ Pagination uses `{ numItems: number, cursor: string | null }`.
288
+
289
+ ## Cleanup
290
+
291
+ Use `cleanUp.cleanupExpired` to delete expired uploads, grants, and files. The
292
+ example app wraps this in a mutation and runs it in a cron job.
293
+
294
+ ```ts
295
+ // convex/crons.ts
296
+ import { cronJobs } from "convex/server";
297
+ import { internal } from "./_generated/api";
298
+
299
+ const crons = cronJobs();
300
+ crons.hourly("cleanup-expired-files", { minuteUTC: 0 }, internal.files.cleanupExpiredFiles, {});
301
+ export default crons;
287
302
  ```
288
303
 
289
- ### Managing Files
304
+ ## Server-side helper (FilesControl)
290
305
 
291
- #### Access Control
306
+ If you prefer a class wrapper around component calls, use `FilesControl`:
292
307
 
293
- Files are protected by "Access Keys". A user can only access a file if they have
294
- a matching key (e.g., their User ID).
308
+ ```ts
309
+ import { FilesControl } from "@gilhrpenner/convex-files-control";
310
+ import { components } from "./_generated/api";
295
311
 
296
- - `addAccessKey(storageId, accessKey)`
297
- - `removeAccessKey(storageId, accessKey)`
298
- - `hasAccessKey(storageId, accessKey)`
299
- - `listAccessKeysPage(storageId, paginationOpts)`
312
+ const files = new FilesControl(components.convexFilesControl, {
313
+ // r2: { accountId, accessKeyId, secretAccessKey, bucketName },
314
+ });
300
315
 
301
- #### File Listings (Paginated)
316
+ await files.generateUploadUrl(ctx, { provider: "convex" });
317
+ ```
302
318
 
303
- - `listFilesPage(paginationOpts)`: List files using cursor-based pagination.
304
- - `listFilesByAccessKeyPage(accessKey, paginationOpts)`: List files for a specific user.
319
+ `FilesControl.clientApi()` also returns a ready-to-export API surface with
320
+ optional hooks if you want the component to generate your Convex mutations and
321
+ queries for you.
305
322
 
306
- `paginationOpts` is the standard Convex pagination object:
307
- `{ numItems: number, cursor: string | null }`.
323
+ ## R2 configuration
308
324
 
309
- #### Cleanup
325
+ Provide R2 credentials when you use R2 for uploads, downloads, deletes, or
326
+ transfers. You can pass `r2Config` to the component calls or supply env vars
327
+ for the HTTP routes:
310
328
 
311
- The component includes a `cleanupExpired` mutation. It is recommended to
312
- schedule this to run periodically (e.g., via Convex Crons) to remove expired
313
- files and unused grants.
329
+ - `R2_ACCOUNT_ID`
330
+ - `R2_ACCESS_KEY_ID`
331
+ - `R2_SECRET_ACCESS_KEY`
332
+ - `R2_BUCKET_NAME`
314
333
 
315
- ```typescript
316
- // convex/crons.ts
317
- import { cronJobs } from "convex/server";
318
- import { api } from "./_generated/api";
334
+ ## Transfer between providers
319
335
 
320
- const crons = cronJobs();
336
+ ```ts
337
+ const result = await ctx.runAction(
338
+ components.convexFilesControl.transfer.transferFile,
339
+ { storageId, targetProvider: "r2", r2Config },
340
+ );
341
+ ```
321
342
 
322
- // Run cleanup every hour
323
- crons.hourly("cleanup-files", { minutes: 0 }, api.files.cleanupExpired, {
324
- limit: 100,
325
- });
343
+ The transfer preserves access keys and download grants, updates the file record,
344
+ and deletes the original storage object.
326
345
 
327
- export default crons;
346
+ ## Testing helper
347
+
348
+ ```ts
349
+ import { convexTest } from "convex-test";
350
+ import { register } from "@gilhrpenner/convex-files-control/test";
351
+
352
+ const t = convexTest(schema, modules);
353
+ register(t, "convexFilesControl");
328
354
  ```
355
+
356
+ ## Example app
357
+
358
+ A full Convex + React + Convex Auth implementation lives in `example/`.
359
+ It demonstrates:
360
+ - presigned and HTTP uploads
361
+ - authenticated downloads and shareable links
362
+ - access key management
363
+ - transfer between Convex and R2
364
+ - scheduled cleanup
@@ -1,7 +1,7 @@
1
- export declare function corsHeaders(): Headers;
2
- export declare function corsResponse(): Response;
3
- export declare function jsonSuccess(data: unknown): Response;
4
- export declare function jsonError(message: string, status: number): Response;
1
+ export declare function corsHeaders(origin?: string, allowHeaders?: string[]): Headers;
2
+ export declare function corsResponse(origin?: string, allowHeaders?: string[]): Response;
3
+ export declare function jsonSuccess(data: unknown, origin?: string, allowHeaders?: string[]): Response;
4
+ export declare function jsonError(message: string, status: number, origin?: string, allowHeaders?: string[]): Response;
5
5
  export declare function parseJsonStringArray(value: string): string[] | null;
6
6
  export declare function parseOptionalTimestamp(value: FormDataEntryValue | null): number | null | undefined | "invalid";
7
7
  export declare function sanitizeFilename(value: string | null): string;
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/client/http.ts"],"names":[],"mappings":"AAAA,wBAAgB,WAAW,IAAI,OAAO,CAMrC;AAED,wBAAgB,YAAY,IAAI,QAAQ,CAEvC;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,GAAG,QAAQ,CAKnD;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,QAAQ,CAKnE;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAenE;AAED,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,kBAAkB,GAAG,IAAI,GAC/B,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,SAAS,CAiBvC;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAO7D;AAED,wBAAgB,0BAA0B,CACxC,MAAM,EACF,SAAS,GACT,WAAW,GACX,cAAc,GACd,eAAe,GACf,mBAAmB,GACnB,kBAAkB,GAClB,MAAM,GACT,MAAM,CAcR"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/client/http.ts"],"names":[],"mappings":"AAyBA,wBAAgB,WAAW,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAU7E;AAED,wBAAgB,YAAY,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,QAAQ,CAK/E;AAED,wBAAgB,WAAW,CACzB,IAAI,EAAE,OAAO,EACb,MAAM,CAAC,EAAE,MAAM,EACf,YAAY,CAAC,EAAE,MAAM,EAAE,GACtB,QAAQ,CAKV;AAED,wBAAgB,SAAS,CACvB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,EACf,YAAY,CAAC,EAAE,MAAM,EAAE,GACtB,QAAQ,CAKV;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAenE;AAED,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,kBAAkB,GAAG,IAAI,GAC/B,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,SAAS,CAiBvC;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAO7D;AAED,wBAAgB,0BAA0B,CACxC,MAAM,EACF,SAAS,GACT,WAAW,GACX,cAAc,GACd,eAAe,GACf,mBAAmB,GACnB,kBAAkB,GAClB,MAAM,GACT,MAAM,CAcR"}
@@ -1,20 +1,49 @@
1
- export function corsHeaders() {
2
- return new Headers({
3
- "Access-Control-Allow-Origin": "*",
1
+ const DEFAULT_ALLOW_HEADERS = ["Content-Type", "Authorization"];
2
+ function buildAllowHeaders(extra) {
3
+ const headers = [];
4
+ const seen = new Set();
5
+ const addHeader = (value) => {
6
+ const trimmed = value.trim();
7
+ if (!trimmed)
8
+ return;
9
+ const key = trimmed.toLowerCase();
10
+ if (seen.has(key))
11
+ return;
12
+ seen.add(key);
13
+ headers.push(trimmed);
14
+ };
15
+ for (const header of DEFAULT_ALLOW_HEADERS) {
16
+ addHeader(header);
17
+ }
18
+ for (const header of extra ?? []) {
19
+ addHeader(header);
20
+ }
21
+ return headers.join(", ");
22
+ }
23
+ export function corsHeaders(origin, allowHeaders) {
24
+ const headers = new Headers({
25
+ "Access-Control-Allow-Origin": origin || "*",
4
26
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
5
- "Access-Control-Allow-Headers": "Content-Type",
27
+ "Access-Control-Allow-Headers": buildAllowHeaders(allowHeaders),
6
28
  });
29
+ if (origin) {
30
+ headers.set("Access-Control-Allow-Credentials", "true");
31
+ }
32
+ return headers;
7
33
  }
8
- export function corsResponse() {
9
- return new Response(null, { status: 204, headers: corsHeaders() });
34
+ export function corsResponse(origin, allowHeaders) {
35
+ return new Response(null, {
36
+ status: 204,
37
+ headers: corsHeaders(origin, allowHeaders),
38
+ });
10
39
  }
11
- export function jsonSuccess(data) {
12
- const headers = corsHeaders();
40
+ export function jsonSuccess(data, origin, allowHeaders) {
41
+ const headers = corsHeaders(origin, allowHeaders);
13
42
  headers.set("Content-Type", "application/json");
14
43
  return new Response(JSON.stringify(data), { status: 200, headers });
15
44
  }
16
- export function jsonError(message, status) {
17
- const headers = corsHeaders();
45
+ export function jsonError(message, status, origin, allowHeaders) {
46
+ const headers = corsHeaders(origin, allowHeaders);
18
47
  headers.set("Content-Type", "application/json");
19
48
  return new Response(JSON.stringify({ error: message }), { status, headers });
20
49
  }