Skip to content

Address various additional site nits discovered in testing#1576

Merged
anthonyiscoding merged 12 commits intomainfrom
fix/site-nits-2
Apr 29, 2026
Merged

Address various additional site nits discovered in testing#1576
anthonyiscoding merged 12 commits intomainfrom
fix/site-nits-2

Conversation

@anthonyiscoding
Copy link
Copy Markdown
Contributor

@anthonyiscoding anthonyiscoding commented Apr 29, 2026

Summary by CodeRabbit

  • New Features

    • Added cookie consent banner enabling opt-in analytics tracking
    • Enhanced hero visualization with new complexity chart
    • Improved mobile carousel navigation with better swipe interactions
    • Published AI-focused documentation (llms.txt and AGENTS.md)
  • Improvements

    • Better email input handling across forms
    • Updated manifesto page routing

Restore cookie banner with iii_cookie_consent storage matching the prior
React site; load cr-relay signals only after Accept or when consent was
already accepted.

After successful Mailmodo POST, call signals.form({ email }) when consent
allows; add name=email on signup inputs.

Apply the same consent loader pattern on manifesto for consistency.
The CloudFront function now owns redirects and SPA fallback (see
infra/terraform/website/cloudfront_functions/redirects.js); .github
workflow syncs the static site directly to S3.
…route

Replaces the hand-maintained /ai SPA route with two industry-standard
plain-text artifacts produced from index.html at build time:

- llms.txt — llms.txt-style snapshot (H1 + blockquote + sections)
- AGENTS.md — agents.md-style product context for autonomous agents

Both files share a canonical "AI overview" (problem framing, three
primitives, two audiences, comparisons) and an extracted homepage copy
fragment scraped from index.html via node-html-parser. AGENTS.md adds a
wire-level appendix (ports, primitives, quickstart) for agent runtimes.

Build pipeline:
- pnpm --filter iii-website build now runs generate-llms-agents.ts and
  generate-sitemap.ts (writes /sitemap.xml directly).
- sitemap now lists /llms.txt and /AGENTS.md as discoverable URLs.
- deploy-website.yml installs deps, builds, and syncs llms.txt/AGENTS.md
  with no-cache headers (alongside *.html).
- /ai route + prerender skip removed; CloudFront function now SPA-falls
  /ai back to /index.html and passes /AGENTS.md through unchanged.

Tests: 4 new node:test cases for the generator shape; redirects test
updated for /ai SPA fallback and /AGENTS.md passthrough.

Generated llms.txt and AGENTS.md are gitignored going forward; current
content is committed as the first generation baseline.
…stored

Previously the banner script returned early when localStorage had
'accepted' or 'rejected', leaving the banner element hidden but still in
the DOM (so its CSS reservation, ARIA, and event handlers stuck around).
Now we remove the node entirely once a prior choice exists.

Same fix applied to index.html and manifesto.html.
…ulas

Adds a small SVG complexity chart and MathML formula readout under the
hero pairwise-mesh visualization. Each stage now expresses its real cost
shape, not just a label:

- mesh:   O(n(n-1)/2)              — steep pairwise curve, dark
- actual: O(n(n-1)/(2·tolerance))  — damped curve at typical service
                                     count, dark
- iii:    O(0)                     — flat on the axis, accent

The chart is desktop-only inside the classic hero-viz (skipped in the
triptych design). Curves cross-fade by data-stage, and the formula is
rendered via inline <math> so the typography reads as math, not code.
Bumps .hero-viz-canvas aspect-ratio 1/1 → 5/4 to make room for the new
chart band, and sets a stable grid-template-rows on the desktop layout.
… overflow

Three mobile UX fixes that shipped together because they share layout
plumbing (min-width:0, overflow handling, scroll-snap):

