From d04849b6d68cd5f65db0c919ceb3193daf475cd6 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 14:44:45 -0400 Subject: [PATCH] feat(static-site): serve the match reports from the site /reports/* routes to the api host through the site's own distribution, so a shared match is a lance.blue link. Its cache key includes the query string: a report is asked for from a point of view and in one of several card layouts, and Managed-CachingOptimized drops both. Origin 5xx are no longer cached. A report answers 503 while its match is still being fought, and CloudFront's ten second error floor would hold that past the point where the report exists. Co-Authored-By: Claude Opus 5 (1M context) --- TODO.md | 7 ++ envs/lance.blue/main.tf | 12 +++ modules/static-site/index-rewrite.js | 10 +++ modules/static-site/main.tf | 127 +++++++++++++++++++++++++++ modules/static-site/variables.tf | 6 ++ 5 files changed, 162 insertions(+) diff --git a/TODO.md b/TODO.md index 9f3f15b..7af1be0 100644 --- a/TODO.md +++ b/TODO.md @@ -31,6 +31,13 @@ compute of its own yet. and a 3.2GB peak. The task now runs 2 vCPU / 8GB, because arena's heap ceilings sum to 4.5GB. Revisit if arena's heaps come down — 8GB is headroom for a ceiling nothing has been observed to reach. +- [ ] **The reports behaviour has no health check of its own.** `/reports/*` + is a second origin on the site's distribution, so the api host being + down is now a hole in lance.blue rather than only in api.lance.blue. + CloudFront serves its cached copies through it - a finished match never + changes, so most reads are edge hits - but a cold URL 502s, and that + 502 is no longer cached. Whatever raises its hand for the api host + should raise it for this too. - [ ] **A Content-Security-Policy for the site.** The response headers policy covers HSTS, framing, sniffing and referrers, but a CSP needs a tested allowlist first: the inline theme script, PostHog, the Bluesky AppView diff --git a/envs/lance.blue/main.tf b/envs/lance.blue/main.tf index 4dddc60..725ff7e 100644 --- a/envs/lance.blue/main.tf +++ b/envs/lance.blue/main.tf @@ -163,6 +163,15 @@ module "api_host" { # flares are filed to; the api refuses a malformed value at startup. COOKIE_DOMAIN = var.domain_name FLARE_SPACE = "at://did:plc:a2j2g42ai6v65qpbvb6hmubi/app.userinput.space/3msr5yrvtq22g" + + # Where the public match reports are published. The distribution below + # routes /reports/ here, so the pages are read at the apex - and this is + # what makes them say so: their og:url, the address of their card, the + # link a player copies, and the address the in-match report hands a + # player on to. Without it they would be served from lance.blue and + # advertise api.lance.blue, which is the one thing the behaviour exists + # to avoid. + SHARE_ORIGIN = "https://${var.domain_name}" } tags = { Component = "api", Environment = local.env } @@ -182,6 +191,9 @@ module "static_site" { domain_name = var.domain_name zone_id = module.dns.zone_id origin_path = local.site_origin_path + # The match reports are served by the API under /reports/, through the + # site's own distribution, so a shared match is a lance.blue link. + reports_origin = "api.${var.domain_name}" providers = { aws = aws diff --git a/modules/static-site/index-rewrite.js b/modules/static-site/index-rewrite.js index ee58772..7593ec0 100644 --- a/modules/static-site/index-rewrite.js +++ b/modules/static-site/index-rewrite.js @@ -44,6 +44,16 @@ function handler(event) { }; } + // The match reports are served by the api host, not out of the bucket, so + // none of the directory mapping below applies to them: /reports/ is a + // page in its own right, and rewriting it to /reports//index.html + // asks that origin for something it has never heard of. Everything above + // this line still runs on them - which is the point of attaching this + // function to that behaviour at all, since the www redirect lives there. + if (uri.indexOf('/reports/') === 0) { + return request; + } + if (uri.endsWith('/')) { // "/blog/" and "/" both mean the index inside that directory. request.uri = uri + 'index.html'; diff --git a/modules/static-site/main.tf b/modules/static-site/main.tf index 83a1701..2065254 100644 --- a/modules/static-site/main.tf +++ b/modules/static-site/main.tf @@ -133,6 +133,66 @@ data "aws_cloudfront_cache_policy" "caching_optimized" { name = "Managed-CachingOptimized" } +# The reports' own cache key. +# +# Managed-CachingOptimized drops the query string, which is exactly wrong +# here: a match report is asked for from a point of view and in one of several +# card layouts - /reports/?perspective=@a&card=lance - and a key that +# ignores that serves whoever asks second the first one's card. +# +# TTLs come from the origin: a finished match never changes, and the pages say +# so in their own Cache-Control. +resource "aws_cloudfront_cache_policy" "reports" { + count = var.reports_origin == "" ? 0 : 1 + + name = "${var.name}-reports" + comment = "Match reports: cache on the path and the query string" + default_ttl = 3600 + min_ttl = 0 + max_ttl = 86400 + + parameters_in_cache_key_and_forwarded_to_origin { + enable_accept_encoding_brotli = true + enable_accept_encoding_gzip = true + + cookies_config { + # A report is public and sessionless. Forwarding cookies here would + # both fragment the cache and hand the origin a session it must not + # read from a page anyone can fetch. + cookie_behavior = "none" + } + + headers_config { + header_behavior = "none" + } + + query_strings_config { + query_string_behavior = "all" + } + } +} + +# What reaches the origin. The same shape as the key above: everything in the +# query string, nothing else. +resource "aws_cloudfront_origin_request_policy" "reports" { + count = var.reports_origin == "" ? 0 : 1 + + name = "${var.name}-reports" + comment = "Match reports: forward the query string and nothing else" + + cookies_config { + cookie_behavior = "none" + } + + headers_config { + header_behavior = "none" + } + + query_strings_config { + query_string_behavior = "all" + } +} + # Security headers on every response. HSTS covers the subdomains too - the api # host serves only HTTPS, so committing them all is safe. Framing is denied # outright: nothing embeds the site, and the match client lives on the api @@ -209,6 +269,26 @@ resource "aws_cloudfront_distribution" "site" { origin_access_control_id = aws_cloudfront_origin_access_control.site.id } + # The API, for the public match reports alone. It is a whole origin rather + # than a redirect because the point is the address: a match shared on + # Bluesky should be a lance.blue link, and an unfurler follows what it is + # given rather than a hop to somewhere else. + dynamic "origin" { + for_each = var.reports_origin == "" ? [] : [var.reports_origin] + + content { + domain_name = origin.value + origin_id = "reports" + + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + origin_ssl_protocols = ["TLSv1.2"] + } + } + } + default_cache_behavior { target_origin_id = "s3" viewer_protocol_policy = "redirect-to-https" @@ -224,6 +304,37 @@ resource "aws_cloudfront_distribution" "site" { } } + # Everything the public match reports are made of: the page, its card, and + # the silhouettes the card is drawn with. One prefix, one behaviour. + # + # The viewer function runs here too, for its www redirect: without it, a + # report reached at www. is served there while its own og:url says + # the apex. It leaves /reports/ paths otherwise alone - the directory + # mapping that serves the site's pages would ask this origin for + # /reports//index.html, which is not a thing it has. + # + # Reports are read, so GET and HEAD only. + dynamic "ordered_cache_behavior" { + for_each = var.reports_origin == "" ? [] : [var.reports_origin] + + content { + path_pattern = "/reports/*" + target_origin_id = "reports" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = aws_cloudfront_cache_policy.reports[0].id + origin_request_policy_id = aws_cloudfront_origin_request_policy.reports[0].id + response_headers_policy_id = aws_cloudfront_response_headers_policy.site.id + + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.index_rewrite.arn + } + } + } + # A path with no page behind it is a real 404: the router is hash-based and # every path the site serves is a prerendered file, so nothing needs the # old serve-index-with-200 fallback, which also hid every miss from the @@ -245,6 +356,22 @@ resource "aws_cloudfront_distribution" "site" { response_page_path = "/404.html" } + # Do not cache origin errors. CloudFront otherwise holds a 5xx for ten + # seconds even when the origin says not to, which outlives what caused it: + # a match report answers 503 while the match is still being fought, and a + # cached one is a link that stays un-previewable after the report exists. + # + # No response_page_path, so the origin's own status and body are passed + # through - the 503 carries the page that says which case it is. + dynamic "custom_error_response" { + for_each = toset([500, 502, 503, 504]) + + content { + error_code = custom_error_response.value + error_caching_min_ttl = 0 + } + } + restrictions { geo_restriction { restriction_type = "none" diff --git a/modules/static-site/variables.tf b/modules/static-site/variables.tf index 58223ae..2a6516c 100644 --- a/modules/static-site/variables.tf +++ b/modules/static-site/variables.tf @@ -24,6 +24,12 @@ variable "origin_path" { default = "" } +variable "reports_origin" { + description = "Host that serves the public match reports under /reports/, e.g. api.lance.blue. Empty leaves that prefix to the bucket, which has nothing under it." + type = string + default = "" +} + variable "tags" { description = "Tags for every resource here." type = map(string) -- 2.51.2