hecks 1.5.1 → 2.0.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.
@@ -21,9 +21,13 @@ module Hecks
21
21
  # `AWS::ECS::TaskDefinition` (`RequiresCompatibilities: [FARGATE]`,
22
22
  # `NetworkMode: awsvpc`) running one container built from the
23
23
  # domain's own `rust/host`, an `AWS::ECS::Service` behind an
24
- # Application Load Balancer, and least-privilege task execution/task
25
- # roles — plus the same private VPC/RDS-or-Aurora instance and
26
- # temporary era-minting bastion `Lambda` generates, via `Shared`.
24
+ # Application Load Balancer fronted by an `AWS::CloudFront::
25
+ # Distribution` (real HTTPS, and a `DefaultCacheBehavior` pinned to
26
+ # Managed-CachingDisabled — see that resource's own comment for
27
+ # why nothing more permissive is a safe default here), and
28
+ # least-privilege task execution/task roles — plus the same private
29
+ # VPC/RDS-or-Aurora instance and temporary era-minting bastion
30
+ # `Lambda` generates, via `Shared`.
27
31
  #
28
32
  # No SAM: this is deployed with plain `aws cloudformation deploy`,
29
33
  # never `sam deploy`, so there is no `samconfig.toml` here. A
@@ -35,13 +39,28 @@ module Hecks
35
39
  #
36
40
  # ## What this assumes, and does not build
37
41
  #
38
- # `rust/host` running as a long-lived HTTP server on this domain's
39
- # own `port`, rather than as a Lambda custom-runtime process
40
- # (`bootstrap`, `Lambda`'s own binary), is a real, separate
41
- # capability this target assumes exists — not one this generator
42
- # builds. The generated `ContainerDefinitions` and target group both
43
- # assume the container answers plain HTTP on `port`; wiring that
44
- # serve loop into `rust/host/src/main.rs` is future, undone work.
42
+ # `rust/host` runs as a long-lived HTTP server on this domain's own
43
+ # `port` here, not as a Lambda custom-runtime process (`bootstrap`,
44
+ # `Lambda`'s own binary): `HECKS_SERVE_MODE: "1"` (below, in
45
+ # `ContainerDefinitions[0].Environment`) is `rust/host/src/main.rs`'s
46
+ # own top-of-`main` switch into `server.rs`'s axum-based server,
47
+ # which answers this stack's own `GET /` health check with a bare,
48
+ # dispatch-free `200` and routes every other request through the
49
+ # same per-invocation dispatch logic the Lambda target's `bootstrap`
50
+ # binary already runs — see `server.rs`'s own header for the
51
+ # concurrency reasoning (the boot-time Postgres client is already
52
+ # `Arc<Mutex<...>>`-shared, and already anticipated exactly this,
53
+ # per `dispatch.rs`'s own comment on `handle`'s locking).
54
+ #
55
+ # Sidecars and ARM64 are generated, not assumed: the Dockerfile
56
+ # COPYs `#{domain_name}.wasm`/`.ir.json` next to the host binary,
57
+ # the task sets `HECKS_WASM_PATH`/`HECKS_IR_PATH`, RuntimePlatform
58
+ # is ARM64, and the Makefile cross-compiles with
59
+ # aarch64-unknown-linux-gnu (plus the GNU cross-linker on macOS).
60
+ # A Shared-mode ALB sits in OwningPublicSubnetAId/BId — the private
61
+ # pair is unreachable from the internet (found live,
62
+ # lifeadelics-platform). SessionSecret is always minted: HECKS_SERVE_MODE
63
+ # always runs web.rs, which panics on an empty SESSION_SECRET.
45
64
  module Fargate
46
65
  extend Projector::Target
47
66
 
@@ -115,6 +134,15 @@ module Hecks
115
134
  port = target.state[:port].value
116
135
  aurora = database == "Aurora"
117
136
  shared = database == "Shared"
137
+ rust_web = target.state[:web].value == "Rust"
138
+ # Same file-presence convention Lambda uses: `.env.local` is the
139
+ # domain's own gitignored secrets file; `make sync-google-oauth`
140
+ # (Lambda) owns the secret's lifecycle. Fargate only needs to
141
+ # *declare* GOOGLE_OAUTH_SECRET_ID + a redirect URI parameter —
142
+ # the secret itself is never a stack resource.
143
+ google_oauth_present = rust_web &&
144
+ File.exist?(File.join(domain, ".env.local")) &&
145
+ File.read(File.join(domain, ".env.local")).match?(/^GOOGLE_CLIENT_ID=\S/)
118
146
 
119
147
  # Every policy in every loaded chapter — see `Lambda.call`'s own
120
148
  # comment on why this reads the whole registry, not only this
@@ -192,6 +220,8 @@ module Hecks
192
220
  alb_id = "#{logical_id}Alb"
193
221
  alb_sg_id = "#{logical_id}AlbSecurityGroup"
194
222
  listener_id = "#{logical_id}Listener"
223
+ distribution_id = "#{logical_id}Distribution"
224
+ session_secret_id = "#{logical_id}SessionSecret"
195
225
 
