@panorama-industries/effect-vercel-blob 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -1
  3. package/dist/client.d.ts +8 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +8 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/contracts/blob-client.d.ts +67 -0
  8. package/dist/contracts/blob-client.d.ts.map +1 -0
  9. package/dist/contracts/blob-client.js +11 -0
  10. package/dist/contracts/blob-client.js.map +1 -0
  11. package/dist/contracts/blob-config.d.ts +114 -0
  12. package/dist/contracts/blob-config.d.ts.map +1 -0
  13. package/dist/contracts/blob-config.js +7 -0
  14. package/dist/contracts/blob-config.js.map +1 -0
  15. package/dist/contracts/blob-domain.d.ts +336 -0
  16. package/dist/contracts/blob-domain.d.ts.map +1 -0
  17. package/dist/contracts/blob-domain.js +152 -0
  18. package/dist/contracts/blob-domain.js.map +1 -0
  19. package/dist/contracts/blob-errors.d.ts +125 -0
  20. package/dist/contracts/blob-errors.d.ts.map +1 -0
  21. package/dist/contracts/blob-errors.js +114 -0
  22. package/dist/contracts/blob-errors.js.map +1 -0
  23. package/dist/contracts/blob-signed-urls.d.ts +83 -0
  24. package/dist/contracts/blob-signed-urls.d.ts.map +1 -0
  25. package/dist/contracts/blob-signed-urls.js +10 -0
  26. package/dist/contracts/blob-signed-urls.js.map +1 -0
  27. package/dist/contracts/blob-store.d.ts +153 -0
  28. package/dist/contracts/blob-store.d.ts.map +1 -0
  29. package/dist/contracts/blob-store.js +15 -0
  30. package/dist/contracts/blob-store.js.map +1 -0
  31. package/dist/contracts/blob-uploads.d.ts +101 -0
  32. package/dist/contracts/blob-uploads.d.ts.map +1 -0
  33. package/dist/contracts/blob-uploads.js +17 -0
  34. package/dist/contracts/blob-uploads.js.map +1 -0
  35. package/dist/implementation/blob-client-live.d.ts +37 -0
  36. package/dist/implementation/blob-client-live.d.ts.map +1 -0
  37. package/dist/implementation/blob-client-live.js +213 -0
  38. package/dist/implementation/blob-client-live.js.map +1 -0
  39. package/dist/implementation/blob-config-live.d.ts +49 -0
  40. package/dist/implementation/blob-config-live.d.ts.map +1 -0
  41. package/dist/implementation/blob-config-live.js +82 -0
  42. package/dist/implementation/blob-config-live.js.map +1 -0
  43. package/dist/implementation/blob-live.d.ts +16 -0
  44. package/dist/implementation/blob-live.d.ts.map +1 -0
  45. package/dist/implementation/blob-live.js +16 -0
  46. package/dist/implementation/blob-live.js.map +1 -0
  47. package/dist/implementation/blob-signed-urls-live.d.ts +12 -0
  48. package/dist/implementation/blob-signed-urls-live.d.ts.map +1 -0
  49. package/dist/implementation/blob-signed-urls-live.js +92 -0
  50. package/dist/implementation/blob-signed-urls-live.js.map +1 -0
  51. package/dist/implementation/blob-store-live.d.ts +18 -0
  52. package/dist/implementation/blob-store-live.d.ts.map +1 -0
  53. package/dist/implementation/blob-store-live.js +364 -0
  54. package/dist/implementation/blob-store-live.js.map +1 -0
  55. package/dist/implementation/blob-uploads-live.d.ts +11 -0
  56. package/dist/implementation/blob-uploads-live.d.ts.map +1 -0
  57. package/dist/implementation/blob-uploads-live.js +218 -0
  58. package/dist/implementation/blob-uploads-live.js.map +1 -0
  59. package/dist/index.d.ts +15 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +15 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/internal/blob-errors.d.ts +79 -0
  64. package/dist/internal/blob-errors.d.ts.map +1 -0
  65. package/dist/internal/blob-errors.js +196 -0
  66. package/dist/internal/blob-errors.js.map +1 -0
  67. package/dist/internal/blob-request.d.ts +255 -0
  68. package/dist/internal/blob-request.d.ts.map +1 -0
  69. package/dist/internal/blob-request.js +291 -0
  70. package/dist/internal/blob-request.js.map +1 -0
  71. package/dist/internal/callback.d.ts +70 -0
  72. package/dist/internal/callback.d.ts.map +1 -0
  73. package/dist/internal/callback.js +141 -0
  74. package/dist/internal/callback.js.map +1 -0
  75. package/dist/internal/client-token.d.ts +65 -0
  76. package/dist/internal/client-token.d.ts.map +1 -0
  77. package/dist/internal/client-token.js +80 -0
  78. package/dist/internal/client-token.js.map +1 -0
  79. package/dist/internal/env.d.ts +34 -0
  80. package/dist/internal/env.d.ts.map +1 -0
  81. package/dist/internal/env.js +61 -0
  82. package/dist/internal/env.js.map +1 -0
  83. package/dist/internal/multipart.d.ts +110 -0
  84. package/dist/internal/multipart.d.ts.map +1 -0
  85. package/dist/internal/multipart.js +290 -0
  86. package/dist/internal/multipart.js.map +1 -0
  87. package/dist/internal/presign.d.ts +123 -0
  88. package/dist/internal/presign.d.ts.map +1 -0
  89. package/dist/internal/presign.js +345 -0
  90. package/dist/internal/presign.js.map +1 -0
  91. package/dist/internal/store-id.d.ts +38 -0
  92. package/dist/internal/store-id.d.ts.map +1 -0
  93. package/dist/internal/store-id.js +54 -0
  94. package/dist/internal/store-id.js.map +1 -0
  95. package/dist/internal/urls.d.ts +26 -0
  96. package/dist/internal/urls.d.ts.map +1 -0
  97. package/dist/internal/urls.js +33 -0
  98. package/dist/internal/urls.js.map +1 -0
  99. package/dist/internal/wire.d.ts +55 -0
  100. package/dist/internal/wire.d.ts.map +1 -0
  101. package/dist/internal/wire.js +64 -0
  102. package/dist/internal/wire.js.map +1 -0
  103. package/dist/internal/write-headers.d.ts +56 -0
  104. package/dist/internal/write-headers.d.ts.map +1 -0
  105. package/dist/internal/write-headers.js +82 -0
  106. package/dist/internal/write-headers.js.map +1 -0
  107. package/dist/testing/index.d.ts +2 -0
  108. package/dist/testing/index.d.ts.map +1 -0
  109. package/dist/testing/index.js +2 -0
  110. package/dist/testing/index.js.map +1 -0
  111. package/dist/testing/test-blob-store.d.ts +134 -0
  112. package/dist/testing/test-blob-store.d.ts.map +1 -0
  113. package/dist/testing/test-blob-store.js +718 -0
  114. package/dist/testing/test-blob-store.js.map +1 -0
  115. package/package.json +58 -4
  116. package/src/client.ts +11 -0
  117. package/src/contracts/blob-client.ts +121 -0
  118. package/src/contracts/blob-config.ts +64 -0
  119. package/src/contracts/blob-domain.ts +304 -0
  120. package/src/contracts/blob-errors.ts +161 -0
  121. package/src/contracts/blob-signed-urls.ts +112 -0
  122. package/src/contracts/blob-store.ts +258 -0
  123. package/src/contracts/blob-uploads.ts +151 -0
  124. package/src/implementation/blob-client-live.ts +387 -0
  125. package/src/implementation/blob-config-live.ts +145 -0
  126. package/src/implementation/blob-live.ts +30 -0
  127. package/src/implementation/blob-signed-urls-live.ts +121 -0
  128. package/src/implementation/blob-store-live.ts +528 -0
  129. package/src/implementation/blob-uploads-live.ts +326 -0
  130. package/src/index.ts +18 -0
  131. package/src/internal/blob-errors.ts +325 -0
  132. package/src/internal/blob-request.ts +617 -0
  133. package/src/internal/callback.ts +200 -0
  134. package/src/internal/client-token.ts +132 -0
  135. package/src/internal/env.ts +128 -0
  136. package/src/internal/multipart.ts +526 -0
  137. package/src/internal/presign.ts +477 -0
  138. package/src/internal/store-id.ts +69 -0
  139. package/src/internal/urls.ts +42 -0
  140. package/src/internal/wire.ts +80 -0
  141. package/src/internal/write-headers.ts +118 -0
  142. package/src/testing/index.ts +1 -0
  143. package/src/testing/test-blob-store.ts +1136 -0
