diff --git a/.gitignore b/.gitignore
index 69374fe71cc..1007e9496bf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -109,3 +109,8 @@ cookbooks
# Autoscaling simulation output
/scaling-graph.csv
/scaling-graph.png
+
+# Terraform
+**/.terraform/
+**/.terraform.lock.hcl
+**/og_router.zip
diff --git a/app/controllers/cdn_spa_shell_controller.rb b/app/controllers/cdn_spa_shell_controller.rb
new file mode 100644
index 00000000000..2a7949167ef
--- /dev/null
+++ b/app/controllers/cdn_spa_shell_controller.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# Serves a minimal SPA shell with only convention-level Open Graph metadata.
+# Intended as the CloudFront origin for regular (non-crawler) user requests;
+# crawler requests are routed to OgShellController by a Lambda@Edge function.
+class CdnSpaShellController < ApplicationController
+ skip_before_action :ensure_user_con_profile_exists
+ skip_before_action :redirect_if_user_con_profile_needs_update
+ skip_before_action :ensure_clickwrap_agreement_accepted
+
+ def show
+ render html: "".html_safe, layout: "cdn_spa_shell"
+ end
+end
diff --git a/app/controllers/og_shell_controller.rb b/app/controllers/og_shell_controller.rb
new file mode 100644
index 00000000000..4db9f396111
--- /dev/null
+++ b/app/controllers/og_shell_controller.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+# Returns a minimal HTML document with Open Graph meta tags for a given path.
+# Intended to be served to crawlers by a CDN edge function; regular browsers
+# receive the static SPA shell instead.
+class OgShellController < ApplicationController
+ include CmsContentHelpers
+
+ skip_before_action :ensure_user_con_profile_exists
+ skip_before_action :redirect_if_user_con_profile_needs_update
+ skip_before_action :ensure_clickwrap_agreement_accepted
+
+ def show
+ path = params[:path].presence || "/"
+ @og_url = "#{request.scheme}://#{request.host_with_port}#{path}"
+ @event = event_for_path(path)
+ @page = current_cms_page(path)
+ render layout: false
+ end
+end
diff --git a/app/views/layouts/cdn_spa_shell.html.erb b/app/views/layouts/cdn_spa_shell.html.erb
new file mode 100644
index 00000000000..d6b4e204a6f
--- /dev/null
+++ b/app/views/layouts/cdn_spa_shell.html.erb
@@ -0,0 +1,32 @@
+
+
+
+
+ <%= convention ? convention.name : "Intercode" %>
+ <% if Rails.env.development? %>
+
+ <%= javascript_include_tag url_with_possible_host("/@vite/client", ENV['ASSETS_HOST']), type: 'module' %>
+ <% end %>
+ <%= javascript_include_tag url_with_possible_host(application_entry_path, ENV['ASSETS_HOST']), defer: true, type: 'module' %>
+
+
+ ">
+
+ <% if convention&.open_graph_image&.attachment -%>
+
+ <% end -%>
+
+ <% if convention&.favicon&.attachment -%>
+
+ <% end -%>
+
+
+ <%= yield %>
+
+
diff --git a/app/views/og_shell/show.html.erb b/app/views/og_shell/show.html.erb
new file mode 100644
index 00000000000..9a2d45c45a0
--- /dev/null
+++ b/app/views/og_shell/show.html.erb
@@ -0,0 +1,29 @@
+
+
+
+
+ <% if @event -%>
+ <%= @event.title %>
+
+
+
+ <% elsif @page -%>
+ <%= @page.name %><%= " - #{convention.name}" if convention %>
+
+ <% if @page.cached_og_description.present? -%>
+
+
+ <% end -%>
+ <% elsif convention -%>
+ <%= convention.name %>
+
+ <% end -%>
+
+
+ <% if convention&.open_graph_image&.attachment -%>
+
+ <% end -%>
+
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index a6506aebe49..63839181de0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -12,6 +12,8 @@
devise_for :users, controllers: { passwords: "passwords", registrations: "registrations", sessions: "sessions" }
get "/authenticity_tokens", to: "authenticity_tokens#show"
get "/client_configuration", to: "client_configuration#show"
+ get "/cdn-spa-shell", to: "cdn_spa_shell#show"
+ get "/og-shell", to: "og_shell#show"
post "/oauth_session/exchange", to: "oauth_sessions#exchange"
post "/oauth_session/refresh", to: "oauth_sessions#refresh"
post "/oauth_session/sign_out", to: "oauth_sessions#sign_out"
diff --git a/lambda/cloudfront-og-router/README.md b/lambda/cloudfront-og-router/README.md
new file mode 100644
index 00000000000..4ffb850b6e8
--- /dev/null
+++ b/lambda/cloudfront-og-router/README.md
@@ -0,0 +1,54 @@
+# cloudfront-og-router
+
+Lambda@Edge origin-request handler that routes CloudFront traffic to the
+appropriate Intercode endpoint based on whether the request is from a crawler.
+
+## How it works
+
+| Request type | Rewrites to |
+| ------------------------ | ------------------------------------ |
+| Known crawler User-Agent | `GET /og-shell?path=` |
+| Everyone else | `GET /cdn-spa-shell` |
+
+`/og-shell` returns a minimal HTML document with per-resource Open Graph tags
+(event title/description, page `cached_og_description`, convention OG image).
+
+`/cdn-spa-shell` returns a lightweight SPA shell with convention-level OG only
+and no per-path DB lookups — suitable for long-term CDN caching.
+
+## CloudFront distribution setup
+
+The function attaches to the **origin request** event of the **default cache
+behaviour** (i.e. the catch-all for SPA routes). More-specific behaviours must
+be defined first so they take precedence:
+
+| Path pattern | Origin | Lambda@Edge | Cache TTL |
+| ----------------------- | ------------- | ------------------------------ | ------------------------------------------- |
+| `/packs/*` | Assets server | None | Long (immutable) |
+| `/client_configuration` | Rails | None | No cache |
+| `/authenticity_tokens` | Rails | None | No cache |
+| `/oauth_session/*` | Rails | None | No cache |
+| `/og-shell` | Rails | None | Medium (keyed on `path` query param + Host) |
+| `/cdn-spa-shell` | Rails | None | Long (keyed on Host) |
+| `*` (default) | Rails | This function (origin request) | Long (keyed on Host) |
+
+### Cache key notes
+
+- `/cdn-spa-shell`: include the `Host` header in the cache key (content varies
+ per convention domain). TTL of several hours is fine — cache is busted
+ whenever the convention's name or OG image changes by deploying a new
+ container.
+- `/og-shell`: include `Host` + the `path` query parameter. TTL of ~1 hour is
+ reasonable; `cached_og_description` is refreshed in the background on each
+ page save.
+- Default behaviour: long TTL is fine since the function always rewrites to one
+ of the above two cached paths.
+
+## Deployment
+
+The function must be deployed to **us-east-1** (Lambda@Edge requirement) and
+associated with the CloudFront distribution. No dependencies — zip just
+`index.mjs` and `package.json`.
+
+Runtime: `nodejs22.x`
+Handler: `index.handler`
diff --git a/lambda/cloudfront-og-router/index.mjs b/lambda/cloudfront-og-router/index.mjs
new file mode 100644
index 00000000000..fc16a26c004
--- /dev/null
+++ b/lambda/cloudfront-og-router/index.mjs
@@ -0,0 +1,39 @@
+// Lambda@Edge origin-request handler for Intercode CloudFront distribution.
+//
+// CloudFront behaviour: attach to the default cache behaviour, which only
+// fires after all more-specific behaviours (Rails routes, /packs/*, /og-shell,
+// /cdn-spa-shell, etc.) have already been evaluated and not matched.
+//
+// Logic:
+// - Known crawler User-Agents → /og-shell?path=
+// - Everyone else → /cdn-spa-shell
+
+const CRAWLER_UA_RE =
+ /Googlebot|bingbot|DuckDuckBot|YandexBot|Baiduspider|facebookexternalhit|Twitterbot|LinkedInBot|WhatsApp|Discordbot|Slackbot|Applebot|Embedly|Pinterest/i;
+
+// Paths that belong to the underlying Rails app and must never be rewritten.
+// This mirrors the specific CloudFront behaviours defined in the Terraform
+// module and acts as a safety net if a new route is added without a
+// corresponding behaviour.
+const RAILS_PATH_RE =
+ /^\/(users|oauth|oauth_session|authenticity_tokens|client_configuration|graphql|graphiql|email_forwarders|sns_notifications|stripe_webhook|stripe_account|healthz|sitemap\.xml|reports|calendars|csv_exports|user_con_profiles|rails|uploads|packs|cdn-spa-shell|og-shell)(\/|$)/;
+
+export const handler = async (event) => {
+ const { request } = event.Records[0].cf;
+ const ua = request.headers["user-agent"]?.[0]?.value ?? "";
+ const originalPath = request.uri;
+
+ if (RAILS_PATH_RE.test(originalPath)) {
+ return request;
+ }
+
+ if (CRAWLER_UA_RE.test(ua)) {
+ request.uri = "/og-shell";
+ request.querystring = `path=${encodeURIComponent(originalPath)}`;
+ } else {
+ request.uri = "/cdn-spa-shell";
+ request.querystring = "";
+ }
+
+ return request;
+};
diff --git a/lambda/cloudfront-og-router/package.json b/lambda/cloudfront-og-router/package.json
new file mode 100644
index 00000000000..6884237ea6e
--- /dev/null
+++ b/lambda/cloudfront-og-router/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "cloudfront-og-router",
+ "version": "1.0.0",
+ "description": "Lambda@Edge origin-request handler for Intercode CloudFront distribution",
+ "type": "module",
+ "main": "index.mjs"
+}
diff --git a/package.json b/package.json
index b6a7afa4fb6..66a06695310 100644
--- a/package.json
+++ b/package.json
@@ -230,7 +230,6 @@
"vite": "^8.0.0",
"vite-bundle-visualizer": "^1.2.1",
"vite-plugin-node-polyfills": "^0.28.0",
- "vite-tsconfig-paths": "^6.0.0",
"vitest": "^4.0.0",
"wrangler": "^4.59.1"
},
diff --git a/terraform/modules/cloudfront_intercode/README.md b/terraform/modules/cloudfront_intercode/README.md
new file mode 100644
index 00000000000..e618823d930
--- /dev/null
+++ b/terraform/modules/cloudfront_intercode/README.md
@@ -0,0 +1,79 @@
+# cloudfront_intercode Terraform module
+
+Creates a CloudFront distribution in front of an Intercode Rails origin with:
+
+- Long-TTL caching of `/packs/*` from the assets server
+- No-cache pass-through for API/auth endpoints
+- Crawler detection via a Lambda@Edge origin-request function:
+ - Crawlers → `/og-shell?path=` (per-resource OG metadata)
+ - Regular users → `/cdn-spa-shell` (lightweight shell, convention-level OG only)
+
+## Usage
+
+```hcl
+provider "aws" {
+ region = "us-east-1"
+ alias = "us_east_1"
+}
+
+module "cloudfront" {
+ source = "path/to/modules/cloudfront_intercode"
+
+ providers = {
+ aws = aws # your default provider
+ aws.us_east_1 = aws.us_east_1
+ }
+
+ name = "intercode-production"
+ rails_origin_domain = "www.neilhosting.net"
+ assets_origin_domain = "assets.neilhosting.net"
+ aliases = ["interconx.com", "www.interconx.com"]
+ acm_certificate_arn = "arn:aws:acm:us-east-1:123456789:certificate/abc-123"
+}
+
+# Point convention domains at the distribution.
+resource "aws_route53_record" "interconx" {
+ zone_id = data.aws_route53_zone.interconx.zone_id
+ name = "interconx.com"
+ type = "A"
+
+ alias {
+ name = module.cloudfront.distribution_domain_name
+ zone_id = module.cloudfront.distribution_hosted_zone_id
+ evaluate_target_health = false
+ }
+}
+```
+
+## Provider aliases
+
+Lambda@Edge functions must be deployed in `us-east-1`. This module requires a
+provider alias named `aws.us_east_1` configured for that region. Pass it via
+the `providers` map as shown above.
+
+## Host header forwarding
+
+Intercode uses the `Host` header to resolve the convention for each request.
+The origin-request policies in this module forward the viewer's `Host` header
+to the Rails origin, so multi-tenant routing works correctly behind CloudFront.
+
+## Inputs
+
+| Name | Description | Default |
+| ---------------------- | -------------------------------------------- | ---------------- |
+| `name` | Resource name prefix | — |
+| `rails_origin_domain` | Rails app domain | — |
+| `assets_origin_domain` | Assets server domain | — |
+| `aliases` | CloudFront CNAMEs | `[]` |
+| `acm_certificate_arn` | ACM certificate ARN (us-east-1) | `null` |
+| `price_class` | CloudFront price class | `PriceClass_100` |
+| `og_shell_ttl` | TTL for `/og-shell` responses (seconds) | `3600` |
+| `cdn_spa_shell_ttl` | TTL for `/cdn-spa-shell` responses (seconds) | `86400` |
+
+## Outputs
+
+| Name | Description |
+| ----------------------------- | ------------------------------------------ |
+| `distribution_id` | CloudFront distribution ID |
+| `distribution_domain_name` | Use as CNAME target for convention domains |
+| `distribution_hosted_zone_id` | Use for Route 53 alias records |
diff --git a/terraform/modules/cloudfront_intercode/lambda.tf b/terraform/modules/cloudfront_intercode/lambda.tf
new file mode 100644
index 00000000000..38fc5a4a727
--- /dev/null
+++ b/terraform/modules/cloudfront_intercode/lambda.tf
@@ -0,0 +1,55 @@
+data "archive_file" "og_router" {
+ type = "zip"
+ output_path = "${path.module}/og_router.zip"
+
+ source {
+ content = file("${path.module}/../../../lambda/cloudfront-og-router/index.mjs")
+ filename = "index.mjs"
+ }
+
+ source {
+ content = file("${path.module}/../../../lambda/cloudfront-og-router/package.json")
+ filename = "package.json"
+ }
+}
+
+resource "aws_iam_role" "lambda_edge" {
+ name = "${var.name}-lambda-edge"
+
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Effect = "Allow"
+ Principal = {
+ Service = [
+ "lambda.amazonaws.com",
+ "edgelambda.amazonaws.com",
+ ]
+ }
+ Action = "sts:AssumeRole"
+ }
+ ]
+ })
+}
+
+resource "aws_iam_role_policy_attachment" "lambda_edge_basic" {
+ role = aws_iam_role.lambda_edge.name
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
+}
+
+# Lambda@Edge functions must be deployed in us-east-1.
+resource "aws_lambda_function" "og_router" {
+ provider = aws.us_east_1
+
+ function_name = "${var.name}-og-router"
+ filename = data.archive_file.og_router.output_path
+ source_code_hash = data.archive_file.og_router.output_base64sha256
+ role = aws_iam_role.lambda_edge.arn
+ handler = "index.handler"
+ runtime = "nodejs22.x"
+
+ # Lambda@Edge requires a numbered version — publish = true creates one on
+ # every deployment.
+ publish = true
+}
diff --git a/terraform/modules/cloudfront_intercode/main.tf b/terraform/modules/cloudfront_intercode/main.tf
new file mode 100644
index 00000000000..e3435a537fd
--- /dev/null
+++ b/terraform/modules/cloudfront_intercode/main.tf
@@ -0,0 +1,279 @@
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 5.0"
+ configuration_aliases = [aws.us_east_1]
+ }
+ archive = {
+ source = "hashicorp/archive"
+ version = ">= 2.0"
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Origins
+# ---------------------------------------------------------------------------
+
+locals {
+ rails_origin_id = "rails"
+ assets_origin_id = "assets"
+}
+
+# ---------------------------------------------------------------------------
+# Cache / origin-request policies
+# ---------------------------------------------------------------------------
+
+# Forward the viewer Host header so Intercode can resolve the convention.
+resource "aws_cloudfront_origin_request_policy" "forward_host" {
+ name = "${var.name}-forward-host"
+
+ cookies_config { cookie_behavior = "none" }
+ query_strings_config { query_string_behavior = "none" }
+
+ headers_config {
+ header_behavior = "whitelist"
+ headers {
+ items = ["Host"]
+ }
+ }
+}
+
+# /og-shell: forward Host + the `path` query param so each path caches
+# separately per convention domain.
+resource "aws_cloudfront_origin_request_policy" "og_shell" {
+ name = "${var.name}-og-shell"
+
+ cookies_config { cookie_behavior = "none" }
+
+ headers_config {
+ header_behavior = "whitelist"
+ headers {
+ items = ["Host"]
+ }
+ }
+
+ query_strings_config {
+ query_string_behavior = "whitelist"
+ query_strings {
+ items = ["path"]
+ }
+ }
+}
+
+resource "aws_cloudfront_cache_policy" "no_cache" {
+ name = "${var.name}-no-cache"
+ default_ttl = 0
+ max_ttl = 0
+ min_ttl = 0
+
+ parameters_in_cache_key_and_forwarded_to_origin {
+ cookies_config { cookie_behavior = "none" }
+ headers_config { header_behavior = "none" }
+ query_strings_config { query_string_behavior = "none" }
+ enable_accept_encoding_gzip = false
+ enable_accept_encoding_brotli = false
+ }
+}
+
+# /cdn-spa-shell: cache keyed on Host so each convention gets its own entry.
+resource "aws_cloudfront_cache_policy" "cdn_spa_shell" {
+ name = "${var.name}-cdn-spa-shell"
+ default_ttl = var.cdn_spa_shell_ttl
+ max_ttl = var.cdn_spa_shell_ttl
+ min_ttl = 0
+
+ parameters_in_cache_key_and_forwarded_to_origin {
+ cookies_config { cookie_behavior = "none" }
+ headers_config {
+ header_behavior = "whitelist"
+ headers {
+ items = ["Host"]
+ }
+ }
+ query_strings_config { query_string_behavior = "none" }
+ enable_accept_encoding_gzip = true
+ enable_accept_encoding_brotli = true
+ }
+}
+
+# /og-shell: cache keyed on Host + path query param.
+resource "aws_cloudfront_cache_policy" "og_shell" {
+ name = "${var.name}-og-shell"
+ default_ttl = var.og_shell_ttl
+ max_ttl = var.og_shell_ttl
+ min_ttl = 0
+
+ parameters_in_cache_key_and_forwarded_to_origin {
+ cookies_config { cookie_behavior = "none" }
+ headers_config {
+ header_behavior = "whitelist"
+ headers {
+ items = ["Host"]
+ }
+ }
+ query_strings_config {
+ query_string_behavior = "whitelist"
+ query_strings {
+ items = ["path"]
+ }
+ }
+ enable_accept_encoding_gzip = true
+ enable_accept_encoding_brotli = true
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Distribution
+# ---------------------------------------------------------------------------
+
+resource "aws_cloudfront_distribution" "this" {
+ enabled = true
+ is_ipv6_enabled = true
+ price_class = var.price_class
+ aliases = var.aliases
+ comment = var.name
+
+ # Rails origin
+ origin {
+ origin_id = local.rails_origin_id
+ domain_name = var.rails_origin_domain
+
+ custom_origin_config {
+ http_port = 80
+ https_port = 443
+ origin_protocol_policy = "https-only"
+ origin_ssl_protocols = ["TLSv1.2"]
+ }
+ }
+
+ # Assets origin (nginx serving /packs)
+ origin {
+ origin_id = local.assets_origin_id
+ domain_name = var.assets_origin_domain
+
+ custom_origin_config {
+ http_port = 80
+ https_port = 443
+ origin_protocol_policy = "https-only"
+ origin_ssl_protocols = ["TLSv1.2"]
+ }
+ }
+
+ # /packs/* — immutable assets, long cache, served from assets origin.
+ ordered_cache_behavior {
+ path_pattern = "/packs/*"
+ target_origin_id = local.assets_origin_id
+ allowed_methods = ["GET", "HEAD"]
+ cached_methods = ["GET", "HEAD"]
+ compress = true
+
+ # CachingOptimized managed policy
+ cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6"
+
+ viewer_protocol_policy = "redirect-to-https"
+ }
+
+ # No-cache pass-through behaviours for all non-shell Rails routes.
+ dynamic "ordered_cache_behavior" {
+ for_each = [
+ # Auth / session
+ "/users/*",
+ "/oauth/*",
+ "/oauth_session/*",
+ "/authenticity_tokens",
+ "/client_configuration",
+ # App-server APIs
+ "/graphql",
+ "/graphiql",
+ "/email_forwarders/*",
+ "/sns_notifications",
+ "/stripe_webhook/*",
+ "/stripe_account/*",
+ "/healthz",
+ "/sitemap.xml",
+ # Convention-scoped HTML routes
+ "/reports/*",
+ "/calendars/*",
+ "/csv_exports/*",
+ "/user_con_profiles/*",
+ # Active Storage / uploads
+ "/rails/active_storage/*",
+ "/uploads/*",
+ ]
+ content {
+ path_pattern = ordered_cache_behavior.value
+ target_origin_id = local.rails_origin_id
+ allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
+ cached_methods = ["GET", "HEAD"]
+ compress = true
+ cache_policy_id = aws_cloudfront_cache_policy.no_cache.id
+ origin_request_policy_id = aws_cloudfront_origin_request_policy.forward_host.id
+ viewer_protocol_policy = "redirect-to-https"
+ }
+ }
+
+ # /cdn-spa-shell — lightweight SPA shell for regular CloudFront users.
+ ordered_cache_behavior {
+ path_pattern = "/cdn-spa-shell"
+ target_origin_id = local.rails_origin_id
+ allowed_methods = ["GET", "HEAD"]
+ cached_methods = ["GET", "HEAD"]
+ compress = true
+ cache_policy_id = aws_cloudfront_cache_policy.cdn_spa_shell.id
+ origin_request_policy_id = aws_cloudfront_origin_request_policy.forward_host.id
+ viewer_protocol_policy = "redirect-to-https"
+ }
+
+ # /og-shell — per-resource OG shell for crawlers.
+ ordered_cache_behavior {
+ path_pattern = "/og-shell"
+ target_origin_id = local.rails_origin_id
+ allowed_methods = ["GET", "HEAD"]
+ cached_methods = ["GET", "HEAD"]
+ compress = true
+ cache_policy_id = aws_cloudfront_cache_policy.og_shell.id
+ origin_request_policy_id = aws_cloudfront_origin_request_policy.og_shell.id
+ viewer_protocol_policy = "redirect-to-https"
+ }
+
+ # Default — all SPA routes. Lambda@Edge rewrites to /cdn-spa-shell or /og-shell.
+ default_cache_behavior {
+ target_origin_id = local.rails_origin_id
+ allowed_methods = ["GET", "HEAD"]
+ cached_methods = ["GET", "HEAD"]
+ compress = true
+ cache_policy_id = aws_cloudfront_cache_policy.cdn_spa_shell.id
+ origin_request_policy_id = aws_cloudfront_origin_request_policy.forward_host.id
+ viewer_protocol_policy = "redirect-to-https"
+
+ lambda_function_association {
+ event_type = "origin-request"
+ lambda_arn = aws_lambda_function.og_router.qualified_arn
+ include_body = false
+ }
+ }
+
+ dynamic "viewer_certificate" {
+ for_each = var.acm_certificate_arn != null ? [1] : []
+ content {
+ acm_certificate_arn = var.acm_certificate_arn
+ ssl_support_method = "sni-only"
+ minimum_protocol_version = "TLSv1.2_2021"
+ }
+ }
+
+ dynamic "viewer_certificate" {
+ for_each = var.acm_certificate_arn == null ? [1] : []
+ content {
+ cloudfront_default_certificate = true
+ }
+ }
+
+ restrictions {
+ geo_restriction {
+ restriction_type = "none"
+ }
+ }
+}
diff --git a/terraform/modules/cloudfront_intercode/outputs.tf b/terraform/modules/cloudfront_intercode/outputs.tf
new file mode 100644
index 00000000000..80a880f1bc4
--- /dev/null
+++ b/terraform/modules/cloudfront_intercode/outputs.tf
@@ -0,0 +1,14 @@
+output "distribution_id" {
+ description = "CloudFront distribution ID."
+ value = aws_cloudfront_distribution.this.id
+}
+
+output "distribution_domain_name" {
+ description = "CloudFront distribution domain name (e.g. d1234.cloudfront.net). Use this as the CNAME target for convention domains."
+ value = aws_cloudfront_distribution.this.domain_name
+}
+
+output "distribution_hosted_zone_id" {
+ description = "CloudFront hosted zone ID. Use this for Route 53 alias records."
+ value = aws_cloudfront_distribution.this.hosted_zone_id
+}
diff --git a/terraform/modules/cloudfront_intercode/variables.tf b/terraform/modules/cloudfront_intercode/variables.tf
new file mode 100644
index 00000000000..c1b3cf2009f
--- /dev/null
+++ b/terraform/modules/cloudfront_intercode/variables.tf
@@ -0,0 +1,44 @@
+variable "name" {
+ description = "Prefix for all resource names created by this module."
+ type = string
+}
+
+variable "rails_origin_domain" {
+ description = "Domain name of the Rails origin (e.g. www.neilhosting.net)."
+ type = string
+}
+
+variable "assets_origin_domain" {
+ description = "Domain name of the assets server (e.g. assets.neilhosting.net)."
+ type = string
+}
+
+variable "aliases" {
+ description = "Additional CNAMEs for the CloudFront distribution (convention domains)."
+ type = list(string)
+ default = []
+}
+
+variable "acm_certificate_arn" {
+ description = "ARN of an ACM certificate in us-east-1 covering all aliases. Required when aliases is non-empty."
+ type = string
+ default = null
+}
+
+variable "price_class" {
+ description = "CloudFront price class."
+ type = string
+ default = "PriceClass_100"
+}
+
+variable "og_shell_ttl" {
+ description = "Cache TTL in seconds for /og-shell responses."
+ type = number
+ default = 3600
+}
+
+variable "cdn_spa_shell_ttl" {
+ description = "Cache TTL in seconds for /cdn-spa-shell responses."
+ type = number
+ default = 86400
+}
diff --git a/terraform/modules/forwardemail_receiving/README.md b/terraform/modules/forwardemail_receiving/README.md
new file mode 100644
index 00000000000..50e658e8d60
--- /dev/null
+++ b/terraform/modules/forwardemail_receiving/README.md
@@ -0,0 +1,37 @@
+# forwardemail_receiving Terraform module
+
+Fetches domain and verification record data from the forwardemail.net API and
+exposes a `domain => verification_code` map. Use the output to wire individual
+domains into a `forwardemail_receiving_domain` DNS module.
+
+No AWS resources are created; no IAM policy attachment is required.
+
+## Usage
+
+```hcl
+module "forwardemail" {
+ source = "path/to/modules/forwardemail_receiving"
+ api_key = var.forwardemail_api_key
+}
+
+module "my_domain_forwardemail" {
+ source = "github.com/neinteractiveliterature/neil-terraform-modules//forwardemail_receiving_domain?ref=v1.0.0"
+
+ cloudflare_zone = cloudflare_zone.my_domain
+ name = "my-domain.com"
+ verification_code = module.forwardemail.verification_records_by_domain["my-domain.com"]
+}
+```
+
+## Inputs
+
+| Name | Description | Default |
+| ------------ | ---------------------------------------------- | ------- |
+| `api_key` | forwardemail.net API key (sensitive) | — |
+| `page_count` | Pages to fetch from the API (100 domains/page) | `2` |
+
+## Outputs
+
+| Name | Description |
+| -------------------------------- | ---------------------------------------- |
+| `verification_records_by_domain` | Map of domain name → verification record |
diff --git a/terraform/modules/forwardemail_receiving/main.tf b/terraform/modules/forwardemail_receiving/main.tf
new file mode 100644
index 00000000000..fecfb87fcb1
--- /dev/null
+++ b/terraform/modules/forwardemail_receiving/main.tf
@@ -0,0 +1,35 @@
+terraform {
+ required_providers {
+ http = {
+ source = "hashicorp/http"
+ version = ">= 3.0"
+ }
+ }
+}
+
+data "http" "domains" {
+ count = var.page_count
+
+ url = "https://api.forwardemail.net/v1/domains?page=${count.index + 1}"
+
+ request_headers = {
+ Authorization = "Basic ${base64encode("${var.api_key}:")}"
+ }
+}
+
+locals {
+ pages = [for page in data.http.domains : jsondecode(page.body)]
+ domains = toset(concat(local.pages...))
+
+ # The API can occasionally return duplicate entries across pages; take the
+ # first verification record for each domain.
+ verification_records_with_dupes = {
+ for domain in local.domains :
+ domain["name"] => domain["verification_record"]...
+ }
+
+ verification_records_by_domain = {
+ for domain, records in local.verification_records_with_dupes :
+ domain => records[0]
+ }
+}
diff --git a/terraform/modules/forwardemail_receiving/outputs.tf b/terraform/modules/forwardemail_receiving/outputs.tf
new file mode 100644
index 00000000000..b54a1d59179
--- /dev/null
+++ b/terraform/modules/forwardemail_receiving/outputs.tf
@@ -0,0 +1,4 @@
+output "verification_records_by_domain" {
+ description = "Map of domain name to forwardemail verification record. Pass individual values to a forwardemail_receiving_domain DNS module."
+ value = local.verification_records_by_domain
+}
diff --git a/terraform/modules/forwardemail_receiving/variables.tf b/terraform/modules/forwardemail_receiving/variables.tf
new file mode 100644
index 00000000000..942a1282fbf
--- /dev/null
+++ b/terraform/modules/forwardemail_receiving/variables.tf
@@ -0,0 +1,11 @@
+variable "api_key" {
+ description = "forwardemail.net API key."
+ type = string
+ sensitive = true
+}
+
+variable "page_count" {
+ description = "Number of domain list pages to fetch from the forwardemail API (100 domains per page). Increase if the account has more than 100 * page_count domains."
+ type = number
+ default = 2
+}
diff --git a/terraform/modules/intercode_aws_resources/README.md b/terraform/modules/intercode_aws_resources/README.md
new file mode 100644
index 00000000000..06f3b1c534e
--- /dev/null
+++ b/terraform/modules/intercode_aws_resources/README.md
@@ -0,0 +1,64 @@
+# intercode_aws_resources Terraform module
+
+Provisions the core AWS resources the Intercode app needs to run:
+
+- **SQS queues** — `default`, `mailers`, `ahoy`, and a shared `dead_letter` queue (consumed by Shoryuken)
+- **S3 bucket** — for uploaded CMS content and product images
+- **IAM user + group + policy** — grants the app access to SQS, S3, SES, and optionally SNS/KMS
+- **CloudWatch alarm** — fires when the oldest message in any non-DLQ queue exceeds 10 minutes
+
+Email receiving is handled separately; wire in an email module's outputs via
+the optional `inbox_bucket_arn`, `inbox_sns_topic_arn`, and `kms_key_arn`
+variables. See `ses_email_receiving` for one such module.
+
+## Usage
+
+```hcl
+module "ses_email" {
+ source = "path/to/modules/ses_email_receiving"
+
+ name = "intercode_production"
+ inbox_bucket_name = "intercode-inbox"
+ sns_notification_endpoint = "https://www.neilhosting.net/sns_notifications"
+}
+
+module "intercode_aws" {
+ source = "path/to/modules/intercode_aws_resources"
+
+ name = "intercode_production"
+ s3_bucket_name = "intercode2-production"
+
+ alarm_email_destinations = ["ops@example.com"]
+}
+
+# Email-receiving modules attach their own policies using iam_group_name.
+module "ses_email" {
+ source = "path/to/modules/ses_email_receiving"
+
+ name = "intercode_production"
+ inbox_bucket_name = "intercode-inbox"
+ sns_notification_endpoint = "https://www.neilhosting.net/sns_notifications"
+ iam_group_name = module.intercode_aws.iam_group_name
+}
+```
+
+## Inputs
+
+| Name | Description | Default |
+| -------------------------- | ----------------------------------------- | ------- |
+| `name` | Prefix for SQS queues and IAM resources | — |
+| `s3_bucket_name` | Uploads S3 bucket name | — |
+| `alarm_email_destinations` | Emails for CloudWatch alarm notifications | `[]` |
+
+## Outputs
+
+| Name | Description |
+| ----------------------- | ------------------------------------------------------------------------ |
+| `iam_group_name` | IAM group name — pass to email modules so they can attach their policies |
+| `s3_bucket_name` | Uploads bucket name |
+| `s3_bucket_arn` | Uploads bucket ARN |
+| `sqs_queue_urls` | Map of queue name → URL |
+| `sqs_queue_arns` | Map of queue name → ARN |
+| `alarm_sns_topic_arn` | CloudWatch alarms SNS topic ARN |
+| `iam_access_key_id` | App IAM access key ID |
+| `iam_access_key_secret` | App IAM secret access key (sensitive) |
diff --git a/terraform/modules/intercode_aws_resources/iam.tf b/terraform/modules/intercode_aws_resources/iam.tf
new file mode 100644
index 00000000000..8884999f1dc
--- /dev/null
+++ b/terraform/modules/intercode_aws_resources/iam.tf
@@ -0,0 +1,95 @@
+locals {
+ region = data.aws_region.current.region
+ account_id = data.aws_caller_identity.current.account_id
+}
+
+resource "aws_iam_group" "this" {
+ name = var.name
+}
+
+resource "aws_iam_group_policy" "this" {
+ name = var.name
+ group = aws_iam_group.this.name
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Sid = "S3ObjectAccess"
+ Effect = "Allow"
+ Action = [
+ "s3:GetObjectVersion",
+ "s3:DeleteObjectVersion",
+ "s3:DeleteObject",
+ "s3:GetObject",
+ "s3:GetObjectAcl",
+ "s3:PutObject",
+ "s3:PutObjectAcl",
+ "s3:RestoreObject",
+ ]
+ Resource = "${aws_s3_bucket.uploads.arn}/*"
+ },
+ {
+ Sid = "S3BucketAccess"
+ Effect = "Allow"
+ Action = ["s3:GetBucketLocation", "s3:ListAllMyBuckets", "s3:ListBucket"]
+ Resource = "arn:aws:s3:::*"
+ },
+ {
+ Sid = "SqsAccess"
+ Effect = "Allow"
+ Action = [
+ "sqs:ChangeMessageVisibility",
+ "sqs:ChangeMessageVisibilityBatch",
+ "sqs:DeleteMessage",
+ "sqs:DeleteMessageBatch",
+ "sqs:GetQueueAttributes",
+ "sqs:GetQueueUrl",
+ "sqs:ReceiveMessage",
+ "sqs:SendMessage",
+ "sqs:SendMessageBatch",
+ "sqs:ListQueues",
+ ]
+ Resource = "arn:aws:sqs:${local.region}:${local.account_id}:${var.name}_*"
+ },
+ {
+ Sid = "SesAccess"
+ Effect = "Allow"
+ Action = ["ses:SendRawEmail", "ses:SendBounce"]
+ Resource = "*"
+ },
+ {
+ Sid = "CloudwatchSchedulerProvisioning"
+ Effect = "Allow"
+ Action = [
+ "sqs:CreateQueue",
+ "sqs:GetQueueAttributes",
+ "sqs:SetQueueAttributes",
+ ]
+ Resource = [
+ "arn:aws:sqs:${local.region}:${local.account_id}:${var.name}_cloudwatch_scheduler",
+ "arn:aws:sqs:${local.region}:${local.account_id}:${var.name}_cloudwatch_scheduler-failures",
+ ]
+ },
+ {
+ Sid = "CloudwatchSchedulerAccess"
+ Effect = "Allow"
+ Action = ["events:PutRule", "events:PutTargets"]
+ Resource = "*"
+ },
+ ]
+ })
+}
+
+resource "aws_iam_user" "this" {
+ name = var.name
+}
+
+resource "aws_iam_user_group_membership" "this" {
+ user = aws_iam_user.this.name
+ groups = [aws_iam_group.this.name]
+}
+
+resource "aws_iam_access_key" "this" {
+ user = aws_iam_user.this.name
+}
diff --git a/terraform/modules/intercode_aws_resources/main.tf b/terraform/modules/intercode_aws_resources/main.tf
new file mode 100644
index 00000000000..9416699991d
--- /dev/null
+++ b/terraform/modules/intercode_aws_resources/main.tf
@@ -0,0 +1,114 @@
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 5.0"
+ }
+ }
+}
+
+data "aws_region" "current" {}
+data "aws_caller_identity" "current" {}
+
+# ---------------------------------------------------------------------------
+# SQS queues (consumed by Shoryuken for background job processing)
+# ---------------------------------------------------------------------------
+
+resource "aws_sqs_queue" "dead_letter" {
+ name = "${var.name}_dead_letter"
+ max_message_size = 1048576
+}
+
+resource "aws_sqs_queue" "default" {
+ name = "${var.name}_default"
+ redrive_policy = jsonencode({
+ deadLetterTargetArn = aws_sqs_queue.dead_letter.arn
+ maxReceiveCount = 1
+ })
+}
+
+resource "aws_sqs_queue" "mailers" {
+ name = "${var.name}_mailers"
+ redrive_policy = jsonencode({
+ deadLetterTargetArn = aws_sqs_queue.dead_letter.arn
+ maxReceiveCount = 1
+ })
+}
+
+resource "aws_sqs_queue" "ahoy" {
+ name = "${var.name}_ahoy"
+ redrive_policy = jsonencode({
+ deadLetterTargetArn = aws_sqs_queue.dead_letter.arn
+ maxReceiveCount = 1
+ })
+}
+
+# ---------------------------------------------------------------------------
+# CloudWatch alarm — fires when the oldest message in any queue is > 10 min
+# ---------------------------------------------------------------------------
+
+resource "aws_sns_topic" "alarms" {
+ name = "${var.name}-alarms"
+}
+
+resource "aws_sns_topic_subscription" "alarms_email" {
+ for_each = var.alarm_email_destinations
+
+ topic_arn = aws_sns_topic.alarms.arn
+ protocol = "email"
+ endpoint = each.value
+}
+
+resource "aws_cloudwatch_metric_alarm" "queue_backup" {
+ alarm_name = "${var.name} queue backup"
+ alarm_description = "Oldest message in ${var.name} SQS queue is older than 10 minutes."
+ comparison_operator = "GreaterThanThreshold"
+ evaluation_periods = 5
+ datapoints_to_alarm = 5
+ threshold = 600
+
+ alarm_actions = [aws_sns_topic.alarms.arn]
+
+ metric_query {
+ id = "q1"
+ label = "Oldest message age in queue"
+ period = 300
+ return_data = true
+ expression = <<-EOT
+ SELECT MAX(ApproximateAgeOfOldestMessage)
+ FROM SCHEMA("AWS/SQS", QueueName)
+ WHERE QueueName != '${aws_sqs_queue.dead_letter.name}'
+ AND QueueName != '${var.name}_cloudwatch_scheduler-failures'
+ EOT
+ }
+}
+
+# ---------------------------------------------------------------------------
+# S3 bucket for uploaded CMS content and product images
+# ---------------------------------------------------------------------------
+
+resource "aws_s3_bucket" "uploads" {
+ bucket = var.s3_bucket_name
+}
+
+resource "aws_s3_bucket_acl" "uploads" {
+ bucket = aws_s3_bucket.uploads.bucket
+ acl = "private"
+}
+
+resource "aws_s3_bucket_cors_configuration" "uploads" {
+ bucket = aws_s3_bucket.uploads.bucket
+
+ cors_rule {
+ allowed_headers = ["*"]
+ allowed_methods = ["GET", "PUT"]
+ allowed_origins = ["*"]
+ expose_headers = [
+ "Origin",
+ "Content-Type",
+ "Content-MD5",
+ "Content-Disposition",
+ ]
+ max_age_seconds = 3000
+ }
+}
diff --git a/terraform/modules/intercode_aws_resources/outputs.tf b/terraform/modules/intercode_aws_resources/outputs.tf
new file mode 100644
index 00000000000..b96d91ede88
--- /dev/null
+++ b/terraform/modules/intercode_aws_resources/outputs.tf
@@ -0,0 +1,50 @@
+output "iam_group_name" {
+ description = "Name of the IAM group for the app. Pass to email-receiving modules so they can attach their own policies."
+ value = aws_iam_group.this.name
+}
+
+output "s3_bucket_name" {
+ description = "Name of the uploads S3 bucket."
+ value = aws_s3_bucket.uploads.bucket
+}
+
+output "s3_bucket_arn" {
+ description = "ARN of the uploads S3 bucket."
+ value = aws_s3_bucket.uploads.arn
+}
+
+output "sqs_queue_urls" {
+ description = "Map of queue name to SQS URL."
+ value = {
+ default = aws_sqs_queue.default.url
+ mailers = aws_sqs_queue.mailers.url
+ ahoy = aws_sqs_queue.ahoy.url
+ dead_letter = aws_sqs_queue.dead_letter.url
+ }
+}
+
+output "sqs_queue_arns" {
+ description = "Map of queue name to SQS ARN."
+ value = {
+ default = aws_sqs_queue.default.arn
+ mailers = aws_sqs_queue.mailers.arn
+ ahoy = aws_sqs_queue.ahoy.arn
+ dead_letter = aws_sqs_queue.dead_letter.arn
+ }
+}
+
+output "alarm_sns_topic_arn" {
+ description = "ARN of the SNS topic that receives CloudWatch alarm notifications."
+ value = aws_sns_topic.alarms.arn
+}
+
+output "iam_access_key_id" {
+ description = "AWS access key ID for the app IAM user."
+ value = aws_iam_access_key.this.id
+}
+
+output "iam_access_key_secret" {
+ description = "AWS secret access key for the app IAM user."
+ value = aws_iam_access_key.this.secret
+ sensitive = true
+}
diff --git a/terraform/modules/intercode_aws_resources/variables.tf b/terraform/modules/intercode_aws_resources/variables.tf
new file mode 100644
index 00000000000..dadcbee29f0
--- /dev/null
+++ b/terraform/modules/intercode_aws_resources/variables.tf
@@ -0,0 +1,15 @@
+variable "name" {
+ description = "Prefix for SQS queue names and IAM resources (e.g. 'intercode_production')."
+ type = string
+}
+
+variable "s3_bucket_name" {
+ description = "Name of the S3 bucket for uploads (e.g. 'intercode2-production')."
+ type = string
+}
+
+variable "alarm_email_destinations" {
+ description = "Email addresses to notify when the CloudWatch queue-backup alarm fires."
+ type = set(string)
+ default = []
+}
diff --git a/terraform/modules/ses_email_receiving/README.md b/terraform/modules/ses_email_receiving/README.md
new file mode 100644
index 00000000000..539b302fe9f
--- /dev/null
+++ b/terraform/modules/ses_email_receiving/README.md
@@ -0,0 +1,47 @@
+# ses_email_receiving Terraform module
+
+Sets up SES-based email receiving for Intercode: incoming mail is stored in S3
+and an SNS notification is POSTed to the app's `/sns_notifications` endpoint.
+
+The module attaches its own IAM group policy directly to the app's IAM group,
+so no ARNs need to be threaded back into `intercode_aws_resources`.
+
+> **Note:** `aws_ses_active_receipt_rule_set` is account-global — only one rule
+> set can be active per AWS account at a time.
+
+## Usage
+
+```hcl
+module "intercode_aws" {
+ source = "path/to/modules/intercode_aws_resources"
+
+ name = "intercode_production"
+ s3_bucket_name = "intercode2-production"
+}
+
+module "ses_email" {
+ source = "path/to/modules/ses_email_receiving"
+
+ name = "intercode_production"
+ inbox_bucket_name = "intercode-inbox"
+ sns_notification_endpoint = "https://www.neilhosting.net/sns_notifications"
+ iam_group_name = module.intercode_aws.iam_group_name
+}
+```
+
+## Inputs
+
+| Name | Description |
+| --------------------------- | ------------------------------------------------- |
+| `name` | Prefix for resource names |
+| `inbox_bucket_name` | S3 bucket name for received email |
+| `sns_notification_endpoint` | HTTPS URL for delivery notifications |
+| `iam_group_name` | IAM group to attach the email-receiving policy to |
+
+## Outputs
+
+| Name | Description |
+| -------------------------------- | ---------------------------------------- |
+| `inbox_bucket_name` | SES inbox bucket name |
+| `inbox_bucket_arn` | SES inbox bucket ARN |
+| `inbox_deliveries_sns_topic_arn` | SNS topic ARN for delivery notifications |
diff --git a/terraform/modules/ses_email_receiving/iam.tf b/terraform/modules/ses_email_receiving/iam.tf
new file mode 100644
index 00000000000..489e04eaf05
--- /dev/null
+++ b/terraform/modules/ses_email_receiving/iam.tf
@@ -0,0 +1,37 @@
+resource "aws_iam_group_policy" "email_receiving" {
+ name = "${var.name}-email-receiving"
+ group = var.iam_group_name
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Sid = "S3InboxAccess"
+ Effect = "Allow"
+ Action = [
+ "s3:GetObjectVersion",
+ "s3:DeleteObjectVersion",
+ "s3:DeleteObject",
+ "s3:GetObject",
+ "s3:GetObjectAcl",
+ "s3:PutObject",
+ "s3:PutObjectAcl",
+ "s3:RestoreObject",
+ ]
+ Resource = "${aws_s3_bucket.inbox.arn}/*"
+ },
+ {
+ Sid = "SnsInboxAccess"
+ Effect = "Allow"
+ Action = ["sns:ConfirmSubscription"]
+ Resource = aws_sns_topic.inbox_deliveries.arn
+ },
+ {
+ Sid = "KmsAccess"
+ Effect = "Allow"
+ Action = "kms:Decrypt"
+ Resource = "arn:aws:kms:${local.region}:${local.account_id}:alias/aws/ses"
+ },
+ ]
+ })
+}
diff --git a/terraform/modules/ses_email_receiving/main.tf b/terraform/modules/ses_email_receiving/main.tf
new file mode 100644
index 00000000000..96db721fa67
--- /dev/null
+++ b/terraform/modules/ses_email_receiving/main.tf
@@ -0,0 +1,206 @@
+terraform {
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = ">= 5.0"
+ }
+ }
+}
+
+data "aws_region" "current" {}
+data "aws_caller_identity" "current" {}
+
+locals {
+ region = data.aws_region.current.region
+ account_id = data.aws_caller_identity.current.account_id
+}
+
+# ---------------------------------------------------------------------------
+# SES receiving infrastructure
+# ---------------------------------------------------------------------------
+
+# Inbox bucket — SES writes received emails here; lifecycle expires them after
+# 14 days.
+resource "aws_s3_bucket" "inbox" {
+ bucket = var.inbox_bucket_name
+}
+
+resource "aws_s3_bucket_acl" "inbox" {
+ bucket = aws_s3_bucket.inbox.bucket
+ acl = "private"
+}
+
+resource "aws_s3_bucket_policy" "inbox" {
+ bucket = aws_s3_bucket.inbox.bucket
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [
+ {
+ Sid = "AllowSESPuts"
+ Effect = "Allow"
+ Principal = {
+ Service = "ses.amazonaws.com"
+ }
+ Action = "s3:PutObject"
+ Resource = "${aws_s3_bucket.inbox.arn}/*"
+ Condition = {
+ StringEquals = {
+ "aws:Referer" = local.account_id
+ }
+ }
+ }
+ ]
+ })
+}
+
+resource "aws_s3_bucket_lifecycle_configuration" "inbox" {
+ bucket = aws_s3_bucket.inbox.bucket
+
+ rule {
+ id = "message_expiration"
+ status = "Enabled"
+ expiration {
+ days = 14
+ }
+ }
+}
+
+# IAM roles for SNS delivery status logging.
+resource "aws_iam_role" "sns_success_feedback" {
+ name = "${var.name}-sns-success-feedback"
+
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Action = "sts:AssumeRole"
+ Effect = "Allow"
+ Principal = { Service = "sns.amazonaws.com" }
+ }]
+ })
+}
+
+resource "aws_iam_role_policy" "sns_success_feedback" {
+ name = "${var.name}-sns-success-feedback"
+ role = aws_iam_role.sns_success_feedback.name
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Action = [
+ "logs:CreateLogGroup",
+ "logs:CreateLogStream",
+ "logs:PutLogEvents",
+ "logs:PutMetricFilter",
+ "logs:PutRetentionPolicy",
+ ]
+ Resource = ["*"]
+ }]
+ })
+}
+
+resource "aws_iam_role" "sns_failure_feedback" {
+ name = "${var.name}-sns-failure-feedback"
+
+ assume_role_policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Action = "sts:AssumeRole"
+ Effect = "Allow"
+ Principal = { Service = "sns.amazonaws.com" }
+ }]
+ })
+}
+
+resource "aws_iam_role_policy" "sns_failure_feedback" {
+ name = "${var.name}-sns-failure-feedback"
+ role = aws_iam_role.sns_failure_feedback.name
+
+ policy = jsonencode({
+ Version = "2012-10-17"
+ Statement = [{
+ Effect = "Allow"
+ Action = [
+ "logs:CreateLogGroup",
+ "logs:CreateLogStream",
+ "logs:PutLogEvents",
+ "logs:PutMetricFilter",
+ "logs:PutRetentionPolicy",
+ ]
+ Resource = ["*"]
+ }]
+ })
+}
+
+# SNS topic for inbox delivery notifications.
+resource "aws_sns_topic" "inbox_deliveries" {
+ name = "${var.name}-inbox-deliveries"
+ http_success_feedback_role_arn = aws_iam_role.sns_success_feedback.arn
+ http_failure_feedback_role_arn = aws_iam_role.sns_failure_feedback.arn
+}
+
+resource "aws_sns_topic_subscription" "inbox_deliveries_webhook" {
+ topic_arn = aws_sns_topic.inbox_deliveries.arn
+ protocol = "https"
+ endpoint = var.sns_notification_endpoint
+ endpoint_auto_confirms = true
+
+ delivery_policy = jsonencode({
+ guaranteed = false
+ healthyRetryPolicy = {
+ backoffFunction = "linear"
+ maxDelayTarget = 300
+ minDelayTarget = 20
+ numMaxDelayRetries = 0
+ numMinDelayRetries = 0
+ numNoDelayRetries = 0
+ numRetries = 3
+ }
+ sicklyRetryPolicy = null
+ throttlePolicy = null
+ })
+}
+
+# SES receipt rules — store incoming mail in S3 and notify via SNS.
+# Note: aws_ses_active_receipt_rule_set is account-global; only one rule set
+# can be active at a time.
+resource "aws_ses_receipt_rule_set" "inbox" {
+ rule_set_name = "${var.name}-inbox"
+}
+
+resource "aws_ses_active_receipt_rule_set" "inbox" {
+ rule_set_name = aws_ses_receipt_rule_set.inbox.rule_set_name
+}
+
+resource "aws_ses_receipt_rule" "store_and_notify" {
+ name = "store_and_notify"
+ rule_set_name = aws_ses_receipt_rule_set.inbox.rule_set_name
+ enabled = true
+ scan_enabled = true
+
+ s3_action {
+ bucket_name = aws_s3_bucket.inbox.bucket
+ position = 1
+ # Uses the AWS-managed SES KMS key; no custom key required.
+ kms_key_arn = "arn:aws:kms:${local.region}:${local.account_id}:alias/aws/ses"
+ topic_arn = aws_sns_topic.inbox_deliveries.arn
+ }
+}
+
+# SES configuration set with CloudWatch event tracking.
+resource "aws_ses_configuration_set" "default" {
+ name = "${var.name}-default"
+}
+
+resource "aws_ses_event_destination" "cloudwatch" {
+ name = "ses-sends"
+ configuration_set_name = aws_ses_configuration_set.default.name
+ enabled = true
+ matching_types = ["bounce", "complaint", "delivery", "reject", "send"]
+
+ cloudwatch_destination {
+ default_value = aws_ses_configuration_set.default.name
+ dimension_name = "ses:configuration-set"
+ value_source = "messageTag"
+ }
+}
diff --git a/terraform/modules/ses_email_receiving/outputs.tf b/terraform/modules/ses_email_receiving/outputs.tf
new file mode 100644
index 00000000000..ffe60f909b2
--- /dev/null
+++ b/terraform/modules/ses_email_receiving/outputs.tf
@@ -0,0 +1,15 @@
+output "inbox_bucket_name" {
+ description = "Name of the SES inbox S3 bucket."
+ value = aws_s3_bucket.inbox.bucket
+}
+
+output "inbox_bucket_arn" {
+ description = "ARN of the SES inbox S3 bucket."
+ value = aws_s3_bucket.inbox.arn
+}
+
+output "inbox_deliveries_sns_topic_arn" {
+ description = "ARN of the SNS topic for SES delivery notifications."
+ value = aws_sns_topic.inbox_deliveries.arn
+}
+
diff --git a/terraform/modules/ses_email_receiving/variables.tf b/terraform/modules/ses_email_receiving/variables.tf
new file mode 100644
index 00000000000..2d613447f2d
--- /dev/null
+++ b/terraform/modules/ses_email_receiving/variables.tf
@@ -0,0 +1,19 @@
+variable "name" {
+ description = "Prefix for resource names (e.g. 'intercode_production')."
+ type = string
+}
+
+variable "inbox_bucket_name" {
+ description = "Name of the S3 bucket for SES-received email (e.g. 'intercode-inbox')."
+ type = string
+}
+
+variable "sns_notification_endpoint" {
+ description = "HTTPS URL that SES delivery notifications are POSTed to (e.g. 'https://www.neilhosting.net/sns_notifications')."
+ type = string
+}
+
+variable "iam_group_name" {
+ description = "Name of the IAM group to attach the email-receiving policy to (from intercode_aws_resources.iam_group_name)."
+ type = string
+}
diff --git a/vite.config.mts b/vite.config.mts
index 473d2cc02b5..0e3f8b17eb6 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -1,6 +1,5 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
-import tsconfigPaths from 'vite-tsconfig-paths';
import { fileURLToPath } from 'url';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
import { globalDefines } from './globalDefines.mts';
@@ -19,11 +18,11 @@ export default defineConfig({
options.inputSourceMap = false;
},
}),
- tsconfigPaths(),
nodePolyfills(),
],
resolve: {
mainFields: ['module'],
+ tsconfigPaths: true,
},
define: globalDefines,
experimental: {
@@ -56,7 +55,18 @@ export default defineConfig({
manualChunks: (id) => {
const chunks: Array<[string, string[]]> = [
['apollo', ['@apollo/client', 'apollo-upload-client']],
- ['codemirror', ['@codemirror/state', '@codemirror/view', '@codemirror/language', '@codemirror/lang-html', '@codemirror/lang-json', '@codemirror/lang-markdown', '@lezer/common']],
+ [
+ 'codemirror',
+ [
+ '@codemirror/state',
+ '@codemirror/view',
+ '@codemirror/language',
+ '@codemirror/lang-html',
+ '@codemirror/lang-json',
+ '@codemirror/lang-markdown',
+ '@lezer/common',
+ ],
+ ],
['currencyCodes', ['@breezehr/currency-codes']],
['graphql', ['graphql']],
['i18next', ['i18next', 'react-i18next']],
diff --git a/yarn.lock b/yarn.lock
index 8af0d8d6645..11f7ae71965 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -14625,13 +14625,6 @@ __metadata:
languageName: node
linkType: hard
-"globrex@npm:^0.1.2":
- version: 0.1.2
- resolution: "globrex@npm:0.1.2"
- checksum: 10c0/a54c029520cf58bda1d8884f72bd49b4cd74e977883268d931fd83bcbd1a9eb96d57c7dbd4ad80148fb9247467ebfb9b215630b2ed7563b2a8de02e1ff7f89d1
- languageName: node
- linkType: hard
-
"gopd@npm:^1.0.1, gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
@@ -22523,7 +22516,6 @@ __metadata:
vite: "npm:^8.0.0"
vite-bundle-visualizer: "npm:^1.2.1"
vite-plugin-node-polyfills: "npm:^0.28.0"
- vite-tsconfig-paths: "npm:^6.0.0"
vitest: "npm:^4.0.0"
wrangler: "npm:^4.59.1"
zod: "npm:^4.4.3"
@@ -24395,20 +24387,6 @@ __metadata:
languageName: node
linkType: hard
-"tsconfck@npm:^3.0.3":
- version: 3.1.3
- resolution: "tsconfck@npm:3.1.3"
- peerDependencies:
- typescript: ^5.0.0
- peerDependenciesMeta:
- typescript:
- optional: true
- bin:
- tsconfck: bin/tsconfck.js
- checksum: 10c0/64f7a8ed0a6d36b0902dfc0075e791d2242f7634644f124343ec0dec4f3f70092f929c5a9f59496d51883aa81bb1e595deb92a219593575d2e75b849064713d1
- languageName: node
- linkType: hard
-
"tsconfig-paths@npm:^3.15.0":
version: 3.15.0
resolution: "tsconfig-paths@npm:3.15.0"
@@ -25251,19 +25229,6 @@ __metadata:
languageName: node
linkType: hard
-"vite-tsconfig-paths@npm:^6.0.0":
- version: 6.1.1
- resolution: "vite-tsconfig-paths@npm:6.1.1"
- dependencies:
- debug: "npm:^4.1.1"
- globrex: "npm:^0.1.2"
- tsconfck: "npm:^3.0.3"
- peerDependencies:
- vite: "*"
- checksum: 10c0/5e61080991418fefa08c5b98995cdcada4931ae01ac97ef9e2ee941051f61b76890a6e7ba48bed3b2a229ec06fef33a06621bba4ce457b3f4233ad31dc0c1d1b
- languageName: node
- linkType: hard
-
"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0, vite@npm:^8.0.0":
version: 8.0.14
resolution: "vite@npm:8.0.14"