dpdp-erasure-cli 1.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.
Files changed (155) hide show
  1. package/.env.example +55 -0
  2. package/Dockerfile +33 -0
  3. package/compliance.worker.yaml +64 -0
  4. package/package.json +41 -0
  5. package/src/constants/index.ts +1 -0
  6. package/src/errors/fail.ts +110 -0
  7. package/src/errors/index.ts +4 -0
  8. package/src/errors/inferer.ts +166 -0
  9. package/src/errors/registry.ts +122 -0
  10. package/src/errors/types.ts +65 -0
  11. package/src/errors/worker.ts +161 -0
  12. package/src/index.ts +328 -0
  13. package/src/lib/crypto/digest.ts +22 -0
  14. package/src/lib/crypto/encoding.ts +78 -0
  15. package/src/lib/crypto/index.ts +2 -0
  16. package/src/lib/index.ts +1 -0
  17. package/src/modules/bootstrap/index.ts +2 -0
  18. package/src/modules/bootstrap/integrity.ts +38 -0
  19. package/src/modules/bootstrap/preflight.ts +296 -0
  20. package/src/modules/cli/check-integrity.ts +48 -0
  21. package/src/modules/cli/dry-run.ts +90 -0
  22. package/src/modules/cli/graph.ts +87 -0
  23. package/src/modules/cli/index.ts +184 -0
  24. package/src/modules/cli/init.ts +115 -0
  25. package/src/modules/cli/inspect.ts +86 -0
  26. package/src/modules/cli/introspector.ts +117 -0
  27. package/src/modules/cli/keygen.ts +38 -0
  28. package/src/modules/cli/scan.ts +126 -0
  29. package/src/modules/cli/sign.ts +50 -0
  30. package/src/modules/cli/ui.ts +61 -0
  31. package/src/modules/cli/verify-schema.ts +31 -0
  32. package/src/modules/cli/verify.ts +85 -0
  33. package/src/modules/config/compatibility.ts +271 -0
  34. package/src/modules/config/index.ts +4 -0
  35. package/src/modules/config/reader.ts +149 -0
  36. package/src/modules/config/signature.ts +69 -0
  37. package/src/modules/config/validation.ts +658 -0
  38. package/src/modules/crypto/aes.ts +158 -0
  39. package/src/modules/crypto/envelope.ts +48 -0
  40. package/src/modules/crypto/hmac.ts +60 -0
  41. package/src/modules/crypto/index.ts +3 -0
  42. package/src/modules/db/drift.ts +36 -0
  43. package/src/modules/db/graph.ts +203 -0
  44. package/src/modules/db/index.ts +4 -0
  45. package/src/modules/db/migrations.ts +254 -0
  46. package/src/modules/db/sql-debug.ts +61 -0
  47. package/src/modules/engine/blob/index.ts +3 -0
  48. package/src/modules/engine/blob/s3.ts +455 -0
  49. package/src/modules/engine/blob/store.ts +236 -0
  50. package/src/modules/engine/blob/types.ts +44 -0
  51. package/src/modules/engine/helpers/identity.ts +47 -0
  52. package/src/modules/engine/helpers/index.ts +4 -0
  53. package/src/modules/engine/helpers/outbox.ts +118 -0
  54. package/src/modules/engine/helpers/runtime.ts +115 -0
  55. package/src/modules/engine/helpers/types.ts +61 -0
  56. package/src/modules/engine/index.ts +6 -0
  57. package/src/modules/engine/notifier/config.ts +147 -0
  58. package/src/modules/engine/notifier/dispatcher.ts +300 -0
  59. package/src/modules/engine/notifier/index.ts +3 -0
  60. package/src/modules/engine/notifier/payload.ts +51 -0
  61. package/src/modules/engine/notifier/reservation.ts +153 -0
  62. package/src/modules/engine/notifier/types.ts +38 -0
  63. package/src/modules/engine/shredder.ts +254 -0
  64. package/src/modules/engine/types.ts +146 -0
  65. package/src/modules/engine/vault/compiled-targets.ts +562 -0
  66. package/src/modules/engine/vault/context.ts +254 -0
  67. package/src/modules/engine/vault/dry-run.ts +94 -0
  68. package/src/modules/engine/vault/execution.ts +485 -0
  69. package/src/modules/engine/vault/index.ts +3 -0
  70. package/src/modules/engine/vault/purge.ts +82 -0
  71. package/src/modules/engine/vault/retention.ts +124 -0
  72. package/src/modules/engine/vault/satellite-mutation.ts +193 -0
  73. package/src/modules/engine/vault/satellite.ts +103 -0
  74. package/src/modules/engine/vault/shadow.ts +36 -0
  75. package/src/modules/engine/vault/static-plan.ts +116 -0
  76. package/src/modules/engine/vault/store.ts +34 -0
  77. package/src/modules/engine/vault/vault.ts +84 -0
  78. package/src/modules/introspector/classifier.ts +502 -0
  79. package/src/modules/introspector/dag.ts +276 -0
  80. package/src/modules/introspector/index.ts +7 -0
  81. package/src/modules/introspector/naming.ts +75 -0
  82. package/src/modules/introspector/report.ts +153 -0
  83. package/src/modules/introspector/run.ts +123 -0
  84. package/src/modules/introspector/s3-sampler.ts +227 -0
  85. package/src/modules/introspector/types.ts +131 -0
  86. package/src/modules/introspector/yaml.ts +101 -0
  87. package/src/modules/network/api/control-plane.ts +275 -0
  88. package/src/modules/network/api/index.ts +1 -0
  89. package/src/modules/network/api/validation.ts +71 -0
  90. package/src/modules/network/index.ts +4 -0
  91. package/src/modules/network/object-store/aws/client.ts +444 -0
  92. package/src/modules/network/object-store/aws/credentials.ts +271 -0
  93. package/src/modules/network/object-store/aws/index.ts +2 -0
  94. package/src/modules/network/object-store/aws/sigv4.ts +190 -0
  95. package/src/modules/network/object-store/aws/type.ts +6 -0
  96. package/src/modules/network/object-store/index.ts +1 -0
  97. package/src/modules/network/outbox/dispatcher.ts +183 -0
  98. package/src/modules/network/outbox/index.ts +3 -0
  99. package/src/modules/network/outbox/process.ts +133 -0
  100. package/src/modules/network/outbox/shared.ts +56 -0
  101. package/src/modules/network/outbox/store.ts +346 -0
  102. package/src/modules/network/outbox/types.ts +54 -0
  103. package/src/modules/network/request-signing.ts +61 -0
  104. package/src/modules/worker/index.ts +2 -0
  105. package/src/modules/worker/tasks.ts +58 -0
  106. package/src/modules/worker/types.ts +89 -0
  107. package/src/modules/worker/worker.ts +243 -0
  108. package/src/secrets/index.ts +4 -0
  109. package/src/secrets/kms/index.ts +2 -0
  110. package/src/secrets/kms/signature.ts +82 -0
  111. package/src/secrets/kms/validation.ts +64 -0
  112. package/src/secrets/reader.ts +42 -0
  113. package/src/secrets/repository/crypto.ts +89 -0
  114. package/src/secrets/repository/index.ts +2 -0
  115. package/src/secrets/repository/methods.ts +37 -0
  116. package/src/secrets/resolvers.ts +247 -0
  117. package/src/secrets/signature.ts +78 -0
  118. package/src/types/index.ts +1 -0
  119. package/src/types/types.ts +23 -0
  120. package/src/utils/identifiers.ts +48 -0
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/json.ts +35 -0
  123. package/src/utils/logger.ts +161 -0
  124. package/src/validation/zod.ts +70 -0
  125. package/tests/adversarial.test.ts +464 -0
  126. package/tests/blob-s3.test.ts +216 -0
  127. package/tests/config.test.ts +395 -0
  128. package/tests/control-plane-client.test.ts +108 -0
  129. package/tests/crypto.test.ts +106 -0
  130. package/tests/errors.test.ts +69 -0
  131. package/tests/fetch-dispatcher.test.ts +213 -0
  132. package/tests/graph.test.ts +84 -0
  133. package/tests/helpers/index.ts +101 -0
  134. package/tests/index-preflight.test.ts +168 -0
  135. package/tests/introspector-classifier.test.ts +62 -0
  136. package/tests/introspector-report.test.ts +85 -0
  137. package/tests/introspector.test.ts +394 -0
  138. package/tests/kms.test.ts +124 -0
  139. package/tests/logger.test.ts +61 -0
  140. package/tests/notifier.test.ts +303 -0
  141. package/tests/outbox.test.ts +478 -0
  142. package/tests/purge-policy.test.ts +124 -0
  143. package/tests/retention.test.ts +103 -0
  144. package/tests/s3-client.test.ts +110 -0
  145. package/tests/satellite.test.ts +119 -0
  146. package/tests/schema-compatibility.test.ts +237 -0
  147. package/tests/schema-integrity.test.ts +64 -0
  148. package/tests/shredder.test.ts +163 -0
  149. package/tests/vault.compiled-targets.test.ts +243 -0
  150. package/tests/vault.replica.test.ts +59 -0
  151. package/tests/vault.test.ts +279 -0
  152. package/tests/worker.retry.test.ts +291 -0
  153. package/tests/worker.test.ts +200 -0
  154. package/tsconfig.json +19 -0
  155. package/vitest.config.ts +13 -0