196
226
  stack_outputs = Shared.stack_outputs(
197
227
  shared: shared, db_id: db_id, db_ref_id: db_ref_id, secret_intrinsic: secret_intrinsic,
@@ -200,6 +230,40 @@ module Hecks
200
230
  bastion_parameters = Shared.bastion_parameters(shared: shared, google_oauth_present: network_needs_internet)
201
231
  Shared.check_bastion_parameters!(bastion_parameters, stack_outputs)
202
232
 
233
+ always_params_yaml = <<~ALWAYSPARAMS.rstrip
234
+ ImageTag:
235
+ Type: String
236
+ Default: latest
237
+ Description: ECR image tag this task pulls — never hardcode latest in the TaskDefinition; a first deploy and a later rollout share this one parameter.
238
+ ALWAYSPARAMS
239
+ oauth_params_yaml = google_oauth_present ? <<~OAUTHPARAMS.rstrip : ""
240
+ # Same chicken-egg as Lambda's WebRedirectBaseUrl — CloudFront's
241
+ # hostname does not exist until this stack does. Empty on a true
242
+ # first deploy; `make deploy` looks up Outputs.CloudFrontDomain
243
+ # after and self-heals.
244
+ WebRedirectBaseUrl:
245
+ Type: String
246
+ Default: ""
247
+ OAUTHPARAMS
248
+ # Built outside the template heredoc so Layout/HeredocIndentation
249
+ # cannot re-indent YAML that must match SessionSecretRead / env.
250
+ # First interpolated line sits at the `\#{...}` column; later lines
251
+ # get that same left pad (lambda.rb's own OAUTHPOLICY pattern).
252
+ oauth_task_policy_yaml = google_oauth_present ? <<~OAUTHPOLICY.rstrip : ""
253
+ - PolicyName: GoogleOauthSecretRead
254
+ PolicyDocument:
255
+ Version: '2012-10-17'
256
+ Statement:
257
+ - Effect: Allow
258
+ Action: secretsmanager:GetSecretValue
259
+ Resource: !Sub "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{stack_name}-web-google-oauth-*"
260
+ OAUTHPOLICY
261
+ oauth_task_env_yaml = google_oauth_present ? <<~OAUTHENV.rstrip : ""
262
+ - Name: GOOGLE_OAUTH_SECRET_ID
263
+ Value: #{stack_name}-web-google-oauth
264
+ - Name: GOOGLE_REDIRECT_URI
265
+ Value: !Sub "${WebRedirectBaseUrl}/auth/google/callback"
266
+ OAUTHENV
203
267
  owning_params_yaml = shared ? <<~SHAREDPARAMS.rstrip : ""
204
268
  # The storehouse — #{owner_domain_name}'s own live stack Outputs,
205
269
  # looked up at deploy time (the generated Makefile's own `deploy:`
@@ -212,6 +276,22 @@ module Hecks
212
276
  Type: AWS::EC2::Subnet::Id
213
277
  OwningSubnetBId:
214
278
  Type: AWS::EC2::Subnet::Id
279
+ # PUBLIC subnets, NOT OwningSubnetAId/OwningSubnetBId above — a
280
+ # real, live deploy (lifeadelics-platform) found the ALB placed in
281
+ # the private pair creates successfully and reports its target
282
+ # health as healthy (health checks run from inside the VPC), yet
283
+ # is completely unreachable from outside it:
284
+ # OwningSubnetAId/OwningSubnetBId have MapPublicIpOnLaunch: false
285
+ # and no Internet Gateway route. These two — resolved from the
286
+ # owner stack's own PublicSubnetId/BastionSubnetId Outputs,
287
+ # MapPublicIpOnLaunch: true with a real 0.0.0.0/0 -> igw route —
288
+ # are the pair that actually works for an internet-facing ALB.
289
+ # Service.NetworkConfiguration below still (correctly) uses the
290
+ # private pair for the tasks themselves — only the ALB moves.
291
+ OwningPublicSubnetAId:
292
+ Type: AWS::EC2::Subnet::Id
293
+ OwningPublicSubnetBId:
294
+ Type: AWS::EC2::Subnet::Id
215
295
  OwningSecurityGroupId:
216
296
  Type: AWS::EC2::SecurityGroup::Id
217
297
  OwningDatabaseEndpoint:
@@ -219,6 +299,7 @@ module Hecks
219
299
  OwningDatabaseSecretArn:
220
300
  Type: String
221
301
  SHAREDPARAMS
302
+ parameters_yaml = [always_params_yaml, oauth_params_yaml, owning_params_yaml].reject(&:empty?).join("\n")
222
303
 
223
304
  template_yaml = <<~YAML
224
305
  # GENERATED by bin/project_deploy #{domain} — re-run it to refresh
@@ -235,7 +316,7 @@ module Hecks
235
316
  #{infra_name} — dispatched through hecks's rust/host, running as a
236
317
  long-lived container on AWS Fargate, backed by its own private RDS
237
318
  Postgres instance.
238
- #{owning_params_yaml.empty? ? "" : "Parameters:\n" + owning_params_yaml.each_line.map { |l| " #{l}" }.join}
319
+ #{parameters_yaml.empty? ? "" : "Parameters:\n" + parameters_yaml.each_line.map { |l| " #{l}" }.join}
239
320
  Resources:
240
321
  #{shared ? "" : Shared.vpc_and_database_yaml(
241
322
  db_id: db_id, db_name: db_name, infra_name: infra_name, aurora: aurora,
@@ -247,12 +328,25 @@ module Hecks
247
328
  Type: AWS::EC2::SecurityGroup
248
329
  Properties:
249
330
  VpcId: #{shared ? "!Ref OwningVpcId" : "!Ref #{db_id}Vpc"}
250
- GroupDescription: #{alb_sg_id} - public HTTP ingress, forwarded to #{logical_id} only
331
+ GroupDescription: #{alb_sg_id} - HTTP ingress from CloudFront only, forwarded to #{logical_id} only
251
332
  SecurityGroupIngress:
333
+ # pl-3b927c52 — com.amazonaws.global.cloudfront.origin-
334
+ # facing, AWS's own global, account-agnostic managed
335
+ # prefix list (confirmed: `aws ec2 describe-managed-
336
+ # prefix-lists`, OwnerId "AWS", same id in every
337
+ # account/region). NOT 0.0.0.0/0 — the whole reason
338
+ # #{distribution_id} above exists is Managed-
339
+ # CachingDisabled on every session-cookie-driven
340
+ # route; leaving the ALB itself open to the public
341
+ # internet on this same port would let anyone bypass
342
+ # that distribution (and its HTTPS) entirely and hit
343
+ # the plain-HTTP origin directly — the exact gap a
344
+ # code review caught the first time this resource was
345
+ # added.
252
346
  - IpProtocol: tcp
253
347
  FromPort: 80
254
348
  ToPort: 80
255
- CidrIp: 0.0.0.0/0
349
+ SourcePrefixListId: pl-3b927c52
256
350
 
257
351
  #{logical_id}IngressFromAlb:
258
352
  Type: AWS::EC2::SecurityGroupIngress
@@ -281,6 +375,19 @@ module Hecks
281
375
  LogGroupName: /ecs/#{stack_name}
282
376
  RetentionInDays: 30
283
377
 
378
+ # Always minted — HECKS_SERVE_MODE always runs web.rs, which
379
+ # panics on an empty SESSION_SECRET (found live: hecksagain-pizzas
380
+ # 502 after the Aurora cutover). rust/host fetches this at cold
381
+ # start (secrets.rs), never a plain env var.
382
+ #{session_secret_id}:
383
+ Type: AWS::SecretsManager::Secret
384
+ Properties:
385
+ GenerateSecretString:
386
+ SecretStringTemplate: '{}'
387
+ GenerateStringKey: session_secret
388
+ PasswordLength: 64
389
+ ExcludePunctuation: true
390
+
284
391
  # Pulls the image and writes CloudWatch Logs — AWS's own managed
285
392
  # AmazonECSTaskExecutionRolePolicy already covers both (ECR auth
286
393
  # + GetDownloadUrlForLayer, and logs:CreateLogStream/PutLogEvents);
@@ -330,6 +437,14 @@ module Hecks
330
437
  - Effect: Allow
331
438
  Action: secretsmanager:GetSecretValue
332
439
  Resource: !Sub "${#{db_secret_ref}}"
440
+ - PolicyName: SessionSecretRead
441
+ PolicyDocument:
442
+ Version: '2012-10-17'
443
+ Statement:
444
+ - Effect: Allow
445
+ Action: secretsmanager:GetSecretValue
446
+ Resource: !Ref #{session_secret_id}
447
+ #{oauth_task_policy_yaml.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join}
333
448
  # TMPL:cross_domain_fargate_policies
334
449
 
335
450
  #{task_definition_id}:
@@ -338,13 +453,22 @@ module Hecks
338
453
  Family: #{infra_name}
339
454
  RequiresCompatibilities: [FARGATE]
340
455
  NetworkMode: awsvpc
456
+ # ARM64, not Fargate's own x86_64 default — matching the
457
+ # generated Makefile's own aarch64-unknown-linux-gnu build
458
+ # (below) and Lambda's own arm64 toolchain this reuses; a
459
+ # container built for the wrong arch fails at task start,
460
+ # not at build time, so this has to agree with the image
461
+ # docker-build actually pushes.
462
+ RuntimePlatform:
463
+ CpuArchitecture: ARM64
464
+ OperatingSystemFamily: LINUX
341
465
  Cpu: "#{cpu}"
342
466
  Memory: "#{memory}"
343
467
  ExecutionRoleArn: !GetAtt #{execution_role_id}.Arn
344
468
  TaskRoleArn: !GetAtt #{task_role_id}.Arn
345
469
  ContainerDefinitions:
346
470
  - Name: #{infra_name}
347
- Image: !Sub "${#{ecr_repository_id}.RepositoryUri}:latest"
471
+ Image: !Sub "${#{ecr_repository_id}.RepositoryUri}:${ImageTag}"
348
472
  PortMappings:
349
473
  - ContainerPort: #{port}
350
474
  LogConfiguration:
@@ -360,19 +484,46 @@ module Hecks
360
484
  Value: "1"
361
485
  - Name: PORT
362
486
  Value: "#{port}"
487
+ # `rust/host/src/server.rs`'s own top-of-`main`
488
+ # switch — without it, this container runs as the
489
+ # Lambda custom-runtime process `Lambda`'s own
490
+ # generated binary always has, which blocks
491
+ # forever polling a Runtime API that doesn't exist
492
+ # here, never answering the health check or
493
+ # anything else on `port`.
494
+ - Name: HECKS_SERVE_MODE
495
+ Value: "1"
363
496
  # `web "Rust"` vs `web "None"` is otherwise inert
364
497
  # here today — both modes generate the identical
365
498
  # task/service/target-group shape, since a Fargate
366
499
  # task always answers HTTP on `port` for dispatch
367
500
  # requests either way. Passed through so
368
- # `rust/host`'s own future long-lived server loop
369
- # (this module's own header names the gap) can read
370
- # it and decide whether to also serve the public
371
- # web UI in-process, the same `web`-shaped choice
372
- # `Lambda`'s own `rust_web` already makes for the
373
- # Lambda path.
501
+ # `rust/host`'s own server loop (server.rs) can
502
+ # read it and decide whether to also serve the
503
+ # public web UI in-process, the same `web`-shaped
504
+ # choice `Lambda`'s own `rust_web` already makes
505
+ # for the Lambda path.
374
506
  - Name: HECKS_WEB
375
507
  Value: #{target.state[:web].value}
508
+ # HECKS_WASM_PATH/HECKS_IR_PATH — main.rs requires
509
+ # both unconditionally at boot (ir::ir().ok_or(...)?,
510
+ # no fallback, and HECKS_WASM_PATH for every
511
+ # dispatch) regardless of `web`/HECKS_SERVE_MODE. A
512
+ # container built with neither set crashes before
513
+ # ever reaching its own serve loop — found live
514
+ # deploying lifeadelics-platform's own domain
515
+ # container, fixed here so every Fargate domain ships
516
+ # both sidecars by default. Paths match the
517
+ # Dockerfile's own COPY destinations, below.
518
+ - Name: HECKS_WASM_PATH
519
+ Value: /usr/local/bin/#{domain_name}.wasm
520
+ - Name: HECKS_IR_PATH
521
+ Value: /usr/local/bin/#{domain_name}.ir.json
522
+ - Name: SESSION_SECRET_ARN
523
+ Value: !Ref #{session_secret_id}
524
+ - Name: HECKS_CHECKOUT_DOMAIN
525
+ Value: #{declared_domain_name}
526
+ #{oauth_task_env_yaml.each_line.with_index.map { |l, i| i.zero? ? l : " " + l }.join}
376
527
  # TMPL:db_env
377
528
 
378
529
  #{target_group_id}:
@@ -392,7 +543,7 @@ module Hecks
392
543
  Scheme: internet-facing
393
544
  Type: application
394
545
  SecurityGroups: [!Ref #{alb_sg_id}]
395
- Subnets: #{shared ? "[!Ref OwningSubnetAId, !Ref OwningSubnetBId]" : "[!Ref #{db_id}PublicSubnet, !Ref #{db_id}BastionPublicSubnet]"}
546
+ Subnets: #{shared ? "[!Ref OwningPublicSubnetAId, !Ref OwningPublicSubnetBId]" : "[!Ref #{db_id}PublicSubnet, !Ref #{db_id}BastionPublicSubnet]"}
396
547
 
397
548
  #{listener_id}:
398
549
  Type: AWS::ElasticLoadBalancingV2::Listener
@@ -423,9 +574,76 @@ module Hecks
423
574
  ContainerPort: #{port}
424
575
  TargetGroupArn: !Ref #{target_group_id}
425
576
 
577
+ # Real HTTPS (the ALB's own Listener above is HTTP-only —
578
+ # nothing else in this stack terminates TLS) and, just as
579
+ # important, the ONE safe default this generator can offer
580
+ # for caching it has no way to reason about: Managed-
581
+ # CachingDisabled. This domain's own routes — including
582
+ # every hecks-native /login, /logout, /auth/google(/callback),
583
+ # /admin/members request (web.rs's own auth_gate/auth_route,
584
+ # generic across every domain, not just this one's own
585
+ # dispatch commands) — are all session-cookie-driven, and
586
+ # this generator has no way to tell which of a domain's own
587
+ # paths would ever be safe to cache. Found live, the hard
588
+ # way (lifeadelics, 2026-09-21): a hand-authored CloudFront
589
+ # stack applied the OPPOSITE default — a custom, cookie-
590
+ # blind cache policy with a 90-120s TTL — and it served one
591
+ # signed-in session's own response (a short-lived SSO
592
+ # handoff token among them) back to a different, unrelated
593
+ # request within that window. A domain that DOES know one
594
+ # of its own paths is genuinely safe to cache (a public,
595
+ # non-personalized page) adds its own more specific
596
+ # CacheBehavior by hand, the same way lifeadelics's own
597
+ # hand-extended three-container stack already does for
598
+ # /_astro/*, /videos/*, and friends — never by loosening
599
+ # this one.
600
+ #{distribution_id}:
601
+ Type: AWS::CloudFront::Distribution
602
+ Properties:
603
+ DistributionConfig:
604
+ Enabled: true
605
+ HttpVersion: http2
606
+ # No ACM/custom domain here — this generator has no
607
+ # notion of one (deploy.bluebook's own FargateTarget
608
+ # declares no `domain` attribute for it) and CloudFront
609
+ # requires an ACM cert in us-east-1 specifically to
610
+ # attach a custom Aliases entry, a real cross-region
611
+ # dependency this generator can't assume. CloudFront's
612
+ # own default *.cloudfront.net certificate/hostname
613
+ # are what Outputs.CloudFrontDomain below reports;
614
+ # point a real domain's DNS at it by hand, same
615
+ # "generated, extend by hand" posture this whole file
616
+ # already has for anything past its own baseline.
617
+ ViewerCertificate:
618
+ CloudFrontDefaultCertificate: true
619
+ Origins:
620
+ - Id: #{alb_id}Origin
621
+ DomainName: !GetAtt #{alb_id}.DNSName
622
+ CustomOriginConfig:
623
+ OriginProtocolPolicy: http-only
624
+ HTTPPort: 80
625
+ HTTPSPort: 443
626
+ DefaultCacheBehavior:
627
+ TargetOriginId: #{alb_id}Origin
628
+ ViewerProtocolPolicy: redirect-to-https
629
+ Compress: true
630
+ AllowedMethods: [GET, HEAD, OPTIONS, PUT, PATCH, POST, DELETE]
631
+ CachedMethods: [GET, HEAD]
632
+ # Managed-CachingDisabled — see this resource's own
633
+ # header comment for why nothing else is safe here
634
+ # by default.
635
+ CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
636
+ # Managed-AllViewer — forwards every cookie/header/
637
+ # query string through uncached, so rust/host's own
638
+ # session-cookie-based auth sees the real request
639
+ # exactly as the browser sent it.
640
+ OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3
641
+
426
642
  Outputs:
427
643
  ServiceUrl:
428
644
  Value: !Sub "http://${#{alb_id}.DNSName}"
645
+ CloudFrontDomain:
646
+ Value: !GetAtt #{distribution_id}.DomainName
429
647
  #{stack_outputs.map { |o| "#{o[:key]}:\n Value: #{o[:ref]}" }.join("\n ")}
430
648
  YAML
431
649
 
@@ -471,6 +689,11 @@ module Hecks
471
689
  && rm -rf /var/lib/apt/lists/*
472
690
 
473
691
  COPY #{domain_name}-host /usr/local/bin/#{domain_name}-host
692
+ # The .wasm/.ir.json sidecars main.rs requires at boot —
693
+ # HECKS_WASM_PATH/HECKS_IR_PATH (template.yaml's own
694
+ # ContainerDefinitions Environment) point at these exact paths.
695
+ COPY #{domain_name}.wasm /usr/local/bin/#{domain_name}.wasm
696
+ COPY #{domain_name}.ir.json /usr/local/bin/#{domain_name}.ir.json
474
697
 
475
698
  ENV PORT=#{port}
476
699
  ENV BIND=0.0.0.0
@@ -495,9 +718,30 @@ module Hecks
495
718
  IMAGE_TAG := latest
496
719
 
497
720
  build:
498
- \t@rustup target list --installed 2>/dev/null | grep -qx x86_64-unknown-linux-gnu || rustup target add x86_64-unknown-linux-gnu
499
- \tcd $(ROOT)/rust/host && rustup run stable cargo build --release --target x86_64-unknown-linux-gnu
500
- \tcp $(ROOT)/rust/host/target/x86_64-unknown-linux-gnu/release/bootstrap #{domain_name}-host
721
+ # aarch64, not x86_64 — matches template.yaml's own
722
+ # RuntimePlatform: ARM64 (this Makefile has to build the same
723
+ # architecture the task definition declares, or the container
724
+ # fails at task start, not at build time), and reuses the same
725
+ # working aarch64-unknown-linux-gnu toolchain the Lambda deploy
726
+ # path already depends on, rather than standing up a second,
727
+ # x86_64-only one.
728
+ \t@rustup target list --installed 2>/dev/null | grep -qx aarch64-unknown-linux-gnu || rustup target add aarch64-unknown-linux-gnu
729
+ # GNU cross-linker, not Apple clang — rustc's aarch64-unknown-linux-gnu
730
+ # target emits `-Wl,--fix-cortex-a53-843419`, which macOS ld rejects
731
+ # (found live building lifeadelics-platform's domain image). Same
732
+ # toolchain Lambda's generated Makefile already documents.
733
+ \t@command -v aarch64-linux-gnu-gcc >/dev/null 2>&1 || { echo "aarch64-linux-gnu-gcc isn't on PATH. Install once with: brew tap messense/macos-cross-toolchains && brew install aarch64-unknown-linux-gnu"; exit 1; }
734
+ # The .wasm/.ir.json sidecars main.rs requires at boot,
735
+ # unconditionally — see template.yaml's own HECKS_WASM_PATH/
736
+ # HECKS_IR_PATH comment for why. bin/project_wasm is the same
737
+ # generator the Lambda deploy path already uses to produce
738
+ # rust/dist/#{domain_name}.wasm/.ir.json from this domain's own
739
+ # .bluebook.
740
+ \tcd $(ROOT) && bin/project_wasm $(DOMAIN)
741
+ \tcd $(ROOT)/rust/host && CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc rustup run stable cargo build --release --target aarch64-unknown-linux-gnu --bin bootstrap
742
+ \tcp $(ROOT)/rust/host/target/aarch64-unknown-linux-gnu/release/bootstrap #{domain_name}-host
743
+ \tcp $(ROOT)/rust/dist/#{domain_name}.wasm #{domain_name}.wasm
744
+ \tcp $(ROOT)/rust/dist/#{domain_name}.ir.json #{domain_name}.ir.json
501
745
 
502
746
  .PHONY: ecr-login
503
747
  ecr-login:
@@ -505,7 +749,10 @@ module Hecks
505
749
 
506
750
  .PHONY: docker-build
507
751
  docker-build: build
508
- \tdocker build --platform linux/amd64 -t #{infra_name}:$(IMAGE_TAG) .
752
+ # linux/arm64, not the generator's old amd64 default — has to
753
+ # match RuntimePlatform/the binary this Makefile's own build:
754
+ # step just cross-compiled, above.
755
+ \tdocker build --platform linux/arm64 -t #{infra_name}:$(IMAGE_TAG) .
509
756
 
510
757
  .PHONY: docker-push
511
758
  docker-push: ecr-login
@@ -515,7 +762,7 @@ module Hecks
515
762
 
516
763
  .PHONY: deploy
517
764
  deploy: docker-build docker-push
518
- #{shared ? "\t@echo \"Looking up #{owner_stack_name}'s shared VpcId/PrivateSubnetAId/PrivateSubnetBId/FunctionSecurityGroupId/DatabaseEndpoint/DatabaseSecretArn outputs to pass as $(STACK)'s Owning* parameters...\"\n\tOWNER_VPC_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='VpcId'].OutputValue\" --output text); \\\n\t\tOWNER_SUBNET_A_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='PrivateSubnetAId'].OutputValue\" --output text); \\\n\t\tOWNER_SUBNET_B_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='PrivateSubnetBId'].OutputValue\" --output text); \\\n\t\tOWNER_SG_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='FunctionSecurityGroupId'].OutputValue\" --output text); \\\n\t\tOWNER_DB_HOST=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='DatabaseEndpoint'].OutputValue\" --output text); \\\n\t\tOWNER_DB_SECRET_ARN=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='DatabaseSecretArn'].OutputValue\" --output text); \\\n\t\taws cloudformation deploy --template-file template.yaml --stack-name $(STACK) --region $(REGION) --capabilities CAPABILITY_IAM \\\n\t\t\t--parameter-overrides OwningVpcId=$$OWNER_VPC_ID OwningSubnetAId=$$OWNER_SUBNET_A_ID OwningSubnetBId=$$OWNER_SUBNET_B_ID OwningSecurityGroupId=$$OWNER_SG_ID OwningDatabaseEndpoint=$$OWNER_DB_HOST OwningDatabaseSecretArn=$$OWNER_DB_SECRET_ARN" : "\taws cloudformation deploy --template-file template.yaml --stack-name $(STACK) --region $(REGION) --capabilities CAPABILITY_IAM"}
765
+ #{shared ? "\t@echo \"Looking up #{owner_stack_name}'s shared VpcId/PrivateSubnetAId/PrivateSubnetBId/PublicSubnetId/BastionSubnetId/FunctionSecurityGroupId/DatabaseEndpoint/DatabaseSecretArn outputs to pass as $(STACK)'s Owning* parameters...\"\n\tOWNER_VPC_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='VpcId'].OutputValue\" --output text); \\\n\t\tOWNER_SUBNET_A_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='PrivateSubnetAId'].OutputValue\" --output text); \\\n\t\tOWNER_SUBNET_B_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='PrivateSubnetBId'].OutputValue\" --output text); \\\n\t\tOWNER_PUBLIC_SUBNET_A_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='PublicSubnetId'].OutputValue\" --output text); \\\n\t\tOWNER_PUBLIC_SUBNET_B_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='BastionSubnetId'].OutputValue\" --output text); \\\n\t\tOWNER_SG_ID=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='FunctionSecurityGroupId'].OutputValue\" --output text); \\\n\t\tOWNER_DB_HOST=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='DatabaseEndpoint'].OutputValue\" --output text); \\\n\t\tOWNER_DB_SECRET_ARN=$$(aws cloudformation describe-stacks --stack-name #{owner_stack_name} --query \"Stacks[0].Outputs[?OutputKey=='DatabaseSecretArn'].OutputValue\" --output text); \\\n\t\taws cloudformation deploy --template-file template.yaml --stack-name $(STACK) --region $(REGION) --capabilities CAPABILITY_IAM \\\n\t\t\t--parameter-overrides ImageTag=$(IMAGE_TAG) OwningVpcId=$$OWNER_VPC_ID OwningSubnetAId=$$OWNER_SUBNET_A_ID OwningSubnetBId=$$OWNER_SUBNET_B_ID OwningPublicSubnetAId=$$OWNER_PUBLIC_SUBNET_A_ID OwningPublicSubnetBId=$$OWNER_PUBLIC_SUBNET_B_ID OwningSecurityGroupId=$$OWNER_SG_ID OwningDatabaseEndpoint=$$OWNER_DB_HOST OwningDatabaseSecretArn=$$OWNER_DB_SECRET_ARN" : "\taws cloudformation deploy --template-file template.yaml --stack-name $(STACK) --region $(REGION) --capabilities CAPABILITY_IAM --parameter-overrides ImageTag=$(IMAGE_TAG)"}
519
766
  \t$(MAKE) mint-era
520
767
 
521
768
  #{shared ? <<~SHAREDMINT.rstrip : <<~OWNMINT.rstrip
@@ -108,7 +108,7 @@ module Hecks
108
108
  # + extraction + memory + prism only) than the one `pending_privacy_
109
109
  # markings` was verified against; kept separate rather than assumed
110
110
  # equivalent.
111
- pii_registry = Hecks::Runtime::Registry.new
111
+ pii_registry = Hecks::Runtime::Registry.new(root: File.expand_path(domain))
112
112
  Hecks.with_registry(pii_registry) do
113
113
  bootstrap = Hecks::Ports::Loading.bootstrap
114
114
  bluebook_dir = File.join(domain, "bluebook")
@@ -841,7 +841,16 @@ module Hecks
841
841
  # triggers that first boot; a LATER real schema evolution
842
842
  # (era 2+) is a separate, later re-generation, not this one.
843
843
  HECKS_DOMAIN: #{declared_domain_name}
844
- HECKS_ERA: "1"#{hecks_schema ? %(\n HECKS_SCHEMA: #{hecks_schema}) : ""}#{rust_web ? %(\n HECKS_IR_PATH: !Sub "/var/task/#{domain_name}.ir.json") : ""}#{rust_web && google_oauth_present ? <<~RUSTOAUTH.each_line.with_index.map { |l, i| i.zero? ? "\n " + l : " " + l }.join.rstrip : ""}
844
+ # UNCONDITIONAL, not gated on `rust_web` — `rust/host/src/main.rs`'s
845
+ # own boot sequence reads `ir::ir().ok_or(...)?` for every domain
846
+ # regardless of web mode (era-lineage bookkeeping needs the IR, not
847
+ # just the optional in-process web UI), so a Shared-mode domain with
848
+ # no web layer still needs this sidecar path to boot at all. Previously
849
+ # gated on `rust_web`, which left every such domain's Lambda crashing
850
+ # on cold start — checkout.rs's own header flagged this exact
851
+ # contradiction, confirmed live against Banking's committed template.
852
+ HECKS_IR_PATH: !Sub "/var/task/#{domain_name}.ir.json"
853
+ HECKS_ERA: "1"#{hecks_schema ? %(\n HECKS_SCHEMA: #{hecks_schema}) : ""}#{rust_web && google_oauth_present ? <<~RUSTOAUTH.each_line.with_index.map { |l, i| i.zero? ? "\n " + l : " " + l }.join.rstrip : ""}
845
854
  # NOT `{{resolve:secretsmanager:...}}` composing
846
855
  # GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/SESSION_SECRET
847
856
  # directly anymore -- the identical GetFunctionConfiguration
@@ -1893,7 +1902,14 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
1893
1902
  # Lambda) the same day this comment was written.
1894
1903
  \tcd $(HOST_DIR) && rustup run stable cargo lambda build --release --arm64
1895
1904
  \tcp $(HOST_DIR)/target/lambda/bootstrap/bootstrap $(ARTIFACTS_DIR)/bootstrap
1896
- \tcp $(WASM) $(ARTIFACTS_DIR)/#{domain_name}.wasm#{rust_web ? %(\n\tcp #{File.join(root, "rust", "dist", "#{domain_name}.ir.json")} $(ARTIFACTS_DIR)/#{domain_name}.ir.json) : ""}
1905
+ # UNCONDITIONAL, not gated on rust_web -- same reason HECKS_IR_PATH
1906
+ # itself is unconditional above: main.rs's own boot sequence reads
1907
+ # the IR for every domain regardless of web mode, so the sidecar
1908
+ # file this env var points to has to actually be in the package
1909
+ # too, or the env var alone just changes the crash from "not set"
1910
+ # to "not found".
1911
+ \tcp $(WASM) $(ARTIFACTS_DIR)/#{domain_name}.wasm
1912
+ \tcp #{File.join(root, "rust", "dist", "#{domain_name}.ir.json")} $(ARTIFACTS_DIR)/#{domain_name}.ir.json
1897
1913
 
1898
1914
  # `sam build <resource>` WIPES .aws-sam/build/ entirely before
1899
1915
  # building just the one resource named -- confirmed live: building
@@ -1917,7 +1933,8 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
1917
1933
  restore-#{logical_id}-build:
1918
1934
  \t@mkdir -p .aws-sam/build/#{logical_id}
1919
1935
  \tcp $(HOST_DIR)/target/lambda/bootstrap/bootstrap .aws-sam/build/#{logical_id}/bootstrap
1920
- \tcp $(WASM) .aws-sam/build/#{logical_id}/#{domain_name}.wasm#{rust_web ? %(\n\tcp #{File.join(root, "rust", "dist", "#{domain_name}.ir.json")} .aws-sam/build/#{logical_id}/#{domain_name}.ir.json) : ""}
1936
+ \tcp $(WASM) .aws-sam/build/#{logical_id}/#{domain_name}.wasm
1937
+ \tcp #{File.join(root, "rust", "dist", "#{domain_name}.ir.json")} .aws-sam/build/#{logical_id}/#{domain_name}.ir.json
1921
1938
  \truby -e 'lines = File.readlines(".aws-sam/build/template.yaml"); start = lines.index { |l| l.strip == "#{logical_id}:" } or raise "restore-#{logical_id}-build: #{logical_id} resource not found in built template"; idx = (start+1...lines.length).find { |i| lines[i] =~ /CodeUri:/ } or raise "restore-#{logical_id}-build: no CodeUri line found under #{logical_id}"; lines[idx] = lines[idx].sub(/CodeUri:.*/, "CodeUri: #{logical_id}"); File.write(".aws-sam/build/template.yaml", lines.join)'
1922
1939
 
1923
1940
  # `make verify-parity-#{logical_id}` — closes the exact gap the
@@ -126,6 +126,54 @@ module Hecks
126
126
  }
