Skip to content

feat(website): resolve pretty URLs via CloudFront KVS, drop per-page terraform apply#1902

Merged
ytallo merged 1 commit into
mainfrom
fix/mot-3669-pretty-urls-via-kvs
Jun 29, 2026
Merged

feat(website): resolve pretty URLs via CloudFront KVS, drop per-page terraform apply#1902
ytallo merged 1 commit into
mainfrom
fix/mot-3669-pretty-urls-via-kvs

Conversation

@ytallo

@ytallo ytallo commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Supersedes #1900 (closed automatically when the branch was renamed). Same diff.

Problem (MOT-3669)

Adding a top-level page like /privacy-policy required two things on two different pipelines:

Where it lives How it ships When live
privacy-policy.html website/ → S3 deploy-website.yml aws s3 syncautomatic on push to main minutes after merge
/privacy-policy/privacy-policy.html rewrite hardcoded htmlPretty map in the redirects CloudFront Function terraform apply (publishes the function) — manual, no apply automation only after someone applies

So the .html shipped automatically, but the clean-URL rewrite didn't. Until the manual apply ran, /privacy-policy fell through to the real-404 branch and 404'd — which is why the footer link had to be pointed at the literal /privacy-policy.html as a workaround.

Fix

Move the pretty-URL → .html map out of the function source and into a CloudFront KeyValueStore (KVS). The function reads it at the edge (async); deploy-website.yml syncs it from website/*.html on every content deploy — same automatic pipeline as the HTML.

Adding a page is now a content-only change: drop foo.html, link to /foo, merge. The clean URL resolves on the next deploy. No terraform apply.

Changes

  • redirects.jsasync handler; KVS lookup replaces the hardcoded map. The pretty-URL rewrite and the real-404 fallback are unified into a single extensionless-path branch, so /, /blog/*, and *.html never incur a KVS read. The notFound() SEO behavior is preserved (KVS miss → real 404).
  • redirects.test.js — loader strips the import cf from 'cloudfront' line and injects a mock cf.kvs(); tests are awaited; +6 KVS cases (hit, miss→404, verbatim value, empty store, www precedence). 46/46 pass.
  • routes-kvs.ts (+ test) — derives the desired map from website/*.html (excludes index.html) and diffs it against the store into a batch {Puts, Deletes}. 6 unit tests.
  • deploy-website.yml — after the HTML sync (so a key never points at a missing object), describe → list → diff → update-keys. Gated on the CF_KVS_ARN repo var, so it no-ops until that's set. concurrency: deploy-website serializes deploys, keeping the describe-time ETag valid.
  • Terraformaws_cloudfront_key_value_store.routes + function association, a least-privilege IAM data-plane grant on the deploy role (Describe/ListKeys/UpdateKeys), and a routes_kvs_arn output. terraform validate passes. Terraform owns the store; the pipeline owns the data (the AWS provider has no key resource — data is decoupled by design).

Rollout (one-time)

Terraform can't seed KVS data, so the first rollout populates the store out-of-band. To avoid a 404 window on /manifesto + /privacy-policy, apply in two phases:

  1. Apply 1 — create the KVS + IAM grant only (temporarily comment out key_value_store_associations so the function keeps its current behavior).
  2. Populate — set repo var CF_KVS_ARN = terraform output -raw routes_kvs_arn, then trigger deploy-website.yml (or run its KVS step locally) to seed /manifesto + /privacy-policy.
  3. Apply 2 — re-add key_value_store_associations and apply; the function now reads a populated store. No 404 window.
  4. Revert the footer "privacy" link from /privacy-policy.html back to /privacy-policy.

(If a brief 404 on those two secondary pages is acceptable, collapse to a single apply + immediate deploy.)

Test plan

  • redirects.test.js — 46/46 pass (40 existing behaviors unchanged + 6 KVS cases)
  • routes-kvs.test.ts — 6/6 pass; generator verified against real website/*.html and against empty / stale-key stores
  • terraform fmt (clean) + terraform validate (success)
  • Post-apply smoke: curl -I https://iii.dev/privacy-policy → 200; unknown extensionless path → 404
  • Deploy a throwaway test-page.html and confirm /test-page resolves with no apply

Note: an unrelated pre-existing test failure (blog-posts.test.ts "finds hello-world") exists on main — there's no hello-world.md in the local blog content; this PR touches no blog files.

Closes MOT-3669.

https://claude.ai/code/session_01N9Xi9vXLynC4JsaCwPJhVq

Summary by CodeRabbit

  • Improvements
    • Enhanced website routing using CloudFront KeyValueStore for clean (“pretty”) URLs, improving resolution of extensionless pages and delivering real 404 responses when routes are missing.
    • Deployment now automatically syncs the route mapping during website publishing, ensuring the routing data stays consistent with the uploaded HTML.
  • Infrastructure
    • Updated CloudFront configuration and permissions to support KeyValueStore-based route lookups, including a new exported KVS ARN for deployment.
  • Tests
    • Expanded unit coverage for routing, redirects, querystring preservation, and KeyValueStore miss/hit behavior.

@vercel

vercel Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
iii-website Ready Ready Preview, Comment Jun 29, 2026 1:32pm
tech-spec Error Error Jun 29, 2026 1:32pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 12902ebe-9f83-4430-b597-d6632a823c35

📥 Commits

Reviewing files that changed from the base of the PR and between d1e7b93 and 186cc24.

📒 Files selected for processing (1)
  • .github/workflows/deploy-website.yml
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/deploy-website.yml

📝 Walkthrough

Walkthrough

Adds a CloudFront KeyValueStore for pretty-URL route mappings, updates the CloudFront Function to read from it, adds a route-diff script, provisions supporting Terraform resources and IAM, and syncs the store during website deploys.

Changes

CloudFront KVS-backed pretty-URL routing

Layer / File(s) Summary
Route-diff script and tests
website/scripts/routes-kvs.ts, website/scripts/routes-kvs.test.ts
Defines the route entry and batch update types, implements route discovery, diff computation, and CLI execution for syncing routes, and covers mapping and diff behavior in tests.
CloudFront Function KVS lookup and routing tests
infra/terraform/website/cloudfront_functions/redirects.js, infra/terraform/website/cloudfront_functions/redirects.test.js
Adds CloudFront KeyValueStore access to the redirects function, switches extensionless pretty-URL handling from a hardcoded map to KVS lookups with 404 fallback, and updates the test harness and routing coverage for the new behavior.
Terraform KVS resource, function association, IAM, and output
infra/terraform/website/cloudfront.tf, infra/terraform/website/iam_github_oidc.tf, infra/terraform/website/outputs.tf
Provisions the CloudFront KeyValueStore, associates it with the redirects function, grants the deploy role permission to manage route keys, and exports the KVS ARN for deployment configuration.
Deploy workflow KVS synchronization
.github/workflows/deploy-website.yml
Adds a GitHub Actions step that reads the current KVS state, computes route changes with the new script, and applies the resulting key updates with concurrency checks.

Sequence Diagram(s)

sequenceDiagram
  participant GHA as GitHub Actions
  participant AWS as AWS CLI
  participant KVS as CloudFront KVS
  GHA->>AWS: describe-key-value-store (ETag)
  AWS-->>GHA: ETag
  GHA->>AWS: list-keys → current snapshot
  GHA->>GHA: routes-kvs.ts --current snapshot
  GHA->>AWS: update-keys --if-match ETag
  AWS-->>KVS: apply Puts and Deletes
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • iii-hq/iii#1470: Modifies the same redirects.js CloudFront Function and deploy workflow, intersecting directly with the routing and deployment changes in this PR.
  • iii-hq/iii#1605: Adds /blog/* trailing-slash redirect/rewrite logic to the same redirects.js handler and redirects.test.js test file that this PR converts to async KVS lookup.
  • iii-hq/iii#1576: Modifies the same redirects.js extensionless pretty-URL handling and www querystring/redirect behavior that this PR replaces with KVS-backed routing.

Suggested reviewers

  • sergiofilhowz

Poem

🐇 A bunny hopped to the KVS gate,
Pretty URLs now arrive on time and straight.
No more hardcoded paths in sight,
Just synced little routes, neat andाइट!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: moving pretty-URL resolution to CloudFront KVS and removing per-page Terraform applies.
Description check ✅ Passed It includes the needed problem, fix, rollout, and test details via equivalent sections, so it is mostly complete.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/mot-3669-pretty-urls-via-kvs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Terraform plan — infra/terraform/website

Click to expand
data.aws_route53_zone.iii_dev: Reading...
data.aws_caller_identity.current: Reading...
aws_s3_bucket.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_acm_certificate.site: Refreshing state... [id=arn:aws:acm:us-east-1:600627348446:certificate/b8e26c06-08b6-4b90-bd59-1f83bc231db2]
aws_cloudfront_origin_access_control.site: Refreshing state... [id=E3GRB5YVVA4332]
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::600627348446:oidc-provider/token.actions.githubusercontent.com]
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms]
aws_cloudfront_response_headers_policy.site: Refreshing state... [id=033b1d66-8d53-48a1-a817-8280d8a60114]
data.aws_caller_identity.current: Read complete after 1s [id=600627348446]
aws_cloudfront_function.redirects: Refreshing state... [id=iii-website-prod-redirects]
data.aws_iam_policy_document.github_trust: Reading...
data.aws_iam_policy_document.github_tf_plan_trust: Reading...
data.aws_iam_policy_document.github_trust: Read complete after 0s [id=932063200]
data.aws_iam_policy_document.github_tf_plan_trust: Read complete after 0s [id=2702237784]
aws_iam_role.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy]
aws_iam_role.github_tf_plan: Refreshing state... [id=iii-infra-github-tf-plan]
aws_sns_topic_subscription.email: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms:0ef0815c-13c7-4db5-b2d6-f544a17e3cdb]
data.aws_route53_zone.iii_dev: Read complete after 1s [id=Z05516132AI1ZGB3NLC6D]
aws_cloudwatch_metric_alarm.acm_days_to_expiry: Refreshing state... [id=iii-website-prod-acm-days-to-expiry]
aws_route53_record.cert_validation["iii-preview.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__0e43dc21d86b26a03742fb15aa5b4558.iii-preview.iii.dev._CNAME]
aws_route53_record.cert_validation["www.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__2bb3298c0bcca2494fce5e36c3d1427d.www.iii.dev._CNAME]
aws_route53_record.cert_validation["iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__7853369f3aea55a5d242d7c68506980e.iii.dev._CNAME]
aws_iam_role_policy_attachment.github_tf_plan_readonly: Refreshing state... [id=iii-infra-github-tf-plan-20260414113923643200000001]
aws_s3_bucket_public_access_block.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_server_side_encryption_configuration.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_versioning.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_acm_certificate_validation.site: Refreshing state... [id=2026-04-14 00:00:29.305 +0000 UTC]
aws_cloudfront_distribution.site: Refreshing state... [id=E3N35RPQE4YVFQ]
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
aws_iam_policy.github_deploy_website: Refreshing state... [id=arn:aws:iam::600627348446:policy/iii-website-prod-github-deploy]
data.aws_iam_policy_document.site_bucket: Reading...
aws_route53_record.preview_aaaa: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_AAAA]
aws_cloudwatch_metric_alarm.cf_5xx_rate: Refreshing state... [id=iii-website-prod-cf-5xx-rate]
aws_route53_record.apex_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_A]
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
aws_route53_record.apex_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_AAAA]
data.aws_iam_policy_document.site_bucket: Read complete after 0s [id=2990320740]
aws_route53_record.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
aws_s3_bucket_policy.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_iam_role_policy_attachment.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy-20260414000337121000000003]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
  - destroy
 <= read (data resources)

Terraform will perform the following actions:

  # data.aws_iam_policy_document.github_deploy_website will be read during apply
  # (config refers to values not yet known)
 <= data "aws_iam_policy_document" "github_deploy_website" {
      + id            = (known after apply)
      + json          = (known after apply)
      + minified_json = (known after apply)

      + statement {
          + actions   = [
              + "s3:GetBucketLocation",
              + "s3:ListBucket",
            ]
          + effect    = "Allow"
          + resources = [
              + "arn:aws:s3:::iii-website-prod-us-east-1",
            ]
          + sid       = "ListBucket"
        }
      + statement {
          + actions   = [
              + "s3:DeleteObject",
              + "s3:GetObject",
              + "s3:PutObject",
            ]
          + effect    = "Allow"
          + resources = [
              + "arn:aws:s3:::iii-website-prod-us-east-1/*",
            ]
          + sid       = "ReadWriteObjects"
        }
      + statement {
          + actions   = [
              + "cloudfront:CreateInvalidation",
              + "cloudfront:GetInvalidation",
              + "cloudfront:ListInvalidations",
            ]
          + effect    = "Allow"
          + resources = [
              + "arn:aws:cloudfront::600627348446:distribution/E3N35RPQE4YVFQ",
            ]
          + sid       = "CloudFrontInvalidation"
        }
      + statement {
          + actions   = [
              + "cloudfront-keyvaluestore:DescribeKeyValueStore",
              + "cloudfront-keyvaluestore:ListKeys",
              + "cloudfront-keyvaluestore:UpdateKeys",
            ]
          + effect    = "Allow"
          + resources = [
              + (known after apply),
            ]
          + sid       = "CloudFrontKeyValueStoreSync"
        }
    }

  # aws_cloudfront_function.redirects will be updated in-place
  ~ resource "aws_cloudfront_function" "redirects" {
      ~ code                         = <<-EOT
            // Viewer-request handler for the default (S3) behavior. Tested in redirects.test.js.
            
          + import cf from 'cloudfront'
          + 
            function redirect(location) {
              return {
                statusCode: 301,
                statusDescription: 'Moved Permanently',
                headers: {
                  location: { value: location },
                  'cache-control': { value: 'public, max-age=3600' },
                },
              }
            }
            
          + // Return a real 404 for unknown extensionless paths instead of the previous
          + // SPA fallback to /index.html. The old behavior served homepage HTML with
          + // status 200 for any unknown path (e.g. /workers/iii-queue from broken docs
          + // links), which Google indexed as soft-duplicate-of-homepage. Returning 404
          + // here lets Search Console's "Not found (404)" bucket reflect reality and
          + // stops generating "Duplicate without user-selected canonical" entries.
          + function notFound() {
          +   return {
          +     statusCode: 404,
          +     statusDescription: 'Not Found',
          +     headers: {
          +       'content-type': { value: 'text/html; charset=utf-8' },
          +       'cache-control': { value: 'public, max-age=300' },
          +     },
          +     body: '<!doctype html><meta charset="utf-8"><title>404 — iii</title><meta name="robots" content="noindex"><style>body{font-family:system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem;color:#1a1a1a}a{color:#1a5fbf}</style><h1>404 — page not found</h1><p>That URL doesn\'t exist on iii.dev.</p><p><a href="/">home</a> · <a href="/docs">docs</a> · <a href="/blog/">blog</a> · <a href="/manifesto">manifesto</a></p>',
          +   }
          + }
          + 
            // CloudFront Functions deliver request.querystring as
            //   { key: { value: string, multiValue?: [{ value: string }, ...] } }
            // where repeated params spill into multiValue. We re-encode and rejoin so the
            // host-redirect path below preserves the original query (otherwise `?a=1&a=2`
            // would silently drop on the 301).
            function serializeQuerystring(qs) {
              if (!qs) return ''
              var parts = []
              for (var key in qs) {
                if (!Object.prototype.hasOwnProperty.call(qs, key)) continue
                var entry = qs[key]
                if (!entry) continue
                var encodedKey = encodeURIComponent(key)
                var primary = entry.value == null ? '' : entry.value
                parts.push(encodedKey + '=' + encodeURIComponent(primary))
                if (entry.multiValue && entry.multiValue.length) {
                  for (var i = 0; i < entry.multiValue.length; i++) {
                    var extra = entry.multiValue[i]
                    var extraValue = extra && extra.value != null ? extra.value : ''
                    parts.push(encodedKey + '=' + encodeURIComponent(extraValue))
                  }
                }
              }
              return parts.length ? '?' + parts.join('&') : ''
            }
            
          + // Pretty-URL route map (/<page> → /<page>.html) lives in a CloudFront
          + // KeyValueStore, populated by deploy-website.yml from the set of website/*.html
          + // files. Adding a page needs no code change and no `terraform apply` — see
          + // infra/terraform/website/README.md. The function is associated with exactly
          + // one store, so cf.kvs() needs no store id.
          + var routes = cf.kvs()
          + 
            // biome-ignore lint/correctness/noUnusedVariables: CloudFront Function entry point
            // biome-ignore lint/complexity/useOptionalChain: cloudfront-js-2.0 does NOT support optional chaining
          - function handler(event) {
          + async function handler(event) {
              var request = event.request
              var uri = request.uri
              var host = request.headers && request.headers.host ? request.headers.host.value : undefined
            
              if (host === 'www.iii.dev') {
                return redirect(`https://iii.dev${uri}${serializeQuerystring(request.querystring)}`)
              }
            
              if (uri.indexOf('/.well-known/') === 0) return request
            
          -   // Pretty URLs → matching *.html objects in S3 (Option A). Add a key when you
          -   // ship a new top-level page as `pagename.html`.
          -   var htmlPretty = {
          -     '/manifesto': '/manifesto.html',
          -   }
          -   var htmlTarget = htmlPretty[uri]
          -   if (htmlTarget !== undefined) {
          -     request.uri = htmlTarget
          -     return request
          -   }
          - 
              // /blog/* — Astro emits build.format: 'directory' with trailingSlash:
              // 'always', so canonical URLs are /blog/<slug>/. CloudFront's
              // default_root_object only applies to the apex, so we rewrite directory
              // URLs to .../index.html and 301 extensionless paths to the canonical
              // trailing-slash form. Must run before the SPA fallback so /blog/<slug>
              // doesn't get hijacked into /index.html.
              var redirectHost = host || 'iii.dev'
              if (uri === '/blog') {
                return redirect('https://' + redirectHost + '/blog/' + serializeQuerystring(request.querystring))
              }
              if (uri.indexOf('/blog/') === 0) {
                if (uri.charAt(uri.length - 1) === '/') {
                  request.uri = uri + 'index.html'
                  return request
                }
                var lastSlashB = uri.lastIndexOf('/')
                var lastSegmentB = uri.substring(lastSlashB + 1)
                if (lastSegmentB.indexOf('.') === -1) {
                  return redirect('https://' + redirectHost + uri + '/' + serializeQuerystring(request.querystring))
                }
                return request
              }
            
          -   // SPA fallback: extensionless path not ending in /
          +   // Extensionless top-level paths resolve to a pretty page via the KVS route
          +   // map (e.g. /privacy-policy → /privacy-policy.html), or return a real 404.
          +   // Previously the map was hardcoded here, so a new page needed a `terraform
          +   // apply` to republish the function; it now lives in a CloudFront
          +   // KeyValueStore that deploy-website.yml syncs from website/*.html, making a
          +   // new page a content-only change. See notFound() for why unknown paths 404
          +   // instead of falling back to /index.html.
              if (uri !== '/' && uri.charAt(uri.length - 1) !== '/') {
                const lastSlash = uri.lastIndexOf('/')
                const lastSegment = uri.substring(lastSlash + 1)
                if (lastSegment.indexOf('.') === -1) {
          -       request.uri = '/index.html'
          -       return request
          +       try {
          +         request.uri = await routes.get(uri)
          +         return request
          +       } catch (err) {
          +         return notFound()
          +       }
                }
              }
            
              return request
            }
        EOT
      ~ comment                      = "viewer-request (default behavior only): www->apex, SPA fallback" -> "viewer-request (default behavior only): www->apex, pretty-URL rewrite via KVS, real 404"
        id                           = "iii-website-prod-redirects"
      ~ key_value_store_associations = [] -> (known after apply)
        name                         = "iii-website-prod-redirects"
        # (6 unchanged attributes hidden)
    }

  # aws_cloudfront_key_value_store.routes will be created
  + resource "aws_cloudfront_key_value_store" "routes" {
      + arn                = (known after apply)
      + comment            = "Pretty-URL → .html map for the redirects function; data synced by deploy-website.yml, not Terraform"
      + etag               = (known after apply)
      + id                 = (known after apply)
      + last_modified_time = (known after apply)
      + name               = "iii-website-prod-routes"
    }

  # aws_iam_policy.github_deploy_website will be updated in-place
  ~ resource "aws_iam_policy" "github_deploy_website" {
        id               = "arn:aws:iam::600627348446:policy/iii-website-prod-github-deploy"
        name             = "iii-website-prod-github-deploy"
      ~ policy           = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "s3:ListBucket",
                          - "s3:GetBucketLocation",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:s3:::iii-website-prod-us-east-1"
                      - Sid      = "ListBucket"
                    },
                  - {
                      - Action   = [
                          - "s3:PutObject",
                          - "s3:GetObject",
                          - "s3:DeleteObject",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:s3:::iii-website-prod-us-east-1/*"
                      - Sid      = "ReadWriteObjects"
                    },
                  - {
                      - Action   = [
                          - "cloudfront:ListInvalidations",
                          - "cloudfront:GetInvalidation",
                          - "cloudfront:CreateInvalidation",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:cloudfront::600627348446:distribution/E3N35RPQE4YVFQ"
                      - Sid      = "CloudFrontInvalidation"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> (known after apply)
        tags             = {}
        # (7 unchanged attributes hidden)
    }

  # aws_route53_record.apex_a[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "apex_a" {
      - fqdn                             = "iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_iii.dev_A" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "A" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.apex_aaaa[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "apex_aaaa" {
      - fqdn                             = "iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_iii.dev_AAAA" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "AAAA" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.www_a[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "www_a" {
      - fqdn                             = "www.iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_www.iii.dev_A" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "www.iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "A" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.www_aaaa[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "www_aaaa" {
      - fqdn                             = "www.iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "www.iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "AAAA" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_sns_topic_subscription.email will be created
  + resource "aws_sns_topic_subscription" "email" {
      + arn                             = (known after apply)
      + confirmation_timeout_in_minutes = 1
      + confirmation_was_authenticated  = (known after apply)
      + endpoint                        = "devops@motia.dev"
      + endpoint_auto_confirms          = false
      + filter_policy_scope             = (known after apply)
      + id                              = (known after apply)
      + owner_id                        = (known after apply)
      + pending_confirmation            = (known after apply)
      + protocol                        = "email"
      + raw_message_delivery            = false
      + topic_arn                       = "arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms"
    }

Plan: 2 to add, 2 to change, 4 to destroy.

Changes to Outputs:
  + routes_kvs_arn           = (known after apply)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
.github/workflows/deploy-website.yml (1)

101-103: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Fail fast when CF_KVS_ARN is missing in the production deploy job.

At Line 102, skipping the sync step silently can leave the route map stale/empty while the deploy still reports success, reintroducing extensionless-route 404 drift.

💡 Proposed hardening
+      - name: Verify CloudFront KVS config
+        run: |
+          if [ -z "${{ vars.CF_KVS_ARN }}" ]; then
+            echo "CF_KVS_ARN is required for deploy-website in iii-website-prod."
+            exit 1
+          fi
+
       - name: Sync pretty-URL route map to CloudFront KeyValueStore
-        if: ${{ vars.CF_KVS_ARN != '' }}
         env:
           KVS_ARN: ${{ vars.CF_KVS_ARN }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy-website.yml around lines 101 - 103, The "Sync
pretty-URL route map to CloudFront KeyValueStore" step currently silently skips
when CF_KVS_ARN is missing, allowing the deploy job to report success despite
this critical step not executing. Replace the conditional skip behavior with a
fail-fast approach by adding a check that explicitly fails the job when
CF_KVS_ARN is not set in production deployments. This ensures the route map is
always synced for production or the job fails visibly, preventing stale/empty
route configurations and extensionless-route 404 drift.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@infra/terraform/website/cloudfront_functions/redirects.js`:
- Around line 114-118: The catch block around routes.get(uri) currently returns
notFound() for all errors, which treats both missing keys and KVS service
failures identically by returning a 404. This causes false 404s when the KVS
service fails. Modify the catch block to return the request object as-is (with
the original URI unchanged) instead of calling notFound(), allowing requests to
proceed gracefully when KVS errors occur, while still returning a proper 404
only when appropriate.

---

Nitpick comments:
In @.github/workflows/deploy-website.yml:
- Around line 101-103: The "Sync pretty-URL route map to CloudFront
KeyValueStore" step currently silently skips when CF_KVS_ARN is missing,
allowing the deploy job to report success despite this critical step not
executing. Replace the conditional skip behavior with a fail-fast approach by
adding a check that explicitly fails the job when CF_KVS_ARN is not set in
production deployments. This ensures the route map is always synced for
production or the job fails visibly, preventing stale/empty route configurations
and extensionless-route 404 drift.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 59a25a45-197b-493a-ac06-a58d195ecb18

📥 Commits

Reviewing files that changed from the base of the PR and between 55c9653 and d1e7b93.

📒 Files selected for processing (8)
  • .github/workflows/deploy-website.yml
  • infra/terraform/website/cloudfront.tf
  • infra/terraform/website/cloudfront_functions/redirects.js
  • infra/terraform/website/cloudfront_functions/redirects.test.js
  • infra/terraform/website/iam_github_oidc.tf
  • infra/terraform/website/outputs.tf
  • website/scripts/routes-kvs.test.ts
  • website/scripts/routes-kvs.ts

Comment on lines +114 to +118
try {
request.uri = await routes.get(uri)
return request
} catch (err) {
return notFound()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For CloudFront Functions (cloudfront-js-2.0), what is the documented error shape/message from cf.kvs().get(key) for a missing key, and how should non-miss service errors be handled?

💡 Result:

In CloudFront Functions (runtime cloudfront-js-2.0), the cf.kvs().get(key) method returns a promise that rejects when a requested key is not found in the associated Key Value Store [1]. The documented guidance for handling this is to wrap the get() call in a try...catch block [1][2][3]. AWS does not explicitly document a specific error "shape" or error code property for missing keys, implying that developers should treat any rejection of the promise as an indication of a failure to retrieve the value, which typically includes cases where the key does not exist [1][3][4]. Regarding non-miss service errors (such as connectivity issues or internal KVS errors), these are also caught by the same try...catch mechanism [1][3]. Best practices for handling these scenarios include: 1. Defensive Coding: Always use a try...catch block when calling get() to prevent the function from failing execution due to an unhandled promise rejection [1][2][4]. 2. Defaulting/Graceful Degradation: If a key is missing or an error occurs, the standard pattern is to log the error for observability and proceed with a sensible default value or allow the request to proceed without the KVS-derived data [2][3][4]. 3. Avoiding Combinators: AWS recommends avoiding the use of promise combinators (like Promise.all or Promise.any) for multiple KVS lookups, as these can lead to higher memory usage and potential execution failures [1]. Instead, use sequential await calls [1].

Citations:


🏁 Script executed:

# Check if there are other CloudFront Functions in the codebase using KVS or similar error handling patterns
fd '\.js$' infra/terraform/website/cloudfront_functions/ | head -20

Repository: iii-hq/iii

Length of output: 175


🏁 Script executed:

# Examine the full context of the redirects.js file to understand the complete logic
wc -l infra/terraform/website/cloudfront_functions/redirects.js

Repository: iii-hq/iii

Length of output: 116


🏁 Script executed:

# Get more context around the problematic lines to understand the overall flow
sed -n '100,130p' infra/terraform/website/cloudfront_functions/redirects.js

Repository: iii-hq/iii

Length of output: 945


Differentiate KVS key-miss from KVS runtime failures to avoid false 404s.

At line 117, the catch-all catch (err) { return notFound() } treats both missing keys and KVS/data-plane service failures the same way, returning a hard 404. While a 404 is appropriate for missing keys (per the stated intent), service failures should not trigger 404 responses; instead, requests should proceed with the original URI.

AWS CloudFront Functions documentation does not document a specific error code or message shape for missing keys, making error detection unreliable. The standard approach per AWS guidance is to fail gracefully by allowing the request to proceed without KVS-derived data when any error occurs.

💡 Proposed fix
     if (lastSegment.indexOf('.') === -1) {
       try {
         request.uri = await routes.get(uri)
         return request
       } catch (err) {
-        return notFound()
+        // KVS errors (missing keys or service failures) should fail open,
+        // not return false 404s. Allow the request to proceed without redirection.
+        return request
       }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
request.uri = await routes.get(uri)
return request
} catch (err) {
return notFound()
try {
request.uri = await routes.get(uri)
return request
} catch (err) {
// KVS errors (missing keys or service failures) should fail open,
// not return false 404s. Allow the request to proceed without redirection.
return request
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infra/terraform/website/cloudfront_functions/redirects.js` around lines 114 -
118, The catch block around routes.get(uri) currently returns notFound() for all
errors, which treats both missing keys and KVS service failures identically by
returning a 404. This causes false 404s when the KVS service fails. Modify the
catch block to return the request object as-is (with the original URI unchanged)
instead of calling notFound(), allowing requests to proceed gracefully when KVS
errors occur, while still returning a proper 404 only when appropriate.

…terraform apply

Adding a new top-level page required editing the hardcoded htmlPretty map in the
redirects CloudFront Function and running `terraform apply` to republish it — a
separate, manual pipeline from the automatic content deploy. Until that apply
ran, the clean URL 404'd, forcing links to the literal `.html` (MOT-3669).

Move the pretty-URL -> .html map into a CloudFront KeyValueStore that
deploy-website.yml syncs from website/*.html on every content deploy. The
function reads the store at the edge (async). Adding a page becomes a
content-only change: drop foo.html, link to /foo, merge — no terraform apply.

- redirects.js: async handler; KVS lookup replaces the hardcoded map and unifies
  the pretty-URL rewrite with the real-404 fallback into one extensionless branch
- redirects.test.js: inject a mock cf.kvs(); await handler; +6 KVS cases (46 pass)
- routes-kvs.ts (+test): derive the desired map from *.html, diff vs the store
- deploy-website.yml: batch update-keys step after the HTML sync (gated on CF_KVS_ARN)
- terraform: aws_cloudfront_key_value_store + function association, IAM data-plane
  grant, routes_kvs_arn output (one-time apply; validates clean)

Claude-Session: https://claude.ai/code/session_01N9Xi9vXLynC4JsaCwPJhVq
@github-actions

Copy link
Copy Markdown
Contributor

Terraform plan — infra/terraform/website

Click to expand
aws_s3_bucket.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_cloudfront_origin_access_control.site: Refreshing state... [id=E3GRB5YVVA4332]
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::600627348446:oidc-provider/token.actions.githubusercontent.com]
data.aws_route53_zone.iii_dev: Reading...
data.aws_caller_identity.current: Reading...
aws_cloudfront_response_headers_policy.site: Refreshing state... [id=033b1d66-8d53-48a1-a817-8280d8a60114]
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms]
aws_acm_certificate.site: Refreshing state... [id=arn:aws:acm:us-east-1:600627348446:certificate/b8e26c06-08b6-4b90-bd59-1f83bc231db2]
aws_cloudfront_function.redirects: Refreshing state... [id=iii-website-prod-redirects]
data.aws_caller_identity.current: Read complete after 0s [id=600627348446]
data.aws_iam_policy_document.github_trust: Reading...
data.aws_iam_policy_document.github_tf_plan_trust: Reading...
data.aws_iam_policy_document.github_trust: Read complete after 0s [id=932063200]
data.aws_iam_policy_document.github_tf_plan_trust: Read complete after 0s [id=2702237784]
aws_iam_role.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy]
aws_iam_role.github_tf_plan: Refreshing state... [id=iii-infra-github-tf-plan]
aws_sns_topic_subscription.email: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms:9ac7d89d-3735-493b-bce4-2b367f5c5211]
aws_cloudwatch_metric_alarm.acm_days_to_expiry: Refreshing state... [id=iii-website-prod-acm-days-to-expiry]
data.aws_route53_zone.iii_dev: Read complete after 1s [id=Z05516132AI1ZGB3NLC6D]
aws_route53_record.cert_validation["iii-preview.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__0e43dc21d86b26a03742fb15aa5b4558.iii-preview.iii.dev._CNAME]
aws_route53_record.cert_validation["iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__7853369f3aea55a5d242d7c68506980e.iii.dev._CNAME]
aws_route53_record.cert_validation["www.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__2bb3298c0bcca2494fce5e36c3d1427d.www.iii.dev._CNAME]
aws_iam_role_policy_attachment.github_tf_plan_readonly: Refreshing state... [id=iii-infra-github-tf-plan-20260414113923643200000001]
aws_acm_certificate_validation.site: Refreshing state... [id=2026-04-14 00:00:29.305 +0000 UTC]
aws_s3_bucket_public_access_block.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_versioning.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_s3_bucket_server_side_encryption_configuration.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_cloudfront_distribution.site: Refreshing state... [id=E3N35RPQE4YVFQ]
aws_route53_record.apex_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_A]
aws_cloudwatch_metric_alarm.cf_5xx_rate: Refreshing state... [id=iii-website-prod-cf-5xx-rate]
data.aws_iam_policy_document.site_bucket: Reading...
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
aws_iam_policy.github_deploy_website: Refreshing state... [id=arn:aws:iam::600627348446:policy/iii-website-prod-github-deploy]
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
aws_route53_record.preview_aaaa: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_AAAA]
data.aws_iam_policy_document.site_bucket: Read complete after 0s [id=2990320740]
aws_route53_record.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
aws_s3_bucket_policy.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_route53_record.apex_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_AAAA]
aws_iam_role_policy_attachment.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy-20260414000337121000000003]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
  - destroy
 <= read (data resources)

Terraform will perform the following actions:

  # data.aws_iam_policy_document.github_deploy_website will be read during apply
  # (config refers to values not yet known)
 <= data "aws_iam_policy_document" "github_deploy_website" {
      + id            = (known after apply)
      + json          = (known after apply)
      + minified_json = (known after apply)

      + statement {
          + actions   = [
              + "s3:GetBucketLocation",
              + "s3:ListBucket",
            ]
          + effect    = "Allow"
          + resources = [
              + "arn:aws:s3:::iii-website-prod-us-east-1",
            ]
          + sid       = "ListBucket"
        }
      + statement {
          + actions   = [
              + "s3:DeleteObject",
              + "s3:GetObject",
              + "s3:PutObject",
            ]
          + effect    = "Allow"
          + resources = [
              + "arn:aws:s3:::iii-website-prod-us-east-1/*",
            ]
          + sid       = "ReadWriteObjects"
        }
      + statement {
          + actions   = [
              + "cloudfront:CreateInvalidation",
              + "cloudfront:GetInvalidation",
              + "cloudfront:ListInvalidations",
            ]
          + effect    = "Allow"
          + resources = [
              + "arn:aws:cloudfront::600627348446:distribution/E3N35RPQE4YVFQ",
            ]
          + sid       = "CloudFrontInvalidation"
        }
      + statement {
          + actions   = [
              + "cloudfront-keyvaluestore:DescribeKeyValueStore",
              + "cloudfront-keyvaluestore:ListKeys",
              + "cloudfront-keyvaluestore:UpdateKeys",
            ]
          + effect    = "Allow"
          + resources = [
              + (known after apply),
            ]
          + sid       = "CloudFrontKeyValueStoreSync"
        }
    }

  # aws_cloudfront_function.redirects will be updated in-place
  ~ resource "aws_cloudfront_function" "redirects" {
      ~ code                         = <<-EOT
            // Viewer-request handler for the default (S3) behavior. Tested in redirects.test.js.
            
          + import cf from 'cloudfront'
          + 
            function redirect(location) {
              return {
                statusCode: 301,
                statusDescription: 'Moved Permanently',
                headers: {
                  location: { value: location },
                  'cache-control': { value: 'public, max-age=3600' },
                },
              }
            }
            
            // Return a real 404 for unknown extensionless paths instead of the previous
            // SPA fallback to /index.html. The old behavior served homepage HTML with
            // status 200 for any unknown path (e.g. /workers/iii-queue from broken docs
            // links), which Google indexed as soft-duplicate-of-homepage. Returning 404
            // here lets Search Console's "Not found (404)" bucket reflect reality and
            // stops generating "Duplicate without user-selected canonical" entries.
            function notFound() {
              return {
                statusCode: 404,
                statusDescription: 'Not Found',
                headers: {
                  'content-type': { value: 'text/html; charset=utf-8' },
                  'cache-control': { value: 'public, max-age=300' },
                },
                body: '<!doctype html><meta charset="utf-8"><title>404 — iii</title><meta name="robots" content="noindex"><style>body{font-family:system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem;color:#1a1a1a}a{color:#1a5fbf}</style><h1>404 — page not found</h1><p>That URL doesn\'t exist on iii.dev.</p><p><a href="/">home</a> · <a href="/docs">docs</a> · <a href="/blog/">blog</a> · <a href="/manifesto">manifesto</a></p>',
              }
            }
            
            // CloudFront Functions deliver request.querystring as
            //   { key: { value: string, multiValue?: [{ value: string }, ...] } }
            // where repeated params spill into multiValue. We re-encode and rejoin so the
            // host-redirect path below preserves the original query (otherwise `?a=1&a=2`
            // would silently drop on the 301).
            function serializeQuerystring(qs) {
              if (!qs) return ''
              var parts = []
              for (var key in qs) {
                if (!Object.prototype.hasOwnProperty.call(qs, key)) continue
                var entry = qs[key]
                if (!entry) continue
                var encodedKey = encodeURIComponent(key)
                var primary = entry.value == null ? '' : entry.value
                parts.push(encodedKey + '=' + encodeURIComponent(primary))
                if (entry.multiValue && entry.multiValue.length) {
                  for (var i = 0; i < entry.multiValue.length; i++) {
                    var extra = entry.multiValue[i]
                    var extraValue = extra && extra.value != null ? extra.value : ''
                    parts.push(encodedKey + '=' + encodeURIComponent(extraValue))
                  }
                }
              }
              return parts.length ? '?' + parts.join('&') : ''
            }
            
          + // Pretty-URL route map (/<page> → /<page>.html) lives in a CloudFront
          + // KeyValueStore, populated by deploy-website.yml from the set of website/*.html
          + // files. Adding a page needs no code change and no `terraform apply` — see
          + // infra/terraform/website/README.md. The function is associated with exactly
          + // one store, so cf.kvs() needs no store id.
          + var routes = cf.kvs()
          + 
            // biome-ignore lint/correctness/noUnusedVariables: CloudFront Function entry point
            // biome-ignore lint/complexity/useOptionalChain: cloudfront-js-2.0 does NOT support optional chaining
          - function handler(event) {
          + async function handler(event) {
              var request = event.request
              var uri = request.uri
              var host = request.headers && request.headers.host ? request.headers.host.value : undefined
            
              if (host === 'www.iii.dev') {
                return redirect(`https://iii.dev${uri}${serializeQuerystring(request.querystring)}`)
              }
            
              if (uri.indexOf('/.well-known/') === 0) return request
            
          -   // Pretty URLs → matching *.html objects in S3 (Option A). Add a key when you
          -   // ship a new top-level page as `pagename.html`.
          -   var htmlPretty = {
          -     '/manifesto': '/manifesto.html',
          -     '/privacy-policy': '/privacy-policy.html',
          -   }
          -   var htmlTarget = htmlPretty[uri]
          -   if (htmlTarget !== undefined) {
          -     request.uri = htmlTarget
          -     return request
          -   }
          - 
              // /blog/* — Astro emits build.format: 'directory' with trailingSlash:
              // 'always', so canonical URLs are /blog/<slug>/. CloudFront's
              // default_root_object only applies to the apex, so we rewrite directory
              // URLs to .../index.html and 301 extensionless paths to the canonical
              // trailing-slash form. Must run before the SPA fallback so /blog/<slug>
              // doesn't get hijacked into /index.html.
              var redirectHost = host || 'iii.dev'
              if (uri === '/blog') {
                return redirect('https://' + redirectHost + '/blog/' + serializeQuerystring(request.querystring))
              }
              if (uri.indexOf('/blog/') === 0) {
                if (uri.charAt(uri.length - 1) === '/') {
                  request.uri = uri + 'index.html'
                  return request
                }
                var lastSlashB = uri.lastIndexOf('/')
                var lastSegmentB = uri.substring(lastSlashB + 1)
                if (lastSegmentB.indexOf('.') === -1) {
                  return redirect('https://' + redirectHost + uri + '/' + serializeQuerystring(request.querystring))
                }
                return request
              }
            
          -   // Real 404 for extensionless paths not in the pretty-URL map. Previously
          -   // this rewrote to /index.html (soft-404 cloning the homepage) — see
          -   // notFound() comment above for the SEO impact.
          +   // Extensionless top-level paths resolve to a pretty page via the KVS route
          +   // map (e.g. /privacy-policy → /privacy-policy.html), or return a real 404.
          +   // Previously the map was hardcoded here, so a new page needed a `terraform
          +   // apply` to republish the function; it now lives in a CloudFront
          +   // KeyValueStore that deploy-website.yml syncs from website/*.html, making a
          +   // new page a content-only change. See notFound() for why unknown paths 404
          +   // instead of falling back to /index.html.
              if (uri !== '/' && uri.charAt(uri.length - 1) !== '/') {
                const lastSlash = uri.lastIndexOf('/')
                const lastSegment = uri.substring(lastSlash + 1)
                if (lastSegment.indexOf('.') === -1) {
          -       return notFound()
          +       try {
          +         request.uri = await routes.get(uri)
          +         return request
          +       } catch (err) {
          +         return notFound()
          +       }
                }
              }
            
              return request
            }
        EOT
      ~ comment                      = "viewer-request (default behavior only): www->apex, SPA fallback" -> "viewer-request (default behavior only): www->apex, pretty-URL rewrite via KVS, real 404"
        id                           = "iii-website-prod-redirects"
      ~ key_value_store_associations = [] -> (known after apply)
        name                         = "iii-website-prod-redirects"
        # (6 unchanged attributes hidden)
    }

  # aws_cloudfront_key_value_store.routes will be created
  + resource "aws_cloudfront_key_value_store" "routes" {
      + arn                = (known after apply)
      + comment            = "Pretty-URL → .html map for the redirects function; data synced by deploy-website.yml, not Terraform"
      + etag               = (known after apply)
      + id                 = (known after apply)
      + last_modified_time = (known after apply)
      + name               = "iii-website-prod-routes"
    }

  # aws_iam_policy.github_deploy_website will be updated in-place
  ~ resource "aws_iam_policy" "github_deploy_website" {
        id               = "arn:aws:iam::600627348446:policy/iii-website-prod-github-deploy"
        name             = "iii-website-prod-github-deploy"
      ~ policy           = jsonencode(
            {
              - Statement = [
                  - {
                      - Action   = [
                          - "s3:ListBucket",
                          - "s3:GetBucketLocation",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:s3:::iii-website-prod-us-east-1"
                      - Sid      = "ListBucket"
                    },
                  - {
                      - Action   = [
                          - "s3:PutObject",
                          - "s3:GetObject",
                          - "s3:DeleteObject",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:s3:::iii-website-prod-us-east-1/*"
                      - Sid      = "ReadWriteObjects"
                    },
                  - {
                      - Action   = [
                          - "cloudfront:ListInvalidations",
                          - "cloudfront:GetInvalidation",
                          - "cloudfront:CreateInvalidation",
                        ]
                      - Effect   = "Allow"
                      - Resource = "arn:aws:cloudfront::600627348446:distribution/E3N35RPQE4YVFQ"
                      - Sid      = "CloudFrontInvalidation"
                    },
                ]
              - Version   = "2012-10-17"
            }
        ) -> (known after apply)
        tags             = {}
        # (7 unchanged attributes hidden)
    }

  # aws_route53_record.apex_a[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "apex_a" {
      - fqdn                             = "iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_iii.dev_A" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "A" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.apex_aaaa[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "apex_aaaa" {
      - fqdn                             = "iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_iii.dev_AAAA" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "AAAA" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.www_a[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "www_a" {
      - fqdn                             = "www.iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_www.iii.dev_A" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "www.iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "A" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_route53_record.www_aaaa[0] will be destroyed
  # (because index [0] is out of range for count)
  - resource "aws_route53_record" "www_aaaa" {
      - fqdn                             = "www.iii.dev" -> null
      - id                               = "Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA" -> null
      - multivalue_answer_routing_policy = false -> null
      - name                             = "www.iii.dev" -> null
      - records                          = [] -> null
      - ttl                              = 0 -> null
      - type                             = "AAAA" -> null
      - zone_id                          = "Z05516132AI1ZGB3NLC6D" -> null
        # (2 unchanged attributes hidden)

      - alias {
          - evaluate_target_health = false -> null
          - name                   = "d3de6i4fbqh35s.cloudfront.net" -> null
          - zone_id                = "Z2FDTNDATAQYW2" -> null
        }
    }

  # aws_sns_topic_subscription.email will be created
  + resource "aws_sns_topic_subscription" "email" {
      + arn                             = (known after apply)
      + confirmation_timeout_in_minutes = 1
      + confirmation_was_authenticated  = (known after apply)
      + endpoint                        = "devops@motia.dev"
      + endpoint_auto_confirms          = false
      + filter_policy_scope             = (known after apply)
      + id                              = (known after apply)
      + owner_id                        = (known after apply)
      + pending_confirmation            = (known after apply)
      + protocol                        = "email"
      + raw_message_delivery            = false
      + topic_arn                       = "arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms"
    }

Plan: 2 to add, 2 to change, 4 to destroy.

Changes to Outputs:
  + routes_kvs_arn           = (known after apply)

@ytallo ytallo merged commit 5a23faf into main Jun 29, 2026
38 of 39 checks passed
@ytallo ytallo deleted the fix/mot-3669-pretty-urls-via-kvs branch June 29, 2026 13:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant