@stacksjs/ts-cloud 0.1.1

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 (117) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +321 -0
  3. package/bin/cli.ts +133 -0
  4. package/bin/commands/analytics.ts +328 -0
  5. package/bin/commands/api.ts +379 -0
  6. package/bin/commands/assets.ts +221 -0
  7. package/bin/commands/audit.ts +501 -0
  8. package/bin/commands/backup.ts +682 -0
  9. package/bin/commands/cache.ts +294 -0
  10. package/bin/commands/cdn.ts +281 -0
  11. package/bin/commands/config.ts +202 -0
  12. package/bin/commands/container.ts +105 -0
  13. package/bin/commands/cost.ts +208 -0
  14. package/bin/commands/database.ts +401 -0
  15. package/bin/commands/deploy.ts +674 -0
  16. package/bin/commands/domain.ts +397 -0
  17. package/bin/commands/email.ts +423 -0
  18. package/bin/commands/environment.ts +285 -0
  19. package/bin/commands/events.ts +424 -0
  20. package/bin/commands/firewall.ts +145 -0
  21. package/bin/commands/function.ts +116 -0
  22. package/bin/commands/generate.ts +280 -0
  23. package/bin/commands/git.ts +139 -0
  24. package/bin/commands/iam.ts +464 -0
  25. package/bin/commands/index.ts +48 -0
  26. package/bin/commands/init.ts +120 -0
  27. package/bin/commands/logs.ts +148 -0
  28. package/bin/commands/network.ts +579 -0
  29. package/bin/commands/notify.ts +489 -0
  30. package/bin/commands/queue.ts +407 -0
  31. package/bin/commands/scheduler.ts +370 -0
  32. package/bin/commands/secrets.ts +54 -0
  33. package/bin/commands/server.ts +629 -0
  34. package/bin/commands/shared.ts +97 -0
  35. package/bin/commands/ssl.ts +138 -0
  36. package/bin/commands/stack.ts +325 -0
  37. package/bin/commands/status.ts +385 -0
  38. package/bin/commands/storage.ts +450 -0
  39. package/bin/commands/team.ts +96 -0
  40. package/bin/commands/tunnel.ts +489 -0
  41. package/bin/commands/utils.ts +202 -0
  42. package/build.ts +15 -0
  43. package/cloud +2 -0
  44. package/package.json +99 -0
  45. package/src/aws/acm.ts +768 -0
  46. package/src/aws/application-autoscaling.ts +845 -0
  47. package/src/aws/bedrock.ts +4074 -0
  48. package/src/aws/client.ts +878 -0
  49. package/src/aws/cloudformation.ts +896 -0
  50. package/src/aws/cloudfront.ts +1531 -0
  51. package/src/aws/cloudwatch-logs.ts +154 -0
  52. package/src/aws/comprehend.ts +839 -0
  53. package/src/aws/connect.ts +1056 -0
  54. package/src/aws/deploy-imap.ts +384 -0
  55. package/src/aws/dynamodb.ts +340 -0
  56. package/src/aws/ec2.ts +1385 -0
  57. package/src/aws/ecr.ts +621 -0
  58. package/src/aws/ecs.ts +615 -0
  59. package/src/aws/elasticache.ts +301 -0
  60. package/src/aws/elbv2.ts +942 -0
  61. package/src/aws/email.ts +928 -0
  62. package/src/aws/eventbridge.ts +248 -0
  63. package/src/aws/iam.ts +1689 -0
  64. package/src/aws/imap-server.ts +2100 -0
  65. package/src/aws/index.ts +213 -0
  66. package/src/aws/kendra.ts +1097 -0
  67. package/src/aws/lambda.ts +786 -0
  68. package/src/aws/opensearch.ts +158 -0
  69. package/src/aws/personalize.ts +977 -0
  70. package/src/aws/polly.ts +559 -0
  71. package/src/aws/rds.ts +888 -0
  72. package/src/aws/rekognition.ts +846 -0
  73. package/src/aws/route53-domains.ts +359 -0
  74. package/src/aws/route53.ts +1046 -0
  75. package/src/aws/s3.ts +2318 -0
  76. package/src/aws/scheduler.ts +571 -0
  77. package/src/aws/secrets-manager.ts +769 -0
  78. package/src/aws/ses.ts +1081 -0
  79. package/src/aws/setup-phone.ts +104 -0
  80. package/src/aws/setup-sms.ts +580 -0
  81. package/src/aws/sms.ts +1735 -0
  82. package/src/aws/smtp-server.ts +531 -0
  83. package/src/aws/sns.ts +758 -0
  84. package/src/aws/sqs.ts +382 -0
  85. package/src/aws/ssm.ts +807 -0
  86. package/src/aws/sts.ts +92 -0
  87. package/src/aws/support.ts +391 -0
  88. package/src/aws/test-imap.ts +86 -0
  89. package/src/aws/textract.ts +780 -0
  90. package/src/aws/transcribe.ts +108 -0
  91. package/src/aws/translate.ts +641 -0
  92. package/src/aws/voice.ts +1379 -0
  93. package/src/config.ts +35 -0
  94. package/src/deploy/index.ts +7 -0
  95. package/src/deploy/static-site-external-dns.ts +906 -0
  96. package/src/deploy/static-site.ts +1125 -0
  97. package/src/dns/godaddy.ts +412 -0
  98. package/src/dns/index.ts +183 -0
  99. package/src/dns/porkbun.ts +362 -0
  100. package/src/dns/route53-adapter.ts +414 -0
  101. package/src/dns/types.ts +114 -0
  102. package/src/dns/validator.ts +369 -0
  103. package/src/generators/index.ts +5 -0
  104. package/src/generators/infrastructure.ts +1660 -0
  105. package/src/index.ts +163 -0
  106. package/src/push/apns.ts +452 -0
  107. package/src/push/fcm.ts +506 -0
  108. package/src/push/index.ts +58 -0
  109. package/src/ssl/acme-client.ts +478 -0
  110. package/src/ssl/index.ts +7 -0
  111. package/src/ssl/letsencrypt.ts +747 -0
  112. package/src/types.ts +2 -0
  113. package/src/utils/cli.ts +398 -0
  114. package/src/validation/index.ts +5 -0
  115. package/src/validation/template.ts +405 -0
  116. package/test/index.test.ts +128 -0
  117. package/tsconfig.json +18 -0
