Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions apps/gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
113 changes: 113 additions & 0 deletions docs/superpowers/specs/2026-05-17-gateway-terraform-design.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
{"name":"constructor-mobile","private":true}
{
"name": "constructor-mobile",
"private": true,
"packageManager": "pnpm@10.28.2"
}
69 changes: 69 additions & 0 deletions terraform/README.md
Original file line number Diff line number Diff line change
@@ -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-<suffix>`) bound to the worker as `GATEWAY_KV`
(push registry / per-session cursors / hashed refresh tokens).
- The gateway Worker (`constructor-gateway-<suffix>`) 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`.
40 changes: 40 additions & 0 deletions terraform/environments/production/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions terraform/environments/production/backend.tf
Original file line number Diff line number Diff line change
@@ -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
}
}
10 changes: 10 additions & 0 deletions terraform/environments/production/backend.tfvars.example
Original file line number Diff line number Diff line change
@@ -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://<CLOUDFLARE_ACCOUNT_ID>.r2.cloudflarestorage.com"
}
13 changes: 13 additions & 0 deletions terraform/environments/production/checks.tf
Original file line number Diff line number Diff line change
@@ -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."
Comment on lines +7 to +11
}
}
8 changes: 8 additions & 0 deletions terraform/environments/production/kv.tf
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 9 additions & 0 deletions terraform/environments/production/locals.tf
Original file line number Diff line number Diff line change
@@ -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"
}
2 changes: 2 additions & 0 deletions terraform/environments/production/moved.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# State-move declarations go here when refactoring resource addresses
# (parity with background-agents/terraform). None yet.
14 changes: 14 additions & 0 deletions terraform/environments/production/outputs.tf
Original file line number Diff line number Diff line change
@@ -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
}
Loading