@cedarjs/uploads 6.0.3-next.227

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 (113) hide show
  1. package/README.md +24 -0
  2. package/dist/authenticator.d.ts +38 -0
  3. package/dist/authenticator.d.ts.map +1 -0
  4. package/dist/authenticator.js +51 -0
  5. package/dist/cleanupStaleUploads.d.ts +50 -0
  6. package/dist/cleanupStaleUploads.d.ts.map +1 -0
  7. package/dist/cleanupStaleUploads.js +80 -0
  8. package/dist/constants.d.ts +7 -0
  9. package/dist/constants.d.ts.map +1 -0
  10. package/dist/constants.js +4 -0
  11. package/dist/deleteFile.d.ts +14 -0
  12. package/dist/deleteFile.d.ts.map +1 -0
  13. package/dist/deleteFile.js +18 -0
  14. package/dist/directives.d.ts +55 -0
  15. package/dist/directives.d.ts.map +1 -0
  16. package/dist/directives.js +174 -0
  17. package/dist/errors.d.ts +12 -0
  18. package/dist/errors.d.ts.map +1 -0
  19. package/dist/errors.js +33 -0
  20. package/dist/fastify/plugin.d.ts +49 -0
  21. package/dist/fastify/plugin.d.ts.map +1 -0
  22. package/dist/fastify/plugin.js +241 -0
  23. package/dist/index.d.ts +34 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +88 -0
  26. package/dist/keys.d.ts +18 -0
  27. package/dist/keys.d.ts.map +1 -0
  28. package/dist/keys.js +25 -0
  29. package/dist/pending.d.ts +39 -0
  30. package/dist/pending.d.ts.map +1 -0
  31. package/dist/pending.js +94 -0
  32. package/dist/profiles.d.ts +49 -0
  33. package/dist/profiles.d.ts.map +1 -0
  34. package/dist/profiles.js +68 -0
  35. package/dist/providers/db.d.ts +9 -0
  36. package/dist/providers/db.d.ts.map +1 -0
  37. package/dist/providers/db.js +41 -0
  38. package/dist/providers/fs.d.ts +34 -0
  39. package/dist/providers/fs.d.ts.map +1 -0
  40. package/dist/providers/fs.js +125 -0
  41. package/dist/providers/s3.d.ts +25 -0
  42. package/dist/providers/s3.d.ts.map +1 -0
  43. package/dist/providers/s3.js +117 -0
  44. package/dist/s3.d.ts +3 -0
  45. package/dist/s3.d.ts.map +1 -0
  46. package/dist/s3.js +4 -0
  47. package/dist/serialize.d.ts +10 -0
  48. package/dist/serialize.d.ts.map +1 -0
  49. package/dist/serialize.js +22 -0
  50. package/dist/serveToken.d.ts +36 -0
  51. package/dist/serveToken.d.ts.map +1 -0
  52. package/dist/serveToken.js +45 -0
  53. package/dist/services.d.ts +70 -0
  54. package/dist/services.d.ts.map +1 -0
  55. package/dist/services.js +141 -0
  56. package/dist/storeFile.d.ts +27 -0
  57. package/dist/storeFile.d.ts.map +1 -0
  58. package/dist/storeFile.js +53 -0
  59. package/dist/targets.d.ts +14 -0
  60. package/dist/targets.d.ts.map +1 -0
  61. package/dist/targets.js +21 -0
  62. package/dist/types.d.ts +212 -0
  63. package/dist/types.d.ts.map +1 -0
  64. package/dist/types.js +0 -0
  65. package/dist/uploadLoader.d.ts +11 -0
  66. package/dist/uploadLoader.d.ts.map +1 -0
  67. package/dist/uploadLoader.js +47 -0
  68. package/dist/uploadToken.d.ts +43 -0
  69. package/dist/uploadToken.d.ts.map +1 -0
  70. package/dist/uploadToken.js +61 -0
  71. package/dist/web/components/DbInput.d.ts +18 -0
  72. package/dist/web/components/DbInput.d.ts.map +1 -0
  73. package/dist/web/components/DbInput.js +46 -0
  74. package/dist/web/components/FsUploader.d.ts +6 -0
  75. package/dist/web/components/FsUploader.d.ts.map +1 -0
  76. package/dist/web/components/FsUploader.js +21 -0
  77. package/dist/web/components/S3Uploader.d.ts +6 -0
  78. package/dist/web/components/S3Uploader.d.ts.map +1 -0
  79. package/dist/web/components/S3Uploader.js +21 -0
  80. package/dist/web/components/UppyUploader.d.ts +23 -0
  81. package/dist/web/components/UppyUploader.d.ts.map +1 -0
  82. package/dist/web/components/UppyUploader.js +72 -0
  83. package/dist/web/createUppy.d.ts +62 -0
  84. package/dist/web/createUppy.d.ts.map +1 -0
  85. package/dist/web/createUppy.js +82 -0
  86. package/dist/web/graphql.d.ts +48 -0
  87. package/dist/web/graphql.d.ts.map +1 -0
  88. package/dist/web/graphql.js +36 -0
  89. package/dist/web/hooks/useDbUpload.d.ts +31 -0
  90. package/dist/web/hooks/useDbUpload.d.ts.map +1 -0
  91. package/dist/web/hooks/useDbUpload.js +80 -0
  92. package/dist/web/hooks/useFsUpload.d.ts +20 -0
  93. package/dist/web/hooks/useFsUpload.d.ts.map +1 -0
  94. package/dist/web/hooks/useFsUpload.js +58 -0
  95. package/dist/web/hooks/useS3Upload.d.ts +23 -0
  96. package/dist/web/hooks/useS3Upload.d.ts.map +1 -0
  97. package/dist/web/hooks/useS3Upload.js +99 -0
  98. package/dist/web/hooks/useUploadToken.d.ts +34 -0
  99. package/dist/web/hooks/useUploadToken.d.ts.map +1 -0
  100. package/dist/web/hooks/useUploadToken.js +60 -0
  101. package/dist/web/hooks/useUppyUpload.d.ts +23 -0
  102. package/dist/web/hooks/useUppyUpload.d.ts.map +1 -0
  103. package/dist/web/hooks/useUppyUpload.js +76 -0
  104. package/dist/web/index.d.ts +22 -0
  105. package/dist/web/index.d.ts.map +1 -0
  106. package/dist/web/index.js +36 -0
  107. package/dist/webhooks/s3.d.ts +52 -0
  108. package/dist/webhooks/s3.d.ts.map +1 -0
  109. package/dist/webhooks/s3.js +122 -0
  110. package/dist/webhooks/sns.d.ts +34 -0
  111. package/dist/webhooks/sns.d.ts.map +1 -0
  112. package/dist/webhooks/sns.js +131 -0
  113. package/package.json +147 -0
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @cedarjs/uploads
2
+
3
+ File uploads and storage for CedarJS apps.
4
+
5
+ - **Named storage targets** backed by providers for S3 (`@cedarjs/uploads/s3`),
6
+ the local filesystem, and the database, all implementing one small
7
+ `StorageProvider` contract.
8
+ - **Upload profiles** that the server owns: clients name a profile, the server
9
+ signs its constraints into a short-lived, user-bound upload token.
10
+ - **A Fastify plugin** with token-gated routes for filesystem uploads,
11
+ signed-URL file serving, and S3 event webhooks.
12
+ - **GraphQL directives** (`@requireUploadToken`, `@withSignedUrl`,
13
+ `@withDataUri`) and service helpers for issuing tokens, presigning direct
14
+ uploads, and confirming them.
15
+ - **Lifecycle utilities** (`storeFile`, `deleteFile`, `cleanupStaleUploads`) for
16
+ server-generated files and explicit cleanup.
17
+ - **Web hooks and components** at `@cedarjs/uploads/web`, built on Uppy.
18
+
19
+ ```bash
20
+ yarn cedar setup uploads
21
+ ```
22
+
23
+ See the [uploads documentation](https://cedarjs.com/docs/uploads) for the full
24
+ guide.
@@ -0,0 +1,38 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+ import type { Decoder } from '@cedarjs/api';
3
+ /** The identity an upload route resolves for a request. */
4
+ export interface UploadRequestUser {
5
+ id: string;
6
+ organizationId?: string;
7
+ }
8
+ export type UploadAuthenticator = (req: FastifyRequest) => Promise<UploadRequestUser | null>;
9
+ export interface CreateUploadAuthenticatorOptions {
10
+ /** The app's auth decoder, for example from `@cedarjs/auth-dbauth-api`. */
11
+ authDecoder: Decoder | Decoder[];
12
+ /**
13
+ * The app's `getCurrentUser`, as exported from `api/src/lib/auth`. It
14
+ * receives the decoded session exactly as the GraphQL server passes it.
15
+ */
16
+ getCurrentUser: (decoded: unknown, raw: {
17
+ type: string;
18
+ schema: string;
19
+ token: string;
20
+ }, req: {
21
+ event: Request;
22
+ request: Request;
23
+ }) => Promise<unknown>;
24
+ /**
25
+ * Picks the organization id off the current user, for multi-tenant apps.
26
+ * Defaults to a top-level `organizationId` property when present.
27
+ */
28
+ getOrganizationId?: (currentUser: unknown) => string | undefined;
29
+ }
30
+ /**
31
+ * Builds the `authenticate` callback for the upload plugin from the same
32
+ * pieces the GraphQL server uses: the request's auth header is decoded with
33
+ * the app's auth decoder and the result handed to the app's
34
+ * `getCurrentUser`. With it in place the upload routes reject a token whose
35
+ * `sub` belongs to someone other than the requester.
36
+ */
37
+ export declare function createUploadAuthenticator({ authDecoder, getCurrentUser, getOrganizationId, }: CreateUploadAuthenticatorOptions): UploadAuthenticator;
38
+ //# sourceMappingURL=authenticator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"authenticator.d.ts","sourceRoot":"","sources":["../src/authenticator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAG7C,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AAE3C,2DAA2D;AAC3D,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAA;IACV,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,MAAM,mBAAmB,GAAG,CAChC,GAAG,EAAE,cAAc,KAChB,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAA;AAEtC,MAAM,WAAW,gCAAgC;IAC/C,2EAA2E;IAC3E,WAAW,EAAE,OAAO,GAAG,OAAO,EAAE,CAAA;IAChC;;;OAGG;IACH,cAAc,EAAE,CACd,OAAO,EAAE,OAAO,EAChB,GAAG,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EACpD,GAAG,EAAE;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAE,KACtC,OAAO,CAAC,OAAO,CAAC,CAAA;IACrB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,CAAA;CACjE;AAkCD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,EACxC,WAAW,EACX,cAAc,EACd,iBAAyC,GAC1C,EAAE,gCAAgC,GAAG,mBAAmB,CA+BxD"}
@@ -0,0 +1,51 @@
1
+ import { getAuthenticationContext } from "@cedarjs/api";
2
+ function toWebRequest(req) {
3
+ const headers = new Headers();
4
+ for (const [name, value] of Object.entries(req.headers)) {
5
+ if (typeof value === "string") {
6
+ headers.set(name, value);
7
+ } else if (Array.isArray(value)) {
8
+ headers.set(name, value.join(", "));
9
+ }
10
+ }
11
+ const host = req.headers.host ?? "localhost";
12
+ return new Request(`${req.protocol}://${host}${req.url}`, {
13
+ method: "GET",
14
+ headers
15
+ });
16
+ }
17
+ function defaultOrganizationId(user) {
18
+ if (typeof user === "object" && user !== null && "organizationId" in user && typeof user.organizationId === "string") {
19
+ return user.organizationId;
20
+ }
21
+ return void 0;
22
+ }
23
+ function createUploadAuthenticator({
24
+ authDecoder,
25
+ getCurrentUser,
26
+ getOrganizationId = defaultOrganizationId
27
+ }) {
28
+ return async (req) => {
29
+ const request = toWebRequest(req);
30
+ const authContext = await getAuthenticationContext({
31
+ authDecoder,
32
+ event: request
33
+ });
34
+ if (!authContext) {
35
+ return null;
36
+ }
37
+ const [decoded, raw] = authContext;
38
+ const user = await getCurrentUser(decoded, raw, { event: request, request });
39
+ if (typeof user !== "object" || user === null || !("id" in user) || typeof user.id !== "string" && typeof user.id !== "number") {
40
+ return null;
41
+ }
42
+ const organizationId = getOrganizationId(user);
43
+ return {
44
+ id: String(user.id),
45
+ ...organizationId ? { organizationId } : {}
46
+ };
47
+ };
48
+ }
49
+ export {
50
+ createUploadAuthenticator
51
+ };
@@ -0,0 +1,50 @@
1
+ import type { StorageTargets, UploadDatabase, UploadRecord } from './types.js';
2
+ export interface CleanupStaleUploadsOptions {
3
+ db: UploadDatabase;
4
+ targets: StorageTargets;
5
+ /**
6
+ * Rows still `pending` after this many milliseconds are claimed as
7
+ * `failed`. Defaults to one hour, well past the five-minute validity of a
8
+ * presigned upload URL.
9
+ */
10
+ olderThan?: number;
11
+ /**
12
+ * `failed` rows that changed within this many milliseconds are re-checked
13
+ * and their bytes deleted if they landed after the claim. Measured from
14
+ * the row's last update (the claim, or the last failed attempt), so a row
15
+ * claimed long after it was created still gets its retries. Defaults to
16
+ * one day.
17
+ */
18
+ retryWindow?: number;
19
+ /** Largest batch of rows to process per call. Defaults to 500. */
20
+ batchSize?: number;
21
+ /**
22
+ * Called for each row whose storage operation failed. Defaults to
23
+ * `console.error`. The sweep continues with the next row either way.
24
+ */
25
+ onError?: (error: unknown, upload: UploadRecord) => void;
26
+ }
27
+ export interface CleanupStaleUploadsResult {
28
+ /** Rows moved from `pending` to `failed` by this run. */
29
+ claimed: number;
30
+ /** Objects deleted from storage by this run. */
31
+ deleted: number;
32
+ /** Rows whose storage operation failed and will be retried next run. */
33
+ errors: number;
34
+ }
35
+ /**
36
+ * Sweeps stale uploads. Presigned uploads that were issued but never
37
+ * completed, and route uploads that crashed between row creation and write,
38
+ * leave `pending` rows behind. This claims them with a conditional
39
+ * `pending` to `failed` update, deletes any bytes that did land, and keeps
40
+ * the rows as `failed` tombstones so byte deletion can be retried on the
41
+ * next run. A row that completes mid-sweep is skipped because its claim
42
+ * matches zero rows, and a claimed row can no longer complete.
43
+ *
44
+ * Once a tombstone's bytes are confirmed gone its `storageKey` is cleared,
45
+ * which drops it from the next run's re-check. A row whose target is
46
+ * unknown or whose provider fails is reported through `onError` and left
47
+ * for the next run; it never aborts the sweep. Run it from a recurring job.
48
+ */
49
+ export declare function cleanupStaleUploads({ db, targets, olderThan, retryWindow, batchSize, onError, }: CleanupStaleUploadsOptions): Promise<CleanupStaleUploadsResult>;
50
+ //# sourceMappingURL=cleanupStaleUploads.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cleanupStaleUploads.d.ts","sourceRoot":"","sources":["../src/cleanupStaleUploads.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAK9E,MAAM,WAAW,0BAA0B;IACzC,EAAE,EAAE,cAAc,CAAA;IAClB,OAAO,EAAE,cAAc,CAAA;IACvB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,KAAK,IAAI,CAAA;CACzD;AAED,MAAM,WAAW,yBAAyB;IACxC,yDAAyD;IACzD,OAAO,EAAE,MAAM,CAAA;IACf,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAA;IACf,wEAAwE;IACxE,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,mBAAmB,CAAC,EACxC,EAAE,EACF,OAAO,EACP,SAAoB,EACpB,WAAqB,EACrB,SAAe,EACf,OAC0E,GAC3E,EAAE,0BAA0B,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAmFjE"}
@@ -0,0 +1,80 @@
1
+ import { resolveTarget } from "./targets.js";
2
+ const ONE_HOUR = 60 * 60 * 1e3;
3
+ const ONE_DAY = 24 * ONE_HOUR;
4
+ async function cleanupStaleUploads({
5
+ db,
6
+ targets,
7
+ olderThan = ONE_HOUR,
8
+ retryWindow = ONE_DAY,
9
+ batchSize = 500,
10
+ onError = (error, upload) => console.error(`[cedar uploads] cleanup failed for ${upload.id}:`, error)
11
+ }) {
12
+ const now = Date.now();
13
+ let claimed = 0;
14
+ let deleted = 0;
15
+ let errors = 0;
16
+ const claimedIds = /* @__PURE__ */ new Set();
17
+ const reclaim = async (upload) => {
18
+ if (!upload.storageKey) {
19
+ return false;
20
+ }
21
+ try {
22
+ const provider = resolveTarget(targets, upload.target);
23
+ const existed = await provider.exists(upload.storageKey);
24
+ if (existed) {
25
+ await provider.delete(upload.storageKey);
26
+ }
27
+ await db.upload.updateMany({
28
+ where: { id: upload.id, status: "failed" },
29
+ data: { storageKey: null }
30
+ });
31
+ return existed;
32
+ } catch (e) {
33
+ errors += 1;
34
+ onError(e, upload);
35
+ return false;
36
+ }
37
+ };
38
+ const stale = await db.upload.findMany({
39
+ where: { status: "pending", createdAt: { lt: new Date(now - olderThan) } },
40
+ omit: { data: true },
41
+ orderBy: { createdAt: "asc" },
42
+ take: batchSize
43
+ });
44
+ for (const upload of stale) {
45
+ const { count } = await db.upload.updateMany({
46
+ where: { id: upload.id, status: "pending" },
47
+ data: { status: "failed" }
48
+ });
49
+ if (count !== 1) {
50
+ continue;
51
+ }
52
+ claimed += 1;
53
+ claimedIds.add(upload.id);
54
+ if (await reclaim(upload)) {
55
+ deleted += 1;
56
+ }
57
+ }
58
+ const tombstones = await db.upload.findMany({
59
+ where: {
60
+ status: "failed",
61
+ storageKey: { not: null },
62
+ updatedAt: { gte: new Date(now - retryWindow) }
63
+ },
64
+ omit: { data: true },
65
+ orderBy: { createdAt: "asc" },
66
+ take: batchSize
67
+ });
68
+ for (const upload of tombstones) {
69
+ if (claimedIds.has(upload.id)) {
70
+ continue;
71
+ }
72
+ if (await reclaim(upload)) {
73
+ deleted += 1;
74
+ }
75
+ }
76
+ return { claimed, deleted, errors };
77
+ }
78
+ export {
79
+ cleanupStaleUploads
80
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Values shared by the api and web entry points. This module must stay free
3
+ * of Node-only imports because the web bundle includes it.
4
+ */
5
+ /** Header the client sends an upload token in. */
6
+ export declare const UPLOAD_TOKEN_HEADER = "x-upload-token";
7
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,kDAAkD;AAClD,eAAO,MAAM,mBAAmB,mBAAmB,CAAA"}
@@ -0,0 +1,4 @@
1
+ const UPLOAD_TOKEN_HEADER = "x-upload-token";
2
+ export {
3
+ UPLOAD_TOKEN_HEADER
4
+ };
@@ -0,0 +1,14 @@
1
+ import type { StorageProvider, UploadDatabase, UploadRecord } from './types.js';
2
+ export interface DeleteFileOptions {
3
+ db: UploadDatabase;
4
+ upload: Pick<UploadRecord, 'id' | 'storageKey'>;
5
+ }
6
+ /**
7
+ * Deletes a stored file: bytes first, then the `Upload` row. Bytes-first
8
+ * ordering means a crash between the two steps leaves a row whose object is
9
+ * gone, which the app can see and fix by calling again, whereas the reverse
10
+ * order would leave an unreachable object nothing references. A missing
11
+ * object or row is tolerated, so the call is idempotent.
12
+ */
13
+ export declare function deleteFile(target: StorageProvider, { db, upload }: DeleteFileOptions): Promise<void>;
14
+ //# sourceMappingURL=deleteFile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deleteFile.d.ts","sourceRoot":"","sources":["../src/deleteFile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAE/E,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,cAAc,CAAA;IAClB,MAAM,EAAE,IAAI,CAAC,YAAY,EAAE,IAAI,GAAG,YAAY,CAAC,CAAA;CAChD;AAQD;;;;;;GAMG;AACH,wBAAsB,UAAU,CAC9B,MAAM,EAAE,eAAe,EACvB,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,iBAAiB,GAChC,OAAO,CAAC,IAAI,CAAC,CAYf"}
@@ -0,0 +1,18 @@
1
+ function isRecordNotFound(e) {
2
+ return typeof e === "object" && e !== null && "code" in e && e.code === "P2025";
3
+ }
4
+ async function deleteFile(target, { db, upload }) {
5
+ if (upload.storageKey) {
6
+ await target.delete(upload.storageKey);
7
+ }
8
+ try {
9
+ await db.upload.delete({ where: { id: upload.id } });
10
+ } catch (e) {
11
+ if (!isRecordNotFound(e)) {
12
+ throw e;
13
+ }
14
+ }
15
+ }
16
+ export {
17
+ deleteFile
18
+ };
@@ -0,0 +1,55 @@
1
+ import type { TransformerDirective, ValidatorDirective } from '@cedarjs/graphql-server';
2
+ import type { ContentDisposition, StorageTargets, UploadDatabase } from './types.js';
3
+ import type { UploadTokenPayload } from './uploadToken.js';
4
+ /** Context key `@requireUploadToken` stores the validated token under. */
5
+ export declare const UPLOAD_TOKEN_CONTEXT_KEY = "uploadTokenPayload";
6
+ /**
7
+ * Reads the validated upload token `@requireUploadToken` attached to the
8
+ * GraphQL context. Throws when the resolver runs without the directive.
9
+ */
10
+ export declare function getUploadTokenPayload(context: Record<string, unknown>): UploadTokenPayload;
11
+ /** The GraphQL context, as directives receive it. */
12
+ export type DirectiveContext = Record<string, unknown>;
13
+ export interface RequireUploadTokenDirectiveOptions {
14
+ /** Secret upload tokens were signed with. */
15
+ secret: string;
16
+ /**
17
+ * Resolves the organization the request is acting under, for multi-tenant
18
+ * apps. Defaults to `context.currentUser.organizationId`.
19
+ */
20
+ getOrganizationId?: (context: DirectiveContext) => string | null | undefined;
21
+ }
22
+ export declare const requireUploadTokenSchema: import("graphql").DocumentNode;
23
+ /**
24
+ * Builds the `@requireUploadToken` validator directive. It verifies the
25
+ * token in the `x-upload-token` header, rejects a token issued to a
26
+ * different user or organization, and attaches the claims to the context
27
+ * for `getUploadTokenPayload()`.
28
+ */
29
+ export declare function createRequireUploadTokenDirective({ secret, getOrganizationId, }: RequireUploadTokenDirectiveOptions): ValidatorDirective;
30
+ export interface UploadUrlDirectiveOptions {
31
+ db: UploadDatabase;
32
+ targets: StorageTargets;
33
+ }
34
+ export interface WithSignedUrlDirectiveOptions extends UploadUrlDirectiveOptions {
35
+ /** Lifetime of generated URLs, in seconds. Provider default when unset. */
36
+ expiresIn?: number;
37
+ /** Defaults to `attachment`. */
38
+ disposition?: ContentDisposition;
39
+ }
40
+ export declare const withSignedUrlSchema: import("graphql").DocumentNode;
41
+ /**
42
+ * Builds the `@withSignedUrl` transformer directive. It turns an Upload id
43
+ * stored in a field into a signed URL from the row's target, or a `data:`
44
+ * URI when the target is the DB provider. Lookups are batched per request
45
+ * and never fetch inline `data` for object-storage rows.
46
+ */
47
+ export declare function createWithSignedUrlDirective({ db, targets, expiresIn, disposition, }: WithSignedUrlDirectiveOptions): TransformerDirective;
48
+ export declare const withDataUriSchema: import("graphql").DocumentNode;
49
+ /**
50
+ * Builds the `@withDataUri` transformer directive. It reads the file from
51
+ * whichever target holds it and returns a base64 `data:` URI. Use it for
52
+ * small files only; every resolved field embeds the whole file.
53
+ */
54
+ export declare function createWithDataUriDirective({ db, targets, }: UploadUrlDirectiveOptions): TransformerDirective;
55
+ //# sourceMappingURL=directives.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"directives.d.ts","sourceRoot":"","sources":["../src/directives.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EACV,oBAAoB,EACpB,kBAAkB,EAEnB,MAAM,yBAAyB,CAAA;AAKhC,OAAO,KAAK,EACV,kBAAkB,EAClB,cAAc,EACd,cAAc,EAEf,MAAM,YAAY,CAAA;AAGnB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAA;AAE1D,0EAA0E;AAC1E,eAAO,MAAM,wBAAwB,uBAAuB,CAAA;AAE5D;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,kBAAkB,CAcpB;AAID,qDAAqD;AACrD,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;AA8CtD,MAAM,WAAW,kCAAkC;IACjD,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;CAC7E;AAED,eAAO,MAAM,wBAAwB,gCAMnC,CAAA;AAEF;;;;;GAKG;AACH,wBAAgB,iCAAiC,CAAC,EAChD,MAAM,EACN,iBAAiB,GAClB,EAAE,kCAAkC,GAAG,kBAAkB,CA8CzD;AAED,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,cAAc,CAAA;IAClB,OAAO,EAAE,cAAc,CAAA;CACxB;AAED,MAAM,WAAW,6BAA8B,SAAQ,yBAAyB;IAC9E,2EAA2E;IAC3E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,gCAAgC;IAChC,WAAW,CAAC,EAAE,kBAAkB,CAAA;CACjC;AAED,eAAO,MAAM,mBAAmB,gCAM9B,CAAA;AA6BF;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,EAC3C,EAAE,EACF,OAAO,EACP,SAAS,EACT,WAAW,GACZ,EAAE,6BAA6B,GAAG,oBAAoB,CA2BtD;AAED,eAAO,MAAM,iBAAiB,gCAM5B,CAAA;AAEF;;;;GAIG;AACH,wBAAgB,0BAA0B,CAAC,EACzC,EAAE,EACF,OAAO,GACR,EAAE,yBAAyB,GAAG,oBAAoB,CAsBlD"}
@@ -0,0 +1,174 @@
1
+ import { parse } from "graphql";
2
+ import { getEventHeader } from "@cedarjs/api";
3
+ import {
4
+ AuthenticationError,
5
+ createTransformerDirective,
6
+ createValidatorDirective,
7
+ ForbiddenError
8
+ } from "@cedarjs/graphql-server";
9
+ import { UploadError } from "./errors.js";
10
+ import { toDataUri } from "./serialize.js";
11
+ import { resolveTarget } from "./targets.js";
12
+ import { loadUpload } from "./uploadLoader.js";
13
+ import { UPLOAD_TOKEN_HEADER, verifyUploadToken } from "./uploadToken.js";
14
+ const UPLOAD_TOKEN_CONTEXT_KEY = "uploadTokenPayload";
15
+ function getUploadTokenPayload(context) {
16
+ const payload = context[UPLOAD_TOKEN_CONTEXT_KEY];
17
+ if (!payload) {
18
+ throw new UploadError(
19
+ "INVALID_TOKEN",
20
+ "No validated upload token on the GraphQL context. Add the `@requireUploadToken` directive to this field."
21
+ );
22
+ }
23
+ return payload;
24
+ }
25
+ function isLambdaEvent(value) {
26
+ return typeof value === "object" && value !== null && "headers" in value && typeof value.headers === "object";
27
+ }
28
+ function requestSourceOf(context) {
29
+ if (context.request instanceof Request) {
30
+ return context.request;
31
+ }
32
+ if (isLambdaEvent(context.event)) {
33
+ return context.event;
34
+ }
35
+ return null;
36
+ }
37
+ function currentUserOf(context) {
38
+ const user = context.currentUser;
39
+ if (!user || typeof user !== "object" || !("id" in user)) {
40
+ return null;
41
+ }
42
+ const id = user.id;
43
+ if (typeof id !== "string" && typeof id !== "number") {
44
+ return null;
45
+ }
46
+ const organizationId = "organizationId" in user && typeof user.organizationId === "string" ? user.organizationId : null;
47
+ return { id, organizationId };
48
+ }
49
+ const requireUploadTokenSchema = parse(`
50
+ """
51
+ Use to require a valid upload token in the x-upload-token request header.
52
+ The token is bound to the user it was issued to.
53
+ """
54
+ directive @requireUploadToken on FIELD_DEFINITION
55
+ `);
56
+ function createRequireUploadTokenDirective({
57
+ secret,
58
+ getOrganizationId
59
+ }) {
60
+ const validate = ({ context }) => {
61
+ const source = requestSourceOf(context);
62
+ const header = source ? getEventHeader(source, UPLOAD_TOKEN_HEADER) : null;
63
+ if (!header) {
64
+ throw new AuthenticationError("Missing upload token.");
65
+ }
66
+ let payload;
67
+ try {
68
+ payload = verifyUploadToken(header, { secret });
69
+ } catch (e) {
70
+ const message = e instanceof Error ? e.message : String(e);
71
+ throw new AuthenticationError(message);
72
+ }
73
+ const user = currentUserOf(context);
74
+ if (!user) {
75
+ throw new AuthenticationError(
76
+ "You must be logged in to use an upload token."
77
+ );
78
+ }
79
+ if (String(user.id) !== payload.sub) {
80
+ throw new ForbiddenError("Upload token was issued to a different user.");
81
+ }
82
+ const organizationId = getOrganizationId ? getOrganizationId(context) : user.organizationId;
83
+ if (payload.organizationId && payload.organizationId !== organizationId) {
84
+ throw new ForbiddenError(
85
+ "Upload token was issued for a different organization."
86
+ );
87
+ }
88
+ context[UPLOAD_TOKEN_CONTEXT_KEY] = payload;
89
+ };
90
+ return createValidatorDirective(requireUploadTokenSchema, validate);
91
+ }
92
+ const withSignedUrlSchema = parse(`
93
+ """
94
+ Use on a field that holds an Upload id to resolve it to a time-limited URL
95
+ for the stored file.
96
+ """
97
+ directive @withSignedUrl on FIELD_DEFINITION
98
+ `);
99
+ async function loadCompletedUpload(context, db, resolvedValue) {
100
+ if (typeof resolvedValue !== "string" || resolvedValue.length === 0) {
101
+ return null;
102
+ }
103
+ const upload = await loadUpload(context, db, resolvedValue);
104
+ if (upload?.status !== "completed") {
105
+ return null;
106
+ }
107
+ return upload;
108
+ }
109
+ async function loadInlineData(db, upload) {
110
+ const row = await db.upload.findUnique({ where: { id: upload.id } });
111
+ return row?.data ?? null;
112
+ }
113
+ function createWithSignedUrlDirective({
114
+ db,
115
+ targets,
116
+ expiresIn,
117
+ disposition
118
+ }) {
119
+ return createTransformerDirective(
120
+ withSignedUrlSchema,
121
+ async ({ context, resolvedValue }) => {
122
+ const upload = await loadCompletedUpload(context, db, resolvedValue);
123
+ if (!upload) {
124
+ return null;
125
+ }
126
+ const target = resolveTarget(targets, upload.target);
127
+ if (target.providerType === "db") {
128
+ const data = await loadInlineData(db, upload);
129
+ return data ? toDataUri(upload.mimeType, data) : null;
130
+ }
131
+ if (!upload.storageKey) {
132
+ return null;
133
+ }
134
+ return target.getSignedReadUrl(upload.storageKey, {
135
+ expiresIn,
136
+ disposition
137
+ });
138
+ }
139
+ );
140
+ }
141
+ const withDataUriSchema = parse(`
142
+ """
143
+ Use on a field that holds an Upload id to resolve it to a data: URI of the
144
+ file's bytes. Suitable for small files only.
145
+ """
146
+ directive @withDataUri on FIELD_DEFINITION
147
+ `);
148
+ function createWithDataUriDirective({
149
+ db,
150
+ targets
151
+ }) {
152
+ return createTransformerDirective(
153
+ withDataUriSchema,
154
+ async ({ context, resolvedValue }) => {
155
+ const upload = await loadCompletedUpload(context, db, resolvedValue);
156
+ if (!upload) {
157
+ return null;
158
+ }
159
+ const target = resolveTarget(targets, upload.target);
160
+ const data = target.providerType === "db" ? await loadInlineData(db, upload) : upload.storageKey ? await target.read(upload.storageKey) : null;
161
+ return data ? toDataUri(upload.mimeType, data) : null;
162
+ }
163
+ );
164
+ }
165
+ export {
166
+ UPLOAD_TOKEN_CONTEXT_KEY,
167
+ createRequireUploadTokenDirective,
168
+ createWithDataUriDirective,
169
+ createWithSignedUrlDirective,
170
+ getUploadTokenPayload,
171
+ requireUploadTokenSchema,
172
+ withDataUriSchema,
173
+ withSignedUrlSchema
174
+ };
@@ -0,0 +1,12 @@
1
+ export type UploadErrorCode = 'CONFIGURATION' | 'UNKNOWN_TARGET' | 'UNKNOWN_PROFILE' | 'UNAUTHENTICATED' | 'INVALID_TOKEN' | 'TOKEN_USER_MISMATCH' | 'TOKEN_ORGANIZATION_MISMATCH' | 'MIME_TYPE_NOT_ALLOWED' | 'FILE_TOO_LARGE' | 'TOO_MANY_FILES' | 'PRESIGN_NOT_SUPPORTED' | 'NOT_SUPPORTED' | 'NOT_FOUND' | 'FORBIDDEN' | 'NOT_PENDING' | 'NOT_IN_STORAGE' | 'SIZE_MISMATCH' | 'INVALID_KEY';
2
+ /**
3
+ * Thrown by every part of the uploads package when a request cannot be
4
+ * honored. `code` is stable for programmatic handling and `statusCode` is the
5
+ * HTTP status the Fastify routes respond with.
6
+ */
7
+ export declare class UploadError extends Error {
8
+ code: UploadErrorCode;
9
+ statusCode: number;
10
+ constructor(code: UploadErrorCode, message: string, cause?: unknown);
11
+ }
12
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GACvB,eAAe,GACf,gBAAgB,GAChB,iBAAiB,GACjB,iBAAiB,GACjB,eAAe,GACf,qBAAqB,GACrB,6BAA6B,GAC7B,uBAAuB,GACvB,gBAAgB,GAChB,gBAAgB,GAChB,uBAAuB,GACvB,eAAe,GACf,WAAW,GACX,WAAW,GACX,aAAa,GACb,gBAAgB,GAChB,eAAe,GACf,aAAa,CAAA;AAuBjB;;;;GAIG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,IAAI,EAAE,eAAe,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;gBAEN,IAAI,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO;CAMpE"}
package/dist/errors.js ADDED
@@ -0,0 +1,33 @@
1
+ const STATUS_CODES = {
2
+ CONFIGURATION: 500,
3
+ UNKNOWN_TARGET: 500,
4
+ UNKNOWN_PROFILE: 400,
5
+ UNAUTHENTICATED: 401,
6
+ INVALID_TOKEN: 401,
7
+ TOKEN_USER_MISMATCH: 403,
8
+ TOKEN_ORGANIZATION_MISMATCH: 403,
9
+ MIME_TYPE_NOT_ALLOWED: 415,
10
+ FILE_TOO_LARGE: 413,
11
+ TOO_MANY_FILES: 400,
12
+ PRESIGN_NOT_SUPPORTED: 400,
13
+ NOT_SUPPORTED: 400,
14
+ NOT_FOUND: 404,
15
+ FORBIDDEN: 403,
16
+ NOT_PENDING: 409,
17
+ NOT_IN_STORAGE: 409,
18
+ SIZE_MISMATCH: 409,
19
+ INVALID_KEY: 400
20
+ };
21
+ class UploadError extends Error {
22
+ code;
23
+ statusCode;
24
+ constructor(code, message, cause) {
25
+ super(message, cause === void 0 ? void 0 : { cause });
26
+ this.name = "UploadError";
27
+ this.code = code;
28
+ this.statusCode = STATUS_CODES[code];
29
+ }
30
+ }
31
+ export {
32
+ UploadError
33
+ };
@@ -0,0 +1,49 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { UploadAuthenticator } from '../authenticator.js';
3
+ import type { StorageTargets, UploadDatabase } from '../types.js';
4
+ import type { FetchCertificate } from '../webhooks/sns.js';
5
+ export interface UploadPluginOptions {
6
+ /** Secret upload tokens and serve URLs are signed with. */
7
+ tokenSecret: string;
8
+ /** The app's storage targets. */
9
+ targets: StorageTargets;
10
+ db: UploadDatabase;
11
+ /** Route prefix. Defaults to `/upload`. */
12
+ prefix?: string;
13
+ /**
14
+ * Hard outer ceiling on upload request size. Defaults to 500 MB. The
15
+ * effective bound per request comes from the token (`maxFiles` times
16
+ * `maxFileSize` plus a multipart envelope allowance), so this only needs
17
+ * to sit above the largest profile's aggregate.
18
+ */
19
+ bodyLimit?: number;
20
+ /**
21
+ * Resolves the requesting user. When configured, the upload routes reject
22
+ * unauthenticated requests and tokens issued to someone else. Build it
23
+ * with `createUploadAuthenticator()`. Without it the token itself is the
24
+ * only identity on the route, so configure it in any app with auth.
25
+ */
26
+ authenticate?: UploadAuthenticator;
27
+ /**
28
+ * Enables `POST {prefix}/webhook/s3` for S3 event notifications delivered
29
+ * through SNS. `topicArn` is the only topic accepted.
30
+ */
31
+ s3Webhook?: {
32
+ topicArn: string;
33
+ fetchCertificate?: FetchCertificate;
34
+ fetch?: typeof fetch;
35
+ };
36
+ /** `Cache-Control` for served files. Defaults to `private, max-age=3600`. */
37
+ serveCacheControl?: string;
38
+ }
39
+ /**
40
+ * Registers the upload routes on a Fastify server. Register it on the
41
+ * `server` returned by `createServer()` in `api/src/server.ts`:
42
+ *
43
+ * - `POST {prefix}/fs` accepts multipart uploads for object-storage targets
44
+ * - `GET {prefix}/serve?token=...` serves files behind signed URLs
45
+ * - `POST {prefix}/webhook/s3` receives S3 event notifications (opt-in)
46
+ * - `GET {prefix}/health` answers liveness checks
47
+ */
48
+ export declare function cedarUploadsPlugin(fastify: FastifyInstance, options: UploadPluginOptions): Promise<void>;
49
+ //# sourceMappingURL=plugin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../../src/fastify/plugin.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAgC,MAAM,SAAS,CAAA;AAE5E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAgB9D,OAAO,KAAK,EAEV,cAAc,EACd,cAAc,EAEf,MAAM,aAAa,CAAA;AAIpB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AA2B1D,MAAM,WAAW,mBAAmB;IAClC,2DAA2D;IAC3D,WAAW,EAAE,MAAM,CAAA;IACnB,iCAAiC;IACjC,OAAO,EAAE,cAAc,CAAA;IACvB,EAAE,EAAE,cAAc,CAAA;IAClB,2CAA2C;IAC3C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,mBAAmB,CAAA;IAClC;;;OAGG;IACH,SAAS,CAAC,EAAE;QACV,QAAQ,EAAE,MAAM,CAAA;QAChB,gBAAgB,CAAC,EAAE,gBAAgB,CAAA;QACnC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;KACrB,CAAA;IACD,6EAA6E;IAC7E,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAgCD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAwQf"}