@filelayer/core 0.3.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 (69) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +202 -0
  3. package/MIGRATIONS.md +328 -0
  4. package/NOTICE +37 -0
  5. package/README.md +343 -0
  6. package/SEMANTICS.md +729 -0
  7. package/dist/authz.d.ts +524 -0
  8. package/dist/authz.d.ts.map +1 -0
  9. package/dist/authz.js +889 -0
  10. package/dist/authz.js.map +1 -0
  11. package/dist/db.d.ts +145 -0
  12. package/dist/db.d.ts.map +1 -0
  13. package/dist/db.js +217 -0
  14. package/dist/db.js.map +1 -0
  15. package/dist/delivery.d.ts +293 -0
  16. package/dist/delivery.d.ts.map +1 -0
  17. package/dist/delivery.js +519 -0
  18. package/dist/delivery.js.map +1 -0
  19. package/dist/errors.d.ts +16 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +21 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/filelayer.d.ts +542 -0
  24. package/dist/filelayer.d.ts.map +1 -0
  25. package/dist/filelayer.js +1360 -0
  26. package/dist/filelayer.js.map +1 -0
  27. package/dist/index.d.ts +8 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +8 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/simple.d.ts +297 -0
  32. package/dist/simple.d.ts.map +1 -0
  33. package/dist/simple.js +492 -0
  34. package/dist/simple.js.map +1 -0
  35. package/dist/storage.d.ts +269 -0
  36. package/dist/storage.d.ts.map +1 -0
  37. package/dist/storage.js +700 -0
  38. package/dist/storage.js.map +1 -0
  39. package/dist/store.d.ts +432 -0
  40. package/dist/store.d.ts.map +1 -0
  41. package/dist/store.js +862 -0
  42. package/dist/store.js.map +1 -0
  43. package/package.json +77 -0
  44. package/schema.sql +1190 -0
  45. package/src/authz.ts +1398 -0
  46. package/src/db.ts +271 -0
  47. package/src/delivery.ts +737 -0
  48. package/src/errors.ts +24 -0
  49. package/src/filelayer.ts +1836 -0
  50. package/src/index.ts +7 -0
  51. package/src/simple.ts +666 -0
  52. package/src/storage.ts +917 -0
  53. package/src/store.ts +1072 -0
  54. package/test/delivery.test.ts +0 -0
  55. package/test/group-subjects.test.ts +1072 -0
  56. package/test/helpers.ts +65 -0
  57. package/test/listing.test.ts +689 -0
  58. package/test/local-s3.d.mts +33 -0
  59. package/test/local-s3.mjs +400 -0
  60. package/test/persistence.test.ts +953 -0
  61. package/test/regression.test.ts +619 -0
  62. package/test/s3-live.test.ts +322 -0
  63. package/test/security.test.ts +1652 -0
  64. package/test/semantics.test.ts +888 -0
  65. package/test/storage.test.ts +437 -0
  66. package/test/tiers.test.ts +432 -0
  67. package/test/vault-example.test.ts +302 -0
  68. package/tsconfig.build.json +29 -0
  69. package/tsconfig.json +19 -0
