Skip to content

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

Closed
ytallo wants to merge 1 commit into
mainfrom
ytallolayon/mot-3669-route-map-kvs
Closed

feat(website): resolve pretty URLs via CloudFront KVS, drop per-page terraform apply#1900
ytallo wants to merge 1 commit into
mainfrom
ytallolayon/mot-3669-route-map-kvs

Conversation

@ytallo

@ytallo ytallo commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

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

Release Notes

  • Chores

    • Updated website routing infrastructure to use CloudFront KeyValueStore for pretty URL resolution, replacing previous SPA fallback behavior.
    • Deployment workflow now syncs URL mappings to CloudFront with automatic reconciliation.
  • Tests

    • Added comprehensive test coverage for routing and URL mapping logic.

…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
@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 22, 2026 5:20pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR replaces the hardcoded pretty-URL map in the CloudFront edge function with a CloudFront KeyValueStore (KVS) lookup. A new routes-kvs.ts CLI script computes desired KVS entries from deployed HTML files and diffs them against current KVS state. Terraform provisions the KVS resource and scoped IAM permissions, and a new GitHub Actions step syncs routes after each deployment.

Changes

CloudFront KVS-Backed Pretty-URL Routing

Layer / File(s) Summary
KVS route diff script and types
website/scripts/routes-kvs.ts, website/scripts/routes-kvs.test.ts
Exports KvsEntry and KvsOps types, implements desiredRoutes() to map .html files to pretty URL keys, implements diff() to produce Puts/Deletes, adds a --current CLI mode, and tests all functions with Node's test runner.
CloudFront edge function KVS lookup
infra/terraform/website/cloudfront_functions/redirects.js
Imports the cloudfront SDK, initializes cf.kvs() as routes, makes handler async, removes the hardcoded htmlPretty map, and replaces extensionless path resolution with await routes.get(uri) returning a real 404 on miss.
CloudFront function test harness and coverage
infra/terraform/website/cloudfront_functions/redirects.test.js
Replaces static handler instantiation with makeHandler(routeMap) that mocks cf.kvs().get(). Rewrites all tests to async/await and adds coverage for KVS hit/miss, blog routing, querystring encoding, www host redirect precedence, and unknown extensionless 404s.
Terraform KVS resource, IAM, and outputs
infra/terraform/website/cloudfront.tf, infra/terraform/website/iam_github_oidc.tf, infra/terraform/website/outputs.tf
Declares aws_cloudfront_key_value_store.routes, associates it with the redirects function, grants the GitHub deploy role scoped KVS describe/list/update permissions, and exports routes_kvs_arn.
GitHub Actions KVS sync step
.github/workflows/deploy-website.yml
Adds a conditional step that reads the current KVS ETag and keys, calls routes-kvs.ts --current to compute puts/deletes, and performs an ETag-guarded update (or exits early when no changes are needed).

Sequence Diagram(s)

sequenceDiagram
  rect rgba(70, 130, 180, 0.5)
    Note over GitHubActions,CloudFrontKVS: Deployment: sync pretty-URL routes
    GitHubActions->>CloudFrontKVS: describe-key-value-store → ETag
    GitHubActions->>CloudFrontKVS: list-keys → current JSON
    GitHubActions->>routes-kvs.ts: tsx routes-kvs.ts --current <file>
    routes-kvs.ts-->>GitHubActions: {Puts, Deletes}
    GitHubActions->>CloudFrontKVS: update-keys --if-match ETag
  end

  rect rgba(60, 179, 113, 0.5)
    Note over Viewer,CloudFrontKVS: Runtime: edge request routing
    Viewer->>redirects.js: GET /manifesto
    redirects.js->>CloudFrontKVS: routes.get("/manifesto")
    alt key exists
      CloudFrontKVS-->>redirects.js: "/manifesto.html"
      redirects.js-->>Viewer: rewrite URI → /manifesto.html
    else key missing
      CloudFrontKVS-->>redirects.js: throws
      redirects.js-->>Viewer: 404 notFound()
    end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • iii-hq/iii#1470: Introduced the original redirects.js CloudFront Function and deploy-website.yml flow that this PR directly extends with KVS-backed routing.
  • iii-hq/iii#1486: Modifies the same redirects.js/redirects.test.js extensionless path and /docs* routing logic that this PR replaces with KVS lookups.
  • iii-hq/iii#1857: Adds /privacy-policy → /privacy-policy.html rewrite behavior to the same redirects.js hardcoded map that this PR removes in favor of KVS-driven resolution.

