## 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 }