Console section (#cs-scroll):
- Replace the per-panel "is-active fade" with a horizontal scroll-snap
  carousel that slides like the desktop translate track.
- Stage now has a fixed clamped height; track is overflow-x:auto with
  scroll-snap-type:x mandatory; panels are flex 0 0 100%.
- Inner card bodies (fn-list, states-table, traces, logs, config, etc.)
  scroll vertically within their panel; pre/code blocks pre-wrap.
- New csScrollSync flag debounces JS-driven scrolls vs. user-driven
  scrolls so chip clicks and resize don't fight each other.

Hello flow (#hello-mflow):
- Wrap track + dots in a new .hello-mflow-body so the swipe handler can
  page the carousel from outside the inner code scroller.
- Swipe handler reads touchstart/end on the body, distinguishes
  horizontal vs. vertical, and ignores swipes that start inside the
  track (so native scroll on the track still works).
- Drop the obsolete .hello-mflow-slide overflow-x:hidden block; code
  panels are now overflow:hidden + pre-wrap (no horizontal trap).

Footer (.foot):
- Cap email/install font-size with clamp(11px,2.85vw,13px) and add
  min-width:0 + max-width:100% on grid children so long install
  commands and email inputs no longer push the row off-screen on
  narrow phones.
- Tighten foot-cta padding and row gaps for sub-400px widths.
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 29, 2026

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

Project Deployment Actions Updated (UTC)
iii-website Ready Ready Preview, Comment Apr 29, 2026 8:39pm

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

📝 Walkthrough

Walkthrough

This PR introduces AI snapshot generation (llms.txt and AGENTS.md), implements cookie consent gating for Common Room analytics, updates CloudFront routing logic to handle new paths and querystring serialization, removes the /ai route from discovery, and enhances website UI with cookie consent banner and mobile carousel improvements.

Changes

Cohort / File(s) Summary
AI/LLM Snapshot Generation
website/scripts/generate-llms-agents.ts, website/scripts/generate-llms-agents.test.ts, website/scripts/ai-overview.ts, website/scripts/agents-appendix.md, website/llms.txt, website/AGENTS.md
New build-time scripts extract homepage copy via HTML parsing and generate AI-targeted snapshots. ai-overview.ts provides positioning overview; agents-appendix.md documents agent-to-primitive mapping and deployment; generate-llms-agents.ts orchestrates file generation with normalized content. Tests validate extraction and structure. Previous llms.txt content cleared for regeneration.
Cookie Consent & Analytics Gating
website/index.html, website/manifesto.html
Common Room signals script now loads only on consent acceptance via window.iiiLoadCommonRoomSignals(). New fixed consent banner UI with accept/decline handlers persists state to localStorage. Email submission callbacks trigger window.iiiNotifyCommonRoomEmail() when available.
Routing & Sitemap Updates
website/scripts/routes.ts, website/scripts/prerender-routes.ts, website/scripts/generate-sitemap.ts, website/sitemap.xml, infra/terraform/website/cloudfront_functions/redirects.js
Removes /ai from indexed routes; prerender now includes /ai. Sitemap adds /llms.txt and /AGENTS.md (priority 0.6), removes /ai. CloudFront adds serializeQuerystring() helper to preserve query params in 301 redirects, introduces pretty-URL rewriting (e.g., /manifesto/manifesto.html), and pass-through for /.well-known/ paths.
CloudFront Tests
infra/terraform/website/cloudfront_functions/redirects.test.js
Expands coverage for querystring serialization, multiValue handling, and encoding validation. Verifies /manifesto rewrite, /AGENTS.md pass-through, /ai rewrite to /index.html, and SPA fallback preserves querystring.
Build & Deployment
.github/workflows/deploy-website.yml, website/package.json
Workflow now runs npm run build (executing LLM/sitemap generation), excludes generated files from immutable S3 sync, then explicitly includes them in no-cache revalidate step. Package.json build script executes generation via tsx; adds generate:llms and test scripts; updates dev dependencies (tsx, node-html-parser).
Configuration Removal & Ignore Updates
website/vercel.json, website/.gitignore
Deletes Vercel routing configuration (permanent/legacy redirects and header overrides). Adds .gitignore entries for generated llms.txt and AGENTS.md.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • rohitg00
  • mfpiccolo
  • sergiofilhowz

Poem

🐰 A rabbit hops through the digital garden,
Planting llms.txt seeds with AI pardon,
Consent banners bloom where analytics hide,
Routes reshape with CloudFront as guide,
Snapshots for agents, a build-time delight! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Description check ⚠️ Warning No pull request description was provided; all required template sections (What, Why, Notes) and the licensing checkbox are missing entirely. Add a complete pull request description following the template, including What (change summary), Why (motivation), Notes (breaking changes/migration), and the licensing checkbox.
Docstring Coverage ⚠️ Warning Docstring coverage is 41.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is vague and non-descriptive, using generic language like 'site nits' without clarifying the main changes (AI snapshot generation, consent UI, CloudFront redirects, etc.). Replace with a more specific title summarizing the primary changes, such as 'Add AI snapshot generation and consent-gated Common Room integration' or similar.
✅ Passed checks (2 passed)
Check name Status Explanation
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 fix/site-nits-2

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
Review rate limit: 5/8 reviews remaining, refill in 17 minutes and 22 seconds.

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...
data.aws_route53_zone.iii_dev: Reading...
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms]
aws_cloudfront_origin_access_control.site: Refreshing state... [id=E3GRB5YVVA4332]
aws_cloudfront_response_headers_policy.site: Refreshing state... [id=033b1d66-8d53-48a1-a817-8280d8a60114]
aws_cloudfront_function.redirects: Refreshing state... [id=iii-website-prod-redirects]
aws_acm_certificate.site: Refreshing state... [id=arn:aws:acm:us-east-1:600627348446:certificate/b8e26c06-08b6-4b90-bd59-1f83bc231db2]
aws_s3_bucket.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::600627348446:oidc-provider/token.actions.githubusercontent.com]
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_tf_plan_trust: Read complete after 0s [id=2702237784]
data.aws_iam_policy_document.github_trust: Read complete after 0s [id=932063200]
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:e50a5c41-0aa3-4855-83ad-8a914562ff95]
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.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]
data.aws_iam_policy_document.github_deploy_website: Reading...
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
aws_route53_record.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
data.aws_iam_policy_document.github_deploy_website: Read complete after 0s [id=1235650417]
aws_route53_record.apex_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_A]
aws_route53_record.apex_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_AAAA]
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_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
  - destroy

Terraform will perform the following actions:

  # 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: 1 to add, 0 to change, 4 to destroy.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/deploy-website.yml (1)

59-83: ⚠️ Potential issue | 🟠 Major