Suggested reviewers

  • anthonyiscoding
  • sergiofilhowz
  • andersonleal

Poem

🐇 Hoppity-hop through the CloudFront gate,
No more hardcoded maps to hardcod-ate!
The KVS holds each pretty URL key,
A diff script computes what to put away.
ETag-guarded syncs keep routes in line —
This bunny's redirects are looking fine! 🗝️

🚥 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 accurately and concisely summarizes the main change: moving pretty URL resolution from hardcoded terraform to CloudFront KVS with automatic deployment syncing.
Description check ✅ Passed The description comprehensively covers the problem, fix, implementation details, rollout strategy, and test plan across all required sections, though structured slightly differently than the template.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ytallolayon/mot-3669-route-map-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 and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

Terraform plan — infra/terraform/website

Click to expand
data.aws_caller_identity.current: Reading...
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...
aws_cloudfront_origin_access_control.site: Refreshing state... [id=E3GRB5YVVA4332]
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms]
aws_s3_bucket.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_cloudfront_response_headers_policy.site: Refreshing state... [id=033b1d66-8d53-48a1-a817-8280d8a60114]
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_tf_plan_trust: Reading...
data.aws_iam_policy_document.github_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]
aws_cloudwatch_metric_alarm.acm_days_to_expiry: Refreshing state... [id=iii-website-prod-acm-days-to-expiry]
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_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]
data.aws_route53_zone.iii_dev: Read complete after 1s [id=Z05516132AI1ZGB3NLC6D]
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_route53_record.cert_validation["iii-preview.iii.dev"]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D__0e43dc21d86b26a03742fb15aa5b4558.iii-preview.iii.dev._CNAME]
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_aaaa: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_AAAA]
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
aws_cloudwatch_metric_alarm.cf_5xx_rate: Refreshing state... [id=iii-website-prod-cf-5xx-rate]
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
data.aws_iam_policy_document.site_bucket: Reading...
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: Read complete after 0s [id=2990320740]
aws_route53_record.apex_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_A]
aws_route53_record.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
aws_route53_record.apex_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_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 commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{"name":"HttpError","status":500,"request":{"method":"PATCH","url":"https://api.github.com/repos/iii-hq/iii/issues/comments/4770979562","headers":{"accept":"application/vnd.github.v3+json","user-agent":"octokit.js/0.0.0-development octokit-core.js/7.0.6 Node.js/24","authorization":"token [REDACTED]","content-type":"application/json; charset=utf-8"},"body":{"body":"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- review_stack_entry_start -->\n\n[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/iii-hq/iii/pull/1900?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)\n\n<!-- review_stack_entry_end -->\n<!-- This is an auto-generated comment: review in progress by coderabbit.ai -->\n\n> [!NOTE]\n> Currently processing new changes in this PR. This may take a few minutes, please wait...\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: Repository UI\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Pro\n> \n> **Run ID**: `68ea8e61-0ff3-4b03-be76-00a8c100ef64`\n> \n> </details>\n> \n> <details>\n> <summary>📥 Commits</summary>\n> \n> Reviewing files that changed from the base of the PR and between 55c9653d779da7807a7e80fd7a75f3b26de779e0 and d1e7b9385199c1748ee34107fc390a3cb81c5ff1.\n> \n> </details>\n> \n> <details>\n> <summary>📒 Files selected for processing (8)</summary>\n> \n> * `.github/workflows/deploy-website.yml`\n> * `infra/terraform/website/cloudfront.tf`\n> * `infra/terraform/website/cloudfront_functions/redirects.js`\n> * `infra/terraform/website/cloudfront_functions/redirects.test.js`\n> * `infra/terraform/website/iam_github_oidc.tf`\n> * `infra/terraform/website/outputs.tf`\n> * `website/scripts/routes-kvs.test.ts`\n> * `website/scripts/routes-kvs.ts`\n> \n> </details>\n> \n> ```ascii\n>  ______________________________________________________________________________________________________\n> < Fools ignore complexity. Pragmatists suffer it. Some can avoid it. Geniuses remove it. - Alan Perlis >\n>  ------------------------------------------------------------------------------------------------------\n>   \\\n>    \\   \\\n>         \\ /\\\n>         ( )\n>       .( o ).\n> ```\n\n<!-- end of auto-generated comment: review in progress by coderabbit.ai -->\n\n<!-- finishing_touch_checkbox_start -->\n\n<details>\n<summary>✨ Finishing Touches</summary>\n\n<details>\n<summary>📝 Generate docstrings</summary>\n\n- [ ] <!-- {\"checkboxId\": \"7962f53c-55bc-4827-bfbf-6a18da830691\"} --> Create stacked PR\n- [ ] <!-- {\"checkboxId\": \"3e1879ae-f29b-4d0d-8e06-d12b7ba33d98\"} --> Commit on current branch\n\n</details>\n<details>\n<summary>🧪 Generate unit tests (beta)</summary>\n\n- [ ] <!-- {\"checkboxId\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"radioGroupId\": \"utg-output-choice-group-unknown_comment_id\"} -->   Create PR with unit tests\n- [ ] <!-- {\"checkboxId\": \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\", \"radioGroupId\": \"utg-output-choice-group-unknown_comment_id\"} -->   Commit unit tests in branch `ytallolayon/mot-3669-route-map-kvs`\n\n</details>\n\n</details>\n\n<!-- finishing_touch_checkbox_end -->\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=iii-hq/iii&utm_content=1900)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summary>❤️ Share</summary>\n\n- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)\n- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)\n- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)\n- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)\n\n</details>\n\n\n<sub>Comment `@coderabbitai help` to get the list of available commands and usage tips.</sub>\n\n<!-- tips_end -->"},"request":{"retryCount":3,"signal":{},"retries":3,"retryAfter":16}}}

