@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
@@ -99,9 +99,17 @@ function makeCtx(runMutation?: unknown, runQuery?: unknown) {
99
99
  }
100
100
 
101
101
  describe("registerRoutes", () => {
102
+ // Helper to create a mock checkUploadRequest hook that returns accessKeys
103
+ function mockCheckUploadRequest(accessKeys: string[] = ["test-user"]) {
104
+ return vi.fn(async () => ({ accessKeys }));
105
+ }
106
+
102
107
  test("registers CORS preflight routes", async () => {
103
108
  const router = createRouter();
104
- registerRoutes(router, component, { enableUploadRoute: true });
109
+ registerRoutes(router, component, {
110
+ enableUploadRoute: true,
111
+ checkUploadRequest: mockCheckUploadRequest(),
112
+ });
105
113
 
106
114
  const uploadOptions = getRoute(router, "/files/upload", "OPTIONS");
107
115
  const downloadOptions = getRoute(router, "/files/download", "OPTIONS");
@@ -120,9 +128,9 @@ describe("registerRoutes", () => {
120
128
  expect(downloadResponse.status).toBe(204);
121
129
  });
122
130
 
123
- test("download route uses custom access key query param", async () => {
131
+ test("download route handles without accessKeyQueryParam", async () => {
124
132
  const router = createRouter();
125
- registerRoutes(router, component, { accessKeyQueryParam: "key" });
133
+ registerRoutes(router, component);
126
134
  const downloadRoute = getRoute(router, "/files/download", "GET");
127
135
  const handler = getHandler(downloadRoute.handler);
128
136
 
@@ -137,13 +145,42 @@ describe("registerRoutes", () => {
137
145
  await handler(
138
146
  ctx,
139
147
  buildDownloadRequest(
140
- "https://example.com/files/download?token=token&key=custom",
148
+ "https://example.com/files/download?token=token",
141
149
  ),
142
150
  );
143
151
 
144
152
  expect(runMutation).toHaveBeenCalledWith(
145
153
  component.download.consumeDownloadGrantForUrl,
146
- { downloadToken: "token", accessKey: "custom", password: undefined },
154
+ { downloadToken: "token", accessKey: undefined, password: undefined },
155
+ );
156
+ });
157
+
158
+ test("download route uses accessKey from checkDownloadRequest hook", async () => {
159
+ const router = createRouter();
160
+ const checkDownloadRequest = vi.fn(async () => ({ accessKey: "from-hook" }));
161
+ registerRoutes(router, component, { checkDownloadRequest });
162
+ const downloadRoute = getRoute(router, "/files/download", "GET");
163
+ const handler = getHandler(downloadRoute.handler);
164
+
165
+ const runMutation = vi.fn(async () => ({
166
+ status: "ok",
167
+ downloadUrl: "https://file.example.com",
168
+ }));
169
+ const ctx = makeCtx(runMutation);
170
+
171
+ vi.stubGlobal("fetch", vi.fn(async () => new Response("file", { status: 200 })));
172
+
173
+ await handler(
174
+ ctx,
175
+ buildDownloadRequest("https://example.com/files/download?token=token"),
176
+ );
177
+
178
+ expect(runMutation).toHaveBeenCalledWith(
179
+ component.download.consumeDownloadGrantForUrl,
180
+ expect.objectContaining({
181
+ downloadToken: "token",
182
+ accessKey: "from-hook",
183
+ }),
147
184
  );
148
185
  });
149
186
 
@@ -188,8 +225,9 @@ describe("registerRoutes", () => {
188
225
  });
189
226
 
190
227
  test("upload route validates input", async () => {
228
+ const checkUploadRequest = mockCheckUploadRequest(["a"]);
191
229
  const router = createRouter();
192
- registerRoutes(router, component, { enableUploadRoute: true });
230
+ registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
193
231
  const uploadRoute = getRoute(router, "/files/upload", "POST");
194
232
  const handler = getHandler(uploadRoute.handler);
195
233
 
@@ -214,41 +252,12 @@ describe("registerRoutes", () => {
214
252
  );
215
253
  expect(missingContentTypeResponse.status).toBe(415);
216
254
 
217
- const missingFileRequest = buildUploadRequest({
218
- [uploadFormFields.accessKeys]: JSON.stringify(["a"]),
219
- });
255
+ const missingFileRequest = buildUploadRequest({});
220
256
  const missingFileResponse = await handler(makeCtx(), missingFileRequest);
221
257
  expect(missingFileResponse.status).toBe(400);
222
258
 
223
- const missingAccessKeysRequest = buildUploadRequest({
224
- [uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
225
- });
226
- const missingAccessKeysResponse = await handler(
227
- makeCtx(),
228
- missingAccessKeysRequest,
229
- );
230
- expect(missingAccessKeysResponse.status).toBe(400);
231
-
232
- const invalidAccessKeysRequest = buildUploadRequest({
233
- [uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
234
- [uploadFormFields.accessKeys]: "not json",
235
- });
236
- const invalidAccessKeysResponse = await handler(
237
- makeCtx(),
238
- invalidAccessKeysRequest,
239
- );
240
- expect(invalidAccessKeysResponse.status).toBe(400);
241
-
242
- const invalidArrayRequest = buildUploadRequest({
243
- [uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
244
- [uploadFormFields.accessKeys]: JSON.stringify([1]),
245
- });
246
- const invalidArrayResponse = await handler(makeCtx(), invalidArrayRequest);
247
- expect(invalidArrayResponse.status).toBe(400);
248
-
249
259
  const invalidTypeRequest = buildUploadRequest({
250
260
  [uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
251
- [uploadFormFields.accessKeys]: JSON.stringify(["a"]),
252
261
  [uploadFormFields.expiresAt]: new File(["nope"], "filename"),
253
262
  });
254
263
  const invalidTypeResponse = await handler(makeCtx(), invalidTypeRequest);
@@ -256,7 +265,6 @@ describe("registerRoutes", () => {
256
265
 
257
266
  const invalidNumberRequest = buildUploadRequest({
258
267
  [uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
259
- [uploadFormFields.accessKeys]: JSON.stringify(["a"]),
260
268
  [uploadFormFields.expiresAt]: "nope",
261
269
  });
262
270
  const invalidNumberResponse = await handler(makeCtx(), invalidNumberRequest);
@@ -264,8 +272,9 @@ describe("registerRoutes", () => {
264
272
  });
265
273
 
266
274
  test("upload route handles uploads", async () => {
275
+ const checkUploadRequest = mockCheckUploadRequest(["a", "b"]);
267
276
  const router = createRouter();
268
- registerRoutes(router, component, { enableUploadRoute: true });
277
+ registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
269
278
  const uploadRoute = getRoute(router, "/files/upload", "POST");
270
279
  const handler = getHandler(uploadRoute.handler);
271
280
 
@@ -314,7 +323,6 @@ describe("registerRoutes", () => {
314
323
 
315
324
  const request = buildUploadRequest({
316
325
  [uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
317
- [uploadFormFields.accessKeys]: JSON.stringify(["a", "b"]),
318
326
  [uploadFormFields.expiresAt]: "123",
319
327
  });
320
328
 
@@ -332,11 +340,18 @@ describe("registerRoutes", () => {
332
340
  storageId: "storage",
333
341
  accessKeys: ["a", "b"],
334
342
  expiresAt: 123,
343
+ metadata: {
344
+ size: 4,
345
+ sha256: expect.any(String),
346
+ contentType: "text/plain",
347
+ },
335
348
  });
336
349
 
350
+ // Reset hook for null expiresAt test
351
+ checkUploadRequest.mockResolvedValue({ accessKeys: ["a"] });
352
+
337
353
  const nullRequest = buildUploadRequest({
338
354
  [uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
339
- [uploadFormFields.accessKeys]: JSON.stringify(["a"]),
340
355
  [uploadFormFields.expiresAt]: "null",
341
356
  });
342
357
 
@@ -346,11 +361,15 @@ describe("registerRoutes", () => {
346
361
  storageId: "storage",
347
362
  accessKeys: ["a"],
348
363
  expiresAt: undefined,
364
+ metadata: {
365
+ size: 4,
366
+ sha256: expect.any(String),
367
+ contentType: "text/plain",
368
+ },
349
369
  });
350
370
 
351
371
  const binaryRequest = buildUploadRequest({
352
372
  [uploadFormFields.file]: new File(["file"], "filename"),
353
- [uploadFormFields.accessKeys]: JSON.stringify(["a"]),
354
373
  });
355
374
 
356
375
  await handler(ctx, binaryRequest);
@@ -362,9 +381,489 @@ describe("registerRoutes", () => {
362
381
  );
363
382
  });
364
383
 
384
+ test("upload route uses btoa fallback when Buffer is unavailable", async () => {
385
+ const checkUploadRequest = mockCheckUploadRequest(["a"]);
386
+ const router = createRouter();
387
+ registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
388
+ const uploadRoute = getRoute(router, "/files/upload", "POST");
389
+ const handler = getHandler(uploadRoute.handler);
390
+
391
+ const uploadUrl = "https://upload.example.com";
392
+ const runMutation = vi.fn(async (ref) => {
393
+ if (ref === component.upload.generateUploadUrl) {
394
+ return {
395
+ uploadUrl,
396
+ uploadToken: "token",
397
+ uploadTokenExpiresAt: Date.now(),
398
+ storageProvider: "convex",
399
+ storageId: null,
400
+ };
401
+ }
402
+ if (ref === component.upload.finalizeUpload) {
403
+ return {
404
+ storageId: "storage",
405
+ storageProvider: "convex",
406
+ expiresAt: null,
407
+ metadata: null,
408
+ };
409
+ }
410
+ throw new Error("Unexpected mutation");
411
+ });
412
+ const ctx = makeCtx(runMutation);
413
+
414
+ vi.stubGlobal("Buffer", undefined as any);
415
+ const btoaSpy = vi.fn((value: string) => `encoded:${value.length}`);
416
+ vi.stubGlobal("btoa", btoaSpy);
417
+ class FakeResponse {
418
+ body: unknown;
419
+ status: number;
420
+ statusText: string;
421
+ headers: Headers;
422
+ ok: boolean;
423
+
424
+ constructor(body: unknown, init?: ResponseInit) {
425
+ this.body = body;
426
+ this.status = init?.status ?? 200;
427
+ this.statusText = init?.statusText ?? "";
428
+ this.headers = new Headers(init?.headers);
429
+ this.ok = this.status >= 200 && this.status < 300;
430
+ }
431
+
432
+ async json() {
433
+ return this.body ? JSON.parse(String(this.body)) : null;
434
+ }
435
+ }
436
+ vi.stubGlobal("Response", FakeResponse as any);
437
+ class FakeTextDecoder {
438
+ decode() {
439
+ throw new Error("latin1 unsupported");
440
+ }
441
+ }
442
+ vi.stubGlobal("TextDecoder", FakeTextDecoder as any);
443
+ vi.stubGlobal("crypto", {
444
+ subtle: {
445
+ digest: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
446
+ },
447
+ });
448
+ expect(typeof Buffer).toBe("undefined");
449
+
450
+ vi.stubGlobal(
451
+ "fetch",
452
+ vi.fn(async (url) => {
453
+ if (url === uploadUrl) {
454
+ return {
455
+ ok: true,
456
+ json: async () => ({ storageId: "storage" }),
457
+ } as Response;
458
+ }
459
+ return { ok: false, status: 404 } as Response;
460
+ }),
461
+ );
462
+
463
+ const request = buildUploadRequest({
464
+ [uploadFormFields.file]: new File(["file"], "filename", {
465
+ type: "text/plain",
466
+ }),
467
+ });
468
+
469
+ const response = await handler(ctx, request);
470
+ expect(response.status).toBe(200);
471
+ expect(btoaSpy).toHaveBeenCalled();
472
+ });
473
+
474
+ test("upload route skips TextDecoder when unavailable", async () => {
475
+ const checkUploadRequest = mockCheckUploadRequest(["a"]);
476
+ const router = createRouter();
477
+ registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
478
+ const uploadRoute = getRoute(router, "/files/upload", "POST");
479
+ const handler = getHandler(uploadRoute.handler);
480
+
481
+ const uploadUrl = "https://upload.example.com";
482
+ const runMutation = vi.fn(async (ref) => {
483
+ if (ref === component.upload.generateUploadUrl) {
484
+ return {
485
+ uploadUrl,
486
+ uploadToken: "token",
487
+ uploadTokenExpiresAt: Date.now(),
488
+ storageProvider: "convex",
489
+ storageId: null,
490
+ };
491
+ }
492
+ if (ref === component.upload.finalizeUpload) {
493
+ return {
494
+ storageId: "storage",
495
+ storageProvider: "convex",
496
+ expiresAt: null,
497
+ metadata: null,
498
+ };
499
+ }
500
+ throw new Error("Unexpected mutation");
501
+ });
502
+ const ctx = makeCtx(runMutation);
503
+
504
+ vi.stubGlobal("Buffer", undefined as any);
505
+ const btoaSpy = vi.fn(() => "encoded");
506
+ vi.stubGlobal("btoa", btoaSpy);
507
+ vi.stubGlobal("TextDecoder", undefined as any);
508
+ class FakeResponse {
509
+ body: unknown;
510
+ status: number;
511
+ statusText: string;
512
+ headers: Headers;
513
+ ok: boolean;
514
+
515
+ constructor(body: unknown, init?: ResponseInit) {
516
+ this.body = body;
517
+ this.status = init?.status ?? 200;
518
+ this.statusText = init?.statusText ?? "";
519
+ this.headers = new Headers(init?.headers);
520
+ this.ok = this.status >= 200 && this.status < 300;
521
+ }
522
+
523
+ async json() {
524
+ return this.body ? JSON.parse(String(this.body)) : null;
525
+ }
526
+ }
527
+ vi.stubGlobal("Response", FakeResponse as any);
528
+ vi.stubGlobal("crypto", {
529
+ subtle: {
530
+ digest: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
531
+ },
532
+ });
533
+
534
+ vi.stubGlobal(
535
+ "fetch",
536
+ vi.fn(async (url) => {
537
+ if (url === uploadUrl) {
538
+ return {
539
+ ok: true,
540
+ json: async () => ({ storageId: "storage" }),
541
+ } as Response;
542
+ }
543
+ return { ok: false, status: 404 } as Response;
544
+ }),
545
+ );
546
+
547
+ const request = buildUploadRequest({
548
+ [uploadFormFields.file]: new File(["file"], "filename", {
549
+ type: "text/plain",
550
+ }),
551
+ });
552
+
553
+ const response = await handler(ctx, request);
554
+ expect(response.status).toBe(200);
555
+ expect(btoaSpy).toHaveBeenCalled();
556
+ });
557
+
558
+ test("upload route uses TextDecoder when available", async () => {
559
+ const checkUploadRequest = mockCheckUploadRequest(["a"]);
560
+ const router = createRouter();
561
+ registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
562
+ const uploadRoute = getRoute(router, "/files/upload", "POST");
563
+ const handler = getHandler(uploadRoute.handler);
564
+
565
+ const uploadUrl = "https://upload.example.com";
566
+ const runMutation = vi.fn(async (ref) => {
567
+ if (ref === component.upload.generateUploadUrl) {
568
+ return {
569
+ uploadUrl,
570
+ uploadToken: "token",
571
+ uploadTokenExpiresAt: Date.now(),
572
+ storageProvider: "convex",
573
+ storageId: null,
574
+ };
575
+ }
576
+ if (ref === component.upload.finalizeUpload) {
577
+ return {
578
+ storageId: "storage",
579
+ storageProvider: "convex",
580
+ expiresAt: null,
581
+ metadata: null,
582
+ };
583
+ }
584
+ throw new Error("Unexpected mutation");
585
+ });
586
+ const ctx = makeCtx(runMutation);
587
+
588
+ vi.stubGlobal("Buffer", undefined as any);
589
+ const btoaSpy = vi.fn(() => "encoded");
590
+ vi.stubGlobal("btoa", btoaSpy);
591
+ class FakeResponse {
592
+ body: unknown;
593
+ status: number;
594
+ statusText: string;
595
+ headers: Headers;
596
+ ok: boolean;
597
+
598
+ constructor(body: unknown, init?: ResponseInit) {
599
+ this.body = body;
600
+ this.status = init?.status ?? 200;
601
+ this.statusText = init?.statusText ?? "";
602
+ this.headers = new Headers(init?.headers);
603
+ this.ok = this.status >= 200 && this.status < 300;
604
+ }
605
+
606
+ async json() {
607
+ return this.body ? JSON.parse(String(this.body)) : null;
608
+ }
609
+ }
610
+ vi.stubGlobal("Response", FakeResponse as any);
611
+ class FakeTextDecoder {
612
+ decode() {
613
+ return "decoded";
614
+ }
615
+ }
616
+ vi.stubGlobal("TextDecoder", FakeTextDecoder as any);
617
+ vi.stubGlobal("crypto", {
618
+ subtle: {
619
+ digest: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
620
+ },
621
+ });
622
+
623
+ vi.stubGlobal(
624
+ "fetch",
625
+ vi.fn(async (url) => {
626
+ if (url === uploadUrl) {
627
+ return {
628
+ ok: true,
629
+ json: async () => ({ storageId: "storage" }),
630
+ } as Response;
631
+ }
632
+ return { ok: false, status: 404 } as Response;
633
+ }),
634
+ );
635
+
636
+ const request = buildUploadRequest({
637
+ [uploadFormFields.file]: new File(["file"], "filename", {
638
+ type: "text/plain",
639
+ }),
640
+ });
641
+
642
+ const response = await handler(ctx, request);
643
+ expect(response.status).toBe(200);
644
+ expect(btoaSpy).toHaveBeenCalledWith("decoded");
645
+ });
646
+
647
+ test("upload route throws when base64 encoding is unavailable", async () => {
648
+ const checkUploadRequest = mockCheckUploadRequest(["a"]);
649
+ const router = createRouter();
650
+ registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
651
+ const uploadRoute = getRoute(router, "/files/upload", "POST");
652
+ const handler = getHandler(uploadRoute.handler);
653
+
654
+ const uploadUrl = "https://upload.example.com";
655
+ const runMutation = vi.fn(async (ref) => {
656
+ if (ref === component.upload.generateUploadUrl) {
657
+ return {
658
+ uploadUrl,
659
+ uploadToken: "token",
660
+ uploadTokenExpiresAt: Date.now(),
661
+ storageProvider: "convex",
662
+ storageId: null,
663
+ };
664
+ }
665
+ if (ref === component.upload.finalizeUpload) {
666
+ return {
667
+ storageId: "storage",
668
+ storageProvider: "convex",
669
+ expiresAt: null,
670
+ metadata: null,
671
+ };
672
+ }
673
+ throw new Error("Unexpected mutation");
674
+ });
675
+ const ctx = makeCtx(runMutation);
676
+
677
+ vi.stubGlobal("Buffer", undefined as any);
678
+ vi.stubGlobal("btoa", undefined as any);
679
+ class FakeResponse {
680
+ body: unknown;
681
+ status: number;
682
+ statusText: string;
683
+ headers: Headers;
684
+ ok: boolean;
685
+
686
+ constructor(body: unknown, init?: ResponseInit) {
687
+ this.body = body;
688
+ this.status = init?.status ?? 200;
689
+ this.statusText = init?.statusText ?? "";
690
+ this.headers = new Headers(init?.headers);
691
+ this.ok = this.status >= 200 && this.status < 300;
692
+ }
693
+ }
694
+ vi.stubGlobal("Response", FakeResponse as any);
695
+ vi.stubGlobal("crypto", {
696
+ subtle: {
697
+ digest: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
698
+ },
699
+ });
700
+
701
+ vi.stubGlobal(
702
+ "fetch",
703
+ vi.fn(async (url) => {
704
+ if (url === uploadUrl) {
705
+ return {
706
+ ok: true,
707
+ json: async () => ({ storageId: "storage" }),
708
+ } as Response;
709
+ }
710
+ return { ok: false, status: 404 } as Response;
711
+ }),
712
+ );
713
+
714
+ const request = buildUploadRequest({
715
+ [uploadFormFields.file]: new File(["file"], "filename", {
716
+ type: "text/plain",
717
+ }),
718
+ });
719
+
720
+ await expect(handler(ctx, request)).rejects.toThrow(
721
+ "Base64 encoding is not available in this environment.",
722
+ );
723
+ });
724
+
725
+ test("upload route calls onUploadComplete hook", async () => {
726
+ const checkUploadRequest = mockCheckUploadRequest(["a"]);
727
+ const onUploadComplete = vi.fn(async () => undefined);
728
+ const router = createRouter();
729
+ registerRoutes(router, component, {
730
+ enableUploadRoute: true,
731
+ checkUploadRequest,
732
+ onUploadComplete,
733
+ });
734
+ const uploadRoute = getRoute(router, "/files/upload", "POST");
735
+ const handler = getHandler(uploadRoute.handler);
736
+
737
+ const uploadUrl = "https://upload.example.com";
738
+ const uploadToken = "upload-token";
739
+ const finalizeResult = {
740
+ storageId: "storage",
741
+ storageProvider: "convex",
742
+ expiresAt: null,
743
+ metadata: {
744
+ storageId: "storage",
745
+ size: 4,
746
+ sha256: "hash",
747
+ contentType: "text/plain",
748
+ },
749
+ };
750
+
751
+ const runMutation = vi.fn(async (ref, _args) => {
752
+ if (ref === component.upload.generateUploadUrl) {
753
+ return {
754
+ uploadUrl,
755
+ uploadToken,
756
+ uploadTokenExpiresAt: Date.now(),
757
+ storageProvider: "convex",
758
+ storageId: null,
759
+ };
760
+ }
761
+ if (ref === component.upload.finalizeUpload) {
762
+ return finalizeResult;
763
+ }
764
+ throw new Error("Unexpected mutation");
765
+ });
766
+
767
+ const ctx = makeCtx(runMutation);
768
+
769
+ const fetchMock = vi.fn(async (url) => {
770
+ if (url === uploadUrl) {
771
+ return new Response(JSON.stringify({ storageId: "storage" }), {
772
+ status: 200,
773
+ headers: { "Content-Type": "application/json" },
774
+ });
775
+ }
776
+ return new Response(null, { status: 404 });
777
+ });
778
+ vi.stubGlobal("fetch", fetchMock);
779
+
780
+ const request = buildUploadRequest({
781
+ [uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
782
+ });
783
+
784
+ await handler(ctx, request);
785
+
786
+ expect(onUploadComplete).toHaveBeenCalledTimes(1);
787
+ const hookArgs = onUploadComplete.mock.calls[0]?.[1];
788
+ expect(hookArgs).toMatchObject({
789
+ provider: "convex",
790
+ accessKeys: ["a"],
791
+ expiresAt: null,
792
+ result: finalizeResult,
793
+ });
794
+ });
795
+
796
+ test("upload route returns hook response with CORS headers", async () => {
797
+ const checkUploadRequest = mockCheckUploadRequest(["a"]);
798
+ const onUploadComplete = vi.fn(async () =>
799
+ new Response("hooked", {
800
+ status: 202,
801
+ headers: { "X-Hook": "true" },
802
+ }),
803
+ );
804
+ const router = createRouter();
805
+ registerRoutes(router, component, {
806
+ enableUploadRoute: true,
807
+ checkUploadRequest,
808
+ onUploadComplete,
809
+ });
810
+ const uploadRoute = getRoute(router, "/files/upload", "POST");
811
+ const handler = getHandler(uploadRoute.handler);
812
+
813
+ const uploadUrl = "https://upload.example.com";
814
+ const uploadToken = "upload-token";
815
+ const runMutation = vi.fn(async (ref) => {
816
+ if (ref === component.upload.generateUploadUrl) {
817
+ return {
818
+ uploadUrl,
819
+ uploadToken,
820
+ uploadTokenExpiresAt: Date.now(),
821
+ storageProvider: "convex",
822
+ storageId: null,
823
+ };
824
+ }
825
+ if (ref === component.upload.finalizeUpload) {
826
+ return {
827
+ storageId: "storage",
828
+ storageProvider: "convex",
829
+ expiresAt: null,
830
+ metadata: null,
831
+ };
832
+ }
833
+ throw new Error("Unexpected mutation");
834
+ });
835
+
836
+ const ctx = makeCtx(runMutation);
837
+ vi.stubGlobal(
838
+ "fetch",
839
+ vi.fn(async (url) => {
840
+ if (url === uploadUrl) {
841
+ return new Response(JSON.stringify({ storageId: "storage" }), {
842
+ status: 200,
843
+ headers: { "Content-Type": "application/json" },
844
+ });
845
+ }
846
+ return new Response(null, { status: 404 });
847
+ }),
848
+ );
849
+
850
+ const request = buildUploadRequest({
851
+ [uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
852
+ });
853
+ request.headers.set("Origin", "https://origin.example");
854
+
855
+ const response = await handler(ctx, request);
856
+ expect(response.status).toBe(202);
857
+ expect(response.headers.get("X-Hook")).toBe("true");
858
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBe(
859
+ "https://origin.example",
860
+ );
861
+ });
862
+
365
863
  test("upload route handles upstream failures", async () => {
864
+ const checkUploadRequest = mockCheckUploadRequest(["a"]);
366
865
  const router = createRouter();
367
- registerRoutes(router, component, { enableUploadRoute: true });
866
+ registerRoutes(router, component, { enableUploadRoute: true, checkUploadRequest });
368
867
  const uploadRoute = getRoute(router, "/files/upload", "POST");
369
868
  const handler = getHandler(uploadRoute.handler);
370
869
 
@@ -388,7 +887,6 @@ describe("registerRoutes", () => {
388
887
 
389
888
  const request = buildUploadRequest({
390
889
  [uploadFormFields.file]: new File(["file"], "filename", { type: "text/plain" }),
391
- [uploadFormFields.accessKeys]: JSON.stringify(["a"]),
392
890
  });
393
891
 
394
892
  const failedUpload = await handler(ctx, request);
@@ -451,7 +949,7 @@ describe("registerRoutes", () => {
451
949
  const response = await handler(
452
950
  ctx,
453
951
  buildDownloadRequest(
454
- "https://example.com/files/download?token=token&accessKey=key",
952
+ "https://example.com/files/download?token=token",
455
953
  ),
456
954
  );
457
955
  expect(response.status).toBe(entry.code);
@@ -533,6 +1031,35 @@ describe("registerRoutes", () => {
533
1031
  "attachment; filename=\"download\"",
534
1032
  );
535
1033
  });
1034
+
1035
+ test("download route omits Content-Type when upstream is missing it", async () => {
1036
+ const router = createRouter();
1037
+ registerRoutes(router, component);
1038
+ const downloadRoute = getRoute(router, "/files/download", "GET");
1039
+ const handler = getHandler(downloadRoute.handler);
1040
+
1041
+ const runMutation = vi.fn(async () => ({
1042
+ status: "ok",
1043
+ downloadUrl: "https://file.example.com",
1044
+ }));
1045
+ const ctx = makeCtx(runMutation);
1046
+
1047
+ vi.stubGlobal(
1048
+ "fetch",
1049
+ vi.fn(async () => ({
1050
+ ok: true,
1051
+ body: "file",
1052
+ headers: new Headers(),
1053
+ }) as any),
1054
+ );
1055
+
1056
+ const response = await handler(
1057
+ ctx,
1058
+ buildDownloadRequest("https://example.com/files/download?token=token"),
1059
+ );
1060
+
1061
+ expect(response.status).toBe(200);
1062
+ });
536
1063
  });
537
1064
 
538
1065
  describe("helpers", () => {
@@ -540,11 +1067,10 @@ describe("helpers", () => {
540
1067
  const url = buildDownloadUrl({
541
1068
  baseUrl: "https://example.com/",
542
1069
  downloadToken: "token",
543
- accessKey: "key",
544
1070
  filename: "file.txt",
545
1071
  });
546
1072
  expect(url).toBe(
547
- "https://example.com/files/download?token=token&accessKey=key&filename=file.txt",
1073
+ "https://example.com/files/download?token=token&filename=file.txt",
548
1074
  );
549
1075
  });
550
1076