storyshelf 0.3.2 → 0.5.4

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,458 @@
1
+ #!/usr/bin/env node
2
+ import "node:path";
3
+ import "node:url";
4
+ import.meta.url;
5
+ //#region src/commands/server/terraform-aws.ts
6
+ /** Terraform CLI version floor for the generated modules. */
7
+ const TERRAFORM_REQUIRED_VERSION = ">= 1.9";
8
+ function header() {
9
+ return `# Generated by \`storyshelf server init --target aws\`. Do not edit by hand;\n# re-run the scaffold or edit the generator in @storyshelf/cli.\n\n`;
10
+ }
11
+ function generateAwsVersions() {
12
+ return `${header()}terraform {
13
+ required_version = "${TERRAFORM_REQUIRED_VERSION}"
14
+
15
+ required_providers {
16
+ aws = {
17
+ source = "hashicorp/aws"
18
+ version = ">= 5.60"
19
+ }
20
+ }
21
+ }
22
+ `;
23
+ }
24
+ function generateAwsVariables() {
25
+ return `${header()}variable "project" {
26
+ description = "Project slug, used as a name prefix for all resources."
27
+ type = string
28
+ }
29
+
30
+ variable "region" {
31
+ description = "AWS region for all resources."
32
+ type = string
33
+ default = "us-east-1"
34
+ }
35
+
36
+ variable "vpc_id" {
37
+ description = "Existing VPC id. Empty creates a new VPC."
38
+ type = string
39
+ default = ""
40
+ }
41
+
42
+ variable "domain_name" {
43
+ description = "Public domain for the ALB (Route 53 + ACM). Empty leaves the ALB HTTP-only."
44
+ type = string
45
+ default = ""
46
+ }
47
+
48
+ variable "saml_metadata_url" {
49
+ description = "SAML metadata URL for Cognito federation (Entra/Okta). Empty skips federation."
50
+ type = string
51
+ default = ""
52
+ }
53
+ `;
54
+ }
55
+ function generateAwsVpc() {
56
+ return `${header()}# VPC: created unless var.vpc_id points at an existing one.
57
+ resource "aws_vpc" "main" {
58
+ count = var.vpc_id == "" ? 1 : 0
59
+ cidr_block = "10.0.0.0/16"
60
+ enable_dns_support = true
61
+ enable_dns_hostnames = true
62
+
63
+ tags = { Name = "\${var.project}-vpc" }
64
+ }
65
+
66
+ locals {
67
+ vpc_id = var.vpc_id != "" ? var.vpc_id : aws_vpc.main[0].id
68
+ }
69
+ `;
70
+ }
71
+ /** ALB + ECS security groups and private data-plane endpoints. */
72
+ function generateAwsSecurity() {
73
+ return `${header()}resource "aws_security_group" "alb" {
74
+ name = "\${var.project}-alb"
75
+ description = "ALB ingress"
76
+ vpc_id = local.vpc_id
77
+
78
+ ingress {
79
+ from_port = 443
80
+ to_port = 443
81
+ protocol = "tcp"
82
+ cidr_blocks = ["0.0.0.0/0"]
83
+ }
84
+
85
+ ingress {
86
+ from_port = 80
87
+ to_port = 80
88
+ protocol = "tcp"
89
+ cidr_blocks = ["0.0.0.0/0"]
90
+ }
91
+
92
+ egress {
93
+ from_port = 0
94
+ to_port = 0
95
+ protocol = "-1"
96
+ cidr_blocks = ["0.0.0.0/0"]
97
+ }
98
+ }
99
+
100
+ resource "aws_security_group" "ecs" {
101
+ name = "\${var.project}-ecs"
102
+ description = "ECS tasks: ALB ingress only"
103
+ vpc_id = local.vpc_id
104
+
105
+ ingress {
106
+ from_port = 3000
107
+ to_port = 3000
108
+ protocol = "tcp"
109
+ security_groups = [aws_security_group.alb.id]
110
+ }
111
+
112
+ egress {
113
+ from_port = 0
114
+ to_port = 0
115
+ protocol = "-1"
116
+ cidr_blocks = ["0.0.0.0/0"]
117
+ }
118
+ }
119
+ `;
120
+ }
121
+ /** Private data-plane endpoints (S3, SQS) off the public internet. */
122
+ function generateAwsEndpoints() {
123
+ return `${header()}# VPC endpoints keep data-plane traffic (S3, SQS) off the public internet.
124
+ resource "aws_vpc_endpoint" "s3" {
125
+ vpc_id = local.vpc_id
126
+ service_name = "com.amazonaws.\${var.region}.s3"
127
+ }
128
+
129
+ resource "aws_vpc_endpoint" "sqs" {
130
+ vpc_id = local.vpc_id
131
+ service_name = "com.amazonaws.\${var.region}.sqs"
132
+ security_group_ids = [aws_security_group.ecs.id]
133
+ }
134
+ `;
135
+ }
136
+ function generateAwsStorage() {
137
+ return `${header()}resource "aws_s3_bucket" "storybook" {
138
+ bucket = "\${var.project}-storybook"
139
+ }
140
+
141
+ resource "aws_s3_bucket_versioning" "storybook" {
142
+ bucket = aws_s3_bucket.storybook.id
143
+
144
+ versioning_configuration {
145
+ status = "Enabled"
146
+ }
147
+ }
148
+
149
+ resource "aws_s3_bucket_server_side_encryption_configuration" "storybook" {
150
+ bucket = aws_s3_bucket.storybook.id
151
+
152
+ rule {
153
+ apply_server_side_encryption_by_default {
154
+ sse_algorithm = "AES256"
155
+ }
156
+ }
157
+ }
158
+
159
+ resource "aws_s3_bucket_public_access_block" "storybook" {
160
+ bucket = aws_s3_bucket.storybook.id
161
+ block_public_acls = true
162
+ block_public_policy = true
163
+ ignore_public_acls = true
164
+ restrict_public_buckets = true
165
+ }
166
+
167
+ # Transient build bundles expire; baselines persist.
168
+ resource "aws_s3_bucket_lifecycle_configuration" "storybook" {
169
+ bucket = aws_s3_bucket.storybook.id
170
+
171
+ rule {
172
+ id = "expire-transient-builds"
173
+ status = "Enabled"
174
+
175
+ filter {
176
+ prefix = "builds/"
177
+ }
178
+
179
+ expiration {
180
+ days = 30
181
+ }
182
+ }
183
+ }
184
+ `;
185
+ }
186
+ function generateAwsQueue() {
187
+ return `${header()}resource "aws_sqs_queue" "capture_dlq" {
188
+ name = "\${var.project}-capture-dlq"
189
+ }
190
+
191
+ resource "aws_sqs_queue" "capture" {
192
+ name = "\${var.project}-capture"
193
+ visibility_timeout_seconds = 300
194
+ redrive_policy = jsonencode({
195
+ deadLetterTargetArn = aws_sqs_queue.capture_dlq.arn
196
+ maxReceiveCount = 3
197
+ })
198
+ }
199
+ `;
200
+ }
201
+ function generateAwsDatabase(dbEngine) {
202
+ if (dbEngine === "dsql") return `${header()}# Serverless Postgres. The server connects with
203
+ # createPostgresDatabase({ url: <cluster endpoint> }).
204
+ resource "aws_dsql_cluster" "main" {
205
+ deletion_protection_enabled = true
206
+
207
+ tags = { Name = "\${var.project}-db" }
208
+ }
209
+ `;
210
+ return `${header()}# VPC-isolated Postgres for regulated estates. The server connects with
211
+ # createPostgresDatabase({ url: <DATABASE_URL with sslmode=require> }).
212
+ resource "aws_db_subnet_group" "main" {
213
+ name = "\${var.project}-db"
214
+ subnet_ids = data.aws_subnets.private.ids
215
+ }
216
+
217
+ resource "aws_db_instance" "main" {
218
+ identifier = "\${var.project}-db"
219
+ engine = "postgres"
220
+ engine_version = "16"
221
+ instance_class = "db.t4g.micro"
222
+ allocated_storage = 20
223
+ db_subnet_group_name = aws_db_subnet_group.main.name
224
+ vpc_security_group_ids = [aws_security_group.ecs.id]
225
+ skip_final_snapshot = true
226
+ }
227
+ `;
228
+ }
229
+ function generateAwsIdentity() {
230
+ return `${header()}# Cognito User Pool: the enterprise identity front door. Federate a
231
+ # corporate IdP (Entra/Okta) via SAML by setting var.saml_metadata_url.
232
+ resource "aws_cognito_user_pool" "main" {
233
+ name = "\${var.project}-users"
234
+ }
235
+
236
+ resource "aws_cognito_user_pool_client" "shelf" {
237
+ name = "\${var.project}-shelf"
238
+ user_pool_id = aws_cognito_user_pool.main.id
239
+ generate_secret = true
240
+
241
+ allowed_oauth_flows = ["code"]
242
+ allowed_oauth_scopes = ["openid", "email", "profile"]
243
+ callback_urls = ["https://\${var.domain_name == "" ? aws_lb.main.dns_name : var.domain_name}/auth/callback"]
244
+ supported_identity_providers = ["COGNITO"]
245
+ }
246
+
247
+ resource "aws_cognito_user_group" "admins" {
248
+ name = "shelf-admins"
249
+ user_pool_id = aws_cognito_user_pool.main.id
250
+ }
251
+
252
+ resource "aws_cognito_identity_provider" "saml" {
253
+ count = var.saml_metadata_url == "" ? 0 : 1
254
+ user_pool_id = aws_cognito_user_pool.main.id
255
+ provider_name = "CorporateSAML"
256
+ provider_type = "SAML"
257
+
258
+ provider_details = {
259
+ MetadataURL = var.saml_metadata_url
260
+ }
261
+ }
262
+ `;
263
+ }
264
+ function generateAwsCompute() {
265
+ return `${header()}# ECS Fargate: long-lived app + worker services. Playwright/Chromium
266
+ # renders on Fargate — Lambda's 15-minute limit and /tmp caps rule it out.
267
+ resource "aws_lb" "main" {
268
+ name = "\${var.project}-alb"
269
+ load_balancer_type = "application"
270
+ security_groups = [aws_security_group.alb.id]
271
+ subnets = data.aws_subnets.public.ids
272
+ }
273
+
274
+ resource "aws_ecs_cluster" "main" {
275
+ name = "\${var.project}-cluster"
276
+ }
277
+
278
+ resource "aws_cloudwatch_log_group" "app" {
279
+ name = "/ecs/\${var.project}-app"
280
+ retention_in_days = 30
281
+ }
282
+
283
+ resource "aws_cloudwatch_log_group" "worker" {
284
+ name = "/ecs/\${var.project}-worker"
285
+ retention_in_days = 30
286
+ }
287
+ `;
288
+ }
289
+ function generateAwsSecrets() {
290
+ return `${header()}# Secrets Manager: session secret, OIDC secret, break-glass admin
291
+ # token. Task roles below may read exactly these ARNs.
292
+ resource "aws_secretsmanager_secret" "shelf_secret" {
293
+ name = "\${var.project}/SECRET"
294
+ }
295
+
296
+ resource "aws_secretsmanager_secret" "oidc_client_secret" {
297
+ name = "\${var.project}/OIDC_CLIENT_SECRET"
298
+ }
299
+
300
+ resource "aws_secretsmanager_secret" "admin_token" {
301
+ name = "\${var.project}/ADMIN_TOKEN"
302
+ }
303
+ `;
304
+ }
305
+ function generateAwsDns() {
306
+ return `${header()}# Route 53 + ACM. Skipped entirely when var.domain_name is empty
307
+ # (the ALB stays HTTP-only for dev).
308
+ resource "aws_acm_certificate" "main" {
309
+ count = var.domain_name == "" ? 0 : 1
310
+ domain_name = var.domain_name
311
+ validation_method = "DNS"
312
+ }
313
+
314
+ resource "aws_route53_zone" "main" {
315
+ count = var.domain_name == "" ? 0 : 1
316
+ name = var.domain_name
317
+ }
318
+
319
+ resource "aws_route53_record" "app" {
320
+ count = var.domain_name == "" ? 0 : 1
321
+ zone_id = aws_route53_zone.main[0].zone_id
322
+ name = var.domain_name
323
+ type = "A"
324
+
325
+ alias {
326
+ name = aws_lb.main.dns_name
327
+ zone_id = aws_lb.main.zone_id
328
+ evaluate_target_health = true
329
+ }
330
+ }
331
+ `;
332
+ }
333
+ function generateAwsOutputs(dbEngine) {
334
+ return `${header()}# Outputs feed directly into the scaffolded server's env.
335
+ # Keys are a stable contract parsed by CI — do not rename.
336
+ output "alb_dns_name" {
337
+ value = aws_lb.main.dns_name
338
+ }
339
+
340
+ output "s3_bucket" {
341
+ value = aws_s3_bucket.storybook.id
342
+ }
343
+
344
+ output "queue_url" {
345
+ value = aws_sqs_queue.capture.url
346
+ }
347
+
348
+ output "db_endpoint" {
349
+ value = ${generateAwsDbEndpoint(dbEngine)}
350
+ }
351
+
352
+ output "user_pool_id" {
353
+ value = aws_cognito_user_pool.main.id
354
+ }
355
+
356
+ output "app_client_id" {
357
+ value = aws_cognito_user_pool_client.shelf.id
358
+ }
359
+ `;
360
+ }
361
+ /** Connection endpoint per Postgres engine (DSQL has no endpoint attribute). */
362
+ function generateAwsDbEndpoint(dbEngine) {
363
+ if (dbEngine === "dsql") return `"\${aws_dsql_cluster.main.identifier}.dsql.\${var.region}.on.aws"`;
364
+ return "aws_db_instance.main.endpoint";
365
+ }
366
+ function generateAwsCiProfile() {
367
+ return `## CI test profile
368
+
369
+ Smallest footprint, unique resources per run, documented destroy.
370
+ Prefer the serverless engine for CI (no instance to pay for idle):
371
+
372
+ \`\`\`sh
373
+ export PROJECT="shelf-ci-$GITHUB_RUN_ID" # unique prefix per run (all names derive from it)
374
+ terraform init
375
+ terraform plan -var "project=$PROJECT" -out=tfplan
376
+ terraform apply tfplan
377
+ terraform output -json # or: npm run infra:outputs
378
+ # ... run live tests against the outputs ...
379
+ terraform destroy -var "project=$PROJECT" -auto-approve
380
+ \`\`\`
381
+
382
+ Re-scaffold with the DSQL engine for the cheapest CI runs
383
+ (\`dbEngine: "dsql"\`); RDS uses the smallest burstable class
384
+ (\`db.t4g.micro\`) when you need a classic instance.
385
+ `;
386
+ }
387
+ function generateAwsReadme(project) {
388
+ return `# ${project} on AWS
389
+
390
+ Scaffolded by \`storyshelf server init --target aws\`.
391
+
392
+ ## Next steps
393
+
394
+ \`\`\`sh
395
+ cd terraform
396
+ terraform init
397
+ terraform plan -out=tfplan # review before applying
398
+ terraform apply tfplan
399
+ \`\`\`
400
+
401
+ ## Wire outputs into env
402
+
403
+ \`\`\`sh
404
+ terraform output -json # or: npm run infra:outputs
405
+ \`\`\`
406
+
407
+ | Output | Env var | Adapter |
408
+ |---|---|---|
409
+ | \`s3_bucket\` | \`S3_BUCKET\` (+ \`AWS_REGION\`) | \`storage-s3\` |
410
+ | \`queue_url\` | \`QUEUE_URL\` | \`queue-sqs\` |
411
+ | \`db_endpoint\` | \`DATABASE_URL\` (\`?sslmode=require\`) | \`db-postgres\` |
412
+ | \`user_pool_id\` / \`app_client_id\` | \`COGNITO_DOMAIN\` / \`COGNITO_REGION\` / \`COGNITO_USER_POOL_ID\` / \`OIDC_CLIENT_ID\` | \`auth\` (cognito social) |
413
+ | Secrets Manager | \`SECRET\` / \`OIDC_CLIENT_SECRET\` / \`ADMIN_TOKEN\` | sessions / OIDC / bootstrap |
414
+
415
+ \`\`\`sh
416
+ npm install
417
+ npm start
418
+ \`\`\`
419
+
420
+ Credentials come from IAM (ECS task roles) — never check access keys into env files.
421
+
422
+ ${generateAwsCiProfile()}`;
423
+ }
424
+ function generateAwsTfvarsExample() {
425
+ return `# Copy to terraform.tfvars and fill in per environment.
426
+ # CI test profile: pass a unique project per run instead —
427
+ # terraform plan -var "project=shelf-ci-$GITHUB_RUN_ID"
428
+ # db_engine is baked at scaffold time (rds | dsql); prefer dsql for CI.
429
+ project = "my-storyshelf"
430
+ region = "us-east-1"
431
+ vpc_id = ""
432
+ domain_name = ""
433
+ saml_metadata_url = ""
434
+ `;
435
+ }
436
+ const AWS_FILES = [
437
+ ["terraform/versions.tf", () => generateAwsVersions()],
438
+ ["terraform/variables.tf", () => generateAwsVariables()],
439
+ ["terraform/network.tf", () => `${generateAwsVpc()}${generateAwsSecurity()}${generateAwsEndpoints()}`],
440
+ ["terraform/storage.tf", () => generateAwsStorage()],
441
+ ["terraform/queue.tf", () => generateAwsQueue()],
442
+ ["terraform/identity.tf", () => generateAwsIdentity()],
443
+ ["terraform/compute.tf", () => generateAwsCompute()],
444
+ ["terraform/secrets.tf", () => generateAwsSecrets()],
445
+ ["terraform/dns.tf", () => generateAwsDns()],
446
+ ["terraform/outputs.tf", (options) => generateAwsOutputs(options.dbEngine)],
447
+ ["terraform/terraform.tfvars.example", () => generateAwsTfvarsExample()]
448
+ ];
449
+ /** Generate every Terraform file for the AWS reference stack. */
450
+ function generateAwsTerraformFiles(options) {
451
+ const files = {};
452
+ for (const [path, generate] of AWS_FILES) files[path] = generate(options);
453
+ files["terraform/database.tf"] = generateAwsDatabase(options.dbEngine);
454
+ files["terraform/README.md"] = generateAwsReadme(options.project);
455
+ return files;
456
+ }
457
+ //#endregion
458
+ export { TERRAFORM_REQUIRED_VERSION, generateAwsCompute, generateAwsDatabase, generateAwsDns, generateAwsEndpoints, generateAwsIdentity, generateAwsOutputs, generateAwsQueue, generateAwsReadme, generateAwsSecrets, generateAwsSecurity, generateAwsStorage, generateAwsTerraformFiles, generateAwsTfvarsExample, generateAwsVariables, generateAwsVersions, generateAwsVpc };