Deploy sitemap.xml with revalidated headers too.

website/scripts/generate-sitemap.ts now regenerates website/sitemap.xml, but this workflow still uploads it in the immutable pass. That means route/indexability changes can leave crawlers seeing a stale sitemap long after deploy.

Suggested fix
       - name: Sync static assets (long cache, immutable)
         run: |
           aws s3 sync website/ "s3://${{ vars.S3_BUCKET }}/" \
             --delete \
             --cache-control "public,max-age=31536000,immutable" \
             --exclude "*.html" \
+            --exclude "sitemap.xml" \
             --exclude "llms.txt" \
             --exclude "AGENTS.md" \
             --exclude "node_modules/*" \
             --exclude "package.json" \
             --exclude "package-lock.json" \
@@
       - name: Sync HTML and AI snapshot (no cache, must revalidate)
         run: |
           aws s3 sync website/ "s3://${{ vars.S3_BUCKET }}/" \
             --delete \
             --cache-control "public,max-age=0,must-revalidate" \
             --exclude "*" \
             --include "*.html" \
+            --include "sitemap.xml" \
             --include "llms.txt" \
             --include "AGENTS.md"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/deploy-website.yml around lines 59 - 83, The sitemap.xml
is being uploaded in the long-cache "Sync static assets (long cache, immutable)"
step causing stale sitemap delivery; update the workflow so website/sitemap.xml
is excluded from the immutable sync and instead included in the "Sync HTML and
AI snapshot (no cache, must revalidate)" sync (modify the --exclude/--include
lists accordingly), ensuring sitemap.xml uses --cache-control
"public,max-age=0,must-revalidate" like "*.html" and is uploaded by the step
named "Sync HTML and AI snapshot (no cache, must revalidate)" so crawlers always
get the revalidated sitemap.
🧹 Nitpick comments (1)
website/scripts/generate-sitemap.ts (1)

1-6: Resolve the output path relative to the script, not process.cwd().

This now works only as long as callers execute the script from the website/ package root. A direct invocation from the repo root would write sitemap.xml to the wrong place. The sibling generator already uses a script-relative root, so this is worth keeping consistent here too.

Suggested refactor
 import fs from 'node:fs/promises'
 import path from 'node:path'
+import { fileURLToPath } from 'node:url'
 import { INDEXABLE_ROUTES, SITE_ORIGIN } from './routes'
 
-const OUT_PATH = path.resolve(process.cwd(), 'sitemap.xml')
+const WEBSITE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
+const OUT_PATH = path.join(WEBSITE_ROOT, 'sitemap.xml')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@website/scripts/generate-sitemap.ts` around lines 1 - 6, The OUT_PATH uses
process.cwd() which makes output dependent on where the script is run; change
OUT_PATH to resolve relative to the script file instead. Import fileURLToPath
from 'node:url', derive __dirname with fileURLToPath(import.meta.url) and
path.dirname, then set OUT_PATH = path.resolve(__dirname, '..', 'sitemap.xml')
(replace the current OUT_PATH declaration that uses process.cwd()). Ensure you
update any imports needed (fileURLToPath) and keep the symbol name OUT_PATH
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@website/index.html`:
- Around line 23-55: The GTM bootstrap is being initialized unconditionally
while iiiLoadCommonRoomSignals is gated by STORAGE_KEY ('iii_cookie_consent'),
so update the GTM initialization to respect the same consent flow: either move
the GTM script injection into a function (e.g., window.iiiLoadGTM) and call it
only when localStorage.getItem(STORAGE_KEY) === 'accepted' (matching how
window.iiiLoadCommonRoomSignals is invoked), or wrap the existing GTM bootstrap
so it first checks STORAGE_KEY and aborts if not 'accepted'; ensure you
reference the same STORAGE_KEY/localStorage check and the GTM init location so
declining the banner actually prevents GTM from loading.
- Around line 7977-7984: The mobile resize/orientation path uses lastIdx
(updated only by desktop scroll) when computing syncIdx, causing mobile swipes
to be overridden; change the logic in the resize/orientation/applyMode() flow to
prefer mIdx when on mobile by computing syncIdx = (isMobileMode ? mIdx :
(lastIdx >= 0 ? lastIdx : mIdx)), then call applyPanelChrome(syncIdx) and set
track.scrollLeft = mIdx * w only when needed while preserving csScrollSync
semantics; update references to lastIdx, mIdx, applyPanelChrome, applyMode(),
csScrollSync, and track.scrollLeft so mobile resizes use mIdx and desktop still
uses lastIdx.
- Around line 7392-7419: The touchend handler currently computes nextIdx then
directly uses mTrack.scrollTo(...) and toggles mScrollSyncing, which leaves UI
state (pills/dots/label) stale when the swipe started outside the track; replace
the direct scroll with a call to applyFlowStep(nextIdx) so the flow state and UI
are updated consistently. Locate the touchend callback (symbols:
mBody.addEventListener('touchend', mSwipe0, mSwipeStartedOnTrack, stopFlow(),
FLOW_STEPS, mTrack, mScrollSyncing) and swap the scrollTo + setTimeout block for
a single applyFlowStep(nextIdx) call (remove or avoid duplicating
scroll/mScrollSyncing logic if applyFlowStep already handles it). Ensure mSwipe0
is still cleared and behavior for small/vertical swipes and mSwipeStartedOnTrack
remains unchanged.

In `@website/manifesto.html`:
- Around line 23-57: The page only gates Common Room via
window.iiiLoadCommonRoomSignals/STORAGE_KEY but leaves Google Tag Manager (GTM)
loading unconditionally (both the inline GTM script and the <noscript> iframe),
so "Decline" doesn't stop non-essential analytics; fix it by removing or
deferring the hardcoded GTM include and instead implement a gated loader (e.g.,
create window.iiiLoadGTM or loadGTM function) that checks the same STORAGE_KEY
('iii_cookie_consent') and only injects the GTM script and noscript iframe into
the DOM when consent === 'accepted'; also ensure any existing inline GTM
bootstrap (dataLayer pushes or global functions) are no-ops until loadGTM runs
so GTM cannot execute before consent.

---

Outside diff comments:
In @.github/workflows/deploy-website.yml:
- Around line 59-83: The sitemap.xml is being uploaded in the long-cache "Sync
static assets (long cache, immutable)" step causing stale sitemap delivery;
update the workflow so website/sitemap.xml is excluded from the immutable sync
and instead included in the "Sync HTML and AI snapshot (no cache, must
revalidate)" sync (modify the --exclude/--include lists accordingly), ensuring
sitemap.xml uses --cache-control "public,max-age=0,must-revalidate" like
"*.html" and is uploaded by the step named "Sync HTML and AI snapshot (no cache,
must revalidate)" so crawlers always get the revalidated sitemap.

