@gilhrpenner/convex-files-control 0.1.1 → 0.3.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 +264 -204
  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 +67 -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 +7 -5
  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 +243 -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,114 @@
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.
6
+
7
+ **[Live Demo →](https://convex-files-control-example.pages.dev)**
5
8
 
6
9
  ## Features
7
10
 
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
11
+ - Two-step uploads (presigned URL) with access keys and optional expiration.
12
+ - Optional HTTP upload/download routes with auth hooks.
13
+ - Download grants with max uses, expiration, optional password, and shareable
13
14
  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.
15
+ - Access-key based authorization (user IDs, tenant IDs, etc.).
16
+ - Built-in cleanup for expired uploads, grants, and files.
17
+ - Transfer files between Convex and R2.
18
+ - React hook for presigned or HTTP uploads.
17
19
 
18
- ## Installation
20
+ ## Install
19
21
 
20
22
  ```bash
21
23
  npm install @gilhrpenner/convex-files-control
22
24
  ```
23
25
 
24
- ## Setup
25
-
26
- ### 1. Configure Component
26
+ ## Quick start
27
27
 
28
- Add the component to your `convex.config.ts`:
28
+ ### 1) Add the component
29
29
 
30
- ```typescript
30
+ ```ts
31
31
  // convex.config.ts
32
32
  import { defineApp } from "convex/server";
33
- import filesControl from "@gilhrpenner/convex-files-control/convex.config";
33
+ import convexFilesControl from "@gilhrpenner/convex-files-control/convex.config";
34
34
 
35
35
  const app = defineApp();
36
- app.use(filesControl);
36
+ app.use(convexFilesControl);
37
37
 
38
38
  export default app;
39
39
  ```
40
40
 
41
- ### 2. Expose the API
41
+ ### 2) Create wrapper functions in your app
42
42
 
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.
43
+ The component stores access control and download grants. Your app should store
44
+ its own file metadata (name, owner, etc.) and enforce auth. The wrappers below
45
+ mirror the example app in `example/convex/files.ts`.
45
46
 
46
- ```typescript
47
+ ```ts
47
48
  // convex/files.ts
48
- import { v } from "convex/values";
49
+ import { ConvexError, v } from "convex/values";
49
50
  import { mutation } from "./_generated/server";
50
51
  import { components } from "./_generated/api";
51
52
 
52
- // Expose only the client-facing download operation (with your auth rules)
53
- export const createDownloadGrant = mutation({
53
+ export const generateUploadUrl = mutation({
54
54
  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())),
55
+ provider: v.union(v.literal("convex"), v.literal("r2")),
58
56
  },
59
57
  handler: async (ctx, args) => {
60
- // Example auth: const identity = await ctx.auth.getUserIdentity();
61
- // if (!identity) throw new Error("Unauthorized");
58
+ const identity = await ctx.auth.getUserIdentity();
59
+ if (!identity) throw new ConvexError("Unauthorized");
60
+
62
61
  return await ctx.runMutation(
63
- components.convexFilesControl.download.createDownloadGrant,
64
- args,
62
+ components.convexFilesControl.upload.generateUploadUrl,
63
+ {
64
+ provider: args.provider,
65
+ // r2Config: { accountId, accessKeyId, secretAccessKey, bucketName },
66
+ },
65
67
  );
66
68
  },
67
69
  });
68
70
 
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
- {},
71
+ export const finalizeUpload = mutation({
72
+ args: {
73
+ uploadToken: v.string(),
74
+ storageId: v.string(),
75
+ fileName: v.string(),
76
+ expiresAt: v.optional(v.union(v.null(), v.number())),
77
+ metadata: v.optional(
78
+ v.object({
79
+ size: v.number(),
80
+ sha256: v.string(),
81
+ contentType: v.union(v.string(), v.null()),
82
+ }),
83
+ ),
84
+ },
85
+ handler: async (ctx, args) => {
86
+ const identity = await ctx.auth.getUserIdentity();
87
+ if (!identity) throw new ConvexError("Unauthorized");
88
+
89
+ const { fileName, ...componentArgs } = args;
90
+ const result = await ctx.runMutation(
91
+ components.convexFilesControl.upload.finalizeUpload,
92
+ {
93
+ ...componentArgs,
94
+ accessKeys: [identity.subject],
95
+ },
77
96
  );
97
+
98
+ // Store your own file record (name, owner, etc.) here.
99
+ // await ctx.db.insert("files", { ... });
100
+
101
+ return result;
78
102
  },
79
103
  });
80
104
  ```
81
105
 
82
- ### 3. Setup HTTP Routes (Optional)
106
+ ### 3) Optional HTTP routes
83
107
 
84
- If you want to support direct HTTP uploads or downloads (proxied through
85
- Convex), register the routes in `convex/http.ts`.
108
+ If you want `/files/upload` and `/files/download`, register the router in
109
+ `convex/http.ts`. Access keys are provided by your hook (not via the form).
86
110
 
87
- ```typescript
111
+ ```ts
88
112
  // convex/http.ts
89
113
  import { httpRouter } from "convex/server";
90
114
  import { registerRoutes } from "@gilhrpenner/convex-files-control";
@@ -93,236 +117,272 @@ import { components } from "./_generated/api";
93
117
  const http = httpRouter();
94
118
 
95
119
  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
120
+ pathPrefix: "files",
121
+ enableUploadRoute: true,
122
+
123
+ // Required when enableUploadRoute is true
124
+ checkUploadRequest: async (ctx) => {
125
+ const identity = await ctx.auth.getUserIdentity();
126
+ if (!identity) {
127
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
128
+ status: 401,
129
+ headers: { "Content-Type": "application/json" },
130
+ });
131
+ }
132
+
133
+ return { accessKeys: [identity.subject] };
134
+ },
135
+
136
+ // Optional: persist file metadata after a successful HTTP upload
137
+ onUploadComplete: async (ctx, { result, file, formData }) => {
138
+ const fileNameFromForm = formData.get("fileName");
139
+ const fileName =
140
+ typeof fileNameFromForm === "string"
141
+ ? fileNameFromForm
142
+ : (file as File).name ?? "untitled";
143
+ // await ctx.runMutation(api.files.recordUpload, { ...result, fileName });
144
+ },
145
+
146
+ // Optional: provide accessKey for downloads
147
+ checkDownloadRequest: async (ctx) => {
148
+ const identity = await ctx.auth.getUserIdentity();
149
+ if (identity) return { accessKey: identity.subject };
150
+ },
99
151
  });
