uploaderkit 1.0.0 → 2.0.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +16 -0
  3. package/README.es.md +1140 -0
  4. package/README.md +1135 -0
  5. package/dist/FileViewer-ChTf7-uY.d.cts +49 -0
  6. package/dist/FileViewer-nuD-GEdJ.d.ts +49 -0
  7. package/dist/adapters/gcs.cjs +83 -0
  8. package/dist/adapters/gcs.cjs.map +1 -0
  9. package/dist/adapters/gcs.d.cts +31 -0
  10. package/dist/adapters/gcs.d.ts +31 -0
  11. package/dist/adapters/gcs.js +81 -0
  12. package/dist/adapters/gcs.js.map +1 -0
  13. package/dist/adapters/memory.cjs +27 -0
  14. package/dist/adapters/memory.cjs.map +1 -0
  15. package/dist/adapters/memory.d.cts +18 -0
  16. package/dist/adapters/memory.d.ts +18 -0
  17. package/dist/adapters/memory.js +25 -0
  18. package/dist/adapters/memory.js.map +1 -0
  19. package/dist/adapters/s3.cjs +75 -0
  20. package/dist/adapters/s3.cjs.map +1 -0
  21. package/dist/adapters/s3.d.cts +27 -0
  22. package/dist/adapters/s3.d.ts +27 -0
  23. package/dist/adapters/s3.js +73 -0
  24. package/dist/adapters/s3.js.map +1 -0
  25. package/dist/chunk-3FI44IOW.js +150 -0
  26. package/dist/chunk-3FI44IOW.js.map +1 -0
  27. package/dist/chunk-H7BRW5IY.js +308 -0
  28. package/dist/chunk-H7BRW5IY.js.map +1 -0
  29. package/dist/chunk-PDKAF4GX.js +707 -0
  30. package/dist/chunk-PDKAF4GX.js.map +1 -0
  31. package/dist/chunk-PTEX7F4R.js +309 -0
  32. package/dist/chunk-PTEX7F4R.js.map +1 -0
  33. package/dist/chunk-T5YZG6JW.js +511 -0
  34. package/dist/chunk-T5YZG6JW.js.map +1 -0
  35. package/dist/index.cjs +220 -27
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +57 -170
  38. package/dist/index.d.ts +57 -170
  39. package/dist/index.js +1 -324
  40. package/dist/index.js.map +1 -1
  41. package/dist/presets.cjs +1665 -0
  42. package/dist/presets.cjs.map +1 -0
  43. package/dist/presets.d.cts +100 -0
  44. package/dist/presets.d.ts +100 -0
  45. package/dist/presets.js +405 -0
  46. package/dist/presets.js.map +1 -0
  47. package/dist/react.cjs +981 -0
  48. package/dist/react.cjs.map +1 -0
  49. package/dist/react.d.cts +106 -0
  50. package/dist/react.d.ts +106 -0
  51. package/dist/react.js +5 -0
  52. package/dist/react.js.map +1 -0
  53. package/dist/server/express.cjs +179 -0
  54. package/dist/server/express.cjs.map +1 -0
  55. package/dist/server/express.d.cts +64 -0
  56. package/dist/server/express.d.ts +64 -0
  57. package/dist/server/express.js +111 -0
  58. package/dist/server/express.js.map +1 -0
  59. package/dist/server/next.cjs +165 -0
  60. package/dist/server/next.cjs.map +1 -0
  61. package/dist/server/next.d.cts +38 -0
  62. package/dist/server/next.d.ts +38 -0
  63. package/dist/server/next.js +107 -0
  64. package/dist/server/next.js.map +1 -0
  65. package/dist/server.cjs +550 -0
  66. package/dist/server.cjs.map +1 -0
  67. package/dist/server.d.cts +19 -0
  68. package/dist/server.d.ts +19 -0
  69. package/dist/server.js +80 -0
  70. package/dist/server.js.map +1 -0
  71. package/dist/storage-CYkSHWZX.d.cts +133 -0
  72. package/dist/storage-Qc9epG0G.d.ts +133 -0
  73. package/dist/types-BSlJJwti.d.cts +341 -0
  74. package/dist/types-BSlJJwti.d.ts +341 -0
  75. package/dist/ui.cjs +2325 -0
  76. package/dist/ui.cjs.map +1 -0
  77. package/dist/ui.d.cts +331 -0
  78. package/dist/ui.d.ts +331 -0
  79. package/dist/ui.js +749 -0
  80. package/dist/ui.js.map +1 -0
  81. package/dist/useSlottedUploader-BzT5jV8c.d.cts +139 -0
  82. package/dist/useSlottedUploader-IkG5RhEO.d.ts +139 -0
  83. package/dist/useUploader-BiBdS-7y.d.cts +117 -0
  84. package/dist/useUploader-CQHpj_oI.d.ts +117 -0
  85. package/package.json +189 -3
  86. package/tailwind.css +80 -0
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ var promises = require('stream/promises');
4
+ require('stream');
5
+
6
+ // src/server/express.ts
7
+ var MIME_TYPES = {
8
+ png: "image/png",
9
+ jpg: "image/jpeg",
10
+ jpeg: "image/jpeg",
11
+ svg: "image/svg+xml",
12
+ webp: "image/webp",
13
+ gif: "image/gif",
14
+ ico: "image/x-icon",
15
+ heic: "image/heic",
16
+ cer: "application/x-x509-ca-cert",
17
+ crt: "application/x-x509-ca-cert",
18
+ pem: "application/x-pem-file",
19
+ key: "application/x-pem-file",
20
+ pdf: "application/pdf",
21
+ doc: "application/msword",
22
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
23
+ xls: "application/vnd.ms-excel",
24
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
25
+ csv: "text/csv",
26
+ txt: "text/plain",
27
+ xml: "application/xml",
28
+ json: "application/json",
29
+ mp4: "video/mp4",
30
+ mov: "video/quicktime",
31
+ avi: "video/x-msvideo",
32
+ webm: "video/webm",
33
+ mp3: "audio/mpeg",
34
+ wav: "audio/wav",
35
+ ogg: "audio/ogg",
36
+ aac: "audio/aac"
37
+ };
38
+
39
+ // src/file.ts
40
+ var getFileExtension = (fileName) => {
41
+ const dot = fileName.lastIndexOf(".");
42
+ if (dot < 1 || dot === fileName.length - 1) return "";
43
+ return fileName.slice(dot + 1).toLowerCase();
44
+ };
45
+ var getMimeType = (fileName) => {
46
+ const ext = getFileExtension(fileName);
47
+ return MIME_TYPES[ext] ?? "application/octet-stream";
48
+ };
49
+ var fromMulterFile = (file) => ({
50
+ name: file.originalname,
51
+ size: file.size,
52
+ type: file.mimetype,
53
+ arrayBuffer: async () => file.buffer.buffer.slice(
54
+ file.buffer.byteOffset,
55
+ file.buffer.byteOffset + file.buffer.byteLength
56
+ )
57
+ });
58
+
59
+ // src/scopes.ts
60
+ var ScopeError = class extends Error {
61
+ name = "ScopeError";
62
+ };
63
+ var StorageRequestError = class extends Error {
64
+ constructor(message, status) {
65
+ super(message);
66
+ this.status = status;
67
+ }
68
+ status;
69
+ name = "StorageRequestError";
70
+ };
71
+
72
+ // src/server/express.ts
73
+ var param = (req, name, copy) => {
74
+ const value = req.params[name];
75
+ if (!value) throw new StorageRequestError(copy.requestIncomplete, 400);
76
+ return value;
77
+ };
78
+ var fail = (res, error, copy) => {
79
+ if (error instanceof StorageRequestError) {
80
+ res.status(error.status).json({ message: error.message });
81
+ return;
82
+ }
83
+ if (error instanceof ScopeError) throw error;
84
+ res.status(500).json({ message: copy.processingFailed });
85
+ };
86
+ var createExpressStorageHandlers = (storage, { authorize } = {}) => {
87
+ const copy = storage.labels;
88
+ const guard = async (req, scope, entityId) => {
89
+ if (!authorize) return {};
90
+ const user = await authorize(req, { scope, entityId });
91
+ if (user === null) throw new StorageRequestError(copy.unauthorized, 401);
92
+ return user;
93
+ };
94
+ return {
95
+ upload: async (req, res) => {
96
+ try {
97
+ const scope = param(req, "scope", copy);
98
+ const entityId = param(req, "entityId", copy);
99
+ const user = await guard(req, scope, entityId);
100
+ if (!req.file) throw new StorageRequestError(copy.fileRequired, 400);
101
+ const stored = await storage.upload({
102
+ scope,
103
+ entityId,
104
+ file: fromMulterFile(req.file),
105
+ ...user.userId ? { uploadedBy: user.userId } : {}
106
+ });
107
+ res.status(200).json(stored);
108
+ } catch (error) {
109
+ fail(res, error, copy);
110
+ }
111
+ },
112
+ remove: async (req, res) => {
113
+ try {
114
+ const scope = param(req, "scope", copy);
115
+ const entityId = param(req, "entityId", copy);
116
+ await guard(req, scope, entityId);
117
+ const key = req.body?.key;
118
+ if (!key) throw new StorageRequestError(copy.requestIncomplete, 400);
119
+ res.status(200).json({ deleted: await storage.remove({ scope, key }) });
120
+ } catch (error) {
121
+ fail(res, error, copy);
122
+ }
123
+ },
124
+ /**
125
+ * Serves the DECRYPTED bytes of an object, inline. The only readable
126
+ * route for encrypted scopes — their bucket objects are ciphertext, so
127
+ * neither the stored URL nor a signed one can render without this.
128
+ */
129
+ view: async (req, res) => {
130
+ try {
131
+ const scope = param(req, "scope", copy);
132
+ const entityId = param(req, "entityId", copy);
133
+ await guard(req, scope, entityId);
134
+ const key = req.query?.key;
135
+ if (typeof key !== "string" || !key) {
136
+ throw new StorageRequestError(copy.requestIncomplete, 400);
137
+ }
138
+ const body = await storage.readStream({ scope, key });
139
+ const fileName = key.split("/").pop() ?? copy.unnamedFile;
140
+ res.setHeader("Content-Type", getMimeType(fileName));
141
+ res.setHeader(
142
+ "Content-Disposition",
143
+ `${req.query?.download === "1" ? "attachment" : "inline"}; filename="${encodeURIComponent(fileName)}"`
144
+ );
145
+ res.setHeader("Cache-Control", "private, no-store");
146
+ try {
147
+ await promises.pipeline(body, res);
148
+ } catch {
149
+ return;
150
+ }
151
+ } catch (error) {
152
+ fail(res, error, copy);
153
+ }
154
+ },
155
+ signedUrl: async (req, res) => {
156
+ try {
157
+ const scope = param(req, "scope", copy);
158
+ const entityId = param(req, "entityId", copy);
159
+ await guard(req, scope, entityId);
160
+ const key = req.query?.key;
161
+ if (typeof key !== "string" || !key) {
162
+ throw new StorageRequestError(copy.requestIncomplete, 400);
163
+ }
164
+ const url = await storage.signedUrl({
165
+ scope,
166
+ key,
167
+ download: req.query?.download === "1"
168
+ });
169
+ res.status(200).json({ url });
170
+ } catch (error) {
171
+ fail(res, error, copy);
172
+ }
173
+ }
174
+ };
175
+ };
176
+
177
+ exports.createExpressStorageHandlers = createExpressStorageHandlers;
178
+ //# sourceMappingURL=express.cjs.map
179
+ //# sourceMappingURL=express.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/constants.ts","../../src/file.ts","../../src/scopes.ts","../../src/server/storage.ts","../../src/server/express.ts"],"names":["pipeline"],"mappings":"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","file":"express.cjs","sourcesContent":["import type { FileCategory, FileCategoryConfig, FileExtension } from './types'\n\nexport const KB = 1024\nexport const MB = 1024 * KB\nexport const GB = 1024 * MB\n\n/**\n * Leading bytes that identify a format regardless of its extension. The check\n * that uses them is the only defence against an executable renamed to `.pdf`,\n * since both the extension and the browser-reported MIME come from the client.\n */\nexport const MAGIC_NUMBERS: Partial<Record<FileExtension, number[]>> = {\n\tpng: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],\n\tjpg: [0xff, 0xd8, 0xff],\n\tjpeg: [0xff, 0xd8, 0xff],\n\tgif: [0x47, 0x49, 0x46, 0x38],\n\twebp: [0x52, 0x49, 0x46, 0x46], // RIFF; bytes 8-11 spell WEBP\n\tico: [0x00, 0x00, 0x01, 0x00],\n\n\tpdf: [0x25, 0x50, 0x44, 0x46], // %PDF\n\n\t// OOXML files are ZIP containers, so docx/xlsx share the PK signature.\n\tdocx: [0x50, 0x4b, 0x03, 0x04],\n\txlsx: [0x50, 0x4b, 0x03, 0x04],\n\tdoc: [0xd0, 0xcf, 0x11, 0xe0],\n\txls: [0xd0, 0xcf, 0x11, 0xe0],\n\n\t// DER/ASN.1 sequence header.\n\tcer: [0x30, 0x82],\n\tcrt: [0x30, 0x82],\n\tkey: [0x30, 0x82],\n\tpem: [0x2d, 0x2d, 0x2d, 0x2d, 0x2d], // -----\n\n\twebm: [0x1a, 0x45, 0xdf, 0xa3],\n\tavi: [0x52, 0x49, 0x46, 0x46],\n\tmp3: [0x49, 0x44, 0x33], // ID3\n\twav: [0x52, 0x49, 0x46, 0x46],\n\togg: [0x4f, 0x67, 0x67, 0x53],\n\taac: [0xff, 0xf1],\n}\n\nexport const MIME_TYPES: Record<FileExtension, string> = {\n\tpng: 'image/png',\n\tjpg: 'image/jpeg',\n\tjpeg: 'image/jpeg',\n\tsvg: 'image/svg+xml',\n\twebp: 'image/webp',\n\tgif: 'image/gif',\n\tico: 'image/x-icon',\n\theic: 'image/heic',\n\n\tcer: 'application/x-x509-ca-cert',\n\tcrt: 'application/x-x509-ca-cert',\n\tpem: 'application/x-pem-file',\n\tkey: 'application/x-pem-file',\n\n\tpdf: 'application/pdf',\n\tdoc: 'application/msword',\n\tdocx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n\txls: 'application/vnd.ms-excel',\n\txlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n\tcsv: 'text/csv',\n\ttxt: 'text/plain',\n\n\txml: 'application/xml',\n\tjson: 'application/json',\n\n\tmp4: 'video/mp4',\n\tmov: 'video/quicktime',\n\tavi: 'video/x-msvideo',\n\twebm: 'video/webm',\n\n\tmp3: 'audio/mpeg',\n\twav: 'audio/wav',\n\togg: 'audio/ogg',\n\taac: 'audio/aac',\n}\n\n/**\n * Defaults per category. A scope always narrows these; it can never widen the\n * extension list, which is what keeps `any` from leaking into a strict scope.\n */\nexport const FILE_CATEGORY_CONFIG: Record<FileCategory, FileCategoryConfig> = {\n\timage: {\n\t\textensions: ['png', 'jpg', 'jpeg', 'svg', 'webp', 'gif'],\n\t\tmaxBytes: 5 * MB,\n\t\tvalidateMagicNumbers: true,\n\t\tlabel: 'image',\n\t\taccept: 'image/*',\n\t},\n\tcertificate: {\n\t\textensions: ['cer', 'crt', 'pem'],\n\t\tmaxBytes: 50 * KB,\n\t\tvalidateMagicNumbers: true,\n\t\tlabel: 'certificate',\n\t\taccept: '.cer,.crt,.pem',\n\t},\n\tkey: {\n\t\textensions: ['key'],\n\t\tmaxBytes: 50 * KB,\n\t\tvalidateMagicNumbers: true,\n\t\tlabel: 'private key',\n\t\taccept: '.key',\n\t},\n\tpdf: {\n\t\textensions: ['pdf'],\n\t\tmaxBytes: 20 * MB,\n\t\tvalidateMagicNumbers: true,\n\t\tlabel: 'PDF',\n\t\taccept: '.pdf',\n\t},\n\tdocument: {\n\t\textensions: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt'],\n\t\tmaxBytes: 20 * MB,\n\t\tvalidateMagicNumbers: false,\n\t\tlabel: 'document',\n\t\taccept: '.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt',\n\t},\n\tdata: {\n\t\textensions: ['xml', 'json'],\n\t\tmaxBytes: 5 * MB,\n\t\tvalidateMagicNumbers: false,\n\t\tlabel: 'data file',\n\t\taccept: '.xml,.json',\n\t},\n\tvideo: {\n\t\textensions: ['mp4', 'mov', 'avi', 'webm'],\n\t\tmaxBytes: 100 * MB,\n\t\tvalidateMagicNumbers: false,\n\t\tlabel: 'video',\n\t\taccept: 'video/*',\n\t},\n\taudio: {\n\t\textensions: ['mp3', 'wav', 'ogg', 'aac'],\n\t\tmaxBytes: 20 * MB,\n\t\tvalidateMagicNumbers: false,\n\t\tlabel: 'audio',\n\t\taccept: 'audio/*',\n\t},\n\tany: {\n\t\textensions: [],\n\t\tmaxBytes: 20 * MB,\n\t\tvalidateMagicNumbers: false,\n\t\tlabel: 'file',\n\t\taccept: '*/*',\n\t},\n}\n","import { MIME_TYPES } from './constants'\nimport type { FileExtension, FileLike } from './types'\n\n/** Lower-case extension without the dot, or `''` when the name has none. */\nexport const getFileExtension = (fileName: string): string => {\n\tconst dot = fileName.lastIndexOf('.')\n\tif (dot < 1 || dot === fileName.length - 1) return ''\n\treturn fileName.slice(dot + 1).toLowerCase()\n}\n\nexport const getMimeType = (fileName: string): string => {\n\tconst ext = getFileExtension(fileName) as FileExtension\n\treturn MIME_TYPES[ext] ?? 'application/octet-stream'\n}\n\nexport const isKnownExtension = (value: string): value is FileExtension =>\n\tvalue in MIME_TYPES\n\nexport const formatFileSize = (bytes: number): string => {\n\tif (bytes < 1024) return `${bytes} B`\n\tconst units = ['KB', 'MB', 'GB', 'TB']\n\tlet size = bytes / 1024\n\tlet unit = 0\n\twhile (size >= 1024 && unit < units.length - 1) {\n\t\tsize /= 1024\n\t\tunit++\n\t}\n\treturn `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`\n}\n\n/** Longest stem kept. Object stores cap the whole key and the prefix spends part of it. */\nconst MAX_STEM_LENGTH = 80\n\n/** What an all-punctuation name degrades to, so a key never ends in a bare dot. */\nconst FALLBACK_STEM = 'file'\n\nconst slugSegment = (value: string): string =>\n\tvalue\n\t\t.normalize('NFD')\n\t\t.replace(/[\\u0300-\\u036f]/g, '')\n\t\t.toLowerCase()\n\t\t.replace(/[^a-z0-9]+/g, '-')\n\t\t.replace(/^-+|-+$/g, '')\n\n/**\n * Turns a user's file name into a safe key segment: ASCII, lower case, one\n * extension, no path syntax.\n *\n * Call it INSIDE your scope's `path()`, not around it:\n *\n * ```ts\n * path: (id, file) => `Docs/${id}/${sanitizeFileName(file.name)}`\n * ```\n *\n * `resolveKey` deliberately does not apply it for you. It would rewrite the key\n * your `path()` returned, and a client computing the same key to decide replace\n * mode would then disagree with the server. Inside `path()` both sides run the\n * one function and cannot drift.\n *\n * This exists because a macOS screenshot took an API down. `Screenshot\n * 2026-08-31 at 4.18.54 p.m..png` ends in `p.m.` + `.png`, so the raw name\n * carries `..`; the traversal guard rejected the key and the consumer dropped\n * the rejection, killing the process.\n *\n * Only the KEY is slugged — keep the original in `StoredFile.fileName`, which\n * is what a UI should render.\n */\nexport const sanitizeFileName = (fileName: string): string => {\n\t// A name arriving as a path is already an attempt, deliberate or not.\n\tconst base = fileName.split(/[/\\\\]/).pop() ?? ''\n\n\tconst dot = base.lastIndexOf('.')\n\t// A leading dot is a dotfile, not an extension: `.env` has no stem.\n\tconst hasExtension = dot > 0\n\tconst stem = slugSegment(hasExtension ? base.slice(0, dot) : base).slice(\n\t\t0,\n\t\tMAX_STEM_LENGTH\n\t)\n\tconst extension = hasExtension ? slugSegment(base.slice(dot + 1)) : ''\n\n\t// A trailing `-` can reappear when the length cut lands mid-separator.\n\tconst safeStem = stem.replace(/-+$/, '') || FALLBACK_STEM\n\n\treturn extension ? `${safeStem}.${extension}` : safeStem\n}\n\n/** Value for an `<input accept=\"…\">` built from an extension list. */\nexport const toAcceptAttribute = (extensions: FileExtension[]): string =>\n\textensions.length === 0\n\t\t? '*/*'\n\t\t: extensions.map(extension => `.${extension}`).join(',')\n\n/**\n * Adapts a multer memory-storage file to `FileLike` so server code validates\n * with the very same function the browser ran.\n */\nexport const fromMulterFile = (file: {\n\toriginalname: string\n\tsize: number\n\tmimetype: string\n\tbuffer: Uint8Array\n}): FileLike => ({\n\tname: file.originalname,\n\tsize: file.size,\n\ttype: file.mimetype,\n\tarrayBuffer: async () =>\n\t\tfile.buffer.buffer.slice(\n\t\t\tfile.buffer.byteOffset,\n\t\t\tfile.buffer.byteOffset + file.buffer.byteLength\n\t\t) as ArrayBuffer,\n})\n","import { FILE_CATEGORY_CONFIG } from './constants'\nimport { toAcceptAttribute } from './file'\nimport type { UploaderLabels } from './labels'\nimport type {\n\tFileLike,\n\tReplaceMode,\n\tScopeConfig,\n\tScopeRegistry,\n\tStorageProvider,\n\tValidationResult,\n} from './types'\nimport { validateFile } from './validation'\n\n/** Thrown for a definition or wiring mistake — never for a user's bad file. */\nexport class ScopeError extends Error {\n\toverride name = 'ScopeError'\n}\n\n/** Stand-in entity used to compare the SHAPE of two `path` callbacks. */\nconst PROBE_ENTITY = '__entity__'\n\nconst probeFile = (name: string): FileLike => ({\n\tname,\n\tsize: 0,\n\ttype: '',\n\tarrayBuffer: async () => new ArrayBuffer(0),\n})\n\n/** True when `path` returns the same key whatever the file is called. */\nexport const hasStableKey = (scope: ScopeConfig): boolean =>\n\tscope.path(PROBE_ENTITY, probeFile('a.png')) ===\n\tscope.path(PROBE_ENTITY, probeFile('b.png'))\n\n/**\n * The mode a scope runs in when it does not name one.\n *\n * @see AGENTS.md §3 — why a stable key needs no sweep and a name-carrying one does\n */\nexport const resolveReplaceMode = (scope: ScopeConfig): ReplaceMode => {\n\tif (scope.replace !== undefined) return scope.replace\n\tif ((scope.maxFiles ?? 1) > 1) return 'key'\n\treturn hasStableKey(scope) ? 'key' : 'entity'\n}\n\nconst dirname = (key: string) => {\n\tconst cut = key.lastIndexOf('/')\n\treturn cut === -1 ? '' : key.slice(0, cut)\n}\n\n/** Folder an `'entity'` replace is allowed to sweep. */\nexport const resolveScopePrefix = (\n\tscope: ScopeConfig,\n\tentityId: string\n): string =>\n\tscope.prefix?.(entityId) ??\n\tdirname(scope.path(entityId, probeFile('probe.bin')))\n\n/** `a` contains `b`, counting only whole path segments. */\nconst containsPath = (a: string, b: string) => b === a || b.startsWith(`${a}/`)\n\nconst assertDefinition = (name: string, scope: ScopeConfig): void => {\n\tif (typeof scope.path !== 'function') {\n\t\tthrow new ScopeError(`Scope \"${name}\": \"path\" must be a function`)\n\t}\n\tif (!Number.isFinite(scope.maxBytes) || scope.maxBytes <= 0) {\n\t\tthrow new ScopeError(\n\t\t\t`Scope \"${name}\": \"maxBytes\" must be a positive number`\n\t\t)\n\t}\n\tif (scope.accept.length === 0) {\n\t\tthrow new ScopeError(\n\t\t\t`Scope \"${name}\": \"accept\" cannot be empty — list the extensions this scope takes`\n\t\t)\n\t}\n\n\t// A scope narrows its category, never widens it. Otherwise a 'pdf' scope\n\t// could quietly start taking executables.\n\tif (scope.category && scope.category !== 'any') {\n\t\tconst allowed = FILE_CATEGORY_CONFIG[scope.category].extensions\n\t\tconst outside = scope.accept.filter(\n\t\t\textension => !allowed.includes(extension)\n\t\t)\n\t\tif (outside.length > 0) {\n\t\t\tthrow new ScopeError(\n\t\t\t\t`Scope \"${name}\": ${outside.join(', ')} not allowed by category \"${scope.category}\"`\n\t\t\t)\n\t\t}\n\t}\n\n\tif (scope.compress) {\n\t\tconst imageOnly = FILE_CATEGORY_CONFIG.image.extensions\n\t\tconst notImages = scope.accept.filter(\n\t\t\textension => !imageOnly.includes(extension)\n\t\t)\n\t\tif (notImages.length > 0) {\n\t\t\tthrow new ScopeError(\n\t\t\t\t`Scope \"${name}\": \"compress\" only applies to images, but it accepts ${notImages.join(', ')}`\n\t\t\t)\n\t\t}\n\t}\n\n\tif (scope.maxFiles !== undefined) {\n\t\tif (!Number.isInteger(scope.maxFiles) || scope.maxFiles < 1) {\n\t\t\tthrow new ScopeError(\n\t\t\t\t`Scope \"${name}\": \"maxFiles\" must be a positive integer, received ${String(scope.maxFiles)}`\n\t\t\t)\n\t\t}\n\t}\n\n\t// The contradiction the derived default exists to prevent: a scope that\n\t// holds a collection cannot also erase the collection on every upload.\n\tif (scope.replace === 'entity' && (scope.maxFiles ?? 1) > 1) {\n\t\tthrow new ScopeError(\n\t\t\t`Scope \"${name}\": replace \"entity\" contradicts maxFiles ${scope.maxFiles} — ` +\n\t\t\t\t'an entity-wide replace would delete the other files on the next upload. ' +\n\t\t\t\t\"Use 'key' to overwrite one slot, or false to keep every version.\"\n\t\t)\n\t}\n\n\tif (\n\t\tresolveReplaceMode(scope) === 'entity' &&\n\t\tresolveScopePrefix(scope, PROBE_ENTITY) === ''\n\t) {\n\t\tthrow new ScopeError(\n\t\t\t`Scope \"${name}\": replace \"entity\" needs a folder to sweep, but \"path\" resolves to a ` +\n\t\t\t\t'bucket-root key. Nest the key under a folder, or declare \"prefix\".'\n\t\t)\n\t}\n}\n\n/**\n * Rejects a registry where sweeping one scope would reach another's objects.\n *\n * `path` shapes are compared through a placeholder entity, so this catches the\n * real hazard — two scopes sharing a folder for the SAME entity — while two\n * scopes rooted at different entity folders stay independent. A false positive\n * is answered with an explicit `prefix`, and the message says so.\n */\nconst assertNoSweepCollision = (scopes: Record<string, ScopeConfig>): void => {\n\tconst entries = Object.entries(scopes)\n\n\tfor (const [name, scope] of entries) {\n\t\tif (resolveReplaceMode(scope) !== 'entity') continue\n\t\tconst prefix = resolveScopePrefix(scope, PROBE_ENTITY)\n\n\t\tfor (const [otherName, other] of entries) {\n\t\t\tif (otherName === name) continue\n\t\t\tconst otherKey = other.path(PROBE_ENTITY, probeFile('probe.bin'))\n\t\t\tif (!containsPath(prefix, otherKey)) continue\n\n\t\t\tthrow new ScopeError(\n\t\t\t\t`Scope \"${name}\" replaces per entity and would sweep \"${prefix}\", which also holds ` +\n\t\t\t\t\t`scope \"${otherName}\" — an upload to one would delete the other's files. ` +\n\t\t\t\t\t'Give each its own folder, or declare a narrower \"prefix\".'\n\t\t\t)\n\t\t}\n\t}\n}\n\n/**\n * Declares every destination an app can write to. The returned registry is\n * imported by the client (to configure inputs and pre-validate) and by the\n * server (to authorize and re-validate), which is what keeps the two in sync.\n *\n * Definitions are checked eagerly: a malformed scope throws at import time,\n * not on the first upload. That includes the two replace mistakes no runtime\n * check could recover from — an entity-wide replace on a collection, and two\n * scopes whose folders overlap, where an avatar upload would delete the same\n * user's documents.\n */\nexport const defineScopes = <T extends Record<string, ScopeConfig>>(\n\tscopes: T\n): ScopeRegistry<T> => {\n\tfor (const [name, scope] of Object.entries(scopes)) {\n\t\tassertDefinition(name, scope)\n\t}\n\tassertNoSweepCollision(scopes)\n\n\tconst names = Object.keys(scopes) as (keyof T & string)[]\n\n\tconst get = (name: string): ScopeConfig => {\n\t\tconst scope = scopes[name as keyof T]\n\t\tif (!scope) {\n\t\t\tthrow new ScopeError(\n\t\t\t\t`Unknown scope \"${name}\". Declared: ${names.join(', ')}`\n\t\t\t)\n\t\t}\n\t\treturn scope\n\t}\n\n\treturn {\n\t\tscopes,\n\t\tnames,\n\t\tget,\n\t\thas: name => name in scopes,\n\t\taccept: name => toAcceptAttribute(get(name).accept),\n\t}\n}\n\n/**\n * The one validation call. Client runs it for feedback, server runs it for\n * safety, both against the same scope.\n *\n * `labels` is what keeps the two answers identical in wording as well as in\n * verdict: the hook passes the copy it renders with, `createStorage` passes\n * the copy it was configured with.\n */\nexport const validateForScope = async <T extends Record<string, ScopeConfig>>(\n\tregistry: ScopeRegistry<T>,\n\tname: string,\n\tfile: FileLike,\n\tlabels?: Partial<UploaderLabels>\n): Promise<ValidationResult> => {\n\tconst scope = registry.get(name)\n\tconst category = scope.category\n\t\t? FILE_CATEGORY_CONFIG[scope.category]\n\t\t: undefined\n\n\treturn validateFile(file, {\n\t\tmaxBytes: scope.maxBytes,\n\t\tallowedExtensions: scope.accept,\n\t\tvalidateMagicNumbers: category?.validateMagicNumbers ?? true,\n\t\tlabels,\n\t})\n}\n\n/**\n * Fails fast when a provider cannot honour what the scopes promise — a private\n * scope on a provider that cannot sign URLs is a runtime 403 waiting to happen.\n */\nexport const assertProviderSupports = <T extends Record<string, ScopeConfig>>(\n\tregistry: ScopeRegistry<T>,\n\tprovider: StorageProvider\n): void => {\n\tconst needsSigning = registry.names.filter(\n\t\tname => registry.get(name).visibility === 'private'\n\t)\n\n\tif (needsSigning.length > 0 && !provider.capabilities.signedUrl) {\n\t\tthrow new ScopeError(\n\t\t\t`Provider \"${provider.name}\" cannot sign URLs, but these scopes are private: ${needsSigning.join(', ')}`\n\t\t)\n\t}\n}\n\n/**\n * Whether a key would escape its own prefix.\n *\n * Judged per SEGMENT. A `key.includes('..')` test reads as the same check and\n * is not: `report..pdf` and `Screenshot at 4.18.54 p.m..png` carry two dots\n * without ever being traversal, and a macOS screenshot is the common case, not\n * a corner one. Traversal needs a segment that IS `..`, so that is what this\n * asks.\n */\nconst escapesPrefix = (key: string): boolean =>\n\tkey.startsWith('/') ||\n\tkey.split('/').some(segment => segment === '..' || segment === '.')\n\n/**\n * Resolves the storage key for an upload.\n *\n * The guard is a backstop, not a sanitizer: it refuses a key rather than\n * rewriting one, because a client computing the same key to decide replace\n * mode would then disagree with the server. Run {@link sanitizeFileName}\n * inside the scope's `path()` and both sides stay identical.\n */\nexport const resolveKey = <T extends Record<string, ScopeConfig>>(\n\tregistry: ScopeRegistry<T>,\n\tname: string,\n\tentityId: string,\n\tfile: FileLike\n): string => {\n\tconst key = registry.get(name).path(entityId, file)\n\tif (escapesPrefix(key)) {\n\t\tthrow new ScopeError(`Scope \"${name}\" produced an unsafe key: ${key}`)\n\t}\n\treturn key\n}\n","import { Readable } from 'node:stream'\n\nimport { resolveLabels, type UploaderLabels } from '../labels'\nimport { resolveReplaceMode, resolveScopePrefix } from '../scopes'\nimport { resolveKey } from '../scopes'\nimport { assertProviderSupports, ScopeError, validateForScope } from '../scopes'\nimport type { ScopeConfig, ScopeRegistry } from '../types'\nimport type {\n\tCryptoHooks,\n\tFileLike,\n\tSignedUrlOptions,\n\tStorageProvider,\n\tStoredFile,\n} from '../types'\n\n/**\n * A request-level failure: bad file, unknown scope name from a URL, missing\n * part. Carries the HTTP status and a message safe to show the user — unlike\n * `ScopeError`, which flags wiring bugs and must stay dev-facing.\n */\nexport class StorageRequestError extends Error {\n\toverride name = 'StorageRequestError'\n\tconstructor(\n\t\tmessage: string,\n\t\treadonly status: number\n\t) {\n\t\tsuper(message)\n\t}\n}\n\nexport type CreateStorageOptions<T extends Record<string, ScopeConfig>> = {\n\tscopes: ScopeRegistry<T>\n\tprovider: StreamingStorageProvider\n\t/** Required when any scope declares `encrypt`. The app owns the cipher. */\n\tcrypto?: StreamingCryptoHooks\n\t/** Lifetime of signed URLs, in seconds. @defaultValue 300 */\n\tsignedUrlTtl?: number\n\t/**\n\t * `StoredFile.url` for encrypted scopes. Required alongside them: a signed\n\t * URL points at the bucket object, which is CIPHERTEXT — the only readable\n\t * route is the app's authenticated view endpoint (which runs `read` and\n\t * decrypts). Return that endpoint's URL, absolute or app-relative.\n\t */\n\tencryptedUrl?: (input: {\n\t\tscope: string\n\t\tentityId: string\n\t\tkey: string\n\t}) => string\n\t/**\n\t * Copy for every `StorageRequestError` this service words, and for the\n\t * validation messages it answers with. Omitted means English.\n\t *\n\t * Hand it the same object the client renders with: the server re-runs the\n\t * very validation the browser ran, so a mismatch here is the one place a\n\t * user sees the same rejection twice, in two languages.\n\t */\n\tlabels?: Partial<UploaderLabels>\n}\n\nexport type UploadInput = {\n\tscope: string\n\tentityId: string\n\tfile: FileLike\n\tuploadedBy?: string\n}\n\n/**\n * A stored file plus the keys the upload removed. `replaced` is empty unless\n * the scope sweeps per entity; when it is not, the app must drop those keys\n * from whatever it persisted, or it keeps rendering objects that are gone.\n */\nexport type UploadResult = StoredFile & { replaced: string[] }\n\n/**\n * A provider that can also read without buffering. Additive and optional, so\n * every existing provider still satisfies it — `readStream` falls back to\n * `get` when the method is absent.\n *\n * Declared here rather than in the core because the core's `StorageProvider`\n * is the published contract; widening it there would be a breaking change for\n * anyone implementing it.\n */\nexport type StreamingStorageProvider = StorageProvider & {\n\t/**\n\t * Reads without holding the object in memory. Serving a 20MB document\n\t * through `get` costs its full size in RAM per concurrent request.\n\t */\n\tgetStream?(key: string): Promise<Readable>\n}\n\n/** {@link CryptoHooks} that can also decrypt progressively. */\nexport type StreamingCryptoHooks = CryptoHooks & {\n\t/**\n\t * Decrypts as bytes arrive, so a read never materializes the plaintext.\n\t *\n\t * Weigh the cost: plaintext reaches the consumer BEFORE the authentication\n\t * tag is verified, so tampering surfaces as a stream that fails at the end,\n\t * mid-delivery. `decrypt` verifies before returning a single byte.\n\t *\n\t * There is no streaming counterpart for encryption on purpose — AES-GCM\n\t * only knows its tag once the input ends, and the layout writes it first.\n\t */\n\tdecryptStream?: (source: Readable) => Readable\n}\n\nexport type StorageService<\n\tT extends Record<string, ScopeConfig> = Record<string, ScopeConfig>,\n> = {\n\tupload(input: UploadInput): Promise<UploadResult>\n\t/** Raw bytes, decrypted when the scope is encrypted. */\n\tread(input: { scope: string; key: string }): Promise<Uint8Array>\n\t/**\n\t * The same content as a stream, so serving a file costs a buffer's worth of\n\t * memory instead of the whole object. Falls back to `read` when the provider\n\t * or the cipher cannot stream, so it is always safe to prefer.\n\t */\n\treadStream(input: { scope: string; key: string }): Promise<Readable>\n\tremove(input: { scope: string; key: string }): Promise<boolean>\n\t/** Fresh expiring URL for a private object. Throws on public scopes. */\n\tsignedUrl(input: {\n\t\tscope: string\n\t\tkey: string\n\t\tdownload?: boolean\n\t\texpiresIn?: number\n\t}): Promise<string>\n\tlist(prefix: string): Promise<{ key: string; size: number }[]>\n\tscopes: ScopeRegistry<T>\n\t/**\n\t * The resolved copy, exposed so the framework adapters word their own\n\t * failures with it. One `labels` on `createStorage` therefore covers the\n\t * whole round trip — the service and the route that wraps it cannot end up\n\t * answering in two languages.\n\t */\n\tlabels: UploaderLabels\n}\n\nconst toHex = (buffer: ArrayBuffer): string =>\n\t[...new Uint8Array(buffer)]\n\t\t.map(byte => byte.toString(16).padStart(2, '0'))\n\t\t.join('')\n\n/**\n * The server side of the contract. Boots defensively: a provider that cannot\n * honour the scopes, or an encrypted scope without an injected cipher, throws\n * here — before the first request, while a deploy can still fail loudly.\n */\nexport const createStorage = <T extends Record<string, ScopeConfig>>({\n\tscopes,\n\tprovider,\n\tcrypto,\n\tsignedUrlTtl = 300,\n\tencryptedUrl,\n\tlabels,\n}: CreateStorageOptions<T>): StorageService<T> => {\n\tconst copy = resolveLabels(labels)\n\n\tassertProviderSupports(scopes, provider)\n\n\tconst encrypted = scopes.names.filter(name => scopes.get(name).encrypt)\n\tif (encrypted.length > 0 && !crypto) {\n\t\tthrow new ScopeError(\n\t\t\t`Scopes ${encrypted.join(', ')} declare \"encrypt\" but no CryptoHooks were provided to createStorage`\n\t\t)\n\t}\n\tif (encrypted.length > 0 && !encryptedUrl) {\n\t\tthrow new ScopeError(\n\t\t\t`Scopes ${encrypted.join(', ')} declare \"encrypt\" but no \"encryptedUrl\" was provided — a signed URL would serve ciphertext. Point it at the app's authenticated view endpoint.`\n\t\t)\n\t}\n\n\tconst getScope = (name: string): ScopeConfig => {\n\t\tif (!scopes.has(name)) {\n\t\t\tthrow new StorageRequestError(copy.unknownScope, 404)\n\t\t}\n\t\treturn scopes.get(name)\n\t}\n\n\tconst upload = async ({\n\t\tscope: name,\n\t\tentityId,\n\t\tfile,\n\t\tuploadedBy,\n\t}: UploadInput): Promise<UploadResult> => {\n\t\tconst scope = getScope(name)\n\n\t\t// The client already validated; running the same function again here is\n\t\t// the part that makes the client check advisory instead of load-bearing.\n\t\tconst result = await validateForScope(scopes, name, file, copy)\n\t\tif (!result.valid) throw new StorageRequestError(result.message, 422)\n\n\t\tconst key = resolveKey(scopes, name, entityId, file)\n\t\tconst raw = new Uint8Array(await file.arrayBuffer())\n\t\tconst checksum = toHex(\n\t\t\tawait globalThis.crypto.subtle.digest('SHA-256', raw)\n\t\t)\n\t\tconst body = scope.encrypt ? await crypto!.encrypt(raw) : raw\n\n\t\tconst put = await provider.put({\n\t\t\tkey,\n\t\t\tbody,\n\t\t\t// Encrypted bytes are opaque on purpose: serving them with the real\n\t\t\t// MIME would let a browser try to render ciphertext.\n\t\t\tcontentType: scope.encrypt ? 'application/octet-stream' : file.type,\n\t\t\tvisibility: scope.visibility,\n\t\t\tmetadata: scope.metadata,\n\t\t})\n\n\t\t// Orphan sweep AFTER a successful put: the new object must exist before\n\t\t// anything is deleted, or a failure between the two leaves the entity\n\t\t// with nothing. Delete failures are swallowed on purpose — the upload\n\t\t// the caller asked for did happen, and a stale object is not worth\n\t\t// failing it over.\n\t\tconst replaced: string[] = []\n\t\tif (resolveReplaceMode(scope) === 'entity') {\n\t\t\tconst prefix = resolveScopePrefix(scope, entityId)\n\t\t\tconst stale = (await provider.list(prefix)).filter(\n\t\t\t\tobject => object.key !== put.key\n\t\t\t)\n\t\t\tawait Promise.all(\n\t\t\t\tstale.map(object =>\n\t\t\t\t\tprovider\n\t\t\t\t\t\t.delete(object.key)\n\t\t\t\t\t\t// Only what the provider confirmed gone: `replaced` is what\n\t\t\t\t\t\t// the app drops from its own records.\n\t\t\t\t\t\t.then(deleted => {\n\t\t\t\t\t\t\tif (deleted) replaced.push(object.key)\n\t\t\t\t\t\t})\n\t\t\t\t\t\t.catch(() => undefined)\n\t\t\t\t)\n\t\t\t)\n\t\t}\n\n\t\tconst contentChecksum = put.checksum ?? checksum\n\t\tconst publicUrl = put.url\n\t\t\t? `${put.url}${put.url.includes('?') ? '&' : '?'}v=${contentChecksum.slice(0, 16)}`\n\t\t\t: put.url\n\n\t\treturn {\n\t\t\tkey: put.key,\n\t\t\treplaced,\n\t\t\turl: scope.encrypt\n\t\t\t\t? encryptedUrl!({ scope: name, entityId, key: put.key })\n\t\t\t\t: scope.visibility === 'private'\n\t\t\t\t\t? await provider.signedUrl!(put.key, { expiresIn: signedUrlTtl })\n\t\t\t\t\t: publicUrl,\n\t\t\tscope: name,\n\t\t\tentityId,\n\t\t\tfileName: file.name,\n\t\t\tmimeType: file.type,\n\t\t\tsize: file.size,\n\t\t\tchecksum: contentChecksum,\n\t\t\tuploadedAt: Date.now(),\n\t\t\t...(uploadedBy ? { uploadedBy } : {}),\n\t\t}\n\t}\n\n\tconst read = async ({ scope: name, key }: { scope: string; key: string }) => {\n\t\tconst scope = getScope(name)\n\t\tconst data = await provider.get(key)\n\t\treturn scope.encrypt ? crypto!.decrypt(data) : data\n\t}\n\n\t/**\n\t * The same read without holding the object in memory.\n\t *\n\t * Degrades honestly: a provider with no `getStream`, or an encrypted scope\n\t * whose crypto has no `decryptStream`, falls back to `read` and is wrapped\n\t * in a one-chunk stream. Callers get one shape either way and never have to\n\t * ask which capabilities are present.\n\t */\n\tconst readStream = async ({\n\t\tscope: name,\n\t\tkey,\n\t}: {\n\t\tscope: string\n\t\tkey: string\n\t}): Promise<Readable> => {\n\t\tconst scope = getScope(name)\n\t\tconst canStream =\n\t\t\tprovider.getStream && (!scope.encrypt || crypto?.decryptStream)\n\n\t\tif (!canStream) {\n\t\t\treturn Readable.from([Buffer.from(await read({ scope: name, key }))])\n\t\t}\n\n\t\tconst source = (await provider.getStream!(key)) as Readable\n\t\treturn scope.encrypt ? (crypto!.decryptStream!(source) as Readable) : source\n\t}\n\n\tconst remove = ({ scope: name, key }: { scope: string; key: string }) => {\n\t\tconst scope = getScope(name)\n\t\t// The history contract, enforced where it cannot be bypassed: a scope\n\t\t// marked `keepOnRemove` answers `false` instead of destroying the\n\t\t// trail, whatever any client asks.\n\t\tif (scope.keepOnRemove) {\n\t\t\treturn Promise.resolve(false)\n\t\t}\n\t\treturn provider.delete(key)\n\t}\n\n\tconst signedUrl = ({\n\t\tscope: name,\n\t\tkey,\n\t\tdownload,\n\t\texpiresIn,\n\t}: {\n\t\tscope: string\n\t\tkey: string\n\t\tdownload?: boolean\n\t\texpiresIn?: number\n\t}) => {\n\t\tconst scope = getScope(name)\n\t\tif (scope.visibility !== 'private') {\n\t\t\tthrow new ScopeError(\n\t\t\t\t`Scope \"${name}\" is public; its objects are reachable by their stored URL and never signed`\n\t\t\t)\n\t\t}\n\t\tconst options: SignedUrlOptions = {\n\t\t\texpiresIn: expiresIn ?? signedUrlTtl,\n\t\t\t...(download !== undefined ? { download } : {}),\n\t\t}\n\t\treturn provider.signedUrl!(key, options)\n\t}\n\n\treturn {\n\t\tupload,\n\t\tread,\n\t\treadStream,\n\t\tremove,\n\t\tsignedUrl,\n\t\tlist: prefix => provider.list(prefix),\n\t\tscopes,\n\t\tlabels: copy,\n\t}\n}\n","import { pipeline } from 'node:stream/promises'\n\nimport { fromMulterFile, getMimeType } from '../file'\nimport type { UploaderLabels } from '../labels'\nimport { ScopeError } from '../scopes'\nimport type { ScopeConfig } from '../types'\nimport { StorageRequestError, type StorageService } from './storage'\n\n/**\n * Structural request/response shapes instead of Express types on purpose: the\n * package stays dependency-free and any Express 4/5 app satisfies them. The\n * app keeps ownership of multer (memory storage) and mounts these as the last\n * handler of each route.\n */\nexport type ExpressishRequest = {\n\tparams: Record<string, string | undefined>\n\tbody?: unknown\n\tquery?: Record<string, unknown>\n\t/** Populated by `multer({ storage: memoryStorage() }).single('file')`. */\n\tfile?: {\n\t\toriginalname: string\n\t\tsize: number\n\t\tmimetype: string\n\t\tbuffer: Uint8Array\n\t}\n}\n\nexport type ExpressishResponse = {\n\tstatus: (code: number) => ExpressishResponse\n\tjson: (body: unknown) => unknown\n\tsetHeader: (name: string, value: string) => unknown\n\tsend: (body: unknown) => unknown\n}\n\nexport type ExpressStorageOptions = {\n\t/**\n\t * Gate every request. Return the acting user (or `{}` for \"allowed\") to\n\t * proceed; return `null` to answer 401. Typically reads `req.user` set by\n\t * the app's auth middleware.\n\t */\n\tauthorize?: (\n\t\treq: ExpressishRequest,\n\t\tcontext: { scope: string; entityId: string }\n\t) => Promise<{ userId?: string } | null>\n}\n\nconst param = (\n\treq: ExpressishRequest,\n\tname: string,\n\tcopy: UploaderLabels\n): string => {\n\tconst value = req.params[name]\n\tif (!value) throw new StorageRequestError(copy.requestIncomplete, 400)\n\treturn value\n}\n\nconst fail = (\n\tres: ExpressishResponse,\n\terror: unknown,\n\tcopy: UploaderLabels\n): void => {\n\tif (error instanceof StorageRequestError) {\n\t\tres.status(error.status).json({ message: error.message })\n\t\treturn\n\t}\n\tif (error instanceof ScopeError) throw error\n\tres.status(500).json({ message: copy.processingFailed })\n}\n\n/**\n * Express-shaped handlers over a {@link StorageService}. Wire them as:\n *\n * ```ts\n * const upload = multer({ storage: multer.memoryStorage() })\n * router.post('/:scope/:entityId/upload', useAuth, upload.single('file'), handlers.upload)\n * router.delete('/:scope/:entityId', useAuth, handlers.remove)\n * router.get('/:scope/:entityId/signed-url', useAuth, handlers.signedUrl)\n * ```\n */\nexport const createExpressStorageHandlers = <\n\tT extends Record<string, ScopeConfig>,\n>(\n\tstorage: StorageService<T>,\n\t{ authorize }: ExpressStorageOptions = {}\n) => {\n\t// The copy `createStorage` was configured with — never a second source.\n\tconst copy = storage.labels\n\n\tconst guard = async (\n\t\treq: ExpressishRequest,\n\t\tscope: string,\n\t\tentityId: string\n\t): Promise<{ userId?: string }> => {\n\t\tif (!authorize) return {}\n\t\tconst user = await authorize(req, { scope, entityId })\n\t\tif (user === null) throw new StorageRequestError(copy.unauthorized, 401)\n\t\treturn user\n\t}\n\n\treturn {\n\t\tupload: async (req: ExpressishRequest, res: ExpressishResponse) => {\n\t\t\ttry {\n\t\t\t\tconst scope = param(req, 'scope', copy)\n\t\t\t\tconst entityId = param(req, 'entityId', copy)\n\t\t\t\tconst user = await guard(req, scope, entityId)\n\n\t\t\t\tif (!req.file) throw new StorageRequestError(copy.fileRequired, 400)\n\n\t\t\t\tconst stored = await storage.upload({\n\t\t\t\t\tscope,\n\t\t\t\t\tentityId,\n\t\t\t\t\tfile: fromMulterFile(req.file),\n\t\t\t\t\t...(user.userId ? { uploadedBy: user.userId } : {}),\n\t\t\t\t})\n\t\t\t\tres.status(200).json(stored)\n\t\t\t} catch (error) {\n\t\t\t\tfail(res, error, copy)\n\t\t\t}\n\t\t},\n\n\t\tremove: async (req: ExpressishRequest, res: ExpressishResponse) => {\n\t\t\ttry {\n\t\t\t\tconst scope = param(req, 'scope', copy)\n\t\t\t\tconst entityId = param(req, 'entityId', copy)\n\t\t\t\tawait guard(req, scope, entityId)\n\n\t\t\t\tconst key = (req.body as { key?: string } | undefined)?.key\n\t\t\t\tif (!key) throw new StorageRequestError(copy.requestIncomplete, 400)\n\n\t\t\t\tres.status(200).json({ deleted: await storage.remove({ scope, key }) })\n\t\t\t} catch (error) {\n\t\t\t\tfail(res, error, copy)\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Serves the DECRYPTED bytes of an object, inline. The only readable\n\t\t * route for encrypted scopes — their bucket objects are ciphertext, so\n\t\t * neither the stored URL nor a signed one can render without this.\n\t\t */\n\t\tview: async (req: ExpressishRequest, res: ExpressishResponse) => {\n\t\t\ttry {\n\t\t\t\tconst scope = param(req, 'scope', copy)\n\t\t\t\tconst entityId = param(req, 'entityId', copy)\n\t\t\t\tawait guard(req, scope, entityId)\n\n\t\t\t\tconst key = req.query?.key\n\t\t\t\tif (typeof key !== 'string' || !key) {\n\t\t\t\t\tthrow new StorageRequestError(copy.requestIncomplete, 400)\n\t\t\t\t}\n\n\t\t\t\tconst body = await storage.readStream({ scope, key })\n\t\t\t\tconst fileName = key.split('/').pop() ?? copy.unnamedFile\n\t\t\t\tres.setHeader('Content-Type', getMimeType(fileName))\n\t\t\t\tres.setHeader(\n\t\t\t\t\t'Content-Disposition',\n\t\t\t\t\t`${req.query?.download === '1' ? 'attachment' : 'inline'}; filename=\"${encodeURIComponent(fileName)}\"`\n\t\t\t\t)\n\t\t\t\t// Decrypted content must never land in a shared cache.\n\t\t\t\tres.setHeader('Cache-Control', 'private, no-store')\n\n\t\t\t\t// Piped, not buffered: a 20MB document would otherwise sit in\n\t\t\t\t// memory in full, per concurrent reader. `pipeline` is what\n\t\t\t\t// destroys the response if the source fails — which for an\n\t\t\t\t// encrypted object includes a failed authentication tag, since\n\t\t\t\t// that is only known once the last byte has gone out.\n\t\t\t\ttry {\n\t\t\t\t\tawait pipeline(body, res as unknown as NodeJS.WritableStream)\n\t\t\t\t} catch {\n\t\t\t\t\t// The status line and part of the body are already out, so\n\t\t\t\t\t// there is nothing left to answer with: `pipeline` destroyed\n\t\t\t\t\t// the socket, and `fail()` here would throw\n\t\t\t\t\t// ERR_HTTP_HEADERS_SENT over the real error.\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tfail(res, error, copy)\n\t\t\t}\n\t\t},\n\n\t\tsignedUrl: async (req: ExpressishRequest, res: ExpressishResponse) => {\n\t\t\ttry {\n\t\t\t\tconst scope = param(req, 'scope', copy)\n\t\t\t\tconst entityId = param(req, 'entityId', copy)\n\t\t\t\tawait guard(req, scope, entityId)\n\n\t\t\t\tconst key = req.query?.key\n\t\t\t\tif (typeof key !== 'string' || !key) {\n\t\t\t\t\tthrow new StorageRequestError(copy.requestIncomplete, 400)\n\t\t\t\t}\n\n\t\t\t\tconst url = await storage.signedUrl({\n\t\t\t\t\tscope,\n\t\t\t\t\tkey,\n\t\t\t\t\tdownload: req.query?.download === '1',\n\t\t\t\t})\n\t\t\t\tres.status(200).json({ url })\n\t\t\t} catch (error) {\n\t\t\t\tfail(res, error, copy)\n\t\t\t}\n\t\t},\n\t}\n}\n"]}
@@ -0,0 +1,64 @@
1
+ import { d as ScopeConfig } from '../types-BSlJJwti.cjs';
2
+ import { c as StorageService } from '../storage-CYkSHWZX.cjs';
3
+ import 'node:stream';
4
+
5
+ /**
6
+ * Structural request/response shapes instead of Express types on purpose: the
7
+ * package stays dependency-free and any Express 4/5 app satisfies them. The
8
+ * app keeps ownership of multer (memory storage) and mounts these as the last
9
+ * handler of each route.
10
+ */
11
+ type ExpressishRequest = {
12
+ params: Record<string, string | undefined>;
13
+ body?: unknown;
14
+ query?: Record<string, unknown>;
15
+ /** Populated by `multer({ storage: memoryStorage() }).single('file')`. */
16
+ file?: {
17
+ originalname: string;
18
+ size: number;
19
+ mimetype: string;
20
+ buffer: Uint8Array;
21
+ };
22
+ };
23
+ type ExpressishResponse = {
24
+ status: (code: number) => ExpressishResponse;
25
+ json: (body: unknown) => unknown;
26
+ setHeader: (name: string, value: string) => unknown;
27
+ send: (body: unknown) => unknown;
28
+ };
29
+ type ExpressStorageOptions = {
30
+ /**
31
+ * Gate every request. Return the acting user (or `{}` for "allowed") to
32
+ * proceed; return `null` to answer 401. Typically reads `req.user` set by
33
+ * the app's auth middleware.
34
+ */
35
+ authorize?: (req: ExpressishRequest, context: {
36
+ scope: string;
37
+ entityId: string;
38
+ }) => Promise<{
39
+ userId?: string;
40
+ } | null>;
41
+ };
42
+ /**
43
+ * Express-shaped handlers over a {@link StorageService}. Wire them as:
44
+ *
45
+ * ```ts
46
+ * const upload = multer({ storage: multer.memoryStorage() })
47
+ * router.post('/:scope/:entityId/upload', useAuth, upload.single('file'), handlers.upload)
48
+ * router.delete('/:scope/:entityId', useAuth, handlers.remove)
49
+ * router.get('/:scope/:entityId/signed-url', useAuth, handlers.signedUrl)
50
+ * ```
51
+ */
52
+ declare const createExpressStorageHandlers: <T extends Record<string, ScopeConfig>>(storage: StorageService<T>, { authorize }?: ExpressStorageOptions) => {
53
+ upload: (req: ExpressishRequest, res: ExpressishResponse) => Promise<void>;
54
+ remove: (req: ExpressishRequest, res: ExpressishResponse) => Promise<void>;
55
+ /**
56
+ * Serves the DECRYPTED bytes of an object, inline. The only readable
57
+ * route for encrypted scopes — their bucket objects are ciphertext, so
58
+ * neither the stored URL nor a signed one can render without this.
59
+ */
60
+ view: (req: ExpressishRequest, res: ExpressishResponse) => Promise<void>;
61
+ signedUrl: (req: ExpressishRequest, res: ExpressishResponse) => Promise<void>;
62
+ };
63
+
64
+ export { type ExpressStorageOptions, type ExpressishRequest, type ExpressishResponse, createExpressStorageHandlers };
@@ -0,0 +1,64 @@
1
+ import { d as ScopeConfig } from '../types-BSlJJwti.js';
2
+ import { c as StorageService } from '../storage-Qc9epG0G.js';
3
+ import 'node:stream';
4
+
5
+ /**
6
+ * Structural request/response shapes instead of Express types on purpose: the
7
+ * package stays dependency-free and any Express 4/5 app satisfies them. The
8
+ * app keeps ownership of multer (memory storage) and mounts these as the last
9
+ * handler of each route.
10
+ */
11
+ type ExpressishRequest = {
12
+ params: Record<string, string | undefined>;
13
+ body?: unknown;
14
+ query?: Record<string, unknown>;
15
+ /** Populated by `multer({ storage: memoryStorage() }).single('file')`. */
16
+ file?: {
17
+ originalname: string;
18
+ size: number;
19
+ mimetype: string;
20
+ buffer: Uint8Array;
21
+ };
22
+ };
23
+ type ExpressishResponse = {
24
+ status: (code: number) => ExpressishResponse;
25
+ json: (body: unknown) => unknown;
26
+ setHeader: (name: string, value: string) => unknown;
27
+ send: (body: unknown) => unknown;
28
+ };
29
+ type ExpressStorageOptions = {
30
+ /**
31
+ * Gate every request. Return the acting user (or `{}` for "allowed") to
32
+ * proceed; return `null` to answer 401. Typically reads `req.user` set by
33
+ * the app's auth middleware.
34
+ */
35
+ authorize?: (req: ExpressishRequest, context: {
36
+ scope: string;
37
+ entityId: string;
38
+ }) => Promise<{
39
+ userId?: string;
40
+ } | null>;
41
+ };
42
+ /**
43
+ * Express-shaped handlers over a {@link StorageService}. Wire them as:
44
+ *
45
+ * ```ts
46
+ * const upload = multer({ storage: multer.memoryStorage() })
47
+ * router.post('/:scope/:entityId/upload', useAuth, upload.single('file'), handlers.upload)
48
+ * router.delete('/:scope/:entityId', useAuth, handlers.remove)
49
+ * router.get('/:scope/:entityId/signed-url', useAuth, handlers.signedUrl)
50
+ * ```
51
+ */
52
+ declare const createExpressStorageHandlers: <T extends Record<string, ScopeConfig>>(storage: StorageService<T>, { authorize }?: ExpressStorageOptions) => {
53
+ upload: (req: ExpressishRequest, res: ExpressishResponse) => Promise<void>;
54
+ remove: (req: ExpressishRequest, res: ExpressishResponse) => Promise<void>;
55
+ /**
56
+ * Serves the DECRYPTED bytes of an object, inline. The only readable
57
+ * route for encrypted scopes — their bucket objects are ciphertext, so
58
+ * neither the stored URL nor a signed one can render without this.
59
+ */
60
+ view: (req: ExpressishRequest, res: ExpressishResponse) => Promise<void>;
61
+ signedUrl: (req: ExpressishRequest, res: ExpressishResponse) => Promise<void>;
62
+ };
63
+
64
+ export { type ExpressStorageOptions, type ExpressishRequest, type ExpressishResponse, createExpressStorageHandlers };
@@ -0,0 +1,111 @@
1
+ import { StorageRequestError } from '../chunk-3FI44IOW.js';
2
+ import { getMimeType, fromMulterFile, ScopeError } from '../chunk-T5YZG6JW.js';
3
+ import { pipeline } from 'stream/promises';
4
+
5
+ var param = (req, name, copy) => {
6
+ const value = req.params[name];
7
+ if (!value) throw new StorageRequestError(copy.requestIncomplete, 400);
8
+ return value;
9
+ };
10
+ var fail = (res, error, copy) => {
11
+ if (error instanceof StorageRequestError) {
12
+ res.status(error.status).json({ message: error.message });
13
+ return;
14
+ }
15
+ if (error instanceof ScopeError) throw error;
16
+ res.status(500).json({ message: copy.processingFailed });
17
+ };
18
+ var createExpressStorageHandlers = (storage, { authorize } = {}) => {
19
+ const copy = storage.labels;
20
+ const guard = async (req, scope, entityId) => {
21
+ if (!authorize) return {};
22
+ const user = await authorize(req, { scope, entityId });
23
+ if (user === null) throw new StorageRequestError(copy.unauthorized, 401);
24
+ return user;
25
+ };
26
+ return {
27
+ upload: async (req, res) => {
28
+ try {
29
+ const scope = param(req, "scope", copy);
30
+ const entityId = param(req, "entityId", copy);
31
+ const user = await guard(req, scope, entityId);
32
+ if (!req.file) throw new StorageRequestError(copy.fileRequired, 400);
33
+ const stored = await storage.upload({
34
+ scope,
35
+ entityId,
36
+ file: fromMulterFile(req.file),
37
+ ...user.userId ? { uploadedBy: user.userId } : {}
38
+ });
39
+ res.status(200).json(stored);
40
+ } catch (error) {
41
+ fail(res, error, copy);
42
+ }
43
+ },
44
+ remove: async (req, res) => {
45
+ try {
46
+ const scope = param(req, "scope", copy);
47
+ const entityId = param(req, "entityId", copy);
48
+ await guard(req, scope, entityId);
49
+ const key = req.body?.key;
50
+ if (!key) throw new StorageRequestError(copy.requestIncomplete, 400);
51
+ res.status(200).json({ deleted: await storage.remove({ scope, key }) });
52
+ } catch (error) {
53
+ fail(res, error, copy);
54
+ }
55
+ },
56
+ /**
57
+ * Serves the DECRYPTED bytes of an object, inline. The only readable
58
+ * route for encrypted scopes — their bucket objects are ciphertext, so
59
+ * neither the stored URL nor a signed one can render without this.
60
+ */
61
+ view: async (req, res) => {
62
+ try {
63
+ const scope = param(req, "scope", copy);
64
+ const entityId = param(req, "entityId", copy);
65
+ await guard(req, scope, entityId);
66
+ const key = req.query?.key;
67
+ if (typeof key !== "string" || !key) {
68
+ throw new StorageRequestError(copy.requestIncomplete, 400);
69
+ }
70
+ const body = await storage.readStream({ scope, key });
71
+ const fileName = key.split("/").pop() ?? copy.unnamedFile;
72
+ res.setHeader("Content-Type", getMimeType(fileName));
73
+ res.setHeader(
74
+ "Content-Disposition",
75
+ `${req.query?.download === "1" ? "attachment" : "inline"}; filename="${encodeURIComponent(fileName)}"`
76
+ );
77
+ res.setHeader("Cache-Control", "private, no-store");
78
+ try {
79
+ await pipeline(body, res);
80
+ } catch {
81
+ return;
82
+ }
83
+ } catch (error) {
84
+ fail(res, error, copy);
85
+ }
86
+ },
87
+ signedUrl: async (req, res) => {
88
+ try {
89
+ const scope = param(req, "scope", copy);
90
+ const entityId = param(req, "entityId", copy);
91
+ await guard(req, scope, entityId);
92
+ const key = req.query?.key;
93
+ if (typeof key !== "string" || !key) {
94
+ throw new StorageRequestError(copy.requestIncomplete, 400);
95
+ }
96
+ const url = await storage.signedUrl({
97
+ scope,
98
+ key,
99
+ download: req.query?.download === "1"
100
+ });
101
+ res.status(200).json({ url });
102
+ } catch (error) {
103
+ fail(res, error, copy);
104
+ }
105
+ }
106
+ };
107
+ };
108
+
109
+ export { createExpressStorageHandlers };
110
+ //# sourceMappingURL=express.js.map
111
+ //# sourceMappingURL=express.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/server/express.ts"],"names":[],"mappings":"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","file":"express.js","sourcesContent":["import { pipeline } from 'node:stream/promises'\n\nimport { fromMulterFile, getMimeType } from '../file'\nimport type { UploaderLabels } from '../labels'\nimport { ScopeError } from '../scopes'\nimport type { ScopeConfig } from '../types'\nimport { StorageRequestError, type StorageService } from './storage'\n\n/**\n * Structural request/response shapes instead of Express types on purpose: the\n * package stays dependency-free and any Express 4/5 app satisfies them. The\n * app keeps ownership of multer (memory storage) and mounts these as the last\n * handler of each route.\n */\nexport type ExpressishRequest = {\n\tparams: Record<string, string | undefined>\n\tbody?: unknown\n\tquery?: Record<string, unknown>\n\t/** Populated by `multer({ storage: memoryStorage() }).single('file')`. */\n\tfile?: {\n\t\toriginalname: string\n\t\tsize: number\n\t\tmimetype: string\n\t\tbuffer: Uint8Array\n\t}\n}\n\nexport type ExpressishResponse = {\n\tstatus: (code: number) => ExpressishResponse\n\tjson: (body: unknown) => unknown\n\tsetHeader: (name: string, value: string) => unknown\n\tsend: (body: unknown) => unknown\n}\n\nexport type ExpressStorageOptions = {\n\t/**\n\t * Gate every request. Return the acting user (or `{}` for \"allowed\") to\n\t * proceed; return `null` to answer 401. Typically reads `req.user` set by\n\t * the app's auth middleware.\n\t */\n\tauthorize?: (\n\t\treq: ExpressishRequest,\n\t\tcontext: { scope: string; entityId: string }\n\t) => Promise<{ userId?: string } | null>\n}\n\nconst param = (\n\treq: ExpressishRequest,\n\tname: string,\n\tcopy: UploaderLabels\n): string => {\n\tconst value = req.params[name]\n\tif (!value) throw new StorageRequestError(copy.requestIncomplete, 400)\n\treturn value\n}\n\nconst fail = (\n\tres: ExpressishResponse,\n\terror: unknown,\n\tcopy: UploaderLabels\n): void => {\n\tif (error instanceof StorageRequestError) {\n\t\tres.status(error.status).json({ message: error.message })\n\t\treturn\n\t}\n\tif (error instanceof ScopeError) throw error\n\tres.status(500).json({ message: copy.processingFailed })\n}\n\n/**\n * Express-shaped handlers over a {@link StorageService}. Wire them as:\n *\n * ```ts\n * const upload = multer({ storage: multer.memoryStorage() })\n * router.post('/:scope/:entityId/upload', useAuth, upload.single('file'), handlers.upload)\n * router.delete('/:scope/:entityId', useAuth, handlers.remove)\n * router.get('/:scope/:entityId/signed-url', useAuth, handlers.signedUrl)\n * ```\n */\nexport const createExpressStorageHandlers = <\n\tT extends Record<string, ScopeConfig>,\n>(\n\tstorage: StorageService<T>,\n\t{ authorize }: ExpressStorageOptions = {}\n) => {\n\t// The copy `createStorage` was configured with — never a second source.\n\tconst copy = storage.labels\n\n\tconst guard = async (\n\t\treq: ExpressishRequest,\n\t\tscope: string,\n\t\tentityId: string\n\t): Promise<{ userId?: string }> => {\n\t\tif (!authorize) return {}\n\t\tconst user = await authorize(req, { scope, entityId })\n\t\tif (user === null) throw new StorageRequestError(copy.unauthorized, 401)\n\t\treturn user\n\t}\n\n\treturn {\n\t\tupload: async (req: ExpressishRequest, res: ExpressishResponse) => {\n\t\t\ttry {\n\t\t\t\tconst scope = param(req, 'scope', copy)\n\t\t\t\tconst entityId = param(req, 'entityId', copy)\n\t\t\t\tconst user = await guard(req, scope, entityId)\n\n\t\t\t\tif (!req.file) throw new StorageRequestError(copy.fileRequired, 400)\n\n\t\t\t\tconst stored = await storage.upload({\n\t\t\t\t\tscope,\n\t\t\t\t\tentityId,\n\t\t\t\t\tfile: fromMulterFile(req.file),\n\t\t\t\t\t...(user.userId ? { uploadedBy: user.userId } : {}),\n\t\t\t\t})\n\t\t\t\tres.status(200).json(stored)\n\t\t\t} catch (error) {\n\t\t\t\tfail(res, error, copy)\n\t\t\t}\n\t\t},\n\n\t\tremove: async (req: ExpressishRequest, res: ExpressishResponse) => {\n\t\t\ttry {\n\t\t\t\tconst scope = param(req, 'scope', copy)\n\t\t\t\tconst entityId = param(req, 'entityId', copy)\n\t\t\t\tawait guard(req, scope, entityId)\n\n\t\t\t\tconst key = (req.body as { key?: string } | undefined)?.key\n\t\t\t\tif (!key) throw new StorageRequestError(copy.requestIncomplete, 400)\n\n\t\t\t\tres.status(200).json({ deleted: await storage.remove({ scope, key }) })\n\t\t\t} catch (error) {\n\t\t\t\tfail(res, error, copy)\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Serves the DECRYPTED bytes of an object, inline. The only readable\n\t\t * route for encrypted scopes — their bucket objects are ciphertext, so\n\t\t * neither the stored URL nor a signed one can render without this.\n\t\t */\n\t\tview: async (req: ExpressishRequest, res: ExpressishResponse) => {\n\t\t\ttry {\n\t\t\t\tconst scope = param(req, 'scope', copy)\n\t\t\t\tconst entityId = param(req, 'entityId', copy)\n\t\t\t\tawait guard(req, scope, entityId)\n\n\t\t\t\tconst key = req.query?.key\n\t\t\t\tif (typeof key !== 'string' || !key) {\n\t\t\t\t\tthrow new StorageRequestError(copy.requestIncomplete, 400)\n\t\t\t\t}\n\n\t\t\t\tconst body = await storage.readStream({ scope, key })\n\t\t\t\tconst fileName = key.split('/').pop() ?? copy.unnamedFile\n\t\t\t\tres.setHeader('Content-Type', getMimeType(fileName))\n\t\t\t\tres.setHeader(\n\t\t\t\t\t'Content-Disposition',\n\t\t\t\t\t`${req.query?.download === '1' ? 'attachment' : 'inline'}; filename=\"${encodeURIComponent(fileName)}\"`\n\t\t\t\t)\n\t\t\t\t// Decrypted content must never land in a shared cache.\n\t\t\t\tres.setHeader('Cache-Control', 'private, no-store')\n\n\t\t\t\t// Piped, not buffered: a 20MB document would otherwise sit in\n\t\t\t\t// memory in full, per concurrent reader. `pipeline` is what\n\t\t\t\t// destroys the response if the source fails — which for an\n\t\t\t\t// encrypted object includes a failed authentication tag, since\n\t\t\t\t// that is only known once the last byte has gone out.\n\t\t\t\ttry {\n\t\t\t\t\tawait pipeline(body, res as unknown as NodeJS.WritableStream)\n\t\t\t\t} catch {\n\t\t\t\t\t// The status line and part of the body are already out, so\n\t\t\t\t\t// there is nothing left to answer with: `pipeline` destroyed\n\t\t\t\t\t// the socket, and `fail()` here would throw\n\t\t\t\t\t// ERR_HTTP_HEADERS_SENT over the real error.\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tfail(res, error, copy)\n\t\t\t}\n\t\t},\n\n\t\tsignedUrl: async (req: ExpressishRequest, res: ExpressishResponse) => {\n\t\t\ttry {\n\t\t\t\tconst scope = param(req, 'scope', copy)\n\t\t\t\tconst entityId = param(req, 'entityId', copy)\n\t\t\t\tawait guard(req, scope, entityId)\n\n\t\t\t\tconst key = req.query?.key\n\t\t\t\tif (typeof key !== 'string' || !key) {\n\t\t\t\t\tthrow new StorageRequestError(copy.requestIncomplete, 400)\n\t\t\t\t}\n\n\t\t\t\tconst url = await storage.signedUrl({\n\t\t\t\t\tscope,\n\t\t\t\t\tkey,\n\t\t\t\t\tdownload: req.query?.download === '1',\n\t\t\t\t})\n\t\t\t\tres.status(200).json({ url })\n\t\t\t} catch (error) {\n\t\t\t\tfail(res, error, copy)\n\t\t\t}\n\t\t},\n\t}\n}\n"]}