S3¶
tomoda uses S3 as the durable origin for every static asset served from assets[-dev].tomoda.life. There is one bucket per environment, both in us-east-2, both completely private. The only entity allowed to read from them is the matching CloudFront distribution, signing requests via Origin Access Control (OAC).
Buckets¶
| Environment | Bucket name | Public domain |
|---|---|---|
| dev | tomoda-assets-dev-373544523193 |
https://assets-dev.tomoda.life |
| prod | tomoda-assets-prod-<account-id> |
https://assets.tomoda.life |
Bucket naming is derived in s3.tf, with an account-ID suffix so the globally-unique name can't collide with a bucket in another account:
locals {
bucket_name = "${var.project_name}-assets-${var.environment}-${data.aws_caller_identity.current.account_id}"
}
Account migration in progress
The account-ID suffix and a disabled custom domain are temporary states from the AWS account migration (old account 209695252502 → new 373544523193). The public domains in the table above are the target; dev currently serves on the default *.cloudfront.net name. Migration open items are tracked in _temp-docs/aws-migration-todo.md in the repo root.
force_destroy is gated by environment: var.environment != "prod". Dev gets true (convenient for tear-down/rebuild); prod gets false (Terraform will refuse to destroy the bucket while it has objects). To intentionally destroy the prod bucket, you must first empty it (aws s3 rm s3://tomoda-assets-prod --recursive) — there is no single-step accident path.
Public access — fully blocked¶
Every bucket-level public-access knob is on (s3.tf):
resource "aws_s3_bucket_public_access_block" "static_assets" {
block_public_acls = true
ignore_public_acls = true
block_public_policy = true
restrict_public_buckets = true
}
Direct S3 URLs return 403:
curl -I https://tomoda-assets-prod.s3.us-east-2.amazonaws.com/test.jpg
# HTTP/1.1 403 Forbidden
The only path that resolves an object is via CloudFront on the custom domain.
CloudFront origin policy¶
The bucket policy grants s3:GetObject to the CloudFront service principal, scoped by AWS:SourceArn to the exact distribution for that environment. This is the OAC pattern — CloudFront signs origin requests with SigV4 and AWS evaluates them against this statement:
data "aws_iam_policy_document" "s3_policy" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.static_assets.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.s3_distribution.arn]
}
}
}
No IAM user, no AWS account, and no other CloudFront distribution can read from the bucket. The matching OAC resource lives in cloudfront.tf — see CloudFront.
Encryption¶
Encryption-at-rest uses AWS-managed keys (the default applied to every new S3 bucket since January 2023). There is no explicit aws_s3_bucket_server_side_encryption_configuration block in s3.tf because the default SSE-S3 behaviour is sufficient for these assets — they are not regulated data and the recovery model relies on the originating tomoda backend keeping its own copies, not on bucket-side cryptography.
Key prefixes¶
The bucket layout is governed by the tomoda backend's AssetService — see tomoda's storage doc for the authoritative (kind, prefix, MIME allowlist) table. From the bucket's perspective, the 10 prefixes split into two ownership tiers:
| Prefix | Ownership | App-managed cascade |
|---|---|---|
avatars/ |
User profile | Replace on rotation; delete on user purge |
group-avatars/ |
Chat room | Replace on rotation; delete on room purge |
chat-images/ |
Chat message | Sender delete (everywhere) + disappearing-message TTL |
moments/ |
Moment | Hard-delete on moment purge — ref-counted via moment_tags (post spec 15) |
event-covers/ |
Event | Hard-delete on event purge |
user-stamps/ |
UserStamp (StampMint asset) | Hard-delete when last stamp_mint_holders row drops |
user-stamps-raw/ |
UserStamp (StampMint raw photo) | Same as user-stamps/ |
stamps/ |
TomodaStamp (curated) | Permanent — no user-facing delete |
stamps/bespoke/ |
TomodaStamp (curated bespoke variant) | Permanent |
curios/ |
Curio (curated) | Permanent |
The app owns every cascade — S3 lifecycle rules only handle multipart hygiene and the moments/ storage-class transition.
Object lifecycle¶
Lifecycle config lives in s3.tf (aws_s3_bucket_lifecycle_configuration.static_assets). Two rules today:
| Rule | Scope | Action |
|---|---|---|
abort-incomplete-multipart |
All prefixes | Abort incomplete multipart uploads after 24h. Abandoned multiparts otherwise live forever and silently inflate the bill. |
moments-to-ia |
moments/ only |
Transition to STANDARD_IA after 30d. Chronological discovery falls off fast; older moments are read rarely (passport / explicit profile scroll) so IA's per-GB retrieval cost is amortised against the cheaper storage. |
Anything else stays STANDARD indefinitely. For curated assets (stamps/, stamps/bespoke/, curios/) that's the right answer — they're permanent. For user assets without a transition (avatars, chat-images, event-covers, user-stamps), the app's cascade-delete is the cost control; an IA transition would just add retrieval cost without saving much given the volumes.
Versioning¶
S3 object versioning is disabled (no aws_s3_bucket_versioning resource in s3.tf). The asset model is append-only with filename-based versioning: when the backend uploads a new avatar or chat image, it writes to a new key derived from the content hash or timestamp. Previous versions are explicitly deleted by the app's cascade-delete path — see the Writes and deletes section.
CORS¶
Verify the current CORS configuration directly in s3.tf before relying on cross-origin reads. At the time of writing, no aws_s3_bucket_cors_configuration resource is present — assets are served exclusively through the CloudFront domain on the same TLD (tomoda.life), so cross-origin reads from app.tomoda.life to assets.tomoda.life are treated as cross-subdomain rather than cross-origin in most asset use cases. If a future feature requires explicit CORS headers (e.g. fetching JSON via XHR with credentials), they should be added at the CloudFront response-headers-policy layer or as an S3 CORS rule, but neither exists today.
Writes and deletes¶
The backend writes and deletes to S3 using the tomoda-uploader-{env} IAM user — see IAM for the credential surface and how External Secrets Operator delivers the access key into the cluster.
Policy actions granted in iam_uploader.tf:
s3:PutObject/s3:PutObjectAcl— uploads (multipart-aware vias3/manager.Uploader).s3:DeleteObject— cascade-delete from the app. Hard-deleted moments / chat-messages / event-covers / un-tagged stamp mints enqueue an asynqcleanup:s3_deletetask that calls this.s3:ListBucket— used only by the weekly orphan-sweep cron (cleanup:s3_orphan_sweep) to reconcile S3 against the DB and re-queue deletes for objects with no DB row.
The matching AssetService + cleanup handlers live in the tomoda repo. If you see objects in S3 with no matching DB row, check the asynq archived queue in the admin DLQ inspector — terminal failures land there for manual replay.