100
152
 
101
153
  export default http;
102
154
  ```
103
155
 
104
- ## Usage
156
+ HTTP upload requires `multipart/form-data` with fields:
157
+
158
+ - `file` (required)
159
+ - `provider` (optional, "convex" | "r2")
160
+ - `expiresAt` (optional, timestamp or `null`)
161
+
162
+ Access keys are not accepted via the form; they must come from
163
+ `checkUploadRequest`. Additional form fields are available on
164
+ `onUploadComplete` via `formData`.
105
165
 
106
- ### Uploading Files
166
+ Useful route options:
107
167
 
108
- #### Option A: Presigned URL (Recommended)
168
+ - `pathPrefix` (default: `/files`)
169
+ - `defaultUploadProvider` (`\"convex\"` or `\"r2\"`)
170
+ - `enableDownloadRoute` (default: `true`)
171
+ - `requireAccessKey` (force `checkDownloadRequest` to return an access key)
172
+ - `passwordHeader` / `passwordQueryParam` (override or disable password inputs)
109
173
 
110
- This is the most efficient method, uploading directly from the client to Convex
111
- storage.
174
+ ## Uploading files
112
175
 
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`.
176
+ ### Presigned URL flow
116
177
 
117
- ```typescript
118
- // Client-side example
119
- const { uploadUrl, uploadToken } = await generateUploadUrl();
178
+ ```ts
179
+ // Client-side
180
+ const { uploadUrl, uploadToken } = await generateUploadUrl({
181
+ provider: "convex",
182
+ });
120
183
 
121
- const result = await fetch(uploadUrl, {
184
+ const uploadResponse = await fetch(uploadUrl, {
122
185
  method: "POST",
123
- body: fileBlob, // your file object
124
- headers: { "Content-Type": fileBlob.type },
186
+ body: file,
187
+ headers: { "Content-Type": file.type || "application/octet-stream" },
125
188
  });
126
- const { storageId } = await result.json();
127
189
 
128
- const fileParams = {
190
+ const { storageId } = await uploadResponse.json();
191
+
192
+ const result = await finalizeUpload({
129
193
  uploadToken,
130
194
  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,
195
+ fileName: file.name,
196
+ expiresAt: Date.now() + 60 * 60 * 1000,
152
197
  });
153
198
  ```
154
199
 
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`.
200
+ ### React hook
160
201
 
161
202
  ```tsx
162
203
  import { useUploadFile } from "@gilhrpenner/convex-files-control/react";
163
204
  import { api } from "../convex/_generated/api";
164
205
 
165
- const convexSiteUrl = import.meta.env.VITE_CONVEX_URL.replace(".cloud", ".site");
206
+ const convexSiteUrl = import.meta.env.VITE_CONVEX_URL.replace(
207
+ ".cloud",
208
+ ".site",
209
+ );
166
210
 