package/src/aws/s3.ts ADDED
@@ -0,0 +1,2318 @@
1
+ /**
2
+ * AWS S3 Operations
3
+ * Direct API calls without AWS CLI dependency
4
+ */
5
+
6
+ import * as crypto from 'node:crypto'
7
+ import { AWSClient } from './client'
8
+ import { readdir, stat } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+ import { readFileSync } from 'node:fs'
11
+
12
+ export interface S3SyncOptions {
13
+ source: string
14
+ bucket: string
15
+ prefix?: string
16
+ delete?: boolean
17
+ acl?: 'private' | 'public-read' | 'public-read-write' | 'authenticated-read'
18
+ cacheControl?: string
19
+ contentType?: string
20
+ metadata?: Record<string, string>
21
+ exclude?: string[]
22
+ include?: string[]
23
+ dryRun?: boolean
24
+ }
25
+
26
+ export interface S3CopyOptions {
27
+ source: string
28
+ bucket: string
29
+ key: string
30
+ acl?: 'private' | 'public-read' | 'public-read-write' | 'authenticated-read'
31
+ cacheControl?: string
32
+ contentType?: string
33
+ metadata?: Record<string, string>
34
+ }
35
+
36
+ export interface S3ListOptions {
37
+ bucket: string
38
+ prefix?: string
39
+ maxKeys?: number
40
+ }
41
+
42
+ export interface S3Object {
43
+ Key: string
44
+ LastModified: string
45
+ Size: number
46
+ ETag?: string
47
+ }
48
+
49
+ /**
50
+ * S3 client using direct API calls
51
+ */
52
+ export class S3Client {
53
+ private client: AWSClient
54
+ private region: string
55
+
56
+ constructor(region: string = 'us-east-1', profile?: string) {
57
+ this.region = region
58
+ this.client = new AWSClient()
59
+ }
60
+
61
+ /**
62
+ * Get AWS credentials from environment or credentials file
63
+ */
64
+ private getCredentials(): { accessKeyId: string, secretAccessKey: string, sessionToken?: string } {
65
+ // 1. Check environment variables first
66
+ const envAccessKey = process.env.AWS_ACCESS_KEY_ID
67
+ const envSecretKey = process.env.AWS_SECRET_ACCESS_KEY
68
+ const envSessionToken = process.env.AWS_SESSION_TOKEN
69
+
70
+ if (envAccessKey && envSecretKey) {
71
+ return { accessKeyId: envAccessKey, secretAccessKey: envSecretKey, sessionToken: envSessionToken }
72
+ }
73
+
74
+ // 2. Try to load from ~/.aws/credentials file
75
+ const fileCreds = this.loadCredentialsFromFile()
76
+ if (fileCreds) {
77
+ return fileCreds
78
+ }
79
+
80
+ throw new Error('AWS credentials not found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables, or configure ~/.aws/credentials.')
81
+ }
82
+
83
+ /**
84
+ * Load credentials from ~/.aws/credentials file
85
+ */
86
+ private loadCredentialsFromFile(): { accessKeyId: string, secretAccessKey: string, sessionToken?: string } | null {
87
+ try {
88
+ const { existsSync, readFileSync } = require('node:fs')
89
+ const { homedir } = require('node:os')
90
+ const { join: pathJoin } = require('node:path')
91
+
92
+ const profile = process.env.AWS_PROFILE || 'default'
93
+ const credentialsPath = process.env.AWS_SHARED_CREDENTIALS_FILE || pathJoin(homedir(), '.aws', 'credentials')
94
+
95
+ if (!existsSync(credentialsPath)) {
96
+ return null
97
+ }
98
+
99
+ const content = readFileSync(credentialsPath, 'utf-8')
100
+ const lines = content.split('\n')
101
+ let currentProfile = ''
102
+ let accessKeyId = ''
103
+ let secretAccessKey = ''
104
+ let sessionToken: string | undefined
105
+
106
+ for (const line of lines) {
107
+ const trimmed = line.trim()
108
+ if (!trimmed || trimmed.startsWith('#')) continue
109
+
110
+ const profileMatch = trimmed.match(/^\[([^\]]+)\]$/)
111
+ if (profileMatch) {
112
+ currentProfile = profileMatch[1]
113
+ continue
114
+ }
115
+
116
+ if (currentProfile === profile) {
117
+ const [key, ...valueParts] = trimmed.split('=')
118
+ const value = valueParts.join('=').trim()
119
+
120
+ if (key.trim() === 'aws_access_key_id') {
121
+ accessKeyId = value
122
+ } else if (key.trim() === 'aws_secret_access_key') {
123
+ secretAccessKey = value
124
+ } else if (key.trim() === 'aws_session_token') {
125
+ sessionToken = value
126
+ }
127
+ }
128
+ }
129
+
130
+ if (accessKeyId && secretAccessKey) {
131
+ return { accessKeyId, secretAccessKey, sessionToken }
132
+ }
133
+ } catch {
134
+ // Failed to read credentials file
135
+ }
136
+
137
+ return null
138
+ }
139
+
140
+ /**
141
+ * List all S3 buckets in the account
142
+ */
143
+ async listBuckets(): Promise<{ Buckets: Array<{ Name: string, CreationDate?: string }> }> {
144
+ const result = await this.client.request({
145
+ service: 's3',
146
+ region: this.region,
147
+ method: 'GET',
148
+ path: '/',
149
+ })
150
+
151
+ const buckets: Array<{ Name: string, CreationDate?: string }> = []
152
+ const bucketList = result?.ListAllMyBucketsResult?.Buckets?.Bucket
153
+
154
+ if (bucketList) {
155
+ const list = Array.isArray(bucketList) ? bucketList : [bucketList]
156
+ for (const b of list) {
157
+ buckets.push({
158
+ Name: b.Name,
159
+ CreationDate: b.CreationDate,
160
+ })
161
+ }
162
+ }
163
+
164
+ return { Buckets: buckets }
165
+ }
166
+
167
+ /**
168
+ * Create an S3 bucket
169
+ */
170
+ async createBucket(bucket: string, options?: { acl?: string }): Promise<void> {
171
+ const headers: Record<string, string> = {}
172
+ if (options?.acl) {
173
+ headers['x-amz-acl'] = options.acl
174
+ }
175
+
176
+ // For us-east-1, don't include LocationConstraint
177
+ // For other regions, include it in the body
178
+ let body: string | undefined
179
+ if (this.region !== 'us-east-1') {
180
+ body = `<?xml version="1.0" encoding="UTF-8"?>
181
+ <CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
182
+ <LocationConstraint>${this.region}</LocationConstraint>
183
+ </CreateBucketConfiguration>`
184
+ headers['Content-Type'] = 'application/xml'
185
+ }
186
+
187
+ await this.client.request({
188
+ service: 's3',
189
+ region: this.region,
190
+ method: 'PUT',
191
+ path: `/${bucket}`,
192
+ headers,
193
+ body,
194
+ })
195
+ }
196
+
197
+ /**
198
+ * Delete an S3 bucket (must be empty)
199
+ */
200
+ async deleteBucket(bucket: string): Promise<void> {
201
+ await this.client.request({
202
+ service: 's3',
203
+ region: this.region,
204
+ method: 'DELETE',
205
+ path: `/${bucket}`,
206
+ })
207
+ }
208
+
209
+ /**
210
+ * Empty and delete an S3 bucket
211
+ */
212
+ async emptyAndDeleteBucket(bucket: string): Promise<void> {
213
+ // First list and delete all objects
214
+ let hasMore = true
215
+ while (hasMore) {
216
+ const objects = await this.listAllObjects({ bucket })
217
+ if (objects.length === 0) {
218
+ hasMore = false
219
+ break
220
+ }
221
+
222
+ // Delete in batches of 1000 (S3 limit)
223
+ const keys = objects.map(obj => obj.Key)
224
+ for (let i = 0; i < keys.length; i += 1000) {
225
+ const batch = keys.slice(i, i + 1000)
226
+ await this.deleteObjects(bucket, batch)
227
+ }
228
+ }
229
+
230
+ // Now delete the bucket
231
+ await this.deleteBucket(bucket)
232
+ }
233
+
234
+ /**
235
+ * List all objects in a bucket (handles pagination)
236
+ */
237
+ async listAllObjects(options: S3ListOptions): Promise<S3Object[]> {
238
+ const allObjects: S3Object[] = []
239
+ let continuationToken: string | undefined
240
+
241
+ do {
242
+ const params: Record<string, any> = {
243
+ 'list-type': '2',
244
+ 'max-keys': '1000',
245
+ }
246
+
247
+ if (options.prefix) {
248
+ params.prefix = options.prefix
249
+ }
250
+
251
+ if (continuationToken) {
252
+ params['continuation-token'] = continuationToken
253
+ }
254
+
255
+ const result = await this.client.request({
256
+ service: 's3',
257
+ region: this.region,
258
+ method: 'GET',
259
+ path: `/${options.bucket}`,
260
+ queryParams: params,
261
+ })
262
+
263
+ const contents = result?.ListBucketResult?.Contents
264
+ if (contents) {
265
+ const list = Array.isArray(contents) ? contents : [contents]
266
+ for (const obj of list) {
267
+ allObjects.push({
268
+ Key: obj.Key,
269
+ LastModified: obj.LastModified || '',
270
+ Size: Number.parseInt(obj.Size || '0'),
271
+ ETag: obj.ETag,
272
+ })
273
+ }
274
+ }
275
+
276
+ // Check for more results
277
+ const isTruncated = result?.ListBucketResult?.IsTruncated
278
+ continuationToken = isTruncated === 'true' || isTruncated === true
279
+ ? result?.ListBucketResult?.NextContinuationToken
280
+ : undefined
281
+
282
+ } while (continuationToken)
283
+
284
+ return allObjects
285
+ }
286
+
287
+ /**
288
+ * List objects in S3 bucket
289
+ */
290
+ async list(options: S3ListOptions): Promise<S3Object[]> {
291
+ // Use path-style URL without query params for simpler signing
292
+ const result = await this.client.request({
293
+ service: 's3',
294
+ region: this.region,
295
+ method: 'GET',
296
+ path: `/${options.bucket}`,
297
+ })
298
+
299
+ // Parse S3 XML response
300
+ const objects: S3Object[] = []
301
+
302
+ // Handle ListBucketResult structure from XML parsing
303
+ const contents = result?.ListBucketResult?.Contents
304
+ if (contents) {
305
+ const items = Array.isArray(contents) ? contents : [contents]
306
+ for (const item of items) {
307
+ // Filter by prefix if specified
308
+ if (options.prefix && !item.Key?.startsWith(options.prefix)) {
309
+ continue
310
+ }
311
+ objects.push({
312
+ Key: item.Key || '',
313
+ LastModified: item.LastModified || '',
314
+ Size: Number.parseInt(item.Size || '0'),
315
+ ETag: item.ETag,
316
+ })
317
+ // Respect maxKeys
318
+ if (options.maxKeys && objects.length >= options.maxKeys) {
319
+ break
320
+ }
321
+ }
322
+ }
323
+
324
+ return objects
325
+ }
326
+
327
+ /**
328
+ * Put object to S3 bucket
329
+ */
330
+ async putObject(options: {
331
+ bucket: string
332
+ key: string
333
+ body: string | Buffer | Uint8Array
334
+ acl?: string
335
+ cacheControl?: string
336
+ contentType?: string
337
+ metadata?: Record<string, string>
338
+ }): Promise<void> {
339
+ const headers: Record<string, string> = {}
340
+
341
+ if (options.acl) {
342
+ headers['x-amz-acl'] = options.acl
343
+ }
344
+
345
+ if (options.cacheControl) {
346
+ headers['Cache-Control'] = options.cacheControl
347
+ }
348
+
349
+ if (options.contentType) {
350
+ headers['Content-Type'] = options.contentType
351
+ }
352
+
353
+ if (options.metadata) {
354
+ for (const [key, value] of Object.entries(options.metadata)) {
355
+ headers[`x-amz-meta-${key}`] = value
356
+ }
357
+ }
358
+
359
+ // Normalize body to Buffer for binary data
360
+ // Uint8Array needs to be converted to Buffer for proper handling
361
+ const normalizedBody = options.body instanceof Uint8Array && !Buffer.isBuffer(options.body)
362
+ ? Buffer.from(options.body)
363
+ : options.body
364
+
365
+ // For binary data (Buffer/Uint8Array), use direct binary upload
366
+ if (Buffer.isBuffer(normalizedBody) || (normalizedBody as any) instanceof Uint8Array) {
367
+ const binaryBody = Buffer.isBuffer(normalizedBody) ? normalizedBody : Buffer.from(normalizedBody)
368
+ // Actually, for S3 we need to send raw binary, not base64
369
+ // Let's use Bun's fetch which handles Buffer natively
370
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials()
371
+ const host = `${options.bucket}.s3.${this.region}.amazonaws.com`
372
+ const url = `https://${host}/${options.key}`
373
+
374
+ const now = new Date()
375
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
376
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
377
+
378
+ const payloadHash = crypto.createHash('sha256').update(binaryBody).digest('hex')
379
+
380
+ const requestHeaders: Record<string, string> = {
381
+ 'host': host,
382
+ 'x-amz-date': amzDate,
383
+ 'x-amz-content-sha256': payloadHash,
384
+ ...headers,
385
+ }
386
+
387
+ if (sessionToken) {
388
+ requestHeaders['x-amz-security-token'] = sessionToken
389
+ }
390
+
391
+ // Create canonical request
392
+ const canonicalHeaders = Object.keys(requestHeaders)
393
+ .sort()
394
+ .map(key => `${key.toLowerCase()}:${requestHeaders[key].trim()}\n`)
395
+ .join('')
396
+
397
+ const signedHeaders = Object.keys(requestHeaders)
398
+ .sort()
399
+ .map(key => key.toLowerCase())
400
+ .join(';')
401
+
402
+ const canonicalRequest = [
403
+ 'PUT',
404
+ `/${options.key}`,
405
+ '',
406
+ canonicalHeaders,
407
+ signedHeaders,
408
+ payloadHash,
409
+ ].join('\n')
410
+
411
+ // Create string to sign
412
+ const algorithm = 'AWS4-HMAC-SHA256'
413
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
414
+ const stringToSign = [
415
+ algorithm,
416
+ amzDate,
417
+ credentialScope,
418
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
419
+ ].join('\n')
420
+
421
+ // Calculate signature
422
+ const kDate = crypto.createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest()
423
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
424
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
425
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
426
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
427
+
428
+ const authorizationHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
429
+
430
+ const response = await fetch(url, {
431
+ method: 'PUT',
432
+ headers: {
433
+ ...requestHeaders,
434
+ 'Authorization': authorizationHeader,
435
+ },
436
+ body: binaryBody,
437
+ })
438
+
439
+ if (!response.ok) {
440
+ const errorText = await response.text()
441
+ throw new Error(`S3 PUT failed: ${response.status} ${errorText}`)
442
+ }
443
+
444
+ return
445
+ }
446
+
447
+ await this.client.request({
448
+ service: 's3',
449
+ region: this.region,
450
+ method: 'PUT',
451
+ path: `/${options.key}`,
452
+ bucket: options.bucket, // Use virtual-hosted style
453
+ headers,
454
+ body: options.body as string,
455
+ })
456
+ }
457
+
458
+ /**
459
+ * Get object from S3 bucket
460
+ * Returns raw content as string (not parsed as XML)
461
+ */
462
+ async getObject(bucket: string, key: string): Promise<string> {
463
+ const result = await this.client.request({
464
+ service: 's3',
465
+ region: this.region,
466
+ method: 'GET',
467
+ path: `/${bucket}/${key}`,
468
+ rawResponse: true,
469
+ })
470
+
471
+ return result
472
+ }
473
+
474
+ /**
475
+ * Copy object within S3 (server-side copy)
476
+ */
477
+ async copyObject(options: {
478
+ sourceBucket: string
479
+ sourceKey: string
480
+ destinationBucket: string
481
+ destinationKey: string
482
+ contentType?: string
483
+ metadata?: Record<string, string>
484
+ metadataDirective?: 'COPY' | 'REPLACE'
485
+ }): Promise<void> {
486
+ const headers: Record<string, string> = {
487
+ 'x-amz-copy-source': `/${options.sourceBucket}/${options.sourceKey}`,
488
+ }
489
+
490
+ if (options.metadataDirective) {
491
+ headers['x-amz-metadata-directive'] = options.metadataDirective
492
+ }
493
+
494
+ if (options.contentType) {
495
+ headers['Content-Type'] = options.contentType
496
+ }
497
+
498
+ if (options.metadata) {
499
+ for (const [key, value] of Object.entries(options.metadata)) {
500
+ headers[`x-amz-meta-${key}`] = value
501
+ }
502
+ }
503
+
504
+ await this.client.request({
505
+ service: 's3',
506
+ region: this.region,
507
+ method: 'PUT',
508
+ path: `/${options.destinationBucket}/${options.destinationKey}`,
509
+ headers,
510
+ })
511
+ }
512
+
513
+ /**
514
+ * Delete object from S3
515
+ */
516
+ async deleteObject(bucket: string, key: string): Promise<void> {
517
+ await this.client.request({
518
+ service: 's3',
519
+ region: this.region,
520
+ method: 'DELETE',
521
+ path: `/${bucket}/${key}`,
522
+ })
523
+ }
524
+
525
+ /**
526
+ * Delete multiple objects from S3
527
+ */
528
+ async deleteObjects(bucket: string, keys: string[]): Promise<void> {
529
+ const deleteXml = `<?xml version="1.0" encoding="UTF-8"?>
530
+ <Delete>
531
+ ${keys.map(key => `<Object><Key>${key}</Key></Object>`).join('\n ')}
532
+ </Delete>`
533
+
534
+ // S3 DeleteObjects requires Content-MD5 header
535
+ const contentMd5 = crypto.createHash('md5').update(deleteXml).digest('base64')
536
+
537
+ await this.client.request({
538
+ service: 's3',
539
+ region: this.region,
540
+ method: 'POST',
541
+ path: `/${bucket}`,
542
+ queryParams: { delete: '' },
543
+ body: deleteXml,
544
+ headers: {
545
+ 'Content-Type': 'application/xml',
546
+ 'Content-MD5': contentMd5,
547
+ },
548
+ })
549
+ }
550
+
551
+ /**
552
+ * Check if bucket exists
553
+ */
554
+ async bucketExists(bucket: string): Promise<boolean> {
555
+ try {
556
+ await this.client.request({
557
+ service: 's3',
558
+ region: this.region,
559
+ method: 'HEAD',
560
+ path: `/${bucket}`,
561
+ })
562
+ return true
563
+ }
564
+ catch {
565
+ return false
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Copy file to S3
571
+ */
572
+ async copy(options: S3CopyOptions): Promise<void> {
573
+ // Read file and upload
574
+ const fileContent = readFileSync(options.source)
575
+
576
+ await this.putObject({
577
+ bucket: options.bucket,
578
+ key: options.key,
579
+ body: fileContent,
580
+ acl: options.acl,
581
+ cacheControl: options.cacheControl,
582
+ contentType: options.contentType,
583
+ metadata: options.metadata,
584
+ })
585
+ }
586
+
587
+ /**
588
+ * Sync local directory to S3 bucket
589
+ * Note: This is a simplified version. For production use, implement proper sync logic
590
+ */
591
+ async sync(options: S3SyncOptions): Promise<void> {
592
+ const files = await this.listFilesRecursive(options.source)
593
+
594
+ for (const file of files) {
595
+ // Skip excluded files
596
+ if (options.exclude && options.exclude.some(pattern => file.includes(pattern))) {
597
+ continue
598
+ }
599
+
600
+ // Check included files
601
+ if (options.include && !options.include.some(pattern => file.includes(pattern))) {
602
+ continue
603
+ }
604
+
605
+ const relativePath = file.substring(options.source.length + 1)
606
+ const s3Key = options.prefix ? `${options.prefix}/${relativePath}` : relativePath
607
+
608
+ if (!options.dryRun) {
609
+ const fileContent = readFileSync(file)
610
+
611
+ await this.putObject({
612
+ bucket: options.bucket,
613
+ key: s3Key,
614
+ body: fileContent,
615
+ acl: options.acl,
616
+ cacheControl: options.cacheControl,
617
+ contentType: options.contentType,
618
+ metadata: options.metadata,
619
+ })
620
+ }
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Delete object from S3 (alias for deleteObject)
626
+ */
627
+ async delete(bucket: string, key: string): Promise<void> {
628
+ await this.deleteObject(bucket, key)
629
+ }
630
+
631
+ /**
632
+ * Delete all objects in a prefix
633
+ */
634
+ async deletePrefix(bucket: string, prefix: string): Promise<void> {
635
+ const objects = await this.list({ bucket, prefix })
636
+ const keys = objects.map(obj => obj.Key)
637
+
638
+ if (keys.length > 0) {
639
+ await this.deleteObjects(bucket, keys)
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Get bucket size
645
+ */
646
+ async getBucketSize(bucket: string, prefix?: string): Promise<number> {
647
+ const objects = await this.list({ bucket, prefix })
648
+ return objects.reduce((total, obj) => total + obj.Size, 0)
649
+ }
650
+
651
+ /**
652
+ * List files recursively in a directory
653
+ */
654
+ private async listFilesRecursive(dir: string): Promise<string[]> {
655
+ const files: string[] = []
656
+ const entries = await readdir(dir, { withFileTypes: true })
657
+
658
+ for (const entry of entries) {
659
+ const fullPath = join(dir, entry.name)
660
+
661
+ if (entry.isDirectory()) {
662
+ const subFiles = await this.listFilesRecursive(fullPath)
663
+ files.push(...subFiles)
664
+ }
665
+ else {
666
+ files.push(fullPath)
667
+ }
668
+ }
669
+
670
+ return files
671
+ }
672
+
673
+ /**
674
+ * Put bucket policy for an S3 bucket
675
+ * Uses path-style URLs to avoid redirect issues
676
+ */
677
+ async putBucketPolicy(bucket: string, policy: object | string): Promise<void> {
678
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials()
679
+ const host = `s3.${this.region}.amazonaws.com`
680
+ const policyString = typeof policy === 'string' ? policy : JSON.stringify(policy)
681
+
682
+ const now = new Date()
683
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
684
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
685
+
686
+ const payloadHash = crypto.createHash('sha256').update(policyString).digest('hex')
687
+
688
+ // Use path-style URL: s3.region.amazonaws.com/bucket?policy
689
+ const canonicalUri = '/' + bucket
690
+ const canonicalQuerystring = 'policy='
691
+
692
+ const requestHeaders: Record<string, string> = {
693
+ 'host': host,
694
+ 'x-amz-date': amzDate,
695
+ 'x-amz-content-sha256': payloadHash,
696
+ 'content-type': 'application/json',
697
+ }
698
+
699
+ if (sessionToken) {
700
+ requestHeaders['x-amz-security-token'] = sessionToken
701
+ }
702
+
703
+ const canonicalHeaders = Object.keys(requestHeaders)
704
+ .sort()
705
+ .map(key => `${key.toLowerCase()}:${requestHeaders[key].trim()}\n`)
706
+ .join('')
707
+
708
+ const signedHeaders = Object.keys(requestHeaders)
709
+ .sort()
710
+ .map(key => key.toLowerCase())
711
+ .join(';')
712
+
713
+ const canonicalRequest = [
714
+ 'PUT',
715
+ canonicalUri,
716
+ canonicalQuerystring,
717
+ canonicalHeaders,
718
+ signedHeaders,
719
+ payloadHash,
720
+ ].join('\n')
721
+
722
+ const algorithm = 'AWS4-HMAC-SHA256'
723
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
724
+ const stringToSign = [
725
+ algorithm,
726
+ amzDate,
727
+ credentialScope,
728
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
729
+ ].join('\n')
730
+
731
+ const kDate = crypto.createHmac('sha256', 'AWS4' + secretAccessKey).update(dateStamp).digest()
732
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
733
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
734
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
735
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
736
+
737
+ const authHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
738
+
739
+ const url = `https://${host}${canonicalUri}?${canonicalQuerystring}`
740
+
741
+ const response = await fetch(url, {
742
+ method: 'PUT',
743
+ headers: {
744
+ ...requestHeaders,
745
+ 'Authorization': authHeader,
746
+ },
747
+ body: policyString,
748
+ })
749
+
750
+ if (!response.ok) {
751
+ const text = await response.text()
752
+ throw new Error(`Failed to put bucket policy: ${response.status} ${text}`)
753
+ }
754
+ }
755
+
756
+ /**
757
+ * Get bucket policy for an S3 bucket
758
+ * Uses path-style URLs to avoid redirect issues
759
+ */
760
+ async getBucketPolicy(bucket: string): Promise<object | null> {
761
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials()
762
+ const host = `s3.${this.region}.amazonaws.com`
763
+
764
+ const now = new Date()
765
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
766
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
767
+
768
+ const payloadHash = crypto.createHash('sha256').update('').digest('hex')
769
+
770
+ // Use path-style URL: s3.region.amazonaws.com/bucket?policy
771
+ const canonicalUri = '/' + bucket
772
+ const canonicalQuerystring = 'policy='
773
+
774
+ const requestHeaders: Record<string, string> = {
775
+ 'host': host,
776
+ 'x-amz-date': amzDate,
777
+ 'x-amz-content-sha256': payloadHash,
778
+ }
779
+
780
+ if (sessionToken) {
781
+ requestHeaders['x-amz-security-token'] = sessionToken
782
+ }
783
+
784
+ const canonicalHeaders = Object.keys(requestHeaders)
785
+ .sort()
786
+ .map(key => `${key.toLowerCase()}:${requestHeaders[key].trim()}\n`)
787
+ .join('')
788
+
789
+ const signedHeaders = Object.keys(requestHeaders)
790
+ .sort()
791
+ .map(key => key.toLowerCase())
792
+ .join(';')
793
+
794
+ const canonicalRequest = [
795
+ 'GET',
796
+ canonicalUri,
797
+ canonicalQuerystring,
798
+ canonicalHeaders,
799
+ signedHeaders,
800
+ payloadHash,
801
+ ].join('\n')
802
+
803
+ const algorithm = 'AWS4-HMAC-SHA256'
804
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
805
+ const stringToSign = [
806
+ algorithm,
807
+ amzDate,
808
+ credentialScope,
809
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
810
+ ].join('\n')
811
+
812
+ const kDate = crypto.createHmac('sha256', 'AWS4' + secretAccessKey).update(dateStamp).digest()
813
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
814
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
815
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
816
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
817
+
818
+ const authHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
819
+
820
+ const url = `https://${host}${canonicalUri}?${canonicalQuerystring}`
821
+
822
+ const response = await fetch(url, {
823
+ method: 'GET',
824
+ headers: {
825
+ ...requestHeaders,
826
+ 'Authorization': authHeader,
827
+ },
828
+ })
829
+
830
+ if (response.status === 404) {
831
+ return null
832
+ }
833
+
834
+ if (!response.ok) {
835
+ const text = await response.text()
836
+ throw new Error(`Failed to get bucket policy: ${response.status} ${text}`)
837
+ }
838
+
839
+ const text = await response.text()
840
+ return JSON.parse(text)
841
+ }
842
+
843
+ /**
844
+ * Delete bucket policy
845
+ */
846
+ async deleteBucketPolicy(bucket: string): Promise<void> {
847
+ await this.client.request({
848
+ service: 's3',
849
+ region: this.region,
850
+ method: 'DELETE',
851
+ path: `/${bucket}`,
852
+ queryParams: { policy: '' },
853
+ })
854
+ }
855
+
856
+ /**
857
+ * Head bucket - check if bucket exists and you have access
858
+ */
859
+ async headBucket(bucket: string): Promise<{ exists: boolean; region?: string }> {
860
+ try {
861
+ const result = await this.client.request({
862
+ service: 's3',
863
+ region: this.region,
864
+ method: 'HEAD',
865
+ path: `/${bucket}`,
866
+ returnHeaders: true,
867
+ })
868
+ return { exists: true, region: result?.headers?.['x-amz-bucket-region'] }
869
+ } catch (e: any) {
870
+ if (e.statusCode === 404) {
871
+ return { exists: false }
872
+ }
873
+ throw e
874
+ }
875
+ }
876
+
877
+ /**
878
+ * Head object - get object metadata without downloading
879
+ */
880
+ async headObject(bucket: string, key: string): Promise<{
881
+ ContentLength?: number
882
+ ContentType?: string
883
+ ETag?: string
884
+ LastModified?: string
885
+ Metadata?: Record<string, string>
886
+ } | null> {
887
+ try {
888
+ const result = await this.client.request({
889
+ service: 's3',
890
+ region: this.region,
891
+ method: 'HEAD',
892
+ path: `/${bucket}/${key}`,
893
+ returnHeaders: true,
894
+ })
895
+ return {
896
+ ContentLength: result?.headers?.['content-length'] ? parseInt(result.headers['content-length']) : undefined,
897
+ ContentType: result?.headers?.['content-type'],
898
+ ETag: result?.headers?.['etag'],
899
+ LastModified: result?.headers?.['last-modified'],
900
+ }
901
+ } catch (e: any) {
902
+ if (e.statusCode === 404) {
903
+ return null
904
+ }
905
+ throw e
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Get object as Buffer
911
+ */
912
+ async getObjectBuffer(bucket: string, key: string): Promise<Buffer> {
913
+ const content = await this.getObject(bucket, key)
914
+ return Buffer.from(content)
915
+ }
916
+
917
+ /**
918
+ * Get object as JSON
919
+ */
920
+ async getObjectJson<T = any>(bucket: string, key: string): Promise<T> {
921
+ const content = await this.getObject(bucket, key)
922
+ return JSON.parse(content)
923
+ }
924
+
925
+ /**
926
+ * Put JSON object
927
+ */
928
+ async putObjectJson(bucket: string, key: string, data: any, options?: {
929
+ acl?: string
930
+ cacheControl?: string
931
+ metadata?: Record<string, string>
932
+ }): Promise<void> {
933
+ await this.putObject({
934
+ bucket,
935
+ key,
936
+ body: JSON.stringify(data),
937
+ contentType: 'application/json',
938
+ ...options,
939
+ })
940
+ }
941
+
942
+ /**
943
+ * Get bucket versioning configuration
944
+ */
945
+ async getBucketVersioning(bucket: string): Promise<{ Status?: 'Enabled' | 'Suspended' }> {
946
+ const result = await this.client.request({
947
+ service: 's3',
948
+ region: this.region,
949
+ method: 'GET',
950
+ path: `/${bucket}`,
951
+ queryParams: { versioning: '' },
952
+ })
953
+ return {
954
+ Status: result?.VersioningConfiguration?.Status,
955
+ }
956
+ }
957
+
958
+ /**
959
+ * Put bucket versioning configuration
960
+ */
961
+ async putBucketVersioning(bucket: string, status: 'Enabled' | 'Suspended'): Promise<void> {
962
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
963
+ <VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
964
+ <Status>${status}</Status>
965
+ </VersioningConfiguration>`
966
+
967
+ await this.client.request({
968
+ service: 's3',
969
+ region: this.region,
970
+ method: 'PUT',
971
+ path: `/${bucket}`,
972
+ queryParams: { versioning: '' },
973
+ headers: { 'Content-Type': 'application/xml' },
974
+ body,
975
+ })
976
+ }
977
+
978
+ /**
979
+ * Get bucket lifecycle configuration
980
+ */
981
+ async getBucketLifecycleConfiguration(bucket: string): Promise<any> {
982
+ try {
983
+ const result = await this.client.request({
984
+ service: 's3',
985
+ region: this.region,
986
+ method: 'GET',
987
+ path: `/${bucket}`,
988
+ queryParams: { lifecycle: '' },
989
+ })
990
+ return result?.LifecycleConfiguration
991
+ } catch (e: any) {
992
+ if (e.statusCode === 404) {
993
+ return null
994
+ }
995
+ throw e
996
+ }
997
+ }
998
+
999
+ /**
1000
+ * Put bucket lifecycle configuration
1001
+ */
1002
+ async putBucketLifecycleConfiguration(bucket: string, rules: Array<{
1003
+ ID: string
1004
+ Status: 'Enabled' | 'Disabled'
1005
+ Filter?: { Prefix?: string }
1006
+ Expiration?: { Days?: number; Date?: string }
1007
+ Transitions?: Array<{ Days?: number; StorageClass: string }>
1008
+ NoncurrentVersionExpiration?: { NoncurrentDays: number }
1009
+ }>): Promise<void> {
1010
+ const rulesXml = rules.map(rule => {
1011
+ let ruleXml = `<Rule><ID>${rule.ID}</ID><Status>${rule.Status}</Status>`
1012
+
1013
+ if (rule.Filter) {
1014
+ ruleXml += `<Filter><Prefix>${rule.Filter.Prefix || ''}</Prefix></Filter>`
1015
+ } else {
1016
+ ruleXml += '<Filter><Prefix></Prefix></Filter>'
1017
+ }
1018
+
1019
+ if (rule.Expiration) {
1020
+ if (rule.Expiration.Days) {
1021
+ ruleXml += `<Expiration><Days>${rule.Expiration.Days}</Days></Expiration>`
1022
+ } else if (rule.Expiration.Date) {
1023
+ ruleXml += `<Expiration><Date>${rule.Expiration.Date}</Date></Expiration>`
1024
+ }
1025
+ }
1026
+
1027
+ if (rule.Transitions) {
1028
+ for (const t of rule.Transitions) {
1029
+ ruleXml += `<Transition><Days>${t.Days}</Days><StorageClass>${t.StorageClass}</StorageClass></Transition>`
1030
+ }
1031
+ }
1032
+
1033
+ if (rule.NoncurrentVersionExpiration) {
1034
+ ruleXml += `<NoncurrentVersionExpiration><NoncurrentDays>${rule.NoncurrentVersionExpiration.NoncurrentDays}</NoncurrentDays></NoncurrentVersionExpiration>`
1035
+ }
1036
+
1037
+ ruleXml += '</Rule>'
1038
+ return ruleXml
1039
+ }).join('')
1040
+
1041
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1042
+ <LifecycleConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1043
+ ${rulesXml}
1044
+ </LifecycleConfiguration>`
1045
+
1046
+ await this.client.request({
1047
+ service: 's3',
1048
+ region: this.region,
1049
+ method: 'PUT',
1050
+ path: `/${bucket}`,
1051
+ queryParams: { lifecycle: '' },
1052
+ headers: { 'Content-Type': 'application/xml' },
1053
+ body,
1054
+ })
1055
+ }
1056
+
1057
+ /**
1058
+ * Delete bucket lifecycle configuration
1059
+ */
1060
+ async deleteBucketLifecycleConfiguration(bucket: string): Promise<void> {
1061
+ await this.client.request({
1062
+ service: 's3',
1063
+ region: this.region,
1064
+ method: 'DELETE',
1065
+ path: `/${bucket}`,
1066
+ queryParams: { lifecycle: '' },
1067
+ })
1068
+ }
1069
+
1070
+ /**
1071
+ * Get bucket CORS configuration
1072
+ */
1073
+ async getBucketCors(bucket: string): Promise<any> {
1074
+ try {
1075
+ const result = await this.client.request({
1076
+ service: 's3',
1077
+ region: this.region,
1078
+ method: 'GET',
1079
+ path: `/${bucket}`,
1080
+ queryParams: { cors: '' },
1081
+ })
1082
+ return result?.CORSConfiguration
1083
+ } catch (e: any) {
1084
+ if (e.statusCode === 404) {
1085
+ return null
1086
+ }
1087
+ throw e
1088
+ }
1089
+ }
1090
+
1091
+ /**
1092
+ * Put bucket CORS configuration
1093
+ */
1094
+ async putBucketCors(bucket: string, rules: Array<{
1095
+ AllowedOrigins: string[]
1096
+ AllowedMethods: string[]
1097
+ AllowedHeaders?: string[]
1098
+ ExposeHeaders?: string[]
1099
+ MaxAgeSeconds?: number
1100
+ }>): Promise<void> {
1101
+ const rulesXml = rules.map(rule => {
1102
+ let ruleXml = '<CORSRule>'
1103
+ for (const origin of rule.AllowedOrigins) {
1104
+ ruleXml += `<AllowedOrigin>${origin}</AllowedOrigin>`
1105
+ }
1106
+ for (const method of rule.AllowedMethods) {
1107
+ ruleXml += `<AllowedMethod>${method}</AllowedMethod>`
1108
+ }
1109
+ if (rule.AllowedHeaders) {
1110
+ for (const header of rule.AllowedHeaders) {
1111
+ ruleXml += `<AllowedHeader>${header}</AllowedHeader>`
1112
+ }
1113
+ }
1114
+ if (rule.ExposeHeaders) {
1115
+ for (const header of rule.ExposeHeaders) {
1116
+ ruleXml += `<ExposeHeader>${header}</ExposeHeader>`
1117
+ }
1118
+ }
1119
+ if (rule.MaxAgeSeconds) {
1120
+ ruleXml += `<MaxAgeSeconds>${rule.MaxAgeSeconds}</MaxAgeSeconds>`
1121
+ }
1122
+ ruleXml += '</CORSRule>'
1123
+ return ruleXml
1124
+ }).join('')
1125
+
1126
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1127
+ <CORSConfiguration>
1128
+ ${rulesXml}
1129
+ </CORSConfiguration>`
1130
+
1131
+ await this.client.request({
1132
+ service: 's3',
1133
+ region: this.region,
1134
+ method: 'PUT',
1135
+ path: `/${bucket}`,
1136
+ queryParams: { cors: '' },
1137
+ headers: { 'Content-Type': 'application/xml' },
1138
+ body,
1139
+ })
1140
+ }
1141
+
1142
+ /**
1143
+ * Delete bucket CORS configuration
1144
+ */
1145
+ async deleteBucketCors(bucket: string): Promise<void> {
1146
+ await this.client.request({
1147
+ service: 's3',
1148
+ region: this.region,
1149
+ method: 'DELETE',
1150
+ path: `/${bucket}`,
1151
+ queryParams: { cors: '' },
1152
+ })
1153
+ }
1154
+
1155
+ /**
1156
+ * Get bucket encryption configuration
1157
+ */
1158
+ async getBucketEncryption(bucket: string): Promise<any> {
1159
+ try {
1160
+ const result = await this.client.request({
1161
+ service: 's3',
1162
+ region: this.region,
1163
+ method: 'GET',
1164
+ path: `/${bucket}`,
1165
+ queryParams: { encryption: '' },
1166
+ })
1167
+ return result?.ServerSideEncryptionConfiguration
1168
+ } catch (e: any) {
1169
+ if (e.statusCode === 404) {
1170
+ return null
1171
+ }
1172
+ throw e
1173
+ }
1174
+ }
1175
+
1176
+ /**
1177
+ * Put bucket encryption configuration
1178
+ */
1179
+ async putBucketEncryption(bucket: string, sseAlgorithm: 'AES256' | 'aws:kms', kmsKeyId?: string): Promise<void> {
1180
+ let ruleXml = `<ApplyServerSideEncryptionByDefault><SSEAlgorithm>${sseAlgorithm}</SSEAlgorithm>`
1181
+ if (kmsKeyId) {
1182
+ ruleXml += `<KMSMasterKeyID>${kmsKeyId}</KMSMasterKeyID>`
1183
+ }
1184
+ ruleXml += '</ApplyServerSideEncryptionByDefault>'
1185
+
1186
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1187
+ <ServerSideEncryptionConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1188
+ <Rule>${ruleXml}</Rule>
1189
+ </ServerSideEncryptionConfiguration>`
1190
+
1191
+ await this.client.request({
1192
+ service: 's3',
1193
+ region: this.region,
1194
+ method: 'PUT',
1195
+ path: `/${bucket}`,
1196
+ queryParams: { encryption: '' },
1197
+ headers: { 'Content-Type': 'application/xml' },
1198
+ body,
1199
+ })
1200
+ }
1201
+
1202
+ /**
1203
+ * Delete bucket encryption configuration
1204
+ */
1205
+ async deleteBucketEncryption(bucket: string): Promise<void> {
1206
+ await this.client.request({
1207
+ service: 's3',
1208
+ region: this.region,
1209
+ method: 'DELETE',
1210
+ path: `/${bucket}`,
1211
+ queryParams: { encryption: '' },
1212
+ })
1213
+ }
1214
+
1215
+ /**
1216
+ * Get bucket tagging
1217
+ */
1218
+ async getBucketTagging(bucket: string): Promise<Array<{ Key: string; Value: string }>> {
1219
+ try {
1220
+ const result = await this.client.request({
1221
+ service: 's3',
1222
+ region: this.region,
1223
+ method: 'GET',
1224
+ path: `/${bucket}`,
1225
+ queryParams: { tagging: '' },
1226
+ })
1227
+ const tagSet = result?.Tagging?.TagSet?.Tag
1228
+ if (!tagSet) return []
1229
+ return Array.isArray(tagSet) ? tagSet : [tagSet]
1230
+ } catch (e: any) {
1231
+ if (e.statusCode === 404) {
1232
+ return []
1233
+ }
1234
+ throw e
1235
+ }
1236
+ }
1237
+
1238
+ /**
1239
+ * Put bucket tagging
1240
+ */
1241
+ async putBucketTagging(bucket: string, tags: Array<{ Key: string; Value: string }>): Promise<void> {
1242
+ const tagsXml = tags.map(t => `<Tag><Key>${t.Key}</Key><Value>${t.Value}</Value></Tag>`).join('')
1243
+
1244
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1245
+ <Tagging>
1246
+ <TagSet>${tagsXml}</TagSet>
1247
+ </Tagging>`
1248
+
1249
+ await this.client.request({
1250
+ service: 's3',
1251
+ region: this.region,
1252
+ method: 'PUT',
1253
+ path: `/${bucket}`,
1254
+ queryParams: { tagging: '' },
1255
+ headers: { 'Content-Type': 'application/xml' },
1256
+ body,
1257
+ })
1258
+ }
1259
+
1260
+ /**
1261
+ * Delete bucket tagging
1262
+ */
1263
+ async deleteBucketTagging(bucket: string): Promise<void> {
1264
+ await this.client.request({
1265
+ service: 's3',
1266
+ region: this.region,
1267
+ method: 'DELETE',
1268
+ path: `/${bucket}`,
1269
+ queryParams: { tagging: '' },
1270
+ })
1271
+ }
1272
+
1273
+ /**
1274
+ * Get object tagging
1275
+ */
1276
+ async getObjectTagging(bucket: string, key: string): Promise<Array<{ Key: string; Value: string }>> {
1277
+ try {
1278
+ const result = await this.client.request({
1279
+ service: 's3',
1280
+ region: this.region,
1281
+ method: 'GET',
1282
+ path: `/${bucket}/${key}`,
1283
+ queryParams: { tagging: '' },
1284
+ })
1285
+ const tagSet = result?.Tagging?.TagSet?.Tag
1286
+ if (!tagSet) return []
1287
+ return Array.isArray(tagSet) ? tagSet : [tagSet]
1288
+ } catch (e: any) {
1289
+ if (e.statusCode === 404) {
1290
+ return []
1291
+ }
1292
+ throw e
1293
+ }
1294
+ }
1295
+
1296
+ /**
1297
+ * Put object tagging
1298
+ */
1299
+ async putObjectTagging(bucket: string, key: string, tags: Array<{ Key: string; Value: string }>): Promise<void> {
1300
+ const tagsXml = tags.map(t => `<Tag><Key>${t.Key}</Key><Value>${t.Value}</Value></Tag>`).join('')
1301
+
1302
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1303
+ <Tagging>
1304
+ <TagSet>${tagsXml}</TagSet>
1305
+ </Tagging>`
1306
+
1307
+ await this.client.request({
1308
+ service: 's3',
1309
+ region: this.region,
1310
+ method: 'PUT',
1311
+ path: `/${bucket}/${key}`,
1312
+ queryParams: { tagging: '' },
1313
+ headers: { 'Content-Type': 'application/xml' },
1314
+ body,
1315
+ })
1316
+ }
1317
+
1318
+ /**
1319
+ * Delete object tagging
1320
+ */
1321
+ async deleteObjectTagging(bucket: string, key: string): Promise<void> {
1322
+ await this.client.request({
1323
+ service: 's3',
1324
+ region: this.region,
1325
+ method: 'DELETE',
1326
+ path: `/${bucket}/${key}`,
1327
+ queryParams: { tagging: '' },
1328
+ })
1329
+ }
1330
+
1331
+ /**
1332
+ * Get bucket ACL
1333
+ */
1334
+ async getBucketAcl(bucket: string): Promise<any> {
1335
+ const result = await this.client.request({
1336
+ service: 's3',
1337
+ region: this.region,
1338
+ method: 'GET',
1339
+ path: `/${bucket}`,
1340
+ queryParams: { acl: '' },
1341
+ })
1342
+ return result?.AccessControlPolicy
1343
+ }
1344
+
1345
+ /**
1346
+ * Put bucket ACL (canned ACL)
1347
+ */
1348
+ async putBucketAcl(bucket: string, acl: 'private' | 'public-read' | 'public-read-write' | 'authenticated-read'): Promise<void> {
1349
+ await this.client.request({
1350
+ service: 's3',
1351
+ region: this.region,
1352
+ method: 'PUT',
1353
+ path: `/${bucket}`,
1354
+ queryParams: { acl: '' },
1355
+ headers: { 'x-amz-acl': acl },
1356
+ })
1357
+ }
1358
+
1359
+ /**
1360
+ * Get object ACL
1361
+ */
1362
+ async getObjectAcl(bucket: string, key: string): Promise<any> {
1363
+ const result = await this.client.request({
1364
+ service: 's3',
1365
+ region: this.region,
1366
+ method: 'GET',
1367
+ path: `/${bucket}/${key}`,
1368
+ queryParams: { acl: '' },
1369
+ })
1370
+ return result?.AccessControlPolicy
1371
+ }
1372
+
1373
+ /**
1374
+ * Put object ACL (canned ACL)
1375
+ */
1376
+ async putObjectAcl(bucket: string, key: string, acl: 'private' | 'public-read' | 'public-read-write' | 'authenticated-read'): Promise<void> {
1377
+ await this.client.request({
1378
+ service: 's3',
1379
+ region: this.region,
1380
+ method: 'PUT',
1381
+ path: `/${bucket}/${key}`,
1382
+ queryParams: { acl: '' },
1383
+ headers: { 'x-amz-acl': acl },
1384
+ })
1385
+ }
1386
+
1387
+ /**
1388
+ * Get bucket location
1389
+ */
1390
+ async getBucketLocation(bucket: string): Promise<string> {
1391
+ const result = await this.client.request({
1392
+ service: 's3',
1393
+ region: this.region,
1394
+ method: 'GET',
1395
+ path: `/${bucket}`,
1396
+ queryParams: { location: '' },
1397
+ })
1398
+ // Empty string means us-east-1
1399
+ return result?.LocationConstraint || 'us-east-1'
1400
+ }
1401
+
1402
+ /**
1403
+ * Get bucket logging configuration
1404
+ */
1405
+ async getBucketLogging(bucket: string): Promise<any> {
1406
+ const result = await this.client.request({
1407
+ service: 's3',
1408
+ region: this.region,
1409
+ method: 'GET',
1410
+ path: `/${bucket}`,
1411
+ queryParams: { logging: '' },
1412
+ })
1413
+ return result?.BucketLoggingStatus
1414
+ }
1415
+
1416
+ /**
1417
+ * Put bucket logging configuration
1418
+ */
1419
+ async putBucketLogging(bucket: string, targetBucket: string, targetPrefix: string): Promise<void> {
1420
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1421
+ <BucketLoggingStatus xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1422
+ <LoggingEnabled>
1423
+ <TargetBucket>${targetBucket}</TargetBucket>
1424
+ <TargetPrefix>${targetPrefix}</TargetPrefix>
1425
+ </LoggingEnabled>
1426
+ </BucketLoggingStatus>`
1427
+
1428
+ await this.client.request({
1429
+ service: 's3',
1430
+ region: this.region,
1431
+ method: 'PUT',
1432
+ path: `/${bucket}`,
1433
+ queryParams: { logging: '' },
1434
+ headers: { 'Content-Type': 'application/xml' },
1435
+ body,
1436
+ })
1437
+ }
1438
+
1439
+ /**
1440
+ * Get bucket notification configuration
1441
+ */
1442
+ async getBucketNotificationConfiguration(bucket: string): Promise<any> {
1443
+ const result = await this.client.request({
1444
+ service: 's3',
1445
+ region: this.region,
1446
+ method: 'GET',
1447
+ path: `/${bucket}`,
1448
+ queryParams: { notification: '' },
1449
+ })
1450
+ return result?.NotificationConfiguration
1451
+ }
1452
+
1453
+ /**
1454
+ * Put bucket notification configuration
1455
+ */
1456
+ async putBucketNotificationConfiguration(bucket: string, config: {
1457
+ LambdaFunctionConfigurations?: Array<{
1458
+ Id?: string
1459
+ LambdaFunctionArn: string
1460
+ Events: string[]
1461
+ Filter?: { Key?: { FilterRules: Array<{ Name: string; Value: string }> } }
1462
+ }>
1463
+ TopicConfigurations?: Array<{
1464
+ Id?: string
1465
+ TopicArn: string
1466
+ Events: string[]
1467
+ Filter?: { Key?: { FilterRules: Array<{ Name: string; Value: string }> } }
1468
+ }>
1469
+ QueueConfigurations?: Array<{
1470
+ Id?: string
1471
+ QueueArn: string
1472
+ Events: string[]
1473
+ Filter?: { Key?: { FilterRules: Array<{ Name: string; Value: string }> } }
1474
+ }>
1475
+ }): Promise<void> {
1476
+ let configXml = ''
1477
+
1478
+ if (config.LambdaFunctionConfigurations) {
1479
+ for (const c of config.LambdaFunctionConfigurations) {
1480
+ configXml += '<CloudFunctionConfiguration>'
1481
+ if (c.Id) configXml += `<Id>${c.Id}</Id>`
1482
+ configXml += `<CloudFunction>${c.LambdaFunctionArn}</CloudFunction>`
1483
+ for (const event of c.Events) {
1484
+ configXml += `<Event>${event}</Event>`
1485
+ }
1486
+ if (c.Filter?.Key?.FilterRules) {
1487
+ configXml += '<Filter><S3Key>'
1488
+ for (const rule of c.Filter.Key.FilterRules) {
1489
+ configXml += `<FilterRule><Name>${rule.Name}</Name><Value>${rule.Value}</Value></FilterRule>`
1490
+ }
1491
+ configXml += '</S3Key></Filter>'
1492
+ }
1493
+ configXml += '</CloudFunctionConfiguration>'
1494
+ }
1495
+ }
1496
+
1497
+ if (config.TopicConfigurations) {
1498
+ for (const c of config.TopicConfigurations) {
1499
+ configXml += '<TopicConfiguration>'
1500
+ if (c.Id) configXml += `<Id>${c.Id}</Id>`
1501
+ configXml += `<Topic>${c.TopicArn}</Topic>`
1502
+ for (const event of c.Events) {
1503
+ configXml += `<Event>${event}</Event>`
1504
+ }
1505
+ configXml += '</TopicConfiguration>'
1506
+ }
1507
+ }
1508
+
1509
+ if (config.QueueConfigurations) {
1510
+ for (const c of config.QueueConfigurations) {
1511
+ configXml += '<QueueConfiguration>'
1512
+ if (c.Id) configXml += `<Id>${c.Id}</Id>`
1513
+ configXml += `<Queue>${c.QueueArn}</Queue>`
1514
+ for (const event of c.Events) {
1515
+ configXml += `<Event>${event}</Event>`
1516
+ }
1517
+ configXml += '</QueueConfiguration>'
1518
+ }
1519
+ }
1520
+
1521
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1522
+ <NotificationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1523
+ ${configXml}
1524
+ </NotificationConfiguration>`
1525
+
1526
+ await this.client.request({
1527
+ service: 's3',
1528
+ region: this.region,
1529
+ method: 'PUT',
1530
+ path: `/${bucket}`,
1531
+ queryParams: { notification: '' },
1532
+ headers: { 'Content-Type': 'application/xml' },
1533
+ body,
1534
+ })
1535
+ }
1536
+
1537
+ /**
1538
+ * Get bucket website configuration
1539
+ */
1540
+ async getBucketWebsite(bucket: string): Promise<any> {
1541
+ try {
1542
+ const result = await this.client.request({
1543
+ service: 's3',
1544
+ region: this.region,
1545
+ method: 'GET',
1546
+ path: `/${bucket}`,
1547
+ queryParams: { website: '' },
1548
+ })
1549
+ return result?.WebsiteConfiguration
1550
+ } catch (e: any) {
1551
+ if (e.statusCode === 404) {
1552
+ return null
1553
+ }
1554
+ throw e
1555
+ }
1556
+ }
1557
+
1558
+ /**
1559
+ * Put bucket website configuration
1560
+ */
1561
+ async putBucketWebsite(bucket: string, config: {
1562
+ IndexDocument: string
1563
+ ErrorDocument?: string
1564
+ RedirectAllRequestsTo?: { HostName: string; Protocol?: string }
1565
+ }): Promise<void> {
1566
+ let configXml = ''
1567
+
1568
+ if (config.RedirectAllRequestsTo) {
1569
+ configXml = `<RedirectAllRequestsTo>
1570
+ <HostName>${config.RedirectAllRequestsTo.HostName}</HostName>
1571
+ ${config.RedirectAllRequestsTo.Protocol ? `<Protocol>${config.RedirectAllRequestsTo.Protocol}</Protocol>` : ''}
1572
+ </RedirectAllRequestsTo>`
1573
+ } else {
1574
+ configXml = `<IndexDocument><Suffix>${config.IndexDocument}</Suffix></IndexDocument>`
1575
+ if (config.ErrorDocument) {
1576
+ configXml += `<ErrorDocument><Key>${config.ErrorDocument}</Key></ErrorDocument>`
1577
+ }
1578
+ }
1579
+
1580
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1581
+ <WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1582
+ ${configXml}
1583
+ </WebsiteConfiguration>`
1584
+
1585
+ await this.client.request({
1586
+ service: 's3',
1587
+ region: this.region,
1588
+ method: 'PUT',
1589
+ path: `/${bucket}`,
1590
+ queryParams: { website: '' },
1591
+ headers: { 'Content-Type': 'application/xml' },
1592
+ body,
1593
+ })
1594
+ }
1595
+
1596
+ /**
1597
+ * Delete bucket website configuration
1598
+ */
1599
+ async deleteBucketWebsite(bucket: string): Promise<void> {
1600
+ await this.client.request({
1601
+ service: 's3',
1602
+ region: this.region,
1603
+ method: 'DELETE',
1604
+ path: `/${bucket}`,
1605
+ queryParams: { website: '' },
1606
+ })
1607
+ }
1608
+
1609
+ /**
1610
+ * Get bucket replication configuration
1611
+ */
1612
+ async getBucketReplication(bucket: string): Promise<any> {
1613
+ try {
1614
+ const result = await this.client.request({
1615
+ service: 's3',
1616
+ region: this.region,
1617
+ method: 'GET',
1618
+ path: `/${bucket}`,
1619
+ queryParams: { replication: '' },
1620
+ })
1621
+ return result?.ReplicationConfiguration
1622
+ } catch (e: any) {
1623
+ if (e.statusCode === 404) {
1624
+ return null
1625
+ }
1626
+ throw e
1627
+ }
1628
+ }
1629
+
1630
+ /**
1631
+ * Delete bucket replication configuration
1632
+ */
1633
+ async deleteBucketReplication(bucket: string): Promise<void> {
1634
+ await this.client.request({
1635
+ service: 's3',
1636
+ region: this.region,
1637
+ method: 'DELETE',
1638
+ path: `/${bucket}`,
1639
+ queryParams: { replication: '' },
1640
+ })
1641
+ }
1642
+
1643
+ /**
1644
+ * Get public access block configuration
1645
+ */
1646
+ async getPublicAccessBlock(bucket: string): Promise<any> {
1647
+ try {
1648
+ const result = await this.client.request({
1649
+ service: 's3',
1650
+ region: this.region,
1651
+ method: 'GET',
1652
+ path: `/${bucket}`,
1653
+ queryParams: { publicAccessBlock: '' },
1654
+ })
1655
+ return result?.PublicAccessBlockConfiguration
1656
+ } catch (e: any) {
1657
+ if (e.statusCode === 404) {
1658
+ return null
1659
+ }
1660
+ throw e
1661
+ }
1662
+ }
1663
+
1664
+ /**
1665
+ * Put public access block configuration
1666
+ */
1667
+ async putPublicAccessBlock(bucket: string, config: {
1668
+ BlockPublicAcls?: boolean
1669
+ IgnorePublicAcls?: boolean
1670
+ BlockPublicPolicy?: boolean
1671
+ RestrictPublicBuckets?: boolean
1672
+ }): Promise<void> {
1673
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1674
+ <PublicAccessBlockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1675
+ <BlockPublicAcls>${config.BlockPublicAcls ?? true}</BlockPublicAcls>
1676
+ <IgnorePublicAcls>${config.IgnorePublicAcls ?? true}</IgnorePublicAcls>
1677
+ <BlockPublicPolicy>${config.BlockPublicPolicy ?? true}</BlockPublicPolicy>
1678
+ <RestrictPublicBuckets>${config.RestrictPublicBuckets ?? true}</RestrictPublicBuckets>
1679
+ </PublicAccessBlockConfiguration>`
1680
+
1681
+ await this.client.request({
1682
+ service: 's3',
1683
+ region: this.region,
1684
+ method: 'PUT',
1685
+ path: `/${bucket}`,
1686
+ queryParams: { publicAccessBlock: '' },
1687
+ headers: { 'Content-Type': 'application/xml' },
1688
+ body,
1689
+ })
1690
+ }
1691
+
1692
+ /**
1693
+ * Delete public access block configuration
1694
+ */
1695
+ async deletePublicAccessBlock(bucket: string): Promise<void> {
1696
+ await this.client.request({
1697
+ service: 's3',
1698
+ region: this.region,
1699
+ method: 'DELETE',
1700
+ path: `/${bucket}`,
1701
+ queryParams: { publicAccessBlock: '' },
1702
+ })
1703
+ }
1704
+
1705
+ /**
1706
+ * Generate a presigned URL for GET
1707
+ */
1708
+ generatePresignedGetUrl(bucket: string, key: string, expiresInSeconds: number = 3600): string {
1709
+ const { accessKeyId, secretAccessKey } = this.getCredentials()
1710
+ const host = `${bucket}.s3.${this.region}.amazonaws.com`
1711
+ const now = new Date()
1712
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
1713
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
1714
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
1715
+ const credential = `${accessKeyId}/${credentialScope}`
1716
+
1717
+ const queryParams = new URLSearchParams({
1718
+ 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
1719
+ 'X-Amz-Credential': credential,
1720
+ 'X-Amz-Date': amzDate,
1721
+ 'X-Amz-Expires': expiresInSeconds.toString(),
1722
+ 'X-Amz-SignedHeaders': 'host',
1723
+ })
1724
+
1725
+ const canonicalRequest = [
1726
+ 'GET',
1727
+ `/${key}`,
1728
+ queryParams.toString(),
1729
+ `host:${host}\n`,
1730
+ 'host',
1731
+ 'UNSIGNED-PAYLOAD',
1732
+ ].join('\n')
1733
+
1734
+ const stringToSign = [
1735
+ 'AWS4-HMAC-SHA256',
1736
+ amzDate,
1737
+ credentialScope,
1738
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
1739
+ ].join('\n')
1740
+
1741
+ const kDate = crypto.createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest()
1742
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
1743
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
1744
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
1745
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
1746
+
1747
+ queryParams.append('X-Amz-Signature', signature)
1748
+
1749
+ return `https://${host}/${key}?${queryParams.toString()}`
1750
+ }
1751
+
1752
+ /**
1753
+ * Generate a presigned URL for PUT
1754
+ */
1755
+ generatePresignedPutUrl(bucket: string, key: string, contentType: string, expiresInSeconds: number = 3600): string {
1756
+ const { accessKeyId, secretAccessKey } = this.getCredentials()
1757
+ const host = `${bucket}.s3.${this.region}.amazonaws.com`
1758
+ const now = new Date()
1759
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
1760
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
1761
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
1762
+ const credential = `${accessKeyId}/${credentialScope}`
1763
+
1764
+ const queryParams = new URLSearchParams({
1765
+ 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
1766
+ 'X-Amz-Credential': credential,
1767
+ 'X-Amz-Date': amzDate,
1768
+ 'X-Amz-Expires': expiresInSeconds.toString(),
1769
+ 'X-Amz-SignedHeaders': 'content-type;host',
1770
+ })
1771
+
1772
+ const canonicalRequest = [
1773
+ 'PUT',
1774
+ `/${key}`,
1775
+ queryParams.toString(),
1776
+ `content-type:${contentType}\nhost:${host}\n`,
1777
+ 'content-type;host',
1778
+ 'UNSIGNED-PAYLOAD',
1779
+ ].join('\n')
1780
+
1781
+ const stringToSign = [
1782
+ 'AWS4-HMAC-SHA256',
1783
+ amzDate,
1784
+ credentialScope,
1785
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
1786
+ ].join('\n')
1787
+
1788
+ const kDate = crypto.createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest()
1789
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
1790
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
1791
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
1792
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
1793
+
1794
+ queryParams.append('X-Amz-Signature', signature)
1795
+
1796
+ return `https://${host}/${key}?${queryParams.toString()}`
1797
+ }
1798
+
1799
+ /**
1800
+ * Initiate multipart upload
1801
+ */
1802
+ async createMultipartUpload(bucket: string, key: string, options?: {
1803
+ contentType?: string
1804
+ metadata?: Record<string, string>
1805
+ }): Promise<{ UploadId: string }> {
1806
+ const headers: Record<string, string> = {}
1807
+ if (options?.contentType) {
1808
+ headers['Content-Type'] = options.contentType
1809
+ }
1810
+ if (options?.metadata) {
1811
+ for (const [k, v] of Object.entries(options.metadata)) {
1812
+ headers[`x-amz-meta-${k}`] = v
1813
+ }
1814
+ }
1815
+
1816
+ const result = await this.client.request({
1817
+ service: 's3',
1818
+ region: this.region,
1819
+ method: 'POST',
1820
+ path: `/${bucket}/${key}`,
1821
+ queryParams: { uploads: '' },
1822
+ headers,
1823
+ })
1824
+
1825
+ return { UploadId: result?.InitiateMultipartUploadResult?.UploadId }
1826
+ }
1827
+
1828
+ /**
1829
+ * Upload a part in multipart upload
1830
+ */
1831
+ async uploadPart(bucket: string, key: string, uploadId: string, partNumber: number, body: Buffer): Promise<{ ETag: string }> {
1832
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials()
1833
+ const host = `${bucket}.s3.${this.region}.amazonaws.com`
1834
+ const url = `https://${host}/${key}?partNumber=${partNumber}&uploadId=${encodeURIComponent(uploadId)}`
1835
+
1836
+ const now = new Date()
1837
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
1838
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
1839
+
1840
+ const payloadHash = crypto.createHash('sha256').update(body).digest('hex')
1841
+
1842
+ const requestHeaders: Record<string, string> = {
1843
+ 'host': host,
1844
+ 'x-amz-date': amzDate,
1845
+ 'x-amz-content-sha256': payloadHash,
1846
+ }
1847
+
1848
+ if (sessionToken) {
1849
+ requestHeaders['x-amz-security-token'] = sessionToken
1850
+ }
1851
+
1852
+ const canonicalHeaders = Object.keys(requestHeaders)
1853
+ .sort()
1854
+ .map(k => `${k.toLowerCase()}:${requestHeaders[k].trim()}\n`)
1855
+ .join('')
1856
+
1857
+ const signedHeaders = Object.keys(requestHeaders)
1858
+ .sort()
1859
+ .map(k => k.toLowerCase())
1860
+ .join(';')
1861
+
1862
+ const canonicalRequest = [
1863
+ 'PUT',
1864
+ `/${key}`,
1865
+ `partNumber=${partNumber}&uploadId=${encodeURIComponent(uploadId)}`,
1866
+ canonicalHeaders,
1867
+ signedHeaders,
1868
+ payloadHash,
1869
+ ].join('\n')
1870
+
1871
+ const algorithm = 'AWS4-HMAC-SHA256'
1872
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
1873
+ const stringToSign = [
1874
+ algorithm,
1875
+ amzDate,
1876
+ credentialScope,
1877
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
1878
+ ].join('\n')
1879
+
1880
+ const kDate = crypto.createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest()
1881
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
1882
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
1883
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
1884
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
1885
+
1886
+ const authHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
1887
+
1888
+ const response = await fetch(url, {
1889
+ method: 'PUT',
1890
+ headers: {
1891
+ ...requestHeaders,
1892
+ 'Authorization': authHeader,
1893
+ },
1894
+ body,
1895
+ })
1896
+
1897
+ if (!response.ok) {
1898
+ const text = await response.text()
1899
+ throw new Error(`Upload part failed: ${response.status} ${text}`)
1900
+ }
1901
+
1902
+ return { ETag: response.headers.get('etag') || '' }
1903
+ }
1904
+
1905
+ /**
1906
+ * Complete multipart upload
1907
+ */
1908
+ async completeMultipartUpload(bucket: string, key: string, uploadId: string, parts: Array<{ PartNumber: number; ETag: string }>): Promise<void> {
1909
+ const partsXml = parts
1910
+ .sort((a, b) => a.PartNumber - b.PartNumber)
1911
+ .map(p => `<Part><PartNumber>${p.PartNumber}</PartNumber><ETag>${p.ETag}</ETag></Part>`)
1912
+ .join('')
1913
+
1914
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1915
+ <CompleteMultipartUpload>${partsXml}</CompleteMultipartUpload>`
1916
+
1917
+ await this.client.request({
1918
+ service: 's3',
1919
+ region: this.region,
1920
+ method: 'POST',
1921
+ path: `/${bucket}/${key}`,
1922
+ queryParams: { uploadId },
1923
+ headers: { 'Content-Type': 'application/xml' },
1924
+ body,
1925
+ })
1926
+ }
1927
+
1928
+ /**
1929
+ * Abort multipart upload
1930
+ */
1931
+ async abortMultipartUpload(bucket: string, key: string, uploadId: string): Promise<void> {
1932
+ await this.client.request({
1933
+ service: 's3',
1934
+ region: this.region,
1935
+ method: 'DELETE',
1936
+ path: `/${bucket}/${key}`,
1937
+ queryParams: { uploadId },
1938
+ })
1939
+ }
1940
+
1941
+ /**
1942
+ * List multipart uploads
1943
+ */
1944
+ async listMultipartUploads(bucket: string): Promise<Array<{ Key: string; UploadId: string; Initiated: string }>> {
1945
+ const result = await this.client.request({
1946
+ service: 's3',
1947
+ region: this.region,
1948
+ method: 'GET',
1949
+ path: `/${bucket}`,
1950
+ queryParams: { uploads: '' },
1951
+ })
1952
+
1953
+ const uploads = result?.ListMultipartUploadsResult?.Upload
1954
+ if (!uploads) return []
1955
+ const list = Array.isArray(uploads) ? uploads : [uploads]
1956
+ return list.map((u: any) => ({
1957
+ Key: u.Key,
1958
+ UploadId: u.UploadId,
1959
+ Initiated: u.Initiated,
1960
+ }))
1961
+ }
1962
+
1963
+ /**
1964
+ * Restore object from Glacier
1965
+ */
1966
+ async restoreObject(bucket: string, key: string, days: number, tier: 'Standard' | 'Bulk' | 'Expedited' = 'Standard'): Promise<void> {
1967
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
1968
+ <RestoreRequest>
1969
+ <Days>${days}</Days>
1970
+ <GlacierJobParameters>
1971
+ <Tier>${tier}</Tier>
1972
+ </GlacierJobParameters>
1973
+ </RestoreRequest>`
1974
+
1975
+ await this.client.request({
1976
+ service: 's3',
1977
+ region: this.region,
1978
+ method: 'POST',
1979
+ path: `/${bucket}/${key}`,
1980
+ queryParams: { restore: '' },
1981
+ headers: { 'Content-Type': 'application/xml' },
1982
+ body,
1983
+ })
1984
+ }
1985
+
1986
+ /**
1987
+ * Select object content (S3 Select)
1988
+ */
1989
+ async selectObjectContent(bucket: string, key: string, expression: string, inputFormat: 'CSV' | 'JSON' | 'Parquet', outputFormat: 'CSV' | 'JSON' = 'JSON'): Promise<string> {
1990
+ let inputSerialization = ''
1991
+ if (inputFormat === 'CSV') {
1992
+ inputSerialization = '<CSV><FileHeaderInfo>USE</FileHeaderInfo></CSV>'
1993
+ } else if (inputFormat === 'JSON') {
1994
+ inputSerialization = '<JSON><Type>DOCUMENT</Type></JSON>'
1995
+ } else {
1996
+ inputSerialization = '<Parquet/>'
1997
+ }
1998
+
1999
+ let outputSerialization = ''
2000
+ if (outputFormat === 'CSV') {
2001
+ outputSerialization = '<CSV/>'
2002
+ } else {
2003
+ outputSerialization = '<JSON/>'
2004
+ }
2005
+
2006
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
2007
+ <SelectObjectContentRequest>
2008
+ <Expression>${expression}</Expression>
2009
+ <ExpressionType>SQL</ExpressionType>
2010
+ <InputSerialization>${inputSerialization}</InputSerialization>
2011
+ <OutputSerialization>${outputSerialization}</OutputSerialization>
2012
+ </SelectObjectContentRequest>`
2013
+
2014
+ const result = await this.client.request({
2015
+ service: 's3',
2016
+ region: this.region,
2017
+ method: 'POST',
2018
+ path: `/${bucket}/${key}`,
2019
+ queryParams: { select: '', 'select-type': '2' },
2020
+ headers: { 'Content-Type': 'application/xml' },
2021
+ body,
2022
+ rawResponse: true,
2023
+ })
2024
+
2025
+ return result
2026
+ }
2027
+
2028
+ /**
2029
+ * Generate a presigned URL for S3 object access
2030
+ * Allows temporary access to private objects without authentication
2031
+ */
2032
+ async getSignedUrl(options: {
2033
+ bucket: string
2034
+ key: string
2035
+ expiresIn?: number
2036
+ operation?: 'getObject' | 'putObject'
2037
+ }): Promise<string> {
2038
+ const { bucket, key, expiresIn = 3600, operation = 'getObject' } = options
2039
+ const { accessKeyId, secretAccessKey, sessionToken } = this.getCredentials()
2040
+
2041
+ const now = new Date()
2042
+ const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '')
2043
+ const dateStamp = now.toISOString().slice(0, 10).replace(/-/g, '')
2044
+
2045
+ const host = `${bucket}.s3.${this.region}.amazonaws.com`
2046
+ const method = operation === 'putObject' ? 'PUT' : 'GET'
2047
+ const algorithm = 'AWS4-HMAC-SHA256'
2048
+ const credentialScope = `${dateStamp}/${this.region}/s3/aws4_request`
2049
+ const credential = `${accessKeyId}/${credentialScope}`
2050
+
2051
+ // Build query parameters
2052
+ const queryParams: Record<string, string> = {
2053
+ 'X-Amz-Algorithm': algorithm,
2054
+ 'X-Amz-Credential': credential,
2055
+ 'X-Amz-Date': amzDate,
2056
+ 'X-Amz-Expires': expiresIn.toString(),
2057
+ 'X-Amz-SignedHeaders': 'host',
2058
+ }
2059
+
2060
+ if (sessionToken) {
2061
+ queryParams['X-Amz-Security-Token'] = sessionToken
2062
+ }
2063
+
2064
+ // Sort and encode query string
2065
+ const sortedParams = Object.keys(queryParams).sort()
2066
+ const canonicalQuerystring = sortedParams
2067
+ .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(queryParams[k])}`)
2068
+ .join('&')
2069
+
2070
+ // Canonical request
2071
+ const canonicalUri = '/' + key
2072
+ const canonicalHeaders = `host:${host}\n`
2073
+ const signedHeaders = 'host'
2074
+ const payloadHash = 'UNSIGNED-PAYLOAD'
2075
+
2076
+ const canonicalRequest = [
2077
+ method,
2078
+ canonicalUri,
2079
+ canonicalQuerystring,
2080
+ canonicalHeaders,
2081
+ signedHeaders,
2082
+ payloadHash,
2083
+ ].join('\n')
2084
+
2085
+ // String to sign
2086
+ const stringToSign = [
2087
+ algorithm,
2088
+ amzDate,
2089
+ credentialScope,
2090
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex'),
2091
+ ].join('\n')
2092
+
2093
+ // Calculate signature
2094
+ const kDate = crypto.createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest()
2095
+ const kRegion = crypto.createHmac('sha256', kDate).update(this.region).digest()
2096
+ const kService = crypto.createHmac('sha256', kRegion).update('s3').digest()
2097
+ const kSigning = crypto.createHmac('sha256', kService).update('aws4_request').digest()
2098
+ const signature = crypto.createHmac('sha256', kSigning).update(stringToSign).digest('hex')
2099
+
2100
+ // Build presigned URL
2101
+ const presignedUrl = `https://${host}${canonicalUri}?${canonicalQuerystring}&X-Amz-Signature=${signature}`
2102
+
2103
+ return presignedUrl
2104
+ }
2105
+
2106
+ /**
2107
+ * List objects in a bucket with pagination support
2108
+ */
2109
+ async listObjects(options: {
2110
+ bucket: string
2111
+ prefix?: string
2112
+ maxKeys?: number
2113
+ continuationToken?: string
2114
+ }): Promise<{
2115
+ objects: S3Object[]
2116
+ nextContinuationToken?: string
2117
+ }> {
2118
+ const { bucket, prefix, maxKeys = 1000, continuationToken } = options
2119
+
2120
+ // Build query parameters for ListObjectsV2
2121
+ const queryParams: Record<string, string> = {
2122
+ 'list-type': '2',
2123
+ 'max-keys': maxKeys.toString(),
2124
+ }
2125
+
2126
+ if (prefix) queryParams.prefix = prefix
2127
+ if (continuationToken) queryParams['continuation-token'] = continuationToken
2128
+
2129
+ const result = await this.client.request({
2130
+ service: 's3',
2131
+ region: this.region,
2132
+ method: 'GET',
2133
+ path: `/${bucket}`,
2134
+ queryParams,
2135
+ })
2136
+
2137
+ // Parse S3 XML response
2138
+ const objects: S3Object[] = []
2139
+ const listResult = result?.ListBucketResult
2140
+
2141
+ if (listResult?.Contents) {
2142
+ const items = Array.isArray(listResult.Contents) ? listResult.Contents : [listResult.Contents]
2143
+ for (const item of items) {
2144
+ objects.push({
2145
+ Key: item.Key || '',
2146
+ LastModified: item.LastModified || '',
2147
+ Size: Number.parseInt(item.Size || '0'),
2148
+ ETag: item.ETag,
2149
+ })
2150
+ }
2151
+ }
2152
+
2153
+ return {
2154
+ objects,
2155
+ nextContinuationToken: listResult?.NextContinuationToken,
2156
+ }
2157
+ }
2158
+
2159
+ /**
2160
+ * Empty a bucket by deleting all objects (required before bucket deletion)
2161
+ */
2162
+ async emptyBucket(bucket: string): Promise<{ deletedCount: number }> {
2163
+ let deletedCount = 0
2164
+
2165
+ // List all objects in the bucket (handles pagination internally)
2166
+ const objects = await this.listAllObjects({ bucket })
2167
+
2168
+ if (objects.length > 0) {
2169
+ // Delete objects in batches of 1000
2170
+ for (let i = 0; i < objects.length; i += 1000) {
2171
+ const batch = objects.slice(i, i + 1000)
2172
+ const keys = batch.map((obj: S3Object) => obj.Key)
2173
+ await this.deleteObjects(bucket, keys)
2174
+ deletedCount += keys.length
2175
+ }
2176
+ }
2177
+
2178
+ // Also delete any object versions if versioning is enabled
2179
+ try {
2180
+ let keyMarker: string | undefined
2181
+ let versionIdMarker: string | undefined
2182
+
2183
+ do {
2184
+ const versionsResult = await this.listObjectVersions({
2185
+ bucket,
2186
+ keyMarker,
2187
+ versionIdMarker,
2188
+ maxKeys: 1000,
2189
+ })
2190
+
2191
+ const versionsToDelete: Array<{ Key: string; VersionId?: string }> = []
2192
+
2193
+ if (versionsResult.versions) {
2194
+ for (const version of versionsResult.versions) {
2195
+ versionsToDelete.push({ Key: version.Key, VersionId: version.VersionId })
2196
+ }
2197
+ }
2198
+
2199
+ if (versionsResult.deleteMarkers) {
2200
+ for (const marker of versionsResult.deleteMarkers) {
2201
+ versionsToDelete.push({ Key: marker.Key, VersionId: marker.VersionId })
2202
+ }
2203
+ }
2204
+
2205
+ if (versionsToDelete.length > 0) {
2206
+ await this.deleteObjectVersions(bucket, versionsToDelete)
2207
+ deletedCount += versionsToDelete.length
2208
+ }
2209
+
2210
+ keyMarker = versionsResult.nextKeyMarker
2211
+ versionIdMarker = versionsResult.nextVersionIdMarker
2212
+ } while (keyMarker)
2213
+ }
2214
+ catch {
2215
+ // Versioning might not be enabled, ignore errors
2216
+ }
2217
+
2218
+ return { deletedCount }
2219
+ }
2220
+
2221
+ /**
2222
+ * List object versions in a bucket
2223
+ */
2224
+ async listObjectVersions(options: {
2225
+ bucket: string
2226
+ prefix?: string
2227
+ keyMarker?: string
2228
+ versionIdMarker?: string
2229
+ maxKeys?: number
2230
+ }): Promise<{
2231
+ versions: Array<{ Key: string; VersionId: string; IsLatest: boolean }>
2232
+ deleteMarkers: Array<{ Key: string; VersionId: string; IsLatest: boolean }>
2233
+ nextKeyMarker?: string
2234
+ nextVersionIdMarker?: string
2235
+ }> {
2236
+ const { bucket, prefix, keyMarker, versionIdMarker, maxKeys = 1000 } = options
2237
+
2238
+ const queryParams: Record<string, string> = {
2239
+ versions: '',
2240
+ 'max-keys': maxKeys.toString(),
2241
+ }
2242
+
2243
+ if (prefix) queryParams.prefix = prefix
2244
+ if (keyMarker) queryParams['key-marker'] = keyMarker
2245
+ if (versionIdMarker) queryParams['version-id-marker'] = versionIdMarker
2246
+
2247
+ const result = await this.client.request({
2248
+ service: 's3',
2249
+ region: this.region,
2250
+ method: 'GET',
2251
+ path: `/${bucket}`,
2252
+ queryParams,
2253
+ })
2254
+
2255
+ const versions: Array<{ Key: string; VersionId: string; IsLatest: boolean }> = []
2256
+ const deleteMarkers: Array<{ Key: string; VersionId: string; IsLatest: boolean }> = []
2257
+
2258
+ // Parse versions
2259
+ if (result.Version) {
2260
+ const versionList = Array.isArray(result.Version) ? result.Version : [result.Version]
2261
+ for (const v of versionList) {
2262
+ versions.push({
2263
+ Key: v.Key,
2264
+ VersionId: v.VersionId,
2265
+ IsLatest: v.IsLatest === 'true',
2266
+ })
2267
+ }
2268
+ }
2269
+
2270
+ // Parse delete markers
2271
+ if (result.DeleteMarker) {
2272
+ const markerList = Array.isArray(result.DeleteMarker) ? result.DeleteMarker : [result.DeleteMarker]
2273
+ for (const m of markerList) {
2274
+ deleteMarkers.push({
2275
+ Key: m.Key,
2276
+ VersionId: m.VersionId,
2277
+ IsLatest: m.IsLatest === 'true',
2278
+ })
2279
+ }
2280
+ }
2281
+
2282
+ return {
2283
+ versions,
2284
+ deleteMarkers,
2285
+ nextKeyMarker: result.NextKeyMarker,
2286
+ nextVersionIdMarker: result.NextVersionIdMarker,
2287
+ }
2288
+ }
2289
+
2290
+ /**
2291
+ * Delete specific object versions
2292
+ */
2293
+ async deleteObjectVersions(
2294
+ bucket: string,
2295
+ objects: Array<{ Key: string; VersionId?: string }>,
2296
+ ): Promise<void> {
2297
+ const deleteXml = `<?xml version="1.0" encoding="UTF-8"?>
2298
+ <Delete>
2299
+ <Quiet>true</Quiet>
2300
+ ${objects.map(obj => `<Object><Key>${obj.Key}</Key>${obj.VersionId ? `<VersionId>${obj.VersionId}</VersionId>` : ''}</Object>`).join('\n ')}
2301
+ </Delete>`
2302
+
2303
+ const contentMd5 = crypto.createHash('md5').update(deleteXml).digest('base64')
2304
+
2305
+ await this.client.request({
2306
+ service: 's3',
2307
+ region: this.region,
2308
+ method: 'POST',
2309
+ path: `/${bucket}`,
2310
+ queryParams: { delete: '' },
2311
+ body: deleteXml,
2312
+ headers: {
2313
+ 'Content-Type': 'application/xml',
2314
+ 'Content-MD5': contentMd5,
2315
+ },
2316
+ })
2317
+ }
2318
+ }