---

Nitpick comments:
In `@website/scripts/generate-sitemap.ts`:
- Around line 1-6: The OUT_PATH uses process.cwd() which makes output dependent
on where the script is run; change OUT_PATH to resolve relative to the script
file instead. Import fileURLToPath from 'node:url', derive __dirname with
fileURLToPath(import.meta.url) and path.dirname, then set OUT_PATH =
path.resolve(__dirname, '..', 'sitemap.xml') (replace the current OUT_PATH
declaration that uses process.cwd()). Ensure you update any imports needed
(fileURLToPath) and keep the symbol name OUT_PATH unchanged.
🪄 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: 4bc31bf8-847b-424a-9a69-bd9fbc7cb257

📥 Commits

Reviewing files that changed from the base of the PR and between 52bacc4 and 2908f48.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (17)
  • .github/workflows/deploy-website.yml
  • infra/terraform/website/cloudfront_functions/redirects.test.js
  • website/.gitignore
  • website/AGENTS.md
  • website/index.html
  • website/llms.txt
  • website/manifesto.html
  • website/package.json
  • website/scripts/agents-appendix.md
  • website/scripts/ai-overview.ts
  • website/scripts/generate-llms-agents.test.ts
  • website/scripts/generate-llms-agents.ts
  • website/scripts/generate-sitemap.ts
  • website/scripts/prerender-routes.ts
  • website/scripts/routes.ts
  • website/sitemap.xml
  • website/vercel.json
💤 Files with no reviewable changes (3)
  • website/scripts/prerender-routes.ts
  • website/vercel.json
  • website/scripts/routes.ts

Comment thread website/index.html
Comment thread website/index.html
Comment thread website/index.html
Comment thread website/manifesto.html
…t tests into pnpm test

