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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ build/
.wrangler/
.astro/

# OpenTofu / Terraform local state and provider installs.
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
!*.tfvars.example

# Codex local integration links. These point at user-specific AI Central
# checkouts and are recreated with `npm run codex:links`.
.codex/skills/
7 changes: 7 additions & 0 deletions docs/20-hosting-and-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,14 @@ Already implemented:
- Pages uses Astro CSP support for script/style hashes and Worker `connect-src`.
- Pages static headers include HSTS, frame denial, referrer policy, nosniff, and a restrictive permissions policy.
- Repository data, analysis results, reports, and exports stay out of Cloudflare.
- Initial OpenTofu scaffold for Cloudflare Pages, KV, domains, and environment output wiring.

Not production-ready yet:
- Production `SETTINGS_KV` namespace IDs are not configured in `wrangler.toml`.
- Pages preview deployment CORS behavior is not finalized.
- Production Cloudflare resource names and custom domains are not finalized.
- Production GitHub App callback URLs are still placeholders.
- OpenTofu has not been applied against the real Cloudflare account yet.

## Production Storage Decision

Expand Down Expand Up @@ -88,6 +90,11 @@ Token posture:

## Cloudflare Configuration

Infrastructure:
- OpenTofu configuration lives in `infra/opentofu`.
- Keep secret values out of OpenTofu state.
- Use OpenTofu outputs for `SETTINGS_KV`, Pages env vars, Worker env vars, and GitHub App URLs.

Pages production config:
- Build command: `pnpm --filter @forage/web build`
- Build output directory: `apps/web/dist`
Expand Down
2 changes: 2 additions & 0 deletions docs/21-hosting-ui-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Custom domains:
Important:
- `PUBLIC_WORKER_ORIGIN` is compiled into the Astro build and into CSP `connect-src`.
- If the Worker API hostname changes, update the Pages environment variable and redeploy Pages.
- If using OpenTofu, confirm the Pages project, build variables, and custom domains match `infra/opentofu` outputs.

## Cloudflare Worker UI

Expand Down Expand Up @@ -125,6 +126,7 @@ KV namespace:
- Create production namespace: `forage-production-settings`
- Create staging namespace: `forage-staging-settings`
- Bind each namespace to the Worker as `SETTINGS_KV`
- If using OpenTofu, the namespace ids come from `tofu output settings_kv_namespaces`.

Do not create or bind storage for repository lists, analysis results, reports, or exports.

Expand Down
67 changes: 67 additions & 0 deletions docs/22-infrastructure-as-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Infrastructure As Code

Status:
Initial OpenTofu scaffold

Forage uses OpenTofu-compatible Terraform configuration for Cloudflare resources under `infra/opentofu`.

The current split is intentional:

- OpenTofu owns stable account/project resources and hostname wiring.
- Wrangler still owns Worker code upload and Durable Object migrations.
- GitHub App creation and OAuth secrets stay manual/operator-controlled.
- Repository data remains browser-local and is not represented in Cloudflare infrastructure.

## Managed Resources

Current OpenTofu scope:

- Cloudflare Pages project for `apps/web`.
- Pages production and preview build environment variables.
- Pages custom domains.
- Cloudflare KV namespaces for `SETTINGS_KV`.
- Optional Worker custom domains after the Worker service exists.
- Outputs for GitHub App homepage/callback URL values.
- Outputs for Worker and Pages environment variables.

Current manual scope:

- GitHub App creation and permissions.
- GitHub App client id/client secret storage.
- Worker secret values.
- First Worker code deployment with Wrangler.
- Any Cloudflare Git integration that requires a connected GitHub account.

## Privacy Boundary

Do not add server-side storage resources for:

- Starred repository lists.
- GitHub repository metadata imported by the user.
- Analysis results.
- Generated reports.
- User exports.

Allowed server-side resources remain limited to auth/session coordination, non-obvious user hashes, settings/preferences, and aggregate analytics if the user opts in.

## First Run

```sh
cd infra/opentofu
cp terraform.tfvars.example terraform.tfvars
tofu init
tofu plan
```

Set `manage_worker_custom_domains = false` until the Worker service has been deployed at least once.

After apply, copy the `settings_kv_namespaces` output into the Worker binding configuration as `SETTINGS_KV` for each environment.

## Sources Checked

