@stacksjs/ts-cloud-core 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/advanced-features.test.ts +465 -0
- package/src/aws/cloudformation.ts +421 -0
- package/src/aws/cloudfront.ts +158 -0
- package/src/aws/credentials.test.ts +132 -0
- package/src/aws/credentials.ts +545 -0
- package/src/aws/index.ts +87 -0
- package/src/aws/s3.test.ts +188 -0
- package/src/aws/s3.ts +1088 -0
- package/src/aws/signature.test.ts +670 -0
- package/src/aws/signature.ts +1155 -0
- package/src/backup/disaster-recovery.test.ts +726 -0
- package/src/backup/disaster-recovery.ts +500 -0
- package/src/backup/index.ts +34 -0
- package/src/backup/manager.test.ts +498 -0
- package/src/backup/manager.ts +432 -0
- package/src/cicd/circleci.ts +430 -0
- package/src/cicd/github-actions.ts +424 -0
- package/src/cicd/gitlab-ci.ts +255 -0
- package/src/cicd/index.ts +8 -0
- package/src/cli/history.ts +396 -0
- package/src/cli/index.ts +10 -0
- package/src/cli/progress.ts +458 -0
- package/src/cli/repl.ts +454 -0
- package/src/cli/suggestions.ts +327 -0
- package/src/cli/table.test.ts +319 -0
- package/src/cli/table.ts +332 -0
- package/src/cloudformation/builder.test.ts +327 -0
- package/src/cloudformation/builder.ts +378 -0
- package/src/cloudformation/builders/api-gateway.ts +449 -0
- package/src/cloudformation/builders/cache.ts +334 -0
- package/src/cloudformation/builders/cdn.ts +278 -0
- package/src/cloudformation/builders/compute.ts +485 -0
- package/src/cloudformation/builders/database.ts +392 -0
- package/src/cloudformation/builders/functions.ts +343 -0
- package/src/cloudformation/builders/messaging.ts +140 -0
- package/src/cloudformation/builders/monitoring.ts +300 -0
- package/src/cloudformation/builders/network.ts +264 -0
- package/src/cloudformation/builders/queue.ts +147 -0
- package/src/cloudformation/builders/security.ts +399 -0
- package/src/cloudformation/builders/storage.ts +285 -0
- package/src/cloudformation/index.ts +30 -0
- package/src/cloudformation/types.ts +173 -0
- package/src/compliance/aws-config.ts +543 -0
- package/src/compliance/cloudtrail.ts +376 -0
- package/src/compliance/compliance.test.ts +423 -0
- package/src/compliance/guardduty.ts +446 -0
- package/src/compliance/index.ts +66 -0
- package/src/compliance/security-hub.ts +456 -0
- package/src/containers/build-optimization.ts +416 -0
- package/src/containers/containers.test.ts +508 -0
- package/src/containers/image-scanning.ts +360 -0
- package/src/containers/index.ts +9 -0
- package/src/containers/registry.ts +293 -0
- package/src/containers/service-mesh.ts +520 -0
- package/src/database/database.test.ts +762 -0
- package/src/database/index.ts +9 -0
- package/src/database/migrations.ts +444 -0
- package/src/database/performance.ts +528 -0
- package/src/database/replicas.ts +534 -0
- package/src/database/users.ts +494 -0
- package/src/dependency-graph.ts +143 -0
- package/src/deployment/ab-testing.ts +582 -0
- package/src/deployment/blue-green.ts +452 -0
- package/src/deployment/canary.ts +500 -0
- package/src/deployment/deployment.test.ts +526 -0
- package/src/deployment/index.ts +61 -0
- package/src/deployment/progressive.ts +62 -0
- package/src/dns/dns.test.ts +641 -0
- package/src/dns/dnssec.ts +315 -0
- package/src/dns/index.ts +8 -0
- package/src/dns/resolver.ts +496 -0
- package/src/dns/routing.ts +593 -0
- package/src/email/advanced/analytics.ts +445 -0
- package/src/email/advanced/index.ts +11 -0
- package/src/email/advanced/rules.ts +465 -0
- package/src/email/advanced/scheduling.ts +352 -0
- package/src/email/advanced/search.ts +412 -0
- package/src/email/advanced/shared-mailboxes.ts +404 -0
- package/src/email/advanced/templates.ts +455 -0
- package/src/email/advanced/threading.ts +281 -0
- package/src/email/analytics.ts +467 -0
- package/src/email/bounce-handling.ts +425 -0
- package/src/email/email.test.ts +431 -0
- package/src/email/handlers/__tests__/inbound.test.ts +38 -0
- package/src/email/handlers/__tests__/outbound.test.ts +37 -0
- package/src/email/handlers/converter.ts +227 -0
- package/src/email/handlers/feedback.ts +228 -0
- package/src/email/handlers/inbound.ts +169 -0
- package/src/email/handlers/outbound.ts +178 -0
- package/src/email/index.ts +15 -0
- package/src/email/reputation.ts +303 -0
- package/src/email/templates.ts +352 -0
- package/src/errors/index.test.ts +434 -0
- package/src/errors/index.ts +416 -0
- package/src/health-checks/index.ts +40 -0
- package/src/index.ts +360 -0
- package/src/intrinsic-functions.ts +118 -0
- package/src/lambda/concurrency.ts +330 -0
- package/src/lambda/destinations.ts +345 -0
- package/src/lambda/dlq.ts +425 -0
- package/src/lambda/index.ts +11 -0
- package/src/lambda/lambda.test.ts +840 -0
- package/src/lambda/layers.ts +263 -0
- package/src/lambda/versions.ts +376 -0
- package/src/lambda/vpc.ts +399 -0
- package/src/local/config.ts +114 -0
- package/src/local/index.ts +6 -0
- package/src/local/mock-aws.ts +351 -0
- package/src/modules/ai.ts +340 -0
- package/src/modules/api.ts +478 -0
- package/src/modules/auth.ts +805 -0
- package/src/modules/cache.ts +417 -0
- package/src/modules/cdn.ts +1062 -0
- package/src/modules/communication.ts +1094 -0
- package/src/modules/compute.ts +3348 -0
- package/src/modules/database.ts +554 -0
- package/src/modules/deployment.ts +1079 -0
- package/src/modules/dns.ts +337 -0
- package/src/modules/email.ts +1538 -0
- package/src/modules/filesystem.ts +515 -0
- package/src/modules/index.ts +32 -0
- package/src/modules/messaging.ts +486 -0
- package/src/modules/monitoring.ts +2086 -0
- package/src/modules/network.ts +664 -0
- package/src/modules/parameter-store.ts +325 -0
- package/src/modules/permissions.ts +1081 -0
- package/src/modules/phone.ts +494 -0
- package/src/modules/queue.ts +1260 -0
- package/src/modules/redirects.ts +464 -0
- package/src/modules/registry.ts +699 -0
- package/src/modules/search.ts +401 -0
- package/src/modules/secrets.ts +416 -0
- package/src/modules/security.ts +731 -0
- package/src/modules/sms.ts +389 -0
- package/src/modules/storage.ts +1120 -0
- package/src/modules/workflow.ts +680 -0
- package/src/multi-account/config.ts +521 -0
- package/src/multi-account/index.ts +7 -0
- package/src/multi-account/manager.ts +427 -0
- package/src/multi-region/cross-region.ts +410 -0
- package/src/multi-region/index.ts +8 -0
- package/src/multi-region/manager.ts +483 -0
- package/src/multi-region/regions.ts +435 -0
- package/src/network-security/index.ts +48 -0
- package/src/observability/index.ts +9 -0
- package/src/observability/logs.ts +522 -0
- package/src/observability/metrics.ts +460 -0
- package/src/observability/observability.test.ts +782 -0
- package/src/observability/synthetics.ts +568 -0
- package/src/observability/xray.ts +358 -0
- package/src/phone/advanced/analytics.ts +349 -0
- package/src/phone/advanced/callbacks.ts +428 -0
- package/src/phone/advanced/index.ts +8 -0
- package/src/phone/advanced/ivr-builder.ts +504 -0
- package/src/phone/advanced/recording.ts +310 -0
- package/src/phone/handlers/__tests__/incoming-call.test.ts +40 -0
- package/src/phone/handlers/incoming-call.ts +117 -0
- package/src/phone/handlers/missed-call.ts +116 -0
- package/src/phone/handlers/voicemail.ts +179 -0
- package/src/phone/index.ts +9 -0
- package/src/presets/api-backend.ts +134 -0
- package/src/presets/data-pipeline.ts +204 -0
- package/src/presets/extend.test.ts +295 -0
- package/src/presets/extend.ts +297 -0
- package/src/presets/fullstack-app.ts +144 -0
- package/src/presets/index.ts +27 -0
- package/src/presets/jamstack.ts +135 -0
- package/src/presets/microservices.ts +167 -0
- package/src/presets/ml-api.ts +208 -0
- package/src/presets/nodejs-server.ts +104 -0
- package/src/presets/nodejs-serverless.ts +114 -0
- package/src/presets/realtime-app.ts +184 -0
- package/src/presets/static-site.ts +64 -0
- package/src/presets/traditional-web-app.ts +339 -0
- package/src/presets/wordpress.ts +138 -0
- package/src/preview/github.test.ts +249 -0
- package/src/preview/github.ts +297 -0
- package/src/preview/index.ts +37 -0
- package/src/preview/manager.test.ts +440 -0
- package/src/preview/manager.ts +326 -0
- package/src/preview/notifications.test.ts +582 -0
- package/src/preview/notifications.ts +341 -0
- package/src/queue/batch-processing.ts +402 -0
- package/src/queue/dlq-monitoring.ts +402 -0
- package/src/queue/fifo.ts +342 -0
- package/src/queue/index.ts +9 -0
- package/src/queue/management.ts +428 -0
- package/src/queue/queue.test.ts +429 -0
- package/src/resource-mgmt/index.ts +39 -0
- package/src/resource-naming.ts +62 -0
- package/src/s3/index.ts +523 -0
- package/src/schema/cloud-config.schema.json +554 -0
- package/src/schema/index.ts +68 -0
- package/src/security/certificate-manager.ts +492 -0
- package/src/security/index.ts +9 -0
- package/src/security/scanning.ts +545 -0
- package/src/security/secrets-manager.ts +476 -0
- package/src/security/secrets-rotation.ts +456 -0
- package/src/security/security.test.ts +738 -0
- package/src/sms/advanced/ab-testing.ts +389 -0
- package/src/sms/advanced/analytics.ts +336 -0
- package/src/sms/advanced/campaigns.ts +523 -0
- package/src/sms/advanced/chatbot.ts +224 -0
- package/src/sms/advanced/index.ts +10 -0
- package/src/sms/advanced/link-tracking.ts +248 -0
- package/src/sms/advanced/mms.ts +308 -0
- package/src/sms/handlers/__tests__/send.test.ts +40 -0
- package/src/sms/handlers/delivery-status.ts +133 -0
- package/src/sms/handlers/receive.ts +162 -0
- package/src/sms/handlers/send.ts +174 -0
- package/src/sms/index.ts +9 -0
- package/src/stack-diff.ts +389 -0
- package/src/static-site/index.ts +85 -0
- package/src/template-builder.ts +110 -0
- package/src/template-validator.ts +574 -0
- package/src/utils/cache.ts +291 -0
- package/src/utils/diff.ts +269 -0
- package/src/utils/hash.ts +227 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/parallel.ts +294 -0
- package/src/validators/credentials.test.ts +274 -0
- package/src/validators/credentials.ts +233 -0
- package/src/validators/quotas.test.ts +434 -0
- package/src/validators/quotas.ts +217 -0
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Signature Version 4 Signing Process
|
|
3
|
+
* Implements request signing for direct AWS API calls without SDK
|
|
4
|
+
*
|
|
5
|
+
* Reference: https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
|
|
6
|
+
*
|
|
7
|
+
* Browser compatible: Use async functions (signRequestAsync, createPresignedUrlAsync)
|
|
8
|
+
* Node.js/Bun: Use sync functions for better performance (signRequest, createPresignedUrl)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Conditional import for Node.js crypto - will be undefined in browser
|
|
12
|
+
let nodeCrypto: typeof import('node:crypto') | undefined
|
|
13
|
+
try {
|
|
14
|
+
nodeCrypto = await import('node:crypto')
|
|
15
|
+
} catch {
|
|
16
|
+
// Running in browser - nodeCrypto stays undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Signing key cache for improved performance on repeated requests
|
|
21
|
+
* Keys are cached by: secretAccessKey + date + region + service
|
|
22
|
+
* Supports both Buffer (Node.js) and Uint8Array (browser)
|
|
23
|
+
*/
|
|
24
|
+
const signingKeyCache = new Map<string, Buffer | Uint8Array>()
|
|
25
|
+
const MAX_CACHE_SIZE = 100
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Service name mappings for hosts that don't follow standard naming
|
|
29
|
+
*/
|
|
30
|
+
const HOST_SERVICES: Record<string, string> = {
|
|
31
|
+
'appstream2': 'appstream',
|
|
32
|
+
'cloudhsmv2': 'cloudhsm',
|
|
33
|
+
'email': 'ses',
|
|
34
|
+
'marketplace': 'aws-marketplace',
|
|
35
|
+
'mobile': 'AWSMobileHubService',
|
|
36
|
+
'pinpoint': 'mobiletargeting',
|
|
37
|
+
'queue': 'sqs',
|
|
38
|
+
'git-codecommit': 'codecommit',
|
|
39
|
+
'mturk-requester-sandbox': 'mturk-requester',
|
|
40
|
+
'personalize-runtime': 'personalize',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SignatureOptions {
|
|
44
|
+
method: string
|
|
45
|
+
url: string
|
|
46
|
+
/** Service name - if not provided, will be auto-detected from URL */
|
|
47
|
+
service?: string
|
|
48
|
+
/** Region - if not provided, will be auto-detected from URL */
|
|
49
|
+
region?: string
|
|
50
|
+
headers?: Record<string, string>
|
|
51
|
+
body?: string
|
|
52
|
+
accessKeyId: string
|
|
53
|
+
secretAccessKey: string
|
|
54
|
+
sessionToken?: string
|
|
55
|
+
/**
|
|
56
|
+
* Optional external cache for signing keys
|
|
57
|
+
* If not provided, uses internal cache
|
|
58
|
+
* Supports both Buffer (Node.js) and Uint8Array (browser)
|
|
59
|
+
*/
|
|
60
|
+
cache?: Map<string, Buffer | Uint8Array>
|
|
61
|
+
/**
|
|
62
|
+
* Sign via query string instead of Authorization header
|
|
63
|
+
* Used for presigned URLs (e.g., S3 presigned URLs)
|
|
64
|
+
*/
|
|
65
|
+
signQuery?: boolean
|
|
66
|
+
/**
|
|
67
|
+
* Expiration time in seconds for presigned URLs (default: 86400 = 24 hours)
|
|
68
|
+
* Only used when signQuery is true
|
|
69
|
+
*/
|
|
70
|
+
expiresIn?: number
|
|
71
|
+
/**
|
|
72
|
+
* Custom datetime for signing (format: YYYYMMDDTHHMMSSZ)
|
|
73
|
+
* If not provided, uses current time
|
|
74
|
+
* Useful for testing and reproducibility
|
|
75
|
+
*/
|
|
76
|
+
datetime?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface SignedRequest {
|
|
80
|
+
url: string
|
|
81
|
+
method: string
|
|
82
|
+
headers: Record<string, string>
|
|
83
|
+
body?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface PresignedUrlOptions {
|
|
87
|
+
url: string
|
|
88
|
+
method?: string
|
|
89
|
+
accessKeyId: string
|
|
90
|
+
secretAccessKey: string
|
|
91
|
+
sessionToken?: string
|
|
92
|
+
/** Service name - if not provided, will be auto-detected from URL */
|
|
93
|
+
service?: string
|
|
94
|
+
/** Region - if not provided, will be auto-detected from URL */
|
|
95
|
+
region?: string
|
|
96
|
+
/** Expiration time in seconds (default: 3600 = 1 hour, max: 604800 = 7 days) */
|
|
97
|
+
expiresIn?: number
|
|
98
|
+
/** Optional external cache for signing keys */
|
|
99
|
+
cache?: Map<string, Buffer | Uint8Array>
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface RetryOptions {
|
|
103
|
+
/** Maximum number of retry attempts (default: 3) */
|
|
104
|
+
maxRetries?: number
|
|
105
|
+
/** Initial delay in ms before first retry (default: 100) */
|
|
106
|
+
initialDelayMs?: number
|
|
107
|
+
/** Maximum delay in ms between retries (default: 5000) */
|
|
108
|
+
maxDelayMs?: number
|
|
109
|
+
/** HTTP status codes that should trigger a retry (default: [429, 500, 502, 503, 504]) */
|
|
110
|
+
retryableStatusCodes?: number[]
|
|
111
|
+
/** Request timeout in milliseconds (default: 30000 = 30 seconds) */
|
|
112
|
+
timeoutMs?: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Detect service and region from AWS URL
|
|
117
|
+
* Supports standard AWS endpoints, Lambda URLs, R2, and Backblaze B2
|
|
118
|
+
*/
|
|
119
|
+
export function detectServiceRegion(url: string | URL): { service: string, region: string } {
|
|
120
|
+
const urlObj = typeof url === 'string' ? new URL(url) : url
|
|
121
|
+
const { hostname, pathname } = urlObj
|
|
122
|
+
|
|
123
|
+
// Lambda function URLs: xxx.lambda-url.region.on.aws
|
|
124
|
+
if (hostname.endsWith('.on.aws')) {
|
|
125
|
+
const match = hostname.match(/^[^.]+\.lambda-url\.([^.]+)\.on\.aws$/)
|
|
126
|
+
if (match) {
|
|
127
|
+
return { service: 'lambda', region: match[1] }
|
|
128
|
+
}
|
|
129
|
+
return { service: '', region: '' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Cloudflare R2: xxx.r2.cloudflarestorage.com
|
|
133
|
+
if (hostname.endsWith('.r2.cloudflarestorage.com')) {
|
|
134
|
+
return { service: 's3', region: 'auto' }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Backblaze B2: xxx.s3.region.backblazeb2.com
|
|
138
|
+
if (hostname.endsWith('.backblazeb2.com')) {
|
|
139
|
+
const match = hostname.match(/^(?:[^.]+\.)?s3\.([^.]+)\.backblazeb2\.com$/)
|
|
140
|
+
if (match) {
|
|
141
|
+
return { service: 's3', region: match[1] }
|
|
142
|
+
}
|
|
143
|
+
return { service: '', region: '' }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Standard AWS endpoints: service.region.amazonaws.com
|
|
147
|
+
const match = hostname
|
|
148
|
+
.replace('dualstack.', '')
|
|
149
|
+
.match(/([^.]+)\.(?:([^.]+)\.)?amazonaws\.com(?:\.cn)?$/)
|
|
150
|
+
|
|
151
|
+
if (!match) {
|
|
152
|
+
return { service: '', region: '' }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let service = match[1]
|
|
156
|
+
let region = match[2] || ''
|
|
157
|
+
|
|
158
|
+
// Handle special cases
|
|
159
|
+
if (region === 'us-gov') {
|
|
160
|
+
region = 'us-gov-west-1'
|
|
161
|
+
} else if (region === 's3' || region === 's3-accelerate') {
|
|
162
|
+
region = 'us-east-1'
|
|
163
|
+
service = 's3'
|
|
164
|
+
} else if (service === 'iot') {
|
|
165
|
+
if (hostname.startsWith('iot.')) {
|
|
166
|
+
service = 'execute-api'
|
|
167
|
+
} else if (hostname.startsWith('data.jobs.iot.')) {
|
|
168
|
+
service = 'iot-jobs-data'
|
|
169
|
+
} else {
|
|
170
|
+
service = pathname === '/mqtt' ? 'iotdevicegateway' : 'iotdata'
|
|
171
|
+
}
|
|
172
|
+
} else if (service === 'autoscaling') {
|
|
173
|
+
// Could be application-autoscaling or autoscaling-plans based on target
|
|
174
|
+
// Default to autoscaling
|
|
175
|
+
} else if (!region && service.startsWith('s3-')) {
|
|
176
|
+
region = service.slice(3).replace(/^fips-|^external-1/, '')
|
|
177
|
+
service = 's3'
|
|
178
|
+
} else if (service.endsWith('-fips')) {
|
|
179
|
+
service = service.slice(0, -5)
|
|
180
|
+
} else if (region && /-\d$/.test(service) && !/-\d$/.test(region)) {
|
|
181
|
+
// Swap service and region if they appear reversed
|
|
182
|
+
[service, region] = [region, service]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Apply service name mappings
|
|
186
|
+
service = HOST_SERVICES[service] || service
|
|
187
|
+
|
|
188
|
+
return { service, region: region || 'us-east-1' }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Sign an AWS request using Signature Version 4
|
|
193
|
+
*/
|
|
194
|
+
export function signRequest(options: SignatureOptions): SignedRequest {
|
|
195
|
+
const {
|
|
196
|
+
method,
|
|
197
|
+
url,
|
|
198
|
+
body = '',
|
|
199
|
+
accessKeyId,
|
|
200
|
+
secretAccessKey,
|
|
201
|
+
sessionToken,
|
|
202
|
+
signQuery = false,
|
|
203
|
+
expiresIn = 86400,
|
|
204
|
+
datetime,
|
|
205
|
+
} = options
|
|
206
|
+
|
|
207
|
+
const urlObj = new URL(url)
|
|
208
|
+
const host = urlObj.hostname
|
|
209
|
+
|
|
210
|
+
// Auto-detect service and region if not provided
|
|
211
|
+
const detected = detectServiceRegion(urlObj)
|
|
212
|
+
const service = options.service || detected.service
|
|
213
|
+
const region = options.region || detected.region
|
|
214
|
+
|
|
215
|
+
if (!service) {
|
|
216
|
+
throw new Error('Could not detect service from URL. Please provide service explicitly.')
|
|
217
|
+
}
|
|
218
|
+
if (!region) {
|
|
219
|
+
throw new Error('Could not detect region from URL. Please provide region explicitly.')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Step 1: Create canonical request
|
|
223
|
+
const timestamp = datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
|
224
|
+
const date = timestamp.substring(0, 8)
|
|
225
|
+
const credentialScope = [date, region, service, 'aws4_request'].join('/')
|
|
226
|
+
const algorithm = 'AWS4-HMAC-SHA256'
|
|
227
|
+
|
|
228
|
+
if (signQuery) {
|
|
229
|
+
// Query string signing (for presigned URLs)
|
|
230
|
+
return signWithQueryString({
|
|
231
|
+
urlObj,
|
|
232
|
+
method,
|
|
233
|
+
body,
|
|
234
|
+
accessKeyId,
|
|
235
|
+
secretAccessKey,
|
|
236
|
+
sessionToken,
|
|
237
|
+
service,
|
|
238
|
+
region,
|
|
239
|
+
timestamp,
|
|
240
|
+
date,
|
|
241
|
+
credentialScope,
|
|
242
|
+
algorithm,
|
|
243
|
+
expiresIn,
|
|
244
|
+
cache: options.cache,
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Header-based signing
|
|
249
|
+
const path = urlObj.pathname || '/'
|
|
250
|
+
const query = canonicalQueryString(urlObj.searchParams)
|
|
251
|
+
|
|
252
|
+
const headers: Record<string, string> = {
|
|
253
|
+
'host': host,
|
|
254
|
+
'x-amz-date': timestamp,
|
|
255
|
+
...options.headers,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (sessionToken) {
|
|
259
|
+
headers['x-amz-security-token'] = sessionToken
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Add content-type for requests with body
|
|
263
|
+
if (body && !headers['content-type']) {
|
|
264
|
+
headers['content-type'] = 'application/x-amz-json-1.0'
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// For S3, add content hash header
|
|
268
|
+
if (service === 's3' && !headers['x-amz-content-sha256']) {
|
|
269
|
+
headers['x-amz-content-sha256'] = hash(body)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const canonicalHeaders = getCanonicalHeaders(headers)
|
|
273
|
+
const signedHeaders = getSignedHeaders(headers)
|
|
274
|
+
const payloadHash = headers['x-amz-content-sha256'] || hash(body)
|
|
275
|
+
|
|
276
|
+
const canonicalRequest = [
|
|
277
|
+
method,
|
|
278
|
+
encodePath(path),
|
|
279
|
+
query,
|
|
280
|
+
canonicalHeaders,
|
|
281
|
+
signedHeaders,
|
|
282
|
+
payloadHash,
|
|
283
|
+
].join('\n')
|
|
284
|
+
|
|
285
|
+
// Step 2: Create string to sign
|
|
286
|
+
const canonicalRequestHash = hash(canonicalRequest)
|
|
287
|
+
|
|
288
|
+
const stringToSign = [
|
|
289
|
+
algorithm,
|
|
290
|
+
timestamp,
|
|
291
|
+
credentialScope,
|
|
292
|
+
canonicalRequestHash,
|
|
293
|
+
].join('\n')
|
|
294
|
+
|
|
295
|
+
// Step 3: Calculate signature (with key caching for performance)
|
|
296
|
+
const cache = options.cache ?? signingKeyCache
|
|
297
|
+
const signature = calculateSignature(
|
|
298
|
+
secretAccessKey,
|
|
299
|
+
date,
|
|
300
|
+
region,
|
|
301
|
+
service,
|
|
302
|
+
stringToSign,
|
|
303
|
+
cache,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
// Step 4: Add authorization header
|
|
307
|
+
const authorization = [
|
|
308
|
+
`${algorithm} Credential=${accessKeyId}/${credentialScope}`,
|
|
309
|
+
`SignedHeaders=${signedHeaders}`,
|
|
310
|
+
`Signature=${signature}`,
|
|
311
|
+
].join(', ')
|
|
312
|
+
|
|
313
|
+
headers['authorization'] = authorization
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
url,
|
|
317
|
+
method,
|
|
318
|
+
headers,
|
|
319
|
+
body: body || undefined,
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Sign an AWS request using Signature Version 4 (async - browser compatible)
|
|
325
|
+
* Use this in browser environments where crypto.subtle is available
|
|
326
|
+
*/
|
|
327
|
+
export async function signRequestAsync(options: SignatureOptions): Promise<SignedRequest> {
|
|
328
|
+
const {
|
|
329
|
+
method,
|
|
330
|
+
url,
|
|
331
|
+
body = '',
|
|
332
|
+
accessKeyId,
|
|
333
|
+
secretAccessKey,
|
|
334
|
+
sessionToken,
|
|
335
|
+
signQuery = false,
|
|
336
|
+
expiresIn = 86400,
|
|
337
|
+
datetime,
|
|
338
|
+
} = options
|
|
339
|
+
|
|
340
|
+
const urlObj = new URL(url)
|
|
341
|
+
const host = urlObj.hostname
|
|
342
|
+
|
|
343
|
+
// Auto-detect service and region if not provided
|
|
344
|
+
const detected = detectServiceRegion(urlObj)
|
|
345
|
+
const service = options.service || detected.service
|
|
346
|
+
const region = options.region || detected.region
|
|
347
|
+
|
|
348
|
+
if (!service) {
|
|
349
|
+
throw new Error('Could not detect service from URL. Please provide service explicitly.')
|
|
350
|
+
}
|
|
351
|
+
if (!region) {
|
|
352
|
+
throw new Error('Could not detect region from URL. Please provide region explicitly.')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Step 1: Create canonical request
|
|
356
|
+
const timestamp = datetime || new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
|
|
357
|
+
const date = timestamp.substring(0, 8)
|
|
358
|
+
const credentialScope = [date, region, service, 'aws4_request'].join('/')
|
|
359
|
+
const algorithm = 'AWS4-HMAC-SHA256'
|
|
360
|
+
|
|
361
|
+
if (signQuery) {
|
|
362
|
+
// Query string signing (for presigned URLs)
|
|
363
|
+
return signWithQueryStringAsync({
|
|
364
|
+
urlObj,
|
|
365
|
+
method,
|
|
366
|
+
body,
|
|
367
|
+
accessKeyId,
|
|
368
|
+
secretAccessKey,
|
|
369
|
+
sessionToken,
|
|
370
|
+
service,
|
|
371
|
+
region,
|
|
372
|
+
timestamp,
|
|
373
|
+
date,
|
|
374
|
+
credentialScope,
|
|
375
|
+
algorithm,
|
|
376
|
+
expiresIn,
|
|
377
|
+
cache: options.cache,
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Header-based signing
|
|
382
|
+
const path = urlObj.pathname || '/'
|
|
383
|
+
const query = canonicalQueryString(urlObj.searchParams)
|
|
384
|
+
|
|
385
|
+
const headers: Record<string, string> = {
|
|
386
|
+
'host': host,
|
|
387
|
+
'x-amz-date': timestamp,
|
|
388
|
+
...options.headers,
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (sessionToken) {
|
|
392
|
+
headers['x-amz-security-token'] = sessionToken
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Add content-type for requests with body
|
|
396
|
+
if (body && !headers['content-type']) {
|
|
397
|
+
headers['content-type'] = 'application/x-amz-json-1.0'
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// For S3, add content hash header
|
|
401
|
+
const bodyHash = await hashAsync(body)
|
|
402
|
+
if (service === 's3' && !headers['x-amz-content-sha256']) {
|
|
403
|
+
headers['x-amz-content-sha256'] = bodyHash
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const canonicalHeaders = getCanonicalHeaders(headers)
|
|
407
|
+
const signedHeaders = getSignedHeaders(headers)
|
|
408
|
+
const payloadHash = headers['x-amz-content-sha256'] || bodyHash
|
|
409
|
+
|
|
410
|
+
const canonicalRequest = [
|
|
411
|
+
method,
|
|
412
|
+
encodePath(path),
|
|
413
|
+
query,
|
|
414
|
+
canonicalHeaders,
|
|
415
|
+
signedHeaders,
|
|
416
|
+
payloadHash,
|
|
417
|
+
].join('\n')
|
|
418
|
+
|
|
419
|
+
// Step 2: Create string to sign
|
|
420
|
+
const canonicalRequestHash = await hashAsync(canonicalRequest)
|
|
421
|
+
|
|
422
|
+
const stringToSign = [
|
|
423
|
+
algorithm,
|
|
424
|
+
timestamp,
|
|
425
|
+
credentialScope,
|
|
426
|
+
canonicalRequestHash,
|
|
427
|
+
].join('\n')
|
|
428
|
+
|
|
429
|
+
// Step 3: Calculate signature (with key caching for performance)
|
|
430
|
+
const cache = options.cache ?? signingKeyCache
|
|
431
|
+
const signature = await calculateSignatureAsync(
|
|
432
|
+
secretAccessKey,
|
|
433
|
+
date,
|
|
434
|
+
region,
|
|
435
|
+
service,
|
|
436
|
+
stringToSign,
|
|
437
|
+
cache,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
// Step 4: Add authorization header
|
|
441
|
+
const authorization = [
|
|
442
|
+
`${algorithm} Credential=${accessKeyId}/${credentialScope}`,
|
|
443
|
+
`SignedHeaders=${signedHeaders}`,
|
|
444
|
+
`Signature=${signature}`,
|
|
445
|
+
].join(', ')
|
|
446
|
+
|
|
447
|
+
headers['authorization'] = authorization
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
url,
|
|
451
|
+
method,
|
|
452
|
+
headers,
|
|
453
|
+
body: body || undefined,
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Sign request using query string parameters (for presigned URLs)
|
|
459
|
+
*/
|
|
460
|
+
function signWithQueryString(params: {
|
|
461
|
+
urlObj: URL
|
|
462
|
+
method: string
|
|
463
|
+
body: string
|
|
464
|
+
accessKeyId: string
|
|
465
|
+
secretAccessKey: string
|
|
466
|
+
sessionToken?: string
|
|
467
|
+
service: string
|
|
468
|
+
region: string
|
|
469
|
+
timestamp: string
|
|
470
|
+
date: string
|
|
471
|
+
credentialScope: string
|
|
472
|
+
algorithm: string
|
|
473
|
+
expiresIn: number
|
|
474
|
+
cache?: Map<string, Buffer | Uint8Array>
|
|
475
|
+
}): SignedRequest {
|
|
476
|
+
const {
|
|
477
|
+
urlObj,
|
|
478
|
+
method,
|
|
479
|
+
body,
|
|
480
|
+
accessKeyId,
|
|
481
|
+
secretAccessKey,
|
|
482
|
+
sessionToken,
|
|
483
|
+
service,
|
|
484
|
+
region,
|
|
485
|
+
timestamp,
|
|
486
|
+
date,
|
|
487
|
+
credentialScope,
|
|
488
|
+
algorithm,
|
|
489
|
+
expiresIn,
|
|
490
|
+
cache,
|
|
491
|
+
} = params
|
|
492
|
+
|
|
493
|
+
// Clone URL to avoid modifying original
|
|
494
|
+
const signedUrl = new URL(urlObj.toString())
|
|
495
|
+
|
|
496
|
+
// Add required query parameters
|
|
497
|
+
signedUrl.searchParams.set('X-Amz-Algorithm', algorithm)
|
|
498
|
+
signedUrl.searchParams.set('X-Amz-Credential', `${accessKeyId}/${credentialScope}`)
|
|
499
|
+
signedUrl.searchParams.set('X-Amz-Date', timestamp)
|
|
500
|
+
signedUrl.searchParams.set('X-Amz-Expires', String(expiresIn))
|
|
501
|
+
|
|
502
|
+
// For S3, default to UNSIGNED-PAYLOAD
|
|
503
|
+
const payloadHash = service === 's3' ? 'UNSIGNED-PAYLOAD' : hash(body)
|
|
504
|
+
if (service === 's3') {
|
|
505
|
+
signedUrl.searchParams.set('X-Amz-Content-Sha256', payloadHash)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Signed headers (only host for query string signing)
|
|
509
|
+
const signedHeaders = 'host'
|
|
510
|
+
signedUrl.searchParams.set('X-Amz-SignedHeaders', signedHeaders)
|
|
511
|
+
|
|
512
|
+
if (sessionToken) {
|
|
513
|
+
signedUrl.searchParams.set('X-Amz-Security-Token', sessionToken)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Build canonical request
|
|
517
|
+
const path = encodePath(signedUrl.pathname || '/')
|
|
518
|
+
const canonicalHeaders = `host:${signedUrl.hostname}\n`
|
|
519
|
+
const query = canonicalQueryString(signedUrl.searchParams)
|
|
520
|
+
|
|
521
|
+
const canonicalRequest = [
|
|
522
|
+
method,
|
|
523
|
+
path,
|
|
524
|
+
query,
|
|
525
|
+
canonicalHeaders,
|
|
526
|
+
signedHeaders,
|
|
527
|
+
payloadHash,
|
|
528
|
+
].join('\n')
|
|
529
|
+
|
|
530
|
+
// Create string to sign
|
|
531
|
+
const stringToSign = [
|
|
532
|
+
algorithm,
|
|
533
|
+
timestamp,
|
|
534
|
+
credentialScope,
|
|
535
|
+
hash(canonicalRequest),
|
|
536
|
+
].join('\n')
|
|
537
|
+
|
|
538
|
+
// Calculate signature
|
|
539
|
+
const signingCache = cache ?? signingKeyCache
|
|
540
|
+
const signature = calculateSignature(
|
|
541
|
+
secretAccessKey,
|
|
542
|
+
date,
|
|
543
|
+
region,
|
|
544
|
+
service,
|
|
545
|
+
stringToSign,
|
|
546
|
+
signingCache,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
// Add signature to URL
|
|
550
|
+
signedUrl.searchParams.set('X-Amz-Signature', signature)
|
|
551
|
+
|
|
552
|
+
return {
|
|
553
|
+
url: signedUrl.toString(),
|
|
554
|
+
method,
|
|
555
|
+
headers: {},
|
|
556
|
+
body: body || undefined,
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Sign request using query string parameters (async - browser compatible)
|
|
562
|
+
*/
|
|
563
|
+
async function signWithQueryStringAsync(params: {
|
|
564
|
+
urlObj: URL
|
|
565
|
+
method: string
|
|
566
|
+
body: string
|
|
567
|
+
accessKeyId: string
|
|
568
|
+
secretAccessKey: string
|
|
569
|
+
sessionToken?: string
|
|
570
|
+
service: string
|
|
571
|
+
region: string
|
|
572
|
+
timestamp: string
|
|
573
|
+
date: string
|
|
574
|
+
credentialScope: string
|
|
575
|
+
algorithm: string
|
|
576
|
+
expiresIn: number
|
|
577
|
+
cache?: Map<string, Buffer | Uint8Array>
|
|
578
|
+
}): Promise<SignedRequest> {
|
|
579
|
+
const {
|
|
580
|
+
urlObj,
|
|
581
|
+
method,
|
|
582
|
+
body,
|
|
583
|
+
accessKeyId,
|
|
584
|
+
secretAccessKey,
|
|
585
|
+
sessionToken,
|
|
586
|
+
service,
|
|
587
|
+
region,
|
|
588
|
+
timestamp,
|
|
589
|
+
date,
|
|
590
|
+
credentialScope,
|
|
591
|
+
algorithm,
|
|
592
|
+
expiresIn,
|
|
593
|
+
cache,
|
|
594
|
+
} = params
|
|
595
|
+
|
|
596
|
+
// Clone URL to avoid modifying original
|
|
597
|
+
const signedUrl = new URL(urlObj.toString())
|
|
598
|
+
|
|
599
|
+
// Add required query parameters
|
|
600
|
+
signedUrl.searchParams.set('X-Amz-Algorithm', algorithm)
|
|
601
|
+
signedUrl.searchParams.set('X-Amz-Credential', `${accessKeyId}/${credentialScope}`)
|
|
602
|
+
signedUrl.searchParams.set('X-Amz-Date', timestamp)
|
|
603
|
+
signedUrl.searchParams.set('X-Amz-Expires', String(expiresIn))
|
|
604
|
+
|
|
605
|
+
// For S3, default to UNSIGNED-PAYLOAD
|
|
606
|
+
const payloadHash = service === 's3' ? 'UNSIGNED-PAYLOAD' : await hashAsync(body)
|
|
607
|
+
if (service === 's3') {
|
|
608
|
+
signedUrl.searchParams.set('X-Amz-Content-Sha256', payloadHash)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Signed headers (only host for query string signing)
|
|
612
|
+
const signedHeaders = 'host'
|
|
613
|
+
signedUrl.searchParams.set('X-Amz-SignedHeaders', signedHeaders)
|
|
614
|
+
|
|
615
|
+
if (sessionToken) {
|
|
616
|
+
signedUrl.searchParams.set('X-Amz-Security-Token', sessionToken)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Build canonical request
|
|
620
|
+
const path = encodePath(signedUrl.pathname || '/')
|
|
621
|
+
const canonicalHeaders = `host:${signedUrl.hostname}\n`
|
|
622
|
+
const query = canonicalQueryString(signedUrl.searchParams)
|
|
623
|
+
|
|
624
|
+
const canonicalRequest = [
|
|
625
|
+
method,
|
|
626
|
+
path,
|
|
627
|
+
query,
|
|
628
|
+
canonicalHeaders,
|
|
629
|
+
signedHeaders,
|
|
630
|
+
payloadHash,
|
|
631
|
+
].join('\n')
|
|
632
|
+
|
|
633
|
+
// Create string to sign
|
|
634
|
+
const stringToSign = [
|
|
635
|
+
algorithm,
|
|
636
|
+
timestamp,
|
|
637
|
+
credentialScope,
|
|
638
|
+
await hashAsync(canonicalRequest),
|
|
639
|
+
].join('\n')
|
|
640
|
+
|
|
641
|
+
// Calculate signature
|
|
642
|
+
const signingCache = cache ?? signingKeyCache
|
|
643
|
+
const signature = await calculateSignatureAsync(
|
|
644
|
+
secretAccessKey,
|
|
645
|
+
date,
|
|
646
|
+
region,
|
|
647
|
+
service,
|
|
648
|
+
stringToSign,
|
|
649
|
+
signingCache,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
// Add signature to URL
|
|
653
|
+
signedUrl.searchParams.set('X-Amz-Signature', signature)
|
|
654
|
+
|
|
655
|
+
return {
|
|
656
|
+
url: signedUrl.toString(),
|
|
657
|
+
method,
|
|
658
|
+
headers: {},
|
|
659
|
+
body: body || undefined,
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Generate a presigned URL for AWS requests (e.g., S3 GetObject, PutObject)
|
|
665
|
+
*/
|
|
666
|
+
export function createPresignedUrl(options: PresignedUrlOptions): string {
|
|
667
|
+
const {
|
|
668
|
+
url,
|
|
669
|
+
method = 'GET',
|
|
670
|
+
expiresIn = 3600,
|
|
671
|
+
...rest
|
|
672
|
+
} = options
|
|
673
|
+
|
|
674
|
+
// Max expiration is 7 days for most services
|
|
675
|
+
const clampedExpires = Math.min(expiresIn, 604800)
|
|
676
|
+
|
|
677
|
+
const signed = signRequest({
|
|
678
|
+
...rest,
|
|
679
|
+
url,
|
|
680
|
+
method,
|
|
681
|
+
signQuery: true,
|
|
682
|
+
expiresIn: clampedExpires,
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
return signed.url
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Generate a presigned URL for AWS requests (async - browser compatible)
|
|
690
|
+
*/
|
|
691
|
+
export async function createPresignedUrlAsync(options: PresignedUrlOptions): Promise<string> {
|
|
692
|
+
const {
|
|
693
|
+
url,
|
|
694
|
+
method = 'GET',
|
|
695
|
+
expiresIn = 3600,
|
|
696
|
+
...rest
|
|
697
|
+
} = options
|
|
698
|
+
|
|
699
|
+
// Max expiration is 7 days for most services
|
|
700
|
+
const clampedExpires = Math.min(expiresIn, 604800)
|
|
701
|
+
|
|
702
|
+
const signed = await signRequestAsync({
|
|
703
|
+
...rest,
|
|
704
|
+
url,
|
|
705
|
+
method,
|
|
706
|
+
signQuery: true,
|
|
707
|
+
expiresIn: clampedExpires,
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
return signed.url
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Create canonical headers string
|
|
715
|
+
*/
|
|
716
|
+
function getCanonicalHeaders(headers: Record<string, string>): string {
|
|
717
|
+
return Object.keys(headers)
|
|
718
|
+
.sort()
|
|
719
|
+
.map(key => `${key.toLowerCase()}:${headers[key].trim().replace(/\s+/g, ' ')}`)
|
|
720
|
+
.join('\n') + '\n'
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Get signed headers string
|
|
725
|
+
*/
|
|
726
|
+
function getSignedHeaders(headers: Record<string, string>): string {
|
|
727
|
+
return Object.keys(headers)
|
|
728
|
+
.sort()
|
|
729
|
+
.map(key => key.toLowerCase())
|
|
730
|
+
.join(';')
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Create canonical query string (sorted and encoded)
|
|
735
|
+
*/
|
|
736
|
+
function canonicalQueryString(params: URLSearchParams): string {
|
|
737
|
+
const sorted: Array<[string, string]> = []
|
|
738
|
+
|
|
739
|
+
params.forEach((value, key) => {
|
|
740
|
+
sorted.push([encodeRfc3986(key), encodeRfc3986(value)])
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
sorted.sort((a, b) => {
|
|
744
|
+
if (a[0] < b[0]) return -1
|
|
745
|
+
if (a[0] > b[0]) return 1
|
|
746
|
+
if (a[1] < b[1]) return -1
|
|
747
|
+
if (a[1] > b[1]) return 1
|
|
748
|
+
return 0
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
return sorted.map(([k, v]) => `${k}=${v}`).join('&')
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Encode path for canonical request
|
|
756
|
+
*/
|
|
757
|
+
function encodePath(path: string): string {
|
|
758
|
+
return path
|
|
759
|
+
.split('/')
|
|
760
|
+
.map(segment => encodeRfc3986(segment))
|
|
761
|
+
.join('/')
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* RFC 3986 URI encoding (stricter than encodeURIComponent)
|
|
766
|
+
*/
|
|
767
|
+
function encodeRfc3986(str: string): string {
|
|
768
|
+
return encodeURIComponent(str).replace(/[!'()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Calculate SHA256 hash (synchronous - Node.js/Bun only)
|
|
773
|
+
*/
|
|
774
|
+
function hash(data: string): string {
|
|
775
|
+
if (!nodeCrypto) {
|
|
776
|
+
throw new Error('Synchronous hash not available in browser. Use signRequestAsync() instead.')
|
|
777
|
+
}
|
|
778
|
+
return nodeCrypto.createHash('sha256').update(data, 'utf8').digest('hex')
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Calculate HMAC SHA256 (synchronous - Node.js/Bun only)
|
|
783
|
+
*/
|
|
784
|
+
function hmac(key: Buffer | string, data: string): Buffer {
|
|
785
|
+
if (!nodeCrypto) {
|
|
786
|
+
throw new Error('Synchronous hmac not available in browser. Use signRequestAsync() instead.')
|
|
787
|
+
}
|
|
788
|
+
return nodeCrypto.createHmac('sha256', key).update(data, 'utf8').digest()
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Calculate SHA256 hash (async - browser compatible)
|
|
793
|
+
*/
|
|
794
|
+
async function hashAsync(data: string): Promise<string> {
|
|
795
|
+
const encoder = new TextEncoder()
|
|
796
|
+
const dataBuffer = encoder.encode(data)
|
|
797
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer)
|
|
798
|
+
return bufferToHex(new Uint8Array(hashBuffer))
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Calculate HMAC SHA256 (async - browser compatible)
|
|
803
|
+
*/
|
|
804
|
+
async function hmacAsync(key: Uint8Array | string, data: string): Promise<Uint8Array> {
|
|
805
|
+
const encoder = new TextEncoder()
|
|
806
|
+
const keyBuffer = typeof key === 'string' ? encoder.encode(key) : key
|
|
807
|
+
const dataBuffer = encoder.encode(data)
|
|
808
|
+
|
|
809
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
810
|
+
'raw',
|
|
811
|
+
keyBuffer.buffer as ArrayBuffer,
|
|
812
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
813
|
+
false,
|
|
814
|
+
['sign'],
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
const signature = await crypto.subtle.sign('HMAC', cryptoKey, dataBuffer.buffer)
|
|
818
|
+
return new Uint8Array(signature)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Convert Uint8Array to hex string
|
|
823
|
+
*/
|
|
824
|
+
function bufferToHex(buffer: Uint8Array): string {
|
|
825
|
+
return Array.from(buffer)
|
|
826
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
827
|
+
.join('')
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Calculate signature using AWS signing key derivation (sync - Node.js/Bun only)
|
|
832
|
+
* Uses caching for signing keys to improve performance on repeated requests
|
|
833
|
+
*/
|
|
834
|
+
function calculateSignature(
|
|
835
|
+
secretAccessKey: string,
|
|
836
|
+
date: string,
|
|
837
|
+
region: string,
|
|
838
|
+
service: string,
|
|
839
|
+
stringToSign: string,
|
|
840
|
+
cache: Map<string, Buffer | Uint8Array>,
|
|
841
|
+
): string {
|
|
842
|
+
// Create cache key from signing parameters
|
|
843
|
+
const cacheKey = `${secretAccessKey}:${date}:${region}:${service}`
|
|
844
|
+
|
|
845
|
+
let kSigning = cache.get(cacheKey) as Buffer | undefined
|
|
846
|
+
|
|
847
|
+
if (!kSigning) {
|
|
848
|
+
// Derive signing key (expensive operation)
|
|
849
|
+
const kDate = hmac(`AWS4${secretAccessKey}`, date)
|
|
850
|
+
const kRegion = hmac(kDate, region)
|
|
851
|
+
const kService = hmac(kRegion, service)
|
|
852
|
+
kSigning = hmac(kService, 'aws4_request')
|
|
853
|
+
|
|
854
|
+
// Limit cache size to prevent memory leaks
|
|
855
|
+
if (cache.size >= MAX_CACHE_SIZE) {
|
|
856
|
+
// Remove oldest entry (first key)
|
|
857
|
+
const firstKey = cache.keys().next().value
|
|
858
|
+
if (firstKey)
|
|
859
|
+
cache.delete(firstKey)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
cache.set(cacheKey, kSigning)
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
return hmac(kSigning, stringToSign).toString('hex')
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Calculate signature using AWS signing key derivation (async - browser compatible)
|
|
870
|
+
* Uses caching for signing keys to improve performance on repeated requests
|
|
871
|
+
*/
|
|
872
|
+
async function calculateSignatureAsync(
|
|
873
|
+
secretAccessKey: string,
|
|
874
|
+
date: string,
|
|
875
|
+
region: string,
|
|
876
|
+
service: string,
|
|
877
|
+
stringToSign: string,
|
|
878
|
+
cache: Map<string, Buffer | Uint8Array>,
|
|
879
|
+
): Promise<string> {
|
|
880
|
+
// Create cache key from signing parameters
|
|
881
|
+
const cacheKey = `${secretAccessKey}:${date}:${region}:${service}`
|
|
882
|
+
|
|
883
|
+
let kSigning = cache.get(cacheKey) as Uint8Array | undefined
|
|
884
|
+
|
|
885
|
+
if (!kSigning) {
|
|
886
|
+
// Derive signing key (expensive operation)
|
|
887
|
+
const kDate = await hmacAsync(`AWS4${secretAccessKey}`, date)
|
|
888
|
+
const kRegion = await hmacAsync(kDate, region)
|
|
889
|
+
const kService = await hmacAsync(kRegion, service)
|
|
890
|
+
kSigning = await hmacAsync(kService, 'aws4_request')
|
|
891
|
+
|
|
892
|
+
// Limit cache size to prevent memory leaks
|
|
893
|
+
if (cache.size >= MAX_CACHE_SIZE) {
|
|
894
|
+
// Remove oldest entry (first key)
|
|
895
|
+
const firstKey = cache.keys().next().value
|
|
896
|
+
if (firstKey)
|
|
897
|
+
cache.delete(firstKey)
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
cache.set(cacheKey, kSigning)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const signature = await hmacAsync(kSigning, stringToSign)
|
|
904
|
+
return bufferToHex(signature)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Check if an error/status code is retryable
|
|
909
|
+
*/
|
|
910
|
+
function isRetryable(status: number, retryableCodes: number[]): boolean {
|
|
911
|
+
return retryableCodes.includes(status)
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Calculate delay with exponential backoff and jitter
|
|
916
|
+
*/
|
|
917
|
+
function calculateBackoff(attempt: number, initialDelayMs: number, maxDelayMs: number): number {
|
|
918
|
+
const exponentialDelay = initialDelayMs * Math.pow(2, attempt)
|
|
919
|
+
const jitter = Math.random() * 0.3 * exponentialDelay // 0-30% jitter
|
|
920
|
+
return Math.min(exponentialDelay + jitter, maxDelayMs)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Sleep for specified milliseconds
|
|
925
|
+
*/
|
|
926
|
+
function sleep(ms: number): Promise<void> {
|
|
927
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Make a signed AWS API request with automatic retry
|
|
932
|
+
*/
|
|
933
|
+
export async function makeAWSRequest(
|
|
934
|
+
options: SignatureOptions,
|
|
935
|
+
retryOptions?: RetryOptions,
|
|
936
|
+
): Promise<Response> {
|
|
937
|
+
const {
|
|
938
|
+
maxRetries = 3,
|
|
939
|
+
initialDelayMs = 100,
|
|
940
|
+
maxDelayMs = 5000,
|
|
941
|
+
retryableStatusCodes = [429, 500, 502, 503, 504],
|
|
942
|
+
timeoutMs = 30000,
|
|
943
|
+
} = retryOptions ?? {}
|
|
944
|
+
|
|
945
|
+
let lastError: Error | undefined
|
|
946
|
+
let lastResponse: Response | undefined
|
|
947
|
+
|
|
948
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
949
|
+
// Re-sign request on each attempt (timestamp changes)
|
|
950
|
+
const signedRequest = signRequest(options)
|
|
951
|
+
|
|
952
|
+
const fetchOptions: RequestInit = {
|
|
953
|
+
method: signedRequest.method,
|
|
954
|
+
headers: signedRequest.headers,
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
if (signedRequest.body) {
|
|
958
|
+
fetchOptions.body = signedRequest.body
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Add timeout support via AbortController
|
|
962
|
+
const controller = new AbortController()
|
|
963
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
|
964
|
+
fetchOptions.signal = controller.signal
|
|
965
|
+
|
|
966
|
+
try {
|
|
967
|
+
const response = await fetch(signedRequest.url, fetchOptions)
|
|
968
|
+
clearTimeout(timeoutId)
|
|
969
|
+
lastResponse = response
|
|
970
|
+
|
|
971
|
+
// Success - return immediately
|
|
972
|
+
if (response.ok) {
|
|
973
|
+
return response
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Check if we should retry
|
|
977
|
+
if (attempt < maxRetries && isRetryable(response.status, retryableStatusCodes)) {
|
|
978
|
+
const delay = calculateBackoff(attempt, initialDelayMs, maxDelayMs)
|
|
979
|
+
await sleep(delay)
|
|
980
|
+
continue
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Non-retryable error or max retries reached
|
|
984
|
+
const errorText = await response.text()
|
|
985
|
+
throw new Error(`AWS API request failed (${response.status}): ${errorText}`)
|
|
986
|
+
} catch (error) {
|
|
987
|
+
clearTimeout(timeoutId)
|
|
988
|
+
lastError = error as Error
|
|
989
|
+
|
|
990
|
+
// Handle timeout errors
|
|
991
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
992
|
+
lastError = new Error(`Request timed out after ${timeoutMs}ms`)
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Network errors and timeouts are retryable
|
|
996
|
+
if (attempt < maxRetries && !(error instanceof Error && error.message.includes('AWS API request failed'))) {
|
|
997
|
+
const delay = calculateBackoff(attempt, initialDelayMs, maxDelayMs)
|
|
998
|
+
await sleep(delay)
|
|
999
|
+
continue
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
throw lastError
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Should not reach here, but just in case
|
|
1007
|
+
throw lastError ?? new Error('Request failed after retries')
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Make a signed AWS API request without retry (for backwards compatibility)
|
|
1012
|
+
*/
|
|
1013
|
+
export async function makeAWSRequestOnce(
|
|
1014
|
+
options: SignatureOptions,
|
|
1015
|
+
): Promise<Response> {
|
|
1016
|
+
return makeAWSRequest(options, { maxRetries: 0 })
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Make a signed AWS API request with automatic retry (async - browser compatible)
|
|
1021
|
+
* Use this in browser environments where crypto.subtle is available
|
|
1022
|
+
*/
|
|
1023
|
+
export async function makeAWSRequestAsync(
|
|
1024
|
+
options: SignatureOptions,
|
|
1025
|
+
retryOptions?: RetryOptions,
|
|
1026
|
+
): Promise<Response> {
|
|
1027
|
+
const {
|
|
1028
|
+
maxRetries = 3,
|
|
1029
|
+
initialDelayMs = 100,
|
|
1030
|
+
maxDelayMs = 5000,
|
|
1031
|
+
retryableStatusCodes = [429, 500, 502, 503, 504],
|
|
1032
|
+
timeoutMs = 30000,
|
|
1033
|
+
} = retryOptions ?? {}
|
|
1034
|
+
|
|
1035
|
+
let lastError: Error | undefined
|
|
1036
|
+
|
|
1037
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1038
|
+
// Re-sign request on each attempt (timestamp changes)
|
|
1039
|
+
const signedRequest = await signRequestAsync(options)
|
|
1040
|
+
|
|
1041
|
+
const fetchOptions: RequestInit = {
|
|
1042
|
+
method: signedRequest.method,
|
|
1043
|
+
headers: signedRequest.headers,
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if (signedRequest.body) {
|
|
1047
|
+
fetchOptions.body = signedRequest.body
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Add timeout support via AbortController
|
|
1051
|
+
const controller = new AbortController()
|
|
1052
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
|
1053
|
+
fetchOptions.signal = controller.signal
|
|
1054
|
+
|
|
1055
|
+
try {
|
|
1056
|
+
const response = await fetch(signedRequest.url, fetchOptions)
|
|
1057
|
+
clearTimeout(timeoutId)
|
|
1058
|
+
|
|
1059
|
+
// Success - return immediately
|
|
1060
|
+
if (response.ok) {
|
|
1061
|
+
return response
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Check if we should retry
|
|
1065
|
+
if (attempt < maxRetries && isRetryable(response.status, retryableStatusCodes)) {
|
|
1066
|
+
const delay = calculateBackoff(attempt, initialDelayMs, maxDelayMs)
|
|
1067
|
+
await sleep(delay)
|
|
1068
|
+
continue
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Non-retryable error or max retries reached
|
|
1072
|
+
const errorText = await response.text()
|
|
1073
|
+
throw new Error(`AWS API request failed (${response.status}): ${errorText}`)
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
clearTimeout(timeoutId)
|
|
1076
|
+
lastError = error as Error
|
|
1077
|
+
|
|
1078
|
+
// Handle timeout errors
|
|
1079
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
1080
|
+
lastError = new Error(`Request timed out after ${timeoutMs}ms`)
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Network errors and timeouts are retryable
|
|
1084
|
+
if (attempt < maxRetries && !(error instanceof Error && error.message.includes('AWS API request failed'))) {
|
|
1085
|
+
const delay = calculateBackoff(attempt, initialDelayMs, maxDelayMs)
|
|
1086
|
+
await sleep(delay)
|
|
1087
|
+
continue
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
throw lastError
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Should not reach here, but just in case
|
|
1095
|
+
throw lastError ?? new Error('Request failed after retries')
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Parse XML response from AWS
|
|
1100
|
+
*/
|
|
1101
|
+
export async function parseXMLResponse<T = any>(response: Response): Promise<T> {
|
|
1102
|
+
const text = await response.text()
|
|
1103
|
+
|
|
1104
|
+
// Simple XML parsing (for production, use a proper XML parser)
|
|
1105
|
+
// This is a basic implementation for demonstration
|
|
1106
|
+
const result: any = {}
|
|
1107
|
+
|
|
1108
|
+
// Extract key-value pairs from XML
|
|
1109
|
+
const regex = /<(\w+)>([^<]+)<\/\1>/g
|
|
1110
|
+
let match
|
|
1111
|
+
while ((match = regex.exec(text)) !== null) {
|
|
1112
|
+
const [, key, value] = match
|
|
1113
|
+
result[key] = value
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
return result as T
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Parse JSON response from AWS
|
|
1121
|
+
*/
|
|
1122
|
+
export async function parseJSONResponse<T = any>(response: Response): Promise<T> {
|
|
1123
|
+
return await response.json() as T
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Clear the internal signing key cache
|
|
1128
|
+
* Call this when credentials change or for testing
|
|
1129
|
+
*/
|
|
1130
|
+
export function clearSigningKeyCache(): void {
|
|
1131
|
+
signingKeyCache.clear()
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Get current cache size (for diagnostics)
|
|
1136
|
+
*/
|
|
1137
|
+
export function getSigningKeyCacheSize(): number {
|
|
1138
|
+
return signingKeyCache.size
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Check if Node.js crypto is available (for sync operations)
|
|
1143
|
+
* Returns true in Node.js/Bun, false in browser
|
|
1144
|
+
*/
|
|
1145
|
+
export function isNodeCryptoAvailable(): boolean {
|
|
1146
|
+
return nodeCrypto !== undefined
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* Check if Web Crypto API is available (for async operations)
|
|
1151
|
+
* Returns true in modern browsers and Node.js 15+
|
|
1152
|
+
*/
|
|
1153
|
+
export function isWebCryptoAvailable(): boolean {
|
|
1154
|
+
return typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'
|
|
1155
|
+
}
|