@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.
Files changed (39) hide show
  1. package/README.md +238 -0
  2. package/lib/binders/base-chaim-binder.d.ts +144 -0
  3. package/lib/binders/base-chaim-binder.js +532 -0
  4. package/lib/binders/chaim-dynamodb-binder.d.ts +95 -0
  5. package/lib/binders/chaim-dynamodb-binder.js +292 -0
  6. package/lib/config/chaim-endpoints.d.ts +47 -0
  7. package/lib/config/chaim-endpoints.js +51 -0
  8. package/lib/index.d.ts +15 -0
  9. package/lib/index.js +43 -0
  10. package/lib/lambda-handler/.test-temp/snapshot.json +1 -0
  11. package/lib/lambda-handler/handler.js +513 -0
  12. package/lib/lambda-handler/handler.test.ts +365 -0
  13. package/lib/lambda-handler/package-lock.json +1223 -0
  14. package/lib/lambda-handler/package.json +14 -0
  15. package/lib/services/ingestion-service.d.ts +50 -0
  16. package/lib/services/ingestion-service.js +81 -0
  17. package/lib/services/os-cache-paths.d.ts +52 -0
  18. package/lib/services/os-cache-paths.js +123 -0
  19. package/lib/services/schema-service.d.ts +11 -0
  20. package/lib/services/schema-service.js +67 -0
  21. package/lib/services/snapshot-cleanup.d.ts +78 -0
  22. package/lib/services/snapshot-cleanup.js +220 -0
  23. package/lib/types/base-binder-props.d.ts +32 -0
  24. package/lib/types/base-binder-props.js +17 -0
  25. package/lib/types/credentials.d.ts +57 -0
  26. package/lib/types/credentials.js +83 -0
  27. package/lib/types/data-store-metadata.d.ts +67 -0
  28. package/lib/types/data-store-metadata.js +4 -0
  29. package/lib/types/failure-mode.d.ts +16 -0
  30. package/lib/types/failure-mode.js +21 -0
  31. package/lib/types/ingest-contract.d.ts +110 -0
  32. package/lib/types/ingest-contract.js +12 -0
  33. package/lib/types/snapshot-cache-policy.d.ts +52 -0
  34. package/lib/types/snapshot-cache-policy.js +57 -0
  35. package/lib/types/snapshot-payload.d.ts +245 -0
  36. package/lib/types/snapshot-payload.js +3 -0
  37. package/lib/types/table-binding-config.d.ts +43 -0
  38. package/lib/types/table-binding-config.js +57 -0
  39. 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
+ }