@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.

🧹 Nitpick comments (1)
website/scripts/routes-kvs.ts (1)

52-53: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Make the route source directory explicit instead of relying on cwd.

Line 53 ties behavior to process.cwd(). If this command is run from an unexpected directory, it can compute the wrong desired set and emit unintended Deletes. Pass a directory explicitly (and wire it from the workflow) to harden this path.

Suggested change
 function main(): void {
-  const desired = desiredRoutes(listFiles(process.cwd()))
+  const dirFlagIdx = process.argv.indexOf('--dir')
+  const sourceDir = dirFlagIdx === -1 ? process.cwd() : process.argv[dirFlagIdx + 1]
+  if (!sourceDir) {
+    console.error('--dir requires a directory path')
+    process.exitCode = 1
+    return
+  }
+  const desired = desiredRoutes(listFiles(sourceDir))
-ops=$(pnpm --filter iii-website exec tsx scripts/routes-kvs.ts --current /tmp/kvs-current.json)
+ops=$(pnpm --filter iii-website exec tsx scripts/routes-kvs.ts --dir . --current /tmp/kvs-current.json)
🤖 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 `@website/scripts/routes-kvs.ts` around lines 52 - 53, The main function relies
on process.cwd() which can produce incorrect results if the script is run from
an unexpected directory. Modify the main function to accept a directory
parameter explicitly and pass that parameter to the listFiles call instead of
using process.cwd(). Update the function signature and the desiredRoutes call to
use this explicit directory parameter, then wire the directory from the workflow
or command line arguments so it is passed explicitly when main is invoked.
🤖 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.

Nitpick comments:
In `@website/scripts/routes-kvs.ts`:
- Around line 52-53: The main function relies on process.cwd() which can produce
incorrect results if the script is run from an unexpected directory. Modify the
main function to accept a directory parameter explicitly and pass that parameter
to the listFiles call instead of using process.cwd(). Update the function
signature and the desiredRoutes call to use this explicit directory parameter,
then wire the directory from the workflow or command line arguments so it is
passed explicitly when main is invoked.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 68ea8e61-0ff3-4b03-be76-00a8c100ef64

📥 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

@ytallo ytallo closed this Jun 22, 2026
@ytallo ytallo deleted the ytallolayon/mot-3669-route-map-kvs branch June 22, 2026 17:36
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