127
127
  end
128
128
 
129
+ # Same seam as `authorization` — which chapter answers "who may
130
+ # sign in" (`Registry#membership_provider_for`), with its declared
131
+ # verbs qualified and the membership aggregate named off `admit`.
132
+ # rust/host reads this instead of HECKS_MEMBERSHIP_AGGREGATE.
133
+ # `{}` when nothing this domain attaches provides membership.
134
+ # @param registry [Runtime::Registry] the booted registry `domain_name` is loaded in
135
+ # @param domain_name [String] the domain to export the membership binding for
136
+ # @return [Hash{Symbol => String, nil}] `:provider` (name), `:admit`, `:grant`,
137
+ # `:people` (qualified verbs), and `:aggregate` (`:admit`'s own leading
138
+ # aggregate name); `{}` if nothing this domain attaches provides membership
139
+ def membership(registry, domain_name)
140
+ provider = registry.membership_provider_for(domain_name)
141
+ return {} unless provider
142
+
143
+ capability = Bluebook::Capabilities::MEMBERSHIP
144
+ admit = provider.provided_verb(capability, :admit)
145
+ {
146
+ provider: provider.name,
147
+ admit: admit,
148
+ grant: provider.provided_verb(capability, :grant),
149
+ people: provider.provided_verb(capability, :people),
150
+ aggregate: admit&.split(".")&.first
151
+ }
152
+ end
153
+
154
+ # Same seam as `authorization` — which chapter answers "who is this
155
+ # authenticated pair" (`Registry#identity_provider_for`), with its
156
+ # declared verbs qualified. rust/host reads this instead of naming
157
+ # Identity::Identity.Register / ExternalIdentifier.Link. `{}` when
158
+ # nothing this domain attaches provides identity. Breaking in 2.0:
159
+ # Link's reference field is `identity`, never `identity_id`.
160
+ # @param registry [Runtime::Registry] the booted registry `domain_name` is loaded in
161
+ # @param domain_name [String] the domain to export the identity binding for
162
+ # @return [Hash{Symbol => String, nil}] `:provider` (name), `:register`, `:link`,
163
+ # `:resolve` (qualified verbs); `{}` if nothing this domain attaches provides identity
164
+ def identity(registry, domain_name)
165
+ provider = registry.identity_provider_for(domain_name)
166
+ return {} unless provider
167
+
168
+ capability = Bluebook::Capabilities::IDENTITY
169
+ {
170
+ provider: provider.name,
171
+ register: provider.provided_verb(capability, :register),
172
+ link: provider.provided_verb(capability, :link),
173
+ resolve: provider.provided_verb(capability, :resolve)
174
+ }
175
+ end
176
+
129
177
  # Translation IR, always as an array, with each aggregate's
