@velajs/storage 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 (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +56 -0
  4. package/dist/base64.d.ts +8 -0
  5. package/dist/base64.js +25 -0
  6. package/dist/client/index.d.ts +50 -0
  7. package/dist/client/index.js +243 -0
  8. package/dist/decorators/inject-storage.decorator.d.ts +17 -0
  9. package/dist/decorators/inject-storage.decorator.js +21 -0
  10. package/dist/drivers/memory/index.d.ts +16 -0
  11. package/dist/drivers/memory/index.js +202 -0
  12. package/dist/drivers/r2/index.d.ts +11 -0
  13. package/dist/drivers/r2/index.js +166 -0
  14. package/dist/drivers/r2/r2.types.d.ts +67 -0
  15. package/dist/drivers/r2/r2.types.js +5 -0
  16. package/dist/drivers/r2-http/index.d.ts +33 -0
  17. package/dist/drivers/r2-http/index.js +52 -0
  18. package/dist/drivers/s3/index.d.ts +5 -0
  19. package/dist/drivers/s3/index.js +3 -0
  20. package/dist/drivers/s3/s3-client.d.ts +33 -0
  21. package/dist/drivers/s3/s3-client.js +154 -0
  22. package/dist/drivers/s3/s3.driver.d.ts +4 -0
  23. package/dist/drivers/s3/s3.driver.js +404 -0
  24. package/dist/drivers/s3/s3.types.d.ts +28 -0
  25. package/dist/drivers/s3/s3.types.js +1 -0
  26. package/dist/drivers/s3/xml.d.ts +6 -0
  27. package/dist/drivers/s3/xml.js +52 -0
  28. package/dist/http/authorizer.types.d.ts +59 -0
  29. package/dist/http/authorizer.types.js +1 -0
  30. package/dist/http/http-helpers.d.ts +22 -0
  31. package/dist/http/http-helpers.js +80 -0
  32. package/dist/http/protocol.types.d.ts +76 -0
  33. package/dist/http/protocol.types.js +1 -0
  34. package/dist/index.d.ts +17 -0
  35. package/dist/index.js +16 -0
  36. package/dist/internal/body.d.ts +18 -0
  37. package/dist/internal/body.js +90 -0
  38. package/dist/internal/retry.d.ts +41 -0
  39. package/dist/internal/retry.js +127 -0
  40. package/dist/internal/stored-file.d.ts +27 -0
  41. package/dist/internal/stored-file.js +114 -0
  42. package/dist/middleware/cache.d.ts +50 -0
  43. package/dist/middleware/cache.js +101 -0
  44. package/dist/middleware/compose.d.ts +12 -0
  45. package/dist/middleware/compose.js +11 -0
  46. package/dist/middleware/compression.d.ts +16 -0
  47. package/dist/middleware/compression.js +101 -0
  48. package/dist/middleware/encryption.d.ts +14 -0
  49. package/dist/middleware/encryption.js +134 -0
  50. package/dist/middleware/failover.d.ts +18 -0
  51. package/dist/middleware/failover.js +52 -0
  52. package/dist/middleware/index.d.ts +15 -0
  53. package/dist/middleware/index.js +8 -0
  54. package/dist/middleware/retry.d.ts +14 -0
  55. package/dist/middleware/retry.js +47 -0
  56. package/dist/middleware/versioning.d.ts +36 -0
  57. package/dist/middleware/versioning.js +89 -0
  58. package/dist/middleware/wrap.d.ts +11 -0
  59. package/dist/middleware/wrap.js +46 -0
  60. package/dist/object-key.d.ts +17 -0
  61. package/dist/object-key.js +42 -0
  62. package/dist/storage.controller.d.ts +21 -0
  63. package/dist/storage.controller.js +380 -0
  64. package/dist/storage.error.d.ts +28 -0
  65. package/dist/storage.error.js +46 -0
  66. package/dist/storage.facade.d.ts +47 -0
  67. package/dist/storage.facade.js +443 -0
  68. package/dist/storage.module.d.ts +46 -0
  69. package/dist/storage.module.js +113 -0
  70. package/dist/storage.service.d.ts +42 -0
  71. package/dist/storage.service.js +86 -0
  72. package/dist/storage.tokens.d.ts +13 -0
  73. package/dist/storage.tokens.js +26 -0
  74. package/dist/storage.types.d.ts +245 -0
  75. package/dist/storage.types.js +1 -0
  76. package/dist/storagesdk/index.d.ts +85 -0
  77. package/dist/storagesdk/index.js +136 -0
  78. package/package.json +116 -0
@@ -0,0 +1,404 @@
1
+ import { byteLengthOf } from "../../internal/body.js";
2
+ import { createStoredFile } from "../../internal/stored-file.js";
3
+ import { StorageError } from "../../storage.error.js";
4
+ import { S3Client } from "./s3-client.js";
5
+ import { escapeXml, tagBlocks, tagText } from "./xml.js";
6
+ const encoder = new TextEncoder();
7
+ const DEFAULT_EXPIRES = 3600;
8
+ const DEFAULT_PART_SIZE = 5 * 1024 * 1024;
9
+ function stripQuotes(s) {
10
+ return s ? s.replace(/^"|"$/g, '') : undefined;
11
+ }
12
+ function parseDate(s) {
13
+ if (!s) return undefined;
14
+ const t = Date.parse(s);
15
+ return Number.isFinite(t) ? t : undefined;
16
+ }
17
+ function formatRange(range) {
18
+ return `bytes=${range.start}-${range.end ?? ''}`;
19
+ }
20
+ function metaFromHeaders(headers) {
21
+ const meta = {};
22
+ headers.forEach((value, key)=>{
23
+ if (key.startsWith('x-amz-meta-')) meta[key.slice('x-amz-meta-'.length)] = value;
24
+ });
25
+ return Object.keys(meta).length ? meta : undefined;
26
+ }
27
+ function joinPublic(base, key) {
28
+ const enc = key.split('/').map(encodeURIComponent).join('/');
29
+ return `${base.replace(/\/$/, '')}/${enc}`;
30
+ }
31
+ /** Map an S3 `<Code>` (and/or HTTP status) to our error taxonomy. */ function mapS3Code(code, status) {
32
+ switch(code){
33
+ case 'NoSuchKey':
34
+ case 'NoSuchUpload':
35
+ case 'NoSuchBucket':
36
+ return 'NotFound';
37
+ case 'AccessDenied':
38
+ case 'InvalidAccessKeyId':
39
+ case 'SignatureDoesNotMatch':
40
+ case 'AllAccessDisabled':
41
+ return 'AccessDenied';
42
+ case 'SlowDown':
43
+ case 'RequestTimeout':
44
+ return 'RateLimited';
45
+ default:
46
+ break;
47
+ }
48
+ if (status === 404) return 'NotFound';
49
+ if (status === 403 || status === 401) return 'AccessDenied';
50
+ if (status === 409) return 'Conflict';
51
+ if (status === 429) return 'RateLimited';
52
+ if (status >= 500) return 'Provider';
53
+ if (status >= 400) return 'InvalidRequest';
54
+ return 'Provider'; // e.g. a 200 response carrying an <Error> body
55
+ }
56
+ function toError(status, code, message, key) {
57
+ const mapped = mapS3Code(code, status);
58
+ const retryable = mapped === 'Provider' || mapped === 'RateLimited' || mapped === 'Timeout' || mapped === 'Network';
59
+ return new StorageError(mapped, message ?? code ?? `S3 error${key ? ` (${key})` : ''}`, {
60
+ status: status >= 400 ? status : undefined,
61
+ retryable
62
+ });
63
+ }
64
+ async function s3Error(res, key) {
65
+ let code;
66
+ let message;
67
+ try {
68
+ const body = await res.text();
69
+ code = tagText(body, 'Code');
70
+ message = tagText(body, 'Message');
71
+ } catch {
72
+ /* ignore body read errors */ }
73
+ return toError(res.status, code, message, key);
74
+ }
75
+ function parseErrorBody(status, body, key) {
76
+ return toError(status, tagText(body, 'Code'), tagText(body, 'Message'), key);
77
+ }
78
+ /** Add `duplex: 'half'` when streaming a request body (required by fetch). */ function withDuplex(init, body) {
79
+ if (body instanceof ReadableStream) {
80
+ return {
81
+ ...init,
82
+ duplex: 'half'
83
+ };
84
+ }
85
+ return init;
86
+ }
87
+ /** S3 / S3-compatible driver signed with aws4fetch (edge-safe). */ export function s3Driver(options) {
88
+ const client = new S3Client(options);
89
+ const expires = options.defaultUrlExpiresIn ?? DEFAULT_EXPIRES;
90
+ function uploadHeaders(opts, unsignedPayload) {
91
+ const h = new Headers();
92
+ if (unsignedPayload) h.set('x-amz-content-sha256', 'UNSIGNED-PAYLOAD');
93
+ if (opts?.contentType) h.set('content-type', opts.contentType);
94
+ if (opts?.cacheControl) h.set('cache-control', opts.cacheControl);
95
+ if (opts?.metadata) for (const [k, v] of Object.entries(opts.metadata))h.set(`x-amz-meta-${k}`, v);
96
+ return h;
97
+ }
98
+ function multipartHandle(key, uploadId, createOpts) {
99
+ return {
100
+ key,
101
+ uploadId,
102
+ async uploadPart (partNumber, part, o) {
103
+ const url = client.objectUrl(key, {
104
+ partNumber: String(partNumber),
105
+ uploadId
106
+ });
107
+ const res = await client.send(url, withDuplex({
108
+ method: 'PUT',
109
+ headers: new Headers({
110
+ 'x-amz-content-sha256': 'UNSIGNED-PAYLOAD'
111
+ }),
112
+ body: part,
113
+ signal: o?.signal
114
+ }, part));
115
+ if (!res.ok) throw await s3Error(res, key);
116
+ const etag = res.headers.get('etag');
117
+ if (!etag) throw new StorageError('Provider', `missing ETag for part ${partNumber}`);
118
+ return {
119
+ partNumber,
120
+ etag
121
+ };
122
+ },
123
+ async complete (parts, o) {
124
+ const xml = '<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUpload>' + [
125
+ ...parts
126
+ ].sort((a, b)=>a.partNumber - b.partNumber).map((p)=>`<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`).join('') + '</CompleteMultipartUpload>';
127
+ const res = await client.send(client.objectUrl(key, {
128
+ uploadId
129
+ }), {
130
+ method: 'POST',
131
+ headers: new Headers({
132
+ 'content-type': 'application/xml'
133
+ }),
134
+ body: encoder.encode(xml),
135
+ signal: o?.signal
136
+ });
137
+ const body = await res.text();
138
+ if (!res.ok || body.includes('<Error')) throw parseErrorBody(res.status, body, key);
139
+ return {
140
+ key,
141
+ size: 0,
142
+ contentType: createOpts?.contentType ?? 'application/octet-stream',
143
+ etag: stripQuotes(tagText(body, 'ETag'))
144
+ };
145
+ },
146
+ async abort (o) {
147
+ const res = await client.send(client.objectUrl(key, {
148
+ uploadId
149
+ }), {
150
+ method: 'DELETE',
151
+ signal: o?.signal
152
+ });
153
+ if (!res.ok && res.status !== 404) throw await s3Error(res, key);
154
+ },
155
+ async listParts (o) {
156
+ const res = await client.send(client.objectUrl(key, {
157
+ uploadId
158
+ }), {
159
+ method: 'GET',
160
+ signal: o?.signal
161
+ });
162
+ const xml = await res.text();
163
+ if (!res.ok) throw parseErrorBody(res.status, xml, key);
164
+ return tagBlocks(xml, 'Part').map((b)=>({
165
+ partNumber: Number(tagText(b, 'PartNumber') ?? 0),
166
+ etag: stripQuotes(tagText(b, 'ETag')) ?? '',
167
+ size: Number(tagText(b, 'Size') ?? 0)
168
+ }));
169
+ }
170
+ };
171
+ }
172
+ const signedMultipart = {
173
+ async create (key, o) {
174
+ const res = await client.send(client.objectUrl(key, {
175
+ uploads: ''
176
+ }), {
177
+ method: 'POST',
178
+ headers: uploadHeaders({
179
+ contentType: o.contentType,
180
+ metadata: o.metadata
181
+ }, false)
182
+ });
183
+ const xml = await res.text();
184
+ if (!res.ok) throw parseErrorBody(res.status, xml, key);
185
+ const uploadId = tagText(xml, 'UploadId');
186
+ if (!uploadId) throw new StorageError('Parse', 'missing UploadId');
187
+ return {
188
+ uploadId,
189
+ partSize: o.partSize ?? DEFAULT_PART_SIZE
190
+ };
191
+ },
192
+ async signPart (key, uploadId, partNumber, o) {
193
+ const url = await client.presign(key, 'PUT', o?.expiresIn ?? expires, {
194
+ partNumber: String(partNumber),
195
+ uploadId
196
+ });
197
+ return {
198
+ url
199
+ };
200
+ },
201
+ complete (key, uploadId, parts) {
202
+ return multipartHandle(key, uploadId).complete(parts);
203
+ },
204
+ abort (key, uploadId) {
205
+ return multipartHandle(key, uploadId).abort();
206
+ }
207
+ };
208
+ return {
209
+ name: options.name ?? 's3',
210
+ raw: client,
211
+ supportsRange: true,
212
+ supportsDelimiter: true,
213
+ supportsMetadata: true,
214
+ supportsCacheControl: true,
215
+ supportsServerSideCopy: true,
216
+ reportsUploadProgress: false,
217
+ signedUrl: {
218
+ supported: true,
219
+ upload: true
220
+ },
221
+ async upload (key, body, opts) {
222
+ const size = byteLengthOf(body) ?? 0;
223
+ const res = await client.send(client.objectUrl(key), withDuplex({
224
+ method: 'PUT',
225
+ headers: uploadHeaders(opts, true),
226
+ body: body,
227
+ signal: opts?.signal
228
+ }, body));
229
+ if (!res.ok) throw await s3Error(res, key);
230
+ return {
231
+ key,
232
+ size,
233
+ contentType: opts?.contentType ?? 'application/octet-stream',
234
+ etag: stripQuotes(res.headers.get('etag')),
235
+ lastModified: parseDate(res.headers.get('last-modified'))
236
+ };
237
+ },
238
+ async download (key, opts) {
239
+ const headers = new Headers();
240
+ if (opts?.range) headers.set('Range', formatRange(opts.range));
241
+ const res = await client.send(client.objectUrl(key), {
242
+ method: 'GET',
243
+ headers,
244
+ signal: opts?.signal
245
+ });
246
+ if (!res.ok || !res.body) {
247
+ if (res.status === 404) throw new StorageError('NotFound', `not found: ${key}`, {
248
+ status: 404
249
+ });
250
+ throw await s3Error(res, key);
251
+ }
252
+ return createStoredFile({
253
+ key,
254
+ size: Number(res.headers.get('content-length') ?? 0),
255
+ type: res.headers.get('content-type') ?? 'application/octet-stream',
256
+ etag: stripQuotes(res.headers.get('etag')),
257
+ lastModified: parseDate(res.headers.get('last-modified')),
258
+ metadata: metaFromHeaders(res.headers)
259
+ }, {
260
+ kind: 'stream',
261
+ stream: res.body
262
+ });
263
+ },
264
+ async head (key, opts) {
265
+ const res = await client.send(client.objectUrl(key), {
266
+ method: 'HEAD',
267
+ signal: opts?.signal
268
+ });
269
+ if (!res.ok) {
270
+ if (res.status === 404) throw new StorageError('NotFound', `not found: ${key}`, {
271
+ status: 404
272
+ });
273
+ throw await s3Error(res, key);
274
+ }
275
+ return createStoredFile({
276
+ key,
277
+ size: Number(res.headers.get('content-length') ?? 0),
278
+ type: res.headers.get('content-type') ?? 'application/octet-stream',
279
+ etag: stripQuotes(res.headers.get('etag')),
280
+ lastModified: parseDate(res.headers.get('last-modified')),
281
+ metadata: metaFromHeaders(res.headers)
282
+ }, {
283
+ kind: 'lazy',
284
+ fetch: ()=>client.send(client.objectUrl(key), {
285
+ method: 'GET'
286
+ })
287
+ });
288
+ },
289
+ async exists (key, opts) {
290
+ const res = await client.send(client.objectUrl(key), {
291
+ method: 'HEAD',
292
+ signal: opts?.signal
293
+ });
294
+ if (res.status === 404) return false;
295
+ if (!res.ok) throw await s3Error(res, key);
296
+ return true;
297
+ },
298
+ async delete (key, opts) {
299
+ const res = await client.send(client.objectUrl(key), {
300
+ method: 'DELETE',
301
+ signal: opts?.signal
302
+ });
303
+ if (!res.ok && res.status !== 404) throw await s3Error(res, key);
304
+ },
305
+ async copy (from, to, opts) {
306
+ const source = `/${client.bucket}/${from.split('/').map(encodeURIComponent).join('/')}`;
307
+ const res = await client.send(client.objectUrl(to), {
308
+ method: 'PUT',
309
+ headers: new Headers({
310
+ 'x-amz-copy-source': source
311
+ }),
312
+ signal: opts?.signal
313
+ });
314
+ const body = await res.text();
315
+ if (!res.ok || body.includes('<Error')) throw parseErrorBody(res.status, body, to);
316
+ },
317
+ async list (opts) {
318
+ const query = {
319
+ 'list-type': '2'
320
+ };
321
+ if (opts?.prefix) query.prefix = opts.prefix;
322
+ if (opts?.delimiter) query.delimiter = opts.delimiter;
323
+ if (opts?.cursor) query['continuation-token'] = opts.cursor;
324
+ if (opts?.limit) query['max-keys'] = String(opts.limit);
325
+ const res = await client.send(client.objectUrl('', query), {
326
+ method: 'GET',
327
+ signal: opts?.signal
328
+ });
329
+ const xml = await res.text();
330
+ if (!res.ok) throw parseErrorBody(res.status, xml);
331
+ const items = tagBlocks(xml, 'Contents').map((b)=>{
332
+ const k = tagText(b, 'Key') ?? '';
333
+ return createStoredFile({
334
+ key: k,
335
+ size: Number(tagText(b, 'Size') ?? 0),
336
+ type: 'application/octet-stream',
337
+ etag: stripQuotes(tagText(b, 'ETag')),
338
+ lastModified: parseDate(tagText(b, 'LastModified'))
339
+ }, {
340
+ kind: 'lazy',
341
+ fetch: ()=>client.send(client.objectUrl(k), {
342
+ method: 'GET'
343
+ })
344
+ });
345
+ });
346
+ const prefixes = tagBlocks(xml, 'CommonPrefixes').map((b)=>tagText(b, 'Prefix')).filter((p)=>!!p);
347
+ const cursor = tagText(xml, 'IsTruncated') === 'true' ? tagText(xml, 'NextContinuationToken') : undefined;
348
+ return {
349
+ items,
350
+ prefixes: prefixes.length ? prefixes : undefined,
351
+ cursor
352
+ };
353
+ },
354
+ async url (key, opts) {
355
+ const forceSign = !!opts?.responseContentDisposition;
356
+ if (options.publicBaseUrl && !forceSign) return joinPublic(options.publicBaseUrl, key);
357
+ const query = opts?.responseContentDisposition ? {
358
+ 'response-content-disposition': opts.responseContentDisposition
359
+ } : undefined;
360
+ return client.presign(key, 'GET', opts?.expiresIn ?? expires, query);
361
+ },
362
+ async signedUploadUrl (key, opts) {
363
+ if (opts.maxSize != null || opts.minSize != null) {
364
+ const { url, fields } = await client.signPostPolicy(key, {
365
+ expiresIn: opts.expiresIn,
366
+ contentType: opts.contentType,
367
+ maxSize: opts.maxSize,
368
+ minSize: opts.minSize
369
+ });
370
+ return {
371
+ method: 'POST',
372
+ url,
373
+ fields
374
+ };
375
+ }
376
+ const url = await client.presign(key, 'PUT', opts.expiresIn);
377
+ return {
378
+ method: 'PUT',
379
+ url,
380
+ headers: opts.contentType ? {
381
+ 'content-type': opts.contentType
382
+ } : undefined
383
+ };
384
+ },
385
+ async createMultipartUpload (key, opts) {
386
+ const res = await client.send(client.objectUrl(key, {
387
+ uploads: ''
388
+ }), {
389
+ method: 'POST',
390
+ headers: uploadHeaders(opts, false),
391
+ signal: opts?.signal
392
+ });
393
+ const xml = await res.text();
394
+ if (!res.ok) throw parseErrorBody(res.status, xml, key);
395
+ const uploadId = tagText(xml, 'UploadId');
396
+ if (!uploadId) throw new StorageError('Parse', 'missing UploadId');
397
+ return multipartHandle(key, uploadId, opts);
398
+ },
399
+ resumeMultipartUpload (key, uploadId) {
400
+ return multipartHandle(key, uploadId);
401
+ },
402
+ signedMultipart
403
+ };
404
+ }
@@ -0,0 +1,28 @@
1
+ export interface S3Credentials {
2
+ accessKeyId: string;
3
+ secretAccessKey: string;
4
+ sessionToken?: string;
5
+ }
6
+ export interface S3DriverOptions {
7
+ /**
8
+ * Service endpoint. Examples:
9
+ * - AWS: `https://s3.us-east-1.amazonaws.com`
10
+ * - R2: `https://<accountId>.r2.cloudflarestorage.com`
11
+ * - MinIO: `http://localhost:9000`
12
+ */
13
+ endpoint: string;
14
+ /** AWS region (`auto` for R2). */
15
+ region: string;
16
+ bucket: string;
17
+ credentials: S3Credentials;
18
+ /** Path-style addressing (`endpoint/bucket/key`). Required by MinIO/R2/most non-AWS. */
19
+ forcePathStyle?: boolean;
20
+ /** CDN/public origin; when set, `url()` returns a permanent link unless signing is forced. */
21
+ publicBaseUrl?: string;
22
+ /** Default `expiresIn` (seconds) for presigned URLs. Default 3600. */
23
+ defaultUrlExpiresIn?: number;
24
+ /** Override the fetch implementation (tests). */
25
+ fetch?: typeof fetch;
26
+ /** Friendly driver name (defaults to `s3`). */
27
+ name?: string;
28
+ }
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,6 @@
1
+ export declare function unescapeXml(s: string): string;
2
+ export declare function escapeXml(s: string): string;
3
+ /** First `<tag>…</tag>` inner text (entity-decoded), or `undefined`. */
4
+ export declare function tagText(xml: string, tag: string): string | undefined;
5
+ /** All `<tag>…</tag>` inner blocks (raw, not decoded — for nested extraction). */
6
+ export declare function tagBlocks(xml: string, tag: string): string[];
@@ -0,0 +1,52 @@
1
+ // Dependency-free, edge-safe S3 XML helpers. Workers has no `DOMParser` and
2
+ // the AWS XML parser is banned, so we extract from S3's fixed, shallow,
3
+ // documented response shapes (ListBucketResult, Initiate/CompleteMultipartUpload,
4
+ // Error, ...) with anchored, ReDoS-free regexes. We never parse untrusted XML.
5
+ const ENTITIES = {
6
+ amp: '&',
7
+ lt: '<',
8
+ gt: '>',
9
+ quot: '"',
10
+ apos: "'"
11
+ };
12
+ export function unescapeXml(s) {
13
+ return s.replace(/&(#x?[0-9a-fA-F]+|amp|lt|gt|quot|apos);/g, (_m, e)=>{
14
+ if (e[0] === '#') {
15
+ const code = e[1] === 'x' ? Number.parseInt(e.slice(2), 16) : Number.parseInt(e.slice(1), 10);
16
+ return Number.isFinite(code) ? String.fromCodePoint(code) : _m;
17
+ }
18
+ return ENTITIES[e] ?? _m;
19
+ });
20
+ }
21
+ export function escapeXml(s) {
22
+ return s.replace(/[&<>"']/g, (c)=>{
23
+ switch(c){
24
+ case '&':
25
+ return '&amp;';
26
+ case '<':
27
+ return '&lt;';
28
+ case '>':
29
+ return '&gt;';
30
+ case '"':
31
+ return '&quot;';
32
+ default:
33
+ return '&apos;';
34
+ }
35
+ });
36
+ }
37
+ // Each pattern uses a single non-greedy `[\s\S]*?` bounded by a literal close
38
+ // tag → linear time, no nested quantifiers, no catastrophic backtracking.
39
+ function tagPattern(tag) {
40
+ return `<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`;
41
+ }
42
+ /** First `<tag>…</tag>` inner text (entity-decoded), or `undefined`. */ export function tagText(xml, tag) {
43
+ const m = new RegExp(tagPattern(tag)).exec(xml);
44
+ return m ? unescapeXml(m[1]) : undefined;
45
+ }
46
+ /** All `<tag>…</tag>` inner blocks (raw, not decoded — for nested extraction). */ export function tagBlocks(xml, tag) {
47
+ const re = new RegExp(tagPattern(tag), 'g');
48
+ const out = [];
49
+ let m;
50
+ while((m = re.exec(xml)) !== null)out.push(m[1]);
51
+ return out;
52
+ }
@@ -0,0 +1,59 @@
1
+ import type { Context } from 'hono';
2
+ /**
3
+ * The typed action a request wants to perform. The authorizer receives this
4
+ * and can deny, allow, or allow-with-overrides (rewriting the effective key /
5
+ * prefix / limits) — the load-bearing IDOR / cross-tenant guard.
6
+ */
7
+ export type StorageAction = {
8
+ type: 'sign-upload';
9
+ key: string;
10
+ contentType?: string;
11
+ size?: number;
12
+ } | {
13
+ type: 'multipart-create';
14
+ key: string;
15
+ contentType?: string;
16
+ } | {
17
+ type: 'multipart-sign-part';
18
+ key: string;
19
+ uploadId: string;
20
+ partNumber: number;
21
+ } | {
22
+ type: 'multipart-complete';
23
+ key: string;
24
+ uploadId: string;
25
+ } | {
26
+ type: 'multipart-abort';
27
+ key: string;
28
+ uploadId: string;
29
+ } | {
30
+ type: 'download';
31
+ key: string;
32
+ } | {
33
+ type: 'head';
34
+ key: string;
35
+ } | {
36
+ type: 'list';
37
+ prefix?: string;
38
+ } | {
39
+ type: 'delete';
40
+ keys: string[];
41
+ };
42
+ export interface StorageAuthContext {
43
+ /** The raw Web `Request` (edge-safe). */
44
+ req: Request;
45
+ /** The Hono context — `c.env` (bindings/secrets), `c.get('user')` from an upstream guard. */
46
+ ctx: Context;
47
+ /** The bucket name this controller is bound to. */
48
+ driver: string;
49
+ }
50
+ /** Return this to ALLOW with server-side modifications. */
51
+ export interface StorageAuthResult {
52
+ key?: string;
53
+ keys?: string[];
54
+ prefix?: string;
55
+ maxSize?: number;
56
+ expiresIn?: number;
57
+ metadata?: Record<string, string>;
58
+ }
59
+ export type StorageAuthorizer = (action: StorageAction, context: StorageAuthContext) => boolean | StorageAuthResult | Promise<boolean | StorageAuthResult>;
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,22 @@
1
+ import type { StorageErrorCode } from '../storage.error';
2
+ import type { ByteRange, StoredFile } from '../storage.types';
3
+ import type { StorageWireErrorCode } from './protocol.types';
4
+ /** Map a core error code to (wire code, HTTP status). */
5
+ export declare function mapError(code: StorageErrorCode): {
6
+ wire: StorageWireErrorCode;
7
+ status: number;
8
+ };
9
+ export declare function serializeStored(file: StoredFile): {
10
+ key: string;
11
+ name: string;
12
+ size: number;
13
+ type: string;
14
+ lastModified: number | undefined;
15
+ etag: string | undefined;
16
+ metadata: Record<string, string> | undefined;
17
+ };
18
+ export declare function clampExpiry(requested: number | undefined, def: number, max: number): number;
19
+ /** Parse an HTTP `Range` header (single range) into a {@link ByteRange}. */
20
+ export declare function parseRange(header: string | undefined | null): ByteRange | undefined;
21
+ /** Content-Disposition header — defaults to `attachment` (anti stored-XSS). */
22
+ export declare function dispositionHeader(disposition: string | undefined, name: string): string;
@@ -0,0 +1,80 @@
1
+ /** Map a core error code to (wire code, HTTP status). */ export function mapError(code) {
2
+ switch(code){
3
+ case 'NotFound':
4
+ return {
5
+ wire: 'not_found',
6
+ status: 404
7
+ };
8
+ case 'AccessDenied':
9
+ case 'ReadOnly':
10
+ return {
11
+ wire: 'forbidden',
12
+ status: 403
13
+ };
14
+ case 'Unsupported':
15
+ return {
16
+ wire: 'capability_unsupported',
17
+ status: 400
18
+ };
19
+ case 'InvalidKey':
20
+ return {
21
+ wire: 'invalid_key',
22
+ status: 400
23
+ };
24
+ case 'InvalidRequest':
25
+ case 'Aborted':
26
+ return {
27
+ wire: 'invalid_request',
28
+ status: 400
29
+ };
30
+ case 'Conflict':
31
+ return {
32
+ wire: 'conflict',
33
+ status: 409
34
+ };
35
+ case 'RateLimited':
36
+ return {
37
+ wire: 'rate_limited',
38
+ status: 429
39
+ };
40
+ case 'Timeout':
41
+ return {
42
+ wire: 'upstream_error',
43
+ status: 504
44
+ };
45
+ default:
46
+ return {
47
+ wire: 'upstream_error',
48
+ status: 502
49
+ };
50
+ }
51
+ }
52
+ export function serializeStored(file) {
53
+ return {
54
+ key: file.key,
55
+ name: file.name,
56
+ size: file.size,
57
+ type: file.type,
58
+ lastModified: file.lastModified,
59
+ etag: file.etag,
60
+ metadata: file.metadata
61
+ };
62
+ }
63
+ export function clampExpiry(requested, def, max) {
64
+ return Math.min(requested ?? def, max);
65
+ }
66
+ /** Parse an HTTP `Range` header (single range) into a {@link ByteRange}. */ export function parseRange(header) {
67
+ if (!header) return undefined;
68
+ const m = /^bytes=(\d+)-(\d*)$/.exec(header.trim());
69
+ if (!m) return undefined;
70
+ const start = Number(m[1]);
71
+ const end = m[2] === '' ? undefined : Number(m[2]);
72
+ return {
73
+ start,
74
+ end
75
+ };
76
+ }
77
+ /** Content-Disposition header — defaults to `attachment` (anti stored-XSS). */ export function dispositionHeader(disposition, name) {
78
+ const filename = `filename*=UTF-8''${encodeURIComponent(name)}`;
79
+ return disposition === 'inline' ? `inline; ${filename}` : `attachment; ${filename}`;
80
+ }