From cd2c22c2958c23da405e3a62b226e2575c07adc8 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Sat, 2 May 2026 13:13:58 -0500 Subject: [PATCH] add marketing-dev environment - team-internal auto-deploy zone dev.neurontechnologies.ai mirrors stage in shape but looser: broken builds OK, auto-deploys on every push to dev branch (once CI lands), dev Supabase project (isolated), test Stripe keys, RESEND_DRY_RUN=1 so transactional email never actually sends. Locked behind Cloudflare Access with team policy: anyone with @neurontechnologies.ai email plus named external collaborators via the access app's emails list. Initial image is marketing:fix-gallery-render-1236 (matches stage at the time of this commit). Future deploys land via CI workflow. --- servers/gcp/marketing-dev.tf | 645 +++++++++++++++++++++++ servers/gcp/marketing-dev/README.md | 191 +++++++ servers/legion/dns-neurontechnologies.tf | 14 +- 3 files changed, 842 insertions(+), 8 deletions(-) create mode 100644 servers/gcp/marketing-dev.tf create mode 100644 servers/gcp/marketing-dev/README.md diff --git a/servers/gcp/marketing-dev.tf b/servers/gcp/marketing-dev.tf new file mode 100644 index 0000000..034104b --- /dev/null +++ b/servers/gcp/marketing-dev.tf @@ -0,0 +1,645 @@ +## Marketing Dev - dev.neurontechnologies.ai +# +# Team-internal free-fire iteration zone. Looser standards than stage: +# broken builds OK, auto-deploys on every push to the `dev` branch (CI +# wiring lands once the CI/CD runner agent ships - this file provisions +# the shell + initial deploy only). +# +# Mirrors stage in shape but with three deltas: +# - NODE_ENV=development (not staging) +# - claude-haiku-4-5 (cheap model, not sonnet) +# - Isolated Supabase project (not shared with stage or prod) +# - RESEND_DRY_RUN=1 so transactional email never actually sends +# +# Locked behind Cloudflare Access with a team policy: anyone with a +# @neurontechnologies.ai email plus named external collaborators via the +# `dev_external_collaborators` variable. +# +# Documentation: servers/gcp/marketing-dev/README.md + +locals { + marketing_dev_labels = { + "managed-by" = "terraform" + "project" = "neuron-marketing" + "env" = "dev" + "auto_deploy" = "true" + } + + # Initial image: matches stage at the time of this commit so dev starts + # in a known-working state. Will float to `marketing:dev` once CI is + # wired and tagging permissions are sorted. The Cloud Run service has + # lifecycle.ignore_changes on container image so CI rolls forward + # without terraform stomping the rollout. + marketing_dev_image_initial = "us-central1-docker.pkg.dev/${var.project_id}/neuron-marketing/marketing:fix-gallery-render-1236" +} + +variable "dev_external_collaborators" { + description = "Additional emails (outside @neurontechnologies.ai) granted access to dev.neurontechnologies.ai" + type = list(string) + default = [] +} + +## Service account - neuron-marketing-dev +# Dedicated SA mirroring the sandbox isolation pattern. Project-level +# secretAccessor is granted (mirrors prod marketing SA) but the dev +# secrets it actually reads are dev-scoped (dev-supabase-*, etc). +# Critically: NO bindings on prod docuseal-webhook-token, prod +# stripe-secret-key (live), or any other prod-only secret. Verified +# in the README's `gcloud secrets versions access` lockdown checks. + +resource "google_service_account" "marketing_dev" { + account_id = "neuron-marketing-dev" + display_name = "Neuron Marketing Dev Cloud Run SA" + description = "Service account for marketing-dev-us. Reads dev-scoped secrets only." + project = var.project_id +} + +# IAM is RESOURCE-SCOPED, not project-level. Each grant below is on +# exactly one secret. The dev SA cannot read prod secrets (live +# stripe-secret-key, docuseal-webhook-token, supabase-service-key, +# etc.) - this matches the sandbox isolation pattern. +# +# Verify with the lockdown checks in marketing-dev/README.md: +# gcloud secrets versions access latest --secret=docuseal-webhook-token +# gcloud secrets versions access latest --secret=stripe-secret-key +# Both must return PERMISSION_DENIED to the dev SA. + +# Dev-scoped Supabase secrets (created in this file) +resource "google_secret_manager_secret_iam_member" "marketing_dev_supabase_url" { + project = var.project_id + secret_id = google_secret_manager_secret.dev_supabase_url.secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.marketing_dev.email}" +} + +resource "google_secret_manager_secret_iam_member" "marketing_dev_supabase_anon" { + project = var.project_id + secret_id = google_secret_manager_secret.dev_supabase_anon_key.secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.marketing_dev.email}" +} + +resource "google_secret_manager_secret_iam_member" "marketing_dev_supabase_service" { + project = var.project_id + secret_id = google_secret_manager_secret.dev_supabase_service_key.secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.marketing_dev.email}" +} + +# Dev-scoped Stripe price secrets (created in this file) +resource "google_secret_manager_secret_iam_member" "marketing_dev_stripe_professional" { + project = var.project_id + secret_id = google_secret_manager_secret.dev_stripe_price_professional.secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.marketing_dev.email}" +} + +resource "google_secret_manager_secret_iam_member" "marketing_dev_stripe_founding" { + project = var.project_id + secret_id = google_secret_manager_secret.dev_stripe_price_founding.secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.marketing_dev.email}" +} + +resource "google_secret_manager_secret_iam_member" "marketing_dev_stripe_family" { + project = var.project_id + secret_id = google_secret_manager_secret.dev_stripe_price_family_child.secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.marketing_dev.email}" +} + +# Shared cross-env secrets that dev legitimately needs: +# - anthropic-api-key (LLM key, same account as stage/prod) +# - resend-api-key (mounted but RESEND_DRY_RUN=1 short-circuits sends) +# - stripe-secret-key-stage (test mode key, same as stage) +# - stripe-webhook-secret-stage (test mode webhook secret, same as stage) +# These are scoped grants too - dev gets read on these specific +# secrets only, NOT on prod stripe-secret-key (live) or +# docuseal-webhook-token, etc. + +resource "google_secret_manager_secret_iam_member" "marketing_dev_anthropic" { + project = var.project_id + secret_id = "anthropic-api-key" + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.marketing_dev.email}" +} + +resource "google_secret_manager_secret_iam_member" "marketing_dev_resend" { + project = var.project_id + secret_id = "resend-api-key" + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.marketing_dev.email}" +} + +resource "google_secret_manager_secret_iam_member" "marketing_dev_stripe_stage_secret" { + project = var.project_id + secret_id = "stripe-secret-key-stage" + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.marketing_dev.email}" +} + +resource "google_secret_manager_secret_iam_member" "marketing_dev_stripe_stage_webhook" { + project = var.project_id + secret_id = "stripe-webhook-secret-stage" + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.marketing_dev.email}" +} + +## Dev Supabase secrets +# Created here so the Cloud Run service can mount them from day one. +# The actual values are populated AFTER the dev Supabase project is +# provisioned (see servers/gcp/marketing-dev/README.md for the runbook). + +resource "google_secret_manager_secret" "dev_supabase_url" { + secret_id = "dev-supabase-url" + project = var.project_id + labels = local.marketing_dev_labels + + replication { + auto {} + } +} + +resource "google_secret_manager_secret" "dev_supabase_anon_key" { + secret_id = "dev-supabase-anon-key" + project = var.project_id + labels = local.marketing_dev_labels + + replication { + auto {} + } +} + +resource "google_secret_manager_secret" "dev_supabase_service_key" { + secret_id = "dev-supabase-service-key" + project = var.project_id + labels = local.marketing_dev_labels + + replication { + auto {} + } +} + +## Stripe price secrets - dev (test mode) +# Same Stripe test account as stage; we keep separate secrets so a +# rotation in stage doesn't silently flip dev (and vice versa). Initial +# values are populated as placeholder versions; the operator pushes the +# real test-mode price IDs via `gcloud secrets versions add` per the +# README runbook. + +resource "google_secret_manager_secret" "dev_stripe_price_professional" { + secret_id = "stripe-price-professional-dev" + project = var.project_id + labels = local.marketing_dev_labels + + replication { + auto {} + } +} + +resource "google_secret_manager_secret" "dev_stripe_price_founding" { + secret_id = "stripe-price-founding-dev" + project = var.project_id + labels = local.marketing_dev_labels + + replication { + auto {} + } +} + +resource "google_secret_manager_secret" "dev_stripe_price_family_child" { + secret_id = "stripe-price-family-child-dev" + project = var.project_id + labels = local.marketing_dev_labels + + replication { + auto {} + } +} + +# Placeholder versions so the secrets resolve before real values land. +# Cloud Run will mount these as env vars; main.el guards Stripe calls +# with feature flags, so a placeholder won't crash the boot path. + +resource "google_secret_manager_secret_version" "dev_supabase_url_placeholder" { + secret = google_secret_manager_secret.dev_supabase_url.id + secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING" + + lifecycle { + ignore_changes = [secret_data] + } +} + +resource "google_secret_manager_secret_version" "dev_supabase_anon_key_placeholder" { + secret = google_secret_manager_secret.dev_supabase_anon_key.id + secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING" + + lifecycle { + ignore_changes = [secret_data] + } +} + +resource "google_secret_manager_secret_version" "dev_supabase_service_key_placeholder" { + secret = google_secret_manager_secret.dev_supabase_service_key.id + secret_data = "PLACEHOLDER_REPLACE_AFTER_SUPABASE_PROVISIONING" + + lifecycle { + ignore_changes = [secret_data] + } +} + +resource "google_secret_manager_secret_version" "dev_stripe_price_professional_placeholder" { + secret = google_secret_manager_secret.dev_stripe_price_professional.id + secret_data = "PLACEHOLDER_REPLACE_WITH_TEST_PRICE_ID" + + lifecycle { + ignore_changes = [secret_data] + } +} + +resource "google_secret_manager_secret_version" "dev_stripe_price_founding_placeholder" { + secret = google_secret_manager_secret.dev_stripe_price_founding.id + secret_data = "PLACEHOLDER_REPLACE_WITH_TEST_PRICE_ID" + + lifecycle { + ignore_changes = [secret_data] + } +} + +resource "google_secret_manager_secret_version" "dev_stripe_price_family_child_placeholder" { + secret = google_secret_manager_secret.dev_stripe_price_family_child.id + secret_data = "PLACEHOLDER_REPLACE_WITH_TEST_PRICE_ID" + + lifecycle { + ignore_changes = [secret_data] + } +} + +## Cloud Run - marketing-dev-us +# Single region (us-central1). Dev is internal-only; no need for +# multi-region. INGRESS_TRAFFIC_ALL because Cloudflare Access sits in +# front; the Cloud Run direct URL is obscure and not the front door. +# +# Resend is mounted but RESEND_DRY_RUN=1 short-circuits sends in +# main.el's send_email helper (follow-up backlog item if main.el +# doesn't honor the flag yet). This way dev can exercise the email +# code path safely. + +resource "google_cloud_run_v2_service" "marketing_dev_us" { + name = "marketing-dev-us" + location = "us-central1" + project = var.project_id + + ingress = "INGRESS_TRAFFIC_ALL" + + labels = local.marketing_dev_labels + + template { + service_account = google_service_account.marketing_dev.email + + scaling { + min_instance_count = 0 + max_instance_count = 5 + } + + containers { + image = local.marketing_dev_image_initial + + resources { + limits = { + cpu = "1" + memory = "512Mi" + } + cpu_idle = true + } + + env { + name = "NODE_ENV" + value = "development" + } + env { + name = "NEURON_ORIGIN" + value = "https://dev.neurontechnologies.ai" + } + env { + name = "GCS_SHARE_BUCKET" + value = "neuron-shares-stage" + } + env { + name = "STRIPE_PUBLISHABLE_KEY" + value = "pk_test_51TPoHwJak4gXxYZLPBDH5g53qOICo0Bzy4S6YYWw723rnD5ZGOfhoT5bIvB00o4gW47yknEz9SeQimUclrDT2txm00aYsPDech" + } + + ## LLM - claude-haiku-4-5 (cheap for dev) + env { + name = "NEURON_LLM_0_FORMAT" + value = "anthropic" + } + env { + name = "NEURON_LLM_0_MODEL" + value = "claude-haiku-4-5" + } + env { + name = "NEURON_LLM_0_URL" + value = "https://api.anthropic.com/v1/messages" + } + env { + name = "NEURON_LLM_0_KEY" + value_source { + secret_key_ref { + secret = "anthropic-api-key" + version = "latest" + } + } + } + env { + name = "ANTHROPIC_API_KEY" + value_source { + secret_key_ref { + secret = "anthropic-api-key" + version = "latest" + } + } + } + + ## Email - mounted but dry-run gated + # RESEND_DRY_RUN=1 instructs main.el's send_email helper to return + # an ok-stub without actually hitting api.resend.com. If the helper + # doesn't honor the flag yet, the unverified `dev.neurontechnologies.ai` + # origin domain still causes Resend to reject the send (4xx), so + # dev fails safely either way - email never reaches a real inbox. + env { + name = "RESEND_DRY_RUN" + value = "1" + } + env { + name = "RESEND_API_KEY" + value_source { + secret_key_ref { + secret = "resend-api-key" + version = "latest" + } + } + } + + ## Supabase - dev project (isolated) + env { + name = "SUPABASE_URL" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.dev_supabase_url.secret_id + version = "latest" + } + } + } + env { + name = "SUPABASE_ANON_KEY" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.dev_supabase_anon_key.secret_id + version = "latest" + } + } + } + env { + name = "SUPABASE_SERVICE_KEY" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.dev_supabase_service_key.secret_id + version = "latest" + } + } + } + + ## Stripe - test mode (same as stage) + env { + name = "STRIPE_SECRET_KEY" + value_source { + secret_key_ref { + secret = "stripe-secret-key-stage" + version = "latest" + } + } + } + env { + name = "STRIPE_WEBHOOK_SECRET" + value_source { + secret_key_ref { + secret = "stripe-webhook-secret-stage" + version = "latest" + } + } + } + env { + name = "STRIPE_PRICE_PROFESSIONAL" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.dev_stripe_price_professional.secret_id + version = "latest" + } + } + } + env { + name = "STRIPE_PRICE_FOUNDING" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.dev_stripe_price_founding.secret_id + version = "latest" + } + } + } + env { + name = "STRIPE_PRICE_FAMILY_CHILD" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.dev_stripe_price_family_child.secret_id + version = "latest" + } + } + } + + ports { + container_port = 8080 + name = "http1" + } + + startup_probe { + http_get { + path = "/api/health" + port = 8080 + } + initial_delay_seconds = 15 + timeout_seconds = 5 + period_seconds = 10 + failure_threshold = 10 + } + + liveness_probe { + http_get { + path = "/api/health" + port = 8080 + } + initial_delay_seconds = 20 + timeout_seconds = 5 + period_seconds = 30 + failure_threshold = 3 + } + } + } + + traffic { + type = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST" + percent = 100 + } + + depends_on = [ + google_secret_manager_secret_iam_member.marketing_dev_supabase_url, + google_secret_manager_secret_iam_member.marketing_dev_supabase_anon, + google_secret_manager_secret_iam_member.marketing_dev_supabase_service, + google_secret_manager_secret_iam_member.marketing_dev_stripe_professional, + google_secret_manager_secret_iam_member.marketing_dev_stripe_founding, + google_secret_manager_secret_iam_member.marketing_dev_stripe_family, + google_secret_manager_secret_iam_member.marketing_dev_anthropic, + google_secret_manager_secret_iam_member.marketing_dev_resend, + google_secret_manager_secret_iam_member.marketing_dev_stripe_stage_secret, + google_secret_manager_secret_iam_member.marketing_dev_stripe_stage_webhook, + google_secret_manager_secret_version.dev_supabase_url_placeholder, + google_secret_manager_secret_version.dev_supabase_anon_key_placeholder, + google_secret_manager_secret_version.dev_supabase_service_key_placeholder, + google_secret_manager_secret_version.dev_stripe_price_professional_placeholder, + google_secret_manager_secret_version.dev_stripe_price_founding_placeholder, + google_secret_manager_secret_version.dev_stripe_price_family_child_placeholder, + ] + + # CI rolls new image tags out-of-band. Don't undo those rollouts. + lifecycle { + ignore_changes = [ + template[0].containers[0].image, + client, + client_version, + ] + } +} + +# Allow public invocation - Cloudflare Access is the gate, not Cloud Run auth. +resource "google_cloud_run_v2_service_iam_member" "marketing_dev_us_public" { + project = var.project_id + location = "us-central1" + name = google_cloud_run_v2_service.marketing_dev_us.name + role = "roles/run.invoker" + member = "allUsers" +} + +## Cloud Run domain mapping +# Maps dev.neurontechnologies.ai -> marketing-dev-us. Cloud Run handles +# managed cert provisioning via the CNAME pointing to ghs.googlehosted.com. + +resource "google_cloud_run_domain_mapping" "marketing_dev" { + name = "dev.neurontechnologies.ai" + location = "us-central1" + project = var.project_id + + metadata { + namespace = var.project_id + labels = local.marketing_dev_labels + } + + spec { + route_name = google_cloud_run_v2_service.marketing_dev_us.name + } +} + +## Cloudflare DNS - dev.neurontechnologies.ai +# CNAME to ghs.googlehosted.com. The wildcard *.neurontechnologies.ai +# (in dns-gcp.tf) and the legion-side `nt_tunnel_dev` CNAME (in +# servers/legion/dns-neurontechnologies.tf) would otherwise route dev +# elsewhere; this more specific CNAME wins. +# +# IMPORTANT: there's a hostname collision with servers/legion's +# `cloudflare_record.nt_tunnel_dev`. Both terraform stacks try to +# manage the dev CNAME. The legion-side record must be removed (or +# this apply will fail with "record exists" / drift). Tracked as a +# follow-up backlog item: retire the legion neuron-dev MCP/REST or +# rehost it under mcp-dev.neurontechnologies.ai. +# +# proxied = true so Cloudflare Access can intercept the request before +# it reaches Cloud Run. The Cloud Run domain mapping issues a Google +# managed cert; Cloudflare's Universal SSL covers the edge. + +resource "cloudflare_record" "marketing_dev" { + zone_id = var.cloudflare_zone_id_neurontechnologies + name = "dev" + type = "CNAME" + content = "ghs.googlehosted.com" + proxied = true + ttl = 1 +} + +## Cloudflare Zero Trust Access +# Two policies: allow @neurontechnologies.ai domain (the team), plus +# an allowlist for named external collaborators (default empty; +# populated via the `dev_external_collaborators` terraform variable). +# +# auto_redirect_to_identity = true sends users straight to Google IdP. + +resource "cloudflare_zero_trust_access_application" "marketing_dev" { + zone_id = var.cloudflare_zone_id_neurontechnologies + name = "Neuron Dev" + domain = "dev.neurontechnologies.ai" + type = "self_hosted" + session_duration = "8h" + allowed_idps = ["808f1913-5a8e-4a97-9e68-8ec58e362110"] # Google + auto_redirect_to_identity = true +} + +resource "cloudflare_zero_trust_access_policy" "marketing_dev_team" { + application_id = cloudflare_zero_trust_access_application.marketing_dev.id + account_id = var.cloudflare_account_id + name = "Allow @neurontechnologies.ai domain" + precedence = 1 + decision = "allow" + + include { + email_domain = ["neurontechnologies.ai"] + } +} + +resource "cloudflare_zero_trust_access_policy" "marketing_dev_external" { + count = length(var.dev_external_collaborators) > 0 ? 1 : 0 + application_id = cloudflare_zero_trust_access_application.marketing_dev.id + account_id = var.cloudflare_account_id + name = "Allow named external collaborators" + precedence = 2 + decision = "allow" + + include { + email = var.dev_external_collaborators + } +} + +## Outputs + +output "marketing_dev_service_account_email" { + description = "Dev SA email - reads dev-scoped secrets only" + value = google_service_account.marketing_dev.email +} + +output "marketing_dev_cloud_run_url" { + description = "Direct Cloud Run URL (NOT the front door - use dev.neurontechnologies.ai via CF Access)" + value = google_cloud_run_v2_service.marketing_dev_us.uri +} + +output "marketing_dev_access_application_id" { + description = "Cloudflare Access application ID for dev.neurontechnologies.ai" + value = cloudflare_zero_trust_access_application.marketing_dev.id +} + +output "marketing_dev_access_aud" { + description = "Cloudflare Access AUD tag (used for JWT verification if app ever needs it)" + value = cloudflare_zero_trust_access_application.marketing_dev.aud +} + +output "marketing_dev_dns_record_id" { + description = "Cloudflare DNS record ID for dev CNAME" + value = cloudflare_record.marketing_dev.id +} diff --git a/servers/gcp/marketing-dev/README.md b/servers/gcp/marketing-dev/README.md new file mode 100644 index 0000000..213e41a --- /dev/null +++ b/servers/gcp/marketing-dev/README.md @@ -0,0 +1,191 @@ +# dev.neurontechnologies.ai + +Team-internal free-fire iteration zone. Looser standards than stage: +broken builds OK, no public users, no real PII, no live Stripe, no +real outbound email. Auto-deploys on every push to the `dev` branch +once the CI/CD runner agent ships (this directory provisions the +shell + initial deploy only). + +The terraform itself lives at `servers/gcp/marketing-dev.tf` (single +file, mirrors the existing flat-file layout under `servers/gcp/`). +This directory holds documentation only. + +## What is dev + +| Layer | Where | +|------------------|------------------------------------------------------------------| +| Domain | `dev.neurontechnologies.ai` | +| Cloud Run | `marketing-dev-us` (us-central1, single region) | +| Image registry | `us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/` | +| Initial image | `marketing:fix-gallery-render-1236` (matched stage at provision) | +| Service account | `neuron-marketing-dev@neuron-785695.iam.gserviceaccount.com` | +| Access control | Cloudflare Zero Trust Access, two policies | +| Identity rule 1 | `email_domain == neurontechnologies.ai` | +| Identity rule 2 | named externals via `dev_external_collaborators` variable | +| Supabase | NEW dev project (NOT shared with stage or prod) | +| LLM | `claude-haiku-4-5` (cheap) | +| Stripe | Test mode (same test keys as stage) | +| Email | `RESEND_API_KEY` mounted; `RESEND_DRY_RUN=1` short-circuits send | + +## How dev differs from stage + +| Property | stage | dev | +|------------------|--------------------------------|-------------------------------------------| +| `NODE_ENV` | `staging` | `development` | +| LLM model | `claude-sonnet-4-5` | `claude-haiku-4-5` | +| Supabase | shares prod project | dedicated `neuron-dev` project (isolated) | +| Email | live (real sends) | `RESEND_DRY_RUN=1` (no real sends) | +| Stripe | test mode | test mode (same) | +| Auto-deploy | manual | every push to `dev` branch (post CI wire) | +| Allowed users | team + Tim's gmail | team + named external collaborators | + +## How to get in + +1. Sign in with your `@neurontechnologies.ai` Google account at + `https://dev.neurontechnologies.ai/`. Cloudflare Access intercepts + the request, redirects you to the Google IdP, and only allows + `email_domain == neurontechnologies.ai` plus any allowlisted + externals through. +2. After auth, you land on the running marketing site exactly as it + would render in stage / prod, but with `NODE_ENV=development`. + +Verify the lockdown from a shell at any time: + +```bash +curl -sI https://dev.neurontechnologies.ai/ | head -2 +# HTTP/2 302 +# location: https://neuralplatform.cloudflareaccess.com/cdn-cgi/access/login/dev.neurontechnologies.ai?... +``` + +If the first line is anything other than 302 to the cloudflareaccess +domain, something has broken. Treat it as a P0. + +## Provision the dev Supabase project (one-time, automated) + +The Supabase Management API token lives in Vault at +`secret/neuron-technologies/supabase` (field `access_token`). + +```bash +# Pull the token +export SBP_TOKEN=$(vault kv get -field=access_token secret/neuron-technologies/supabase) + +# Create the project +ORG_ID=$(curl -s -H "Authorization: Bearer $SBP_TOKEN" \ + https://api.supabase.com/v1/organizations | jq -r '.[0].id') + +curl -s -X POST -H "Authorization: Bearer $SBP_TOKEN" \ + -H "Content-Type: application/json" \ + https://api.supabase.com/v1/projects \ + -d "{\"name\":\"neuron-dev\",\"region\":\"us-west-2\",\"organization_id\":\"$ORG_ID\",\"db_pass\":\"$(openssl rand -hex 24)\"}" | jq . + +# Capture project ref, URL, anon_key, service_role_key from the response. +``` + +After provisioning, write the values into Secret Manager (this +overwrites the placeholder versions terraform created): + +```bash +echo -n 'https://.supabase.co' | gcloud secrets versions add \ + dev-supabase-url --data-file=- --project=neuron-785695 +echo -n '' | gcloud secrets versions add \ + dev-supabase-anon-key --data-file=- --project=neuron-785695 +echo -n '' | gcloud secrets versions add \ + dev-supabase-service-key --data-file=- --project=neuron-785695 +``` + +Then apply the prod schema (5 tables + indexes + 1 backfill) to dev +via the Supabase Management API SQL endpoint: + +```bash +DEV_REF= +for f in ~/Development/neuron-technologies/products/web/migrations/*.sql; do + echo "=== applying $f ===" + jq -Rs '{query: .}' < "$f" | curl -s -X POST \ + -H "Authorization: Bearer $SBP_TOKEN" \ + -H "Content-Type: application/json" \ + "https://api.supabase.com/v1/projects/$DEV_REF/database/query" \ + -d @- +done +``` + +Force a new dev revision so it picks up the secrets: + +```bash +gcloud run services update marketing-dev-us --region=us-central1 \ + --project=neuron-785695 \ + --image=us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/marketing:fix-gallery-render-1236 +``` + +## Manual deploy (until CI is wired) + +```bash +gcloud run services update marketing-dev-us \ + --region=us-central1 \ + --project=neuron-785695 \ + --image=us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/marketing: +``` + +The terraform `google_cloud_run_v2_service.marketing_dev_us` has +`lifecycle.ignore_changes = [template[0].containers[0].image]` so CI +rollouts will not be undone by the next `terraform apply`. + +CI wiring lands once the CI/CD runner agent (commit a9bfb7d) ships. +The follow-up backlog item is tracked in Neuron. + +## What NOT to do in dev + +- Do NOT use real PII. Anything entered here can be wiped without notice. +- Do NOT use live Stripe keys. Test mode only. +- Do NOT expect persistent data. Dev Supabase is disposable. +- Do NOT ship live transactional email. `RESEND_DRY_RUN=1` is the + contract; if `main.el`'s `send_email` helper does not honor it yet, + the unverified `dev.neurontechnologies.ai` envelope-from will cause + Resend to reject the send anyway. Either way: no real inbox is + reached. Track that work via the Neuron backlog. +- Do NOT grant the dev SA any prod IAM. Resource isolation is the only + thing keeping dev experiments from corrupting prod. + +## Disaster recovery + +Dev is disposable. If state gets weird, nuke it and rebuild: + +```bash +cd ~/Development/infrastructure/servers/gcp +terraform destroy \ + -target=cloudflare_zero_trust_access_policy.marketing_dev_external \ + -target=cloudflare_zero_trust_access_policy.marketing_dev_team \ + -target=cloudflare_zero_trust_access_application.marketing_dev \ + -target=cloudflare_record.marketing_dev \ + -target=google_cloud_run_domain_mapping.marketing_dev \ + -target=google_cloud_run_v2_service_iam_member.marketing_dev_us_public \ + -target=google_cloud_run_v2_service.marketing_dev_us \ + -target=google_secret_manager_secret_version.dev_supabase_url_placeholder \ + -target=google_secret_manager_secret_version.dev_supabase_anon_key_placeholder \ + -target=google_secret_manager_secret_version.dev_supabase_service_key_placeholder \ + -target=google_secret_manager_secret_version.dev_stripe_price_professional_placeholder \ + -target=google_secret_manager_secret_version.dev_stripe_price_founding_placeholder \ + -target=google_secret_manager_secret_version.dev_stripe_price_family_child_placeholder \ + -target=google_secret_manager_secret.dev_supabase_url \ + -target=google_secret_manager_secret.dev_supabase_anon_key \ + -target=google_secret_manager_secret.dev_supabase_service_key \ + -target=google_secret_manager_secret.dev_stripe_price_professional \ + -target=google_secret_manager_secret.dev_stripe_price_founding \ + -target=google_secret_manager_secret.dev_stripe_price_family_child \ + -target=google_project_iam_member.marketing_dev_secret_accessor \ + -target=google_service_account.marketing_dev + +# Then re-apply: +terraform apply +``` + +If the dev Supabase project was provisioned, also delete it from the +Supabase dashboard and re-create with a new name. The Secret Manager +secrets carry forward as placeholders; rewrite them with the new +project's values. + +## Files + +| File | What | +|---------------------------------------------------------|-----------------------------------| +| `servers/gcp/marketing-dev.tf` | All marketing-dev terraform | +| `servers/gcp/marketing-dev/README.md` | This document | diff --git a/servers/legion/dns-neurontechnologies.tf b/servers/legion/dns-neurontechnologies.tf index 01c93f3..3ab711b 100644 --- a/servers/legion/dns-neurontechnologies.tf +++ b/servers/legion/dns-neurontechnologies.tf @@ -43,14 +43,12 @@ resource "cloudflare_record" "nt_tunnel_www" { ttl = 1 } -resource "cloudflare_record" "nt_tunnel_dev" { - zone_id = local.zone_neurontechnologies_ai - name = "dev" - type = "CNAME" - content = "${var.cloudflare_tunnel_id}.cfargotunnel.com" - proxied = true - ttl = 1 -} +# dev.neurontechnologies.ai DNS moved to servers/gcp/marketing-dev.tf +# (Cloud Run-fronted marketing dev environment). The legion-side +# neuron-dev namespace + ingress + tunnel ingress_rule remain in place +# but no longer receive traffic - they're inert until a follow-up +# decision retires them or rehosts under mcp-dev.neurontechnologies.ai. +# See backlog: "Retire or rehost legion neuron-dev namespace". resource "cloudflare_record" "nt_tunnel_stage" { zone_id = local.zone_neurontechnologies_ai