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"