@chaim-tools/cdk-lib 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +238 -0
- package/lib/binders/base-chaim-binder.d.ts +144 -0
- package/lib/binders/base-chaim-binder.js +532 -0
- package/lib/binders/chaim-dynamodb-binder.d.ts +95 -0
- package/lib/binders/chaim-dynamodb-binder.js +292 -0
- package/lib/config/chaim-endpoints.d.ts +47 -0
- package/lib/config/chaim-endpoints.js +51 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +43 -0
- package/lib/lambda-handler/.test-temp/snapshot.json +1 -0
- package/lib/lambda-handler/handler.js +513 -0
- package/lib/lambda-handler/handler.test.ts +365 -0
- package/lib/lambda-handler/package-lock.json +1223 -0
- package/lib/lambda-handler/package.json +14 -0
- package/lib/services/ingestion-service.d.ts +50 -0
- package/lib/services/ingestion-service.js +81 -0
- package/lib/services/os-cache-paths.d.ts +52 -0
- package/lib/services/os-cache-paths.js +123 -0
- package/lib/services/schema-service.d.ts +11 -0
- package/lib/services/schema-service.js +67 -0
- package/lib/services/snapshot-cleanup.d.ts +78 -0
- package/lib/services/snapshot-cleanup.js +220 -0
- package/lib/types/base-binder-props.d.ts +32 -0
- package/lib/types/base-binder-props.js +17 -0
- package/lib/types/credentials.d.ts +57 -0
- package/lib/types/credentials.js +83 -0
- package/lib/types/data-store-metadata.d.ts +67 -0
- package/lib/types/data-store-metadata.js +4 -0
- package/lib/types/failure-mode.d.ts +16 -0
- package/lib/types/failure-mode.js +21 -0
- package/lib/types/ingest-contract.d.ts +110 -0
- package/lib/types/ingest-contract.js +12 -0
- package/lib/types/snapshot-cache-policy.d.ts +52 -0
- package/lib/types/snapshot-cache-policy.js +57 -0
- package/lib/types/snapshot-payload.d.ts +245 -0
- package/lib/types/snapshot-payload.js +3 -0
- package/lib/types/table-binding-config.d.ts +43 -0
- package/lib/types/table-binding-config.js +57 -0
- package/package.json +67 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chaim Ingestion Lambda Handler
|
|
3
|
+
*
|
|
4
|
+
* This is the CANONICAL Lambda handler for Chaim schema ingestion.
|
|
5
|
+
* It implements the presigned upload flow with HMAC authentication:
|
|
6
|
+
*
|
|
7
|
+
* Create/Update:
|
|
8
|
+
* 1. Read snapshot.json from bundled asset
|
|
9
|
+
* 2. Generate eventId (UUID v4) and nonce (UUID v4) at runtime
|
|
10
|
+
* 3. Compute contentHash (SHA-256 of snapshot bytes)
|
|
11
|
+
* 4. POST /ingest/presign with HMAC signature → get presigned S3 URL
|
|
12
|
+
* 5. PUT snapshot bytes to presigned S3 URL
|
|
13
|
+
*
|
|
14
|
+
* Delete:
|
|
15
|
+
* 1. Build DELETE snapshot (action: 'DELETE', schema: null)
|
|
16
|
+
* 2. POST /ingest/presign with HMAC signature → get presigned S3 URL
|
|
17
|
+
* 3. PUT DELETE snapshot bytes to presigned S3 URL
|
|
18
|
+
* 4. Return SUCCESS to CloudFormation
|
|
19
|
+
*
|
|
20
|
+
* FailureMode:
|
|
21
|
+
* - STRICT: Return FAILED to CloudFormation on any error
|
|
22
|
+
* - BEST_EFFORT: Log error but return SUCCESS to CloudFormation
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const https = require('https');
|
|
26
|
+
const { URL } = require('url');
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
|
|
30
|
+
// Default configuration (can be overridden via environment variables)
|
|
31
|
+
const DEFAULT_API_BASE_URL = 'https://api.chaim.co';
|
|
32
|
+
const DEFAULT_MAX_SNAPSHOT_BYTES = 10 * 1024 * 1024; // 10MB
|
|
33
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Infer deletion reason from CloudFormation event metadata.
|
|
37
|
+
*
|
|
38
|
+
* This function attempts to determine why a resource is being deleted by analyzing
|
|
39
|
+
* the CloudFormation event. If the reason cannot be confidently determined from
|
|
40
|
+
* the available metadata, it defaults to 'BINDER_REMOVED' as the most common case.
|
|
41
|
+
*
|
|
42
|
+
* Inference strategy:
|
|
43
|
+
* 1. Check for stack deletion indicators in ResourceProperties
|
|
44
|
+
* 2. Check for explicit resource removal patterns
|
|
45
|
+
* 3. Fall back to BINDER_REMOVED (default) for uncertain cases
|
|
46
|
+
*
|
|
47
|
+
* @param {Object} event - CloudFormation custom resource event
|
|
48
|
+
* @param {Object} snapshotPayload - The original snapshot payload
|
|
49
|
+
* @returns {Object} { reason, scope } - Deletion context
|
|
50
|
+
*/
|
|
51
|
+
function inferDeletionContext(event, snapshotPayload) {
|
|
52
|
+
// Strategy 1: Check if entire stack is being deleted
|
|
53
|
+
// CloudFormation may include StackStatus in ResourceProperties for some events
|
|
54
|
+
const stackStatus = event.ResourceProperties?.StackStatus;
|
|
55
|
+
|
|
56
|
+
if (stackStatus && stackStatus.includes('DELETE')) {
|
|
57
|
+
return {
|
|
58
|
+
reason: 'STACK_DELETED',
|
|
59
|
+
scope: 'BINDING', // When stack is deleted, binding is removed
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Strategy 2: Check for explicit resource removal indicators
|
|
64
|
+
// The LogicalResourceId pattern can sometimes indicate the operation type
|
|
65
|
+
const logicalResourceId = event.LogicalResourceId;
|
|
66
|
+
if (logicalResourceId && logicalResourceId.includes('IngestionResource')) {
|
|
67
|
+
// This is our custom resource being explicitly removed
|
|
68
|
+
return {
|
|
69
|
+
reason: 'BINDER_REMOVED',
|
|
70
|
+
scope: 'BINDING',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Strategy 3: FALLBACK - Default to BINDER_REMOVED
|
|
75
|
+
// This is the most common case when a ChaimBinder construct is removed from the CDK app
|
|
76
|
+
// We default to this when we cannot confidently determine the reason from CF metadata
|
|
77
|
+
console.log('Could not confidently determine deletion reason from CF event - defaulting to BINDER_REMOVED');
|
|
78
|
+
return {
|
|
79
|
+
reason: 'BINDER_REMOVED',
|
|
80
|
+
scope: 'BINDING',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get package version from embedded metadata in snapshot.
|
|
86
|
+
* Falls back to a default if not available.
|
|
87
|
+
*/
|
|
88
|
+
function getPackageVersion(snapshotPayload) {
|
|
89
|
+
// Check if snapshot has embedded version
|
|
90
|
+
if (snapshotPayload._packageVersion) {
|
|
91
|
+
return snapshotPayload._packageVersion;
|
|
92
|
+
}
|
|
93
|
+
// Fallback to a default version
|
|
94
|
+
return '0.2.0';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Lambda handler entry point.
|
|
99
|
+
*/
|
|
100
|
+
exports.handler = async (event, context) => {
|
|
101
|
+
console.log('CloudFormation Event:', JSON.stringify(event, null, 2));
|
|
102
|
+
|
|
103
|
+
const requestType = event.RequestType; // 'Create', 'Update', or 'Delete'
|
|
104
|
+
const cfRequestId = event.RequestId; // CloudFormation RequestId
|
|
105
|
+
const failureMode = process.env.FAILURE_MODE || 'BEST_EFFORT';
|
|
106
|
+
const apiBaseUrl = process.env.CHAIM_API_BASE_URL || DEFAULT_API_BASE_URL;
|
|
107
|
+
const maxSnapshotBytes = parseInt(process.env.CHAIM_MAX_SNAPSHOT_BYTES || String(DEFAULT_MAX_SNAPSHOT_BYTES), 10);
|
|
108
|
+
|
|
109
|
+
// Generate eventId at runtime (not synth-time)
|
|
110
|
+
const eventId = crypto.randomUUID();
|
|
111
|
+
let contentHash = '';
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
// Read snapshot from bundled asset directory
|
|
115
|
+
const snapshotBytes = fs.readFileSync('./snapshot.json', 'utf-8');
|
|
116
|
+
const snapshotPayload = JSON.parse(snapshotBytes);
|
|
117
|
+
|
|
118
|
+
// Build operation metadata (common for all request types)
|
|
119
|
+
const operation = {
|
|
120
|
+
phase: requestType === 'Delete' ? 'DELETE' : 'DEPLOY',
|
|
121
|
+
eventId,
|
|
122
|
+
cfRequestId,
|
|
123
|
+
requestType,
|
|
124
|
+
failureMode,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Build producer metadata
|
|
128
|
+
const producer = {
|
|
129
|
+
component: 'chaim-cdk',
|
|
130
|
+
version: getPackageVersion(snapshotPayload),
|
|
131
|
+
runtime: 'nodejs20.x',
|
|
132
|
+
mode: 'PUBLISHED',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (requestType === 'Delete') {
|
|
136
|
+
// DELETE flow: Send DELETE snapshot through presigned upload
|
|
137
|
+
console.log('Processing Delete request - ChaimBinder removed from stack');
|
|
138
|
+
console.log('Resource:', snapshotPayload.resourceId);
|
|
139
|
+
console.log('Entity:', snapshotPayload.identity?.entityName);
|
|
140
|
+
|
|
141
|
+
// Infer deletion context
|
|
142
|
+
const deleteContext = inferDeletionContext(event, snapshotPayload);
|
|
143
|
+
const deletedAt = new Date().toISOString();
|
|
144
|
+
|
|
145
|
+
console.log('Deletion reason:', deleteContext.reason);
|
|
146
|
+
console.log('Deletion scope:', deleteContext.scope);
|
|
147
|
+
|
|
148
|
+
// Build DELETE snapshot with enhanced metadata
|
|
149
|
+
// Remove internal fields before publishing
|
|
150
|
+
const { _schemaHash, _packageVersion, ...cleanPayload } = snapshotPayload;
|
|
151
|
+
|
|
152
|
+
const deleteSnapshot = {
|
|
153
|
+
...cleanPayload,
|
|
154
|
+
action: 'DELETE',
|
|
155
|
+
schema: null, // Schema not needed for deletion
|
|
156
|
+
capturedAt: deletedAt,
|
|
157
|
+
|
|
158
|
+
// NEW: Add operation metadata
|
|
159
|
+
operation,
|
|
160
|
+
|
|
161
|
+
// NEW: Add delete metadata
|
|
162
|
+
delete: {
|
|
163
|
+
reason: deleteContext.reason,
|
|
164
|
+
scope: deleteContext.scope,
|
|
165
|
+
deletedAt,
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// NEW: Add producer metadata
|
|
169
|
+
producer,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const deleteSnapshotBytes = JSON.stringify(deleteSnapshot, null, 2);
|
|
173
|
+
|
|
174
|
+
// NEW: Compute hashes
|
|
175
|
+
const deleteContentHash = 'sha256:' + crypto.createHash('sha256').update(deleteSnapshotBytes).digest('hex');
|
|
176
|
+
|
|
177
|
+
deleteSnapshot.hashes = {
|
|
178
|
+
contentHash: deleteContentHash,
|
|
179
|
+
schemaHash: null, // null for DELETE snapshots (not applicable)
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const finalDeleteBytes = JSON.stringify(deleteSnapshot, null, 2);
|
|
183
|
+
|
|
184
|
+
console.log('Sending DELETE snapshot through presigned upload...');
|
|
185
|
+
|
|
186
|
+
// Get API credentials
|
|
187
|
+
const { apiKey, apiSecret } = await getCredentials();
|
|
188
|
+
|
|
189
|
+
// Step 1: Request presigned upload URL
|
|
190
|
+
console.log('Step 1: Requesting presigned upload URL for DELETE snapshot...');
|
|
191
|
+
const presignResponse = await postPresign({
|
|
192
|
+
apiBaseUrl,
|
|
193
|
+
apiKey,
|
|
194
|
+
apiSecret,
|
|
195
|
+
appId: deleteSnapshot.appId,
|
|
196
|
+
eventId,
|
|
197
|
+
contentHash: deleteContentHash,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const { uploadUrl } = presignResponse;
|
|
201
|
+
console.log('Received presigned URL (expires at:', presignResponse.expiresAt + ')');
|
|
202
|
+
|
|
203
|
+
// Step 2: Upload DELETE snapshot bytes to S3
|
|
204
|
+
console.log('Step 2: Uploading DELETE snapshot to S3...');
|
|
205
|
+
await uploadToS3(uploadUrl, finalDeleteBytes);
|
|
206
|
+
console.log('DELETE snapshot uploaded to S3 successfully');
|
|
207
|
+
console.log('S3 Key:', presignResponse.s3Key);
|
|
208
|
+
|
|
209
|
+
console.log('Entity marked as deleted successfully');
|
|
210
|
+
return buildResponse(eventId, 'SUCCESS', 'DELETE', deletedAt, deleteContentHash);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// CREATE/UPDATE flow: presigned upload
|
|
214
|
+
console.log('Processing Create/Update request - executing ingestion workflow');
|
|
215
|
+
console.log('EventId:', eventId);
|
|
216
|
+
|
|
217
|
+
// Remove internal fields before publishing
|
|
218
|
+
const { _schemaHash, _packageVersion, ...cleanPayload } = snapshotPayload;
|
|
219
|
+
|
|
220
|
+
// Add operation and producer metadata to snapshot
|
|
221
|
+
const enhancedSnapshot = {
|
|
222
|
+
...cleanPayload,
|
|
223
|
+
operation,
|
|
224
|
+
producer,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const enhancedSnapshotBytes = JSON.stringify(enhancedSnapshot, null, 2);
|
|
228
|
+
|
|
229
|
+
// Compute hashes
|
|
230
|
+
contentHash = 'sha256:' + crypto.createHash('sha256').update(enhancedSnapshotBytes).digest('hex');
|
|
231
|
+
|
|
232
|
+
// Compute schemaHash if schema exists (use pre-computed from synth if available)
|
|
233
|
+
let schemaHash;
|
|
234
|
+
if (snapshotPayload._schemaHash) {
|
|
235
|
+
schemaHash = snapshotPayload._schemaHash;
|
|
236
|
+
} else if (snapshotPayload.schema) {
|
|
237
|
+
const schemaBytes = JSON.stringify(snapshotPayload.schema);
|
|
238
|
+
schemaHash = 'sha256:' + crypto.createHash('sha256').update(schemaBytes).digest('hex');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
enhancedSnapshot.hashes = {
|
|
242
|
+
contentHash,
|
|
243
|
+
schemaHash,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const finalSnapshotBytes = JSON.stringify(enhancedSnapshot, null, 2);
|
|
247
|
+
|
|
248
|
+
console.log('ContentHash:', contentHash);
|
|
249
|
+
if (schemaHash) {
|
|
250
|
+
console.log('SchemaHash:', schemaHash);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Validate snapshot size
|
|
254
|
+
if (finalSnapshotBytes.length > maxSnapshotBytes) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`Snapshot size (${finalSnapshotBytes.length} bytes) exceeds maximum allowed (${maxSnapshotBytes} bytes)`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Get API credentials
|
|
261
|
+
const { apiKey, apiSecret } = await getCredentials();
|
|
262
|
+
|
|
263
|
+
// Step 1: Request presigned upload URL
|
|
264
|
+
console.log('Step 1: Requesting presigned upload URL from /ingest/presign...');
|
|
265
|
+
const presignResponse = await postPresign({
|
|
266
|
+
apiBaseUrl,
|
|
267
|
+
apiKey,
|
|
268
|
+
apiSecret,
|
|
269
|
+
appId: snapshotPayload.appId,
|
|
270
|
+
eventId,
|
|
271
|
+
contentHash: enhancedSnapshot.hashes.contentHash,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const { uploadUrl } = presignResponse;
|
|
275
|
+
console.log('Received presigned URL (expires at:', presignResponse.expiresAt + ')');
|
|
276
|
+
|
|
277
|
+
// Step 2: Upload snapshot bytes to S3
|
|
278
|
+
console.log('Step 2: Uploading snapshot to S3...');
|
|
279
|
+
await uploadToS3(uploadUrl, finalSnapshotBytes);
|
|
280
|
+
console.log('Snapshot uploaded to S3 successfully');
|
|
281
|
+
console.log('S3 Key:', presignResponse.s3Key);
|
|
282
|
+
|
|
283
|
+
return buildResponse(eventId, 'SUCCESS', 'UPSERT', snapshotPayload.capturedAt, contentHash);
|
|
284
|
+
|
|
285
|
+
} catch (error) {
|
|
286
|
+
console.error('Ingestion error:', error.message);
|
|
287
|
+
console.error('Stack trace:', error.stack);
|
|
288
|
+
|
|
289
|
+
if (failureMode === 'STRICT') {
|
|
290
|
+
// STRICT mode: fail the CloudFormation deployment
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// BEST_EFFORT mode: log error but return success to CloudFormation
|
|
295
|
+
console.log('BEST_EFFORT mode: returning SUCCESS despite error');
|
|
296
|
+
return buildResponse(eventId, 'FAILED', requestType === 'Delete' ? 'DELETE' : 'UPSERT', new Date().toISOString(), contentHash, error.message);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Build CloudFormation custom resource response.
|
|
302
|
+
*/
|
|
303
|
+
function buildResponse(eventId, status, action, timestamp, contentHash, errorMessage) {
|
|
304
|
+
const response = {
|
|
305
|
+
PhysicalResourceId: eventId,
|
|
306
|
+
Data: {
|
|
307
|
+
EventId: eventId,
|
|
308
|
+
IngestStatus: status,
|
|
309
|
+
Action: action,
|
|
310
|
+
Timestamp: timestamp,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
if (contentHash) {
|
|
315
|
+
response.Data.ContentHash = contentHash;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (errorMessage) {
|
|
319
|
+
response.Data.Error = errorMessage;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return response;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Get API credentials from Secrets Manager or environment variables.
|
|
327
|
+
*/
|
|
328
|
+
async function getCredentials() {
|
|
329
|
+
const secretName = process.env.SECRET_NAME;
|
|
330
|
+
|
|
331
|
+
if (secretName) {
|
|
332
|
+
// Secrets Manager mode
|
|
333
|
+
console.log('Retrieving credentials from Secrets Manager...');
|
|
334
|
+
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
|
|
335
|
+
const client = new SecretsManagerClient();
|
|
336
|
+
|
|
337
|
+
const response = await client.send(new GetSecretValueCommand({
|
|
338
|
+
SecretId: secretName,
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
if (!response.SecretString) {
|
|
342
|
+
throw new Error('Secret value is empty');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const secret = JSON.parse(response.SecretString);
|
|
346
|
+
|
|
347
|
+
if (!secret.apiKey || !secret.apiSecret) {
|
|
348
|
+
throw new Error('Secret must contain apiKey and apiSecret fields');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
console.log('Successfully retrieved credentials from Secrets Manager');
|
|
352
|
+
return { apiKey: secret.apiKey, apiSecret: secret.apiSecret };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Direct credentials mode
|
|
356
|
+
const apiKey = process.env.API_KEY;
|
|
357
|
+
const apiSecret = process.env.API_SECRET;
|
|
358
|
+
|
|
359
|
+
if (!apiKey || !apiSecret) {
|
|
360
|
+
throw new Error('Missing credentials: provide SECRET_NAME or API_KEY/API_SECRET');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { apiKey, apiSecret };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* POST to /ingest/presign endpoint.
|
|
368
|
+
*
|
|
369
|
+
* Request body includes:
|
|
370
|
+
* - appId: Application identifier
|
|
371
|
+
* - eventId: UUID v4 for this upload
|
|
372
|
+
* - contentHash: SHA-256 hash with 'sha256:' prefix
|
|
373
|
+
* - timestamp: ISO 8601 timestamp (must be within 5 minutes of server time)
|
|
374
|
+
* - nonce: UUID v4 for replay protection
|
|
375
|
+
*
|
|
376
|
+
* HMAC signature computed over the entire request body.
|
|
377
|
+
*
|
|
378
|
+
* @returns {Object} { uploadUrl, s3Key, expiresAt }
|
|
379
|
+
*/
|
|
380
|
+
async function postPresign({ apiBaseUrl, apiKey, apiSecret, appId, eventId, contentHash }) {
|
|
381
|
+
const url = `${apiBaseUrl}/ingest/presign`;
|
|
382
|
+
|
|
383
|
+
// Generate nonce (UUID v4) for replay protection
|
|
384
|
+
const nonce = crypto.randomUUID();
|
|
385
|
+
|
|
386
|
+
// Generate timestamp (ISO 8601) - must be within 5 minutes of server time
|
|
387
|
+
const timestamp = new Date().toISOString();
|
|
388
|
+
|
|
389
|
+
const payload = {
|
|
390
|
+
appId,
|
|
391
|
+
eventId,
|
|
392
|
+
contentHash,
|
|
393
|
+
timestamp,
|
|
394
|
+
nonce,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const body = JSON.stringify(payload);
|
|
398
|
+
|
|
399
|
+
console.log('Presign request:', { appId, eventId, contentHash, timestamp, nonce });
|
|
400
|
+
|
|
401
|
+
const responseText = await httpRequest({
|
|
402
|
+
method: 'POST',
|
|
403
|
+
url,
|
|
404
|
+
headers: {
|
|
405
|
+
'Content-Type': 'application/json',
|
|
406
|
+
'x-chaim-key': apiKey,
|
|
407
|
+
},
|
|
408
|
+
body,
|
|
409
|
+
apiSecret,
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const response = JSON.parse(responseText);
|
|
413
|
+
|
|
414
|
+
// Validate response structure
|
|
415
|
+
if (!response.uploadUrl) {
|
|
416
|
+
throw new Error('Invalid presign response: missing uploadUrl');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return response;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* PUT snapshot bytes to S3 presigned URL.
|
|
424
|
+
*
|
|
425
|
+
* Important:
|
|
426
|
+
* - Use HTTP PUT method
|
|
427
|
+
* - Set Content-Type: application/json
|
|
428
|
+
* - Do NOT add AWS signature headers (presigned URL handles auth)
|
|
429
|
+
* - Upload must complete within 5 minutes (URL expiry)
|
|
430
|
+
*/
|
|
431
|
+
async function uploadToS3(presignedUrl, snapshotBytes) {
|
|
432
|
+
await httpRequest({
|
|
433
|
+
method: 'PUT',
|
|
434
|
+
url: presignedUrl,
|
|
435
|
+
headers: {
|
|
436
|
+
'Content-Type': 'application/json',
|
|
437
|
+
},
|
|
438
|
+
body: snapshotBytes,
|
|
439
|
+
// No HMAC signature for S3 presigned URL - authentication is in the URL
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Make an HTTP/HTTPS request.
|
|
445
|
+
*
|
|
446
|
+
* @param {Object} options - Request options
|
|
447
|
+
* @param {string} options.method - HTTP method
|
|
448
|
+
* @param {string} options.url - Full URL
|
|
449
|
+
* @param {Object} options.headers - Request headers
|
|
450
|
+
* @param {string} [options.body] - Request body
|
|
451
|
+
* @param {string} [options.apiSecret] - API secret for HMAC signature
|
|
452
|
+
* @returns {Promise<string>} Response body
|
|
453
|
+
*/
|
|
454
|
+
async function httpRequest({ method, url, headers, body, apiSecret }) {
|
|
455
|
+
return new Promise((resolve, reject) => {
|
|
456
|
+
const parsedUrl = new URL(url);
|
|
457
|
+
|
|
458
|
+
const finalHeaders = { ...headers };
|
|
459
|
+
|
|
460
|
+
// Add HMAC signature if apiSecret provided and body exists
|
|
461
|
+
if (apiSecret && body) {
|
|
462
|
+
const signature = crypto
|
|
463
|
+
.createHmac('sha256', apiSecret)
|
|
464
|
+
.update(body)
|
|
465
|
+
.digest('hex');
|
|
466
|
+
finalHeaders['x-chaim-signature'] = signature;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const options = {
|
|
470
|
+
hostname: parsedUrl.hostname,
|
|
471
|
+
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
|
|
472
|
+
path: parsedUrl.pathname + parsedUrl.search,
|
|
473
|
+
method,
|
|
474
|
+
headers: finalHeaders,
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const protocol = parsedUrl.protocol === 'https:' ? https : require('http');
|
|
478
|
+
|
|
479
|
+
const req = protocol.request(options, (res) => {
|
|
480
|
+
let data = '';
|
|
481
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
482
|
+
res.on('end', () => {
|
|
483
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
484
|
+
resolve(data);
|
|
485
|
+
} else {
|
|
486
|
+
// Try to parse error response
|
|
487
|
+
let errorMessage = `HTTP ${res.statusCode}: ${data}`;
|
|
488
|
+
try {
|
|
489
|
+
const errorBody = JSON.parse(data);
|
|
490
|
+
if (errorBody.errorMessage) {
|
|
491
|
+
errorMessage = `HTTP ${res.statusCode}: ${errorBody.errorMessage}`;
|
|
492
|
+
}
|
|
493
|
+
} catch (e) {
|
|
494
|
+
// Use default error message if JSON parsing fails
|
|
495
|
+
}
|
|
496
|
+
reject(new Error(errorMessage));
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
req.on('error', reject);
|
|
502
|
+
|
|
503
|
+
req.setTimeout(DEFAULT_REQUEST_TIMEOUT_MS, () => {
|
|
504
|
+
req.destroy();
|
|
505
|
+
reject(new Error('Request timeout'));
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
if (body) {
|
|
509
|
+
req.write(body);
|
|
510
|
+
}
|
|
511
|
+
req.end();
|
|
512
|
+
});
|
|
513
|
+
}
|