167
211
  const { uploadFile } = useUploadFile(api.files, {
212
+ method: "presigned",
168
213
  http: { baseUrl: convexSiteUrl },
169
214
  });
170
215
 
171
- // Presigned URL upload
172
- await uploadFile({
173
- file,
174
- accessKeys: ["user_123"],
175
- expiresAt: Date.now() + 60 * 60 * 1000,
176
- method: "presigned",
177
- });
216
+ // Presigned
217
+ await uploadFile({ file, provider: "convex" });
178
218
 
179
- // HTTP action upload
219
+ // HTTP route
180
220
  await uploadFile({
181
221
  file,
182
- accessKeys: ["user_123"],
183
222
  method: "http",
223
+ provider: "convex",
224
+ http: {
225
+ baseUrl: convexSiteUrl,
226
+ // authToken: useAuthToken() from @convex-dev/auth/react
227
+ },
184
228
  });
185
229
  ```
186
230
 
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.
231
+ `uploadFile` accepts:
189
232
 
190
- ### Downloading Files
233
+ - `file` (required)
234
+ - `provider` ("convex" | "r2")
235
+ - `expiresAt` (timestamp or `null`)
236
+ - `method` ("presigned" | "http")
191
237
 
192
- To download a file securely, you create a "Download Grant". This generates a
193
- token that can be exchanged for the file content.
238
+ ## Downloading files
194
239
 
195
- 1. **Create Grant**: Call `createDownloadGrant` with the `storageId`.
196
- 2. **Build URL**: Use the helper to construct the download link.
240
+ ### Create a grant + build a URL
197
241
 
198
- ```typescript
242
+ ```ts
199
243
  import { buildDownloadUrl } from "@gilhrpenner/convex-files-control";
200
244
 
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
- });
245
+ const grant = await ctx.runMutation(
246
+ components.convexFilesControl.download.createDownloadGrant,
247
+ {
248
+ storageId,
249
+ maxUses: 1,
250
+ expiresAt: Date.now() + 10 * 60 * 1000,
251
+ shareableLink: false,
220
252
  },
253
+ );
254
+
255
+ const url = buildDownloadUrl({
256
+ baseUrl: "https://<your-convex-site>",
257
+ downloadToken: grant.downloadToken,
258
+ filename: "report.pdf",
259
+ // pathPrefix: "/files", // Optional if you changed the HTTP route prefix
221
260
  });
222
261
  ```
223
262
 
224
- The user then visits this URL. The component validates the grant and redirects
225
- to the secure storage URL.
263
+ Access keys are not placed in the URL. For private grants, supply them via
264
+ `checkDownloadRequest` (HTTP route) or pass `accessKey` when calling
265
+ `consumeDownloadGrantForUrl`.
226
266
 
227
- #### Password-Protected Download Grants
267
+ ### Shareable links
228
268
 
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.
269
+ Set `shareableLink: true` to allow unauthenticated downloads (no access key
270
+ required). This is how the example app generates public links. If you enable
271
+ `requireAccessKey` on the HTTP route, shareable links will still require
272
+ `checkDownloadRequest` to return an access key.
231
273
 
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
- });
274
+ ### Password-protected grants
275
+
276
+ ```ts
277
+ const grant = await ctx.runMutation(
278
+ components.convexFilesControl.download.createDownloadGrant,
279
+ { storageId, password: "secret-passphrase" },
280
+ );
249
281
  ```
250
282
 
