@ultimat3/storage 24.0.0 → 25.0.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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Single responsibility: the two s3 calls `Bun.S3Client` cannot make — a PUT carrying user
|
|
2
|
+
// metadata, `Cache-Control` and Object Lock headers, and the `?retention` / `?legal-hold` reads
|
|
3
|
+
// behind `retentionOf` — over the signed wire in `driver-s3-signed.ts`, answered in this package's
|
|
4
|
+
// vocabulary. A plain `put()` never comes here: it stays on the Bun client.
|
|
5
|
+
|
|
6
|
+
import { assert, describeValue } from '@ultimat3/core';
|
|
7
|
+
import { DEFAULT_CONTENT_TYPE, type PutOptions } from './driver';
|
|
8
|
+
import { type S3Refusal, type S3WireOptions, sendSigned, xmlField } from './driver-s3-signed';
|
|
9
|
+
import { isRetentionMode, type ObjectLock } from './object-lock';
|
|
10
|
+
|
|
11
|
+
/** RFC 9110 `token` — the only bytes an `x-amz-meta-<name>` header name may carry. */
|
|
12
|
+
const HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
13
|
+
/** Printable ASCII: S3 stores a metadata value as a header, and a CR or a non-ASCII byte is not one. */
|
|
14
|
+
const HEADER_VALUE = /^[\x20-\x7e]*$/;
|
|
15
|
+
|
|
16
|
+
/** The options only the signed PUT can carry. Any one of them routes the put off the Bun client. */
|
|
17
|
+
export function needsSignedPut(options: PutOptions | undefined): boolean {
|
|
18
|
+
return (
|
|
19
|
+
options?.metadata !== undefined ||
|
|
20
|
+
options?.cacheControl !== undefined ||
|
|
21
|
+
options?.retention !== undefined ||
|
|
22
|
+
options?.legalHold !== undefined
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Every header the signed PUT sends beyond the signer's own, screened before a byte moves. */
|
|
27
|
+
export function putHeaders(bytes: Uint8Array, options: PutOptions): Record<string, string> {
|
|
28
|
+
const headers: Record<string, string> = {
|
|
29
|
+
'content-type': options.contentType ?? DEFAULT_CONTENT_TYPE,
|
|
30
|
+
// Required by S3 on any PUT that sets a retention, and an integrity check on every other one:
|
|
31
|
+
// a body that changed in flight is `BadDigest`, never a stored object.
|
|
32
|
+
'content-md5': new Bun.CryptoHasher('md5').update(bytes).digest('base64'),
|
|
33
|
+
};
|
|
34
|
+
if (options.cacheControl !== undefined) {
|
|
35
|
+
assert(
|
|
36
|
+
HEADER_VALUE.test(options.cacheControl),
|
|
37
|
+
`put() on the s3 disk was given a cacheControl that cannot travel as a header (${describeValue(options.cacheControl)})`,
|
|
38
|
+
"put(key, body, { cacheControl: 'public, max-age=3600' }) — printable ASCII, no line breaks",
|
|
39
|
+
);
|
|
40
|
+
headers['cache-control'] = options.cacheControl;
|
|
41
|
+
}
|
|
42
|
+
for (const [name, value] of Object.entries(options.metadata ?? {})) {
|
|
43
|
+
assert(
|
|
44
|
+
HEADER_TOKEN.test(name) && HEADER_VALUE.test(value),
|
|
45
|
+
`put() on the s3 disk was given metadata that cannot travel as an x-amz-meta-* header (name ${describeValue(name)}, value ${describeValue(value)})`,
|
|
46
|
+
'name each field with letters, digits and hyphens, and keep values printable ASCII — encodeURIComponent(value) anything else',
|
|
47
|
+
);
|
|
48
|
+
headers[`x-amz-meta-${name.toLowerCase()}`] = value;
|
|
49
|
+
}
|
|
50
|
+
if (options.retention !== undefined) {
|
|
51
|
+
headers['x-amz-object-lock-mode'] = options.retention.mode;
|
|
52
|
+
headers['x-amz-object-lock-retain-until-date'] = options.retention.retainUntil.toISOString();
|
|
53
|
+
}
|
|
54
|
+
if (options.legalHold !== undefined) {
|
|
55
|
+
headers['x-amz-object-lock-legal-hold'] = options.legalHold ? 'ON' : 'OFF';
|
|
56
|
+
}
|
|
57
|
+
return headers;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The signed PUT. `undefined` on success; the provider's refusal otherwise, for the driver to code. */
|
|
61
|
+
export async function signedPut(
|
|
62
|
+
wire: S3WireOptions,
|
|
63
|
+
key: string,
|
|
64
|
+
bytes: Uint8Array,
|
|
65
|
+
options: PutOptions,
|
|
66
|
+
): Promise<S3Refusal | undefined> {
|
|
67
|
+
const headers = putHeaders(bytes, options);
|
|
68
|
+
const result = await sendSigned(wire, { method: 'PUT', key, headers, body: bytes });
|
|
69
|
+
return result.ok ? undefined : result.refusal;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* "Nothing locks this", as the provider says it: no configuration on the object, or a bucket made
|
|
74
|
+
* without Object Lock at all. Asked BEFORE the absent-object test, because the first answers 404.
|
|
75
|
+
*/
|
|
76
|
+
function isUnlockedAnswer(refusal: S3Refusal): boolean {
|
|
77
|
+
if (refusal.code === 'NoSuchObjectLockConfiguration') return true;
|
|
78
|
+
if (refusal.code === 'ObjectLockConfigurationNotFoundError') return true;
|
|
79
|
+
return refusal.code === 'InvalidRequest' && /object lock/i.test(refusal.message);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type LockRead =
|
|
83
|
+
| { readonly kind: 'lock'; readonly lock: ObjectLock }
|
|
84
|
+
| { readonly kind: 'refused'; readonly refusal: S3Refusal };
|
|
85
|
+
|
|
86
|
+
async function readSubresource(
|
|
87
|
+
wire: S3WireOptions,
|
|
88
|
+
key: string,
|
|
89
|
+
subresource: 'retention' | 'legal-hold',
|
|
90
|
+
): Promise<{ readonly xml: string } | { readonly unlocked: true } | S3Refusal> {
|
|
91
|
+
const result = await sendSigned(wire, { method: 'GET', key, subresource });
|
|
92
|
+
if (result.ok) return { xml: await result.response.text() };
|
|
93
|
+
return isUnlockedAnswer(result.refusal) ? { unlocked: true } : result.refusal;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const unreadable = (key: string, what: string): S3Refusal => ({
|
|
97
|
+
name: 'S3Error',
|
|
98
|
+
code: 'UnreadableAnswer',
|
|
99
|
+
message: `the provider's ${what} for "${key}" did not parse`,
|
|
100
|
+
statusCode: 200,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Both halves of the lock, read in parallel. A refusal of either is the answer — a caller that
|
|
105
|
+
* asked "may I delete this?" must not hear "nothing locks it" because one read was denied.
|
|
106
|
+
*/
|
|
107
|
+
export async function readObjectLock(wire: S3WireOptions, key: string): Promise<LockRead> {
|
|
108
|
+
const [retention, hold] = await Promise.all([
|
|
109
|
+
readSubresource(wire, key, 'retention'),
|
|
110
|
+
readSubresource(wire, key, 'legal-hold'),
|
|
111
|
+
]);
|
|
112
|
+
if ('code' in retention) return { kind: 'refused', refusal: retention };
|
|
113
|
+
if ('code' in hold) return { kind: 'refused', refusal: hold };
|
|
114
|
+
let lock: ObjectLock = { legalHold: false };
|
|
115
|
+
if ('xml' in retention) {
|
|
116
|
+
const mode = xmlField(retention.xml, 'Mode');
|
|
117
|
+
const until = new Date(xmlField(retention.xml, 'RetainUntilDate') ?? '');
|
|
118
|
+
if (!isRetentionMode(mode) || !Number.isFinite(until.getTime())) {
|
|
119
|
+
return { kind: 'refused', refusal: unreadable(key, 'retention') };
|
|
120
|
+
}
|
|
121
|
+
lock = { retention: { mode, retainUntil: until }, legalHold: false };
|
|
122
|
+
}
|
|
123
|
+
if ('xml' in hold) {
|
|
124
|
+
const status = xmlField(hold.xml, 'Status');
|
|
125
|
+
if (status !== 'ON' && status !== 'OFF') {
|
|
126
|
+
return { kind: 'refused', refusal: unreadable(key, 'legal hold') };
|
|
127
|
+
}
|
|
128
|
+
lock = { ...lock, legalHold: status === 'ON' };
|
|
129
|
+
}
|
|
130
|
+
return { kind: 'lock', lock };
|
|
131
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Single responsibility: the s3 disk's SIGNED requests — the ones `Bun.S3Client` has no option for
|
|
2
|
+
// (`x-amz-meta-*`, `Cache-Control`, `x-amz-object-lock-*`, `?retention`, `?legal-hold`) — built,
|
|
3
|
+
// signed through core's one SigV4 signer and sent with `fetch`. The endpoint, the addressing style,
|
|
4
|
+
// the region and the credentials are read from the SAME `S3DriverOptions` the Bun client is built
|
|
5
|
+
// from, so the two transports of one disk cannot point at two buckets.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type AwsCredentials,
|
|
9
|
+
type Clock,
|
|
10
|
+
ConfigInvalidError,
|
|
11
|
+
EnvMissingError,
|
|
12
|
+
signAwsRequest,
|
|
13
|
+
systemClock,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
|
+
|
|
16
|
+
/** The slice of `fetch` this file uses; injected in tests so no socket is ever opened. */
|
|
17
|
+
export type S3FetchLike = (
|
|
18
|
+
url: string,
|
|
19
|
+
init: {
|
|
20
|
+
readonly method: string;
|
|
21
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
22
|
+
readonly body?: Uint8Array<ArrayBuffer> | undefined;
|
|
23
|
+
},
|
|
24
|
+
) => Promise<Response>;
|
|
25
|
+
|
|
26
|
+
/** Structurally `S3DriverOptions`, so the driver passes its own options through unchanged. */
|
|
27
|
+
export interface S3WireOptions {
|
|
28
|
+
readonly bucket: string;
|
|
29
|
+
readonly region?: string | undefined;
|
|
30
|
+
readonly endpoint?: string | undefined;
|
|
31
|
+
readonly forcePathStyle?: boolean | undefined;
|
|
32
|
+
readonly accessKeyIdEnv?: string | undefined;
|
|
33
|
+
readonly secretAccessKeyEnv?: string | undefined;
|
|
34
|
+
readonly sessionTokenEnv?: string | undefined;
|
|
35
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
36
|
+
readonly fetch?: S3FetchLike | undefined;
|
|
37
|
+
readonly clock?: Clock | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a provider's refusal is read as — the `code`/`message`/`statusCode` triple `Bun.S3Client`'s
|
|
42
|
+
* `S3Error` carries, so `regionMismatch` and `isAbsentObject` classify a signed request's failure
|
|
43
|
+
* exactly as they classify the Bun client's. A plain object: it is never thrown bare, only handed
|
|
44
|
+
* to a coded factory.
|
|
45
|
+
*/
|
|
46
|
+
export interface S3Refusal {
|
|
47
|
+
readonly name: 'S3Error';
|
|
48
|
+
readonly code: string;
|
|
49
|
+
readonly message: string;
|
|
50
|
+
readonly statusCode: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface S3SignedRequest {
|
|
54
|
+
readonly method: 'GET' | 'PUT';
|
|
55
|
+
readonly key: string;
|
|
56
|
+
/** A sub-resource such as `retention` — sent and signed as `?retention=`. */
|
|
57
|
+
readonly subresource?: string | undefined;
|
|
58
|
+
readonly headers?: Readonly<Record<string, string>> | undefined;
|
|
59
|
+
readonly body?: Uint8Array | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type S3SignedResult =
|
|
63
|
+
| { readonly ok: true; readonly response: Response }
|
|
64
|
+
| { readonly ok: false; readonly refusal: S3Refusal };
|
|
65
|
+
|
|
66
|
+
/** One credential reader for both transports: an unset or empty variable is `X_ENV_MISSING`. */
|
|
67
|
+
export function requireEnv(
|
|
68
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
69
|
+
name: string,
|
|
70
|
+
partner: string,
|
|
71
|
+
): string {
|
|
72
|
+
const value = env[name];
|
|
73
|
+
if (value === undefined || value === '') {
|
|
74
|
+
throw new EnvMissingError({
|
|
75
|
+
cause: `${name} is not set, so the s3 disk cannot authenticate`,
|
|
76
|
+
fix: `set ${name} and ${partner} in .env (or the container's secret store), then re-run`,
|
|
77
|
+
meta: { missing: name },
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Where a disk's requests go, and as whom beyond the key pair — resolved ONCE for both transports. */
|
|
84
|
+
export interface S3Target {
|
|
85
|
+
readonly endpoint?: string | undefined;
|
|
86
|
+
readonly region?: string | undefined;
|
|
87
|
+
readonly sessionToken?: string | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The first of `names` set to something other than `''` — Bun's reading of an unset variable. */
|
|
91
|
+
const firstSet = (
|
|
92
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
93
|
+
names: readonly string[],
|
|
94
|
+
): string | undefined => {
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
const value = env[name];
|
|
97
|
+
if (value !== undefined && value !== '') return value;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Bun's own defaults, restated so the signed path cannot target another place: an option wins,
|
|
104
|
+
* then `S3_*`, then `AWS_*` — `S3_ENDPOINT`/`AWS_ENDPOINT`, `S3_REGION`/`AWS_REGION`,
|
|
105
|
+
* `S3_SESSION_TOKEN`/`AWS_SESSION_TOKEN` (measured on Bun 1.4.2, and its `S3Options` docs). Read
|
|
106
|
+
* from `options.env ?? process.env`, and `buildClient` hands the result to Bun explicitly, so an
|
|
107
|
+
* injected table governs both transports. A `sessionTokenEnv` names the one variable to read.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveS3Target(options: S3WireOptions): S3Target {
|
|
110
|
+
const env = options.env ?? process.env;
|
|
111
|
+
const tokenVar = options.sessionTokenEnv;
|
|
112
|
+
const endpoint = options.endpoint ?? firstSet(env, ['S3_ENDPOINT', 'AWS_ENDPOINT']);
|
|
113
|
+
const region = options.region ?? firstSet(env, ['S3_REGION', 'AWS_REGION']);
|
|
114
|
+
const sessionToken =
|
|
115
|
+
tokenVar === undefined
|
|
116
|
+
? firstSet(env, ['S3_SESSION_TOKEN', 'AWS_SESSION_TOKEN'])
|
|
117
|
+
: firstSet(env, [tokenVar]);
|
|
118
|
+
return {
|
|
119
|
+
...(endpoint === undefined ? {} : { endpoint }),
|
|
120
|
+
...(region === undefined ? {} : { region }),
|
|
121
|
+
...(sessionToken === undefined ? {} : { sessionToken }),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Read lazily, at the first signed request — importing or constructing the disk reads nothing. */
|
|
126
|
+
export function s3Credentials(options: S3WireOptions): AwsCredentials {
|
|
127
|
+
const env = options.env ?? process.env;
|
|
128
|
+
const idVar = options.accessKeyIdEnv ?? 'S3_ACCESS_KEY_ID';
|
|
129
|
+
const secretVar = options.secretAccessKeyEnv ?? 'S3_SECRET_ACCESS_KEY';
|
|
130
|
+
const { sessionToken } = resolveS3Target(options);
|
|
131
|
+
return {
|
|
132
|
+
accessKeyId: requireEnv(env, idVar, secretVar),
|
|
133
|
+
secretAccessKey: requireEnv(env, secretVar, idVar),
|
|
134
|
+
...(sessionToken === undefined || sessionToken === '' ? {} : { sessionToken }),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Bun's addressing, restated: path style unless `forcePathStyle` is EXPLICITLY false (Bun's own
|
|
140
|
+
* `virtualHostedStyle` defaults to false), and AWS's regional endpoint when none is configured.
|
|
141
|
+
*/
|
|
142
|
+
export function s3ObjectUrl(options: S3WireOptions, key: string): URL {
|
|
143
|
+
const target = resolveS3Target(options);
|
|
144
|
+
const endpoint = target.endpoint ?? `https://s3.${target.region ?? 'us-east-1'}.amazonaws.com`;
|
|
145
|
+
const base = new URL(endpoint);
|
|
146
|
+
const path = key.split('/').map(encodeURIComponent).join('/');
|
|
147
|
+
if (options.forcePathStyle === false) {
|
|
148
|
+
return new URL(`${base.protocol}//${options.bucket}.${base.host}/${path}`);
|
|
149
|
+
}
|
|
150
|
+
const prefix = base.pathname.replace(/\/+$/, '');
|
|
151
|
+
return new URL(
|
|
152
|
+
`${base.protocol}//${base.host}${prefix}/${encodeURIComponent(options.bucket)}/${path}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const XML_FIELD = (name: string): RegExp => new RegExp(`<${name}>([^<]{0,512})</${name}>`);
|
|
157
|
+
|
|
158
|
+
/** The first `<name>…</name>` of an S3 XML answer — the shapes read here are flat and fixed. */
|
|
159
|
+
export function xmlField(xml: string, name: string): string | undefined {
|
|
160
|
+
return XML_FIELD(name).exec(xml)?.[1]?.trim();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A non-2xx answer as the refusal it is. A HEAD-style empty body still has its status. */
|
|
164
|
+
async function refusalOf(response: Response): Promise<S3Refusal> {
|
|
165
|
+
const body = await response.text().catch(() => '');
|
|
166
|
+
return {
|
|
167
|
+
name: 'S3Error',
|
|
168
|
+
code: xmlField(body, 'Code') ?? `HTTP${response.status}`,
|
|
169
|
+
message: xmlField(body, 'Message') ?? response.statusText,
|
|
170
|
+
statusCode: response.status,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Sign and send one request. Resolves with the response or the provider's refusal — never throws
|
|
176
|
+
* for an answer the provider gave, so each caller decides what a refusal means for ITS call. A
|
|
177
|
+
* request with no answer at all (DNS, reset) rejects with whatever `fetch` raised.
|
|
178
|
+
*/
|
|
179
|
+
export async function sendSigned(
|
|
180
|
+
options: S3WireOptions,
|
|
181
|
+
request: S3SignedRequest,
|
|
182
|
+
): Promise<S3SignedResult> {
|
|
183
|
+
// The Bun client's own refusal, for the transport beside it: one disk, one rule.
|
|
184
|
+
if (options.bucket === '') {
|
|
185
|
+
throw new ConfigInvalidError({
|
|
186
|
+
cause: 's3 disk was defined without a bucket',
|
|
187
|
+
fix: "s3Driver({ bucket: 'my-bucket' }) # storage.disks.<name> in app.config.ts",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
const url = s3ObjectUrl(options, request.key);
|
|
191
|
+
if (request.subresource !== undefined) url.search = `${request.subresource}=`;
|
|
192
|
+
const signed = await signAwsRequest({
|
|
193
|
+
method: request.method,
|
|
194
|
+
url,
|
|
195
|
+
headers: request.headers ?? {},
|
|
196
|
+
// The body is in memory and bounded by `maxPutBytes`, so it is hashed and the provider can
|
|
197
|
+
// refuse a body that changed in flight; a read has none.
|
|
198
|
+
payload: { body: request.body ?? new Uint8Array() },
|
|
199
|
+
credentials: s3Credentials(options),
|
|
200
|
+
region: resolveS3Target(options).region ?? 'auto',
|
|
201
|
+
service: 's3',
|
|
202
|
+
clock: options.clock ?? systemClock,
|
|
203
|
+
});
|
|
204
|
+
const send = options.fetch ?? fetch;
|
|
205
|
+
const body = request.body;
|
|
206
|
+
const response = await send(signed.url, {
|
|
207
|
+
method: signed.method,
|
|
208
|
+
headers: signed.headers,
|
|
209
|
+
// `fetch` takes an ArrayBuffer-backed view; only a view over shared memory is copied.
|
|
210
|
+
...(body === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: {
|
|
213
|
+
body:
|
|
214
|
+
body.buffer instanceof ArrayBuffer
|
|
215
|
+
? new Uint8Array(body.buffer, body.byteOffset, body.byteLength)
|
|
216
|
+
: new Uint8Array(body),
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
return response.ok ? { ok: true, response } : { ok: false, refusal: await refusalOf(response) };
|
|
220
|
+
}
|