- [Cloudflare Terraform overview](https://developers.cloudflare.com/terraform/)
- [Cloudflare Workers infrastructure-as-code guidance](https://developers.cloudflare.com/workers/platform/infrastructure-as-code/)
- [Cloudflare Terraform Pages resources](https://developers.cloudflare.com/api/terraform/resources/pages)
- [Cloudflare Terraform Workers resources](https://developers.cloudflare.com/api/terraform/resources/workers/)
- [Cloudflare Terraform KV resources](https://developers.cloudflare.com/api/terraform/resources/kv/)
- [Cloudflare Terraform Workers custom domain resources](https://developers.cloudflare.com/api/terraform/resources/workers/subresources/domains/)
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ This directory contains the planning and technical decision material for Forage.
- [Code Audit](./19-code-audit.md)
- [Hosting And Security Plan](./20-hosting-and-security.md)
- [Hosting UI Setup](./21-hosting-ui-setup.md)
- [Infrastructure As Code](./22-infrastructure-as-code.md)

## Architecture Decisions

Expand Down
19 changes: 19 additions & 0 deletions infra/opentofu/.terraform.lock.hcl

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

70 changes: 70 additions & 0 deletions infra/opentofu/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Forage Cloudflare OpenTofu

This directory manages the hosted Cloudflare infrastructure that can be safely represented as code for the Forage MVP.

Managed here:
- Cloudflare KV namespaces for small settings/preferences records.
- Cloudflare Pages project build settings and non-secret environment variables.
- Cloudflare Pages custom domains.
- Optional Cloudflare Worker custom domains after the Worker service exists.
- Outputs for GitHub App callback/homepage URLs and Worker/Page environment wiring.

Not managed here:
- GitHub App creation or OAuth client secrets.
- Secret values such as `GITHUB_CLIENT_SECRET`, `SETTINGS_HASH_SALT`, or `SESSION_ENCRYPTION_KEY`.
- Browser-local repository data, analysis results, reports, or exports.
- Worker code uploads for MVP; `apps/worker` still deploys through Wrangler so Durable Object migrations stay aligned with `wrangler.toml`.

## Usage

```sh
cd infra/opentofu
cp terraform.tfvars.example terraform.tfvars
tofu init
tofu plan
tofu apply
```

Use environment variables for Cloudflare authentication:

```sh
export CLOUDFLARE_API_TOKEN=...
```

The token needs enough access to manage Pages, Workers custom domains, and Workers KV in the selected account and zone.

Formatting:

```sh
pnpm infra:fmt:check
pnpm infra:fmt
```

## First Apply

For a new account, keep `manage_worker_custom_domains = false` until `pnpm --filter @forage/worker exec wrangler deploy --env <environment>` or equivalent Wrangler deployment has created the Worker service.

After the Worker exists:

1. Set `manage_worker_custom_domains = true`.
2. Re-run `tofu plan`.
3. Re-run `tofu apply`.

## Secrets

OpenTofu intentionally does not accept secret variables for the GitHub App or Worker session crypto. Configure these with Wrangler or Cloudflare UI/CI secret handling:

```sh
pnpm --filter @forage/worker exec wrangler secret put GITHUB_CLIENT_ID --env production
pnpm --filter @forage/worker exec wrangler secret put GITHUB_CLIENT_SECRET --env production
pnpm --filter @forage/worker exec wrangler secret put SETTINGS_HASH_SALT --env production
pnpm --filter @forage/worker exec wrangler secret put SESSION_ENCRYPTION_KEY --env production
```

Use separate values for staging and production.

## Wrangler Bindings

After apply, read `settings_kv_namespaces` from `tofu output`. Bind each namespace as `SETTINGS_KV` in `apps/worker/wrangler.toml` or equivalent deployment automation.

Do not add KV namespaces for repositories, analysis, reports, or exports.
129 changes: 129 additions & 0 deletions infra/opentofu/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
terraform {
required_version = ">= 1.8.0"

required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "5.9.0"
}
}
}

provider "cloudflare" {}

locals {
environment_config = {
for name, environment in var.environments : name => merge(environment, {
github_redirect_uri = coalesce(
environment.github_redirect_uri,
"https://${environment.api_hostname}/auth/github/callback",
)
settings_kv_title = coalesce(
environment.settings_kv_title,
"${var.project_slug}-${name}-settings",
)
worker_service_name = coalesce(
environment.worker_service_name,
name == "production" ? var.worker_service_name : "${var.worker_service_name}-${name}",
)
})
}

pages_environment_variables = {
for name, environment in local.environment_config : name => {
NODE_VERSION = var.node_version
PUBLIC_WORKER_ORIGIN = "https://${environment.api_hostname}"
}
}

worker_environment_variables = {
for name, environment in local.environment_config : name => {
ENVIRONMENT = name
GITHUB_API_VERSION = var.github_api_version
GITHUB_REDIRECT_URI = environment.github_redirect_uri
WEB_ORIGIN = "https://${environment.web_hostname}"
SETTINGS_KV_ID = try(cloudflare_workers_kv_namespace.settings[name].id, null)
}
}
}

resource "cloudflare_workers_kv_namespace" "settings" {
for_each = {
for name, environment in local.environment_config : name => environment
if environment.create_settings_kv
}

account_id = var.cloudflare_account_id
title = each.value.settings_kv_title
}

resource "cloudflare_pages_project" "web" {
count = var.manage_pages_project ? 1 : 0

account_id = var.cloudflare_account_id
name = var.pages_project_name
production_branch = var.production_branch

build_config = {
build_caching = true
build_command = "pnpm --filter @forage/web build"
destination_dir = "apps/web/dist"
root_dir = "/"
}

deployment_configs = {
preview = {
env_vars = {
NODE_VERSION = {
type = "plain_text"
value = var.node_version
}
PUBLIC_WORKER_ORIGIN = {
type = "plain_text"
value = "https://${local.environment_config[var.preview_environment_name].api_hostname}"
}
}
fail_open = false
}
production = {
env_vars = {
NODE_VERSION = {
type = "plain_text"
value = var.node_version
}
PUBLIC_WORKER_ORIGIN = {
type = "plain_text"
value = "https://${local.environment_config["production"].api_hostname}"
}
}
fail_open = false
}
}
}

resource "cloudflare_pages_domain" "web" {
for_each = {
for name, environment in local.environment_config : name => environment
if var.manage_pages_domains && environment.manage_pages_domain
}

account_id = var.cloudflare_account_id
project_name = var.pages_project_name
name = each.value.web_hostname

depends_on = [cloudflare_pages_project.web]
}

resource "cloudflare_workers_custom_domain" "api" {
for_each = {
for name, environment in local.environment_config : name => environment
if var.manage_worker_custom_domains && environment.manage_worker_custom_domain
}

account_id = var.cloudflare_account_id
environment = each.key
hostname = each.value.api_hostname
service = each.value.worker_service_name
zone_id = var.cloudflare_zone_id
zone_name = var.cloudflare_zone_name
}
Loading
Loading