- CloudFront viewer-request now rewrites /manifesto → /manifesto.html (Option A flat HTML), with matching test update.
- Tighten AGENTS.md and llms.txt: drop hardcoded port list (point at config.yaml + docs), reframe Quickstart as Install/start, clarify that `iii trigger` is for manual checks (not app integration), simplify licensing line.
- Run cloudfront_functions/*.test.js as part of `pnpm --filter iii-website test` so they stop being orphaned.
@github-actions
Copy link
Copy Markdown
Contributor

Terraform plan — infra/terraform/website

Click to expand
aws_cloudfront_origin_access_control.site: Refreshing state... [id=E3GRB5YVVA4332]
data.aws_route53_zone.iii_dev: Reading...
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]
aws_s3_bucket.site: Refreshing state... [id=iii-website-prod-us-east-1]
data.aws_caller_identity.current: Reading...
aws_cloudfront_function.redirects: Refreshing state... [id=iii-website-prod-redirects]
aws_acm_certificate.site: Refreshing state... [id=arn:aws:acm:us-east-1:600627348446:certificate/b8e26c06-08b6-4b90-bd59-1f83bc231db2]
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:e50a5c41-0aa3-4855-83ad-8a914562ff95]
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 0s [id=Z05516132AI1ZGB3NLC6D]
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_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_cloudwatch_metric_alarm.cf_5xx_rate: Refreshing state... [id=iii-website-prod-cf-5xx-rate]
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
aws_route53_record.apex_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_AAAA]
data.aws_iam_policy_document.github_deploy_website: Reading...
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
aws_route53_record.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
data.aws_iam_policy_document.site_bucket: Reading...
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]
data.aws_iam_policy_document.github_deploy_website: Read complete after 0s [id=1235650417]
aws_s3_bucket_policy.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_iam_policy.github_deploy_website: Refreshing state... [id=arn:aws:iam::600627348446:policy/iii-website-prod-github-deploy]
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

Terraform will perform the following actions:

  # 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.
            
            function redirect(location) {
              return {
                statusCode: 301,
                statusDescription: 'Moved Permanently',
                headers: {
                  location: { value: location },
                  'cache-control': { value: 'public, max-age=3600' },
                },
          -   }
          +   };
            }
            
            // 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) {
          -   var request = event.request
          -   var uri = request.uri
          -   var host = request.headers && request.headers.host ? request.headers.host.value : undefined
          +   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}`)
          +   if (host === 'www.iii.dev') return redirect(`https://iii.dev${uri}`);
            
          -   if (uri.indexOf('/.well-known/') === 0) return request
          +   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;
          +   }
          + 
              // SPA fallback: extensionless path not ending in /
              if (uri !== '/' && uri.charAt(uri.length - 1) !== '/') {
          -     const lastSlash = uri.lastIndexOf('/')
          -     const lastSegment = uri.substring(lastSlash + 1)
          +     const lastSlash = uri.lastIndexOf('/');
          +     const lastSegment = uri.substring(lastSlash + 1);
                if (lastSegment.indexOf('.') === -1) {
          -       request.uri = '/index.html'
          -       return request
          +       request.uri = '/index.html';
          +       return request;
                }
              }
            
          -   return request
          +   return request;
            }
        EOT
        id                           = "iii-website-prod-redirects"
        name                         = "iii-website-prod-redirects"
        # (8 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: 1 to add, 1 to change, 4 to destroy.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
infra/terraform/website/cloudfront_functions/redirects.js (1)

30-33: Hoist htmlPretty out of handler to avoid per-request allocation.

htmlPretty is static and recreated on every invocation. Moving it to module scope is a small but clean optimization for hot-path request handling.

Optional refactor
+var HTML_PRETTY = {
+  '/manifesto': '/manifesto.html',
+};
+
 function handler(event) {
@@
-  var htmlPretty = {
-    '/manifesto': '/manifesto.html',
-  };
-  var htmlTarget = htmlPretty[uri];
+  var htmlTarget = HTML_PRETTY[uri];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infra/terraform/website/cloudfront_functions/redirects.js` around lines 30 -
33, Move the static htmlPretty map out of the per-request handler function to
module scope so it isn't recreated on every invocation: declare htmlPretty at
top-level (module scope) and then inside handler reference htmlPretty[uri] to
compute htmlTarget as before; ensure no other references rely on closure state
so only htmlPretty is relocated and htmlTarget/uri usage inside handler remains
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@infra/terraform/website/cloudfront_functions/redirects.js`:
- Line 24: Implement a serializeQuerystring helper that iterates over
request.querystring (shape: {param: {value, multiValue?}}) and returns a
properly encoded "?a=1&b=2" string handling multiValue arrays and empty values;
replace the bare redirect in redirects.js (the host === 'www.iii.dev' branch
that calls redirect(`https://iii.dev${uri}`)) to append the serialized
querystring (e.g.,
redirect(`https://iii.dev${uri}${serializeQuerystring(request.querystring)}`));
add a unit test exercising a non-empty querystring (including multiValue and
empty param cases) to prevent regression.

In `@website/scripts/agents-appendix.md`:
- Around line 15-19: Update the "Function" primitive row in the agents appendix
so its "How an agent uses it" cell exactly matches the generated AGENTS.md:
replace the current sentence that starts "Invoke from another worker via your
language SDK..." with "Call via `iii.trigger(name, input)` from anywhere else on
the engine" (ensure the text includes the exact `iii.trigger(name, input)`
phrasing referenced).

---

Nitpick comments:
In `@infra/terraform/website/cloudfront_functions/redirects.js`:
- Around line 30-33: Move the static htmlPretty map out of the per-request
handler function to module scope so it isn't recreated on every invocation:
declare htmlPretty at top-level (module scope) and then inside handler reference
htmlPretty[uri] to compute htmlTarget as before; ensure no other references rely
on closure state so only htmlPretty is relocated and htmlTarget/uri usage inside
handler remains unchanged.
🪄 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: 3b1b6811-2a62-43f2-94a3-dde2fcc1f34a

📥 Commits

Reviewing files that changed from the base of the PR and between 2908f48 and d08cd63.

📒 Files selected for processing (6)
  • infra/terraform/website/cloudfront_functions/redirects.js
  • infra/terraform/website/cloudfront_functions/redirects.test.js
  • website/AGENTS.md
  • website/package.json
  • website/scripts/agents-appendix.md
  • website/scripts/generate-llms-agents.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • infra/terraform/website/cloudfront_functions/redirects.test.js
  • website/package.json
  • website/scripts/generate-llms-agents.ts

Comment thread infra/terraform/website/cloudfront_functions/redirects.js Outdated
Comment thread website/scripts/agents-appendix.md
The CloudFront Function dropped any inbound query params when redirecting
www.iii.dev to iii.dev, breaking inbound links that carried tracking
parameters or auth state. Add a serializeQuerystring helper that walks
the {value, multiValue?} shape, re-encodes keys/values, and append it to
the redirect target. Covered by new tests for multiValue, empty values,
and the no-querystring case.
@github-actions
Copy link
Copy Markdown
Contributor

Terraform plan — infra/terraform/website

Click to expand
aws_acm_certificate.site: Refreshing state... [id=arn:aws:acm:us-east-1:600627348446:certificate/b8e26c06-08b6-4b90-bd59-1f83bc231db2]
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...
aws_cloudfront_function.redirects: Refreshing state... [id=iii-website-prod-redirects]
data.aws_caller_identity.current: Reading...
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 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_tf_plan_trust: Read complete after 0s [id=2702237784]
data.aws_iam_policy_document.github_trust: Read complete after 0s [id=932063200]
aws_iam_role.github_tf_plan: Refreshing state... [id=iii-infra-github-tf-plan]
aws_iam_role.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy]
aws_sns_topic_subscription.email: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms:e50a5c41-0aa3-4855-83ad-8a914562ff95]
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 0s [id=Z05516132AI1ZGB3NLC6D]
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_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_acm_certificate_validation.site: Refreshing state... [id=2026-04-14 00:00:29.305 +0000 UTC]
aws_s3_bucket_versioning.site: Refreshing state... [id=iii-website-prod-us-east-1]
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_cloudfront_distribution.site: Refreshing state... [id=E3N35RPQE4YVFQ]
aws_cloudwatch_metric_alarm.cf_5xx_rate: Refreshing state... [id=iii-website-prod-cf-5xx-rate]
aws_route53_record.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
data.aws_iam_policy_document.site_bucket: Reading...
data.aws_iam_policy_document.github_deploy_website: Reading...
aws_route53_record.preview_aaaa: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_AAAA]
aws_route53_record.apex_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_A]
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
data.aws_iam_policy_document.site_bucket: Read complete after 0s [id=2990320740]
aws_route53_record.apex_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_AAAA]
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
data.aws_iam_policy_document.github_deploy_website: Read complete after 0s [id=1235650417]
aws_s3_bucket_policy.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_iam_policy.github_deploy_website: Refreshing state... [id=arn:aws:iam::600627348446:policy/iii-website-prod-github-deploy]
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

Terraform will perform the following actions:

  # 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.
            
            function redirect(location) {
              return {
                statusCode: 301,
                statusDescription: 'Moved Permanently',
                headers: {
                  location: { value: location },
                  'cache-control': { value: 'public, max-age=3600' },
                },
          +   };
          + }
          + 
          + // 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('&') : '';
            }
            
            // 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) {
          -   var request = event.request
          -   var uri = request.uri
          -   var host = request.headers && request.headers.host ? request.headers.host.value : undefined
          +   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}`)
          +   if (host === 'www.iii.dev') {
          +     return redirect(
          +       `https://iii.dev${uri}${serializeQuerystring(request.querystring)}`,
          +     );
          +   }
            
          -   if (uri.indexOf('/.well-known/') === 0) return request
          +   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;
          +   }
          + 
              // SPA fallback: extensionless path not ending in /
              if (uri !== '/' && uri.charAt(uri.length - 1) !== '/') {
          -     const lastSlash = uri.lastIndexOf('/')
          -     const lastSegment = uri.substring(lastSlash + 1)
          +     const lastSlash = uri.lastIndexOf('/');
          +     const lastSegment = uri.substring(lastSlash + 1);
                if (lastSegment.indexOf('.') === -1) {
          -       request.uri = '/index.html'
          -       return request
          +       request.uri = '/index.html';
          +       return request;
                }
              }
            
          -   return request
          +   return request;
            }
        EOT
        id                           = "iii-website-prod-redirects"
        name                         = "iii-website-prod-redirects"
        # (8 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: 1 to add, 1 to change, 4 to destroy.

Both files are produced by `pnpm --filter website build` via
`scripts/generate-llms-agents.ts` and are already listed in
`website/.gitignore`, but historical copies remained tracked and would
churn on every developer's build (the generator stamps `Last updated:
<today>`). Files stay on disk locally; the build will overwrite them.
@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_cloudfront_origin_access_control.site: Refreshing state... [id=E3GRB5YVVA4332]
aws_cloudfront_function.redirects: Refreshing state... [id=iii-website-prod-redirects]
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]
aws_acm_certificate.site: Refreshing state... [id=arn:aws:acm:us-east-1:600627348446:certificate/b8e26c06-08b6-4b90-bd59-1f83bc231db2]
aws_s3_bucket.site: Refreshing state... [id=iii-website-prod-us-east-1]
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_tf_plan_trust: Read complete after 0s [id=2702237784]
data.aws_iam_policy_document.github_trust: Read complete after 0s [id=932063200]
aws_iam_role.github_tf_plan: Refreshing state... [id=iii-infra-github-tf-plan]
aws_iam_role.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy]
aws_sns_topic_subscription.email: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms:e50a5c41-0aa3-4855-83ad-8a914562ff95]
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_server_side_encryption_configuration.site: Refreshing state... [id=iii-website-prod-us-east-1]
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_cloudfront_distribution.site: Refreshing state... [id=E3N35RPQE4YVFQ]
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
aws_route53_record.preview_aaaa: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_AAAA]
aws_route53_record.apex_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_A]
data.aws_iam_policy_document.site_bucket: Reading...
aws_cloudwatch_metric_alarm.cf_5xx_rate: Refreshing state... [id=iii-website-prod-cf-5xx-rate]
data.aws_iam_policy_document.github_deploy_website: Reading...
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
aws_route53_record.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
data.aws_iam_policy_document.github_deploy_website: Read complete after 0s [id=1235650417]
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_s3_bucket_policy.site: Refreshing state... [id=iii-website-prod-us-east-1]
aws_iam_policy.github_deploy_website: Refreshing state... [id=arn:aws:iam::600627348446:policy/iii-website-prod-github-deploy]
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

Terraform will perform the following actions:

  # 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.
            
            function redirect(location) {
              return {
                statusCode: 301,
                statusDescription: 'Moved Permanently',
                headers: {
                  location: { value: location },
                  'cache-control': { value: 'public, max-age=3600' },
                },
          +   };
          + }
          + 
          + // 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('&') : '';
            }
            
            // 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) {
          -   var request = event.request
          -   var uri = request.uri
          -   var host = request.headers && request.headers.host ? request.headers.host.value : undefined
          +   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}`)
          +   if (host === 'www.iii.dev') {
          +     return redirect(
          +       `https://iii.dev${uri}${serializeQuerystring(request.querystring)}`,
          +     );
          +   }
            
          -   if (uri.indexOf('/.well-known/') === 0) return request
          +   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;
          +   }
          + 
              // SPA fallback: extensionless path not ending in /
              if (uri !== '/' && uri.charAt(uri.length - 1) !== '/') {
          -     const lastSlash = uri.lastIndexOf('/')
          -     const lastSegment = uri.substring(lastSlash + 1)
          +     const lastSlash = uri.lastIndexOf('/');
          +     const lastSegment = uri.substring(lastSlash + 1);
                if (lastSegment.indexOf('.') === -1) {
          -       request.uri = '/index.html'
          -       return request
          +       request.uri = '/index.html';
          +       return request;
                }
              }
            
          -   return request
          +   return request;
            }
        EOT
        id                           = "iii-website-prod-redirects"
        name                         = "iii-website-prod-redirects"
        # (8 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: 1 to add, 1 to change, 4 to destroy.

Adds two regressions on top of the www→apex querystring fix:
- reserved chars (&, =, +, #) in values and spaces in keys must
  percent-encode so they cannot corrupt the 301 target,
- the SPA-fallback path mutates uri but must leave request.querystring
  identical (CloudFront forwards it as-is); pin the no-op so a future
  refactor cannot silently drop it.
@github-actions
Copy link
Copy Markdown
Contributor

Terraform plan — infra/terraform/website

Click to expand
data.aws_route53_zone.iii_dev: Reading...
aws_cloudfront_function.redirects: Refreshing state... [id=iii-website-prod-redirects]
aws_sns_topic.alarms: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms]
aws_iam_openid_connect_provider.github: Refreshing state... [id=arn:aws:iam::600627348446:oidc-provider/token.actions.githubusercontent.com]
aws_acm_certificate.site: Refreshing state... [id=arn:aws:acm:us-east-1:600627348446:certificate/b8e26c06-08b6-4b90-bd59-1f83bc231db2]
aws_cloudfront_response_headers_policy.site: Refreshing state... [id=033b1d66-8d53-48a1-a817-8280d8a60114]
data.aws_caller_identity.current: Reading...
aws_cloudfront_origin_access_control.site: Refreshing state... [id=E3GRB5YVVA4332]
aws_s3_bucket.site: Refreshing state... [id=iii-website-prod-us-east-1]
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_tf_plan: Refreshing state... [id=iii-infra-github-tf-plan]
aws_iam_role.github_deploy_website: Refreshing state... [id=iii-website-prod-github-deploy]
aws_iam_role_policy_attachment.github_tf_plan_readonly: Refreshing state... [id=iii-infra-github-tf-plan-20260414113923643200000001]
aws_sns_topic_subscription.email: Refreshing state... [id=arn:aws:sns:us-east-1:600627348446:iii-website-prod-alarms:e50a5c41-0aa3-4855-83ad-8a914562ff95]
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_s3_bucket_versioning.site: Refreshing state... [id=iii-website-prod-us-east-1]
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_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.www_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_AAAA]
aws_cloudwatch_metric_alarm.cf_5xx_rate: Refreshing state... [id=iii-website-prod-cf-5xx-rate]
data.aws_iam_policy_document.github_deploy_website: Reading...
aws_route53_record.apex_aaaa[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_AAAA]
data.aws_iam_policy_document.site_bucket: Reading...
aws_route53_record.apex_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii.dev_A]
aws_route53_record.preview_a: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_A]
aws_route53_record.preview_aaaa: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_iii-preview.iii.dev_AAAA]
aws_route53_record.www_a[0]: Refreshing state... [id=Z05516132AI1ZGB3NLC6D_www.iii.dev_A]
data.aws_iam_policy_document.github_deploy_website: Read complete after 0s [id=1235650417]
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_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

Terraform will perform the following actions:

  # 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.
            
            function redirect(location) {
              return {
                statusCode: 301,
                statusDescription: 'Moved Permanently',
                headers: {
                  location: { value: location },
                  'cache-control': { value: 'public, max-age=3600' },
                },
          +   };
          + }
          + 
          + // 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('&') : '';
            }
            
            // 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) {
          -   var request = event.request
          -   var uri = request.uri
          -   var host = request.headers && request.headers.host ? request.headers.host.value : undefined
          +   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}`)
          +   if (host === 'www.iii.dev') {
          +     return redirect(
          +       `https://iii.dev${uri}${serializeQuerystring(request.querystring)}`,
          +     );
          +   }
            
          -   if (uri.indexOf('/.well-known/') === 0) return request
          +   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;
          +   }
          + 
              // SPA fallback: extensionless path not ending in /
              if (uri !== '/' && uri.charAt(uri.length - 1) !== '/') {
          -     const lastSlash = uri.lastIndexOf('/')
          -     const lastSegment = uri.substring(lastSlash + 1)
          +     const lastSlash = uri.lastIndexOf('/');
          +     const lastSegment = uri.substring(lastSlash + 1);
                if (lastSegment.indexOf('.') === -1) {
          -       request.uri = '/index.html'
          -       return request
          +       request.uri = '/index.html';
          +       return request;
                }
              }
            
          -   return request
          +   return request;
            }
        EOT
        id                           = "iii-website-prod-redirects"
        name                         = "iii-website-prod-redirects"
        # (8 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: 1 to add, 1 to change, 4 to destroy.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
infra/terraform/website/cloudfront_functions/redirects.test.js (1)

124-133: Clarify SPA test intent and avoid identity-coupled assertion.

The test name says “no rewrite” but Line 132 expects a rewrite to /index.html (it’s “no redirect,” not “no rewrite”). Also, Line 133 uses reference equality for querystring, which makes the test brittle to harmless refactors that clone objects while preserving values.

Proposed test-tightening diff
-test('SPA fallback preserves querystring on the request object (no rewrite)', () => {
+test('SPA fallback rewrites uri and preserves querystring values (no redirect)', () => {
@@
-  assert.equal(result.querystring, qs)
+  assert.deepEqual(result.querystring, qs)
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@infra/terraform/website/cloudfront_functions/redirects.test.js` around lines
124 - 133, Rename or rephrase the test description to reflect "no redirect" (not
"no rewrite") and update assertions in the SPA fallback test: keep calling
buildEvent('/some/route', 'iii.dev', qs) and handler(event), assert that
isRedirect(result) is false and result.uri === '/index.html', but replace the
identity check assert.equal(result.querystring, qs) with a value equality check
(e.g., assert.deepEqual or assert.deepStrictEqual) so the test verifies
preserved querystring values rather than object identity; reference symbols:
test name, handler, isRedirect, buildEvent, result.querystring.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@infra/terraform/website/cloudfront_functions/redirects.test.js`:
- Around line 124-133: Rename or rephrase the test description to reflect "no
redirect" (not "no rewrite") and update assertions in the SPA fallback test:
keep calling buildEvent('/some/route', 'iii.dev', qs) and handler(event), assert
that isRedirect(result) is false and result.uri === '/index.html', but replace
the identity check assert.equal(result.querystring, qs) with a value equality
check (e.g., assert.deepEqual or assert.deepStrictEqual) so the test verifies
preserved querystring values rather than object identity; reference symbols:
test name, handler, isRedirect, buildEvent, result.querystring.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: bd671b73-ea63-4264-9e49-8771480565a4

📥 Commits

Reviewing files that changed from the base of the PR and between d08cd63 and 874c5b6.

📒 Files selected for processing (3)
  • infra/terraform/website/cloudfront_functions/redirects.js
  • infra/terraform/website/cloudfront_functions/redirects.test.js
  • website/llms.txt
💤 Files with no reviewable changes (1)
  • website/llms.txt
🚧 Files skipped from review as they are similar to previous changes (1)
  • infra/terraform/website/cloudfront_functions/redirects.js

@anthonyiscoding anthonyiscoding merged commit 969d003 into main Apr 29, 2026
36 of 37 checks passed
@anthonyiscoding anthonyiscoding deleted the fix/site-nits-2 branch April 29, 2026 20:49
ytallo added a commit that referenced this pull request Apr 29, 2026
Two changes:

1. Strip stale `# Leave false until Phase 4 cutover…` comments from
   manage_apex_records / manage_www_records — the variable description
   field already says what they do, and the cutover narrative they
   preserved is no longer load-bearing.

2. Add a tf-apply pipeline (`.github/workflows/tf-apply.yml`) so changes
   under `infra/terraform/website/` actually deploy on merge to main.
   Previously only `tf-plan.yml` ran on PRs and applies were manual,
   which is how the cleanUrls fix sat unapplied for hours after #1576
   merged.

   - New IAM role `iii-website-prod-github-tf-apply` (AdministratorAccess,
     trust narrowly scoped to a new `iii-website-prod-tf-apply` env so
     repo settings can require reviewers without gating routine S3
     deploys).
   - Workflow runs on push to main + workflow_dispatch, uses concurrency
     `tf-apply-website` to serialize applies, captures output to the job
     summary.

Bootstrap (one-time, manual):
  AWS_PROFILE=motia-prod terraform apply
  → grab `github_tf_apply_role_arn` from outputs
  → set repo secret `AWS_TF_APPLY_ROLE_ARN`
  → create `iii-website-prod-tf-apply` GitHub environment with required
    reviewers
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