feat(s3): files.coilysiren.me, a private bucket the public reads through CloudFront #873

Merged
coilysiren merged 1 commit from feat/public-asset-host into main 2026-08-19 16:03:57 +00:00
Member

files.coilysiren.me, the asset host Sirens Dowel publishes to through the mcp-beaver serve-s3 MCP, plus the mcp-beaver-s3 workload identity that writes it.

Pairs with coilyco-flight-deck/mcp-beaver#90, which adds the serve mode.

A separate stack, not aws-inventory

That stack owns the coilysiren.me zone and all 17 of its records, so a bad apply there breaks DNS for every service. This one creates one hostname and touches nothing that already resolves. Its README also asks it stay S3 and Route53 rather than grow into a mega-stack, and CloudFront plus ACM is neither.

The bucket is private

The obvious shape is a public-read policy on a bucket named after the domain, and that shape costs TLS. An S3 website endpoint does not speak it, and the *.s3.amazonaws.com certificate does not match a bucket name containing dots, so the pretty hostname and HTTPS are mutually exclusive without something in front. scripts/demo-asset-host.sh in the deploy repo hit exactly this and shipped two URLs per object as a result.

CloudFront is that something, and once it is there the bucket has no reason to be public. All four public-access-block flags stay on, and the only principal in the bucket policy is cloudfront.amazonaws.com, narrowed by AWS:SourceArn to this one distribution. Without that condition the service principal admits any distribution in any account, which is the usual way an OAC bucket ends up readable through a stranger's edge.

The IAM half

Lands in aws-iam, where every principal in this account is declared. mcp-beaver-s3 gets PutObject, GetObject, and ListBucket, and no DeleteObject: publishing is the grant, unpublishing is not. Kai creates the user by hand, so an import block adopts it rather than racing to create a second one. Versioning makes the overwrite PutObject does allow recoverable, with noncurrent versions expiring at 90 days.

The two stacks meet only through the bucket name, so neither reads the other's state.

Validation, and what is still owed

pre-commit run --all-files is green.

terraform fmt, validate, and plan could not run in this session and are still owed before any apply. The HCL is hand-checked for alignment against the sibling stacks but nothing has parsed it. Please run:

just terraform-aws-public-assets action=init
just terraform-aws-public-assets action=plan

The IAM stack needs its own plan too, since the import block for mcp-beaver-s3 only resolves once that user exists.

A first apply waits on the CloudFront distribution reaching Deployed, several minutes of AWS propagating rather than a hang. ACM validation resolves on its own because the stack writes its own validation records.

Cache note

The distribution uses AWS-managed CachingOptimized, so an overwritten key serves stale bytes until its TTL expires. Prefer publishing a new key over replacing one. 403 and 404 cache for 10 seconds so a link shared just before its object lands recovers quickly.

`files.coilysiren.me`, the asset host Sirens Dowel publishes to through the `mcp-beaver serve-s3` MCP, plus the `mcp-beaver-s3` workload identity that writes it. Pairs with coilyco-flight-deck/mcp-beaver#90, which adds the serve mode. ## A separate stack, not `aws-inventory` That stack owns the `coilysiren.me` zone and all 17 of its records, so a bad apply there breaks DNS for every service. This one creates one hostname and touches nothing that already resolves. Its README also asks it stay S3 and Route53 rather than grow into a mega-stack, and CloudFront plus ACM is neither. ## The bucket is private The obvious shape is a public-read policy on a bucket named after the domain, and that shape costs TLS. An S3 website endpoint does not speak it, and the `*.s3.amazonaws.com` certificate does not match a bucket name containing dots, so the pretty hostname and HTTPS are mutually exclusive without something in front. `scripts/demo-asset-host.sh` in the deploy repo hit exactly this and shipped two URLs per object as a result. CloudFront is that something, and once it is there the bucket has no reason to be public. All four public-access-block flags stay **on**, and the only principal in the bucket policy is `cloudfront.amazonaws.com`, narrowed by `AWS:SourceArn` to this one distribution. Without that condition the service principal admits any distribution in any account, which is the usual way an OAC bucket ends up readable through a stranger's edge. ## The IAM half Lands in `aws-iam`, where every principal in this account is declared. `mcp-beaver-s3` gets `PutObject`, `GetObject`, and `ListBucket`, and **no** `DeleteObject`: publishing is the grant, unpublishing is not. Kai creates the user by hand, so an `import` block adopts it rather than racing to create a second one. Versioning makes the overwrite `PutObject` does allow recoverable, with noncurrent versions expiring at 90 days. The two stacks meet only through the bucket name, so neither reads the other's state. ## Validation, and what is still owed `pre-commit run --all-files` is green. **`terraform fmt`, `validate`, and `plan` could not run in this session** and are still owed before any apply. The HCL is hand-checked for alignment against the sibling stacks but nothing has parsed it. Please run: ``` just terraform-aws-public-assets action=init just terraform-aws-public-assets action=plan ``` The IAM stack needs its own `plan` too, since the import block for `mcp-beaver-s3` only resolves once that user exists. A first `apply` waits on the CloudFront distribution reaching Deployed, several minutes of AWS propagating rather than a hang. ACM validation resolves on its own because the stack writes its own validation records. ## Cache note The distribution uses AWS-managed CachingOptimized, so an overwritten key serves stale bytes until its TTL expires. Prefer publishing a new key over replacing one. `403` and `404` cache for 10 seconds so a link shared just before its object lands recovers quickly.
feat(s3): files.coilysiren.me, a private bucket the public reads through CloudFront
All checks were successful
TruffleHog / Scan for secrets (pull_request) Successful in 6s
CI / lint (pull_request) Successful in 43s
2a50f15f57
Stands up the asset host Sirens Dowel publishes to through the mcp-beaver
serve-s3 MCP, plus the mcp-beaver-s3 workload identity that writes it.

A SEPARATE STACK, not aws-inventory. That stack owns the coilysiren.me zone
and all 17 of its records, so a bad apply there breaks DNS for every service.
This one creates one hostname and touches nothing that already resolves. Its
README also asks it stay S3 and Route53 rather than become a mega-stack, and
CloudFront plus ACM is neither.

THE BUCKET IS PRIVATE. The obvious shape is a public-read policy on a bucket
named after the domain, and that shape costs TLS: an S3 website endpoint does
not speak it, and the *.s3.amazonaws.com certificate does not match a dotted
bucket name, so the pretty hostname and HTTPS are mutually exclusive without
something in front. CloudFront is that something, and once it is there the
bucket has no reason to be public. All four public-access-block flags stay on
and the only principal is cloudfront.amazonaws.com, narrowed by SourceArn to
this one distribution. Without that condition the service principal admits any
distribution in any account.

The IAM half lands in aws-iam, where every principal in this account is
declared. mcp-beaver-s3 gets PutObject, GetObject, and ListBucket, and no
DeleteObject: publishing is the grant, unpublishing is not. Kai creates the
user by hand, so an import block adopts it rather than racing to create a
second one. Versioning makes the overwrite PutObject does allow recoverable.

terraform fmt, validate, and plan could not run in this session, so they are
still owed before apply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/infrastructure!873
No description provided.