@@ -0,0 +1,477 @@
1
+ import { DateTime, Effect, Option, Redacted, Result, Schema } from "effect";
2
+ import { Base64Url } from "effect/encoding";
3
+
4
+ import type { BlobAccess, DelegationOperation, StoreId } from "../contracts/blob-domain.ts";
5
+ import type { PresignOptions, SigningToken } from "../contracts/blob-signed-urls.ts";
6
+
7
+ import { PresignedUrlPayload } from "../contracts/blob-domain.ts";
8
+ import { BlobPresignFailed } from "../contracts/blob-errors.ts";
9
+ import { storeIdFromDelegationToken } from "./store-id.ts";
10
+ import { blobUrl, isUrl } from "./urls.ts";
11
+
12
+ /*
13
+ * Presigned URLs, ported from the SDK's `signed-token.ts` (`presign`, `presignUrl`,
14
+ * `canonicalString`) and `presign-query-params.ts`. The canonical string and query entries must
15
+ * stay aligned with the Blob API and proxy that verify them.
16
+ */
17
+
18
+ /** Signed expiry of one URL, when earlier than the delegation's. */
19
+ export const presignQueryValidUntil = "vercel-blob-valid-until";
20
+ /** Largest upload a presigned `put` accepts. */
21
+ export const presignQueryMaximumSize = "vercel-blob-maximum-size-in-bytes";
22
+ /** Media types a presigned `put` accepts, sorted and comma-separated. */
23
+ export const presignQueryAllowedContentTypes = "vercel-blob-allowed-content-types";
24
+ /** `true`/`false`. */
25
+ export const presignQueryAddRandomSuffix = "vercel-blob-add-random-suffix";
26
+ /** `true`/`false`. */
27
+ export const presignQueryAllowOverwrite = "vercel-blob-allow-overwrite";
28
+ /** Seconds. */
29
+ export const presignQueryCacheControlMaxAge = "vercel-blob-cache-control-max-age";
30
+ /** ETag a presigned `put` or `delete` requires. */
31
+ export const presignQueryIfMatch = "vercel-blob-if-match";
32
+ /** Where the store sends `blob.upload-completed`. */
33
+ export const presignQueryCallbackUrl = "vercel-blob-callback-url";
34
+ /** `tokenPayload` of the upload-completed callback. */
35
+ export const presignQueryCallbackTokenPayload = "vercel-blob-callback-token-payload";
36
+
37
+ /** Every signed constraint, in UTF-8 order, as the SDK's `PRESIGN_CANONICAL_QUERY_KEYS`. */
38
+ export const presignCanonicalQueryKeys = [
39
+ presignQueryAddRandomSuffix,
40
+ presignQueryAllowOverwrite,
41
+ presignQueryAllowedContentTypes,
42
+ presignQueryCacheControlMaxAge,
43
+ presignQueryCallbackTokenPayload,
44
+ presignQueryCallbackUrl,
45
+ presignQueryIfMatch,
46
+ presignQueryMaximumSize,
47
+ presignQueryValidUntil,
48
+ ] as const;
49
+
50
+ /** Longest `onUploadCompleted.callbackUrl` a presigned URL may carry. */
51
+ export const maximumPresignCallbackUrlLength = 4096;
52
+
53
+ /** Longest `onUploadCompleted.tokenPayload` a presigned URL may carry. */
54
+ export const maximumPresignCallbackTokenPayloadLength = 8192;
55
+
56
+ /** Largest `cacheControlMaxAge` a presigned URL may carry: one year, in seconds. */
57
+ export const maximumPresignCacheControlMaxAge = 365 * 24 * 60 * 60;
58
+
59
+ const maximumIfMatchLength = 256;
60
+
61
+ // oxlint-disable-next-line no-control-regex -- control characters are what it rejects
62
+ const ifMatchControlCharacters = /[\x00-\x1f\x7f]/;
63
+
64
+ const label = "presignUrl";
65
+
66
+ /** One signed query parameter. */
67
+ export type PresignQueryEntry = readonly [key: string, value: string];
68
+
69
+ /** What a delegation token's payload constrains, as the SDK reads it for presigning. */
70
+ export const DelegationScope = Schema.Struct({
71
+ pathname: Schema.optionalKey(Schema.String),
72
+ operations: Schema.optionalKey(Schema.Array(Schema.String)),
73
+ /** Epoch milliseconds. */
74
+ validUntil: Schema.optionalKey(Schema.Number),
75
+ maximumSizeInBytes: Schema.optionalKey(Schema.Number),
76
+ allowedContentTypes: Schema.optionalKey(Schema.Array(Schema.String)),
77
+ });
78
+
79
+ /** What a delegation token's payload constrains. */
80
+ export type DelegationScope = typeof DelegationScope.Type;
81
+
82
+ const decodeScope = Schema.decodeUnknownOption(Schema.fromJsonString(DelegationScope));
83
+
84
+ /**
85
+ * Read the scope of a delegation token: the base64url JSON before its first `.`. Standard base64
86
+ * characters and padding are accepted, as the SDK's decoder accepts them.
87
+ *
88
+ * @returns None when the token has no `.` or the segment is not base64url JSON of an object.
89
+ */
90
+ export const delegationScope = (delegationToken: string): Option.Option<DelegationScope> => {
91
+ const dot = delegationToken.indexOf(".");
92
+ if (dot < 0) {
93
+ return Option.none();
94
+ }
95
+ const segment = delegationToken
96
+ .slice(0, dot)
97
+ .replace(/\+/g, "-")
98
+ .replace(/\//g, "_")
99
+ .replace(/=+$/, "");
100
+ return Result.getSuccess(Base64Url.decodeString(segment)).pipe(Option.flatMap(decodeScope));
101
+ };
102
+
103
+ const utf8 = new TextEncoder();
104
+
105
+ /** Bytewise UTF-8 order, as the SDK's `compareUtf8` (Go string order). */
106
+ const compareUtf8 = (a: string, b: string): number => {
107
+ const left = utf8.encode(a);
108
+ const right = utf8.encode(b);
109
+ const length = Math.min(left.length, right.length);
110
+ for (let i = 0; i < length; i++) {
111
+ const difference = left[i]! - right[i]!;
112
+ if (difference !== 0) {
113
+ return difference;
114
+ }
115
+ }
116
+ return left.length - right.length;
117
+ };
118
+
119
+ /**
120
+ * The string a presigned URL's signature covers, as the SDK's `canonicalString`: `operation=…`,
121
+ * `pathname=…` and every non-empty signed entry as `key=value`, sorted bytewise and joined by `\n`.
122
+ */
123
+ export const canonicalString = (
124
+ pathname: string,
125
+ entries: ReadonlyArray<PresignQueryEntry>,
126
+ operation: DelegationOperation,
127
+ ): string => {
128
+ const lines = [`operation=${operation}`, `pathname=${pathname}`];
129
+ for (const key of presignCanonicalQueryKeys) {
130
+ const value = entries.find(([entry]) => entry === key)?.[1];
131
+ if (value) {
132
+ lines.push(`${key}=${value}`);
133
+ }
134
+ }
135
+ return lines.sort(compareUtf8).join("\n");
136
+ };
137
+
138
+ /** HMAC-SHA256 of `data` keyed by `secret` (both UTF-8), as unpadded base64url. */
139
+ export const hmacSha256Base64Url = Effect.fnUntraced(function* (secret: string, data: string) {
140
+ const key = yield* Effect.promise(() =>
141
+ globalThis.crypto.subtle.importKey(
142
+ "raw",
143
+ utf8.encode(secret),
144
+ { name: "HMAC", hash: "SHA-256" },
145
+ false,
146
+ ["sign"],
147
+ ),
148
+ );
149
+ const signature = yield* Effect.promise(() =>
150
+ globalThis.crypto.subtle.sign("HMAC", key, utf8.encode(data)),
151
+ );
152
+ return Base64Url.encode(new Uint8Array(signature));
153
+ });
154
+
155
+ const failed = (message: string) => Effect.fail(new BlobPresignFailed({ message }));
156
+
157
+ /** Signed constraints of one presigned URL, in epoch milliseconds and seconds as on the wire. */
158
+ export interface PresignUrlConstraints {
159
+ readonly validUntil?: number | undefined;
160
+ readonly allowedContentTypes?: ReadonlyArray<string> | undefined;
161
+ readonly maximumSizeInBytes?: number | undefined;
162
+ readonly addRandomSuffix?: boolean | undefined;
163
+ readonly allowOverwrite?: boolean | undefined;
164
+ readonly cacheControlMaxAge?: number | undefined;
165
+ readonly ifMatch?: string | undefined;
166
+ readonly onUploadCompleted?:
167
+ | { readonly callbackUrl: string; readonly tokenPayload?: string | null | undefined }
168
+ | undefined;
169
+ }
170
+
171
+ /** What the delegation allows, as the SDK's `DelegationScopeForPresign`. */
172
+ export interface PresignDelegation {
173
+ /** Epoch milliseconds; none when the delegation states no expiry. */
174
+ readonly validUntil: number | undefined;
175
+ readonly maximumSizeInBytes?: number | undefined;
176
+ readonly allowedContentTypes?: ReadonlyArray<string> | undefined;
177
+ }
178
+
179
+ const contentTypeAllowed = (contentType: string, allowed: ReadonlyArray<string>): boolean => {
180
+ const [type] = contentType.split("/");
181
+ return allowed.includes(contentType) || (type ? allowed.includes(`${type}/*`) : false);
182
+ };
183
+
184
+ /**
185
+ * The URL's expiry, as the SDK's `resolvePresignUrlValidUntilMs`: the requested one capped to the
186
+ * delegation's, which must be after `now`. A delegation without an expiry and no requested one
187
+ * yields `NaN`, as in the SDK, which then signs no expiry.
188
+ */
189
+ const resolveValidUntil = (
190
+ delegationValidUntil: number | undefined,
191
+ requested: number | undefined,
192
+ now: number,
193
+ ) => {
194
+ let until = Math.trunc(requested ?? delegationValidUntil ?? Number.NaN);
195
+ if (delegationValidUntil !== undefined && Number.isFinite(delegationValidUntil)) {
196
+ until = Math.min(until, Math.trunc(delegationValidUntil));
197
+ }
198
+ if (until <= now) {
199
+ return failed(
200
+ `${label}: resolved URL expiry is not after the current time; issue a new delegation token or pass a later validUntil.`,
201
+ );
202
+ }
203
+ return Effect.succeed(until);
204
+ };
205
+
206
+ const validateUploadConstraints = Effect.fnUntraced(function* (
207
+ options: PresignUrlConstraints,
208
+ delegation: PresignDelegation,
209
+ ) {
210
+ const allowed = delegation.allowedContentTypes;
211
+ if (options.allowedContentTypes?.length && allowed?.length) {
212
+ for (const contentType of options.allowedContentTypes) {
213
+ if (!contentTypeAllowed(contentType, allowed)) {
214
+ return yield* failed(
215
+ `${label}: allowedContentTypes entry "${contentType}" is not permitted by the delegation token.`,
216
+ );
217
+ }
218
+ }
219
+ }
220
+ if (
221
+ options.maximumSizeInBytes !== undefined &&
222
+ delegation.maximumSizeInBytes !== undefined &&
223
+ options.maximumSizeInBytes > delegation.maximumSizeInBytes
224
+ ) {
225
+ return yield* failed(
226
+ `${label}: maximumSizeInBytes must be ≤ delegation (${String(delegation.maximumSizeInBytes)}).`,
227
+ );
228
+ }
229
+ const maxAge = options.cacheControlMaxAge;
230
+ if (
231
+ maxAge !== undefined &&
232
+ (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > maximumPresignCacheControlMaxAge)
233
+ ) {
234
+ return yield* failed(
235
+ `${label}: cacheControlMaxAge must be an integer between 0 and ${maximumPresignCacheControlMaxAge}.`,
236
+ );
237
+ }
238
+ const ifMatch = options.ifMatch;
239
+ if (ifMatch !== undefined) {
240
+ if (ifMatch.length === 0) {
241
+ return yield* failed(`${label}: ifMatch must be a non-empty string.`);
242
+ }
243
+ if (ifMatch.length > maximumIfMatchLength) {
244
+ return yield* failed(`${label}: ifMatch is too long.`);
245
+ }
246
+ if (ifMatchControlCharacters.test(ifMatch)) {
247
+ return yield* failed(`${label}: ifMatch contains disallowed control characters.`);
248
+ }
249
+ }
250
+ const callback = options.onUploadCompleted;
251
+ if (callback !== undefined) {
252
+ if (callback.callbackUrl.length === 0) {
253
+ return yield* failed(`${label}: onUploadCompleted.callbackUrl must be a non-empty string.`);
254
+ }
255
+ if (callback.callbackUrl.length > maximumPresignCallbackUrlLength) {
256
+ return yield* failed(`${label}: onUploadCompleted.callbackUrl is too long.`);
257
+ }
258
+ if (!URL.canParse(callback.callbackUrl)) {
259
+ return yield* failed(`${label}: onUploadCompleted.callbackUrl must be a valid URL.`);
260
+ }
261
+ if (
262
+ callback.tokenPayload !== undefined &&
263
+ callback.tokenPayload !== null &&
264
+ callback.tokenPayload.length > maximumPresignCallbackTokenPayloadLength
265
+ ) {
266
+ return yield* failed(`${label}: onUploadCompleted.tokenPayload is too long.`);
267
+ }
268
+ }
269
+ });
270
+
271
+ const sortedCsv = (types: ReadonlyArray<string>): string =>
272
+ [...types].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join(",");
273
+
274
+ /**
275
+ * The signed query entries of one presigned URL, as the SDK's `buildPresignCanonicalQueryEntries`,
276
+ * in the same order. `vercel-blob-valid-until` is left out when it equals the delegation's expiry;
277
+ * `get` and `head` sign only the expiry, `delete` also `ifMatch`, and `put` every constraint after
278
+ * checking that each one only narrows the delegation's.
279
+ */
280
+ export const presignCanonicalQueryEntries = Effect.fnUntraced(function* (args: {
281
+ readonly operation: DelegationOperation;
282
+ readonly delegation: PresignDelegation;
283
+ readonly urlOptions: PresignUrlConstraints;
284
+ readonly now: number;
285
+ }) {
286
+ const { operation, delegation, urlOptions, now } = args;
287
+ const until = yield* resolveValidUntil(delegation.validUntil, urlOptions.validUntil, now);
288
+ const entries: Array<PresignQueryEntry> = [];
289
+ if (until < Math.trunc(delegation.validUntil ?? Number.NaN)) {
290
+ entries.push([presignQueryValidUntil, String(until)]);
291
+ }
292
+
293
+ if (operation === "delete") {
294
+ if (urlOptions.ifMatch !== undefined) {
295
+ entries.push([presignQueryIfMatch, urlOptions.ifMatch]);
296
+ }
297
+ return entries;
298
+ }
299
+ if (operation !== "put") {
300
+ return entries;
301
+ }
302
+
303
+ yield* validateUploadConstraints(urlOptions, delegation);
304
+
305
+ if (urlOptions.allowedContentTypes !== undefined) {
306
+ const csv = sortedCsv(urlOptions.allowedContentTypes);
307
+ if (csv.length > 16_384) {
308
+ return yield* failed(`${label}: allowedContentTypes query value is too long.`);
309
+ }
310
+ entries.push([presignQueryAllowedContentTypes, csv]);
311
+ }
312
+ if (urlOptions.maximumSizeInBytes !== undefined) {
313
+ entries.push([presignQueryMaximumSize, String(Math.trunc(urlOptions.maximumSizeInBytes))]);
314
+ }
315
+ if (urlOptions.addRandomSuffix !== undefined) {
316
+ entries.push([presignQueryAddRandomSuffix, urlOptions.addRandomSuffix ? "true" : "false"]);
317
+ }
318
+ if (urlOptions.allowOverwrite !== undefined) {
319
+ entries.push([presignQueryAllowOverwrite, urlOptions.allowOverwrite ? "true" : "false"]);
320
+ }
321
+ if (urlOptions.cacheControlMaxAge !== undefined) {
322
+ entries.push([
323
+ presignQueryCacheControlMaxAge,
324
+ String(Math.trunc(urlOptions.cacheControlMaxAge)),
325
+ ]);
326
+ }
327
+ if (urlOptions.ifMatch !== undefined) {
328
+ entries.push([presignQueryIfMatch, urlOptions.ifMatch]);
329
+ }
330
+ if (urlOptions.onUploadCompleted !== undefined) {
331
+ const { callbackUrl, tokenPayload } = urlOptions.onUploadCompleted;
332
+ entries.push([presignQueryCallbackUrl, callbackUrl]);
333
+ if (tokenPayload !== undefined && tokenPayload !== null && tokenPayload !== "") {
334
+ entries.push([presignQueryCallbackTokenPayload, tokenPayload]);
335
+ }
336
+ }
337
+ return entries;
338
+ });
339
+
340
+ const epochMillis = (value: DateTime.Utc | undefined): number | undefined =>
341
+ value === undefined ? undefined : DateTime.toEpochMillis(value);
342
+
343
+ /** The signed constraints of {@link PresignOptions}, in wire units. */
344
+ const urlConstraints = (options: PresignOptions): PresignUrlConstraints => {
345
+ const validUntil = epochMillis(options.validUntil);
346
+ switch (options.operation) {
347
+ case "get":
348
+ case "head":
349
+ return { validUntil };
350
+ case "delete":
351
+ return { validUntil, ifMatch: options.ifMatch };
352
+ case "put":
353
+ return {
354
+ validUntil,
355
+ allowedContentTypes: options.allowedContentTypes,
356
+ maximumSizeInBytes: options.maximumSizeInBytes,
357
+ addRandomSuffix: options.addRandomSuffix,
358
+ allowOverwrite: options.allowOverwrite,
359
+ cacheControlMaxAge: options.cacheControlMaxAge,
360
+ ifMatch: options.ifMatch,
361
+ onUploadCompleted: options.onUploadCompleted,
362
+ };
363
+ }
364
+ };
365
+
366
+ const operationNotDelegated: Record<DelegationOperation, string> = {
367
+ get: 'The delegation token is not valid for `GET` requests. Include `"get"` in `operations` when calling `issueSignedToken`.',
368
+ head: 'The delegation token is not valid for `HEAD` requests. Include `"head"` in `operations` when calling `issueSignedToken`.',
369
+ put: 'The delegation token is not valid for presigned write requests. Include `"put"` in `operations` when calling `issueSignedToken`.',
370
+ delete:
371
+ 'The delegation token is not valid for presigned delete requests. Include `"delete"` in `operations` when calling `issueSignedToken`.',
372
+ };
373
+
374
+ /**
375
+ * Sign one request with a signed token, as the SDK's `presign`: check the delegation's pathname,
376
+ * expiry and operations, build the signed query entries, and HMAC their canonical string with the
377
+ * client signing token.
378
+ *
379
+ * @param now - Current time in epoch milliseconds.
380
+ * @returns The payload, or `BlobPresignFailed` with the SDK's message.
381
+ */
382
+ export const presignPayload = Effect.fnUntraced(function* (
383
+ token: SigningToken,
384
+ options: PresignOptions,
385
+ now: number,
386
+ ) {
387
+ const clientSigningToken = Redacted.value(token.clientSigningToken);
388
+ if (!clientSigningToken || !token.delegationToken) {
389
+ return yield* failed(
390
+ "`clientSigningToken` and `delegationToken` from `issueSignedToken` are required.",
391
+ );
392
+ }
393
+ const scope = delegationScope(token.delegationToken);
394
+ if (Option.isNone(scope)) {
395
+ return yield* failed("Invalid or unreadable `delegationToken` payload.");
396
+ }
397
+ const { pathname, validUntil, operations, maximumSizeInBytes, allowedContentTypes } = scope.value;
398
+ if (pathname && pathname !== "*" && options.pathname !== pathname) {
399
+ return yield* failed(
400
+ `Blob path does not match the signed token scope; expected \`${pathname}\`, got \`${options.pathname}\`.`,
401
+ );
402
+ }
403
+ if (validUntil !== undefined && Number.isFinite(validUntil) && now > validUntil) {
404
+ return yield* failed("The signed delegation has expired; issue a new token first.");
405
+ }
406
+ if (!operations?.includes(options.operation)) {
407
+ return yield* failed(operationNotDelegated[options.operation]);
408
+ }
409
+
410
+ const entries = yield* presignCanonicalQueryEntries({
411
+ operation: options.operation,
412
+ delegation: { validUntil, maximumSizeInBytes, allowedContentTypes },
413
+ urlOptions: urlConstraints(options),
414
+ now,
415
+ });
416
+ const signature = yield* hmacSha256Base64Url(
417
+ clientSigningToken,
418
+ canonicalString(options.pathname, entries, options.operation),
419
+ );
420
+ return new PresignedUrlPayload({
421
+ delegationToken: token.delegationToken,
422
+ signature,
423
+ params: Object.fromEntries(entries),
424
+ });
425
+ });
426
+
427
+ /** `url` with the payload's params, `vercel-blob-delegation` and `vercel-blob-signature` set. */
428
+ export const withPresignedParams = (url: URL, payload: PresignedUrlPayload): URL => {
429
+ const result = new URL(url);
430
+ for (const [key, value] of Object.entries(payload.params)) {
431
+ result.searchParams.set(key, value);
432
+ }
433
+ result.searchParams.set("vercel-blob-delegation", payload.delegationToken);
434
+ result.searchParams.set("vercel-blob-signature", payload.signature);
435
+ return result;
436
+ };
437
+
438
+ /**
439
+ * The presigned URL of a `get` or `head`, as the SDK's `buildPresignedGetUrl`: a blob URL is used
440
+ * as is, a pathname is resolved under the delegation's store (`blobBaseUrl`); `cache=0` (unsigned)
441
+ * is added for a private `get` with `useCache: false`.
442
+ *
443
+ * @returns None when the delegation token carries no valid store id, which the SDK requires even
444
+ * for a blob URL.
445
+ */
446
+ export const presignedBlobUrl = (args: {
447
+ readonly pathnameOrUrl: string;
448
+ readonly payload: PresignedUrlPayload;
449
+ readonly access: BlobAccess;
450
+ readonly useCache: boolean | undefined;
451
+ readonly blobBaseUrl: (storeId: StoreId, access: BlobAccess) => URL;
452
+ }): Option.Option<URL> =>
453
+ Option.map(storeIdFromDelegationToken(args.payload.delegationToken), (storeId) => {
454
+ const url = isUrl(args.pathnameOrUrl)
455
+ ? new URL(args.pathnameOrUrl)
456
+ : blobUrl(args.blobBaseUrl(storeId, args.access), args.pathnameOrUrl);
457
+ if (args.useCache === false && args.access === "private") {
458
+ url.searchParams.set("cache", "0");
459
+ }
460
+ return withPresignedParams(url, args.payload);
461
+ });
462
+
463
+ /**
464
+ * The presigned URL of a `put` or `delete`, as the SDK's `buildPresignedPutUrl`: the Blob API's
465
+ * `/?pathname=…` (trailing slashes of `apiUrl` removed) plus the payload's params.
466
+ */
467
+ export const presignedApiUrl = (
468
+ apiUrl: URL,
469
+ pathname: string,
470
+ payload: PresignedUrlPayload,
471
+ ): URL => {
472
+ const base = apiUrl.href.replace(/\/+$/, "");
473
+ return withPresignedParams(
474
+ new URL(`${base}/?${new URLSearchParams({ pathname }).toString()}`),
475
+ payload,
476
+ );
477
+ };
@@ -0,0 +1,69 @@
1
+ import { Option, Result, Schema } from "effect";
2
+ import { Base64Url } from "effect/encoding";
3
+
4
+ import { StoreId } from "../contracts/blob-domain.ts";
5
+
6
+ const decodeStoreId = Schema.decodeUnknownOption(StoreId);
7
+
8
+ /**
9
+ * Strip the optional `store_` prefix from a store id. `BLOB_STORE_ID` (as written by
10
+ * `vercel env pull`) and the `storeId` option accept `store_<id>` or `<id>`; the API wants the bare
11
+ * id, case preserved.
12
+ */
13
+ export const normalizeStoreId = (storeId: string): string =>
14
+ storeId.startsWith("store_") ? storeId.slice("store_".length) : storeId;
15
+
16
+ /**
17
+ * Validate a store id given in either form (`store_<id>` or `<id>`).
18
+ *
19
+ * @returns The bare id, or none when it is empty or contains whitespace or `_`.
20
+ */
21
+ export const parseStoreId = (storeId: string): Option.Option<StoreId> =>
22
+ decodeStoreId(normalizeStoreId(storeId));
23
+
24
+ /**
25
+ * The store id embedded in a read-write token (`vercel_blob_rw_<storeId>_<secret>`): the fourth
26
+ * `_`-separated segment, as the SDK's `parseStoreIdFromReadWriteToken`.
27
+ *
28
+ * @returns None when the token has no valid fourth segment.
29
+ */
30
+ export const storeIdFromReadWriteToken = (token: string): Option.Option<StoreId> =>
31
+ Option.flatMap(Option.fromUndefinedOr(token.split("_")[3]), decodeStoreId);
32
+
33
+ /**
34
+ * The store id embedded in a client token (`vercel_blob_client_<storeId>_<payload>`). The SDK reads
35
+ * it with the same fourth-segment rule as read-write tokens.
36
+ */
37
+ export const storeIdFromClientToken: (token: string) => Option.Option<StoreId> =
38
+ storeIdFromReadWriteToken;
39
+
40
+ const DelegationPayload = Schema.fromJsonString(Schema.Struct({ storeId: Schema.NonEmptyString }));
41
+
42
+ const decodeDelegationPayload = Schema.decodeUnknownOption(DelegationPayload);
43
+
44
+ /**
45
+ * Read the store id from a delegation token, the public half of a signed token that every presigned
46
+ * URL carries in `vercel-blob-delegation`. The token's payload is the base64url JSON before the first
47
+ * `.`; its `storeId` field is returned without the `store_` prefix.
48
+ *
49
+ * Works in browsers, so a client can address the right store from a presigned payload alone.
50
+ *
51
+ * @returns None when the token has no `.`, its payload is not base64url JSON, or it has no
52
+ * non-empty `storeId` string that is a valid store id.
53
+ */
54
+ export const storeIdFromDelegationToken = (delegationToken: string): Option.Option<StoreId> => {
55
+ const dot = delegationToken.indexOf(".");
56
+ if (dot < 0) {
57
+ return Option.none();
58
+ }
59
+ // The SDK's decoder also accepts standard base64 characters; normalize them to base64url.
60
+ const segment = delegationToken
61
+ .slice(0, dot)
62
+ .replace(/\+/g, "-")
63
+ .replace(/\//g, "_")
64
+ .replace(/=+$/, "");
65
+ return Result.getSuccess(Base64Url.decodeString(segment)).pipe(
66
+ Option.flatMap(decodeDelegationPayload),
67
+ Option.flatMap((payload) => parseStoreId(payload.storeId)),
68
+ );
69
+ };
@@ -0,0 +1,42 @@
1
+ import type { BlobAccess, StoreId } from "../contracts/blob-domain.ts";
2
+
3
+ /** The production Blob API, used unless `VERCEL_BLOB_API_URL` or an option overrides it. */
4
+ export const defaultApiUrl = "https://vercel.com/api/blob";
5
+
6
+ /** Where a store serves its blobs: `https://<storeId>.<access>.blob.vercel-storage.com/`. */
7
+ export const defaultBlobBaseUrl = (storeId: StoreId, access: BlobAccess): URL =>
8
+ new URL(`https://${storeId}.${access}.blob.vercel-storage.com/`);
9
+
10
+ /**
11
+ * The URL of the blob at `pathname` under a store's base URL (`BlobConfig.blobBaseUrl`), as the
12
+ * SDK's `constructBlobUrl`: the pathname is appended as is, so URL syntax in it (`?`, `#`) keeps its
13
+ * URL meaning exactly as with the SDK.
14
+ */
15
+ export const blobUrl = (baseUrl: URL, pathname: string): URL => {
16
+ const base = baseUrl.href.endsWith("/") ? baseUrl.href : `${baseUrl.href}/`;
17
+ return new URL(`${base}${pathname}`);
18
+ };
19
+
20
+ /** True for strings the SDK treats as URLs rather than pathnames (`http://` or `https://`). */
21
+ export const isUrl = (urlOrPathname: string): boolean =>
22
+ urlOrPathname.startsWith("http://") || urlOrPathname.startsWith("https://");
23
+
24
+ /**
25
+ * Whether `get` may send credentials to `url`: hosts under `.blob.vercel-storage.com`, and, when
26
+ * the API URL is overridden (an emulator or a test server), that API URL's origin.
27
+ *
28
+ * @param apiUrl - The configured API URL; the production default allows no extra origin.
29
+ */
30
+ export const isAllowedBlobUrl = (url: URL, apiUrl: URL): boolean =>
31
+ url.hostname.endsWith(".blob.vercel-storage.com") ||
32
+ (apiUrl.href !== new URL(defaultApiUrl).href && apiUrl.origin === url.origin);
33
+
34
+ /**
35
+ * The URL that makes browsers download a blob instead of displaying it: the blob URL with
36
+ * `download=1` in its query.
37
+ */
38
+ export const downloadUrl = (url: URL | string): URL => {
39
+ const result = new URL(url);
40
+ result.searchParams.set("download", "1");
41
+ return result;
42
+ };
@@ -0,0 +1,80 @@
1
+ import { Schema, SchemaGetter } from "effect";
2
+
3
+ import {
4
+ BlobMetadata,
5
+ CreateFolderResult,
6
+ ListPage,
7
+ PutBlobResult,
8
+ } from "../contracts/blob-domain.ts";
9
+
10
+ /*
11
+ * JSON bodies of Blob API responses, decoded into the contract classes. Unknown fields are ignored
12
+ * so newer API versions keep decoding. Pass these as `response` to `BlobRequester.call`.
13
+ */
14
+
15
+ /** `PUT /`, `PUT ?pathname&fromUrl` (copy), rename, put-image and multipart `complete`. */
16
+ export const PutBlobResponse = Schema.Struct({
17
+ url: Schema.URLFromString,
18
+ downloadUrl: Schema.URLFromString,
19
+ pathname: Schema.String,
20
+ contentType: Schema.String,
21
+ contentDisposition: Schema.String,
22
+ etag: Schema.String,
23
+ }).pipe(Schema.decodeTo(PutBlobResult));
24
+
25
+ /** `PUT /?pathname=<folder>/`: only `url` and `pathname` are kept, as the SDK does. */
26
+ export const CreateFolderResponse = Schema.Struct({
27
+ url: Schema.URLFromString,
28
+ pathname: Schema.String,
29
+ }).pipe(Schema.decodeTo(CreateFolderResult));
30
+
31
+ /** `GET ?url=…` (head). `uploadedAt` is an ISO 8601 string. */
32
+ export const HeadBlobResponse = Schema.Struct({
33
+ url: Schema.URLFromString,
34
+ downloadUrl: Schema.URLFromString,
35
+ pathname: Schema.String,
36
+ size: Schema.Int,
37
+ uploadedAt: Schema.DateTimeUtcFromString,
38
+ contentType: Schema.String,
39
+ contentDisposition: Schema.String,
40
+ cacheControl: Schema.String,
41
+ etag: Schema.String,
42
+ }).pipe(Schema.decodeTo(BlobMetadata));
43
+
44
+ const ListedBlobWire = Schema.Struct({
45
+ url: Schema.URLFromString,
46
+ downloadUrl: Schema.URLFromString,
47
+ pathname: Schema.String,
48
+ size: Schema.Int,
49
+ uploadedAt: Schema.DateTimeUtcFromString,
50
+ etag: Schema.String,
51
+ });
52
+
53
+ const ListWire = Schema.Struct({
54
+ blobs: Schema.Array(ListedBlobWire),
55
+ folders: Schema.optionalKey(Schema.Array(Schema.String)),
56
+ cursor: Schema.OptionFromOptionalNullOr(Schema.String),
57
+ hasMore: Schema.Boolean,
58
+ });
59
+
60
+ /**
61
+ * `GET ?limit&prefix&cursor&mode` (list). `folders` is only sent for `mode=folded`; it decodes to
62
+ * an empty array otherwise.
63
+ */
64
+ export const ListResponse = ListWire.pipe(
65
+ Schema.decodeTo(ListPage, {
66
+ decode: SchemaGetter.transform(({ folders, ...page }) => ({ ...page, folders: folders ?? [] })),
67
+ encode: SchemaGetter.transform((page) => page),
68
+ }),
69
+ );
70
+
71
+ /** `POST /mpu` with `x-mpu-action: create`. */
72
+ export const CreateMultipartUploadResponse = Schema.Struct({
73
+ uploadId: Schema.String,
74
+ key: Schema.String,
75
+ });
76
+
77
+ /** `POST /mpu` with `x-mpu-action: upload`. */
78
+ export const UploadPartResponse = Schema.Struct({
79
+ etag: Schema.String,
80
+ });