@ultimat3/storage 24.0.0 → 25.1.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.
- package/CLAUDE.md +36 -40
- package/README.md +86 -17
- package/package.json +3 -3
- package/src/attachment.ts +6 -9
- package/src/driver-local-read.ts +19 -9
- package/src/driver-local-sidecar.ts +61 -3
- package/src/driver-local.ts +98 -33
- package/src/driver-memory-conflict.ts +45 -0
- package/src/driver-memory.ts +75 -16
- package/src/driver-s3-absent.ts +32 -0
- package/src/driver-s3-client.ts +94 -0
- package/src/driver-s3-lock.ts +131 -0
- package/src/driver-s3-signed.ts +220 -0
- package/src/driver-s3.ts +95 -155
- package/src/driver.ts +36 -19
- package/src/errors.ts +23 -6
- package/src/index.ts +15 -7
- package/src/object-lock.ts +173 -0
- package/src/signed-url.ts +0 -4
- package/src/signing-secret.ts +12 -7
- package/src/upload.ts +51 -0
package/src/driver-s3.ts
CHANGED
|
@@ -5,19 +5,21 @@
|
|
|
5
5
|
// credentials arrive as env var NAMES: a literal key in app.config.ts is a key in git.
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
-
|
|
9
|
-
EnvMissingError,
|
|
8
|
+
type Clock,
|
|
10
9
|
finiteCount,
|
|
10
|
+
isUltimateError,
|
|
11
11
|
NotImplementedError,
|
|
12
|
+
type Page,
|
|
13
|
+
pageOf,
|
|
14
|
+
renderFixLiteral,
|
|
12
15
|
renderFixShellArg,
|
|
13
|
-
|
|
16
|
+
systemClock,
|
|
14
17
|
} from '@ultimat3/core';
|
|
15
18
|
import {
|
|
16
19
|
assertListOptions,
|
|
17
20
|
assertPutOptions,
|
|
18
21
|
DEFAULT_CONTENT_TYPE,
|
|
19
22
|
type ListOptions,
|
|
20
|
-
type ListPage,
|
|
21
23
|
type PutOptions,
|
|
22
24
|
resolveListLimit,
|
|
23
25
|
type SignedUrlOptions,
|
|
@@ -29,7 +31,11 @@ import {
|
|
|
29
31
|
sha256Base64,
|
|
30
32
|
toBytes,
|
|
31
33
|
} from './driver';
|
|
34
|
+
import { isAbsentObject } from './driver-s3-absent';
|
|
35
|
+
import { buildClient, type S3ClientLike, type S3ListResultLike } from './driver-s3-client';
|
|
36
|
+
import { needsSignedPut, readObjectLock, signedPut } from './driver-s3-lock';
|
|
32
37
|
import { regionMismatch } from './driver-s3-region';
|
|
38
|
+
import type { S3FetchLike } from './driver-s3-signed';
|
|
33
39
|
import {
|
|
34
40
|
checksumMismatch,
|
|
35
41
|
deleteFailed,
|
|
@@ -40,53 +46,13 @@ import {
|
|
|
40
46
|
putFailed,
|
|
41
47
|
readFailed,
|
|
42
48
|
} from './errors';
|
|
49
|
+
import { assertObjectLockOptions, type ObjectLock } from './object-lock';
|
|
43
50
|
import { assertSafeKey } from './path';
|
|
44
51
|
import { DEFAULT_SIGNED_URL_TTL_MS } from './signed-url';
|
|
45
52
|
import { DEFAULT_MAX_UPLOAD_BYTES } from './upload';
|
|
46
53
|
|
|
47
54
|
const DRIVER_NAME = 's3';
|
|
48
55
|
|
|
49
|
-
/** Structural view of `Bun.S3Client` — typing it here keeps `bun-types` out of the contract. */
|
|
50
|
-
export interface S3FileLike {
|
|
51
|
-
/** `S3FileLike` is in the union because Bun's own `write` takes an `S3File` — that is `copy`. */
|
|
52
|
-
write(data: Uint8Array | Blob | S3FileLike, options?: { type?: string }): Promise<number>;
|
|
53
|
-
arrayBuffer(): Promise<ArrayBuffer>;
|
|
54
|
-
exists(): Promise<boolean>;
|
|
55
|
-
delete(): Promise<void>;
|
|
56
|
-
stream(): ReadableStream<Uint8Array>;
|
|
57
|
-
stat(): Promise<S3StatLike>;
|
|
58
|
-
presign(options: { method?: string; expiresIn?: number; type?: string }): string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface S3StatLike {
|
|
62
|
-
readonly size: number;
|
|
63
|
-
readonly type?: string | undefined;
|
|
64
|
-
readonly etag?: string | undefined;
|
|
65
|
-
readonly lastModified?: string | Date | undefined;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface S3ListEntryLike {
|
|
69
|
-
readonly key?: string | undefined;
|
|
70
|
-
readonly size?: number | undefined;
|
|
71
|
-
readonly eTag?: string | undefined;
|
|
72
|
-
readonly lastModified?: string | Date | undefined;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface S3ListResultLike {
|
|
76
|
-
readonly contents?: readonly S3ListEntryLike[] | undefined;
|
|
77
|
-
readonly isTruncated?: boolean | undefined;
|
|
78
|
-
readonly nextContinuationToken?: string | undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface S3ClientLike {
|
|
82
|
-
file(key: string): S3FileLike;
|
|
83
|
-
list(input: {
|
|
84
|
-
prefix?: string;
|
|
85
|
-
maxKeys?: number;
|
|
86
|
-
continuationToken?: string;
|
|
87
|
-
}): Promise<S3ListResultLike>;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
56
|
export interface S3DriverOptions {
|
|
91
57
|
readonly bucket: string;
|
|
92
58
|
/**
|
|
@@ -107,6 +73,15 @@ export interface S3DriverOptions {
|
|
|
107
73
|
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
108
74
|
/** Injected in tests; production constructs `Bun.S3Client`. */
|
|
109
75
|
readonly client?: S3ClientLike | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* Injected in tests; production uses the global `fetch`. Carries the SIGNED requests the Bun
|
|
78
|
+
* client has no option for — a `put()` with `metadata`, `cacheControl`, `retention` or
|
|
79
|
+
* `legalHold`, and `retentionOf()` — signed by core's `signAwsRequest` against the same
|
|
80
|
+
* endpoint, region and credentials.
|
|
81
|
+
*/
|
|
82
|
+
readonly fetch?: S3FetchLike | undefined;
|
|
83
|
+
/** Signs the signed requests and screens a `retainUntil`. Default `systemClock`. */
|
|
84
|
+
readonly clock?: Clock | undefined;
|
|
110
85
|
/**
|
|
111
86
|
* Ceiling on ONE server-side `put()`, because `put()` buffers the whole body. Defaults to the
|
|
112
87
|
* upload policy's ceiling — the same number for the same fact. Raise it for a disk that really
|
|
@@ -122,59 +97,6 @@ export interface S3DriverOptions {
|
|
|
122
97
|
readonly maxGetBytes?: number | undefined;
|
|
123
98
|
}
|
|
124
99
|
|
|
125
|
-
interface S3ClientConstructor {
|
|
126
|
-
new (options: Record<string, unknown>): S3ClientLike;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function requireEnv(
|
|
130
|
-
env: Readonly<Record<string, string | undefined>>,
|
|
131
|
-
name: string,
|
|
132
|
-
partner: string,
|
|
133
|
-
): string {
|
|
134
|
-
const value = env[name];
|
|
135
|
-
if (value === undefined || value === '') {
|
|
136
|
-
throw new EnvMissingError({
|
|
137
|
-
cause: `${name} is not set, so the s3 disk cannot authenticate`,
|
|
138
|
-
fix: `set ${name} and ${partner} in .env (or the container's secret store), then re-run`,
|
|
139
|
-
meta: { missing: name },
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
return value;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function buildClient(options: S3DriverOptions): S3ClientLike {
|
|
146
|
-
if (options.client !== undefined) return options.client;
|
|
147
|
-
if (options.bucket === '') {
|
|
148
|
-
throw new ConfigInvalidError({
|
|
149
|
-
cause: 's3 disk was defined without a bucket',
|
|
150
|
-
fix: 'set storage.disks.<name>.bucket in app.config.ts',
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
const env = options.env ?? process.env;
|
|
154
|
-
const idVar = options.accessKeyIdEnv ?? 'S3_ACCESS_KEY_ID';
|
|
155
|
-
const secretVar = options.secretAccessKeyEnv ?? 'S3_SECRET_ACCESS_KEY';
|
|
156
|
-
const Client = (Bun as unknown as { S3Client?: S3ClientConstructor }).S3Client;
|
|
157
|
-
if (Client === undefined) {
|
|
158
|
-
throw new ConfigInvalidError({
|
|
159
|
-
cause: 'Bun.S3Client is unavailable in this runtime',
|
|
160
|
-
fix: 'upgrade the runtime: bun upgrade # the s3 disk needs bun >= 1.3',
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
const tokenVar = options.sessionTokenEnv;
|
|
164
|
-
const sessionToken = tokenVar === undefined ? undefined : env[tokenVar];
|
|
165
|
-
// Bun's flag is the inverse: path style means "not virtual hosted".
|
|
166
|
-
const pathStyle = options.forcePathStyle;
|
|
167
|
-
return new Client({
|
|
168
|
-
bucket: options.bucket,
|
|
169
|
-
accessKeyId: requireEnv(env, idVar, secretVar),
|
|
170
|
-
secretAccessKey: requireEnv(env, secretVar, idVar),
|
|
171
|
-
...(options.region === undefined ? {} : { region: options.region }),
|
|
172
|
-
...(options.endpoint === undefined ? {} : { endpoint: options.endpoint }),
|
|
173
|
-
...(pathStyle === undefined ? {} : { virtualHostedStyle: !pathStyle }),
|
|
174
|
-
...(sessionToken === undefined ? {} : { sessionToken }),
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
100
|
/**
|
|
179
101
|
* A missing `LastModified` is ABSENT, never `new Date(0)`: epoch 0 is older than every window, so
|
|
180
102
|
* `sweepOrphans` deleted an upload on the strength of a field the provider never sent.
|
|
@@ -182,59 +104,27 @@ function buildClient(options: S3DriverOptions): S3ClientLike {
|
|
|
182
104
|
const dated = (value: string | Date | undefined): { readonly lastModified?: Date } =>
|
|
183
105
|
value === undefined ? {} : { lastModified: value instanceof Date ? value : new Date(value) };
|
|
184
106
|
|
|
185
|
-
/** One numeric field off a value that may fight being read — `stringField`'s missing twin. */
|
|
186
|
-
function numberField(value: unknown, key: string): number | undefined {
|
|
187
|
-
if (typeof value !== 'object' || value === null) return undefined;
|
|
188
|
-
try {
|
|
189
|
-
const held = (value as Record<string, unknown>)[key];
|
|
190
|
-
return typeof held === 'number' ? held : undefined;
|
|
191
|
-
} catch {
|
|
192
|
-
return undefined;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** The provider codes that mean "there is nothing at that key", and nothing wider. */
|
|
197
|
-
const ABSENT_OBJECT_CODES: ReadonlySet<string> = new Set(['NoSuchKey', 'NotFound', 'ENOENT']);
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* The ONE delete failure the contract calls success. `AccessDenied`, `SlowDown`, an expired
|
|
201
|
-
* credential and a reset connection are none of them, and the previous `.catch(() => undefined)`
|
|
202
|
-
* reported all four as deleted — which is how an erasure sweep certifies data it never removed.
|
|
203
|
-
*
|
|
204
|
-
* Read structurally: an `S3Error` is `name: 'S3Error'` with a `code` the service returned, and
|
|
205
|
-
* every field of a value this process did not build is a getter that can throw.
|
|
206
|
-
*/
|
|
207
|
-
function isAbsentObject(error: unknown): boolean {
|
|
208
|
-
const code = stringField(error, 'code');
|
|
209
|
-
if (code !== undefined && ABSENT_OBJECT_CODES.has(code)) return true;
|
|
210
|
-
return numberField(error, 'statusCode') === 404 || numberField(error, 'status') === 404;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
107
|
/**
|
|
214
108
|
* Everything `put()` may be handed that this driver cannot honour, refused before a byte moves.
|
|
215
109
|
* A typed refusal at the call site is the whole point: `serverSideEncryption` exists on
|
|
216
110
|
* `PutOptions` so that "this disk cannot prove per-object encryption" is something an engineer
|
|
217
111
|
* meets while writing the call, not while answering a security review.
|
|
218
112
|
*/
|
|
219
|
-
function refuseUnsupportedPut(bucket: string,
|
|
220
|
-
if (putOptions?.metadata !== undefined || putOptions?.cacheControl !== undefined) {
|
|
221
|
-
const uri = renderFixShellArg(`s3://${bucket}/${key}`, '<s3-uri>');
|
|
222
|
-
throw new NotImplementedError({
|
|
223
|
-
cause:
|
|
224
|
-
'user metadata and cache-control on the s3 driver (Bun exposes no header hook yet) is not implemented by this driver — drop metadata/cacheControl from put(), or set them out of band with the command in fix',
|
|
225
|
-
fix: `aws s3 cp ${uri} ${uri} --metadata-directive REPLACE`,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
113
|
+
function refuseUnsupportedPut(bucket: string, putOptions?: PutOptions): void {
|
|
228
114
|
const sse = putOptions?.serverSideEncryption;
|
|
229
115
|
if (sse === undefined) return;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
116
|
+
// The key id is the caller's own option, spliced into single-quoted JSON: `renderFixLiteral`
|
|
117
|
+
// escapes the `"` and `\` JSON needs, and a `'` — the one byte live inside single quotes — makes
|
|
118
|
+
// it the placeholder rather than ending the argument (plan 101 row S12).
|
|
119
|
+
const keyId = sse.kmsKeyId?.includes("'") === false ? sse.kmsKeyId : undefined;
|
|
120
|
+
const bucketArg = renderFixShellArg(bucket, "'<bucket>'");
|
|
234
121
|
throw new NotImplementedError({
|
|
235
122
|
cause:
|
|
236
123
|
'per-object server-side encryption on the s3 driver (Bun.S3Client exposes acl, storageClass and type, and nothing for x-amz-server-side-encryption) is not implemented by this driver — set it bucket-wide with the command in fix, then drop serverSideEncryption from put()',
|
|
237
|
-
fix:
|
|
124
|
+
fix:
|
|
125
|
+
sse.algorithm === 'aws:kms'
|
|
126
|
+
? `aws s3api put-bucket-encryption --bucket ${bucketArg} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"aws:kms","KMSMasterKeyID":${renderFixLiteral(keyId, '"<key-arn>"')}},"BucketKeyEnabled":true}]}'`
|
|
127
|
+
: `aws s3api put-bucket-encryption --bucket ${bucketArg} --server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"},"BucketKeyEnabled":true}]}'`,
|
|
238
128
|
});
|
|
239
129
|
}
|
|
240
130
|
|
|
@@ -253,6 +143,10 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
253
143
|
options.maxGetBytes === undefined ? maxPutBytes : options.maxGetBytes,
|
|
254
144
|
1,
|
|
255
145
|
);
|
|
146
|
+
// The key `defineStorage` registered this disk under: a not-found `fix:` is `disk('<it>')…`,
|
|
147
|
+
// and the driver kind there is `X_STORAGE_DISK_UNKNOWN` on any app that named its disk.
|
|
148
|
+
let diskName = DRIVER_NAME;
|
|
149
|
+
const clock = options.clock ?? systemClock;
|
|
256
150
|
let client: S3ClientLike | undefined;
|
|
257
151
|
const conn = (): S3ClientLike => {
|
|
258
152
|
client ??= buildClient(options);
|
|
@@ -277,12 +171,15 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
277
171
|
const refusedWrite =
|
|
278
172
|
(key: string) =>
|
|
279
173
|
(error: unknown): never => {
|
|
174
|
+
// A refusal this process already coded — a screened header, a missing credential — is its
|
|
175
|
+
// own answer, never re-read as the provider refusing the write.
|
|
176
|
+
if (isUltimateError(error)) throw error;
|
|
280
177
|
throwIfMisconfigured(error);
|
|
281
178
|
throw putFailed(
|
|
282
179
|
DRIVER_NAME,
|
|
283
180
|
key,
|
|
284
181
|
error,
|
|
285
|
-
`aws s3api get-bucket-policy --bucket ${renderFixShellArg(options.bucket, '<bucket>')}`,
|
|
182
|
+
`aws s3api get-bucket-policy --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")}`,
|
|
286
183
|
);
|
|
287
184
|
};
|
|
288
185
|
|
|
@@ -294,13 +191,13 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
294
191
|
(key: string) =>
|
|
295
192
|
(error: unknown): never => {
|
|
296
193
|
if (isStorageError(error)) throw error;
|
|
297
|
-
if (isAbsentObject(error)) throw objectNotFound(
|
|
194
|
+
if (isAbsentObject(error)) throw objectNotFound(diskName, key);
|
|
298
195
|
throwIfMisconfigured(error);
|
|
299
196
|
throw readFailed(
|
|
300
197
|
DRIVER_NAME,
|
|
301
198
|
key,
|
|
302
199
|
error,
|
|
303
|
-
`aws s3api head-object --bucket ${renderFixShellArg(options.bucket, '<bucket>')} --key ${renderFixShellArg(key, '<key>')}`,
|
|
200
|
+
`aws s3api head-object --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --key ${renderFixShellArg(key, "'<key>'")}`,
|
|
304
201
|
);
|
|
305
202
|
};
|
|
306
203
|
const present = (key: string): Promise<boolean> =>
|
|
@@ -319,11 +216,15 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
319
216
|
|
|
320
217
|
return {
|
|
321
218
|
name: DRIVER_NAME,
|
|
219
|
+
registerAs(name: string): void {
|
|
220
|
+
diskName = name;
|
|
221
|
+
},
|
|
322
222
|
|
|
323
223
|
async put(key: string, body: StorageBody, putOptions?: PutOptions): Promise<StorageObject> {
|
|
324
224
|
const safe = assertSafeKey(key);
|
|
325
|
-
refuseUnsupportedPut(options.bucket,
|
|
225
|
+
refuseUnsupportedPut(options.bucket, putOptions);
|
|
326
226
|
assertPutOptions(DRIVER_NAME, putOptions);
|
|
227
|
+
assertObjectLockOptions(DRIVER_NAME, putOptions, clock);
|
|
327
228
|
// Buffered on purpose: size and checksum must be known before the object exists — so this
|
|
328
229
|
// path is for objects that FIT IN MEMORY, and `maxPutBytes` is what makes that a contract
|
|
329
230
|
// rather than a hope. User uploads never come through here: they go direct to the bucket
|
|
@@ -334,6 +235,20 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
334
235
|
const actual = sha256Base64(bytes);
|
|
335
236
|
if (claimed !== actual) throw checksumMismatch(safe, claimed, actual);
|
|
336
237
|
}
|
|
238
|
+
if (putOptions !== undefined && needsSignedPut(putOptions)) {
|
|
239
|
+
// Off the Bun client only for what it cannot send: `S3File.write` takes a type, an ACL and
|
|
240
|
+
// a storage class and no header. One request either way — never a PUT then a second call.
|
|
241
|
+
const refusal = await signedPut(options, safe, bytes, putOptions).catch(refusedWrite(safe));
|
|
242
|
+
if (refusal !== undefined) refusedWrite(safe)(refusal);
|
|
243
|
+
// Stored — the PUT succeeded with them — so reported, as the local disk reports its own.
|
|
244
|
+
return {
|
|
245
|
+
...(await statObject(safe)),
|
|
246
|
+
...(putOptions.cacheControl === undefined
|
|
247
|
+
? {}
|
|
248
|
+
: { cacheControl: putOptions.cacheControl }),
|
|
249
|
+
...(putOptions.metadata === undefined ? {} : { metadata: { ...putOptions.metadata } }),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
337
252
|
await conn()
|
|
338
253
|
.file(safe)
|
|
339
254
|
.write(bytes, { type: putOptions?.contentType ?? DEFAULT_CONTENT_TYPE })
|
|
@@ -349,7 +264,7 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
349
264
|
async get(key: string): Promise<StorageRead> {
|
|
350
265
|
const safe = assertSafeKey(key);
|
|
351
266
|
const file = conn().file(safe);
|
|
352
|
-
if (!(await present(safe))) throw objectNotFound(
|
|
267
|
+
if (!(await present(safe))) throw objectNotFound(diskName, safe);
|
|
353
268
|
// The HEAD comes FIRST, and it is the whole point: a client PUT straight into the bucket is
|
|
354
269
|
// bounded by nothing on this disk, so `arrayBuffer()` on whatever is there was heap growth
|
|
355
270
|
// the uploader chose. Refused on the provider's own size before a byte is read.
|
|
@@ -362,7 +277,7 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
362
277
|
async stream(key: string): Promise<ReadableStream<Uint8Array>> {
|
|
363
278
|
const safe = assertSafeKey(key);
|
|
364
279
|
const file = conn().file(safe);
|
|
365
|
-
if (!(await present(safe))) throw objectNotFound(
|
|
280
|
+
if (!(await present(safe))) throw objectNotFound(diskName, safe);
|
|
366
281
|
return file.stream();
|
|
367
282
|
},
|
|
368
283
|
|
|
@@ -377,7 +292,7 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
377
292
|
const source = assertSafeKey(from);
|
|
378
293
|
const destination = assertSafeKey(to);
|
|
379
294
|
const file = conn().file(source);
|
|
380
|
-
if (!(await present(source))) throw objectNotFound(
|
|
295
|
+
if (!(await present(source))) throw objectNotFound(diskName, source);
|
|
381
296
|
const stat = await file.stat().catch(refusedRead(source));
|
|
382
297
|
await conn()
|
|
383
298
|
.file(destination)
|
|
@@ -399,7 +314,8 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
399
314
|
DRIVER_NAME,
|
|
400
315
|
safe,
|
|
401
316
|
error,
|
|
402
|
-
`
|
|
317
|
+
// `isSafeKey` admits `$(…)`, a backtick and `;` — legal key bytes, live shell syntax.
|
|
318
|
+
`grant s3:DeleteObject on this prefix to the app's role, then reproduce with the provider's own words: aws s3api delete-object --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --key ${renderFixShellArg(safe, "'<key>'")}`,
|
|
403
319
|
);
|
|
404
320
|
}
|
|
405
321
|
},
|
|
@@ -408,7 +324,22 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
408
324
|
return present(assertSafeKey(key));
|
|
409
325
|
},
|
|
410
326
|
|
|
411
|
-
|
|
327
|
+
/** Signed `GET ?retention` and `?legal-hold`, in parallel. A bucket with no lock reports none. */
|
|
328
|
+
async retentionOf(key: string): Promise<ObjectLock> {
|
|
329
|
+
const safe = assertSafeKey(key);
|
|
330
|
+
const read = await readObjectLock(options, safe).catch(refusedRead(safe));
|
|
331
|
+
if (read.kind === 'lock') return read.lock;
|
|
332
|
+
if (isAbsentObject(read.refusal)) throw objectNotFound(diskName, safe);
|
|
333
|
+
throwIfMisconfigured(read.refusal);
|
|
334
|
+
throw readFailed(
|
|
335
|
+
DRIVER_NAME,
|
|
336
|
+
safe,
|
|
337
|
+
read.refusal,
|
|
338
|
+
`aws s3api get-object-retention --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --key ${renderFixShellArg(safe, "'<key>'")} # reproduces the refusal; grant s3:GetObjectRetention and s3:GetObjectLegalHold to the app's role`,
|
|
339
|
+
);
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
async list(listOptions?: ListOptions): Promise<Page<StorageListEntry>> {
|
|
412
343
|
const prefix = listOptions?.prefix ?? '';
|
|
413
344
|
// Refused at the seam both drivers share, before the provider is asked: `maxKeys: 0` used to
|
|
414
345
|
// go straight through, while the local disk answered a complete-looking empty page.
|
|
@@ -422,7 +353,9 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
422
353
|
result = await client.list({
|
|
423
354
|
maxKeys,
|
|
424
355
|
...(listOptions?.prefix === undefined ? {} : { prefix: listOptions.prefix }),
|
|
425
|
-
...(listOptions?.cursor ===
|
|
356
|
+
...(typeof listOptions?.cursor === 'string'
|
|
357
|
+
? { continuationToken: listOptions.cursor }
|
|
358
|
+
: {}),
|
|
426
359
|
});
|
|
427
360
|
} catch (error) {
|
|
428
361
|
// A bare `S3Error` used to escape here, uncoded: no `X_*`, no `fix`, no `--json` shape, and
|
|
@@ -433,7 +366,11 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
433
366
|
DRIVER_NAME,
|
|
434
367
|
prefix,
|
|
435
368
|
error,
|
|
436
|
-
|
|
369
|
+
// Two whole lines rather than a ternary INSIDE one: each substitution is then a screen
|
|
370
|
+
// the fix-shell-arg guard can read as one.
|
|
371
|
+
prefix === ''
|
|
372
|
+
? `grant s3:ListBucket on this bucket to the app's role, then reproduce with the provider's own words: aws s3api list-objects-v2 --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")}`
|
|
373
|
+
: `grant s3:ListBucket on this bucket to the app's role, then reproduce with the provider's own words: aws s3api list-objects-v2 --bucket ${renderFixShellArg(options.bucket, "'<bucket>'")} --prefix ${renderFixShellArg(prefix, "'<prefix>'")}`,
|
|
437
374
|
);
|
|
438
375
|
}
|
|
439
376
|
const objects: StorageListEntry[] = [];
|
|
@@ -451,10 +388,13 @@ export function s3Driver(options: S3DriverOptions): StorageDriver {
|
|
|
451
388
|
...dated(entry.lastModified),
|
|
452
389
|
});
|
|
453
390
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
391
|
+
// `IsTruncated` decides and `NextContinuationToken` is the cursor: a token beside
|
|
392
|
+
// `IsTruncated: false` is not a next page, and a truncated answer with no token has none to
|
|
393
|
+
// offer — both are the last page, never a cursor that fetches an empty one.
|
|
394
|
+
return pageOf(
|
|
395
|
+
objects,
|
|
396
|
+
result.isTruncated === true ? (result.nextContinuationToken ?? null) : null,
|
|
397
|
+
);
|
|
458
398
|
},
|
|
459
399
|
|
|
460
400
|
/**
|
package/src/driver.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// strings — a base64 round trip through JSON is how a "small" upload becomes a 33%-larger OOM —
|
|
4
4
|
// and never unbounded, which is the same failure with the sender choosing the size.
|
|
5
5
|
|
|
6
|
-
import type { Clock } from '@ultimat3/core';
|
|
6
|
+
import type { Clock, Page } from '@ultimat3/core';
|
|
7
7
|
import { assert } from '@ultimat3/core';
|
|
8
8
|
import { putTooLarge } from './errors';
|
|
9
|
+
import type { ObjectLock, ObjectLockOptions } from './object-lock';
|
|
9
10
|
import type { SignedUrlVerification } from './signed-url';
|
|
10
11
|
|
|
11
12
|
export type StorageBody = Uint8Array | ReadableStream<Uint8Array> | Blob;
|
|
@@ -53,7 +54,12 @@ export interface ServerSideEncryption {
|
|
|
53
54
|
readonly kmsKeyId?: string | undefined;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
/**
|
|
58
|
+
* `retention` and `legalHold` (from `ObjectLockOptions`) are S3 Object Lock, per object: sent as
|
|
59
|
+
* `x-amz-object-lock-*` on s3 — which needs a bucket created with Object Lock — and EMULATED by the
|
|
60
|
+
* local and memory disks, which refuse a delete or an overwrite of a locked object until it lapses.
|
|
61
|
+
*/
|
|
62
|
+
export interface PutOptions extends ObjectLockOptions {
|
|
57
63
|
readonly contentType?: string | undefined;
|
|
58
64
|
readonly cacheControl?: string | undefined;
|
|
59
65
|
readonly metadata?: Readonly<Record<string, string>> | undefined;
|
|
@@ -65,18 +71,15 @@ export interface PutOptions {
|
|
|
65
71
|
|
|
66
72
|
export interface ListOptions {
|
|
67
73
|
readonly prefix?: string | undefined;
|
|
68
|
-
/**
|
|
69
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Opaque; pass back the `nextCursor` of the previous page. `null` and absent both mean the
|
|
76
|
+
* first page, so `cursor: page.nextCursor` threads a loop with no conversion — the loop stops
|
|
77
|
+
* on `hasMore: false` before a `null` is ever handed back as "the page after the last".
|
|
78
|
+
*/
|
|
79
|
+
readonly cursor?: string | null | undefined;
|
|
70
80
|
readonly limit?: number | undefined;
|
|
71
81
|
}
|
|
72
82
|
|
|
73
|
-
export interface ListPage {
|
|
74
|
-
readonly objects: readonly StorageListEntry[];
|
|
75
|
-
readonly truncated: boolean;
|
|
76
|
-
/** Absent when `truncated` is false. */
|
|
77
|
-
readonly cursor?: string | undefined;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
83
|
export interface StorageRead {
|
|
81
84
|
readonly object: StorageObject;
|
|
82
85
|
readonly bytes: Uint8Array;
|
|
@@ -112,11 +115,13 @@ export interface StorageDriver {
|
|
|
112
115
|
*/
|
|
113
116
|
readonly signedUrlBase?: string | undefined;
|
|
114
117
|
/**
|
|
115
|
-
* `defineStorage` tells the driver the key it was registered under, at boot, once.
|
|
116
|
-
* mints its own URLs hangs them off THAT, never off `name
|
|
117
|
-
* route resolves the segment through the registry, so a disk registered
|
|
118
|
-
* say `local` 404s every signature it just wrote
|
|
119
|
-
*
|
|
118
|
+
* `defineStorage` tells the driver the key it was registered under, at boot, once. Two readers:
|
|
119
|
+
* a driver that mints its own URLs hangs them off THAT, never off `name` — the mounted
|
|
120
|
+
* `/_storage/:disk/*key` route resolves the segment through the registry, so a disk registered
|
|
121
|
+
* as `uploads` whose URLs say `local` 404s every signature it just wrote — and every
|
|
122
|
+
* `X_STORAGE_NOT_FOUND`, whose `fix:` is `disk('<name>').list(…)`. `local`, `s3`
|
|
123
|
+
* and `memory` use it for both — their URLs and their not-found errors. Optional: a
|
|
124
|
+
* third-party driver that ignores it keeps minting and refusing under `name`.
|
|
120
125
|
*/
|
|
121
126
|
registerAs?(diskName: string): void;
|
|
122
127
|
put(key: string, body: StorageBody, options?: PutOptions): Promise<StorageObject>;
|
|
@@ -147,8 +152,20 @@ export interface StorageDriver {
|
|
|
147
152
|
*/
|
|
148
153
|
delete(key: string): Promise<void>;
|
|
149
154
|
exists(key: string): Promise<boolean>;
|
|
150
|
-
|
|
155
|
+
/**
|
|
156
|
+
* One page of core's ONE `Page` shape: `nextCursor` exists exactly when another object does,
|
|
157
|
+
* so a full last page answers `hasMore: false` rather than a cursor to an empty page.
|
|
158
|
+
*/
|
|
159
|
+
list(options?: ListOptions): Promise<Page<StorageListEntry>>;
|
|
151
160
|
signedUrl(key: string, options?: SignedUrlOptions): Promise<string>;
|
|
161
|
+
/**
|
|
162
|
+
* What locks one object — its Object Lock retention and legal hold — read back from the disk.
|
|
163
|
+
* `{ legalHold: false }` with no `retention` means nothing does, including on a bucket created
|
|
164
|
+
* without Object Lock. `X_STORAGE_NOT_FOUND` when no object is at the key. Optional, like
|
|
165
|
+
* `registerAs`: every shipped disk answers it, and a third-party driver that does not is a
|
|
166
|
+
* driver with no lock to report.
|
|
167
|
+
*/
|
|
168
|
+
retentionOf?(key: string): Promise<ObjectLock>;
|
|
152
169
|
/**
|
|
153
170
|
* Verify a URL THIS disk minted — without surrendering the key it minted with.
|
|
154
171
|
*
|
|
@@ -180,7 +197,7 @@ export const DEFAULT_LIST_LIMIT = 1000;
|
|
|
180
197
|
* The page size a `list()` honours — refused rather than clamped when it cannot be one.
|
|
181
198
|
*
|
|
182
199
|
* At the `ListOptions` seam and not inside a driver, because the two answered a non-positive
|
|
183
|
-
* limit differently: the local disk sliced `[0, 0)` and then dropped its own
|
|
200
|
+
* limit differently: the local disk sliced `[0, 0)` and then dropped its own "more" flag, so
|
|
184
201
|
* `list({ limit: 0 })` over a full disk reported a COMPLETE empty listing, while the s3 disk
|
|
185
202
|
* handed `maxKeys: 0` straight to the provider. `sweepOrphans` pages through `list()`, so a page
|
|
186
203
|
* that is empty and claims to be complete is a false erasure report — the same lie a swallowed
|
|
@@ -229,7 +246,7 @@ export function assertListOptions(options: ListOptions | undefined): void {
|
|
|
229
246
|
for (const name of ['prefix', 'cursor'] as const) {
|
|
230
247
|
const value = options?.[name];
|
|
231
248
|
assert(
|
|
232
|
-
value === undefined || typeof value === 'string',
|
|
249
|
+
value === undefined || typeof value === 'string' || (name === 'cursor' && value === null),
|
|
233
250
|
`list() was given a ${name} that is not a string`,
|
|
234
251
|
`disk.list({ ${name}: 'a string' }) — or omit ${name}`,
|
|
235
252
|
);
|
package/src/errors.ts
CHANGED
|
@@ -29,6 +29,7 @@ export const STORAGE_OWNED_ERROR_CODES = [
|
|
|
29
29
|
'X_STORAGE_KEY_CONFLICT',
|
|
30
30
|
'X_STORAGE_PUT_FAILED',
|
|
31
31
|
'X_STORAGE_READ_FAILED',
|
|
32
|
+
'X_STORAGE_OBJECT_LOCKED',
|
|
32
33
|
] as const;
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -65,9 +66,10 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
|
|
|
65
66
|
X_STORAGE_LIST_FAILED: 'the objects could not be listed',
|
|
66
67
|
X_STORAGE_QUARANTINED: 'the object is still in quarantine',
|
|
67
68
|
X_STORAGE_NOT_PENDING: 'the key is not a pending upload',
|
|
68
|
-
X_STORAGE_KEY_CONFLICT: 'the key collides with another key on
|
|
69
|
+
X_STORAGE_KEY_CONFLICT: 'the key collides with another key on a local or memory disk',
|
|
69
70
|
X_STORAGE_PUT_FAILED: 'the object could not be written',
|
|
70
71
|
X_STORAGE_READ_FAILED: 'the object could not be read',
|
|
72
|
+
X_STORAGE_OBJECT_LOCKED: 'the object is under retention or a legal hold',
|
|
71
73
|
};
|
|
72
74
|
|
|
73
75
|
// One unconditional call, so a second package claiming one of storage's codes throws
|
|
@@ -167,7 +169,7 @@ export const putTooLarge = (
|
|
|
167
169
|
(disk === 's3'
|
|
168
170
|
? `s3Driver({ bucket, maxPutBytes: ${bytes} })`
|
|
169
171
|
: disk === 'memory'
|
|
170
|
-
? `
|
|
172
|
+
? `memoryStorageDriver({ maxPutBytes: ${bytes} })`
|
|
171
173
|
: `localDriver({ root, maxPutBytes: ${bytes} })`),
|
|
172
174
|
meta: { disk, key, bytes, maxBytes },
|
|
173
175
|
});
|
|
@@ -191,7 +193,7 @@ export const getTooLarge = (
|
|
|
191
193
|
(disk === 's3'
|
|
192
194
|
? `s3Driver({ bucket, maxGetBytes: ${bytes} })`
|
|
193
195
|
: disk === 'memory'
|
|
194
|
-
? `
|
|
196
|
+
? `memoryStorageDriver({ maxGetBytes: ${bytes} })`
|
|
195
197
|
: `localDriver({ root, maxGetBytes: ${bytes} })`),
|
|
196
198
|
meta: { disk, key, bytes, maxBytes },
|
|
197
199
|
});
|
|
@@ -199,8 +201,9 @@ export const getTooLarge = (
|
|
|
199
201
|
/**
|
|
200
202
|
* The local disk stores an object as a FILE at its key, so a key cannot also be the directory of
|
|
201
203
|
* another: `put('a')` then `put('a/b')`, or the reverse, or the same pair one tree over in the
|
|
202
|
-
* sidecars (`a` and `a.json/b`). It surfaced as a bare `ENOTDIR` / `EISDIR`. S3
|
|
203
|
-
*
|
|
204
|
+
* sidecars (`a` and `a.json/b`). It surfaced as a bare `ENOTDIR` / `EISDIR`. S3 holds both —
|
|
205
|
+
* this is the local disk's own limit, refused by name rather than hidden, and the memory disk
|
|
206
|
+
* emulates it (`driver-memory-conflict.ts`) because a test's disk stands in for the dev disk.
|
|
204
207
|
*
|
|
205
208
|
* The `fix` lists what is in the way: the remedy is storing one of the two under another key, and
|
|
206
209
|
* which one is only decidable by a reader who can see both.
|
|
@@ -208,7 +211,7 @@ export const getTooLarge = (
|
|
|
208
211
|
export const keyConflict = (disk: string, key: string, blocking: string): StorageError =>
|
|
209
212
|
new StorageError({
|
|
210
213
|
code: 'X_STORAGE_KEY_CONFLICT',
|
|
211
|
-
cause: `the "${disk}" disk
|
|
214
|
+
cause: `the "${disk}" disk stores keys as POSIX paths (a local disk, or the memory disk standing in for one) and cannot store "${key}": "${blocking}" is already on it, and a POSIX path is a file or a directory, never both — so one key cannot be a path prefix of another here. Store one of the two under a different key; an s3 disk holds both`,
|
|
212
215
|
// The REGISTERED name, never the driver kind: `disk('local')` on a disk registered as
|
|
213
216
|
// `uploads` lists another disk, or answers X_STORAGE_DISK_UNKNOWN.
|
|
214
217
|
fix: `disk(${renderFixLiteral(disk, "'local'")}).list({ prefix: ${renderFixLiteral(blocking, "'a/'")} })`,
|
|
@@ -258,6 +261,20 @@ export const contentTypeUnrecognised = (key: string, declared: string): StorageE
|
|
|
258
261
|
meta: { key, declared },
|
|
259
262
|
});
|
|
260
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The XML twin of `contentTypeUnrecognised`: no signature names an XML type, so "matches no known
|
|
266
|
+
* signature" would be false here. The finding is that the bytes are not UTF-8 text — UTF-16, a
|
|
267
|
+
* legacy charset, a control byte — so the screen for script-carrying markup never saw what a
|
|
268
|
+
* browser would decode. Same shipped code: what a client retries on is unchanged.
|
|
269
|
+
*/
|
|
270
|
+
export const xmlBodyUnreadable = (key: string, declared: string): StorageError =>
|
|
271
|
+
new StorageError({
|
|
272
|
+
code: 'X_STORAGE_TYPE_REJECTED',
|
|
273
|
+
cause: `"${key}" declares ${declared}, and its bytes are not readable UTF-8 text, so they cannot be screened for the script a browser would run from them`,
|
|
274
|
+
fix: `validateUpload({ key: ${renderFixLiteral(key, "'a/data.xml'")}, declaredContentType: ${renderFixLiteral(declared, "'application/xml'")}, bytes: new TextEncoder().encode(text) }, policy) — where text is the document decoded from its own charset (iconv -f UTF-16 -t UTF-8), with no byte-order mark and no control characters`,
|
|
275
|
+
meta: { key, declared },
|
|
276
|
+
});
|
|
277
|
+
|
|
261
278
|
export const checksumMismatch = (key: string, declared: string, actual: string): StorageError =>
|
|
262
279
|
new StorageError({
|
|
263
280
|
code: 'X_STORAGE_CHECKSUM_MISMATCH',
|
package/src/index.ts
CHANGED
|
@@ -31,7 +31,6 @@ export {
|
|
|
31
31
|
export type {
|
|
32
32
|
ByteLimit,
|
|
33
33
|
ListOptions,
|
|
34
|
-
ListPage,
|
|
35
34
|
PutOptions,
|
|
36
35
|
ServerSideEncryption,
|
|
37
36
|
SignedUrlMethod,
|
|
@@ -55,17 +54,18 @@ export {
|
|
|
55
54
|
} from './driver';
|
|
56
55
|
export type { LocalDriverOptions } from './driver-local';
|
|
57
56
|
export { localDriver } from './driver-local';
|
|
58
|
-
export type {
|
|
59
|
-
export {
|
|
57
|
+
export type { MemoryStorageDriver, MemoryStorageDriverOptions } from './driver-memory';
|
|
58
|
+
export { memoryStorageDriver } from './driver-memory';
|
|
59
|
+
export type { S3DriverOptions } from './driver-s3';
|
|
60
|
+
export { s3Driver } from './driver-s3';
|
|
60
61
|
export type {
|
|
61
62
|
S3ClientLike,
|
|
62
|
-
S3DriverOptions,
|
|
63
63
|
S3FileLike,
|
|
64
64
|
S3ListEntryLike,
|
|
65
65
|
S3ListResultLike,
|
|
66
66
|
S3StatLike,
|
|
67
|
-
} from './driver-s3';
|
|
68
|
-
export {
|
|
67
|
+
} from './driver-s3-client';
|
|
68
|
+
export type { S3FetchLike } from './driver-s3-signed';
|
|
69
69
|
export type { StorageErrorCode, StorageErrorInit } from './errors';
|
|
70
70
|
export {
|
|
71
71
|
checksumMismatch,
|
|
@@ -94,6 +94,7 @@ export {
|
|
|
94
94
|
signingSecretMissing,
|
|
95
95
|
tooLarge,
|
|
96
96
|
uploadFailed,
|
|
97
|
+
xmlBodyUnreadable,
|
|
97
98
|
} from './errors';
|
|
98
99
|
export type { GrantUploadInput, UploadGrant, UploadRequest } from './grant';
|
|
99
100
|
export { grantUpload } from './grant';
|
|
@@ -119,6 +120,14 @@ export {
|
|
|
119
120
|
VARIANT_FORMATS,
|
|
120
121
|
variantKey,
|
|
121
122
|
} from './image';
|
|
123
|
+
export type {
|
|
124
|
+
LockedAction,
|
|
125
|
+
ObjectLock,
|
|
126
|
+
ObjectLockOptions,
|
|
127
|
+
ObjectRetention,
|
|
128
|
+
RetentionMode,
|
|
129
|
+
} from './object-lock';
|
|
130
|
+
export { isLocked, ObjectLockedError, RETENTION_MODES } from './object-lock';
|
|
122
131
|
|
|
123
132
|
export {
|
|
124
133
|
assertSafeKey,
|
|
@@ -152,7 +161,6 @@ export {
|
|
|
152
161
|
signConstraints,
|
|
153
162
|
signedUrlBaseFor,
|
|
154
163
|
signedUrlBasePath,
|
|
155
|
-
timingSafeEqual,
|
|
156
164
|
verifySignedUrl,
|
|
157
165
|
} from './signed-url';
|
|
158
166
|
export {
|