@kirschbaum-development/sst-laravel 0.2.10 → 0.2.12

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.
@@ -0,0 +1,544 @@
1
+ import {
2
+ SecretsManagerClient,
3
+ GetSecretValueCommand,
4
+ PutSecretValueCommand,
5
+ CreateSecretCommand,
6
+ DeleteSecretCommand,
7
+ ResourceNotFoundException,
8
+ DescribeSecretCommand,
9
+ ListSecretsCommand,
10
+ } from '@aws-sdk/client-secrets-manager';
11
+ import { createHash } from 'crypto';
12
+
13
+ /**
14
+ * AWS Secrets Manager limit is 65,536 bytes.
15
+ * We use 60KB as our threshold to leave some buffer.
16
+ */
17
+ const MAX_SECRET_SIZE_BYTES = 60 * 1024;
18
+
19
+ /**
20
+ * Metadata structure for chunked secrets.
21
+ */
22
+ interface ChunkedSecretMetadata {
23
+ version: number;
24
+ chunked: boolean;
25
+ chunks: number;
26
+ totalKeys: number;
27
+ }
28
+
29
+ /**
30
+ * Get the secret path for a given app and stage.
31
+ * Format: /{app-name}/{stage}/env
32
+ */
33
+ export function getSecretPath(appName: string, stage: string): string {
34
+ return `/${appName}/${stage}/env`;
35
+ }
36
+
37
+ /**
38
+ * List available stages for an app by inspecting Secrets Manager paths.
39
+ */
40
+ export async function listAvailableStages(appName: string, region?: string): Promise<string[]> {
41
+ const client = new SecretsManagerClient({ region });
42
+ const stages = new Set<string>();
43
+ const prefix = `/${appName}/`;
44
+ const suffix = '/env';
45
+ let nextToken: string | undefined;
46
+
47
+ do {
48
+ const command = new ListSecretsCommand({
49
+ NextToken: nextToken,
50
+ Filters: [
51
+ {
52
+ Key: 'name',
53
+ Values: [prefix],
54
+ },
55
+ ],
56
+ });
57
+
58
+ const response = await client.send(command);
59
+
60
+ if (response.SecretList) {
61
+ for (const secret of response.SecretList) {
62
+ if (!secret.Name) {
63
+ continue;
64
+ }
65
+
66
+ if (!secret.Name.startsWith(prefix) || !secret.Name.endsWith(suffix)) {
67
+ continue;
68
+ }
69
+
70
+ const parts = secret.Name.split('/');
71
+ // Secrets are stored as /{app}/{stage}/env
72
+ if (parts.length >= 4 && parts[2]) {
73
+ stages.add(parts[2]);
74
+ }
75
+ }
76
+ }
77
+
78
+ nextToken = response.NextToken;
79
+ } while (nextToken);
80
+
81
+ return Array.from(stages).sort();
82
+ }
83
+
84
+ /**
85
+ * Get the chunk path for a given secret path and chunk index.
86
+ */
87
+ function getChunkPath(basePath: string, chunkIndex: number): string {
88
+ return `${basePath}/${chunkIndex}`;
89
+ }
90
+
91
+ /**
92
+ * Parse an .env file content into a key-value object.
93
+ */
94
+ export function parseEnvFile(content: string): Record<string, string> {
95
+ const result: Record<string, string> = {};
96
+ const lines = content.split('\n');
97
+
98
+ for (const line of lines) {
99
+ const trimmed = line.trim();
100
+
101
+ // Skip empty lines and comments
102
+ if (!trimmed || trimmed.startsWith('#')) {
103
+ continue;
104
+ }
105
+
106
+ // Find the first = sign
107
+ const equalIndex = trimmed.indexOf('=');
108
+ if (equalIndex === -1) {
109
+ continue;
110
+ }
111
+
112
+ const key = trimmed.substring(0, equalIndex).trim();
113
+ let value = trimmed.substring(equalIndex + 1);
114
+
115
+ // Handle quoted values
116
+ if ((value.startsWith('"') && value.endsWith('"')) ||
117
+ (value.startsWith("'") && value.endsWith("'"))) {
118
+ value = value.slice(1, -1);
119
+ }
120
+
121
+ if (key) {
122
+ result[key] = value;
123
+ }
124
+ }
125
+
126
+ return result;
127
+ }
128
+
129
+ /**
130
+ * Convert a key-value object to .env file content.
131
+ */
132
+ export function toEnvFileContent(vars: Record<string, string>): string {
133
+ // Sort keys alphabetically for consistent output
134
+ const sortedKeys = Object.keys(vars).sort();
135
+
136
+ return sortedKeys
137
+ .map((key) => {
138
+ const value = vars[key];
139
+ // Quote values that contain spaces, quotes, or special characters
140
+ if (value.includes(' ') || value.includes('"') || value.includes("'") || value.includes('\n')) {
141
+ // Escape existing double quotes and wrap in double quotes
142
+ const escaped = value.replace(/"/g, '\\"');
143
+ return `${key}="${escaped}"`;
144
+ }
145
+ return `${key}=${value}`;
146
+ })
147
+ .join('\n');
148
+ }
149
+
150
+ /**
151
+ * Calculate the byte size of a JSON string.
152
+ */
153
+ function getJsonByteSize(obj: Record<string, string>): number {
154
+ return Buffer.byteLength(JSON.stringify(obj), 'utf8');
155
+ }
156
+
157
+ /**
158
+ * Split variables into chunks that fit within the AWS Secrets Manager limit.
159
+ */
160
+ export function splitIntoChunks(vars: Record<string, string>): Record<string, string>[] {
161
+ const sortedKeys = Object.keys(vars).sort();
162
+ const chunks: Record<string, string>[] = [];
163
+ let currentChunk: Record<string, string> = {};
164
+
165
+ for (const key of sortedKeys) {
166
+ const testChunk = { ...currentChunk, [key]: vars[key] };
167
+
168
+ if (getJsonByteSize(testChunk) > MAX_SECRET_SIZE_BYTES) {
169
+ // Current chunk is full, start a new one
170
+ if (Object.keys(currentChunk).length > 0) {
171
+ chunks.push(currentChunk);
172
+ currentChunk = { [key]: vars[key] };
173
+ } else {
174
+ // Single variable exceeds limit - this is an edge case
175
+ // We still add it, but it may fail on AWS side
176
+ console.warn(`Warning: Variable "${key}" alone exceeds the secret size limit.`);
177
+ chunks.push({ [key]: vars[key] });
178
+ currentChunk = {};
179
+ }
180
+ } else {
181
+ currentChunk = testChunk;
182
+ }
183
+ }
184
+
185
+ // Don't forget the last chunk
186
+ if (Object.keys(currentChunk).length > 0) {
187
+ chunks.push(currentChunk);
188
+ }
189
+
190
+ return chunks;
191
+ }
192
+
193
+ /**
194
+ * Check if variables need to be chunked.
195
+ */
196
+ export function needsChunking(vars: Record<string, string>): boolean {
197
+ return getJsonByteSize(vars) > MAX_SECRET_SIZE_BYTES;
198
+ }
199
+
200
+ /**
201
+ * Get a secret value from AWS Secrets Manager.
202
+ */
203
+ async function getSecretValue(
204
+ client: SecretsManagerClient,
205
+ secretPath: string
206
+ ): Promise<string | null> {
207
+ try {
208
+ const command = new GetSecretValueCommand({
209
+ SecretId: secretPath,
210
+ });
211
+
212
+ const response = await client.send(command);
213
+ return response.SecretString || null;
214
+ } catch (error) {
215
+ if (error instanceof ResourceNotFoundException) {
216
+ return null;
217
+ }
218
+ throw error;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Check if a secret is using the chunked format.
224
+ */
225
+ function isChunkedSecret(data: any): data is ChunkedSecretMetadata {
226
+ return data && typeof data === 'object' && data.chunked === true && typeof data.chunks === 'number';
227
+ }
228
+
229
+ /**
230
+ * Pull environment variables from AWS Secrets Manager.
231
+ * Handles both legacy single secrets and chunked secrets.
232
+ */
233
+ export async function pullSecrets(
234
+ secretPath: string,
235
+ region?: string
236
+ ): Promise<Record<string, string> | null> {
237
+ const client = new SecretsManagerClient({ region });
238
+
239
+ const secretValue = await getSecretValue(client, secretPath);
240
+
241
+ if (!secretValue) {
242
+ return null;
243
+ }
244
+
245
+ const data = JSON.parse(secretValue);
246
+
247
+ // Check if this is a chunked secret
248
+ if (isChunkedSecret(data)) {
249
+ return await pullChunkedSecrets(client, secretPath, data.chunks);
250
+ }
251
+
252
+ // Legacy format - direct key-value pairs
253
+ return data;
254
+ }
255
+
256
+ /**
257
+ * Pull and merge all chunks of a chunked secret.
258
+ */
259
+ async function pullChunkedSecrets(
260
+ client: SecretsManagerClient,
261
+ basePath: string,
262
+ chunkCount: number
263
+ ): Promise<Record<string, string>> {
264
+ const allVars: Record<string, string> = {};
265
+
266
+ // Fetch all chunks in parallel
267
+ const chunkPromises = Array.from({ length: chunkCount }, (_, i) =>
268
+ getSecretValue(client, getChunkPath(basePath, i + 1))
269
+ );
270
+
271
+ const chunkValues = await Promise.all(chunkPromises);
272
+
273
+ for (let i = 0; i < chunkValues.length; i++) {
274
+ const chunkValue = chunkValues[i];
275
+ if (chunkValue) {
276
+ const chunkData = JSON.parse(chunkValue);
277
+ Object.assign(allVars, chunkData);
278
+ } else {
279
+ console.warn(`Warning: Chunk ${i + 1} not found at ${getChunkPath(basePath, i + 1)}`);
280
+ }
281
+ }
282
+
283
+ return allVars;
284
+ }
285
+
286
+ /**
287
+ * Get a deterministic fingerprint for the current secret contents.
288
+ */
289
+ export async function getSecretsFingerprint(secretPath: string, region?: string): Promise<string> {
290
+ const secrets = await pullSecrets(secretPath, region);
291
+
292
+ if (!secrets) {
293
+ return 'missing';
294
+ }
295
+
296
+ return createHash('sha256')
297
+ .update(JSON.stringify(sortObjectKeys(secrets)), 'utf8')
298
+ .digest('hex');
299
+ }
300
+
301
+ function sortObjectKeys(vars: Record<string, string>): Record<string, string> {
302
+ return Object.keys(vars)
303
+ .sort()
304
+ .reduce((result, key) => {
305
+ result[key] = vars[key];
306
+ return result;
307
+ }, {} as Record<string, string>);
308
+ }
309
+
310
+ /**
311
+ * Check if a secret exists in AWS Secrets Manager.
312
+ */
313
+ export async function secretExists(
314
+ secretPath: string,
315
+ region?: string
316
+ ): Promise<boolean> {
317
+ const client = new SecretsManagerClient({ region });
318
+
319
+ try {
320
+ const command = new DescribeSecretCommand({
321
+ SecretId: secretPath,
322
+ });
323
+
324
+ await client.send(command);
325
+ return true;
326
+ } catch (error) {
327
+ if (error instanceof ResourceNotFoundException) {
328
+ return false;
329
+ }
330
+ throw error;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Create or update a secret in AWS Secrets Manager.
336
+ */
337
+ async function upsertSecret(
338
+ client: SecretsManagerClient,
339
+ secretPath: string,
340
+ secretValue: string,
341
+ description: string,
342
+ region?: string
343
+ ): Promise<void> {
344
+ const exists = await secretExists(secretPath, region);
345
+
346
+ if (exists) {
347
+ const command = new PutSecretValueCommand({
348
+ SecretId: secretPath,
349
+ SecretString: secretValue,
350
+ });
351
+ await client.send(command);
352
+ } else {
353
+ const command = new CreateSecretCommand({
354
+ Name: secretPath,
355
+ SecretString: secretValue,
356
+ Description: description,
357
+ });
358
+ await client.send(command);
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Delete a secret from AWS Secrets Manager.
364
+ */
365
+ async function deleteSecret(
366
+ client: SecretsManagerClient,
367
+ secretPath: string
368
+ ): Promise<void> {
369
+ try {
370
+ const command = new DeleteSecretCommand({
371
+ SecretId: secretPath,
372
+ ForceDeleteWithoutRecovery: true,
373
+ });
374
+ await client.send(command);
375
+ } catch (error) {
376
+ if (error instanceof ResourceNotFoundException) {
377
+ // Secret doesn't exist, that's fine
378
+ return;
379
+ }
380
+ throw error;
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Find existing chunk secrets for a given base path.
386
+ */
387
+ async function findExistingChunks(
388
+ client: SecretsManagerClient,
389
+ basePath: string
390
+ ): Promise<string[]> {
391
+ const chunks: string[] = [];
392
+
393
+ try {
394
+ // List secrets that match the chunk pattern
395
+ const command = new ListSecretsCommand({
396
+ Filters: [
397
+ {
398
+ Key: 'name',
399
+ Values: [`${basePath}/`],
400
+ },
401
+ ],
402
+ });
403
+
404
+ const response = await client.send(command);
405
+
406
+ if (response.SecretList) {
407
+ for (const secret of response.SecretList) {
408
+ if (secret.Name && secret.Name.startsWith(`${basePath}/`)) {
409
+ chunks.push(secret.Name);
410
+ }
411
+ }
412
+ }
413
+ } catch (error) {
414
+ // If listing fails, we'll just proceed without cleanup
415
+ console.warn('Warning: Could not list existing chunks for cleanup.');
416
+ }
417
+
418
+ return chunks;
419
+ }
420
+
421
+ /**
422
+ * Clean up old chunks that are no longer needed.
423
+ */
424
+ async function cleanupOldChunks(
425
+ client: SecretsManagerClient,
426
+ basePath: string,
427
+ newChunkCount: number
428
+ ): Promise<void> {
429
+ const existingChunks = await findExistingChunks(client, basePath);
430
+
431
+ for (const chunkPath of existingChunks) {
432
+ // Extract chunk number from path
433
+ const match = chunkPath.match(/\/(\d+)$/);
434
+ if (match) {
435
+ const chunkNum = parseInt(match[1], 10);
436
+ if (chunkNum > newChunkCount) {
437
+ console.log(`Cleaning up old chunk: ${chunkPath}`);
438
+ await deleteSecret(client, chunkPath);
439
+ }
440
+ }
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Push environment variables to AWS Secrets Manager.
446
+ * Automatically handles chunking for large env files.
447
+ */
448
+ export async function pushSecrets(
449
+ secretPath: string,
450
+ vars: Record<string, string>,
451
+ region?: string
452
+ ): Promise<{ chunked: boolean; chunks: number }> {
453
+ const client = new SecretsManagerClient({ region });
454
+
455
+ if (!needsChunking(vars)) {
456
+ // Small enough for a single secret - use legacy format for simplicity
457
+ await upsertSecret(
458
+ client,
459
+ secretPath,
460
+ JSON.stringify(vars),
461
+ 'Laravel environment variables',
462
+ region
463
+ );
464
+
465
+ // Clean up any old chunks if we're switching from chunked to single
466
+ await cleanupOldChunks(client, secretPath, 0);
467
+
468
+ return { chunked: false, chunks: 1 };
469
+ }
470
+
471
+ // Need to chunk the secrets
472
+ const chunks = splitIntoChunks(vars);
473
+
474
+ console.log(`Environment file exceeds size limit. Splitting into ${chunks.length} chunks...`);
475
+
476
+ // Create metadata secret
477
+ const metadata: ChunkedSecretMetadata = {
478
+ version: 1,
479
+ chunked: true,
480
+ chunks: chunks.length,
481
+ totalKeys: Object.keys(vars).length,
482
+ };
483
+
484
+ await upsertSecret(
485
+ client,
486
+ secretPath,
487
+ JSON.stringify(metadata),
488
+ 'Laravel environment variables (chunked metadata)',
489
+ region
490
+ );
491
+
492
+ // Create chunk secrets in parallel
493
+ const chunkPromises = chunks.map((chunk, index) =>
494
+ upsertSecret(
495
+ client,
496
+ getChunkPath(secretPath, index + 1),
497
+ JSON.stringify(chunk),
498
+ `Laravel environment variables (chunk ${index + 1}/${chunks.length})`,
499
+ region
500
+ )
501
+ );
502
+
503
+ await Promise.all(chunkPromises);
504
+
505
+ // Clean up any old chunks beyond the new count
506
+ await cleanupOldChunks(client, secretPath, chunks.length);
507
+
508
+ return { chunked: true, chunks: chunks.length };
509
+ }
510
+
511
+ /**
512
+ * Get info about the current secret structure.
513
+ */
514
+ export async function getSecretInfo(
515
+ secretPath: string,
516
+ region?: string
517
+ ): Promise<{ exists: boolean; chunked: boolean; chunks: number; totalKeys: number } | null> {
518
+ const client = new SecretsManagerClient({ region });
519
+
520
+ const secretValue = await getSecretValue(client, secretPath);
521
+
522
+ if (!secretValue) {
523
+ return null;
524
+ }
525
+
526
+ const data = JSON.parse(secretValue);
527
+
528
+ if (isChunkedSecret(data)) {
529
+ return {
530
+ exists: true,
531
+ chunked: true,
532
+ chunks: data.chunks,
533
+ totalKeys: data.totalKeys,
534
+ };
535
+ }
536
+
537
+ // Legacy format
538
+ return {
539
+ exists: true,
540
+ chunked: false,
541
+ chunks: 1,
542
+ totalKeys: Object.keys(data).length,
543
+ };
544
+ }