@@ -0,0 +1,444 @@
1
+ import type { Override } from "@/types";
2
+ import { CODE, fail } from "@/errors";
3
+ import { bytesToBase64 } from "@/lib";
4
+ import type { S3AwsCredentials } from "./type";
5
+ import { resolveAwsCredentials } from "./credentials";
6
+ import { signAwsRequest } from "./sigv4";
7
+
8
+ const textEncoder = new TextEncoder();
9
+
10
+ export interface S3ObjectPointer {
11
+ bucket: string;
12
+ key: string;
13
+ versionId?: string;
14
+ }
15
+
16
+ export interface S3ObjectHead extends Override<S3ObjectPointer, { versionId: string | null; }> {
17
+ eTag: string | null;
18
+ };
19
+
20
+ interface KeyAndVersion {
21
+ key: string;
22
+ versionId: string;
23
+ }
24
+
25
+ export interface S3ObjectVersion extends KeyAndVersion {
26
+ eTag: string | null;
27
+ isDeleteMarker: boolean;
28
+ }
29
+
30
+ export interface S3DeleteReceipt {
31
+ key: string;
32
+ versionId: string | null;
33
+ deleteMarker: boolean;
34
+ status: number;
35
+ }
36
+
37
+ export interface S3PutReceipt extends Omit<S3ObjectHead, "bucket"> {
38
+ status: number;
39
+ };
40
+
41
+ export interface S3RequestOptions extends S3ObjectPointer {
42
+ region: string;
43
+ expectedBucketOwner?: string;
44
+ }
45
+
46
+ export interface S3ClientOptions {
47
+ env?: Record<string, string | undefined>;
48
+ fetchFn?: typeof fetch;
49
+ credentials?: S3AwsCredentials;
50
+ endpointOverride?: string;
51
+ timeoutMs?: number;
52
+ }
53
+
54
+ export interface S3Client {
55
+ headObject(options: S3RequestOptions): Promise<S3ObjectHead>;
56
+ putObjectLegalHold(options: S3RequestOptions & { status: "ON" | "OFF" }): Promise<void>;
57
+ listObjectVersions(options: Omit<S3RequestOptions, "versionId">): Promise<S3ObjectVersion[]>;
58
+ deleteObjectVersion(options: S3RequestOptions & { bypassGovernanceRetention?: boolean }): Promise<S3DeleteReceipt>;
59
+ putObject(options: Omit<S3RequestOptions, "versionId"> & { body: Uint8Array; contentType: string }): Promise<S3PutReceipt>;
60
+ }
61
+
62
+ function encodeS3KeyPath(key: string): string {
63
+ return `/${key.split("/").map((segment) => encodeURIComponent(segment)).join("/")}`;
64
+ }
65
+
66
+ function stripQuotedHeader(value: string | null): string | null {
67
+ return value ? value.replace(/^"|"$/g, "") : null;
68
+ }
69
+
70
+ function buildS3Url(bucket: string, key: string | null, region: string, endpointOverride?: string): URL {
71
+ const host = endpointOverride
72
+ ? new URL(endpointOverride).host
73
+ : `${bucket}.s3.${region}.amazonaws.com`;
74
+ const protocol = endpointOverride ? new URL(endpointOverride).protocol : "https:";
75
+ const path = key ? encodeS3KeyPath(key) : "/";
76
+ return new URL(`${protocol}//${host}${path}`);
77
+ }
78
+
79
+ function appendExpectedBucketOwner(headers: Headers, expectedBucketOwner?: string): void {
80
+ if (expectedBucketOwner) {
81
+ headers.set("x-amz-expected-bucket-owner", expectedBucketOwner);
82
+ }
83
+ }
84
+
85
+ async function sha256Base64(bytes: Uint8Array): Promise<string> {
86
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.slice().buffer as ArrayBuffer);
87
+ return bytesToBase64(new Uint8Array(digest));
88
+ }
89
+
90
+ function decodeXml(value: string): string {
91
+ return value
92
+ .replace(/&amp;/g, "&")
93
+ .replace(/&lt;/g, "<")
94
+ .replace(/&gt;/g, ">")
95
+ .replace(/&quot;/g, "\"")
96
+ .replace(/&apos;/g, "'");
97
+ }
98
+
99
+ function readXmlTag(block: string, tag: string): string | null {
100
+ const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
101
+ const match = new RegExp(`<${escapedTag}>([\\s\\S]*?)</${escapedTag}>`).exec(block);
102
+ return match ? decodeXml(match[1] ?? "") : null;
103
+ }
104
+
105
+ function parseBooleanXml(value: string | null): boolean {
106
+ return value?.trim().toLowerCase() === "true";
107
+ }
108
+
109
+ function parseListObjectVersionsXml(xml: string, requestedKey: string): {
110
+ versions: S3ObjectVersion[];
111
+ isTruncated: boolean;
112
+ nextKeyMarker: string | null;
113
+ nextVersionIdMarker: string | null;
114
+ } {
115
+ const versions: S3ObjectVersion[] = [];
116
+ for (const match of xml.matchAll(/<(Version|DeleteMarker)>([\s\S]*?)<\/\1>/g)) {
117
+ const type = match[1];
118
+ const block = match[2] ?? "";
119
+ const key = readXmlTag(block, "Key");
120
+ const versionId = readXmlTag(block, "VersionId");
121
+ if (!key || !versionId || key !== requestedKey) {
122
+ continue;
123
+ }
124
+
125
+ versions.push({
126
+ key,
127
+ versionId,
128
+ eTag: stripQuotedHeader(readXmlTag(block, "ETag")),
129
+ isDeleteMarker: type === "DeleteMarker",
130
+ });
131
+ }
132
+
133
+ return {
134
+ versions,
135
+ isTruncated: parseBooleanXml(readXmlTag(xml, "IsTruncated")),
136
+ nextKeyMarker: readXmlTag(xml, "NextKeyMarker"),
137
+ nextVersionIdMarker: readXmlTag(xml, "NextVersionIdMarker"),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Parses supported S3 URL forms into bucket, key, and optional version id.
143
+ *
144
+ * @param value - `s3://bucket/key`, virtual-hosted S3 HTTPS URL, or path-style S3 URL.
145
+ * @returns Parsed object pointer.
146
+ * @throws {WorkerError} When the URL is absent, malformed, or not an S3 URL.
147
+ */
148
+ export function parseS3ObjectUrl(value: string): S3ObjectPointer {
149
+ let url: URL;
150
+ try {
151
+ url = new URL(value);
152
+ } catch {
153
+ fail({
154
+ code: CODE.BLOB_URL_INVALID,
155
+ detail: "Blob target value must be a valid s3:// or https:// S3 URL.",
156
+ });
157
+ }
158
+
159
+ if (url.protocol === "s3:") {
160
+ const key = decodeURIComponent(url.pathname.replace(/^\/+/, ""));
161
+ if (!url.hostname || !key) {
162
+ fail({
163
+ code: CODE.BLOB_URL_INVALID,
164
+ detail: "s3:// blob URL must contain a bucket and object key.",
165
+ });
166
+ }
167
+
168
+ return {
169
+ bucket: url.hostname,
170
+ key,
171
+ versionId: url.searchParams.get("versionId") ?? undefined,
172
+ };
173
+ }
174
+
175
+ if (url.protocol !== "https:") {
176
+ fail({
177
+ code: CODE.BLOB_URL_UNSUPPORTED,
178
+ title: "Unsupported blob URL protocol",
179
+ detail: "S3 blob targets must use s3:// or https:// URLs.",
180
+ });
181
+ }
182
+
183
+ const hostParts = url.hostname.split(".");
184
+ const s3Index = hostParts.findIndex((part) => part === "s3" || part.startsWith("s3-"));
185
+ if (s3Index <= 0 && !url.hostname.startsWith("s3.")) {
186
+ fail({
187
+ code: CODE.BLOB_URL_UNSUPPORTED,
188
+ title: "Unsupported S3 URL host",
189
+ detail: "HTTPS blob URL must use an Amazon S3 virtual-hosted or path-style hostname.",
190
+ });
191
+ }
192
+
193
+ if (s3Index > 0) {
194
+ const key = decodeURIComponent(url.pathname.replace(/^\/+/, ""));
195
+ if (!key) {
196
+ fail({
197
+ code: CODE.BLOB_URL_INVALID,
198
+ detail: "Virtual-hosted S3 URL must contain an object key.",
199
+ });
200
+ }
201
+
202
+ return {
203
+ bucket: hostParts.slice(0, s3Index).join("."),
204
+ key,
205
+ versionId: url.searchParams.get("versionId") ?? undefined,
206
+ };
207
+ }
208
+
209
+ const [bucket, ...keyParts] = url.pathname.replace(/^\/+/, "").split("/");
210
+ if (!bucket || keyParts.length === 0) {
211
+ fail({
212
+ code: CODE.BLOB_URL_INVALID,
213
+ title: "Invalid S3 path-style URL",
214
+ detail: "Path-style S3 URL must contain bucket and key path segments.",
215
+ });
216
+ }
217
+
218
+ return {
219
+ bucket,
220
+ key: decodeURIComponent(keyParts.join("/")),
221
+ versionId: url.searchParams.get("versionId") ?? undefined,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Creates a minimal AWS S3 REST client using SigV4 and native fetch.
227
+ *
228
+ * @param options - Credential, fetch, endpoint, and timeout overrides.
229
+ * @returns S3 client used by vaulting and shredding blob workflows.
230
+ */
231
+ export function createS3Client(options: S3ClientOptions = {}): S3Client {
232
+ const fetchFn = options.fetchFn ?? fetch;
233
+ const timeoutMs = options.timeoutMs ?? 10_000;
234
+ let cachedCredentials: S3AwsCredentials | null = options.credentials ?? null;
235
+
236
+ async function credentials(): Promise<S3AwsCredentials> {
237
+ if (
238
+ cachedCredentials &&
239
+ (!cachedCredentials.expiration || cachedCredentials.expiration.getTime() - Date.now() > 60_000)
240
+ ) {
241
+ return cachedCredentials;
242
+ }
243
+
244
+ cachedCredentials = await resolveAwsCredentials({
245
+ env: options.env,
246
+ fetchFn,
247
+ timeoutMs: Math.min(timeoutMs, 2_000),
248
+ });
249
+ return cachedCredentials;
250
+ }
251
+
252
+ async function signedFetch(
253
+ method: string,
254
+ requestUrl: URL,
255
+ region: string,
256
+ headers: Headers,
257
+ body?: Uint8Array | string
258
+ ): Promise<Response> {
259
+ const controller = new AbortController();
260
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
261
+
262
+ try {
263
+ const signedHeaders = await signAwsRequest({
264
+ method,
265
+ url: requestUrl,
266
+ region,
267
+ service: "s3",
268
+ headers,
269
+ body,
270
+ credentials: await credentials(),
271
+ });
272
+ try {
273
+ return await fetchFn(requestUrl, {
274
+ method,
275
+ headers: signedHeaders,
276
+ body,
277
+ signal: controller.signal,
278
+ redirect: "error",
279
+ });
280
+ } catch (error) {
281
+ fail({
282
+ code: "S3_OPERATION_RETRYABLE",
283
+ title: "S3 operation failed",
284
+ detail: error instanceof Error ? error.message : "S3 request could not be completed.",
285
+ category: "network",
286
+ retryable: true,
287
+ fatal: false,
288
+ context: {
289
+ method,
290
+ host: requestUrl.host,
291
+ },
292
+ });
293
+ }
294
+ } finally {
295
+ clearTimeout(timer);
296
+ }
297
+ }
298
+
299
+ async function assertS3Response(response: Response, operation: string): Promise<void> {
300
+ if (response.ok) {
301
+ return;
302
+ }
303
+
304
+ fail({
305
+ code: response.status >= 500 || response.status === 429
306
+ ? "S3_OPERATION_RETRYABLE"
307
+ : "S3_OPERATION_REJECTED",
308
+ title: "S3 operation failed",
309
+ detail: `${operation} returned HTTP ${response.status}.`,
310
+ category: response.status >= 500 || response.status === 429 ? "network" : "external",
311
+ retryable: response.status >= 500 || response.status === 429,
312
+ fatal: response.status === 401 || response.status === 403,
313
+ context: {
314
+ operation,
315
+ status: response.status,
316
+ },
317
+ });
318
+ }
319
+
320
+ return {
321
+ async headObject(input) {
322
+ const url = buildS3Url(input.bucket, input.key, input.region, options.endpointOverride);
323
+ if (input.versionId) {
324
+ url.searchParams.set("versionId", input.versionId);
325
+ }
326
+ const headers = new Headers();
327
+ appendExpectedBucketOwner(headers, input.expectedBucketOwner);
328
+ const response = await signedFetch("HEAD", url, input.region, headers);
329
+ await assertS3Response(response, "HeadObject");
330
+
331
+ return {
332
+ bucket: input.bucket,
333
+ key: input.key,
334
+ versionId: response.headers.get("x-amz-version-id"),
335
+ eTag: stripQuotedHeader(response.headers.get("etag")),
336
+ };
337
+ },
338
+
339
+ async putObjectLegalHold(input) {
340
+ const url = buildS3Url(input.bucket, input.key, input.region, options.endpointOverride);
341
+ url.searchParams.set("legal-hold", "");
342
+ if (input.versionId) {
343
+ url.searchParams.set("versionId", input.versionId);
344
+ }
345
+
346
+ const body = `<LegalHold xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Status>${input.status}</Status></LegalHold>`;
347
+ const headers = new Headers({
348
+ "content-type": "application/xml",
349
+ "x-amz-checksum-sha256": await sha256Base64(textEncoder.encode(body)),
350
+ });
351
+ appendExpectedBucketOwner(headers, input.expectedBucketOwner);
352
+ const response = await signedFetch("PUT", url, input.region, headers, body);
353
+ await assertS3Response(response, "PutObjectLegalHold");
354
+ },
355
+
356
+ async listObjectVersions(input) {
357
+ const versions: S3ObjectVersion[] = [];
358
+ let keyMarker: string | null = null;
359
+ let versionIdMarker: string | null = null;
360
+
361
+ while (true) {
362
+ const url = buildS3Url(input.bucket, null, input.region, options.endpointOverride);
363
+ url.searchParams.set("versions", "");
364
+ url.searchParams.set("prefix", input.key);
365
+ url.searchParams.set("max-keys", "1000");
366
+ if (keyMarker) {
367
+ url.searchParams.set("key-marker", keyMarker);
368
+ }
369
+ if (versionIdMarker) {
370
+ url.searchParams.set("version-id-marker", versionIdMarker);
371
+ }
372
+
373
+ const headers = new Headers();
374
+ appendExpectedBucketOwner(headers, input.expectedBucketOwner);
375
+ const response = await signedFetch("GET", url, input.region, headers);
376
+ await assertS3Response(response, "ListObjectVersions");
377
+ const parsed = parseListObjectVersionsXml(await response.text(), input.key);
378
+ versions.push(...parsed.versions);
379
+
380
+ if (!parsed.isTruncated) {
381
+ break;
382
+ }
383
+
384
+ keyMarker = parsed.nextKeyMarker;
385
+ versionIdMarker = parsed.nextVersionIdMarker;
386
+ if (!keyMarker || !versionIdMarker) {
387
+ fail({
388
+ code: "S3_VERSION_PAGINATION_INVALID",
389
+ title: "S3 version pagination invalid",
390
+ detail: "ListObjectVersions returned truncated=true without next markers.",
391
+ category: "external",
392
+ retryable: true,
393
+ });
394
+ }
395
+ }
396
+
397
+ return versions;
398
+ },
399
+
400
+ async deleteObjectVersion(input) {
401
+ const url = buildS3Url(input.bucket, input.key, input.region, options.endpointOverride);
402
+ if (input.versionId) {
403
+ url.searchParams.set("versionId", input.versionId);
404
+ }
405
+ const headers = new Headers();
406
+ appendExpectedBucketOwner(headers, input.expectedBucketOwner);
407
+ if (input.bypassGovernanceRetention) {
408
+ headers.set("x-amz-bypass-governance-retention", "true");
409
+ }
410
+
411
+ const response = await signedFetch("DELETE", url, input.region, headers);
412
+ await assertS3Response(response, "DeleteObject");
413
+
414
+ return {
415
+ key: input.key,
416
+ versionId: response.headers.get("x-amz-version-id") ?? input.versionId ?? null,
417
+ deleteMarker: response.headers.get("x-amz-delete-marker") === "true",
418
+ status: response.status,
419
+ };
420
+ },
421
+
422
+ async putObject(input) {
423
+ const url = buildS3Url(input.bucket, input.key, input.region, options.endpointOverride);
424
+ const headers = new Headers({
425
+ "content-type": input.contentType,
426
+ "content-length": String(input.body.length),
427
+ "x-amz-checksum-sha256": bytesToBase64(
428
+ new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", input.body.slice().buffer as ArrayBuffer))
429
+ ),
430
+ });
431
+ appendExpectedBucketOwner(headers, input.expectedBucketOwner);
432
+ const response = await signedFetch("PUT", url, input.region, headers, input.body);
433
+ await assertS3Response(response, "PutObject");
434
+
435
+ return {
436
+ key: input.key,
437
+ versionId: response.headers.get("x-amz-version-id"),
438
+ eTag: stripQuotedHeader(response.headers.get("etag")),
439
+ status: response.status,
440
+ };
441
+ },
442
+ };
443
+ }
444
+
@@ -0,0 +1,271 @@
1
+ import { CODE, fail } from "@/errors";
2
+ import z from "zod";
3
+ import type { S3AwsCredentials } from "./type";
4
+
5
+ const ECS_CREDENTIAL_ENDPOINT = "http://169.254.170.2";
6
+ const EC2_METADATA_ENDPOINT = "http://169.254.169.254";
7
+ const ALLOWED_HTTP_FULL_URI_HOSTS = new Set([
8
+ "localhost",
9
+ "127.0.0.1",
10
+ "[::1]",
11
+ "169.254.170.2",
12
+ "169.254.170.23",
13
+ ]);
14
+
15
+ const awsCredentialResponseSchema = z.object({
16
+ AccessKeyId: z.string().min(1),
17
+ SecretAccessKey: z.string().min(1),
18
+ Token: z.string().min(1).optional(),
19
+ Expiration: z.string().datetime().optional(),
20
+ });
21
+
22
+ interface AwsCredentialProviderOptions {
23
+ env?: Record<string, string | undefined>;
24
+ fetchFn?: typeof fetch;
25
+ timeoutMs?: number;
26
+ }
27
+
28
+ function validateContainerCredentialsFullUri(value: string): string {
29
+ let url: URL;
30
+ try {
31
+ url = new URL(value);
32
+ } catch {
33
+ fail({
34
+ code: "AWS_CREDENTIALS_URI_INVALID",
35
+ title: "AWS credential URI invalid",
36
+ detail: "AWS_CONTAINER_CREDENTIALS_FULL_URI must be a valid URL.",
37
+ category: "configuration",
38
+ retryable: false,
39
+ fatal: true,
40
+ });
41
+ }
42
+
43
+ if (url.protocol === "https:") {
44
+ return url.toString();
45
+ }
46
+
47
+ if (url.protocol === "http:" && ALLOWED_HTTP_FULL_URI_HOSTS.has(url.hostname)) {
48
+ return url.toString();
49
+ }
50
+
51
+ fail({
52
+ code: "AWS_CREDENTIALS_URI_REJECTED",
53
+ title: "AWS credential URI rejected",
54
+ detail: "HTTP container credential endpoints must be loopback or AWS container metadata endpoints.",
55
+ category: "configuration",
56
+ retryable: false,
57
+ fatal: true,
58
+ context: { host: url.hostname, protocol: url.protocol },
59
+ });
60
+ }
61
+
62
+ async function resolveContainerAuthorizationHeader(env: Record<string, string | undefined>): Promise<Record<string, string> | undefined> {
63
+ if (env.AWS_CONTAINER_AUTHORIZATION_TOKEN) {
64
+ return { authorization: env.AWS_CONTAINER_AUTHORIZATION_TOKEN };
65
+ }
66
+
67
+ if (!env.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE) {
68
+ return undefined;
69
+ }
70
+
71
+ let token: string;
72
+ try {
73
+ token = (await Bun.file(env.AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE).text()).trim();
74
+ } catch (error) {
75
+ fail({
76
+ code: "AWS_CREDENTIALS_TOKEN_UNAVAILABLE",
77
+ title: "AWS credential token unavailable",
78
+ detail: error instanceof Error ? error.message : "AWS container authorization token file could not be read.",
79
+ category: "configuration",
80
+ retryable: false,
81
+ fatal: true,
82
+ });
83
+ }
84
+
85
+ if (!token) {
86
+ fail({
87
+ code: "AWS_CREDENTIALS_TOKEN_EMPTY",
88
+ title: "AWS credential token empty",
89
+ detail: "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE did not contain a token.",
90
+ category: "configuration",
91
+ retryable: false,
92
+ fatal: true,
93
+ });
94
+ }
95
+
96
+ return { authorization: token };
97
+ }
98
+
99
+ async function fetchWithTimeout(fetchFn: typeof fetch, url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
100
+ const controller = new AbortController();
101
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
102
+
103
+ try {
104
+ return await fetchFn(url, {
105
+ ...init,
106
+ signal: controller.signal,
107
+ redirect: "error",
108
+ });
109
+ } catch (error) {
110
+ fail({
111
+ code: CODE.AWS_CREDENTIALS_UNAVAILABLE,
112
+ detail: error instanceof Error ? error.message : "AWS credential endpoint could not be reached.",
113
+ context: { url },
114
+ });
115
+ } finally {
116
+ clearTimeout(timeout);
117
+ }
118
+ }
119
+
120
+ async function readJsonCredentials(fetchFn: typeof fetch, url: string, init: RequestInit, timeoutMs: number): Promise<S3AwsCredentials> {
121
+ const response = await fetchWithTimeout(fetchFn, url, init, timeoutMs);
122
+ if (!response.ok) {
123
+ fail({
124
+ code: CODE.AWS_CREDENTIALS_UNAVAILABLE,
125
+ detail: `AWS credential endpoint returned HTTP ${response.status}.`,
126
+ retryable: response.status >= 500 || response.status === 429,
127
+ fatal: response.status >= 400 && response.status < 500 && response.status !== 429,
128
+ context: { url },
129
+ });
130
+ }
131
+
132
+ let body: unknown;
133
+ try {
134
+ body = await response.json();
135
+ } catch {
136
+ fail({
137
+ code: CODE.AWS_CREDENTIALS_INVALID,
138
+ detail: "AWS credential endpoint returned non-JSON credentials.",
139
+ context: { url },
140
+ });
141
+ }
142
+
143
+ const parsed = awsCredentialResponseSchema.safeParse(body);
144
+ if (!parsed.success) {
145
+ fail({
146
+ code: CODE.AWS_CREDENTIALS_INVALID,
147
+ detail: "AWS credential endpoint returned an unexpected response shape.",
148
+ context: {
149
+ url,
150
+ issues: parsed.error.issues.map((issue) => ({
151
+ path: issue.path.join("."),
152
+ message: issue.message,
153
+ })),
154
+ },
155
+ });
156
+ }
157
+
158
+ return {
159
+ accessKeyId: parsed.data.AccessKeyId,
160
+ secretAccessKey: parsed.data.SecretAccessKey,
161
+ sessionToken: parsed.data.Token,
162
+ expiration: parsed.data.Expiration ? new Date(parsed.data.Expiration) : undefined,
163
+ };
164
+ }
165
+
166
+ async function resolveEc2RoleName(fetchFn: typeof fetch, timeoutMs: number): Promise<string> {
167
+ const tokenResponse = await fetchWithTimeout(
168
+ fetchFn,
169
+ `${EC2_METADATA_ENDPOINT}/latest/api/token`,
170
+ {
171
+ method: "PUT",
172
+ headers: {
173
+ "x-aws-ec2-metadata-token-ttl-seconds": "21600",
174
+ },
175
+ },
176
+ timeoutMs
177
+ );
178
+ if (!tokenResponse.ok) {
179
+ fail({
180
+ code: CODE.AWS_CREDENTIALS_UNAVAILABLE,
181
+ detail: `EC2 metadata token endpoint returned HTTP ${tokenResponse.status}.`,
182
+ });
183
+ }
184
+
185
+ const token = await tokenResponse.text();
186
+ const roleResponse = await fetchWithTimeout(
187
+ fetchFn,
188
+ `${EC2_METADATA_ENDPOINT}/latest/meta-data/iam/security-credentials/`,
189
+ {
190
+ headers: {
191
+ "x-aws-ec2-metadata-token": token,
192
+ },
193
+ },
194
+ timeoutMs
195
+ );
196
+ if (!roleResponse.ok) {
197
+ fail({
198
+ code: CODE.AWS_CREDENTIALS_UNAVAILABLE,
199
+ detail: `EC2 metadata role endpoint returned HTTP ${roleResponse.status}.`,
200
+ });
201
+ }
202
+
203
+ const roleName = (await roleResponse.text()).trim().split("\n")[0];
204
+ if (!roleName) {
205
+ fail({
206
+ code: CODE.AWS_CREDENTIALS_UNAVAILABLE,
207
+ detail: "EC2 metadata did not return an IAM role name.",
208
+ });
209
+ }
210
+
211
+ return roleName;
212
+ }
213
+
214
+ /**
215
+ * Resolves AWS credentials from env, ECS task metadata, or EC2 IMDSv2.
216
+ *
217
+ * @param options - Environment, fetch implementation, and metadata timeout overrides.
218
+ * @returns Temporary or static AWS credentials used for SigV4 requests.
219
+ * @throws {WorkerError} When no credential source can be resolved.
220
+ */
221
+ export async function resolveAwsCredentials(options: AwsCredentialProviderOptions = {}): Promise<S3AwsCredentials> {
222
+ const env = options.env ?? process.env;
223
+ const fetchFn = options.fetchFn ?? fetch;
224
+ const timeoutMs = options.timeoutMs ?? 2_000;
225
+
226
+ if (env.AWS_ACCESS_KEY_ID && env.AWS_SECRET_ACCESS_KEY) {
227
+ return {
228
+ accessKeyId: env.AWS_ACCESS_KEY_ID,
229
+ secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
230
+ sessionToken: env.AWS_SESSION_TOKEN,
231
+ };
232
+ }
233
+
234
+ if (env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) {
235
+ return readJsonCredentials(
236
+ fetchFn,
237
+ `${ECS_CREDENTIAL_ENDPOINT}${env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}`,
238
+ {},
239
+ timeoutMs
240
+ );
241
+ }
242
+
243
+ if (env.AWS_CONTAINER_CREDENTIALS_FULL_URI) {
244
+ return readJsonCredentials(
245
+ fetchFn,
246
+ validateContainerCredentialsFullUri(env.AWS_CONTAINER_CREDENTIALS_FULL_URI),
247
+ { headers: await resolveContainerAuthorizationHeader(env) },
248
+ timeoutMs
249
+ );
250
+ }
251
+
252
+ if (env.AWS_EC2_METADATA_DISABLED !== "true") {
253
+ const roleName = await resolveEc2RoleName(fetchFn, timeoutMs);
254
+ return readJsonCredentials(
255
+ fetchFn,
256
+ `${EC2_METADATA_ENDPOINT}/latest/meta-data/iam/security-credentials/${encodeURIComponent(roleName)}`,
257
+ {},
258
+ timeoutMs
259
+ );
260
+ }
261
+
262
+ fail({
263
+ code: "AWS_CREDENTIALS_MISSING",
264
+ title: "AWS credentials missing",
265
+ detail: "Set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or run the worker with an ECS/EC2 IAM role.",
266
+ category: "configuration",
267
+ retryable: false,
268
+ fatal: true,
269
+ });
270
+ }
271
+