251
- To consume a password-protected grant, pass the password to
252
- `consumeDownloadGrantForUrl`:
283
+ To consume a password-protected grant, pass `password` to
284
+ `consumeDownloadGrantForUrl`, or send it to the HTTP route via the
285
+ `x-download-password` header (preferred) or the `password` query param. Query
286
+ params can leak into logs, so headers or POST flows are safer.
287
+
288
+ ## Access control & queries
289
+
290
+ Access keys are normalized (trimmed) and must contain at least one non-empty
291
+ value.
292
+
293
+ - `accessControl.addAccessKey(storageId, accessKey)`
294
+ - `accessControl.removeAccessKey(storageId, accessKey)`
295
+ - `accessControl.updateFileExpiration(storageId, expiresAt)`
296
+ - `queries.hasAccessKey(storageId, accessKey)`
297
+ - `queries.listAccessKeysPage(storageId, paginationOpts)`
298
+ - `queries.listFilesPage(paginationOpts)`
299
+ - `queries.listFilesByAccessKeyPage(accessKey, paginationOpts)`
300
+ - `queries.listDownloadGrantsPage(paginationOpts)`
301
+ - `queries.getFile({ storageId })`
302
+
303
+ Pagination uses `{ numItems: number, cursor: string | null }`.
304
+
305
+ ## Cleanup
306
+
307
+ Use `cleanUp.cleanupExpired` to delete expired uploads, grants, and files. The
308
+ example app wraps this in a mutation and runs it in a cron job.
309
+
310
+ ```ts
311
+ // convex/crons.ts
312
+ import { cronJobs } from "convex/server";
313
+ import { internal } from "./_generated/api";
253
314
 
254
- ```typescript
255
- const result = await ctx.runMutation(
256
- components.convexFilesControl.download.consumeDownloadGrantForUrl,
257
- { downloadToken, accessKey, password: "secret-passphrase" },
315
+ const crons = cronJobs();
316
+ crons.hourly(
317
+ "cleanup-expired-files",
318
+ { minuteUTC: 0 },
319
+ internal.files.cleanupExpiredFiles,
320
+ {},
258
321
  );
322
+ export default crons;
259
323
  ```
260
324
 
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.
325
+ ## Server-side helper (FilesControl)
264
326
 
265
- #### Rate Limiting Downloads
327
+ If you prefer a class wrapper around component calls, use `FilesControl`:
266
328
 
267
- If you want to rate limit downloads, use the `checkDownloadRequest` hook when
268
- registering routes and call your preferred rate limiter there.
329
+ ```ts
330
+ import { FilesControl } from "@gilhrpenner/convex-files-control";
331
+ import { components } from "./_generated/api";
269
332
 
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
- },
333
+ const files = new FilesControl(components.convexFilesControl, {
334
+ // r2: { accountId, accessKeyId, secretAccessKey, bucketName },
286
335
  });
336
+
337
+ await files.generateUploadUrl(ctx, { provider: "convex" });
287
338
  ```
288
339
 
289
- ### Managing Files
340
+ `FilesControl.clientApi()` also returns a ready-to-export API surface with
341
+ optional hooks if you want the component to generate your Convex mutations and
342
+ queries for you.
290
343
 
291
- #### Access Control
344
+ ## R2 configuration
292
345
 
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).
346
+ Provide R2 credentials when you use R2 for uploads, downloads, deletes, or
347
+ transfers. You can pass `r2Config` to the component calls or supply env vars for
348
+ the HTTP routes:
295
349
 
296
- - `addAccessKey(storageId, accessKey)`
297
- - `removeAccessKey(storageId, accessKey)`
298
- - `hasAccessKey(storageId, accessKey)`
299
- - `listAccessKeysPage(storageId, paginationOpts)`
350
+ - `R2_ACCOUNT_ID`
351
+ - `R2_ACCESS_KEY_ID`
352
+ - `R2_SECRET_ACCESS_KEY`
353
+ - `R2_BUCKET_NAME`
300
354
 
301
- #### File Listings (Paginated)
355
+ ## Transfer between providers
302
356
 
303
- - `listFilesPage(paginationOpts)`: List files using cursor-based pagination.
304
- - `listFilesByAccessKeyPage(accessKey, paginationOpts)`: List files for a specific user.
357
+ ```ts
358
+ const result = await ctx.runAction(
359
+ components.convexFilesControl.transfer.transferFile,
360
+ { storageId, targetProvider: "r2", r2Config },
361
+ );
362
+ ```
305
363
 
306
- `paginationOpts` is the standard Convex pagination object:
307
- `{ numItems: number, cursor: string | null }`.
364
+ The transfer preserves access keys and download grants, updates the file record,
365
+ and deletes the original storage object.
308
366
 
309
- #### Cleanup
367
+ ## Testing helper
310
368
 
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.
369
+ ```ts
370
+ import { convexTest } from "convex-test";
371
+ import { register } from "@gilhrpenner/convex-files-control/test";
314
372
 
315
- ```typescript
316
- // convex/crons.ts
317
- import { cronJobs } from "convex/server";
318
- import { api } from "./_generated/api";
373
+ const t = convexTest(schema, modules);
374
+ register(t, "convexFilesControl");
375
+ ```
319
376
 
320
- const crons = cronJobs();
377
+ ## Example app
321
378
 
322
- // Run cleanup every hour
323
- crons.hourly("cleanup-files", { minutes: 0 }, api.files.cleanupExpired, {
324
- limit: 100,
325
- });
379
+ **[Live Demo →](https://convex-files-control-example.pages.dev)**
326
380
 
327
- export default crons;
328
- ```
381
+ A full Convex + React + Convex Auth implementation lives in `example/`. It
382
+ demonstrates:
383
+
384
+ - presigned and HTTP uploads
385
+ - authenticated downloads and shareable links
386
+ - access key management
387
+ - transfer between Convex and R2
388
+ - 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"}