@@ -0,0 +1,33 @@
1
+ /** Types for the JS test harness. Test-only; excluded from LOC counts. */
2
+ export interface LocalS3Object {
3
+ body: Buffer;
4
+ contentType: string;
5
+ lastModified: Date;
6
+ }
7
+ export interface LocalS3LogEntry {
8
+ method: string;
9
+ key: string;
10
+ query: Record<string, string>;
11
+ presigned: boolean;
12
+ at: number;
13
+ }
14
+ export interface LocalS3 {
15
+ server: import('node:http').Server;
16
+ objects: Map<string, LocalS3Object>;
17
+ uploads: Map<string, unknown>;
18
+ requestLog: LocalS3LogEntry[];
19
+ listen(port?: number): Promise<number>;
20
+ close(): Promise<void>;
21
+ endpoint(): string;
22
+ bucket: string;
23
+ deactivateAccessKey(id: string): void;
24
+ activateAccessKey(id: string): void;
25
+ injectFault(f: { key?: string; method?: string; status?: number; code?: string }): void;
26
+ clearFaults(): void;
27
+ }
28
+ export function createLocalS3(opts: {
29
+ accessKeyId: string;
30
+ secretAccessKey: string;
31
+ bucket?: string;
32
+ now?: () => number;
33
+ }): LocalS3;
@@ -0,0 +1,400 @@
1
+ /**
2
+ * TEST HARNESS -- NOT APPLICATION CODE. Excluded from all LOC counts.
3
+ *
4
+ * A local S3-protocol object store that performs REAL AWS Signature Version 4
5
+ * verification of both header-signed and presigned requests, and implements
6
+ * enough of the API surface that `S3Storage` can be exercised end to end
7
+ * without credentials:
8
+ *
9
+ * PUT / GET / HEAD / DELETE, ranged GET (206 + Content-Range, 416),
10
+ * ListObjectsV2 with continuation tokens,
11
+ * CreateMultipartUpload / UploadPart / CompleteMultipartUpload /
12
+ * AbortMultipartUpload,
13
+ * presigned GET including expiry, response-content-type and
14
+ * response-content-disposition overrides.
15
+ *
16
+ * WHAT MAKES IT WORTH ANYTHING. It recomputes the signature from the request as
17
+ * RECEIVED -- the raw encoded path, the raw query string, the actual header
18
+ * values named in SignedHeaders -- and returns 403 SignatureDoesNotMatch on any
19
+ * mismatch. It also verifies that `x-amz-content-sha256` matches the body that
20
+ * actually arrived. A client that signs one string and sends another fails here
21
+ * exactly as it would fail against AWS.
22
+ *
23
+ * It also enforces the rules that bite in production and never bite in a mock:
24
+ * - every part except the last must be >= 5 MiB (EntityTooSmall)
25
+ * - completing with an ETag that does not match the stored part is InvalidPart
26
+ * - an unknown uploadId is NoSuchUpload
27
+ * - an expired presigned URL is AccessDenied
28
+ * - a request with no date or a skewed date is RequestTimeTooSkewed
29
+ *
30
+ * DERIVED FROM an earlier S3 test harness in this repository, which verified
31
+ * presigned signatures only and stubbed header-signed ones. That gap is exactly
32
+ * where our adapter's bugs were, so it is closed here.
33
+ */
34
+ import http from 'node:http';
35
+ import crypto from 'node:crypto';
36
+
37
+ const ALGO = 'AWS4-HMAC-SHA256';
38
+ const MIN_PART = 5 * 1024 * 1024;
39
+ const MAX_SKEW_MS = 15 * 60 * 1000;
40
+
41
+ const hmac = (key, data) => crypto.createHmac('sha256', key).update(data, 'utf8').digest();
42
+ const sha256hex = (data) => crypto.createHash('sha256').update(data).digest('hex');
43
+ const signingKey = (secret, date, region, service) =>
44
+ hmac(hmac(hmac(hmac('AWS4' + secret, date), region), service), 'aws4_request');
45
+
46
+ const rfc3986 = (s) =>
47
+ encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
48
+
49
+ /** Sorted, re-encoded canonical query string, derived from the RAW query. */
50
+ function canonicalQuery(rawQuery) {
51
+ if (!rawQuery) return '';
52
+ return rawQuery
53
+ .split('&')
54
+ .filter(Boolean)
55
+ .map((pair) => {
56
+ const i = pair.indexOf('=');
57
+ const k = i < 0 ? pair : pair.slice(0, i);
58
+ const v = i < 0 ? '' : pair.slice(i + 1);
59
+ return [rfc3986(decodeURIComponent(k)), rfc3986(decodeURIComponent(v))];
60
+ })
61
+ .filter(([k]) => k !== 'X-Amz-Signature')
62
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0))
63
+ .map(([k, v]) => `${k}=${v}`)
64
+ .join('&');
65
+ }
66
+
67
+ function parseAmzDate(amzDate) {
68
+ if (!/^\d{8}T\d{6}Z$/.test(amzDate)) return NaN;
69
+ return Date.UTC(
70
+ +amzDate.slice(0, 4),
71
+ +amzDate.slice(4, 6) - 1,
72
+ +amzDate.slice(6, 8),
73
+ +amzDate.slice(9, 11),
74
+ +amzDate.slice(11, 13),
75
+ +amzDate.slice(13, 15),
76
+ );
77
+ }
78
+
79
+ export function createLocalS3({ accessKeyId, secretAccessKey, bucket = 'test-bucket', now = Date.now } = {}) {
80
+ /** @type {Map<string, {body: Buffer, contentType: string, lastModified: Date}>} */
81
+ const objects = new Map();
82
+ /** @type {Map<string, {key: string, contentType: string, parts: Map<number,{body:Buffer,etag:string}>}>} */
83
+ const uploads = new Map();
84
+ const credentials = new Map([[accessKeyId, { secretAccessKey, active: true }]]);
85
+ const requestLog = [];
86
+ /** Faults the test can inject, e.g. { key: 'a/b', method: 'PUT', status: 500, code: 'InternalError' } */
87
+ let faults = [];
88
+
89
+ const etagOf = (buf) => `"${crypto.createHash('md5').update(buf).digest('hex')}"`;
90
+
91
+ /** Recompute the signature over the request AS RECEIVED. */
92
+ function verify(req, rawPath, rawQuery, body) {
93
+ const q = new URLSearchParams(rawQuery);
94
+ const presigned = q.has('X-Amz-Signature');
95
+
96
+ let credential, amzDate, signedHeaders, signature, payloadHash;
97
+ if (presigned) {
98
+ credential = q.get('X-Amz-Credential') ?? '';
99
+ amzDate = q.get('X-Amz-Date') ?? '';
100
+ signedHeaders = q.get('X-Amz-SignedHeaders') ?? 'host';
101
+ signature = q.get('X-Amz-Signature');
102
+ payloadHash = 'UNSIGNED-PAYLOAD';
103
+ if (q.get('X-Amz-Algorithm') !== ALGO) return { code: 'InvalidRequest', status: 400 };
104
+ } else {
105
+ const auth = String(req.headers.authorization ?? '');
106
+ if (!auth.startsWith(ALGO + ' ')) return { code: 'AccessDenied', status: 403 };
107
+ const cred = /Credential=([^,\s]+)/.exec(auth);
108
+ const sh = /SignedHeaders=([^,\s]+)/.exec(auth);
109
+ const sig = /Signature=([0-9a-f]+)/.exec(auth);
110
+ if (!cred || !sh || !sig) return { code: 'AuthorizationHeaderMalformed', status: 400 };
111
+ credential = cred[1];
112
+ signedHeaders = sh[1];
113
+ signature = sig[1];
114
+ amzDate = String(req.headers['x-amz-date'] ?? '');
115
+ payloadHash = String(req.headers['x-amz-content-sha256'] ?? '');
116
+ if (!payloadHash) return { code: 'MissingSecurityHeader', status: 400 };
117
+ // Real S3 rejects a body that does not match the declared payload hash.
118
+ if (payloadHash !== 'UNSIGNED-PAYLOAD' && payloadHash !== sha256hex(body)) {
119
+ return { code: 'XAmzContentSHA256Mismatch', status: 400 };
120
+ }
121
+ }
122
+
123
+ const [keyId, dateStamp, credRegion, service] = String(credential).split('/');
124
+ const c = credentials.get(keyId);
125
+ if (!c || !c.active) return { code: 'InvalidAccessKeyId', status: 403 };
126
+
127
+ const signedAt = parseAmzDate(amzDate);
128
+ if (Number.isNaN(signedAt)) return { code: 'AuthorizationHeaderMalformed', status: 400 };
129
+ if (presigned) {
130
+ const expires = parseInt(q.get('X-Amz-Expires') ?? '0', 10);
131
+ if (now() > signedAt + expires * 1000) return { code: 'AccessDenied', status: 403, expired: true };
132
+ } else if (Math.abs(now() - signedAt) > MAX_SKEW_MS) {
133
+ return { code: 'RequestTimeTooSkewed', status: 403 };
134
+ }
135
+
136
+ // `host` must be verified against what the client actually sent, because
137
+ // that is what AWS does and it is how a virtual-host/path-style mix-up is
138
+ // caught.
139
+ const canonicalHeaders = String(signedHeaders)
140
+ .split(';')
141
+ .map((h) => `${h}:${String(req.headers[h] ?? '').trim().replace(/\s+/g, ' ')}\n`)
142
+ .join('');
143
+
144
+ const canonicalRequest = [
145
+ req.method,
146
+ rawPath,
147
+ canonicalQuery(rawQuery),
148
+ canonicalHeaders,
149
+ signedHeaders,
150
+ payloadHash,
151
+ ].join('\n');
152
+
153
+ const scope = `${dateStamp}/${credRegion}/${service}/aws4_request`;
154
+ const stringToSign = [ALGO, amzDate, scope, sha256hex(canonicalRequest)].join('\n');
155
+ const expected = crypto
156
+ .createHmac('sha256', signingKey(c.secretAccessKey, dateStamp, credRegion, service))
157
+ .update(stringToSign, 'utf8')
158
+ .digest('hex');
159
+
160
+ if (expected !== signature) {
161
+ return { code: 'SignatureDoesNotMatch', status: 403, canonicalRequest, expected, got: signature };
162
+ }
163
+ return { ok: true, presigned, query: q };
164
+ }
165
+
166
+ const server = http.createServer((req, res) => {
167
+ const qIndex = (req.url ?? '').indexOf('?');
168
+ const rawPath = qIndex < 0 ? req.url : req.url.slice(0, qIndex);
169
+ const rawQuery = qIndex < 0 ? '' : req.url.slice(qIndex + 1);
170
+
171
+ const chunks = [];
172
+ req.on('data', (c) => chunks.push(c));
173
+ req.on('end', () => {
174
+ const body = Buffer.concat(chunks);
175
+ try {
176
+ handle(req, res, rawPath, rawQuery, body);
177
+ } catch (err) {
178
+ xml(res, 500, 'InternalError', String(err && err.message));
179
+ }
180
+ });
181
+ });
182
+
183
+ function xml(res, status, code, message = '') {
184
+ const b = Buffer.from(
185
+ `<?xml version="1.0" encoding="UTF-8"?><Error><Code>${code}</Code><Message>${message}</Message></Error>`,
186
+ );
187
+ res.writeHead(status, { 'content-type': 'application/xml', 'content-length': String(b.length) });
188
+ res.end(b);
189
+ }
190
+
191
+ function handle(req, res, rawPath, rawQuery, body) {
192
+ // Path-style: /<bucket>/<key...>
193
+ const decodedPath = decodeURIComponent(rawPath);
194
+ const segs = decodedPath.replace(/^\//, '').split('/');
195
+ const reqBucket = segs.shift();
196
+ const key = segs.join('/');
197
+ const q = new URLSearchParams(rawQuery);
198
+
199
+ requestLog.push({
200
+ method: req.method,
201
+ key,
202
+ query: Object.fromEntries(q.entries()),
203
+ presigned: q.has('X-Amz-Signature'),
204
+ at: now(),
205
+ });
206
+
207
+ const v = verify(req, rawPath, rawQuery, body);
208
+ if (!v.ok) {
209
+ if (v.code === 'SignatureDoesNotMatch') {
210
+ // Surfaced to the test runner, because a silent 403 in a signing test is
211
+ // an afternoon lost.
212
+ server.emit('sigfail', v);
213
+ }
214
+ return xml(res, v.status, v.code, v.canonicalRequest ? 'canonical request mismatch' : '');
215
+ }
216
+ if (reqBucket !== bucket) return xml(res, 404, 'NoSuchBucket', reqBucket ?? '');
217
+
218
+ for (const f of faults) {
219
+ if ((f.key === undefined || f.key === key) && (f.method === undefined || f.method === req.method)) {
220
+ return xml(res, f.status ?? 500, f.code ?? 'InternalError', 'injected fault');
221
+ }
222
+ }
223
+
224
+ // --- ListObjectsV2 ------------------------------------------------------
225
+ if (req.method === 'GET' && key === '' && q.get('list-type') === '2') {
226
+ const prefix = q.get('prefix') ?? '';
227
+ const max = Math.min(Number(q.get('max-keys') ?? 1000), 1000);
228
+ const after = q.get('continuation-token') ?? '';
229
+ const all = [...objects.entries()]
230
+ .filter(([k]) => k.startsWith(prefix) && k > after)
231
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
232
+ const page = all.slice(0, max);
233
+ const truncated = all.length > max;
234
+ const b = Buffer.from(
235
+ `<?xml version="1.0" encoding="UTF-8"?><ListBucketResult>` +
236
+ `<Name>${bucket}</Name><Prefix>${prefix}</Prefix>` +
237
+ `<KeyCount>${page.length}</KeyCount><MaxKeys>${max}</MaxKeys>` +
238
+ `<IsTruncated>${truncated}</IsTruncated>` +
239
+ (truncated ? `<NextContinuationToken>${page[page.length - 1][0]}</NextContinuationToken>` : '') +
240
+ page
241
+ .map(
242
+ ([k, o]) =>
243
+ `<Contents><Key>${k.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</Key>` +
244
+ `<LastModified>${o.lastModified.toISOString()}</LastModified>` +
245
+ `<ETag>${etagOf(o.body).replace(/"/g, '&quot;')}</ETag>` +
246
+ `<Size>${o.body.length}</Size></Contents>`,
247
+ )
248
+ .join('') +
249
+ `</ListBucketResult>`,
250
+ );
251
+ res.writeHead(200, { 'content-type': 'application/xml', 'content-length': String(b.length) });
252
+ return res.end(b);
253
+ }
254
+
255
+ // --- Multipart ----------------------------------------------------------
256
+ if (req.method === 'POST' && q.has('uploads')) {
257
+ const uploadId = crypto.randomUUID();
258
+ uploads.set(uploadId, {
259
+ key,
260
+ contentType: String(req.headers['content-type'] ?? 'application/octet-stream'),
261
+ parts: new Map(),
262
+ });
263
+ const b = Buffer.from(
264
+ `<?xml version="1.0" encoding="UTF-8"?><InitiateMultipartUploadResult>` +
265
+ `<Bucket>${bucket}</Bucket><Key>${key}</Key><UploadId>${uploadId}</UploadId>` +
266
+ `</InitiateMultipartUploadResult>`,
267
+ );
268
+ res.writeHead(200, { 'content-type': 'application/xml', 'content-length': String(b.length) });
269
+ return res.end(b);
270
+ }
271
+
272
+ if (req.method === 'PUT' && q.has('uploadId')) {
273
+ const up = uploads.get(q.get('uploadId'));
274
+ if (!up) return xml(res, 404, 'NoSuchUpload');
275
+ const n = Number(q.get('partNumber'));
276
+ if (!Number.isInteger(n) || n < 1 || n > 10000) return xml(res, 400, 'InvalidArgument');
277
+ const etag = etagOf(body);
278
+ up.parts.set(n, { body, etag });
279
+ res.writeHead(200, { ETag: etag, 'content-length': '0' });
280
+ return res.end();
281
+ }
282
+
283
+ if (req.method === 'DELETE' && q.has('uploadId')) {
284
+ uploads.delete(q.get('uploadId'));
285
+ res.writeHead(204).end();
286
+ return;
287
+ }
288
+
289
+ if (req.method === 'POST' && q.has('uploadId')) {
290
+ const uploadId = q.get('uploadId');
291
+ const up = uploads.get(uploadId);
292
+ if (!up) return xml(res, 404, 'NoSuchUpload');
293
+ const doc = body.toString('utf8');
294
+ const listed = [...doc.matchAll(/<Part><PartNumber>(\d+)<\/PartNumber><ETag>(.*?)<\/ETag><\/Part>/g)].map(
295
+ (m) => ({ n: Number(m[1]), etag: m[2].replace(/&quot;/g, '"') }),
296
+ );
297
+ if (listed.length === 0) return xml(res, 400, 'MalformedXML');
298
+ for (let i = 1; i < listed.length; i++) {
299
+ if (listed[i].n <= listed[i - 1].n) return xml(res, 400, 'InvalidPartOrder');
300
+ }
301
+ const buffers = [];
302
+ for (let i = 0; i < listed.length; i++) {
303
+ const p = up.parts.get(listed[i].n);
304
+ if (!p) return xml(res, 400, 'InvalidPart', `part ${listed[i].n}`);
305
+ if (p.etag !== listed[i].etag) return xml(res, 400, 'InvalidPart', 'etag mismatch');
306
+ // The rule that only ever fires in production.
307
+ if (i < listed.length - 1 && p.body.length < MIN_PART) {
308
+ return xml(res, 400, 'EntityTooSmall', `part ${listed[i].n} is ${p.body.length} bytes`);
309
+ }
310
+ buffers.push(p.body);
311
+ }
312
+ const full = Buffer.concat(buffers);
313
+ objects.set(up.key, { body: full, contentType: up.contentType, lastModified: new Date(now()) });
314
+ uploads.delete(uploadId);
315
+ const b = Buffer.from(
316
+ `<?xml version="1.0" encoding="UTF-8"?><CompleteMultipartUploadResult>` +
317
+ `<Location>http://localhost/${bucket}/${up.key}</Location><Bucket>${bucket}</Bucket>` +
318
+ `<Key>${up.key}</Key><ETag>&quot;${crypto.createHash('md5').update(full).digest('hex')}-${listed.length}&quot;</ETag>` +
319
+ `</CompleteMultipartUploadResult>`,
320
+ );
321
+ res.writeHead(200, { 'content-type': 'application/xml', 'content-length': String(b.length) });
322
+ return res.end(b);
323
+ }
324
+
325
+ // --- Single-object operations ------------------------------------------
326
+ if (req.method === 'PUT') {
327
+ objects.set(key, {
328
+ body,
329
+ contentType: String(req.headers['content-type'] ?? 'application/octet-stream'),
330
+ lastModified: new Date(now()),
331
+ });
332
+ res.writeHead(200, { ETag: etagOf(body), 'content-length': '0' });
333
+ return res.end();
334
+ }
335
+
336
+ if (req.method === 'DELETE') {
337
+ objects.delete(key);
338
+ res.writeHead(204).end();
339
+ return;
340
+ }
341
+
342
+ const obj = objects.get(key);
343
+ if (!obj) return xml(res, 404, 'NoSuchKey', key);
344
+
345
+ // Presigned response-header overrides: the store, not the client, decides.
346
+ const ct = v.presigned ? (v.query.get('response-content-type') ?? obj.contentType) : obj.contentType;
347
+ const cd = v.presigned ? v.query.get('response-content-disposition') : null;
348
+
349
+ const range = String(req.headers.range ?? '');
350
+ const m = /^bytes=(\d*)-(\d*)$/.exec(range);
351
+ if (m) {
352
+ const total = obj.body.length;
353
+ let start = m[1] === '' ? total - Number(m[2]) : Number(m[1]);
354
+ let end = m[1] === '' ? total - 1 : m[2] === '' ? total - 1 : Number(m[2]);
355
+ if (Number.isNaN(start) || start >= total || start < 0) {
356
+ res.writeHead(416, { 'content-range': `bytes */${total}` });
357
+ return res.end();
358
+ }
359
+ end = Math.min(end, total - 1);
360
+ const slice = obj.body.subarray(start, end + 1);
361
+ const headers = {
362
+ 'content-type': ct,
363
+ 'content-length': String(slice.length),
364
+ 'content-range': `bytes ${start}-${end}/${total}`,
365
+ ETag: etagOf(obj.body),
366
+ 'last-modified': obj.lastModified.toUTCString(),
367
+ };
368
+ if (cd) headers['content-disposition'] = cd;
369
+ res.writeHead(206, headers);
370
+ return req.method === 'HEAD' ? res.end() : res.end(slice);
371
+ }
372
+
373
+ const headers = {
374
+ 'content-type': ct,
375
+ 'content-length': String(obj.body.length),
376
+ ETag: etagOf(obj.body),
377
+ 'last-modified': obj.lastModified.toUTCString(),
378
+ 'accept-ranges': 'bytes',
379
+ };
380
+ if (cd) headers['content-disposition'] = cd;
381
+ res.writeHead(200, headers);
382
+ return req.method === 'HEAD' ? res.end() : res.end(obj.body);
383
+ }
384
+
385
+ return {
386
+ server,
387
+ objects,
388
+ uploads,
389
+ requestLog,
390
+ listen: (port = 0) =>
391
+ new Promise((r) => server.listen(port, '127.0.0.1', () => r(server.address().port))),
392
+ close: () => new Promise((r) => server.close(r)),
393
+ endpoint: () => `http://127.0.0.1:${server.address().port}`,
394
+ bucket,
395
+ deactivateAccessKey: (id) => { credentials.get(id).active = false; },
396
+ activateAccessKey: (id) => { credentials.get(id).active = true; },
397
+ injectFault: (f) => { faults.push(f); },
398
+ clearFaults: () => { faults = []; },
399
+ };
400
+ }