@nextlyhq/storage-vercel-blob 0.0.2-alpha.67 → 0.0.2-alpha.69

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.
package/dist/index.cjs CHANGED
@@ -1,1517 +1,1705 @@
1
- 'use strict';
2
-
3
- var blob = require('@vercel/blob');
4
-
5
- // src/adapter.ts
6
-
7
- // ../nextly/dist/chunk-UF2PDDU4.mjs
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _vercel_blob = require("@vercel/blob");
3
+ // ../nextly/dist/nextly-error-D21_8kpX.mjs
8
4
  function isDbError(err) {
9
- if (!err || typeof err !== "object") return false;
10
- const obj = err;
11
- return obj.name === "DbError" && typeof obj.kind === "string";
5
+ if (!err || typeof err !== "object") return false;
6
+ const obj = err;
7
+ return obj.name === "DbError" && typeof obj.kind === "string";
8
+ }
9
+ /**
10
+ * The driver's own code for an error, wherever that driver puts it.
11
+ *
12
+ * Exported so callers that must distinguish one failure from another classify
13
+ * by the SAME extraction this module's mapping uses. A second reader would go
14
+ * looking in a different set of properties and disagree on exactly the drivers
15
+ * whose codes are hardest to find.
16
+ */
17
+ /**
18
+ * Whether an error is a duplicate-key rejection.
19
+ *
20
+ * Exposed because a plugin inserting into a table with a unique index has to
21
+ * tell "this row already exists" from "the database is broken", and the two
22
+ * arrive as the same thrown object. Derived from the existing SQLSTATE
23
+ * classification rather than by matching driver messages: those differ per
24
+ * driver and per version, and a plugin re-deriving it would be a second
25
+ * implementation of a question this module already answers.
26
+ */
27
+ /**
28
+ * Whether a driver code says the database was busy: the adapters' own code
29
+ * for a request that waited out `pool.connectionTimeoutMs`, the same on
30
+ * every dialect, or SQLITE_BUSY and its extended codes, another process
31
+ * holding the SQLite file past `busyTimeout`. Neither depends on the dialect,
32
+ * which is what lets {@link isDatabaseBusy} answer without one.
33
+ */
34
+ function isBusyCode(code) {
35
+ if (code === void 0) return false;
36
+ return code === "NEXTLY_POOL_WAIT_TIMEOUT" || code.toUpperCase().startsWith("SQLITE_BUSY");
37
+ }
38
+ /**
39
+ * Whether an error is the database being busy, however it arrives: a DbError
40
+ * of kind "busy", or the raw error an adapter or driver threw.
41
+ *
42
+ * For the boundaries that answer a request with whatever its work threw.
43
+ * `withDbErrors` converts only DbErrors, so a raw driver error reaches them
44
+ * unconverted, and without this it would read as an internal fault rather
45
+ * than a request worth retrying.
46
+ */
47
+ function isDatabaseBusy(error) {
48
+ if (isDbError(error)) return error.kind === "busy";
49
+ return isBusyCode(safeCode(error));
12
50
  }