130
178
  # precompiled SQL attached (`compiled_translation_aggregate`) —
131
179
  # this is the export a consumer embeds (`ir.json`'s `translations`
@@ -138,12 +138,22 @@ module Hecks
138
138
  current_entries.reject { |entry| entry.fqn.aggregate.nil? }
139
139
  .group_by { |entry| [entry.fqn.aggregate, entry.fqn.verb] }
140
140
  .each do |(aggregate, verb), candidates|
141
+ # Bounded chapters wrap in their own module (`Domain::Aggregate`)
142
+ # so two BCs can both declare `Person` without colliding on
143
+ # Object::Person. Folder-spread files of the SAME chapter still
144
+ # get the shortcut — they are not BCs.
145
+ next if bounded_chapter?(candidates)
146
+
141
147
  shortcut_target(aggregate).define_singleton_method(verb) do |**args|
142
148
  installer.send(:dispatch_short, candidates, **args)
143
149
  end
144
150
  end
145
151
  end
146
152
 
153
+ def bounded_chapter?(candidates)
154
+ candidates.any? { |entry| entry.dispatcher.registry.bounded?(entry.fqn.domain) }
155
+ end
156
+
147
157
  def dispatch_short(candidates, **args)
148
158
  if candidates.length > 1
149
159
  shown = candidates.map { |entry| entry.fqn.to_s }.sort.join(", ")