@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
@@ -0,0 +1,70 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+
3
+ const findFileByStorageIdMock = vi.fn();
4
+
5
+ vi.mock("../component/lib.js", async (importOriginal) => {
6
+ const actual = await importOriginal<typeof import("../component/lib.js")>();
7
+ return {
8
+ ...actual,
9
+ findFileByStorageId: findFileByStorageIdMock,
10
+ };
11
+ });
12
+
13
+ function getHandler(fn: unknown) {
14
+ return (fn as { _handler: (...args: any[]) => any })._handler;
15
+ }
16
+
17
+ function makeCtx(grantOverrides: Record<string, unknown> = {}) {
18
+ return {
19
+ db: {
20
+ get: vi.fn(async () => ({
21
+ _id: "grant",
22
+ storageId: "storage",
23
+ maxUses: null,
24
+ useCount: 0,
25
+ shareableLink: true,
26
+ ...grantOverrides,
27
+ })),
28
+ delete: vi.fn(async () => undefined),
29
+ },
30
+ } as any;
31
+ }
32
+
33
+ describe("consumeDownloadGrantForUrl shareable link edge cases", () => {
34
+ beforeEach(() => {
35
+ findFileByStorageIdMock.mockReset();
36
+ });
37
+
38
+ test("deletes grant when shareable link file is missing", async () => {
39
+ const { consumeDownloadGrantForUrl } = await import("../component/download.js");
40
+ const ctx = makeCtx();
41
+ findFileByStorageIdMock.mockResolvedValueOnce(null);
42
+
43
+ const result = await getHandler(consumeDownloadGrantForUrl)(ctx, {
44
+ downloadToken: "grant",
45
+ });
46
+
47
+ expect(result).toEqual({ status: "file_missing" });
48
+ expect(ctx.db.delete).toHaveBeenCalledWith("grant");
49
+ });
50
+
51
+ test("handles missing file after initial shareable check", async () => {
52
+ const { consumeDownloadGrantForUrl } = await import("../component/download.js");
53
+ const ctx = makeCtx();
54
+ let calls = 0;
55
+ const thenable = {
56
+ then: (resolve: (value: any) => void) => {
57
+ calls += 1;
58
+ resolve(calls === 1 ? { storageProvider: "convex" } : null);
59
+ },
60
+ };
61
+ findFileByStorageIdMock.mockReturnValueOnce(thenable as any);
62
+
63
+ const result = await getHandler(consumeDownloadGrantForUrl)(ctx, {
64
+ downloadToken: "grant",
65
+ });
66
+
67
+ expect(result).toEqual({ status: "file_missing" });
68
+ expect(ctx.db.delete).toHaveBeenCalledWith("grant");
69
+ });
70
+ });
@@ -0,0 +1,13 @@
1
+ import { describe, expect, test } from "vitest";
2
+
3
+ describe("entrypoint modules", () => {
4
+ test("shared entrypoint and schema load from source", async () => {
5
+ const shared = await import(new URL("../shared.ts", import.meta.url).href);
6
+ expect(shared.DEFAULT_PATH_PREFIX).toBe("/files");
7
+
8
+ const schemaModule = await import(
9
+ new URL("../component/schema.ts", import.meta.url).href
10
+ );
11
+ expect(schemaModule.default).toBeTruthy();
12
+ });
13
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { corsHeaders, parseJsonStringArray } from "../client/http.js";
3
+
4
+ describe("http helpers", () => {
5
+ test("corsHeaders sets credentials for specific origins", () => {
6
+ const headers = corsHeaders("https://example.com");
7
+ expect(headers.get("Access-Control-Allow-Origin")).toBe("https://example.com");
8
+ expect(headers.get("Access-Control-Allow-Credentials")).toBe("true");
9
+ });
10
+
11
+ test("corsHeaders includes extra allow headers", () => {
12
+ const headers = corsHeaders(undefined, ["X-Extra"]);
13
+ expect(headers.get("Access-Control-Allow-Headers")).toContain("X-Extra");
14
+ });
15
+
16
+ test("corsHeaders trims and deduplicates allow headers", () => {
17
+ const headers = corsHeaders(undefined, ["", " Authorization ", "authorization"]);
18
+ const allow = headers.get("Access-Control-Allow-Headers") ?? "";
19
+ const matches = allow.split(", ").filter((value) => value === "Authorization");
20
+ expect(matches).toHaveLength(1);
21
+ });
22
+
23
+ test("parseJsonStringArray returns string arrays", () => {
24
+ expect(parseJsonStringArray('["a","b"]')).toEqual(["a", "b"]);
25
+ });
26
+
27
+ test("parseJsonStringArray rejects non-string arrays", () => {
28
+ expect(parseJsonStringArray('["a", 1]')).toBeNull();
29
+ });
30
+
31
+ test("parseJsonStringArray returns null for invalid JSON", () => {
32
+ expect(parseJsonStringArray("not-json")).toBeNull();
33
+ });
34
+ });
@@ -48,7 +48,6 @@ describe("useUploadFile", () => {
48
48
  const file = new File(["data"], "file.txt", { type: "text/plain" });
49
49
  const result = await uploadViaPresignedUrl({
50
50
  file,
51
- accessKeys: ["a"],
52
51
  expiresAt: 123,
53
52
  });
54
53
 
@@ -57,8 +56,8 @@ describe("useUploadFile", () => {
57
56
  expect(finalizeUpload).toHaveBeenCalledWith({
58
57
  uploadToken,
59
58
  storageId: "storage",
60
- accessKeys: ["a"],
61
59
  expiresAt: 123,
60
+ fileName: "file.txt",
62
61
  });
63
62
  expect(fetchMock).toHaveBeenCalledWith(
64
63
  uploadUrl,
@@ -94,7 +93,6 @@ describe("useUploadFile", () => {
94
93
  const file = new File(["data"], "file.bin");
95
94
  const result = await uploadViaPresignedUrl({
96
95
  file,
97
- accessKeys: ["a"],
98
96
  provider: "r2",
99
97
  });
100
98
 
@@ -129,7 +127,6 @@ describe("useUploadFile", () => {
129
127
  await expect(
130
128
  uploadViaPresignedUrl({
131
129
  file: new File(["data"], "file.txt"),
132
- accessKeys: ["a"],
133
130
  }),
134
131
  ).rejects.toThrow("Upload failed: bad");
135
132
 
@@ -146,7 +143,6 @@ describe("useUploadFile", () => {
146
143
  await expect(
147
144
  uploadViaPresignedUrl({
148
145
  file: new File(["data"], "file.txt"),
149
- accessKeys: ["a"],
150
146
  }),
151
147
  ).rejects.toThrow("Upload did not return a storageId.");
152
148
  });
@@ -162,14 +158,12 @@ describe("useUploadFile", () => {
162
158
  await expect(
163
159
  uploadViaHttpAction({
164
160
  file: new File(["data"], "file.txt"),
165
- accessKeys: ["a"],
166
161
  }),
167
162
  ).rejects.toThrow("Missing HTTP upload URL");
168
163
 
169
164
  await expect(
170
165
  uploadViaHttpAction({
171
166
  file: new File(["data"], "file.txt"),
172
- accessKeys: ["a"],
173
167
  http: {},
174
168
  }),
175
169
  ).rejects.toThrow("Missing HTTP upload URL");
@@ -182,7 +176,6 @@ describe("useUploadFile", () => {
182
176
  await expect(
183
177
  uploadViaHttpAction({
184
178
  file: new File(["data"], "file.txt"),
185
- accessKeys: ["a"],
186
179
  http: { uploadUrl: "https://upload.example.com" },
187
180
  }),
188
181
  ).rejects.toThrow("Bad request");
@@ -195,7 +188,6 @@ describe("useUploadFile", () => {
195
188
  await expect(
196
189
  uploadViaHttpAction({
197
190
  file: new File(["data"], "file.txt"),
198
- accessKeys: ["a"],
199
191
  http: { uploadUrl: "https://upload.example.com" },
200
192
  }),
201
193
  ).rejects.toThrow("HTTP upload failed.");
@@ -219,18 +211,24 @@ describe("useUploadFile", () => {
219
211
  const fetchMock = vi.fn(async (_url: string, init?: any) => {
220
212
  expect(init?.body).toBeInstanceOf(FormData);
221
213
  const form = init?.body as FormData;
222
- expect(form.get(uploadFormFields.accessKeys)).toBe("[\"a\"]");
214
+ // Note: accessKeys is NOT in form - it's added server-side via checkUploadRequest hook
223
215
  expect(form.get(uploadFormFields.provider)).toBe("convex");
224
216
  expect(form.get(uploadFormFields.expiresAt)).toBe("null");
217
+ expect(init?.headers).toEqual({
218
+ Authorization: "Bearer token",
219
+ });
225
220
  return new Response(JSON.stringify(responsePayload), { status: 200 });
226
221
  });
227
222
  vi.stubGlobal("fetch", fetchMock);
228
223
 
229
224
  const result = await uploadViaHttpAction({
230
225
  file: new File(["data"], "file.txt"),
231
- accessKeys: ["a"],
232
226
  expiresAt: null,
233
- http: { baseUrl: "https://example.com", pathPrefix: "/files" },
227
+ http: {
228
+ baseUrl: "https://example.com",
229
+ pathPrefix: "/files",
230
+ authToken: "token",
231
+ },
234
232
  });
235
233
 
236
234
  expect(fetchMock).toHaveBeenCalledWith(
@@ -265,7 +263,6 @@ describe("useUploadFile", () => {
265
263
 
266
264
  await uploadViaHttpAction({
267
265
  file: new File(["data"], "file.txt"),
268
- accessKeys: ["a"],
269
266
  expiresAt: 123,
270
267
  http: { baseUrl: "https://example.com" },
271
268
  });
@@ -310,14 +307,12 @@ describe("useUploadFile", () => {
310
307
 
311
308
  await uploadFile({
312
309
  file: new File(["data"], "file.txt"),
313
- accessKeys: ["a"],
314
310
  });
315
311
 
316
312
  expect(fetchMock).toHaveBeenCalled();
317
313
 
318
314
  await uploadFile({
319
315
  file: new File(["data"], "file.txt"),
320
- accessKeys: ["a"],
321
316
  method: "presigned",
322
317
  });
323
318
 
@@ -355,7 +350,6 @@ describe("useUploadFile", () => {
355
350
 
356
351
  await uploadFile({
357
352
  file: new File(["data"], "file.txt"),
358
- accessKeys: ["a"],
359
353
  });
360
354
 
361
355
  expect(generateUploadUrl).toHaveBeenCalled();
@@ -66,9 +66,4 @@ describe("test helpers", () => {
66
66
  expect(testHelpers.modules).toBeTypeOf("object");
67
67
  });
68
68
 
69
- test("shared exports are available", () => {
70
- expect(shared.DEFAULT_PATH_PREFIX).toBe("/files");
71
- expect(__ignore).toBe(true);
72
- });
73
-
74
69
  });
@@ -198,6 +198,48 @@ describe("transferFile", () => {
198
198
  expect(ctx.runMutation).toHaveBeenCalled();
199
199
  });
200
200
 
201
+ test("transferFile uses r2 download url for r2 sources", async () => {
202
+ const getUrl = vi.fn();
203
+ const ctx = {
204
+ runQuery: vi.fn(async () => ({
205
+ _id: "file",
206
+ storageId: "storage",
207
+ storageProvider: "r2",
208
+ })),
209
+ runMutation: vi.fn(async () => ({
210
+ storageId: "new-storage",
211
+ storageProvider: "convex",
212
+ })),
213
+ storage: {
214
+ getUrl,
215
+ store: vi.fn(async () => "new-storage"),
216
+ },
217
+ } as any;
218
+
219
+ const downloadSpy = vi
220
+ .spyOn(r2, "getR2DownloadUrl")
221
+ .mockResolvedValue("https://r2-download.example.com");
222
+
223
+ vi.stubGlobal(
224
+ "fetch",
225
+ vi.fn(async () =>
226
+ new Response("data", {
227
+ status: 200,
228
+ headers: { "Content-Type": "text/plain" },
229
+ }),
230
+ ),
231
+ );
232
+
233
+ await getHandler(transferFile)(ctx, {
234
+ storageId: "storage",
235
+ targetProvider: "convex",
236
+ r2Config,
237
+ });
238
+
239
+ expect(downloadSpy).toHaveBeenCalledWith(r2Config, "storage");
240
+ expect(getUrl).not.toHaveBeenCalled();
241
+ });
242
+
201
243
  test("transfers convex source to r2 target", async () => {
202
244
  const send = vi.fn(async () => ({}));
203
245
  vi.spyOn(r2, "createR2Client").mockReturnValue({ send } as any);
@@ -241,6 +283,26 @@ describe("transferFile", () => {
241
283
  expect(send.mock.calls[0]?.[0]).toBeInstanceOf(PutObjectCommand);
242
284
  });
243
285
 
286
+ test("throws when source provider is unrecognized", async () => {
287
+ const ctx = {
288
+ runQuery: vi.fn(async () => ({
289
+ _id: "file",
290
+ storageId: "storage",
291
+ storageProvider: "unknown",
292
+ })),
293
+ storage: {
294
+ getUrl: vi.fn(),
295
+ },
296
+ } as any;
297
+
298
+ await expect(
299
+ getHandler(transferFile)(ctx, {
300
+ storageId: "storage",
301
+ targetProvider: "convex",
302
+ }),
303
+ ).rejects.toThrow("File not found.");
304
+ });
305
+
244
306
  test("transfers with needsR2 false uses undefined r2Config", async () => {
245
307
  let providerReads = 0;
246
308
  const file = {
@@ -372,6 +434,47 @@ describe("commitTransfer", () => {
372
434
  expect(ctx.storage.delete).toHaveBeenCalled();
373
435
  });
374
436
 
437
+ test("commitTransfer invokes index callbacks for access and grants", async () => {
438
+ const file = {
439
+ _id: "file",
440
+ storageId: "storage",
441
+ storageProvider: "convex",
442
+ };
443
+ const queryObj = {
444
+ eq: vi.fn(() => queryObj),
445
+ };
446
+
447
+ const ctx = {
448
+ db: {
449
+ query: (table: string) => ({
450
+ withIndex: (_index: string, cb?: (q: typeof queryObj) => void) => {
451
+ if (cb) {
452
+ cb(queryObj);
453
+ }
454
+ return {
455
+ first: async () => (table === "files" ? file : null),
456
+ collect: async () => [],
457
+ };
458
+ },
459
+ }),
460
+ patch: vi.fn(async () => undefined),
461
+ },
462
+ storage: {
463
+ delete: vi.fn(async () => undefined),
464
+ },
465
+ } as any;
466
+
467
+ await getHandler(commitTransfer)(ctx, {
468
+ storageId: "storage",
469
+ newStorageId: "new",
470
+ targetProvider: "convex",
471
+ sourceProvider: "convex",
472
+ });
473
+
474
+ expect(queryObj.eq).toHaveBeenCalledWith("fileId", "file");
475
+ expect(queryObj.eq).toHaveBeenCalledWith("storageId", "storage");
476
+ });
477
+
375
478
  test("updates records and deletes r2 storage", async () => {
376
479
  const deleteSpy = vi
377
480
  .spyOn(r2, "deleteR2Object")
@@ -1,24 +1,65 @@
1
- export function corsHeaders(): Headers {
2
- return new Headers({
3
- "Access-Control-Allow-Origin": "*",
1
+ const DEFAULT_ALLOW_HEADERS = ["Content-Type", "Authorization"];
2
+
3
+ function buildAllowHeaders(extra?: string[]): string {
4
+ const headers: string[] = [];
5
+ const seen = new Set<string>();
6
+
7
+ const addHeader = (value: string) => {
8
+ const trimmed = value.trim();
9
+ if (!trimmed) return;
10
+ const key = trimmed.toLowerCase();
11
+ if (seen.has(key)) return;
12
+ seen.add(key);
13
+ headers.push(trimmed);
14
+ };
15
+
16
+ for (const header of DEFAULT_ALLOW_HEADERS) {
17
+ addHeader(header);
18
+ }
19
+ for (const header of extra ?? []) {
20
+ addHeader(header);
21
+ }
22
+
23
+ return headers.join(", ");
24
+ }
25
+
26
+ export function corsHeaders(origin?: string, allowHeaders?: string[]): Headers {
27
+ const headers = new Headers({
28
+ "Access-Control-Allow-Origin": origin || "*",
4
29
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
5
- "Access-Control-Allow-Headers": "Content-Type",
30
+ "Access-Control-Allow-Headers": buildAllowHeaders(allowHeaders),
6
31
  });
32
+ if (origin) {
33
+ headers.set("Access-Control-Allow-Credentials", "true");
34
+ }
35
+ return headers;
7
36
  }
8
37
 
9
- export function corsResponse(): Response {
10
- return new Response(null, { status: 204, headers: corsHeaders() });
38
+ export function corsResponse(origin?: string, allowHeaders?: string[]): Response {
39
+ return new Response(null, {
40
+ status: 204,
41
+ headers: corsHeaders(origin, allowHeaders),
42
+ });
11
43
  }
12
44
 
13
- export function jsonSuccess(data: unknown): Response {
14
- const headers = corsHeaders();
45
+ export function jsonSuccess(
46
+ data: unknown,
47
+ origin?: string,
48
+ allowHeaders?: string[],
49
+ ): Response {
50
+ const headers = corsHeaders(origin, allowHeaders);
15
51
  headers.set("Content-Type", "application/json");
16
52
 
17
53
  return new Response(JSON.stringify(data), { status: 200, headers });
18
54
  }
19
55
 
20
- export function jsonError(message: string, status: number): Response {
21
- const headers = corsHeaders();
56
+ export function jsonError(
57
+ message: string,
58
+ status: number,
59
+ origin?: string,
60
+ allowHeaders?: string[],
61
+ ): Response {
62
+ const headers = corsHeaders(origin, allowHeaders);
22
63
  headers.set("Content-Type", "application/json");
23
64
 
24
65
  return new Response(JSON.stringify({ error: message }), { status, headers });