13
- var NEXTLY_ERROR_STATUS = {
14
- VALIDATION_ERROR: 400,
15
- INVALID_INPUT: 400,
16
- AUTH_REQUIRED: 401,
17
- AUTH_INVALID_CREDENTIALS: 401,
18
- TOKEN_EXPIRED: 401,
19
- FORBIDDEN: 403,
20
- // The password was right and the address is unverified. Distinct from
21
- // AUTH_INVALID_CREDENTIALS so the login page can offer to resend the link;
22
- // only a caller who proved the password ever receives it.
23
- EMAIL_NOT_VERIFIED: 403,
24
- // The schema builder is off in this environment (production by default).
25
- // Separate from FORBIDDEN: the caller's permissions are not the problem.
26
- BUILDER_DISABLED: 403,
27
- // A cookie-authenticated write whose origin or CSRF token did not check
28
- // out. Separate from FORBIDDEN for the same reason: the caller may hold
29
- // every permission, and a client can recover by reloading, not by asking
30
- // an administrator for access.
31
- CSRF_FAILED: 403,
32
- NOT_FOUND: 404,
33
- CONFLICT: 409,
34
- DUPLICATE: 409,
35
- RATE_LIMITED: 429,
36
- PAYLOAD_TOO_LARGE: 413,
37
- UNSUPPORTED_MEDIA_TYPE: 415,
38
- // 422: understood, well-formed, and refused on a rule the caller can act on.
39
- // Deliberately NOT added to CANONICAL_CODE_FOR_STATUS -- that list drives the
40
- // status -> code direction, where 422 stays mapped to INVALID_INPUT.
41
- BUSINESS_RULE_VIOLATION: 422,
42
- INTERNAL_ERROR: 500,
43
- DATABASE_ERROR: 500,
44
- EXTERNAL_SERVICE_ERROR: 502,
45
- SERVICE_UNAVAILABLE: 503,
46
- // Outbound-fetch safety (utils/validate-external-url): a URL refused for SSRF
47
- // safety, and a fetch that timed out / exceeded the size cap / failed to decode.
48
- EXTERNAL_URL_BLOCKED: 400,
49
- EXTERNAL_REQUEST_FAILED: 502,
50
- FILENAME_INVALID: 400,
51
- EXTENSION_BLOCKED: 400,
52
- MIME_BLOCKED: 415,
53
- MIME_NOT_ALLOWED: 415,
54
- SIZE_EXCEEDED: 413,
55
- // A stored object exceeded the cap a READ was given, which is a different
56
- // question from SIZE_EXCEEDED above: that one refuses an upload the caller
57
- // is sending, this one refuses to buffer an object already stored. Kept
58
- // apart so a caller discriminating on the code cannot match both.
59
- STORAGE_READ_TOO_LARGE: 413,
60
- // A stored object did not answer within the deadline the read was given. 504
61
- // rather than 500 because the failure is the BACKEND not answering, and a
62
- // caller can act on that difference: a gateway timeout is worth retrying, an
63
- // internal error is not.
64
- STORAGE_READ_TIMEOUT: 504,
65
- // The store answered, and answered badly. 502 rather than 500 because the
66
- // fault is UPSTREAM of this process: a caller can retry it, and an operator
67
- // reading the log needs to look at the bucket rather than at this service.
68
- // Distinct from the timeout above, which never got an answer at all.
69
- STORAGE_READ_UNREACHABLE: 502,
70
- MAGIC_BYTE_MISMATCH: 400,
71
- SVG_SANITIZATION_FAILED: 400,
72
- UNSUPPORTED_FOR_BACKEND: 415,
73
- // Plan B — schema bookkeeping consolidation.
74
- NEXTLY_LEGACY_BOOKKEEPING_DETECTED: 409,
75
- NEXTLY_UPGRADE_TABLE_NAME_COLLISION: 409,
76
- NEXTLY_UPGRADE_IN_PROGRESS: 409,
77
- // Plan C2 — nextly migrate phases.
78
- NEXTLY_MIGRATE_LOCK_BUSY: 409,
79
- NEXTLY_BASELINE_LOCK_NOT_HELD: 409,
80
- NEXTLY_RESOLVE_LOCK_NOT_HELD: 409,
81
- // Boot refused to serve: the migrate lock stayed held past the wait deadline,
82
- // so this process never established whether the schema matches the code. 503
83
- // rather than 409 — a load balancer should take the instance out of rotation
84
- // and retry it, which is exactly the recovery this refusal wants.
85
- NEXTLY_BOOT_MIGRATIONS_NOT_RUN: 503,
86
- // Still running rather than refused. 503 for the same reason: retry shortly.
87
- NEXTLY_BOOT_MIGRATIONS_PENDING: 503,
88
- NEXTLY_CORE_DESTRUCTIVE_REFUSED: 409,
89
- NEXTLY_MIGRATION_DRIFT: 409,
90
- NEXTLY_MIGRATION_APPLY_FAILED: 500,
91
- // A SQLite migration unit would leave a row referencing a row that does
92
- // not exist, or could not switch foreign-key enforcement off to run.
93
- NEXTLY_MIGRATION_FOREIGN_KEY_VIOLATION: 409,
94
- // A migration unit marked to run outside a transaction failed part-way;
95
- // the statements before the failing one stayed applied.
96
- NEXTLY_MIGRATION_PARTIALLY_APPLIED: 500,
97
- // Another run recorded the same migration applied, or rolled back, while
98
- // this one was running it: this run's ledger write lost, and its work is
99
- // rolled back wherever the transaction can undo it.
100
- NEXTLY_MIGRATION_CONCURRENT_RUN: 409,
101
- // `nextly migrate:resolve`, the recovery command.
102
- NEXTLY_MIGRATION_FILE_MISSING: 404,
103
- NEXTLY_MIGRATION_SNAPSHOT_MISSING: 404,
104
- NEXTLY_MIGRATION_RESOLVE_DRIFT: 409,
105
- NEXTLY_MIGRATION_RESOLVE_PRECONDITION: 409,
106
- // The Builder's UI schema.
107
- NEXTLY_UI_SCHEMA_INVALID: 400,
108
- NEXTLY_SCHEMA_SLUG_COLLISION: 409,
109
- NEXTLY_SCHEMA_RELATION_TARGET_MISSING: 400,
110
- // A declared table's relation names a table or column the registry does
111
- // not have once every table is registered.
112
- NEXTLY_SCHEMA_RELATION_UNRESOLVED: 400,
113
- // Plugin schema: `contributes.extend` and relations.
114
- NEXTLY_SCHEMA_EXTEND_TARGET_UNKNOWN: 400,
115
- NEXTLY_SCHEMA_EXTEND_FIELD_DUPLICATE: 409,
116
- NEXTLY_SCHEMA_CROSS_PLUGIN_RELATION: 409,
117
- // Plugin schema: the framework remap (`.rename()`).
118
- NEXTLY_SCHEMA_RENAME_UNKNOWN_TARGET: 400,
119
- // Plugin platform — a declared admin.clientConfig that cannot be delivered
120
- // to the browser, refused at boot rather than serialized mangled.
121
- NEXTLY_PLUGIN_CLIENT_CONFIG_INVALID: 500,
122
- // Plugin platform — a contributed admin widget that cannot be delivered to
123
- // the browser. Refused at boot because it is serialized into the ONE
124
- // `/api/admin-meta/workspace` payload: a value `JSON.stringify` throws on
125
- // fails that request for every admin, not just the widget's own card.
126
- NEXTLY_PLUGIN_ADMIN_WIDGET_INVALID: 500,
127
- // A plugin's document view cannot be routed: a malformed or reserved path,
128
- // or one two declarations claim on the same document. Refused at boot.
129
- NEXTLY_PLUGIN_DOCUMENT_VIEW_INVALID: 500,
130
- // Plugin platform (P0) — boot-time plugin dependency/version resolution.
131
- PLUGIN_RESOLUTION_ERROR: 500,
132
- // Plugin schema lifecycle. All 409: each is a state
133
- // the database and the config disagree about, which retrying cannot change
134
- // and an operator command resolves.
135
- PLUGIN_DEPENDENCY_NOT_INSTALLED: 409,
136
- PLUGIN_HAS_DEPENDENTS: 409,
137
- PLUGIN_UNINSTALL_IRREVERSIBLE: 409,
138
- // A full uninstall that would drop tables, run without `--yes`.
139
- PLUGIN_UNINSTALL_UNCONFIRMED: 409,
140
- PLUGIN_SCHEMA_BEHIND: 409,
141
- PLUGIN_SCHEMA_UNINSTALLED: 409,
142
- // A configured plugin `nextly plugins install` has not recorded as
143
- // installed, refused at a production boot.
144
- PLUGIN_NOT_INSTALLED: 409,
145
- PLUGIN_DISABLED: 409,
146
- // An uninstall that began and did not finish: refused at boot, and when
147
- // run again the other way (with or without `--keep-data`).
148
- PLUGIN_UNINSTALL_UNFINISHED: 409,
149
- PLUGIN_SCHEMA_VERSION_NOT_ADVANCED: 409,
150
- // A generated plugin migration that would drop a table or column the
151
- // plugin's earlier modules created: a rename comes out as a drop and an add.
152
- PLUGIN_MIGRATION_DROPS_CREATED_SCHEMA: 409,
153
- // A generated plugin migration that would add a NOT NULL column with no
154
- // default to a table its earlier modules created: existing rows have no
155
- // value for it.
156
- PLUGIN_MIGRATION_ADDS_UNFILLABLE_COLUMN: 409,
157
- // A plugin migration that changes the schema on a dialect with no
158
- // statements for it: its author's SQL was never written.
159
- PLUGIN_MIGRATION_SQL_MISSING: 409,
160
- PLUGIN_MIGRATIONS_UNAVAILABLE: 409,
161
- // A migration would drop or rename a table or column another owner holds
162
- // (or, for a plugin, a table no owner row claims), or names a drop target
163
- // the guard cannot read — refused for the same reason.
164
- DROP_OF_FOREIGN_TABLE: 409,
165
- // A plugin migration module whose content no longer matches its checksum.
166
- MIGRATION_CHECKSUM_MISMATCH: 409,
167
- // Plugin platform (P4) — contributes.routes collection (D25).
168
- NEXTLY_ROUTE_COLLISION: 409,
169
- NEXTLY_ROUTE_INVALID_PATH: 400,
170
- // A root-mounted route declared where the request pipeline never reaches the
171
- // root pass. 400 like an invalid path and not 409: nothing else claims the
172
- // address, the declaration simply cannot be served from there.
173
- NEXTLY_ROUTE_UNREACHABLE_ROOT: 400,
174
- // A `mount` outside the declared union, which only an untyped caller can
175
- // produce. 400 for the same reason: the declaration itself is unserveable.
176
- NEXTLY_ROUTE_INVALID_MOUNT: 400,
177
- // An email transport whose library is an optional peer dependency the host
178
- // has not installed. 503 rather than 500: the request is not malformed and
179
- // nothing is broken, the install simply cannot carry it out yet, and the
180
- // remedy is one command on the server rather than a change by the caller.
181
- NEXTLY_EMAIL_TRANSPORT_UNAVAILABLE: 503,
182
- // The tooling that compiles `nextly.config.ts` is an optional peer the host
183
- // has not installed. 503 rather than 500 for the same reason as the mail
184
- // transport above: nothing is broken and the request is not malformed, the
185
- // install simply cannot carry it out until one command is run.
186
- NEXTLY_CONFIG_TOOLING_UNAVAILABLE: 503,
187
- // `db.postgres.schema` names one schema and the adapter the application
188
- // supplied writes to another. 500 like the other boot-time configuration
189
- // refusals: no request caused it, and the fix is a change to the server's
190
- // own configuration.
191
- NEXTLY_POSTGRES_SCHEMA_MISMATCH: 500,
192
- // `db.postgres.schema` names a schema other than `public`, which the schema
193
- // push cannot yet create tables in. 500 for the same reason as the mismatch
194
- // above: a boot-time refusal of the server's own configuration.
195
- NEXTLY_POSTGRES_SCHEMA_UNSUPPORTED: 500
51
+ function safeCode(error) {
52
+ if (!error || typeof error !== "object") return void 0;
53
+ const e = error;
54
+ let c = e.code ?? e.sqlState ?? e.state ?? e.errno ?? void 0;
55
+ if (!c && typeof e.originalError?.code === "string") c = e.originalError.code;
56
+ if (!c && typeof e.cause?.code === "string") c = e.cause.code;
57
+ if (!c && typeof e.severity === "string" && typeof e.code === "string") c = e.code;
58
+ if (typeof c === "number") return String(c);
59
+ if (typeof c === "string" && c.length > 0) return c;
60
+ }
61
+ /**
62
+ * Canonical NextlyError codes and their HTTP status mappings.
63
+ *
64
+ * Plugin codes outside this enum must always pass an explicit `statusCode`
65
+ * to the NextlyError constructor (no enum lookup happens for unknown codes).
66
+ */
67
+ const NEXTLY_ERROR_STATUS = {
68
+ VALIDATION_ERROR: 400,
69
+ INVALID_INPUT: 400,
70
+ AUTH_REQUIRED: 401,
71
+ AUTH_INVALID_CREDENTIALS: 401,
72
+ TOKEN_EXPIRED: 401,
73
+ FORBIDDEN: 403,
74
+ EMAIL_NOT_VERIFIED: 403,
75
+ BUILDER_DISABLED: 403,
76
+ CSRF_FAILED: 403,
77
+ SETUP_TOKEN_REQUIRED: 403,
78
+ API_KEY_ROLE_ABOVE_CREATOR: 403,
79
+ NOT_FOUND: 404,
80
+ METHOD_NOT_ALLOWED: 405,
81
+ CONFLICT: 409,
82
+ DUPLICATE: 409,
83
+ RATE_LIMITED: 429,
84
+ PAYLOAD_TOO_LARGE: 413,
85
+ UNSUPPORTED_MEDIA_TYPE: 415,
86
+ BUSINESS_RULE_VIOLATION: 422,
87
+ INTERNAL_ERROR: 500,
88
+ DATABASE_ERROR: 500,
89
+ DATABASE_BUSY: 503,
90
+ EXTERNAL_SERVICE_ERROR: 502,
91
+ SERVICE_UNAVAILABLE: 503,
92
+ EXTERNAL_URL_BLOCKED: 400,
93
+ EXTERNAL_REQUEST_FAILED: 502,
94
+ FILENAME_INVALID: 400,
95
+ EXTENSION_BLOCKED: 400,
96
+ MIME_BLOCKED: 415,
97
+ MIME_NOT_ALLOWED: 415,
98
+ SIZE_EXCEEDED: 413,
99
+ STORAGE_READ_TOO_LARGE: 413,
100
+ STORAGE_READ_TIMEOUT: 504,
101
+ STORAGE_READ_UNREACHABLE: 502,
102
+ MAGIC_BYTE_MISMATCH: 400,
103
+ SVG_SANITIZATION_FAILED: 400,
104
+ UNSUPPORTED_FOR_BACKEND: 415,
105
+ NEXTLY_LEGACY_BOOKKEEPING_DETECTED: 409,
106
+ NEXTLY_UPGRADE_TABLE_NAME_COLLISION: 409,
107
+ NEXTLY_UPGRADE_IN_PROGRESS: 409,
108
+ NEXTLY_MIGRATE_LOCK_BUSY: 409,
109
+ NEXTLY_BASELINE_LOCK_NOT_HELD: 409,
110
+ NEXTLY_RESOLVE_LOCK_NOT_HELD: 409,
111
+ NEXTLY_BOOT_MIGRATIONS_NOT_RUN: 503,
112
+ NEXTLY_BOOT_MIGRATIONS_PENDING: 503,
113
+ NEXTLY_CORE_DESTRUCTIVE_REFUSED: 409,
114
+ NEXTLY_MIGRATION_DRIFT: 409,
115
+ NEXTLY_MIGRATION_APPLY_FAILED: 500,
116
+ NEXTLY_MIGRATION_FOREIGN_KEY_VIOLATION: 409,
117
+ NEXTLY_MIGRATION_PARTIALLY_APPLIED: 500,
118
+ NEXTLY_MIGRATION_CONCURRENT_RUN: 409,
119
+ NEXTLY_MIGRATION_FILE_MISSING: 404,
120
+ NEXTLY_MIGRATION_SNAPSHOT_MISSING: 404,
121
+ NEXTLY_MIGRATION_RESOLVE_DRIFT: 409,
122
+ NEXTLY_MIGRATION_RESOLVE_PRECONDITION: 409,
123
+ NEXTLY_UI_SCHEMA_INVALID: 400,
124
+ NEXTLY_SCHEMA_SLUG_COLLISION: 409,
125
+ NEXTLY_SCHEMA_RELATION_TARGET_MISSING: 400,
126
+ NEXTLY_SCHEMA_RELATION_UNRESOLVED: 400,
127
+ NEXTLY_SCHEMA_EXTEND_TARGET_UNKNOWN: 400,
128
+ NEXTLY_SCHEMA_EXTEND_FIELD_DUPLICATE: 409,
129
+ NEXTLY_SCHEMA_CROSS_PLUGIN_RELATION: 409,
130
+ NEXTLY_SCHEMA_RENAME_UNKNOWN_TARGET: 400,
131
+ NEXTLY_PLUGIN_CLIENT_CONFIG_INVALID: 500,
132
+ NEXTLY_PLUGIN_ADMIN_WIDGET_INVALID: 500,
133
+ NEXTLY_ADMIN_COMPONENT_MAP_STALE: 500,
134
+ NEXTLY_PLUGIN_DOCUMENT_VIEW_INVALID: 500,
135
+ PLUGIN_RESOLUTION_ERROR: 500,
136
+ PLUGIN_DEPENDENCY_NOT_INSTALLED: 409,
137
+ PLUGIN_HAS_DEPENDENTS: 409,
138
+ PLUGIN_UNINSTALL_IRREVERSIBLE: 409,
139
+ PLUGIN_UNINSTALL_UNCONFIRMED: 409,
140
+ PLUGIN_SCHEMA_BEHIND: 409,
141
+ PLUGIN_SCHEMA_UNINSTALLED: 409,
142
+ PLUGIN_NOT_INSTALLED: 409,
143
+ PLUGIN_DISABLED: 409,
144
+ PLUGIN_UNINSTALL_UNFINISHED: 409,
145
+ PLUGIN_SCHEMA_VERSION_NOT_ADVANCED: 409,
146
+ PLUGIN_MIGRATION_DROPS_CREATED_SCHEMA: 409,
147
+ PLUGIN_MIGRATION_ADDS_UNFILLABLE_COLUMN: 409,
148
+ PLUGIN_MIGRATION_SQL_MISSING: 409,
149
+ PLUGIN_MIGRATIONS_UNAVAILABLE: 409,
150
+ DROP_OF_FOREIGN_TABLE: 409,
151
+ MIGRATION_CHECKSUM_MISMATCH: 409,
152
+ PLUGIN_MIGRATIONS_OUT_OF_ORDER: 409,
153
+ PLUGIN_MIGRATION_HISTORY_CHANGED: 409,
154
+ NEXTLY_ROUTE_COLLISION: 409,
155
+ NEXTLY_ROUTE_INVALID_PATH: 400,
156
+ NEXTLY_ROUTE_UNREACHABLE_ROOT: 400,
157
+ NEXTLY_ROUTE_INVALID_MOUNT: 400,
158
+ NEXTLY_EMAIL_TRANSPORT_UNAVAILABLE: 503,
159
+ NEXTLY_CONFIG_TOOLING_UNAVAILABLE: 503,
160
+ NEXTLY_POSTGRES_SCHEMA_MISMATCH: 500,
161
+ NEXTLY_POSTGRES_SCHEMA_UNSUPPORTED: 500
196
162
  };
197
- var CANONICAL_CODE_FOR_STATUS = [
198
- "VALIDATION_ERROR",
199
- "AUTH_REQUIRED",
200
- "FORBIDDEN",
201
- "NOT_FOUND",
202
- "CONFLICT",
203
- "PAYLOAD_TOO_LARGE",
204
- "UNSUPPORTED_MEDIA_TYPE",
205
- "RATE_LIMITED",
206
- "EXTERNAL_SERVICE_ERROR",
207
- "SERVICE_UNAVAILABLE"
208
- ];
209
- ({
210
- ...Object.fromEntries(
211
- CANONICAL_CODE_FOR_STATUS.map((code) => [NEXTLY_ERROR_STATUS[code], code])
212
- )});
213
- var DB_ERROR_MAPPING = {
214
- "unique-violation": {
215
- code: "DUPLICATE",
216
- statusCode: 409,
217
- publicMessage: "Resource already exists."
218
- },
219
- "fk-violation": {
220
- code: "VALIDATION_ERROR",
221
- statusCode: 400,
222
- publicMessage: "Referenced record does not exist."
223
- },
224
- "not-null-violation": {
225
- code: "VALIDATION_ERROR",
226
- statusCode: 400,
227
- publicMessage: "A required field is missing."
228
- },
229
- constraint: {
230
- code: "VALIDATION_ERROR",
231
- statusCode: 400,
232
- publicMessage: "The provided data violates a constraint."
233
- },
234
- deadlock: {
235
- code: "CONFLICT",
236
- statusCode: 409,
237
- publicMessage: "The operation could not be completed. Please retry."
238
- },
239
- "serialization-failure": {
240
- code: "CONFLICT",
241
- statusCode: 409,
242
- publicMessage: "The operation could not be completed. Please retry."
243
- },
244
- timeout: {
245
- code: "DATABASE_ERROR",
246
- statusCode: 500,
247
- publicMessage: "The operation timed out. Please try again."
248
- },
249
- "connection-lost": {
250
- code: "DATABASE_ERROR",
251
- statusCode: 500,
252
- publicMessage: "A temporary database error occurred. Please try again."
253
- },
254
- syntax: {
255
- code: "INTERNAL_ERROR",
256
- statusCode: 500,
257
- publicMessage: "An unexpected error occurred."
258
- },
259
- internal: {
260
- code: "INTERNAL_ERROR",
261
- statusCode: 500,
262
- publicMessage: "An unexpected error occurred."
263
- }
163
+ ({ ...Object.fromEntries([
164
+ "VALIDATION_ERROR",
165
+ "AUTH_REQUIRED",
166
+ "FORBIDDEN",
167
+ "NOT_FOUND",
168
+ "CONFLICT",
169
+ "PAYLOAD_TOO_LARGE",
170
+ "UNSUPPORTED_MEDIA_TYPE",
171
+ "RATE_LIMITED",
172
+ "EXTERNAL_SERVICE_ERROR",
173
+ "SERVICE_UNAVAILABLE"
174
+ ].map((code) => [NEXTLY_ERROR_STATUS[code], code])) });
175
+ /**
176
+ * The sentence a caller reads for a code, when the failure supplied none.
177
+ *
178
+ * Kept beside the status table because they answer the same question together:
179
+ * a code-less envelope's own `message` is NOT usable here. Those envelopes come
180
+ * from legacy converters that store a raw exception's text, so promoting it
181
+ * would put driver output, table names and internal paths on the wire -- the
182
+ * disclosure the public error shape exists to prevent (spec 13.8).
183
+ *
184
+ * Every entry is generic by design. The specific detail stays in `logContext`
185
+ * for the operator, against the same request id.
186
+ */
187
+ const GENERIC_PUBLIC_MESSAGE = {
188
+ VALIDATION_ERROR: "Validation failed.",
189
+ INVALID_INPUT: "The request could not be processed.",
190
+ AUTH_REQUIRED: "Authentication required.",
191
+ FORBIDDEN: "You don't have permission to perform this action.",
192
+ NOT_FOUND: "Not found.",
193
+ METHOD_NOT_ALLOWED: "That method is not allowed on this address.",
194
+ CONFLICT: "The resource has changed since you last loaded it. Please refresh and try again.",
195
+ DUPLICATE: "Resource already exists.",
196
+ PAYLOAD_TOO_LARGE: "The request is too large.",
197
+ UNSUPPORTED_MEDIA_TYPE: "That file type is not supported.",
198
+ RATE_LIMITED: "Too many requests. Please try again later.",
199
+ EXTERNAL_SERVICE_ERROR: "An upstream service failed.",
200
+ SERVICE_UNAVAILABLE: "The service is temporarily unavailable.",
201
+ DATABASE_BUSY: "The database is busy. Please try again shortly.",
202
+ INTERNAL_ERROR: "An unexpected error occurred."
264
203
  };
265
- var NEXTLY_ERROR_BRAND = Symbol.for("nextly/NextlyError");
266
- function hasBrand(value) {
267
- if (value === null || typeof value !== "object" && typeof value !== "function") {
268
- return false;
269
- }
270
- return value[NEXTLY_ERROR_BRAND] === true;
204
+ /** The generic sentence for a code; the internal one for anything unlisted. */
205
+ function genericPublicMessage(code) {
206
+ return GENERIC_PUBLIC_MESSAGE[code] ?? GENERIC_PUBLIC_MESSAGE.INTERNAL_ERROR;
271
207
  }
272
- var NextlyError = class _NextlyError extends Error {
273
- code;
274
- statusCode;
275
- publicMessage;
276
- publicData;
277
- messageKey;
278
- logMessage;
279
- logContext;
280
- cause;
281
- timestamp;
282
- constructor(opts) {
283
- super(opts.publicMessage);
284
- this.name = "NextlyError";
285
- this.code = opts.code;
286
- this.publicMessage = opts.publicMessage;
287
- this.publicData = opts.publicData;
288
- this.messageKey = opts.messageKey;
289
- this.logMessage = opts.logMessage;
290
- this.logContext = opts.logContext;
291
- this.cause = opts.cause;
292
- this.timestamp = /* @__PURE__ */ new Date();
293
- this.statusCode = _NextlyError.resolveStatusCode(opts);
294
- Error.captureStackTrace?.(this, _NextlyError);
295
- }
296
- static {
297
- _NextlyError.prototype[NEXTLY_ERROR_BRAND] = true;
298
- }
299
- static resolveStatusCode(opts) {
300
- if (typeof opts.statusCode === "number") return opts.statusCode;
301
- if (opts.code in NEXTLY_ERROR_STATUS) {
302
- return NEXTLY_ERROR_STATUS[opts.code];
303
- }
304
- return 500;
305
- }
306
- /** HTTP-safe JSON. Strips logMessage / logContext / cause / stack. */
307
- toResponseJSON(requestId) {
308
- const json = {
309
- code: String(this.code),
310
- message: this.publicMessage,
311
- requestId
312
- };
313
- if (this.messageKey) json.messageKey = this.messageKey;
314
- if (this.publicData !== void 0) json.data = this.publicData;
315
- return json;
316
- }
317
- /** Operator-facing JSON for log lines. Includes everything. */
318
- toLogJSON(requestId) {
319
- return {
320
- code: this.code,
321
- statusCode: this.statusCode,
322
- publicMessage: this.publicMessage,
323
- messageKey: this.messageKey,
324
- logMessage: this.logMessage,
325
- logContext: this.logContext,
326
- cause: this.cause ? {
327
- name: this.cause.name,
328
- message: this.cause.message,
329
- stack: this.cause.stack
330
- } : void 0,
331
- timestamp: this.timestamp.toISOString(),
332
- requestId
333
- };
334
- }
335
- // ────────────────────────────────────────────────────────────────────
336
- // Static factories — the recommended throw site for every common case.
337
- // Public messages here follow the §13.8 rubric: complete sentence,
338
- // generic, no identifiers, no value echoing, no policy hints.
339
- // ────────────────────────────────────────────────────────────────────
340
- static invalidCredentials(opts) {
341
- return new _NextlyError({
342
- code: "AUTH_INVALID_CREDENTIALS",
343
- publicMessage: "Invalid email or password.",
344
- logMessage: "Login failed",
345
- logContext: opts?.logContext
346
- });
347
- }
348
- /**
349
- * The password was correct and the address is unverified. Thrown only once
350
- * the password is proven, so only someone who holds it learns the address
351
- * is unverified; every other refusal stays
352
- * {@link NextlyError.invalidCredentials}.
353
- */
354
- static emailNotVerified(opts) {
355
- return new _NextlyError({
356
- code: "EMAIL_NOT_VERIFIED",
357
- publicMessage: "Verify your email address to sign in. Check your inbox for the link.",
358
- logMessage: "Login refused: email not verified",
359
- logContext: opts?.logContext
360
- });
361
- }
362
- static authRequired(opts) {
363
- return new _NextlyError({
364
- code: "AUTH_REQUIRED",
365
- publicMessage: "Authentication required.",
366
- logContext: opts?.logContext
367
- });
368
- }
369
- /**
370
- * Distinct from `authRequired`: the caller was authenticated but the
371
- * session token expired. Clients key on the TOKEN_EXPIRED *code* to
372
- * silently refresh and retry rather than redirecting to login; the public
373
- * message stays the generic spec §13.6 string ("Authentication required.")
374
- * so the wire never reveals the session state — same as `authRequired`.
375
- */
376
- static tokenExpired(opts) {
377
- return new _NextlyError({
378
- code: "TOKEN_EXPIRED",
379
- publicMessage: "Authentication required.",
380
- logContext: opts?.logContext
381
- });
382
- }
383
- static notFound(opts) {
384
- return new _NextlyError({
385
- code: "NOT_FOUND",
386
- publicMessage: opts?.message ?? "Not found.",
387
- cause: opts?.cause,
388
- logContext: opts?.logContext
389
- });
390
- }
391
- static forbidden(opts) {
392
- return new _NextlyError({
393
- code: "FORBIDDEN",
394
- publicMessage: "You don't have permission to perform this action.",
395
- cause: opts?.cause,
396
- logContext: opts?.logContext
397
- });
398
- }
399
- /**
400
- * A cookie-authenticated write refused because its origin or CSRF token did
401
- * not check out. The public message names no detail of which check failed;
402
- * that goes to the log through `logMessage`.
403
- */
404
- static csrfFailed(opts) {
405
- return new _NextlyError({
406
- code: "CSRF_FAILED",
407
- publicMessage: "This request could not be verified. Reload the page and try again.",
408
- logMessage: opts?.logMessage,
409
- logContext: opts?.logContext
410
- });
411
- }
412
- /**
413
- * A call the caller got wrong, where naming the mistake IS the value of the
414
- * error.
415
- *
416
- * The only factory that takes its public message from the caller. The
417
- * generic messages elsewhere exist so an HTTP response cannot leak internal
418
- * detail; this one is for arguments and configuration a developer controls
419
- * and must be told about — a missing option, an unusable combination — where
420
- * `internal()` would reduce the one useful sentence to "An unexpected error
421
- * occurred." Do not pass user-supplied data through it.
422
- */
423
- static invalidInput(opts) {
424
- return new _NextlyError({
425
- code: "INVALID_INPUT",
426
- publicMessage: opts.message,
427
- logContext: opts.logContext
428
- });
429
- }
430
- static validation(opts) {
431
- return new _NextlyError({
432
- code: "VALIDATION_ERROR",
433
- publicMessage: "Validation failed.",
434
- publicData: { errors: opts.errors },
435
- cause: opts.cause,
436
- logContext: opts.logContext
437
- });
438
- }
439
- static conflict(opts) {
440
- return new _NextlyError({
441
- code: "CONFLICT",
442
- publicMessage: opts?.message ?? "The resource has changed since you last loaded it. Please refresh and try again.",
443
- cause: opts?.cause,
444
- logContext: { reason: opts?.reason, ...opts?.logContext }
445
- });
446
- }
447
- static duplicate(opts) {
448
- return new _NextlyError({
449
- code: "DUPLICATE",
450
- publicMessage: "Resource already exists.",
451
- logContext: opts?.logContext
452
- });
453
- }
454
- static rateLimited(opts) {
455
- return new _NextlyError({
456
- code: "RATE_LIMITED",
457
- publicMessage: "Too many requests. Please try again later.",
458
- publicData: opts?.retryAfterSeconds !== void 0 ? { retryAfterSeconds: opts.retryAfterSeconds } : void 0,
459
- logContext: opts?.logContext
460
- });
461
- }
462
- static internal(opts) {
463
- return new _NextlyError({
464
- code: "INTERNAL_ERROR",
465
- publicMessage: "An unexpected error occurred.",
466
- cause: opts?.cause,
467
- logContext: opts?.logContext
468
- });
469
- }
470
- // Accepts an optional `logMessage` override so callers (e.g. the health
471
- // route) can record a specific operator narrative ("Health check failed")
472
- // while the public message stays canonical per spec §13.8.5.
473
- static serviceUnavailable(opts) {
474
- return new _NextlyError({
475
- code: "SERVICE_UNAVAILABLE",
476
- publicMessage: opts?.publicMessage ?? "Service unavailable. Please try again later.",
477
- logMessage: opts?.logMessage,
478
- cause: opts?.cause,
479
- logContext: opts?.logContext
480
- });
481
- }
482
- /**
483
- * Convert a DbError (or arbitrary unknown thrown by the DB layer) to a
484
- * NextlyError with a generic public message and rich logContext. Used by
485
- * `withDbErrors` for auto-conversion (Pattern A) and by services that
486
- * catch DB errors at boundaries (Pattern B). Spec §8.2 mapping table.
487
- *
488
- * Never leaks DB driver text, constraint names, or table names into
489
- * `publicMessage`. All DB context goes into `logContext`. The original
490
- * DbError is preserved as `cause`.
491
- */
492
- static fromDatabaseError(error) {
493
- if (isDbError(error)) {
494
- const mapping = DB_ERROR_MAPPING[error.kind];
495
- const logContext = {
496
- dbKind: error.kind,
497
- dialect: error.dialect
498
- };
499
- if (error.code !== void 0) logContext.dbCode = error.code;
500
- if (error.meta !== void 0) logContext.meta = error.meta;
501
- return new _NextlyError({
502
- code: mapping.code,
503
- statusCode: mapping.statusCode,
504
- publicMessage: mapping.publicMessage,
505
- logMessage: "Database error",
506
- logContext,
507
- cause: error
508
- });
509
- }
510
- return new _NextlyError({
511
- code: "INTERNAL_ERROR",
512
- statusCode: 500,
513
- publicMessage: "An unexpected error occurred.",
514
- logMessage: "Non-DbError passed to fromDatabaseError",
515
- cause: error instanceof Error ? error : void 0,
516
- logContext: error instanceof Error ? void 0 : { value: String(error) }
517
- });
518
- }
519
- // ────────────────────────────────────────────────────────────────────
520
- // Type guards. Structural rather than `instanceof` so they survive
521
- // package-boundary mismatches (one consumer's NextlyError is a
522
- // different module instance from another's).
523
- // ────────────────────────────────────────────────────────────────────
524
- static is(err) {
525
- return hasBrand(err);
526
- }
527
- static isCode(err, code) {
528
- return hasBrand(err) && err.code === code;
529
- }
530
- static isNotFound(err) {
531
- return _NextlyError.isCode(err, "NOT_FOUND");
532
- }
533
- static isValidation(err) {
534
- return _NextlyError.isCode(err, "VALIDATION_ERROR");
535
- }
536
- static isAuthRequired(err) {
537
- return _NextlyError.isCode(err, "AUTH_REQUIRED");
538
- }
539
- static isForbidden(err) {
540
- return _NextlyError.isCode(err, "FORBIDDEN");
541
- }
542
- static isConflict(err) {
543
- return _NextlyError.isCode(err, "CONFLICT");
544
- }
545
- static isRateLimited(err) {
546
- return _NextlyError.isCode(err, "RATE_LIMITED");
547
- }
208
+ /**
209
+ * Default mapping from DbErrorKind to NextlyError contract. Used by
210
+ * `NextlyError.fromDatabaseError`. Public messages are intentionally generic
211
+ * per spec §13.8 — no DB driver text, no constraint names, no field hints.
212
+ *
213
+ * `satisfies Record<DbErrorKind, ...>` makes the mapping exhaustive at
214
+ * compile time: adding a new DbErrorKind without updating this table is a
215
+ * type error.
216
+ */
217
+ const DB_ERROR_MAPPING = {
218
+ "unique-violation": {
219
+ code: "DUPLICATE",
220
+ statusCode: 409,
221
+ publicMessage: "Resource already exists."
222
+ },
223
+ "fk-violation": {
224
+ code: "VALIDATION_ERROR",
225
+ statusCode: 400,
226
+ publicMessage: "Referenced record does not exist."
227
+ },
228
+ "not-null-violation": {
229
+ code: "VALIDATION_ERROR",
230
+ statusCode: 400,
231
+ publicMessage: "A required field is missing."
232
+ },
233
+ constraint: {
234
+ code: "VALIDATION_ERROR",
235
+ statusCode: 400,
236
+ publicMessage: "The provided data violates a constraint."
237
+ },
238
+ deadlock: {
239
+ code: "CONFLICT",
240
+ statusCode: 409,
241
+ publicMessage: "The operation could not be completed. Please retry."
242
+ },
243
+ "serialization-failure": {
244
+ code: "CONFLICT",
245
+ statusCode: 409,
246
+ publicMessage: "The operation could not be completed. Please retry."
247
+ },
248
+ busy: {
249
+ code: "DATABASE_BUSY",
250
+ statusCode: NEXTLY_ERROR_STATUS.DATABASE_BUSY,
251
+ publicMessage: genericPublicMessage("DATABASE_BUSY"),
252
+ publicData: { retryAfterSeconds: 5 }
253
+ },
254
+ timeout: {
255
+ code: "DATABASE_ERROR",
256
+ statusCode: 500,
257
+ publicMessage: "The operation timed out. Please try again."
258
+ },
259
+ "connection-lost": {
260
+ code: "DATABASE_ERROR",
261
+ statusCode: 500,
262
+ publicMessage: "A temporary database error occurred. Please try again."
263
+ },
264
+ syntax: {
265
+ code: "INTERNAL_ERROR",
266
+ statusCode: 500,
267
+ publicMessage: "An unexpected error occurred."
268
+ },
269
+ internal: {
270
+ code: "INTERNAL_ERROR",
271
+ statusCode: 500,
272
+ publicMessage: "An unexpected error occurred."
273
+ }
548
274
  };
549
-
550
- // ../nextly/dist/chunk-V6N5XUUA.mjs
551
- var StorageReadTooLargeError = class extends NextlyError {
552
- constructor(path, maxBytes, size) {
553
- super({
554
- code: "STORAGE_READ_TOO_LARGE",
555
- publicMessage: "The stored file is larger than the limit for this read.",
556
- logContext: {
557
- path,
558
- maxBytes,
559
- ...size === void 0 ? {} : { size }
560
- }
561
- });
562
- this.path = path;
563
- this.maxBytes = maxBytes;
564
- this.size = size;
565
- }
275
+ const NEXTLY_ERROR_BRAND = Symbol.for("nextly/NextlyError");
276
+ function hasBrand(value) {
277
+ if (value === null || typeof value !== "object" && typeof value !== "function") return false;
278
+ return value[NEXTLY_ERROR_BRAND] === true;
279
+ }
280
+ /**
281
+ * Unified error class for Nextly. Used at every throw site across services,
282
+ * direct API, auth, and plugins. Carries two distinct payloads:
283
+ *
284
+ * - Public (`code`, `publicMessage`, `publicData`, `statusCode`, `messageKey`)
285
+ * sent in HTTP responses and Server Action results.
286
+ * - Log (`logMessage`, `logContext`, `cause`, stack trace)
287
+ * written to the server logger; never serialised to the wire.
288
+ *
289
+ * Use the static factories (notFound, forbidden, validation, ...) for the
290
+ * common cases. Use the free-form constructor for plugin codes or one-off
291
+ * shapes.
292
+ */
293
+ var NextlyError = class NextlyError extends Error {
294
+ code;
295
+ statusCode;
296
+ publicMessage;
297
+ publicData;
298
+ messageKey;
299
+ logMessage;
300
+ logContext;
301
+ cause;
302
+ timestamp;
303
+ constructor(opts) {
304
+ super(opts.publicMessage);
305
+ this.name = "NextlyError";
306
+ this.code = opts.code;
307
+ this.publicMessage = opts.publicMessage;
308
+ this.publicData = opts.publicData;
309
+ this.messageKey = opts.messageKey;
310
+ this.logMessage = opts.logMessage;
311
+ this.logContext = opts.logContext;
312
+ this.cause = opts.cause;
313
+ this.timestamp = /* @__PURE__ */ new Date();
314
+ this.statusCode = NextlyError.resolveStatusCode(opts);
315
+ Error.captureStackTrace?.(this, NextlyError);
316
+ }
317
+ static {
318
+ NextlyError.prototype[NEXTLY_ERROR_BRAND] = true;
319
+ }
320
+ static resolveStatusCode(opts) {
321
+ if (typeof opts.statusCode === "number") return opts.statusCode;
322
+ if (opts.code in NEXTLY_ERROR_STATUS) return NEXTLY_ERROR_STATUS[opts.code];
323
+ return 500;
324
+ }
325
+ /** HTTP-safe JSON. Strips logMessage / logContext / cause / stack. */
326
+ toResponseJSON(requestId) {
327
+ const json = {
328
+ code: String(this.code),
329
+ message: this.publicMessage,
330
+ requestId
331
+ };
332
+ if (this.messageKey) json.messageKey = this.messageKey;
333
+ if (this.publicData !== void 0) json.data = this.publicData;
334
+ return json;
335
+ }
336
+ /** Operator-facing JSON for log lines. Includes everything. */
337
+ toLogJSON(requestId) {
338
+ return {
339
+ code: this.code,
340
+ statusCode: this.statusCode,
341
+ publicMessage: this.publicMessage,
342
+ messageKey: this.messageKey,
343
+ logMessage: this.logMessage,
344
+ logContext: this.logContext,
345
+ cause: this.cause ? {
346
+ name: this.cause.name,
347
+ message: this.cause.message,
348
+ stack: this.cause.stack
349
+ } : void 0,
350
+ timestamp: this.timestamp.toISOString(),
351
+ requestId
352
+ };
353
+ }
354
+ static invalidCredentials(opts) {
355
+ return new NextlyError({
356
+ code: "AUTH_INVALID_CREDENTIALS",
357
+ publicMessage: "Invalid email or password.",
358
+ logMessage: "Login failed",
359
+ logContext: opts?.logContext
360
+ });
361
+ }
362
+ /**
363
+ * The password was correct and the address is unverified. Thrown only once
364
+ * the password is proven, so only someone who holds it learns the address
365
+ * is unverified; every other refusal stays
366
+ * {@link NextlyError.invalidCredentials}.
367
+ */
368
+ static emailNotVerified(opts) {
369
+ return new NextlyError({
370
+ code: "EMAIL_NOT_VERIFIED",
371
+ publicMessage: "Verify your email address to sign in. Check your inbox for the link.",
372
+ logMessage: "Login refused: email not verified",
373
+ logContext: opts?.logContext
374
+ });
375
+ }
376
+ static authRequired(opts) {
377
+ return new NextlyError({
378
+ code: "AUTH_REQUIRED",
379
+ publicMessage: "Authentication required.",
380
+ logContext: opts?.logContext
381
+ });
382
+ }
383
+ /**
384
+ * Distinct from `authRequired`: the caller was authenticated but the
385
+ * session token expired. Clients key on the TOKEN_EXPIRED *code* to
386
+ * silently refresh and retry rather than redirecting to login; the public
387
+ * message stays the generic spec §13.6 string ("Authentication required.")
388
+ * so the wire never reveals the session state — same as `authRequired`.
389
+ */
390
+ static tokenExpired(opts) {
391
+ return new NextlyError({
392
+ code: "TOKEN_EXPIRED",
393
+ publicMessage: "Authentication required.",
394
+ logContext: opts?.logContext
395
+ });
396
+ }
397
+ static notFound(opts) {
398
+ return new NextlyError({
399
+ code: "NOT_FOUND",
400
+ publicMessage: opts?.message ?? "Not found.",
401
+ cause: opts?.cause,
402
+ logContext: opts?.logContext
403
+ });
404
+ }
405
+ static forbidden(opts) {
406
+ return new NextlyError({
407
+ code: "FORBIDDEN",
408
+ publicMessage: "You don't have permission to perform this action.",
409
+ cause: opts?.cause,
410
+ logContext: opts?.logContext
411
+ });
412
+ }
413
+ /**
414
+ * A collection, Single or component defined in code, which is changed by
415
+ * changing its code and not from here. Forbidden, and the message says so:
416
+ * it is a fact about the thing, not about the caller's permissions, so it
417
+ * names no permission and no policy. The lock's detail goes to the log
418
+ * through `logContext`.
419
+ */
420
+ static definedInCode(opts) {
421
+ return new NextlyError({
422
+ code: "FORBIDDEN",
423
+ publicMessage: `This ${opts.thing} is defined in code, so it can't be ${opts.change === "change" ? "changed" : "deleted"} here.`,
424
+ logContext: opts.logContext
425
+ });
426
+ }
427
+ /**
428
+ * An autosave where the collection or Single does not autosave: its owner
429
+ * switched autosave off, or it keeps no drafts to autosave into. Forbidden,
430
+ * and the message says so, since it is a setting and not a permission.
431
+ */
432
+ static autosaveOff(opts) {
433
+ return new NextlyError({
434
+ code: "FORBIDDEN",
435
+ publicMessage: `Autosave is turned off for this ${opts.scopeKind === "single" ? "Single" : "collection"}.`,
436
+ logContext: opts.logContext
437
+ });
438
+ }
439
+ /**
440
+ * An API key refused because its role allows more than the person creating
441
+ * it may do. The sentence is FORBIDDEN's and names no permission; which one
442
+ * exceeded goes to the log through `logContext`. The code is its own, so a
443
+ * client can tell "choose a smaller role" from plain FORBIDDEN.
444
+ */
445
+ static apiKeyRoleAboveCreator(opts) {
446
+ return new NextlyError({
447
+ code: "API_KEY_ROLE_ABOVE_CREATOR",
448
+ publicMessage: genericPublicMessage("FORBIDDEN"),
449
+ logContext: opts?.logContext
450
+ });
451
+ }
452
+ /**
453
+ * A cookie-authenticated write refused because its origin or CSRF token did
454
+ * not check out. The public message names no detail of which check failed;
455
+ * that goes to the log through `logMessage`.
456
+ */
457
+ static csrfFailed(opts) {
458
+ return new NextlyError({
459
+ code: "CSRF_FAILED",
460
+ publicMessage: "This request could not be verified. Reload the page and try again.",
461
+ logMessage: opts?.logMessage,
462
+ logContext: opts?.logContext
463
+ });
464
+ }
465
+ /**
466
+ * A call the caller got wrong, where naming the mistake IS the value of the
467
+ * error.
468
+ *
469
+ * The only factory that takes its public message from the caller. The
470
+ * generic messages elsewhere exist so an HTTP response cannot leak internal
471
+ * detail; this one is for arguments and configuration a developer controls
472
+ * and must be told about — a missing option, an unusable combination — where
473
+ * `internal()` would reduce the one useful sentence to "An unexpected error
474
+ * occurred." Do not pass user-supplied data through it.
475
+ */
476
+ static invalidInput(opts) {
477
+ return new NextlyError({
478
+ code: "INVALID_INPUT",
479
+ publicMessage: opts.message,
480
+ logContext: opts.logContext
481
+ });
482
+ }
483
+ static validation(opts) {
484
+ return new NextlyError({
485
+ code: "VALIDATION_ERROR",
486
+ publicMessage: "Validation failed.",
487
+ publicData: { errors: opts.errors },
488
+ cause: opts.cause,
489
+ logContext: opts.logContext
490
+ });
491
+ }
492
+ static conflict(opts) {
493
+ return new NextlyError({
494
+ code: "CONFLICT",
495
+ publicMessage: opts?.message ?? "The resource has changed since you last loaded it. Please refresh and try again.",
496
+ publicData: opts?.data,
497
+ cause: opts?.cause,
498
+ logContext: {
499
+ reason: opts?.reason,
500
+ ...opts?.logContext
501
+ }
502
+ });
503
+ }
504
+ static duplicate(opts) {
505
+ return new NextlyError({
506
+ code: "DUPLICATE",
507
+ publicMessage: "Resource already exists.",
508
+ logContext: opts?.logContext
509
+ });
510
+ }
511
+ static rateLimited(opts) {
512
+ return new NextlyError({
513
+ code: "RATE_LIMITED",
514
+ publicMessage: "Too many requests. Please try again later.",
515
+ publicData: opts?.retryAfterSeconds !== void 0 ? { retryAfterSeconds: opts.retryAfterSeconds } : void 0,
516
+ logContext: opts?.logContext
517
+ });
518
+ }
519
+ /**
520
+ * A request that waited `pool.connectionTimeoutMs` for a database
521
+ * connection and was given none. The statement or transaction that waited
522
+ * never ran; work committed before it stays committed. The same error
523
+ * `fromDatabaseError` gives a busy DbError, from the same table row.
524
+ */
525
+ static databaseBusy(opts) {
526
+ const busy = DB_ERROR_MAPPING.busy;
527
+ return new NextlyError({
528
+ code: busy.code,
529
+ statusCode: busy.statusCode,
530
+ publicMessage: busy.publicMessage,
531
+ publicData: busy.publicData,
532
+ cause: opts?.cause,
533
+ logContext: opts?.logContext
534
+ });
535
+ }
536
+ /**
537
+ * What a request boundary answers with for a thrown value that is not a
538
+ * NextlyError: "database busy" when the request waited out its bound for a
539
+ * connection, which is retryable and no fault of the server's code, and an
540
+ * internal error for anything else.
541
+ *
542
+ * The busy case has to be read here because it usually arrives raw:
543
+ * `withDbErrors` converts only DbErrors, so a driver's error passes through
544
+ * services unconverted to the boundary that answers the request.
545
+ */
546
+ static fromUnexpected(error) {
547
+ return NextlyError.busyOr(error, () => NextlyError.internal({ cause: error instanceof Error ? error : void 0 }));
548
+ }
549
+ /**
550
+ * "Database busy" when `error` is a request that waited out its bound for
551
+ * a connection, and `otherwise()` for anything else.
552
+ *
553
+ * For a boundary that answers an unexpected failure with an error of its
554
+ * own choosing rather than an internal one (a 503 for a flow that must not
555
+ * answer on incomplete data), so that a busy database still answers there
556
+ * as it does everywhere else. `fromUnexpected` is this rule with the
557
+ * internal error as its fallback.
558
+ */
559
+ static busyOr(error, otherwise) {
560
+ if (!isDatabaseBusy(error)) return otherwise();
561
+ return NextlyError.databaseBusy({ cause: error instanceof Error ? error : void 0 });
562
+ }
563
+ static internal(opts) {
564
+ return new NextlyError({
565
+ code: "INTERNAL_ERROR",
566
+ publicMessage: "An unexpected error occurred.",
567
+ cause: opts?.cause,
568
+ logContext: opts?.logContext
569
+ });
570
+ }
571
+ static serviceUnavailable(opts) {
572
+ return new NextlyError({
573
+ code: "SERVICE_UNAVAILABLE",
574
+ publicMessage: opts?.publicMessage ?? "Service unavailable. Please try again later.",
575
+ logMessage: opts?.logMessage,
576
+ cause: opts?.cause,
577
+ logContext: opts?.logContext
578
+ });
579
+ }
580
+ /**
581
+ * Convert a DbError (or arbitrary unknown thrown by the DB layer) to a
582
+ * NextlyError with a generic public message and rich logContext. Used by
583
+ * `withDbErrors` for auto-conversion (Pattern A) and by services that
584
+ * catch DB errors at boundaries (Pattern B). Spec §8.2 mapping table.
585
+ *
586
+ * Never leaks DB driver text, constraint names, or table names into
587
+ * `publicMessage`. All DB context goes into `logContext`. The original
588
+ * DbError is preserved as `cause`.
589
+ */
590
+ static fromDatabaseError(error) {
591
+ if (isDbError(error)) {
592
+ const mapping = DB_ERROR_MAPPING[error.kind];
593
+ const logContext = {
594
+ dbKind: error.kind,
595
+ dialect: error.dialect
596
+ };
597
+ if (error.code !== void 0) logContext.dbCode = error.code;
598
+ if (error.meta !== void 0) logContext.meta = error.meta;
599
+ return new NextlyError({
600
+ code: mapping.code,
601
+ statusCode: mapping.statusCode,
602
+ publicMessage: mapping.publicMessage,
603
+ publicData: "publicData" in mapping ? mapping.publicData : void 0,
604
+ logMessage: "Database error",
605
+ logContext,
606
+ cause: error
607
+ });
608
+ }
609
+ return new NextlyError({
610
+ code: "INTERNAL_ERROR",
611
+ statusCode: 500,
612
+ publicMessage: "An unexpected error occurred.",
613
+ logMessage: "Non-DbError passed to fromDatabaseError",
614
+ cause: error instanceof Error ? error : void 0,
615
+ logContext: error instanceof Error ? void 0 : { value: String(error) }
616
+ });
617
+ }
618
+ static is(err) {
619
+ return hasBrand(err);
620
+ }
621
+ static isCode(err, code) {
622
+ return hasBrand(err) && err.code === code;
623
+ }
624
+ static isNotFound(err) {
625
+ return NextlyError.isCode(err, "NOT_FOUND");
626
+ }
627
+ static isValidation(err) {
628
+ return NextlyError.isCode(err, "VALIDATION_ERROR");
629
+ }
630
+ static isAuthRequired(err) {
631
+ return NextlyError.isCode(err, "AUTH_REQUIRED");
632
+ }
633
+ static isForbidden(err) {
634
+ return NextlyError.isCode(err, "FORBIDDEN");
635
+ }
636
+ static isConflict(err) {
637
+ return NextlyError.isCode(err, "CONFLICT");
638
+ }
639
+ static isRateLimited(err) {
640
+ return NextlyError.isCode(err, "RATE_LIMITED");
641
+ }
566
642
  };
567
-
568
- // ../nextly/dist/chunk-FLULBGQB.mjs
643
+ // ../nextly/dist/get-trusted-client-ip-CYEbmLA3.mjs
644
+ /**
645
+ * A dotted-quad IPv4 address as an unsigned 32-bit integer, or null when it is
646
+ * not one. Shared by the client-address, refresh-binding and outbound-URL
647
+ * prefix checks.
648
+ */
569
649
  function ipv4ToInt(ip) {
570
- const parts = ip.split(".");
571
- if (parts.length !== 4) return null;
572
- let acc = 0;
573
- for (const p of parts) {
574
- const n = Number(p);
575
- if (!Number.isInteger(n) || n < 0 || n > 255) return null;
576
- acc = acc << 8 | n;
577
- }
578
- return acc >>> 0;
650
+ const parts = ip.split(".");
651
+ if (parts.length !== 4) return null;
652
+ let acc = 0;
653
+ for (const p of parts) {
654
+ const n = Number(p);
655
+ if (!Number.isInteger(n) || n < 0 || n > 255) return null;
656
+ acc = acc << 8 | n;
657
+ }
658
+ return acc >>> 0;
579
659
  }
580
-
581
- // ../nextly/dist/chunk-FKT4HLI3.mjs
582
- var IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
583
- var PRIVATE_IPV4_CIDRS = [
584
- "0.0.0.0/8",
585
- // RFC1122 "this network" / wildcard
586
- "10.0.0.0/8",
587
- // RFC1918
588
- "100.64.0.0/10",
589
- // RFC6598 CGNAT
590
- "127.0.0.0/8",
591
- // RFC1122 loopback
592
- "169.254.0.0/16",
593
- // RFC3927 link-local + AWS/GCP metadata
594
- "172.16.0.0/12",
595
- // RFC1918
596
- "192.0.0.0/24",
597
- // RFC6890 IETF protocol assignments
598
- "192.0.2.0/24",
599
- // RFC5737 documentation
600
- "192.168.0.0/16",
601
- // RFC1918
602
- "198.18.0.0/15",
603
- // RFC2544 benchmarking
604
- "198.51.100.0/24",
605
- // RFC5737 documentation
606
- "203.0.113.0/24",
607
- // RFC5737 documentation
608
- "224.0.0.0/4",
609
- // RFC5771 multicast
610
- "240.0.0.0/4"
611
- // RFC1112 reserved
660
+ // ../nextly/dist/storage/read-errors.mjs
661
+ /**
662
+ * The one way the storage layer says a read exceeded the caller's cap.
663
+ *
664
+ * `StorageReadOptions.maxBytes` is part of the adapter contract, so what happens
665
+ * when it is exceeded belongs to the contract too. Without this each backend
666
+ * refuses in its own vocabulary — the URL-addressed ones through a fetch error
667
+ * carrying a `reason`, S3 through whatever its own code threw — and a caller
668
+ * wanting to tell "too large" from "could not read" has to know which adapter it
669
+ * is talking to. That is the thing an adapter contract exists to stop.
670
+ *
671
+ * Distinct from a generic internal failure because callers ACT on the
672
+ * difference. The email attachment path answers an over-cap read with
673
+ * `ATTACHMENT_SIZE_EXCEEDED`, which tells an author to attach something smaller;
674
+ * a storage failure tells them nothing they can act on. Collapsing the two turns
675
+ * a fixable refusal into an opaque error, which is the same conflation this
676
+ * contract already draws between a missing key and an unreachable store.
677
+ *
678
+ * @module storage/read-errors
679
+ */
680
+ /**
681
+ * A read refused because the object is larger than the caller allowed.
682
+ *
683
+ * Carries the numbers rather than only a message, so a caller can say what the
684
+ * limit was without parsing prose — and so the message itself stays free of the
685
+ * key, which should not travel to whoever asked for the file.
686
+ */
687
+ var StorageReadTooLargeError = class extends NextlyError {
688
+ path;
689
+ maxBytes;
690
+ size;
691
+ constructor(path, maxBytes, size) {
692
+ super({
693
+ code: "STORAGE_READ_TOO_LARGE",
694
+ publicMessage: "The stored file is larger than the limit for this read.",
695
+ logContext: {
696
+ path,
697
+ maxBytes,
698
+ ...size === void 0 ? {} : { size }
699
+ }
700
+ });
701
+ this.path = path;
702
+ this.maxBytes = maxBytes;
703
+ this.size = size;
704
+ }
705
+ };
706
+ // ../nextly/dist/fetch-stored-bytes-CJ9Uv8it.mjs
707
+ const IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
708
+ /**
709
+ * IPv4 CIDR ranges that must NEVER be the destination of an outbound
710
+ * fetch sourced from a user-controllable URL. Listed roughly by RFC.
711
+ */
712
+ const PRIVATE_IPV4_CIDRS = [
713
+ "0.0.0.0/8",
714
+ "10.0.0.0/8",
715
+ "100.64.0.0/10",
716
+ "127.0.0.0/8",
717
+ "169.254.0.0/16",
718
+ "172.16.0.0/12",
719
+ "192.0.0.0/24",
720
+ "192.0.2.0/24",
721
+ "192.168.0.0/16",
722
+ "198.18.0.0/15",
723
+ "198.51.100.0/24",
724
+ "203.0.113.0/24",
725
+ "224.0.0.0/4",
726
+ "240.0.0.0/4"
612
727
  ];
613
- var CLOUD_METADATA_HOSTS = /* @__PURE__ */ new Set([
614
- "metadata.google.internal",
615
- "metadata.googleapis.com",
616
- "metadata"
617
- // GCP short form
728
+ /**
729
+ * Cloud-metadata hostnames. These often resolve to private IPs and the
730
+ * private-IP block catches them, but we reject by hostname first so the
731
+ * error message is clear.
732
+ */
733
+ const CLOUD_METADATA_HOSTS = /* @__PURE__ */ new Set([
734
+ "metadata.google.internal",
735
+ "metadata.googleapis.com",
736
+ "metadata"
618
737
  ]);
738
+ /**
739
+ * An outbound URL refused for SSRF safety. Extends `NextlyError` (the package's
740
+ * canonical error) so throw sites honor the error contract, while remaining a
741
+ * distinct class the callers (e.g. the form-builder webhook handler) match with
742
+ * `instanceof`.
743
+ */
619
744
  var ExternalUrlError = class extends NextlyError {
620
- constructor(message, url) {
621
- super({
622
- code: "EXTERNAL_URL_BLOCKED",
623
- publicMessage: message,
624
- logContext: { url }
625
- });
626
- this.url = url;
627
- this.name = "ExternalUrlError";
628
- }
745
+ url;
746
+ constructor(message, url) {
747
+ super({
748
+ code: "EXTERNAL_URL_BLOCKED",
749
+ publicMessage: message,
750
+ logContext: { url }
751
+ });
752
+ this.url = url;
753
+ this.name = "ExternalUrlError";
754
+ }
629
755
  };
756
+ /** Validate a URL for outbound fetch. Throws `ExternalUrlError` on rejection. */
630
757
  async function validateExternalUrl(rawUrl, options = {}) {
631
- let parsed;
632
- try {
633
- parsed = new URL(rawUrl);
634
- } catch {
635
- throw new ExternalUrlError("Invalid URL", rawUrl);
636
- }
637
- const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
638
- const isLocalhostHostname = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
639
- const baseProtocols = options.allowedProtocols ?? ["https:"];
640
- const protocols = options.allowLocalhost && isLocalhostHostname ? [...baseProtocols, "http:"] : baseProtocols;
641
- if (!protocols.includes(parsed.protocol)) {
642
- throw new ExternalUrlError(
643
- `Protocol ${parsed.protocol} not allowed (allowed: ${protocols.join(", ")})`,
644
- rawUrl
645
- );
646
- }
647
- if (CLOUD_METADATA_HOSTS.has(hostname)) {
648
- throw new ExternalUrlError(
649
- `Cloud-metadata hostname rejected: ${parsed.hostname}`,
650
- rawUrl
651
- );
652
- }
653
- if (IPV4_RE.test(hostname)) {
654
- if (!isPublicIpv4(
655
- hostname,
656
- options.allowLocalhost === true && hostname === "127.0.0.1"
657
- )) {
658
- throw new ExternalUrlError(
659
- `Resolved to non-public IP: ${hostname}`,
660
- rawUrl
661
- );
662
- }
663
- return { url: parsed, pinnedIp: hostname, family: 4 };
664
- }
665
- if (hostname.includes(":")) {
666
- if (!isPublicIpv6(
667
- hostname,
668
- options.allowLocalhost === true && hostname === "::1"
669
- )) {
670
- throw new ExternalUrlError(
671
- `Resolved to non-public IP: ${hostname}`,
672
- rawUrl
673
- );
674
- }
675
- return { url: parsed, pinnedIp: hostname, family: 6 };
676
- }
677
- const { lookup } = await import('dns/promises');
678
- let addresses;
679
- try {
680
- addresses = await lookup(hostname, { all: true });
681
- } catch (err) {
682
- throw new ExternalUrlError(
683
- `DNS lookup failed: ${err.message}`,
684
- rawUrl
685
- );
686
- }
687
- if (addresses.length === 0) {
688
- throw new ExternalUrlError("No IPs returned for host", rawUrl);
689
- }
690
- for (const { address, family } of addresses) {
691
- const allow = options.allowLocalhost === true && isLocalhostHostname && family === 4;
692
- const ok = family === 4 ? isPublicIpv4(address, allow) : isPublicIpv6(address, allow);
693
- if (!ok) {
694
- throw new ExternalUrlError(
695
- `Resolved to non-public IP: ${address}`,
696
- rawUrl
697
- );
698
- }
699
- }
700
- const first = addresses[0];
701
- return {
702
- url: parsed,
703
- pinnedIp: first.address,
704
- family: first.family === 6 ? 6 : 4
705
- };
758
+ let parsed;
759
+ try {
760
+ parsed = new URL(rawUrl);
761
+ } catch {
762
+ throw new ExternalUrlError("Invalid URL", rawUrl);
763
+ }
764
+ const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
765
+ const isLocalhostHostname = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
766
+ const baseProtocols = options.allowedProtocols ?? ["https:"];
767
+ const protocols = options.allowLocalhost && isLocalhostHostname ? [...baseProtocols, "http:"] : baseProtocols;
768
+ if (!protocols.includes(parsed.protocol)) throw new ExternalUrlError(`Protocol ${parsed.protocol} not allowed (allowed: ${protocols.join(", ")})`, rawUrl);
769
+ if (CLOUD_METADATA_HOSTS.has(hostname)) throw new ExternalUrlError(`Cloud-metadata hostname rejected: ${parsed.hostname}`, rawUrl);
770
+ if (IPV4_RE.test(hostname)) {
771
+ if (!isPublicIpv4(hostname, options.allowLocalhost === true && hostname === "127.0.0.1")) throw new ExternalUrlError(`Resolved to non-public IP: ${hostname}`, rawUrl);
772
+ return {
773
+ url: parsed,
774
+ pinnedIp: hostname,
775
+ family: 4
776
+ };
777
+ }
778
+ if (hostname.includes(":")) {
779
+ if (!isPublicIpv6(hostname, options.allowLocalhost === true && hostname === "::1")) throw new ExternalUrlError(`Resolved to non-public IP: ${hostname}`, rawUrl);
780
+ return {
781
+ url: parsed,
782
+ pinnedIp: hostname,
783
+ family: 6
784
+ };
785
+ }
786
+ const { lookup } = await import("node:dns/promises");
787
+ let addresses;
788
+ try {
789
+ addresses = await lookup(hostname, { all: true });
790
+ } catch (err) {
791
+ throw new ExternalUrlError(`DNS lookup failed: ${err.message}`, rawUrl);
792
+ }
793
+ if (addresses.length === 0) throw new ExternalUrlError("No IPs returned for host", rawUrl);
794
+ for (const { address, family } of addresses) {
795
+ const allow = options.allowLocalhost === true && isLocalhostHostname && family === 4;
796
+ if (!(family === 4 ? isPublicIpv4(address, allow) : isPublicIpv6(address, allow))) throw new ExternalUrlError(`Resolved to non-public IP: ${address}`, rawUrl);
797
+ }
798
+ const first = addresses[0];
799
+ return {
800
+ url: parsed,
801
+ pinnedIp: first.address,
802
+ family: first.family === 6 ? 6 : 4
803
+ };
706
804
  }
707
- var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
708
- var DEFAULT_TIMEOUT_MS = 3e4;
805
+ /** Default response body cap: reject anything larger before buffering it all. */
806
+ /**
807
+ * How many bytes a bounded external read may buffer, absent a caller's own cap.
808
+ *
809
+ * Exported for the same reason as the deadline below: an adapter that never
810
+ * touches `safeFetch` still owes callers this default, and restating the number
811
+ * gives the tree two that drift.
812
+ */
813
+ const DEFAULT_MAX_RESPONSE_BYTES = 10485760;
814
+ /** Default overall deadline covering DNS + connect + TLS + response. */
815
+ /**
816
+ * How long a bounded external read may take, absent a caller's own limit.
817
+ *
818
+ * Exported so a caller that must begin the clock EARLIER — a storage adapter
819
+ * bounding a metadata lookup as well as the fetch that follows — uses this
820
+ * number rather than restating it. Two spellings of one default agree today and
821
+ * drift the first time either is tuned.
822
+ */
823
+ const DEFAULT_TIMEOUT_MS = 3e4;
824
+ /**
825
+ * Raised for fetch-phase failures that are NOT SSRF rejections: an over-large
826
+ * response body, the overall deadline elapsing, or an undecodable content
827
+ * encoding. Extends `NextlyError` (the canonical error contract) while staying a
828
+ * distinct class, so a caller can tell "URL refused for safety"
829
+ * (`ExternalUrlError`) apart from "the request itself failed".
830
+ */
709
831
  var SafeFetchError = class extends NextlyError {
710
- constructor(message, url, reason, status) {
711
- super({
712
- code: "EXTERNAL_REQUEST_FAILED",
713
- publicMessage: message,
714
- logContext: { url, reason }
715
- });
716
- this.url = url;
717
- this.reason = reason;
718
- this.status = status;
719
- this.name = "SafeFetchError";
720
- }
832
+ url;
833
+ reason;
834
+ status;
835
+ constructor(message, url, reason, status) {
836
+ super({
837
+ code: "EXTERNAL_REQUEST_FAILED",
838
+ publicMessage: message,
839
+ logContext: {
840
+ url,
841
+ reason
842
+ }
843
+ });
844
+ this.url = url;
845
+ this.reason = reason;
846
+ this.status = status;
847
+ this.name = "SafeFetchError";
848
+ }
721
849
  };
850
+ /**
851
+ * Build a `dns.lookup`-compatible function that ignores the hostname and
852
+ * always resolves to `ip`/`family`. Handing this to `http(s).request` forces
853
+ * the socket to dial the exact address `validateExternalUrl` already vetted,
854
+ * closing the DNS-rebinding window where a second resolution at connect time
855
+ * could return a private IP. Not re-exported from the package barrel; exported
856
+ * for the plugin `ctx.fetch` transport, which pins its vetted address the same
857
+ * way, and so the rebinding invariant can be unit-tested directly.
858
+ */
722
859
  function createPinnedLookup(ip, family) {
723
- return (_hostname, options, callback) => {
724
- if (options && options.all) {
725
- callback(null, [{ address: ip, family }]);
726
- } else {
727
- callback(null, ip, family);
728
- }
729
- };
860
+ return (_hostname, options, callback) => {
861
+ if (options && options.all) callback(null, [{
862
+ address: ip,
863
+ family
864
+ }]);
865
+ else callback(null, ip, family);
866
+ };
730
867
  }
868
+ /**
869
+ * Validate `rawUrl` then fetch it with the validated IP pinned at the socket.
870
+ * The convenience wrapper for the common case (webhook delivery, email
871
+ * attachment fetch). Node-runtime only.
872
+ *
873
+ * DNS is resolved once by `validateExternalUrl`; `createPinnedLookup` forces
874
+ * the connection to that same address, so an attacker's DNS cannot rebind the
875
+ * request to a private host between validation and connect. Redirects are not
876
+ * followed, the body is capped at `maxResponseBytes`, and the request is
877
+ * bounded by `timeoutMs`.
878
+ */
731
879
  async function safeFetch(rawUrl, options = {}) {
732
- const {
733
- allowLocalhost,
734
- allowedProtocols,
735
- method,
736
- headers,
737
- body,
738
- signal,
739
- maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES,
740
- timeoutMs = DEFAULT_TIMEOUT_MS
741
- } = options;
742
- const controller = new AbortController();
743
- const forwardAbort = () => controller.abort(signal?.reason);
744
- if (signal) {
745
- if (signal.aborted) controller.abort(signal.reason);
746
- else signal.addEventListener("abort", forwardAbort, { once: true });
747
- }
748
- const timer = setTimeout(
749
- () => controller.abort(
750
- new SafeFetchError(`Request exceeded ${timeoutMs}ms`, rawUrl, "timeout")
751
- ),
752
- timeoutMs
753
- );
754
- try {
755
- const validated = await abortable(
756
- validateExternalUrl(rawUrl, { allowLocalhost, allowedProtocols }),
757
- controller.signal
758
- );
759
- return await pinnedFetch(validated, {
760
- method,
761
- headers,
762
- body,
763
- signal: controller.signal,
764
- maxResponseBytes
765
- });
766
- } finally {
767
- clearTimeout(timer);
768
- if (signal) signal.removeEventListener("abort", forwardAbort);
769
- }
880
+ const { allowLocalhost, allowedProtocols, method, headers, body, signal, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
881
+ const controller = new AbortController();
882
+ const forwardAbort = () => controller.abort(signal?.reason);
883
+ if (signal) {
884
+ if (signal.aborted) controller.abort(signal.reason);
885
+ else signal.addEventListener("abort", forwardAbort, { once: true });
886
+ }
887
+ const timer = setTimeout(() => controller.abort(new SafeFetchError(`Request exceeded ${timeoutMs}ms`, rawUrl, "timeout")), timeoutMs);
888
+ try {
889
+ return await pinnedFetch(await abortable(validateExternalUrl(rawUrl, {
890
+ allowLocalhost,
891
+ allowedProtocols
892
+ }), controller.signal), {
893
+ method,
894
+ headers,
895
+ body,
896
+ signal: controller.signal,
897
+ maxResponseBytes
898
+ });
899
+ } finally {
900
+ clearTimeout(timer);
901
+ if (signal) signal.removeEventListener("abort", forwardAbort);
902
+ }
770
903
  }
904
+ /**
905
+ * The signal's abort reason as an `Error`. Our own aborts carry a
906
+ * `SafeFetchError` (timeout); a caller's `AbortController.abort()` carries a
907
+ * DOMException `AbortError`; anything else (a non-Error reason) is surfaced as
908
+ * a standard `AbortError` so downstream `name === "AbortError"` checks hold.
909
+ */
771
910
  function abortError(signal) {
772
- const reason = signal.reason;
773
- if (reason instanceof Error) return reason;
774
- return new DOMException("The operation was aborted", "AbortError");
911
+ const reason = signal.reason;
912
+ if (reason instanceof Error) return reason;
913
+ return new DOMException("The operation was aborted", "AbortError");
775
914
  }
915
+ /**
916
+ * Resolve `promise`, but reject with the signal's reason if it aborts first.
917
+ * Lets a phase that runs before the request (DNS validation) share the overall
918
+ * deadline and caller cancellation.
919
+ */
776
920
  function abortable(promise, signal) {
777
- if (signal.aborted) return Promise.reject(abortError(signal));
778
- let onAbort;
779
- const aborted = new Promise((_resolve, reject) => {
780
- onAbort = () => reject(abortError(signal));
781
- signal.addEventListener("abort", onAbort, { once: true });
782
- });
783
- return Promise.race([promise, aborted]).finally(
784
- () => signal.removeEventListener("abort", onAbort)
785
- );
921
+ if (signal.aborted) return Promise.reject(abortError(signal));
922
+ let onAbort;
923
+ const aborted = new Promise((_resolve, reject) => {
924
+ onAbort = () => reject(abortError(signal));
925
+ signal.addEventListener("abort", onAbort, { once: true });
926
+ });
927
+ return Promise.race([promise, aborted]).finally(() => signal.removeEventListener("abort", onAbort));
786
928
  }
929
+ /**
930
+ * Issue the request over `node:http`/`node:https` with the validated IP pinned
931
+ * via a custom `lookup`, then adapt the Node response into a WHATWG `Response`.
932
+ * `agent: false` forces a fresh socket per call so no pooled connection can
933
+ * reuse a differently-resolved address. The overall deadline is enforced by the
934
+ * caller through `init.signal`, so there is no separate timer here.
935
+ */
787
936
  async function pinnedFetch(validated, init) {
788
- const { url, pinnedIp, family } = validated;
789
- const httpMod = url.protocol === "https:" ? await import('https') : await import('http');
790
- const zlib = await import('zlib');
791
- const lookup = createPinnedLookup(pinnedIp, family);
792
- const outHeaders = toOutgoingHeaders(init.headers);
793
- if (init.body != null) {
794
- delete outHeaders["transfer-encoding"];
795
- outHeaders["content-length"] = String(
796
- typeof init.body === "string" ? Buffer.byteLength(init.body) : init.body.byteLength
797
- );
798
- }
799
- return new Promise((resolve, reject) => {
800
- let settled = false;
801
- const settle = (fn) => {
802
- if (settled) return;
803
- settled = true;
804
- fn();
805
- };
806
- const failure = (err) => init.signal.aborted ? abortError(init.signal) : err;
807
- const req = httpMod.request(
808
- url,
809
- {
810
- method: init.method ?? "GET",
811
- headers: outHeaders,
812
- lookup,
813
- // Fresh socket per request so a pooled connection can't reuse a
814
- // differently-resolved address, and so the pinned lookup always runs.
815
- agent: false,
816
- // The combined deadline/caller signal aborts the request in flight.
817
- signal: init.signal
818
- },
819
- (res) => {
820
- const chunks = [];
821
- let received = 0;
822
- res.on("data", (chunk) => {
823
- received += chunk.length;
824
- if (received > init.maxResponseBytes) {
825
- res.destroy();
826
- req.destroy();
827
- settle(
828
- () => reject(
829
- new SafeFetchError(
830
- `Response body exceeded ${init.maxResponseBytes} bytes`,
831
- url.href,
832
- "response-too-large",
833
- res.statusCode
834
- )
835
- )
836
- );
837
- return;
838
- }
839
- chunks.push(chunk);
840
- });
841
- res.on("end", () => {
842
- const decoded = decodeBody(
843
- Buffer.concat(chunks),
844
- res.headers["content-encoding"],
845
- zlib,
846
- init.maxResponseBytes,
847
- url.href,
848
- res.statusCode
849
- );
850
- if (decoded instanceof SafeFetchError) {
851
- settle(() => reject(decoded));
852
- return;
853
- }
854
- settle(
855
- () => resolve(toWhatwgResponse(res, decoded.body, decoded.decoded))
856
- );
857
- });
858
- res.on("error", (err) => settle(() => reject(failure(err))));
859
- }
860
- );
861
- req.on("error", (err) => settle(() => reject(failure(err))));
862
- if (!req.destroyed) {
863
- if (init.body != null) req.write(init.body);
864
- req.end();
865
- }
866
- });
937
+ const { url, pinnedIp, family } = validated;
938
+ const httpMod = url.protocol === "https:" ? await import("node:https") : await import("node:http");
939
+ const zlib = await import("node:zlib");
940
+ const lookup = createPinnedLookup(pinnedIp, family);
941
+ const outHeaders = toOutgoingHeaders(init.headers);
942
+ if (init.body != null) {
943
+ delete outHeaders["transfer-encoding"];
944
+ outHeaders["content-length"] = String(typeof init.body === "string" ? Buffer.byteLength(init.body) : init.body.byteLength);
945
+ }
946
+ return new Promise((resolve, reject) => {
947
+ let settled = false;
948
+ const settle = (fn) => {
949
+ if (settled) return;
950
+ settled = true;
951
+ fn();
952
+ };
953
+ const failure = (err) => init.signal.aborted ? abortError(init.signal) : err;
954
+ const req = httpMod.request(url, {
955
+ method: init.method ?? "GET",
956
+ headers: outHeaders,
957
+ lookup,
958
+ agent: false,
959
+ signal: init.signal
960
+ }, (res) => {
961
+ const chunks = [];
962
+ let received = 0;
963
+ res.on("data", (chunk) => {
964
+ received += chunk.length;
965
+ if (received > init.maxResponseBytes) {
966
+ res.destroy();
967
+ req.destroy();
968
+ settle(() => reject(new SafeFetchError(`Response body exceeded ${init.maxResponseBytes} bytes`, url.href, "response-too-large", res.statusCode)));
969
+ return;
970
+ }
971
+ chunks.push(chunk);
972
+ });
973
+ res.on("end", () => {
974
+ const decoded = decodeBody(Buffer.concat(chunks), res.headers["content-encoding"], zlib, init.maxResponseBytes, url.href, res.statusCode);
975
+ if (decoded instanceof SafeFetchError) {
976
+ settle(() => reject(decoded));
977
+ return;
978
+ }
979
+ settle(() => resolve(toWhatwgResponse(res, decoded.body, decoded.decoded)));
980
+ });
981
+ res.on("error", (err) => settle(() => reject(failure(err))));
982
+ });
983
+ req.on("error", (err) => settle(() => reject(failure(err))));
984
+ if (!req.destroyed) {
985
+ if (init.body != null) req.write(init.body);
986
+ req.end();
987
+ }
988
+ });
867
989
  }
990
+ /**
991
+ * Normalize a `HeadersInit` into Node's outgoing-header shape. Header names are
992
+ * lowercased (they are case-insensitive) so later framing checks
993
+ * (`content-length`) and the Host strip are reliable regardless of caller
994
+ * casing. A caller-supplied `Host` is dropped: it is derived from the validated
995
+ * URL, and honoring an override would let Host-based routing reach an internal
996
+ * vhost behind a validated public IP, defeating the SSRF pin.
997
+ */
868
998
  function toOutgoingHeaders(headers) {
869
- const out = {};
870
- if (!headers) return out;
871
- const add = (rawKey, value) => {
872
- const key = rawKey.toLowerCase();
873
- if (key === "host") return;
874
- const existing = out[key];
875
- out[key] = existing === void 0 ? value : [].concat(existing, value);
876
- };
877
- if (headers instanceof Headers) {
878
- headers.forEach((value, key) => add(key, value));
879
- } else if (Array.isArray(headers)) {
880
- for (const [key, value] of headers) add(key, value);
881
- } else {
882
- for (const [key, value] of Object.entries(headers)) add(key, value);
883
- }
884
- return out;
999
+ const out = {};
1000
+ if (!headers) return out;
1001
+ const add = (rawKey, value) => {
1002
+ const key = rawKey.toLowerCase();
1003
+ if (key === "host") return;
1004
+ const existing = out[key];
1005
+ out[key] = existing === void 0 ? value : [].concat(existing, value);
1006
+ };
1007
+ if (headers instanceof Headers) headers.forEach((value, key) => add(key, value));
1008
+ else if (Array.isArray(headers)) for (const [key, value] of headers) add(key, value);
1009
+ else for (const [key, value] of Object.entries(headers)) add(key, value);
1010
+ return out;
885
1011
  }
1012
+ /**
1013
+ * Adapt a Node `IncomingMessage` + buffered body into a WHATWG `Response`.
1014
+ * Total by construction: a header the WHATWG layer rejects is skipped and the
1015
+ * status is clamped to a constructible range, so this never throws (a throw
1016
+ * would otherwise escape the `end` handler as an uncaught error).
1017
+ */
886
1018
  function toWhatwgResponse(res, body, stripContentHeaders) {
887
- const headers = new Headers();
888
- for (const [key, value] of Object.entries(res.headers)) {
889
- if (value == null) continue;
890
- const lower = key.toLowerCase();
891
- if (stripContentHeaders && (lower === "content-encoding" || lower === "content-length")) {
892
- continue;
893
- }
894
- const values = Array.isArray(value) ? value : [value];
895
- for (const v of values) {
896
- try {
897
- headers.append(key, v);
898
- } catch {
899
- }
900
- }
901
- }
902
- const raw = res.statusCode ?? 502;
903
- const status = raw >= 200 && raw <= 599 ? raw : 502;
904
- const nullBody = status === 204 || status === 205 || status === 304;
905
- const bytes = new Uint8Array(body.byteLength);
906
- bytes.set(body);
907
- return new Response(nullBody ? null : bytes, {
908
- status,
909
- statusText: res.statusMessage ?? "",
910
- headers
911
- });
1019
+ const headers = new Headers();
1020
+ for (const [key, value] of Object.entries(res.headers)) {
1021
+ if (value == null) continue;
1022
+ const lower = key.toLowerCase();
1023
+ if (stripContentHeaders && (lower === "content-encoding" || lower === "content-length")) continue;
1024
+ const values = Array.isArray(value) ? value : [value];
1025
+ for (const v of values) try {
1026
+ headers.append(key, v);
1027
+ } catch {}
1028
+ }
1029
+ const raw = res.statusCode ?? 502;
1030
+ const status = raw >= 200 && raw <= 599 ? raw : 502;
1031
+ const nullBody = status === 204 || status === 205 || status === 304;
1032
+ const bytes = new Uint8Array(body.byteLength);
1033
+ bytes.set(body);
1034
+ return new Response(nullBody ? null : bytes, {
1035
+ status,
1036
+ statusText: res.statusMessage ?? "",
1037
+ headers
1038
+ });
912
1039
  }
1040
+ /**
1041
+ * Content-decode a response body per its `Content-Encoding`. node:http returns
1042
+ * raw wire bytes (unlike global fetch), so gzip/deflate/br bodies are inflated
1043
+ * here to preserve the previous behavior. `maxOutputLength` bounds the inflated
1044
+ * size, turning a decompression bomb into a `SafeFetchError` rather than an OOM.
1045
+ * Returns the (possibly decoded) buffer, or a `SafeFetchError` on failure.
1046
+ */
913
1047
  function decodeBody(buf, encoding, zlib, cap, url, status) {
914
- if (!encoding) return { body: buf, decoded: false };
915
- if (buf.length === 0) return { body: buf, decoded: true };
916
- const layers = encoding.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
917
- const opts = { maxOutputLength: cap };
918
- let current = buf;
919
- let decoded = false;
920
- try {
921
- for (let i = layers.length - 1; i >= 0; i--) {
922
- const enc = layers[i];
923
- if (enc === "identity") continue;
924
- if (enc === "gzip" || enc === "x-gzip") {
925
- current = zlib.gunzipSync(current, opts);
926
- } else if (enc === "br") {
927
- current = zlib.brotliDecompressSync(current, opts);
928
- } else if (enc === "deflate") {
929
- try {
930
- current = zlib.inflateSync(current, opts);
931
- } catch (inflateErr) {
932
- if (errorCode(inflateErr) === "ERR_BUFFER_TOO_LARGE")
933
- throw inflateErr;
934
- current = zlib.inflateRawSync(current, opts);
935
- }
936
- } else {
937
- if (decoded) {
938
- return new SafeFetchError(
939
- `Cannot fully decode content-encoding "${encoding}"`,
940
- url,
941
- "decode-failed",
942
- status
943
- );
944
- }
945
- return { body: buf, decoded: false };
946
- }
947
- decoded = true;
948
- }
949
- return { body: current, decoded };
950
- } catch (err) {
951
- const tooLarge = errorCode(err) === "ERR_BUFFER_TOO_LARGE";
952
- return new SafeFetchError(
953
- tooLarge ? `Decoded ${encoding} response body exceeded the size cap` : `Failed to decode ${encoding} response body`,
954
- url,
955
- tooLarge ? "response-too-large" : "decode-failed",
956
- status
957
- );
958
- }
1048
+ if (!encoding) return {
1049
+ body: buf,
1050
+ decoded: false
1051
+ };
1052
+ if (buf.length === 0) return {
1053
+ body: buf,
1054
+ decoded: true
1055
+ };
1056
+ const layers = encoding.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean);
1057
+ const opts = { maxOutputLength: cap };
1058
+ let current = buf;
1059
+ let decoded = false;
1060
+ try {
1061
+ for (let i = layers.length - 1; i >= 0; i--) {
1062
+ const enc = layers[i];
1063
+ if (enc === "identity") continue;
1064
+ if (enc === "gzip" || enc === "x-gzip") current = zlib.gunzipSync(current, opts);
1065
+ else if (enc === "br") current = zlib.brotliDecompressSync(current, opts);
1066
+ else if (enc === "deflate") try {
1067
+ current = zlib.inflateSync(current, opts);
1068
+ } catch (inflateErr) {
1069
+ if (errorCode(inflateErr) === "ERR_BUFFER_TOO_LARGE") throw inflateErr;
1070
+ current = zlib.inflateRawSync(current, opts);
1071
+ }
1072
+ else {
1073
+ if (decoded) return new SafeFetchError(`Cannot fully decode content-encoding "${encoding}"`, url, "decode-failed", status);
1074
+ return {
1075
+ body: buf,
1076
+ decoded: false
1077
+ };
1078
+ }
1079
+ decoded = true;
1080
+ }
1081
+ return {
1082
+ body: current,
1083
+ decoded
1084
+ };
1085
+ } catch (err) {
1086
+ const tooLarge = errorCode(err) === "ERR_BUFFER_TOO_LARGE";
1087
+ return new SafeFetchError(tooLarge ? `Decoded ${encoding} response body exceeded the size cap` : `Failed to decode ${encoding} response body`, url, tooLarge ? "response-too-large" : "decode-failed", status);
1088
+ }
959
1089
  }
1090
+ /** The `.code` of a Node system error, if present. */
960
1091
  function errorCode(err) {
961
- if (err != null && typeof err === "object" && "code" in err) {
962
- const code = err.code;
963
- return typeof code === "string" ? code : void 0;
964
- }
965
- return void 0;
1092
+ if (err != null && typeof err === "object" && "code" in err) {
1093
+ const code = err.code;
1094
+ return typeof code === "string" ? code : void 0;
1095
+ }
966
1096
  }
967
1097
  function isIpv4InCidr(intIp, cidr) {
968
- const [addr, prefixRaw] = cidr.split("/");
969
- const intCidr = ipv4ToInt(addr);
970
- if (intCidr === null) return false;
971
- const prefix = Number(prefixRaw);
972
- if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false;
973
- const mask = prefix === 0 ? 0 : -1 >>> 32 - prefix << 32 - prefix;
974
- return (intIp & mask) >>> 0 === (intCidr & mask) >>> 0;
1098
+ const [addr, prefixRaw] = cidr.split("/");
1099
+ const intCidr = ipv4ToInt(addr);
1100
+ if (intCidr === null) return false;
1101
+ const prefix = Number(prefixRaw);
1102
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false;
1103
+ const mask = prefix === 0 ? 0 : -1 >>> 32 - prefix << 32 - prefix;
1104
+ return (intIp & mask) >>> 0 === (intCidr & mask) >>> 0;
975
1105
  }
976
1106
  function isPublicIpv4(addr, allowLoopback) {
977
- const intIp = ipv4ToInt(addr);
978
- if (intIp === null) return false;
979
- if (allowLoopback && addr === "127.0.0.1") return true;
980
- for (const cidr of PRIVATE_IPV4_CIDRS) {
981
- if (isIpv4InCidr(intIp, cidr)) return false;
982
- }
983
- return true;
1107
+ const intIp = ipv4ToInt(addr);
1108
+ if (intIp === null) return false;
1109
+ if (allowLoopback && addr === "127.0.0.1") return true;
1110
+ for (const cidr of PRIVATE_IPV4_CIDRS) if (isIpv4InCidr(intIp, cidr)) return false;
1111
+ return true;
984
1112
  }
1113
+ /**
1114
+ * Extract the embedded IPv4 of an IPv4-mapped IPv6 address, or null. Handles
1115
+ * both the dotted form (`::ffff:1.2.3.4`) and the hex form (`::ffff:7f00:1`)
1116
+ * that `URL.hostname` normalizes the dotted literal to — the latter would
1117
+ * otherwise skip the IPv4 denylist and pin, e.g., loopback as public.
1118
+ */
985
1119
  function mappedIpv4(lower) {
986
- const dotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
987
- if (dotted) return dotted[1];
988
- const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
989
- if (hex) {
990
- const hi = parseInt(hex[1], 16);
991
- const lo = parseInt(hex[2], 16);
992
- return [hi >> 8, hi & 255, lo >> 8, lo & 255].join(".");
993
- }
994
- return null;
1120
+ const dotted = lower.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
1121
+ if (dotted) return dotted[1];
1122
+ const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
1123
+ if (hex) {
1124
+ const hi = parseInt(hex[1], 16);
1125
+ const lo = parseInt(hex[2], 16);
1126
+ return [
1127
+ hi >> 8,
1128
+ hi & 255,
1129
+ lo >> 8,
1130
+ lo & 255
1131
+ ].join(".");
1132
+ }
1133
+ return null;
995
1134
  }
996
1135
  function isPublicIpv6(addr, allowLoopback) {
997
- const lower = addr.toLowerCase();
998
- if (allowLoopback && lower === "::1") return true;
999
- const mapped = mappedIpv4(lower);
1000
- if (mapped) {
1001
- return isPublicIpv4(mapped, allowLoopback);
1002
- }
1003
- if (lower === "::" || lower === "::1") return false;
1004
- const firstHextet = lower.split(":")[0] || "";
1005
- if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd"))
1006
- return false;
1007
- if (firstHextet.startsWith("fe8") || firstHextet.startsWith("fe9"))
1008
- return false;
1009
- if (firstHextet.startsWith("fea") || firstHextet.startsWith("feb"))
1010
- return false;
1011
- if (firstHextet.startsWith("ff")) return false;
1012
- return true;
1136
+ const lower = addr.toLowerCase();
1137
+ if (allowLoopback && lower === "::1") return true;
1138
+ const mapped = mappedIpv4(lower);
1139
+ if (mapped) return isPublicIpv4(mapped, allowLoopback);
1140
+ if (lower === "::" || lower === "::1") return false;
1141
+ const firstHextet = lower.split(":")[0] || "";
1142
+ if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) return false;
1143
+ if (firstHextet.startsWith("fe8") || firstHextet.startsWith("fe9")) return false;
1144
+ if (firstHextet.startsWith("fea") || firstHextet.startsWith("feb")) return false;
1145
+ if (firstHextet.startsWith("ff")) return false;
1146
+ return true;
1013
1147
  }
1148
+ /**
1149
+ * Reading bytes back from a storage service that only exposes them over HTTP.
1150
+ *
1151
+ * The local adapter reads from disk and S3 reads through its own SDK, so
1152
+ * neither needs this. The services that address a file by URL — Vercel Blob,
1153
+ * UploadThing — all end up doing the same three things, and doing them the same
1154
+ * way matters more than it looks: each step below is a place where the obvious
1155
+ * shortcut reports the wrong thing to a caller who may act on it.
1156
+ *
1157
+ * Built on {@link safeFetch} rather than on a bare `fetch`, because "read a
1158
+ * remote object into memory" already has an implementation here that caps the
1159
+ * body, bounds the request and refuses an address that resolves somewhere
1160
+ * private. A second capped fetch beside it would be two answers to one
1161
+ * question, and the two would drift the first time either learned something.
1162
+ * Measured: it costs an adapter package about 80K, against the 1MB the whole
1163
+ * storage barrel costs.
1164
+ *
1165
+ * @module storage/fetch-stored-bytes
1166
+ */
1167
+ /**
1168
+ * Fetch a stored object's bytes from the URL its service issued.
1169
+ *
1170
+ * Separates the two answers a read can have, which is the whole reason this is
1171
+ * a function rather than three lines inlined twice.
1172
+ *
1173
+ * `null` means the object is NOT THERE — an ordinary fact about the store. A
1174
+ * 404 is that answer even when the lookup just succeeded: these stores are
1175
+ * remote and concurrent, so an object can be deleted between resolving its
1176
+ * address and fetching it. Reporting that ordinary race as a server failure
1177
+ * would make a caller handle an error for something its own contract already
1178
+ * has a value for.
1179
+ *
1180
+ * Anything else THROWS. A dropped connection, a 5xx or a refused request says
1181
+ * nothing about whether the object exists, and reporting it as absence invites
1182
+ * a caller to treat a live file as deleted and write a replacement over it. The
1183
+ * two are easy to state apart and easy to lose when the fetch sits inside the
1184
+ * same `try` that catches the lookup.
1185
+ *
1186
+ * The URL is always one the SERVICE issued, never one assembled from a key.
1187
+ * These stores mint addresses carrying suffixes the adapter never chose, so a
1188
+ * derived URL is a guess at a string another system owns.
1189
+ *
1190
+ * @param url - The address the storage service reported for this object
1191
+ * @param context - What is being read, for the error message: usually the key
1192
+ * @param label - The service's name, so a failure says which store answered
1193
+ * @param options - Bounds for the read; see {@link StorageReadOptions}
1194
+ * @param signal - A deadline ALREADY RUNNING, covering an earlier phase too
1195
+ * @returns The object's bytes, or `null` when the store answers 404
1196
+ */
1014
1197
  async function fetchStoredBytes(url, context, label, options, signal) {
1015
- let response;
1016
- try {
1017
- response = await safeFetch(url, {
1018
- ...options?.maxBytes === void 0 ? {} : { maxResponseBytes: options.maxBytes },
1019
- /*
1020
- * A caller's signal REPLACES the deadline rather than joining it.
1021
- *
1022
- * These adapters look the object's address up before fetching it, and
1023
- * that lookup can stall too. Starting a fresh timer here would give the
1024
- * fetch its own full budget on top of however long the lookup took, so a
1025
- * read could outlive the deadline the caller was told applied — by
1026
- * roughly double. One signal begun before the lookup governs both phases.
1027
- */
1028
- ...signal !== void 0 ? { signal } : options?.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
1029
- });
1030
- } catch (error) {
1031
- const verdict = classifyFetchFailure(error);
1032
- if (verdict === "absent") return null;
1033
- if (verdict === "oversized") {
1034
- throw new StorageReadTooLargeError(
1035
- context,
1036
- resolveReadBounds(options).maxBytes
1037
- );
1038
- }
1039
- throw error;
1040
- }
1041
- if (response.status === 404) return null;
1042
- if (!response.ok) {
1043
- throw NextlyError.internal({
1044
- logContext: {
1045
- service: label,
1046
- path: context,
1047
- status: response.status,
1048
- statusText: response.statusText
1049
- }
1050
- });
1051
- }
1052
- return Buffer.from(await response.arrayBuffer());
1198
+ let response;
1199
+ try {
1200
+ response = await safeFetch(url, {
1201
+ ...options?.maxBytes === void 0 ? {} : { maxResponseBytes: options.maxBytes },
1202
+ ...signal !== void 0 ? { signal } : options?.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs }
1203
+ });
1204
+ } catch (error) {
1205
+ const verdict = classifyFetchFailure(error);
1206
+ if (verdict === "absent") return null;
1207
+ if (verdict === "oversized") throw new StorageReadTooLargeError(context, resolveReadBounds(options).maxBytes);
1208
+ throw error;
1209
+ }
1210
+ if (response.status === 404) return null;
1211
+ if (!response.ok) throw NextlyError.internal({ logContext: {
1212
+ service: label,
1213
+ path: context,
1214
+ status: response.status,
1215
+ statusText: response.statusText
1216
+ } });
1217
+ return Buffer.from(await response.arrayBuffer());
1053
1218
  }
1219
+ /**
1220
+ * What a failed fetch actually says about the object.
1221
+ *
1222
+ * Its own function because the three answers are the whole point of this module
1223
+ * and they are easy to collapse into each other — each collapse having been a
1224
+ * real defect here rather than a hypothetical:
1225
+ *
1226
+ * - ABSENT. A 404 is absence whatever size its body was. `safeFetch` caps while
1227
+ * buffering, so a verbose CDN error page raises before a `Response` exists
1228
+ * and the status check further down is never reached; the object is still
1229
+ * gone, and must not become an error because the page explaining it was long.
1230
+ * - OVERSIZED. Only a SUCCESSFUL response that blew the cap. A failed response
1231
+ * can exceed it too, and translating on the reason alone reported a backend
1232
+ * outage as "your file is too big" — a cause the author would act on, wrongly.
1233
+ * - UNKNOWN. Everything else, including a too-large refusal carrying NO status:
1234
+ * that means the failure arrived before any status did, which says nothing
1235
+ * about whether the object was oversized.
1236
+ */
1054
1237
  function classifyFetchFailure(error) {
1055
- if (!(error instanceof SafeFetchError)) return "unknown";
1056
- if (error.status === 404) return "absent";
1057
- const successful = error.status !== void 0 && error.status >= 200 && error.status < 300;
1058
- return error.reason === "response-too-large" && successful ? "oversized" : "unknown";
1238
+ if (!(error instanceof SafeFetchError)) return "unknown";
1239
+ if (error.status === 404) return "absent";
1240
+ const successful = error.status !== void 0 && error.status >= 200 && error.status < 300;
1241
+ return error.reason === "response-too-large" && successful ? "oversized" : "unknown";
1059
1242
  }
1060
- var DEFAULT_READ_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
1061
- var DEFAULT_READ_MAX_BYTES = DEFAULT_MAX_RESPONSE_BYTES;
1243
+ /**
1244
+ * The bounds a read actually runs under, defaults filled in.
1245
+ *
1246
+ * ONE place, because every adapter needs the same answer and the ones that
1247
+ * derived it themselves diverged: the URL-backed pair inherited `safeFetch`'s
1248
+ * cap and deadline for free, while the local and S3 adapters — which never
1249
+ * touch `safeFetch` — applied NO bound at all when the caller named none.
1250
+ * `StorageReadOptions` promises the default either way, and a production caller
1251
+ * reading media without options got it from two of four backends.
1252
+ *
1253
+ * Filling the defaults here rather than per adapter is what makes the promise
1254
+ * true by construction rather than by four implementations agreeing.
1255
+ */
1062
1256
  function resolveReadBounds(options) {
1063
- return {
1064
- maxBytes: options?.maxBytes ?? DEFAULT_READ_MAX_BYTES,
1065
- timeoutMs: options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS
1066
- };
1257
+ return {
1258
+ maxBytes: options?.maxBytes ?? 10485760,
1259
+ timeoutMs: options?.timeoutMs ?? 3e4
1260
+ };
1067
1261
  }
1068
-
1069
1262
  // src/adapter.ts
1263
+ /**
1264
+ * Vercel Blob Storage Adapter
1265
+ *
1266
+ * Stores files on Vercel Blob Storage, a globally distributed object storage
1267
+ * service powered by Cloudflare R2. Optimized for Vercel deployments with
1268
+ * built-in CDN distribution.
1269
+ *
1270
+ * Features:
1271
+ * - Globally distributed CDN-backed storage
1272
+ * - Automatic file management with unique filenames
1273
+ * - Token-based authentication
1274
+ * - Client-side upload support (via handleUpload API)
1275
+ * - Serverless-friendly (no filesystem access required)
1276
+ *
1277
+ * @example Basic usage
1278
+ * ```typescript
1279
+ * const adapter = new VercelBlobStorageAdapter({
1280
+ * token: process.env.BLOB_READ_WRITE_TOKEN,
1281
+ * collections: { media: true }
1282
+ * });
1283
+ *
1284
+ * const result = await adapter.upload(buffer, {
1285
+ * filename: 'photo.jpg',
1286
+ * mimeType: 'image/jpeg'
1287
+ * });
1288
+ * // result.url: "https://abc123.public.blob.vercel-storage.com/photo-xyz.jpg"
1289
+ * ```
1290
+ */
1291
+ /**
1292
+ * Vercel Blob Storage Adapter
1293
+ *
1294
+ * Implements the IStorageAdapter interface for Vercel Blob Storage.
1295
+ * Provides file upload, deletion, existence checks, and metadata retrieval.
1296
+ */
1070
1297
  var VercelBlobStorageAdapter = class {
1071
- /**
1072
- * Create a new Vercel Blob storage adapter.
1073
- *
1074
- * @param config - Vercel Blob storage configuration
1075
- * @throws Error if token is not provided
1076
- */
1077
- constructor(config) {
1078
- this.config = config;
1079
- const token = config.token || process.env.BLOB_READ_WRITE_TOKEN || "";
1080
- if (!token) {
1081
- throw new Error(
1082
- "@nextly/storage-vercel-blob: token is required.\n\nEither set the BLOB_READ_WRITE_TOKEN environment variable or pass token in config.\nGet your token from: Vercel Dashboard > Storage > Blob > Tokens"
1083
- );
1084
- }
1085
- this.resolvedConfig = {
1086
- token,
1087
- addRandomSuffix: config.addRandomSuffix ?? true,
1088
- cacheControlMaxAge: config.cacheControlMaxAge ?? 31536e3,
1089
- access: config.access ?? "public",
1090
- storeId: config.storeId,
1091
- allowOverwrite: config.allowOverwrite ?? false,
1092
- multipartThreshold: config.multipartThreshold ?? 5 * 1024 * 1024
1093
- // 5MB
1094
- };
1095
- }
1096
- resolvedConfig;
1097
- // ============================================================
1098
- // Core IStorageAdapter Methods
1099
- // ============================================================
1100
- /**
1101
- * Upload file to Vercel Blob.
1102
- *
1103
- * Files are stored with globally distributed CDN backing.
1104
- * By default, a random suffix is added to prevent filename collisions.
1105
- *
1106
- * @param buffer - File content as Buffer
1107
- * @param options - Upload options (filename, mimeType, folder, collection)
1108
- * @returns Upload result with URL and storage path
1109
- */
1110
- async upload(buffer, options) {
1111
- const mimeType = (options.contentType || options.mimeType || "").toLowerCase().trim();
1112
- const filename = (options.filename || "").toLowerCase();
1113
- const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1) : "";
1114
- const isHtml = mimeType === "text/html" || mimeType === "application/xhtml+xml" || ext === "html" || ext === "htm" || ext === "xhtml";
1115
- if (isHtml) {
1116
- throw NextlyError.validation({
1117
- errors: [
1118
- {
1119
- path: "file",
1120
- code: "UNSUPPORTED_FOR_BACKEND",
1121
- message: "HTML files cannot be hosted on Vercel Blob. Use the S3 or R2 adapter, or upload as another format."
1122
- }
1123
- ],
1124
- logContext: {
1125
- adapter: "vercel-blob",
1126
- claimedMimeType: options.mimeType,
1127
- reason: "vercel-blob-rejects-html",
1128
- filename: options.filename
1129
- }
1130
- });
1131
- }
1132
- const isSvg = mimeType === "image/svg+xml" || ext === "svg" || ext === "svgz";
1133
- const pathname = this.buildPathname(options.filename, options.folder);
1134
- const result = await blob.put(pathname, buffer, {
1135
- access: this.resolvedConfig.access,
1136
- token: this.resolvedConfig.token,
1137
- contentType: options.contentType || options.mimeType,
1138
- addRandomSuffix: this.resolvedConfig.addRandomSuffix,
1139
- cacheControlMaxAge: this.resolvedConfig.cacheControlMaxAge
1140
- });
1141
- const url = isSvg && options.contentDisposition === "attachment" ? result.downloadUrl : result.url;
1142
- return {
1143
- url,
1144
- path: result.url
1145
- };
1146
- }
1147
- /**
1148
- * Delete file from Vercel Blob.
1149
- *
1150
- * @param filePath - Full blob URL to delete
1151
- */
1152
- async delete(filePath) {
1153
- await blob.del(filePath, {
1154
- token: this.resolvedConfig.token
1155
- });
1156
- }
1157
- /**
1158
- * Delete multiple files from Vercel Blob in chunked batches.
1159
- *
1160
- * Processes deletions in chunks of 10 concurrent calls using Promise.allSettled,
1161
- * collecting successes and failures without short-circuiting.
1162
- *
1163
- * @param filePaths - Array of full blob URLs to delete
1164
- * @returns BulkDeleteResult with successful and failed arrays
1165
- */
1166
- async bulkDelete(filePaths) {
1167
- const successful = [];
1168
- const failed = [];
1169
- const chunkSize = 10;
1170
- for (let i = 0; i < filePaths.length; i += chunkSize) {
1171
- const chunk = filePaths.slice(i, i + chunkSize);
1172
- const results = await Promise.allSettled(
1173
- chunk.map((fp) => blob.del(fp, { token: this.resolvedConfig.token }))
1174
- );
1175
- results.forEach((result, idx) => {
1176
- const fp = chunk[idx];
1177
- if (result.status === "fulfilled") {
1178
- successful.push(fp);
1179
- } else {
1180
- failed.push({
1181
- filePath: fp,
1182
- error: result.reason instanceof Error ? result.reason.message : String(result.reason)
1183
- });
1184
- }
1185
- });
1186
- }
1187
- return { successful, failed };
1188
- }
1189
- /**
1190
- * Check if file exists in Vercel Blob.
1191
- *
1192
- * Uses the head() function which is efficient for existence checks.
1193
- *
1194
- * @param filePath - Full blob URL to check
1195
- * @returns true if file exists, false otherwise
1196
- */
1197
- async exists(filePath) {
1198
- try {
1199
- await blob.head(filePath, {
1200
- token: this.resolvedConfig.token
1201
- });
1202
- return true;
1203
- } catch {
1204
- return false;
1205
- }
1206
- }
1207
- /**
1208
- * Read a stored blob back as bytes, or `null` when it is not there.
1209
- *
1210
- * A NETWORK round trip, unlike the local adapter's disk read, because the
1211
- * bytes live on Vercel's CDN and there is no other way to reach them. That
1212
- * cost is the reason a caller serving these from its own origin has to cache:
1213
- * the privacy rule that forces same-origin serving does not also make the
1214
- * fetch free, and an uncached proxy pays it on every request.
1215
- *
1216
- * The URL is taken from `head` rather than assembled from `filePath`,
1217
- * because a blob's address is issued by the service — it carries a random
1218
- * suffix this adapter never chose — so deriving one here would be guessing at
1219
- * a string another system owns.
1220
- *
1221
- * `null` for a missing blob rather than a throw, matching {@link exists}.
1222
- *
1223
- * @param filePath - Blob path/key
1224
- * @returns The blob's bytes, or `null` when it does not exist
1225
- */
1226
- async read(filePath, options) {
1227
- const deadline = AbortSignal.timeout(
1228
- options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS
1229
- );
1230
- let target;
1231
- try {
1232
- const meta = await blob.head(filePath, {
1233
- token: this.resolvedConfig.token,
1234
- abortSignal: deadline
1235
- });
1236
- target = meta.downloadUrl ?? meta.url;
1237
- } catch (error) {
1238
- if (error instanceof blob.BlobNotFoundError) return null;
1239
- throw error;
1240
- }
1241
- return await fetchStoredBytes(
1242
- target,
1243
- filePath,
1244
- "Vercel Blob",
1245
- options,
1246
- deadline
1247
- );
1248
- }
1249
- /**
1250
- * Get public URL for Vercel Blob file.
1251
- *
1252
- * Vercel Blob paths ARE the public URLs - they're already full HTTPS URLs
1253
- * with CDN distribution.
1254
- *
1255
- * @param filePath - Full blob URL
1256
- * @returns The same URL (Vercel Blob paths are already public URLs)
1257
- */
1258
- getPublicUrl(filePath) {
1259
- return filePath;
1260
- }
1261
- /**
1262
- * Get storage type identifier.
1263
- */
1264
- getType() {
1265
- return "vercel-blob";
1266
- }
1267
- // ============================================================
1268
- // Optional IStorageAdapter Methods
1269
- // ============================================================
1270
- /**
1271
- * Get adapter info including capabilities.
1272
- *
1273
- * @returns Adapter info with type, name, and capability flags
1274
- */
1275
- getInfo() {
1276
- return {
1277
- type: "vercel-blob",
1278
- name: "VercelBlobStorageAdapter",
1279
- supportsSignedUrls: false,
1280
- // Vercel Blob URLs are public by default
1281
- supportsClientUploads: true
1282
- // Via handleUpload API
1283
- };
1284
- }
1285
- /**
1286
- * Get file metadata from Vercel Blob.
1287
- *
1288
- * Retrieves file information including size, content type, and upload date.
1289
- *
1290
- * @param filePath - Full blob URL
1291
- * @returns File metadata or null if file not found
1292
- */
1293
- async getMetadata(filePath) {
1294
- try {
1295
- const result = await blob.head(filePath, {
1296
- token: this.resolvedConfig.token
1297
- });
1298
- const filename = result.pathname.split("/").pop() || result.pathname;
1299
- return {
1300
- id: filePath,
1301
- filename,
1302
- originalFilename: filename,
1303
- mimeType: result.contentType,
1304
- size: result.size,
1305
- url: result.url,
1306
- createdAt: result.uploadedAt.toISOString()
1307
- };
1308
- } catch {
1309
- return null;
1310
- }
1311
- }
1312
- /**
1313
- * Generate pre-signed URL for client-side uploads.
1314
- *
1315
- * Vercel Blob uses a different pattern for client uploads - they require
1316
- * the handleUpload API which handles token generation server-side.
1317
- *
1318
- * This method is not implemented because Vercel Blob's client upload flow
1319
- * requires server-side route handlers with handleUpload().
1320
- *
1321
- * @see https://vercel.com/docs/storage/vercel-blob/client-upload
1322
- * @throws Error always - client uploads require handleUpload API
1323
- */
1324
- getPresignedUploadUrl(_key, _mimeType, _expiresIn) {
1325
- return Promise.reject(
1326
- new Error(
1327
- "@nextly/storage-vercel-blob: Client uploads require the handleUpload API.\n\nVercel Blob uses a different pattern for client-side uploads that involves:\n1. A server-side route handler with handleUpload()\n2. The @vercel/blob/client upload() function\n\nSee: https://vercel.com/docs/storage/vercel-blob/client-upload\n\nFor server-side uploads, use the standard upload() method instead."
1328
- )
1329
- );
1330
- }
1331
- // ============================================================
1332
- // Additional Methods
1333
- // ============================================================
1334
- /**
1335
- * List blobs with optional prefix filter.
1336
- *
1337
- * Useful for browsing or batch operations on stored files.
1338
- *
1339
- * @param prefix - Optional prefix to filter results
1340
- * @param options - List options (limit, cursor)
1341
- * @returns List of blob metadata
1342
- */
1343
- async listBlobs(prefix, options) {
1344
- const result = await blob.list({
1345
- token: this.resolvedConfig.token,
1346
- prefix,
1347
- limit: options?.limit,
1348
- cursor: options?.cursor
1349
- });
1350
- return {
1351
- blobs: result.blobs.map((blob) => ({
1352
- url: blob.url,
1353
- pathname: blob.pathname,
1354
- size: blob.size,
1355
- uploadedAt: blob.uploadedAt
1356
- })),
1357
- hasMore: result.hasMore,
1358
- cursor: result.cursor
1359
- };
1360
- }
1361
- // ============================================================
1362
- // Helper Methods
1363
- // ============================================================
1364
- /**
1365
- * Build pathname for Vercel Blob upload.
1366
- *
1367
- * Creates pathname in format: {folder}/{filename}
1368
- * If no folder is provided, uses the filename directly.
1369
- *
1370
- * @param filename - Original filename (will be sanitized)
1371
- * @param folder - Optional folder/prefix for organizing uploads
1372
- * @returns Generated pathname
1373
- * @throws Error if folder fails sanitization
1374
- */
1375
- buildPathname(filename, folder) {
1376
- const sanitized = this.sanitizeFilename(filename);
1377
- if (!folder) return sanitized;
1378
- return `${this.sanitizeFolder(folder)}/${sanitized}`;
1379
- }
1380
- /**
1381
- * Sanitize filename to prevent path traversal issues.
1382
- *
1383
- * @param filename - Original filename
1384
- * @returns Sanitized filename
1385
- */
1386
- sanitizeFilename(filename) {
1387
- const basename = filename.split(/[/\\]/).pop() || filename;
1388
- return basename.replace(/[^a-zA-Z0-9._-]/g, "-");
1389
- }
1390
- /**
1391
- * Reject folder values that would let a caller
1392
- * escape the configured prefix via path traversal or weird
1393
- * separators. Vercel Blob is flat-keyed so `/` is just part of the
1394
- * pathname, but an attacker who controls `folder` could still:
1395
- *
1396
- * - Use `..` to chain a relative-traversal-style key.
1397
- * - Use `\` (Windows separator) to confuse downstream tooling.
1398
- * - Use control chars / null bytes to truncate the key.
1399
- * - Use a leading `/` to look like an absolute path.
1400
- *
1401
- * Internal `/` segments stay allowed because callers legitimately
1402
- * use nested folders (`docs/2026/...`); a leading `/`, empty
1403
- * segments (`docs//x`), or `..` segments are rejected.
1404
- *
1405
- * @throws Error with a stable message so the storage facade can
1406
- * surface a 400 instead of a 500.
1407
- */
1408
- sanitizeFolder(folder) {
1409
- const trimmed = folder.replace(/\/+$/, "");
1410
- if (!trimmed) {
1411
- throw new Error("Folder must be a non-empty string.");
1412
- }
1413
- if (/[\\\0\x01-\x1f\x7f]/.test(trimmed)) {
1414
- throw new Error(
1415
- "Folder contains forbidden characters (backslash, control chars, or null bytes)."
1416
- );
1417
- }
1418
- if (trimmed.startsWith("/")) {
1419
- throw new Error("Folder must not start with `/`.");
1420
- }
1421
- const segments = trimmed.split("/");
1422
- for (const segment of segments) {
1423
- if (segment === "") {
1424
- throw new Error("Folder must not contain empty segments (`//`).");
1425
- }
1426
- if (segment === "..") {
1427
- throw new Error("Folder must not contain `..` segments.");
1428
- }
1429
- }
1430
- return trimmed;
1431
- }
1432
- // ============================================================
1433
- // Public Accessors
1434
- // ============================================================
1435
- /**
1436
- * Get the configured token (masked for logging).
1437
- */
1438
- getTokenMasked() {
1439
- const token = this.resolvedConfig.token;
1440
- if (token.length <= 8) return "****";
1441
- return `${token.slice(0, 4)}...${token.slice(-4)}`;
1442
- }
1443
- /**
1444
- * Check if random suffix is enabled.
1445
- */
1446
- hasRandomSuffix() {
1447
- return this.resolvedConfig.addRandomSuffix;
1448
- }
1449
- /**
1450
- * Get configured cache control max age.
1451
- */
1452
- getCacheControlMaxAge() {
1453
- return this.resolvedConfig.cacheControlMaxAge;
1454
- }
1298
+ config;
1299
+ resolvedConfig;
1300
+ /**
1301
+ * Create a new Vercel Blob storage adapter.
1302
+ *
1303
+ * @param config - Vercel Blob storage configuration
1304
+ * @throws Error if token is not provided
1305
+ */
1306
+ constructor(config) {
1307
+ this.config = config;
1308
+ const token = config.token || process.env.BLOB_READ_WRITE_TOKEN || "";
1309
+ if (!token) throw new Error("@nextly/storage-vercel-blob: token is required.\n\nEither set the BLOB_READ_WRITE_TOKEN environment variable or pass token in config.\nGet your token from: Vercel Dashboard > Storage > Blob > Tokens");
1310
+ this.resolvedConfig = {
1311
+ token,
1312
+ addRandomSuffix: config.addRandomSuffix ?? true,
1313
+ cacheControlMaxAge: config.cacheControlMaxAge ?? 31536e3,
1314
+ access: config.access ?? "public",
1315
+ storeId: config.storeId,
1316
+ allowOverwrite: config.allowOverwrite ?? false,
1317
+ multipartThreshold: config.multipartThreshold ?? 5242880
1318
+ };
1319
+ }
1320
+ /**
1321
+ * Upload file to Vercel Blob.
1322
+ *
1323
+ * Files are stored with globally distributed CDN backing.
1324
+ * By default, a random suffix is added to prevent filename collisions.
1325
+ *
1326
+ * @param buffer - File content as Buffer
1327
+ * @param options - Upload options (filename, mimeType, folder, collection)
1328
+ * @returns Upload result with URL and storage path
1329
+ */
1330
+ async upload(buffer, options) {
1331
+ const mimeType = (options.contentType || options.mimeType || "").toLowerCase().trim();
1332
+ const filename = (options.filename || "").toLowerCase();
1333
+ const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1) : "";
1334
+ if (mimeType === "text/html" || mimeType === "application/xhtml+xml" || ext === "html" || ext === "htm" || ext === "xhtml") throw NextlyError.validation({
1335
+ errors: [{
1336
+ path: "file",
1337
+ code: "UNSUPPORTED_FOR_BACKEND",
1338
+ message: "HTML files cannot be hosted on Vercel Blob. Use the S3 or R2 adapter, or upload as another format."
1339
+ }],
1340
+ logContext: {
1341
+ adapter: "vercel-blob",
1342
+ claimedMimeType: options.mimeType,
1343
+ reason: "vercel-blob-rejects-html",
1344
+ filename: options.filename
1345
+ }
1346
+ });
1347
+ const isSvg = mimeType === "image/svg+xml" || ext === "svg" || ext === "svgz";
1348
+ const pathname = this.buildPathname(options.filename, options.folder);
1349
+ const result = await (0, _vercel_blob.put)(pathname, buffer, {
1350
+ access: this.resolvedConfig.access,
1351
+ token: this.resolvedConfig.token,
1352
+ contentType: options.contentType || options.mimeType,
1353
+ addRandomSuffix: this.resolvedConfig.addRandomSuffix,
1354
+ cacheControlMaxAge: this.resolvedConfig.cacheControlMaxAge
1355
+ });
1356
+ return {
1357
+ url: isSvg && options.contentDisposition === "attachment" ? result.downloadUrl : result.url,
1358
+ path: result.url
1359
+ };
1360
+ }
1361
+ /**
1362
+ * Delete file from Vercel Blob.
1363
+ *
1364
+ * @param filePath - Full blob URL to delete
1365
+ */
1366
+ async delete(filePath) {
1367
+ await (0, _vercel_blob.del)(filePath, { token: this.resolvedConfig.token });
1368
+ }
1369
+ /**
1370
+ * Delete multiple files from Vercel Blob in chunked batches.
1371
+ *
1372
+ * Processes deletions in chunks of 10 concurrent calls using Promise.allSettled,
1373
+ * collecting successes and failures without short-circuiting.
1374
+ *
1375
+ * @param filePaths - Array of full blob URLs to delete
1376
+ * @returns BulkDeleteResult with successful and failed arrays
1377
+ */
1378
+ async bulkDelete(filePaths) {
1379
+ const successful = [];
1380
+ const failed = [];
1381
+ const chunkSize = 10;
1382
+ for (let i = 0; i < filePaths.length; i += chunkSize) {
1383
+ const chunk = filePaths.slice(i, i + chunkSize);
1384
+ (await Promise.allSettled(chunk.map((fp) => (0, _vercel_blob.del)(fp, { token: this.resolvedConfig.token })))).forEach((result, idx) => {
1385
+ const fp = chunk[idx];
1386
+ if (result.status === "fulfilled") successful.push(fp);
1387
+ else failed.push({
1388
+ filePath: fp,
1389
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason)
1390
+ });
1391
+ });
1392
+ }
1393
+ return {
1394
+ successful,
1395
+ failed
1396
+ };
1397
+ }
1398
+ /**
1399
+ * Check if file exists in Vercel Blob.
1400
+ *
1401
+ * Uses the head() function which is efficient for existence checks.
1402
+ *
1403
+ * @param filePath - Full blob URL to check
1404
+ * @returns true if file exists, false otherwise
1405
+ */
1406
+ async exists(filePath) {
1407
+ try {
1408
+ await (0, _vercel_blob.head)(filePath, { token: this.resolvedConfig.token });
1409
+ return true;
1410
+ } catch {
1411
+ return false;
1412
+ }
1413
+ }
1414
+ /**
1415
+ * Read a stored blob back as bytes, or `null` when it is not there.
1416
+ *
1417
+ * A NETWORK round trip, unlike the local adapter's disk read, because the
1418
+ * bytes live on Vercel's CDN and there is no other way to reach them. That
1419
+ * cost is the reason a caller serving these from its own origin has to cache:
1420
+ * the privacy rule that forces same-origin serving does not also make the
1421
+ * fetch free, and an uncached proxy pays it on every request.
1422
+ *
1423
+ * The URL is taken from `head` rather than assembled from `filePath`,
1424
+ * because a blob's address is issued by the service — it carries a random
1425
+ * suffix this adapter never chose — so deriving one here would be guessing at
1426
+ * a string another system owns.
1427
+ *
1428
+ * `null` for a missing blob rather than a throw, matching {@link exists}.
1429
+ *
1430
+ * @param filePath - Blob path/key
1431
+ * @returns The blob's bytes, or `null` when it does not exist
1432
+ */
1433
+ async read(filePath, options) {
1434
+ const deadline = AbortSignal.timeout(options?.timeoutMs ?? 3e4);
1435
+ let target;
1436
+ try {
1437
+ const meta = await (0, _vercel_blob.head)(filePath, {
1438
+ token: this.resolvedConfig.token,
1439
+ abortSignal: deadline
1440
+ });
1441
+ target = meta.downloadUrl ?? meta.url;
1442
+ } catch (error) {
1443
+ if (error instanceof _vercel_blob.BlobNotFoundError) return null;
1444
+ throw error;
1445
+ }
1446
+ return await fetchStoredBytes(target, filePath, "Vercel Blob", options, deadline);
1447
+ }
1448
+ /**
1449
+ * Get public URL for Vercel Blob file.
1450
+ *
1451
+ * Vercel Blob paths ARE the public URLs - they're already full HTTPS URLs
1452
+ * with CDN distribution.
1453
+ *
1454
+ * @param filePath - Full blob URL
1455
+ * @returns The same URL (Vercel Blob paths are already public URLs)
1456
+ */
1457
+ /**
1458
+ * Every blob in the store, by its URL (which is its stored path here), with
1459
+ * when it was uploaded, a page at a time.
1460
+ */
1461
+ async *list() {
1462
+ let cursor;
1463
+ do {
1464
+ const page = await this.listBlobs(void 0, { cursor });
1465
+ for (const blob of page.blobs) yield {
1466
+ path: blob.url,
1467
+ lastModified: blob.uploadedAt
1468
+ };
1469
+ cursor = page.hasMore ? page.cursor : void 0;
1470
+ } while (cursor);
1471
+ }
1472
+ getPublicUrl(filePath) {
1473
+ return filePath;
1474
+ }
1475
+ /**
1476
+ * Get storage type identifier.
1477
+ */
1478
+ getType() {
1479
+ return "vercel-blob";
1480
+ }
1481
+ /**
1482
+ * Get adapter info including capabilities.
1483
+ *
1484
+ * @returns Adapter info with type, name, and capability flags
1485
+ */
1486
+ getInfo() {
1487
+ return {
1488
+ type: "vercel-blob",
1489
+ name: "VercelBlobStorageAdapter",
1490
+ supportsSignedUrls: false,
1491
+ supportsClientUploads: true
1492
+ };
1493
+ }
1494
+ /**
1495
+ * Get file metadata from Vercel Blob.
1496
+ *
1497
+ * Retrieves file information including size, content type, and upload date.
1498
+ *
1499
+ * @param filePath - Full blob URL
1500
+ * @returns File metadata or null if file not found
1501
+ */
1502
+ async getMetadata(filePath) {
1503
+ try {
1504
+ const result = await (0, _vercel_blob.head)(filePath, { token: this.resolvedConfig.token });
1505
+ const filename = result.pathname.split("/").pop() || result.pathname;
1506
+ return {
1507
+ id: filePath,
1508
+ filename,
1509
+ originalFilename: filename,
1510
+ mimeType: result.contentType,
1511
+ size: result.size,
1512
+ url: result.url,
1513
+ createdAt: result.uploadedAt.toISOString()
1514
+ };
1515
+ } catch {
1516
+ return null;
1517
+ }
1518
+ }
1519
+ /**
1520
+ * Generate pre-signed URL for client-side uploads.
1521
+ *
1522
+ * Vercel Blob uses a different pattern for client uploads - they require
1523
+ * the handleUpload API which handles token generation server-side.
1524
+ *
1525
+ * This method is not implemented because Vercel Blob's client upload flow
1526
+ * requires server-side route handlers with handleUpload().
1527
+ *
1528
+ * @see https://vercel.com/docs/storage/vercel-blob/client-upload
1529
+ * @throws Error always - client uploads require handleUpload API
1530
+ */
1531
+ getPresignedUploadUrl(_key, _mimeType, _expiresIn) {
1532
+ return Promise.reject(/* @__PURE__ */ new Error("@nextly/storage-vercel-blob: Client uploads require the handleUpload API.\n\nVercel Blob uses a different pattern for client-side uploads that involves:\n1. A server-side route handler with handleUpload()\n2. The @vercel/blob/client upload() function\n\nSee: https://vercel.com/docs/storage/vercel-blob/client-upload\n\nFor server-side uploads, use the standard upload() method instead."));
1533
+ }
1534
+ /**
1535
+ * List blobs with optional prefix filter.
1536
+ *
1537
+ * Useful for browsing or batch operations on stored files.
1538
+ *
1539
+ * @param prefix - Optional prefix to filter results
1540
+ * @param options - List options (limit, cursor)
1541
+ * @returns List of blob metadata
1542
+ */
1543
+ async listBlobs(prefix, options) {
1544
+ const result = await (0, _vercel_blob.list)({
1545
+ token: this.resolvedConfig.token,
1546
+ prefix,
1547
+ limit: options?.limit,
1548
+ cursor: options?.cursor
1549
+ });
1550
+ return {
1551
+ blobs: result.blobs.map((blob) => ({
1552
+ url: blob.url,
1553
+ pathname: blob.pathname,
1554
+ size: blob.size,
1555
+ uploadedAt: blob.uploadedAt
1556
+ })),
1557
+ hasMore: result.hasMore,
1558
+ cursor: result.cursor
1559
+ };
1560
+ }
1561
+ /**
1562
+ * Build pathname for Vercel Blob upload.
1563
+ *
1564
+ * Creates pathname in format: {folder}/{filename}
1565
+ * If no folder is provided, uses the filename directly.
1566
+ *
1567
+ * @param filename - Original filename (will be sanitized)
1568
+ * @param folder - Optional folder/prefix for organizing uploads
1569
+ * @returns Generated pathname
1570
+ * @throws Error if folder fails sanitization
1571
+ */
1572
+ buildPathname(filename, folder) {
1573
+ const sanitized = this.sanitizeFilename(filename);
1574
+ if (!folder) return sanitized;
1575
+ return `${this.sanitizeFolder(folder)}/${sanitized}`;
1576
+ }
1577
+ /**
1578
+ * Sanitize filename to prevent path traversal issues.
1579
+ *
1580
+ * @param filename - Original filename
1581
+ * @returns Sanitized filename
1582
+ */
1583
+ sanitizeFilename(filename) {
1584
+ return (filename.split(/[/\\]/).pop() || filename).replace(/[^a-zA-Z0-9._-]/g, "-");
1585
+ }
1586
+ /**
1587
+ * Reject folder values that would let a caller
1588
+ * escape the configured prefix via path traversal or weird
1589
+ * separators. Vercel Blob is flat-keyed so `/` is just part of the
1590
+ * pathname, but an attacker who controls `folder` could still:
1591
+ *
1592
+ * - Use `..` to chain a relative-traversal-style key.
1593
+ * - Use `\` (Windows separator) to confuse downstream tooling.
1594
+ * - Use control chars / null bytes to truncate the key.
1595
+ * - Use a leading `/` to look like an absolute path.
1596
+ *
1597
+ * Internal `/` segments stay allowed because callers legitimately
1598
+ * use nested folders (`docs/2026/...`); a leading `/`, empty
1599
+ * segments (`docs//x`), or `..` segments are rejected.
1600
+ *
1601
+ * @throws Error with a stable message so the storage facade can
1602
+ * surface a 400 instead of a 500.
1603
+ */
1604
+ sanitizeFolder(folder) {
1605
+ const trimmed = folder.replace(/\/+$/, "");
1606
+ if (!trimmed) throw new Error("Folder must be a non-empty string.");
1607
+ if (/[\\\0\x01-\x1f\x7f]/.test(trimmed)) throw new Error("Folder contains forbidden characters (backslash, control chars, or null bytes).");
1608
+ if (trimmed.startsWith("/")) throw new Error("Folder must not start with `/`.");
1609
+ const segments = trimmed.split("/");
1610
+ for (const segment of segments) {
1611
+ if (segment === "") throw new Error("Folder must not contain empty segments (`//`).");
1612
+ if (segment === "..") throw new Error("Folder must not contain `..` segments.");
1613
+ }
1614
+ return trimmed;
1615
+ }
1616
+ /**
1617
+ * Get the configured token (masked for logging).
1618
+ */
1619
+ getTokenMasked() {
1620
+ const token = this.resolvedConfig.token;
1621
+ if (token.length <= 8) return "****";
1622
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
1623
+ }
1624
+ /**
1625
+ * Check if random suffix is enabled.
1626
+ */
1627
+ hasRandomSuffix() {
1628
+ return this.resolvedConfig.addRandomSuffix;
1629
+ }
1630
+ /**
1631
+ * Get configured cache control max age.
1632
+ */
1633
+ getCacheControlMaxAge() {
1634
+ return this.resolvedConfig.cacheControlMaxAge;
1635
+ }
1455
1636
  };
1456
-
1457
1637
  // src/plugin.ts
1638
+ /**
1639
+ * Create a Vercel Blob storage plugin for Nextly.
1640
+ *
1641
+ * This factory function creates a StoragePlugin that can be added to
1642
+ * the `storage` array in `nextly.config.ts`. Vercel Blob is optimized
1643
+ * for Vercel deployments with:
1644
+ *
1645
+ * - Global CDN distribution
1646
+ * - Simple token-based authentication
1647
+ * - Client-side upload support (via handleUpload API)
1648
+ * - Automatic file management
1649
+ *
1650
+ * @param config - Vercel Blob storage configuration
1651
+ * @returns A StoragePlugin that MediaStorage can register
1652
+ *
1653
+ * @throws Error if token is not provided (via adapter)
1654
+ */
1458
1655
  function vercelBlobStorage(config) {
1459
- if (config.enabled === false) {
1460
- return {
1461
- name: "vercel-blob-storage",
1462
- type: "vercel-blob",
1463
- collections: {},
1464
- adapter: null
1465
- };
1466
- }
1467
- const adapter = new VercelBlobStorageAdapter(config);
1468
- const plugin = {
1469
- name: "vercel-blob-storage",
1470
- type: "vercel-blob",
1471
- collections: config.collections,
1472
- adapter,
1473
- /**
1474
- * Generate upload URL for client-side uploads.
1475
- *
1476
- * Note: Vercel Blob uses a different pattern for client uploads.
1477
- * Instead of pre-signed URLs, it uses the handleUpload() API which
1478
- * requires server-side route handlers.
1479
- *
1480
- * This method throws an error explaining the correct approach.
1481
- *
1482
- * @see https://vercel.com/docs/storage/vercel-blob/client-upload
1483
- */
1484
- getClientUploadUrl(_filename, _mimeType, _collection) {
1485
- return Promise.reject(
1486
- new Error(
1487
- "@nextly/storage-vercel-blob: Client uploads require the handleUpload API.\n\nVercel Blob uses a different pattern than S3 for client-side uploads:\n\n1. Create a server-side route handler with handleUpload()\n2. Use upload() from @vercel/blob/client on the frontend\n\nSee: https://vercel.com/docs/storage/vercel-blob/client-upload"
1488
- )
1489
- );
1490
- },
1491
- /**
1492
- * Get signed download URL for file access.
1493
- *
1494
- * Note: Vercel Blob URLs are public by default and do not support
1495
- * signed/temporary URLs. All uploaded blobs are accessible via their
1496
- * public URL.
1497
- *
1498
- * @param path - Storage path (full blob URL)
1499
- * @returns The same URL (Vercel Blob URLs are public)
1500
- */
1501
- getSignedDownloadUrl(path) {
1502
- return Promise.resolve(path);
1503
- }
1504
- };
1505
- return plugin;
1656
+ if (config.enabled === false) return {
1657
+ name: "vercel-blob-storage",
1658
+ type: "vercel-blob",
1659
+ collections: {},
1660
+ adapter: null
1661
+ };
1662
+ const adapter = new VercelBlobStorageAdapter(config);
1663
+ return {
1664
+ name: "vercel-blob-storage",
1665
+ type: "vercel-blob",
1666
+ collections: config.collections,
1667
+ adapter,
1668
+ /**
1669
+ * Generate upload URL for client-side uploads.
1670
+ *
1671
+ * Note: Vercel Blob uses a different pattern for client uploads.
1672
+ * Instead of pre-signed URLs, it uses the handleUpload() API which
1673
+ * requires server-side route handlers.
1674
+ *
1675
+ * This method throws an error explaining the correct approach.
1676
+ *
1677
+ * @see https://vercel.com/docs/storage/vercel-blob/client-upload
1678
+ */
1679
+ getClientUploadUrl(_filename, _mimeType, _collection) {
1680
+ return Promise.reject(/* @__PURE__ */ new Error("@nextly/storage-vercel-blob: Client uploads require the handleUpload API.\n\nVercel Blob uses a different pattern than S3 for client-side uploads:\n\n1. Create a server-side route handler with handleUpload()\n2. Use upload() from @vercel/blob/client on the frontend\n\nSee: https://vercel.com/docs/storage/vercel-blob/client-upload"));
1681
+ },
1682
+ /**
1683
+ * Get signed download URL for file access.
1684
+ *
1685
+ * Note: Vercel Blob URLs are public by default and do not support
1686
+ * signed/temporary URLs. All uploaded blobs are accessible via their
1687
+ * public URL.
1688
+ *
1689
+ * @param path - Storage path (full blob URL)
1690
+ * @returns The same URL (Vercel Blob URLs are public)
1691
+ */
1692
+ getSignedDownloadUrl(path) {
1693
+ return Promise.resolve(path);
1694
+ }
1695
+ };
1506
1696
  }
1507
-
1508
1697
  // src/index.ts
1509
- var PACKAGE_NAME = "@nextly/storage-vercel-blob";
1510
- var PACKAGE_VERSION = "0.1.0";
1511
-
1698
+ const PACKAGE_NAME = "@nextly/storage-vercel-blob";
1699
+ const PACKAGE_VERSION = "0.1.0";
1512
1700
  exports.PACKAGE_NAME = PACKAGE_NAME;
1513
1701
  exports.PACKAGE_VERSION = PACKAGE_VERSION;
1514
1702
  exports.VercelBlobStorageAdapter = VercelBlobStorageAdapter;
1515
1703
  exports.vercelBlobStorage = vercelBlobStorage;
1516
- //# sourceMappingURL=index.cjs.map
1704
+
1517
1705
  //# sourceMappingURL=index.cjs.map