diff --git a/.gitignore b/.gitignore index 1b85a63..8298635 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,46 @@ dist/ .wrangler/ *.log .DS_Store + +# upstream protocol clone (vendor source; never committed) +.upstream/ + +# local Claude Code permission overrides (not committed) +.claude/settings.local.json + +# --- secrets & credentials (repo-wide; never commit) --- +.env +.env.* +!.env.example +.dev.vars +.dev.vars.* +!.dev.vars.example +*.pem +*.key +*.p8 +*.p12 +*.pfx +*.cer +*.certSigningRequest +*.mobileprovision +*.keystore +*.jks +credentials.json +secrets.json +*.secret +google-services.json +GoogleService-Info.plist + +# --- terraform (state + tfvars hold secrets; never commit) --- +*.tfvars +*.tfvars.json +!*.tfvars.example +*.tfstate +*.tfstate.* +.terraform/ +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json diff --git a/apps/gateway/package.json b/apps/gateway/package.json index c91b270..16af9a1 100644 --- a/apps/gateway/package.json +++ b/apps/gateway/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { + "build": "wrangler deploy --dry-run --outdir dist", "deploy": "wrangler deploy", "dev": "wrangler dev", "start": "wrangler dev", diff --git a/docs/superpowers/specs/2026-05-17-gateway-terraform-design.md b/docs/superpowers/specs/2026-05-17-gateway-terraform-design.md new file mode 100644 index 0000000..10c7105 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-gateway-terraform-design.md @@ -0,0 +1,113 @@ +# Gateway Terraform (Cloudflare IaC) — Design Spec + +> Date: 2026-05-17 · Branch: `build/mobile-ui` · Status: **approved (gates +> compressed per user: "ok for now, patch misconceptions later")**. Plan folded +> in below; no separate writing-plans round-trip. Reference: +> `.upstream/background-agents/terraform` (vendored clone, gitignored). + +## 1. Goal & decisions + +Provide Infrastructure-as-Code for the **mobile gateway only** (PLAN-01: a single +Cloudflare Worker, public-URL + HMAC, KV store, `jose`), mirroring +background-agents' Terraform *pattern* scoped to that one Worker. + +User-chosen forks: +1. **Full mirror** — Terraform owns build **and** deploy of the gateway Worker + (`cloudflare_worker` + `cloudflare_worker_version` + `cloudflare_workers_deployment`). + `apps/gateway/wrangler.jsonc` is kept for `wrangler dev` (local) and as the + build input only — **not** for deploy. +2. **R2 S3 state backend** — `backend "s3"` on Cloudflare R2, same shape as + background-agents; creds via `-backend-config=backend.tfvars`. +3. **Secrets as TF vars → `secret_text` bindings** — they land in TF state; + state lives in a **private** R2 bucket and is treated sensitive. + +## 2. Layout (mirror, scoped) + +``` +terraform/ + README.md init/plan/apply runbook + modules/ + cloudflare-kv/ main.tf variables.tf outputs.tf versions.tf + cloudflare-worker/ main.tf variables.tf outputs.tf versions.tf (trimmed to KV + plain_text + secrets) + environments/production/ + versions.tf terraform >= 1.14; cloudflare ~> 5.16; provider api_token = var.cloudflare_api_token + backend.tf backend "s3" (R2): bucket constructor-gateway-tfstate, key production/terraform.tfstate, + region "auto", skip_* R2 flags; creds via backend.tfvars + backend.tfvars.example + variables.tf cloudflare_account_id, cloudflare_api_token (sensitive), project_root, + name_suffix, control_plane_url, ws_url, + sensitive secret vars + locals.tf worker_name, built script path, url passthroughs + kv.tf module gateway_kv → cloudflare-kv (constructor-gateway-kv-${name_suffix}) + worker-gateway.tf null_resource build (local-exec) → module gateway_worker (KV binding + + plain_text [CONTROL_PLANE_URL, WS_URL] + secret_text [INTERNAL_CALLBACK_SECRET, + GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_CLIENT_SECRET, APP_JWT_SIGNING_KEY, + EXPO_ACCESS_TOKEN]); workers.dev subdomain enabled + outputs.tf worker_name, workers_dev_url, kv_namespace_id + checks.tf check block: required secret vars non-empty + moved.tf placeholder (parity / future state moves) + terraform.tfvars.example +``` + +Modules are adapted from `background-agents/terraform/modules/{cloudflare-kv, +cloudflare-worker}`; the worker module is trimmed to the binding types we use +(KV, plain_text, secret_text) — D1/R2/Durable-Object/service-binding inputs +dropped (YAGNI; not used by the gateway). + +## 3. Build / deploy flow + +`null_resource.gateway_build` runs `local-exec`: +`pnpm --filter gateway run build` where the gateway gets a `build` script +`wrangler deploy --dry-run --outdir dist` (produces the bundled worker without +deploying). `locals.gateway_script_path` points at the emitted bundle; +`cloudflare_worker_version` uploads it; `cloudflare_workers_deployment` promotes +it. `wrangler.jsonc` (`nodejs_compat`, `compatibility_date`) is the build input; +TF carries the matching `compatibility_date`/flags into the worker version. + +## 4. Secret hygiene (mandatory) + +Since secrets flow through tfvars → state, `.gitignore` is extended repo-wide: +`*.tfvars`, `*.tfvars.json`, `!*.tfvars.example`, `*.tfstate`, `*.tfstate.*`, +`.terraform/`, `.terraform.lock.hcl` is **committed** (pinned providers, like +background-agents), `crash.log`, `crash.*.log`. Real values live only in +local `terraform.tfvars` + `backend.tfvars` and in the private R2 state bucket. +`.upstream/` (the reference clone) is already gitignored. + +## 5. Explicitly out of scope + +D1, R2 *buckets* (R2 is only the TF state backend), Durable Objects, Modal, +Vercel, Daytona, slack/github/linear workers, web-*. Those are control-plane / +background-agents and violate PLAN-00's zero-coupling constraint if added here. +Custom domain / `zone_id` route is supported by the module but **deferred** +(gateway uses `*.workers.dev`; the app only needs the gateway URL per PLAN-02). + +## 6. Implementation phases + +1. `.gitignore` hardening (TF state/tfvars patterns) — do first so nothing leaks. +2. `apps/gateway/package.json`: add `build` script for the `local-exec`. +3. `terraform/modules/cloudflare-kv/*` (port ~verbatim). +4. `terraform/modules/cloudflare-worker/*` (port + trim; verify the v5 + `cloudflare_worker*` resource schema against the vendored reference module). +5. `terraform/environments/production/*` (versions, backend, variables, locals, + kv, worker-gateway, outputs, checks, moved, tfvars examples). +6. `terraform/README.md` runbook. +7. `terraform fmt -recursive` if the CLI is present; otherwise careful authoring. +8. Signed commit + push. + +## 7. Success criteria + +- `terraform/` mirrors background-agents' structure, scoped to one Worker + KV. +- No secrets/state committed; `.gitignore` covers TF artifacts; verified by a + tracked-file sweep before push. +- `README.md` documents the exact `terraform init -backend-config=backend.tfvars` + → `plan` → `apply` runbook and the one-time R2 bucket/token setup. +- HCL is well-formed (`terraform fmt`/`validate` clean if CLI available). +- I do **not** run `terraform`/`wrangler` (needs the user's CF account, API + token, and R2 bucket) — IaC + runbook only; user runs `init/plan/apply`. + +## 8. Known iteration points ("patch later") + +The Cloudflare Terraform provider v5 `cloudflare_worker*` resource schema is +fast-moving; the worker module is ported from the vendored reference at +`a7b968f` and may need field tweaks when the user first runs `terraform plan`. +Compatibility date/flags must stay in sync with `apps/gateway/wrangler.jsonc`. +These are expected patch points, not blockers. diff --git a/package.json b/package.json index 054a674..903da4c 100644 --- a/package.json +++ b/package.json @@ -1 +1,5 @@ -{"name":"constructor-mobile","private":true} +{ + "name": "constructor-mobile", + "private": true, + "packageManager": "pnpm@10.28.2" +} diff --git a/terraform/README.md b/terraform/README.md new file mode 100644 index 0000000..637148e --- /dev/null +++ b/terraform/README.md @@ -0,0 +1,69 @@ +# Terraform — Cloudflare gateway IaC + +Infrastructure-as-Code for the **mobile gateway only** (PLAN-01). Mirrors +`background-agents/terraform`'s pattern (reusable modules + a `environments/` +root, R2 S3 state backend), scoped to one Worker + one KV namespace. It does +**not** manage the control plane (D1/R2 buckets/Durable Objects/Modal/Vercel/ +other workers) — that is background-agents and out of scope per PLAN-00. + +``` +terraform/ + modules/cloudflare-kv/ KV namespace + modules/cloudflare-worker/ Worker (3-resource v5 pattern) + bindings + cron + environments/production/ root: gateway worker + KV + secrets + state backend +``` + +## What it provisions + +- A KV namespace (`constructor-gateway-kv-`) bound to the worker as `GATEWAY_KV` + (push registry / per-session cursors / hashed refresh tokens). +- The gateway Worker (`constructor-gateway-`) on `*.workers.dev` + (custom domain optional), with: + - plain-text bindings: `CONTROL_PLANE_URL`, `WS_URL`, `GITHUB_OAUTH_CLIENT_ID` + - secret bindings: `INTERNAL_CALLBACK_SECRET`, `GITHUB_OAUTH_CLIENT_SECRET`, + `APP_JWT_SIGNING_KEY`, `EXPO_ACCESS_TOKEN` + - cron trigger(s) for the push cron-poll (PLAN-03), default every 2 min. + +TF owns build **and** deploy: a `null_resource` runs `pnpm --filter gateway run +build` (`wrangler deploy --dry-run --outdir dist`) and the worker module deploys +the emitted `apps/gateway/dist/index.js`. `apps/gateway/wrangler.jsonc` is the +build input + `wrangler dev` only — never the deploy path. + +## Prerequisites (one-time) + +1. Cloudflare account; an **API token** with Workers Scripts + Workers KV + + Workers Routes edit. +2. R2 bucket for state: `wrangler r2 bucket create constructor-gateway-tfstate` + (keep it **private** — secrets live in state), and an R2 API token. +3. `cp environments/production/backend.tfvars.example backend.tfvars` and fill + `access_key`/`secret_key`/`endpoints` (gitignored). +4. `cp environments/production/terraform.tfvars.example terraform.tfvars` and + fill account id, api token, urls, and the four secrets (gitignored). + +## Runbook + +```bash +cd terraform/environments/production +terraform init -backend-config=backend.tfvars +terraform plan -var-file=terraform.tfvars +terraform apply -var-file=terraform.tfvars +terraform output gateway_url # → enter this as the connection-profile URL in the app +``` + +To set/rotate the push cadence, change `push_cron`; to use a custom domain set +`zone_id` + `gateway_custom_domain`. + +## Secret hygiene + +`terraform.tfvars`, `backend.tfvars`, `*.tfstate*`, and `.terraform/` are +gitignored repo-wide. Secrets are `secret_text` bindings → they exist in TF +state; the R2 state bucket MUST stay private. `.terraform.lock.hcl` is committed +once generated by `terraform init` (pinned providers, like background-agents). + +## Known patch points + +The Cloudflare provider v5 `cloudflare_worker*` schema is fast-moving; modules +were ported from the vendored reference at `a7b968f`. Run `terraform validate` +then `plan` on first use and adjust field names if the installed provider +differs. Keep `compatibility_date`/`compatibility_flags` in sync with +`apps/gateway/wrangler.jsonc`. diff --git a/terraform/environments/production/.terraform.lock.hcl b/terraform/environments/production/.terraform.lock.hcl new file mode 100644 index 0000000..6580357 --- /dev/null +++ b/terraform/environments/production/.terraform.lock.hcl @@ -0,0 +1,40 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudflare/cloudflare" { + version = "5.19.1" + constraints = ">= 5.0.0, ~> 5.16" + hashes = [ + "h1:HkKPMZ/n+QiExkRUSLjGMTGnuIaph+k932LiTp7CKZM=", + "zh:0651618000db705564dab5a25322b9d76ea54b7dd78931ed3565497b559babeb", + "zh:1a7847e9479fb6d21a65ef933ffae1416b1e4b44ca940c0d6c50fc4248cc4a0d", + "zh:5597cee5854131045eb9f201ae3a70b59c51955d31a647d9616863c746d902cb", + "zh:580786830d93e35b957754fd4c62d4681a3b19abc28b757e41acba26455663b1", + "zh:83c4bdfb0e74fd50e56fff3c461d76c1c1ec61af3f679e4de1aa70b5ed05a09f", + "zh:abb4d1052cee61d80f9cb51e5421e3c118312403afb7104b98bd7e310ac736ee", + "zh:b0aeeb3d66ea4d719989875e778c477065ba941e3a76e9a8caacc3be08208dd9", + "zh:e43b4b2dfcec1ce2115f5a5c86042d432deb49bee8eae103eb56d97ea02e2e3b", + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", + ] +} + +provider "registry.terraform.io/hashicorp/null" { + version = "3.3.0" + constraints = "~> 3.0" + hashes = [ + "h1:a14TKo7Xvg4W8+H1VA6p+oLZTLxVQnYUD8LOaOs14A8=", + "zh:021748b5ea3b5f6956f2e75c42c5cdc113b391fb98ac71364a4965d23b37000f", + "zh:3b27956f8541d46704fda234e0d535c2ae2a4b33411848b1ee262a1ec03568b0", + "zh:3de4ed47d6d0f4d8edba4a5092c7c9799950eda63989d8d0d2586e6afcb0aa20", + "zh:57ed8935c7d56dbc91cf2673534582cacfaab7a2f105f51d9f797e99df0c0c47", + "zh:58e176ba1d142827089e30e0711e007309a9f2726e8881986da5026e9778fdf4", + "zh:5949c4a3d4a93f841f155cdb7e991c087e637145c1630572e21948224f8f4923", + "zh:76d60f366b743003c1b085afa769b45b2198ee919927e45807d7d44fb42c067d", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:79cd1bab1261a07f84e917191d7ddc4340ac5f5524283767256f7ffd7f87caf0", + "zh:8ec9083038cf710b30e319eaa467c9df7fa52bbd9969b61053a35bc2cdd2e0a6", + "zh:a6e502cb579685ab7aeb886c2bb11ddd9cfed74b41008592d57cbc3351a9218b", + "zh:acb74d6b4f66ff6acfcda315df802a7432170ef3955c9b432cb4580767004006", + "zh:f0ce55d8d9ffdb33dab612b1246f9bab060a9d54fc32ce2b4a038646155660af", + ] +} diff --git a/terraform/environments/production/backend.tf b/terraform/environments/production/backend.tf new file mode 100644 index 0000000..1a5eb21 --- /dev/null +++ b/terraform/environments/production/backend.tf @@ -0,0 +1,27 @@ +# Terraform state backend — Cloudflare R2 (S3-compatible), mirrors background-agents. +# +# One-time setup: +# 1. wrangler r2 bucket create constructor-gateway-tfstate +# 2. Cloudflare dashboard → R2 → Manage R2 API Tokens → create a read/write token +# 3. cp backend.tfvars.example backend.tfvars (gitignored) and fill it in +# 4. terraform init -backend-config=backend.tfvars +# +# State is sensitive (secrets are stored as secret_text bindings → state). Keep +# the R2 bucket PRIVATE. + +terraform { + backend "s3" { + bucket = "constructor-gateway-tfstate" + key = "production/terraform.tfstate" + region = "auto" + + # access_key / secret_key / endpoints supplied via -backend-config=backend.tfvars + + # R2 compatibility + skip_credentials_validation = true + skip_metadata_api_check = true + skip_region_validation = true + skip_requesting_account_id = true + skip_s3_checksum = true + } +} diff --git a/terraform/environments/production/backend.tfvars.example b/terraform/environments/production/backend.tfvars.example new file mode 100644 index 0000000..552c492 --- /dev/null +++ b/terraform/environments/production/backend.tfvars.example @@ -0,0 +1,10 @@ +# Cloudflare R2 backend credentials. Copy to backend.tfvars (gitignored) and fill. +# Usage: terraform init -backend-config=backend.tfvars +# NEVER commit backend.tfvars. + +access_key = "" +secret_key = "" + +endpoints = { + s3 = "https://.r2.cloudflarestorage.com" +} diff --git a/terraform/environments/production/checks.tf b/terraform/environments/production/checks.tf new file mode 100644 index 0000000..f5f6ac5 --- /dev/null +++ b/terraform/environments/production/checks.tf @@ -0,0 +1,13 @@ +# Fail fast if required config/secrets are blank (parity with background-agents). +check "required_inputs" { + assert { + condition = ( + length(trimspace(var.internal_callback_secret)) > 0 && + length(trimspace(var.app_jwt_signing_key)) > 0 && + length(trimspace(var.github_oauth_client_secret)) > 0 && + length(trimspace(var.control_plane_url)) > 0 && + length(trimspace(var.ws_url)) > 0 + ) + error_message = "internal_callback_secret, app_jwt_signing_key, github_oauth_client_secret, control_plane_url and ws_url must all be set in terraform.tfvars." + } +} diff --git a/terraform/environments/production/kv.tf b/terraform/environments/production/kv.tf new file mode 100644 index 0000000..82d51e8 --- /dev/null +++ b/terraform/environments/production/kv.tf @@ -0,0 +1,8 @@ +# Gateway KV namespace: push registry + per-session cursors + hashed refresh +# tokens (PLAN-01 / PLAN-03). +module "gateway_kv" { + source = "../../modules/cloudflare-kv" + + account_id = var.cloudflare_account_id + namespace_name = local.kv_name +} diff --git a/terraform/environments/production/locals.tf b/terraform/environments/production/locals.tf new file mode 100644 index 0000000..fbc706c --- /dev/null +++ b/terraform/environments/production/locals.tf @@ -0,0 +1,9 @@ +locals { + worker_name = "constructor-gateway-${var.name_suffix}" + kv_name = "constructor-gateway-kv-${var.name_suffix}" + + gateway_dir = "${var.project_root}/apps/gateway" + # `wrangler deploy --dry-run --outdir dist` (the gateway `build` script) emits + # the bundled ES module here. + gateway_script_path = "${local.gateway_dir}/dist/index.js" +} diff --git a/terraform/environments/production/moved.tf b/terraform/environments/production/moved.tf new file mode 100644 index 0000000..df6a37a --- /dev/null +++ b/terraform/environments/production/moved.tf @@ -0,0 +1,2 @@ +# State-move declarations go here when refactoring resource addresses +# (parity with background-agents/terraform). None yet. diff --git a/terraform/environments/production/outputs.tf b/terraform/environments/production/outputs.tf new file mode 100644 index 0000000..f450498 --- /dev/null +++ b/terraform/environments/production/outputs.tf @@ -0,0 +1,14 @@ +output "gateway_worker_name" { + description = "Deployed gateway worker name" + value = module.gateway_worker.worker_name +} + +output "gateway_url" { + description = "Gateway URL (custom domain if set, else workers.dev). Enter this as the connection-profile gateway URL in the app." + value = coalesce(module.gateway_worker.custom_domain, module.gateway_worker.worker_url) +} + +output "gateway_kv_namespace_id" { + description = "KV namespace id bound to the gateway as GATEWAY_KV" + value = module.gateway_kv.namespace_id +} diff --git a/terraform/environments/production/terraform.tfvars.example b/terraform/environments/production/terraform.tfvars.example new file mode 100644 index 0000000..d17e64e --- /dev/null +++ b/terraform/environments/production/terraform.tfvars.example @@ -0,0 +1,25 @@ +# Copy to terraform.tfvars (gitignored) and fill in. Contains SECRETS that land +# in TF state — keep terraform.tfvars local and the R2 state bucket private. + +cloudflare_account_id = "" +cloudflare_api_token = "" # Workers Scripts + KV + Routes edit + +# Absolute path to this monorepo checkout (for the build local-exec): +project_root = "/Users/quantumly/Documents/Development/Refrakts/constructor-mobile" + +name_suffix = "prod" + +# Non-secret config: +control_plane_url = "https://.workers.dev" +ws_url = "wss://.workers.dev" +github_oauth_client_id = "" + +# Optional custom domain (else *.workers.dev). Requires zone_id. +# zone_id = "" +# gateway_custom_domain = "gateway.example.com" + +# Secrets (DO NOT COMMIT): +internal_callback_secret = "" +github_oauth_client_secret = "" +app_jwt_signing_key = "" +expo_access_token = "" diff --git a/terraform/environments/production/variables.tf b/terraform/environments/production/variables.tf new file mode 100644 index 0000000..d7ed7a7 --- /dev/null +++ b/terraform/environments/production/variables.tf @@ -0,0 +1,95 @@ +# --- Cloudflare account / auth ------------------------------------------------ +variable "cloudflare_account_id" { + description = "Cloudflare account ID" + type = string +} + +variable "cloudflare_api_token" { + description = "Cloudflare API token (Workers Scripts + KV + Workers Routes edit)" + type = string + sensitive = true +} + +# --- deployment --------------------------------------------------------------- +variable "project_root" { + description = "Absolute path to the monorepo root (so the build local-exec can pnpm --filter gateway)" + type = string +} + +variable "name_suffix" { + description = "Suffix for resource names (e.g. prod, staging)" + type = string + default = "prod" +} + +variable "compatibility_date" { + description = "Worker compatibility date — keep in sync with apps/gateway/wrangler.jsonc" + type = string + default = "2026-05-16" +} + +variable "compatibility_flags" { + description = "Worker compatibility flags" + type = list(string) + default = ["nodejs_compat"] +} + +variable "push_cron" { + description = "Cron schedule(s) for the gateway push cron-poll (PLAN-03). Empty = disabled." + type = list(string) + default = ["*/2 * * * *"] +} + +# --- optional custom domain --------------------------------------------------- +variable "zone_id" { + description = "Cloudflare zone ID (only needed if gateway_custom_domain is set)" + type = string + default = null +} + +variable "gateway_custom_domain" { + description = "Optional custom domain for the gateway (else *.workers.dev)" + type = string + default = null +} + +# --- non-secret gateway config (plain_text bindings) -------------------------- +variable "control_plane_url" { + description = "Deployed control-plane base URL (background-agents)" + type = string +} + +variable "ws_url" { + description = "Control-plane WebSocket base URL (returned to the app via GET /config)" + type = string +} + +variable "github_oauth_client_id" { + description = "Mobile GitHub OAuth App client id (not secret)" + type = string +} + +# --- secrets (TF vars → secret_text bindings; land in state — keep R2 private) - +variable "internal_callback_secret" { + description = "Shared HMAC secret with the control plane (god-mode)" + type = string + sensitive = true +} + +variable "github_oauth_client_secret" { + description = "Mobile GitHub OAuth App client secret" + type = string + sensitive = true +} + +variable "app_jwt_signing_key" { + description = "Gateway-owned key used to sign short-lived app-session JWTs" + type = string + sensitive = true +} + +variable "expo_access_token" { + description = "Expo access token for sending push notifications" + type = string + sensitive = true +} diff --git a/terraform/environments/production/versions.tf b/terraform/environments/production/versions.tf new file mode 100644 index 0000000..9dc957a --- /dev/null +++ b/terraform/environments/production/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 5.16" + } + null = { + source = "hashicorp/null" + version = "~> 3.0" + } + } +} + +provider "cloudflare" { + api_token = var.cloudflare_api_token +} diff --git a/terraform/environments/production/worker-gateway.tf b/terraform/environments/production/worker-gateway.tf new file mode 100644 index 0000000..569787c --- /dev/null +++ b/terraform/environments/production/worker-gateway.tf @@ -0,0 +1,49 @@ +# Build the gateway bundle before deploy (TF owns build+deploy; wrangler.jsonc +# is only the build input + `wrangler dev`, never the deploy mechanism). +resource "null_resource" "gateway_build" { + triggers = { + always_run = timestamp() + } + + provisioner "local-exec" { + command = "pnpm --filter gateway run build" + working_dir = var.project_root + } +} + +module "gateway_worker" { + source = "../../modules/cloudflare-worker" + + account_id = var.cloudflare_account_id + worker_name = local.worker_name + script_path = local.gateway_script_path + compatibility_date = var.compatibility_date + compatibility_flags = var.compatibility_flags + + kv_namespaces = [ + { + binding_name = "GATEWAY_KV" + namespace_id = module.gateway_kv.namespace_id + } + ] + + plain_text_bindings = [ + { name = "CONTROL_PLANE_URL", value = var.control_plane_url }, + { name = "WS_URL", value = var.ws_url }, + { name = "GITHUB_OAUTH_CLIENT_ID", value = var.github_oauth_client_id }, + ] + + secrets = [ + { name = "INTERNAL_CALLBACK_SECRET", value = var.internal_callback_secret }, + { name = "GITHUB_OAUTH_CLIENT_SECRET", value = var.github_oauth_client_secret }, + { name = "APP_JWT_SIGNING_KEY", value = var.app_jwt_signing_key }, + { name = "EXPO_ACCESS_TOKEN", value = var.expo_access_token }, + ] + + cron_triggers = var.push_cron + + zone_id = var.zone_id + custom_domain = var.gateway_custom_domain + + depends_on = [null_resource.gateway_build] +} diff --git a/terraform/modules/cloudflare-kv/main.tf b/terraform/modules/cloudflare-kv/main.tf new file mode 100644 index 0000000..dda2252 --- /dev/null +++ b/terraform/modules/cloudflare-kv/main.tf @@ -0,0 +1,5 @@ +# Cloudflare KV Namespace module (adapted from background-agents/terraform). +resource "cloudflare_workers_kv_namespace" "this" { + account_id = var.account_id + title = var.namespace_name +} diff --git a/terraform/modules/cloudflare-kv/outputs.tf b/terraform/modules/cloudflare-kv/outputs.tf new file mode 100644 index 0000000..4e703ae --- /dev/null +++ b/terraform/modules/cloudflare-kv/outputs.tf @@ -0,0 +1,9 @@ +output "namespace_id" { + description = "The ID of the KV namespace" + value = cloudflare_workers_kv_namespace.this.id +} + +output "namespace_name" { + description = "The title of the KV namespace" + value = cloudflare_workers_kv_namespace.this.title +} diff --git a/terraform/modules/cloudflare-kv/variables.tf b/terraform/modules/cloudflare-kv/variables.tf new file mode 100644 index 0000000..b6ce47c --- /dev/null +++ b/terraform/modules/cloudflare-kv/variables.tf @@ -0,0 +1,9 @@ +variable "account_id" { + description = "Cloudflare account ID" + type = string +} + +variable "namespace_name" { + description = "Name of the KV namespace" + type = string +} diff --git a/terraform/modules/cloudflare-kv/versions.tf b/terraform/modules/cloudflare-kv/versions.tf new file mode 100644 index 0000000..5c9f70b --- /dev/null +++ b/terraform/modules/cloudflare-kv/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = ">= 5.0" + } + } +} diff --git a/terraform/modules/cloudflare-worker/main.tf b/terraform/modules/cloudflare-worker/main.tf new file mode 100644 index 0000000..bae49f3 --- /dev/null +++ b/terraform/modules/cloudflare-worker/main.tf @@ -0,0 +1,101 @@ +# Cloudflare Worker module — adapted from background-agents/terraform, trimmed to +# the bindings the gateway uses (KV + plain_text + secret_text + cron). +# 3-resource pattern: cloudflare_worker + cloudflare_worker_version + cloudflare_workers_deployment. + +locals { + bindings = concat( + [for kv in var.kv_namespaces : { + type = "kv_namespace" + name = kv.binding_name + namespace_id = kv.namespace_id + }], + [for pt in var.plain_text_bindings : { + type = "plain_text" + name = pt.name + text = pt.value + }], + [for sec in var.secrets : { + type = "secret_text" + name = sec.name + text = sec.value + }], + ) +} + +resource "cloudflare_worker" "this" { + account_id = var.account_id + name = var.worker_name + + subdomain = { + enabled = true + } + + observability = { + enabled = true + head_sampling_rate = 1 + logs = { + enabled = true + head_sampling_rate = 1 + invocation_logs = true + } + } +} + +resource "cloudflare_worker_version" "this" { + account_id = var.account_id + worker_id = cloudflare_worker.this.id + compatibility_date = var.compatibility_date + compatibility_flags = var.compatibility_flags + + main_module = "index.js" + + modules = [ + { + name = "index.js" + content_type = "application/javascript+module" + content_file = var.script_path + } + ] + + bindings = local.bindings +} + +resource "cloudflare_workers_deployment" "this" { + account_id = var.account_id + script_name = cloudflare_worker.this.name + strategy = "percentage" + + versions = [ + { + percentage = 100 + version_id = cloudflare_worker_version.this.id + } + ] +} + +resource "cloudflare_workers_custom_domain" "this" { + count = var.custom_domain != null ? 1 : 0 + + account_id = var.account_id + zone_id = var.zone_id + hostname = var.custom_domain + service = cloudflare_worker.this.name +} + +resource "cloudflare_workers_route" "this" { + count = var.route_pattern != null ? 1 : 0 + + zone_id = var.zone_id + pattern = var.route_pattern + script = cloudflare_worker.this.name +} + +resource "cloudflare_workers_cron_trigger" "this" { + count = length(var.cron_triggers) > 0 ? 1 : 0 + + account_id = var.account_id + script_name = cloudflare_worker.this.name + schedules = [for expr in var.cron_triggers : { cron = expr }] + + depends_on = [cloudflare_workers_deployment.this] +} diff --git a/terraform/modules/cloudflare-worker/outputs.tf b/terraform/modules/cloudflare-worker/outputs.tf new file mode 100644 index 0000000..5c02758 --- /dev/null +++ b/terraform/modules/cloudflare-worker/outputs.tf @@ -0,0 +1,29 @@ +output "worker_name" { + description = "Name of the deployed worker" + value = cloudflare_worker.this.name +} + +output "worker_id" { + description = "ID of the worker" + value = cloudflare_worker.this.id +} + +output "version_id" { + description = "ID of the current worker version" + value = cloudflare_worker_version.this.id +} + +output "deployment_id" { + description = "ID of the deployment" + value = cloudflare_workers_deployment.this.id +} + +output "worker_url" { + description = "Default workers.dev URL (actual subdomain varies by account)" + value = "https://${cloudflare_worker.this.name}.workers.dev" +} + +output "custom_domain" { + description = "Custom domain (if configured)" + value = var.custom_domain != null ? cloudflare_workers_custom_domain.this[0].hostname : null +} diff --git a/terraform/modules/cloudflare-worker/variables.tf b/terraform/modules/cloudflare-worker/variables.tf new file mode 100644 index 0000000..ea3e6a5 --- /dev/null +++ b/terraform/modules/cloudflare-worker/variables.tf @@ -0,0 +1,77 @@ +variable "account_id" { + description = "Cloudflare account ID" + type = string +} + +variable "zone_id" { + description = "Cloudflare zone ID (required only for custom domain / route)" + type = string + default = null +} + +variable "worker_name" { + description = "Name of the worker" + type = string +} + +variable "script_path" { + description = "Path to the bundled ES-module worker script (produced by the build step)" + type = string +} + +variable "kv_namespaces" { + description = "KV namespace bindings" + type = list(object({ + binding_name = string + namespace_id = string + })) + default = [] +} + +variable "plain_text_bindings" { + description = "Plain-text environment variable bindings (non-secret config)" + type = list(object({ + name = string + value = string + })) + default = [] +} + +variable "secrets" { + description = "Secret-text bindings (values land in TF state — keep state private)" + type = list(object({ + name = string + value = string + })) + default = [] + sensitive = true +} + +variable "cron_triggers" { + description = "Cron expressions for the worker scheduled() handler (push cron-poll)" + type = list(string) + default = [] +} + +variable "compatibility_date" { + description = "Worker compatibility date (keep in sync with apps/gateway/wrangler.jsonc)" + type = string +} + +variable "compatibility_flags" { + description = "Worker compatibility flags (e.g. [\"nodejs_compat\"])" + type = list(string) + default = [] +} + +variable "custom_domain" { + description = "Optional custom domain hostname for the worker" + type = string + default = null +} + +variable "route_pattern" { + description = "Optional zone route pattern for the worker" + type = string + default = null +} diff --git a/terraform/modules/cloudflare-worker/versions.tf b/terraform/modules/cloudflare-worker/versions.tf new file mode 100644 index 0000000..5c9f70b --- /dev/null +++ b/terraform/modules/cloudflare-worker/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + cloudflare = { + source = "cloudflare/cloudflare" + version = ">= 5.0" + } + } +}