From a2adeec9d1ffdbb965e05c5ed24bd67db757f85b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 17 Sep 2026 00:16:17 +0200 Subject: [PATCH] feat(infra): the stack deploys a second geographic instance from its stage string An EU instance is the whole stack deployed as `prd-eu`: its own Workers under `*.eu.maple.dev`, its own ingest fleet and Electric in eu-central-1, its own Tinybird workspace, database and secrets. Nothing routes per org; an org's region is the instance it was created on. The plan and the decisions behind it are in docs/eu-region-plan.md. The region rides on the alchemy stage string rather than an env var, because alchemy keys its state by stage: `prd` and `prd-eu` can never plan against each other's resources, and nothing has to be set in lockstep. `parseMapleDeployment` is the one parser and `MapleStack` carries the region to every Worker module. `us` stays unsuffixed in every name and hostname, so the existing production renames nothing. What the region decides on the Cloudflare half: - Worker names, domains and placement (`resolveWorkerName`, `resolveMapleDomains`, `resolveWorkerPlacement`); landing and local-ui are shared apps and stay on `us`. - Storage jurisdiction: the EU replay bucket is created in the `eu` jurisdiction, and the ingest gateway's writer token and S3 endpoint follow it. The chat Durable Object's jurisdiction is a property of the object id, so `chatSessionStub` applies it where ids are minted, reading the stack-derived `MAPLE_REGION`. - Hyperdrive: `resolveHyperdriveRefId` throws for `eu` until the EU configs exist, rather than binding nothing and 500ing every DB-backed route the way a PR preview does by design. - Public URLs (`appUrlsEnv`) default to the deploy's own hostnames, so EU emails and share links point at the EU app. Deploy: `deploy-prd-instance.yml` is the per-instance body, called for `us` on every green CI run and for `eu` behind the `MAPLE_DEPLOY_EU` repository variable. One `region` input picks the stage, the GitHub and Infisical environments and the AWS region; the composite action gained an `aws-region` input, and the stack still refuses an `AWS_REGION` that disagrees with its stage. --- .github/actions/deploy-setup/action.yml | 9 +- .github/workflows/deploy-prd-instance.yml | 138 ++++++++++++++ .github/workflows/deploy-prd.yml | 136 +++----------- alchemy.run.ts | 49 ++--- apps/ai/src/worker.ts | 18 +- apps/alerting/src/worker.ts | 24 +-- apps/api/src/resources/env.ts | 13 +- apps/api/src/resources/replay-blobs.ts | 10 +- apps/api/src/worker.ts | 17 +- apps/electric-sync/src/worker.ts | 15 +- apps/ingest/alchemy.run.ts | 24 ++- apps/landing/src/worker.ts | 8 +- apps/local-ui/src/worker.ts | 8 +- apps/sandbox/alchemy.run.ts | 10 +- apps/web/src/worker.ts | 23 ++- docs/eu-region-plan.md | 190 ++++++++++++++++++++ docs/infra.md | 42 ++++- package.json | 2 +- packages/backend/src/http/api-cors.ts | 2 +- packages/domain/src/chat-session-stub.ts | 19 +- packages/domain/src/chat-session.test.ts | 32 ++++ packages/infra/src/aws/stage.ts | 32 +--- packages/infra/src/cloudflare/index.ts | 1 + packages/infra/src/cloudflare/maple-db.ts | 10 +- packages/infra/src/cloudflare/stack.ts | 22 ++- packages/infra/src/cloudflare/stage.test.ts | 102 ++++++++++- packages/infra/src/cloudflare/stage.ts | 145 ++++++++++++++- packages/infra/src/env.test.ts | 26 ++- packages/infra/src/env.ts | 29 ++- packages/infra/src/region.ts | 44 +++++ 30 files changed, 939 insertions(+), 261 deletions(-) create mode 100644 .github/workflows/deploy-prd-instance.yml create mode 100644 docs/eu-region-plan.md create mode 100644 packages/infra/src/region.ts diff --git a/.github/actions/deploy-setup/action.yml b/.github/actions/deploy-setup/action.yml index 92858cc46..e01866016 100644 --- a/.github/actions/deploy-setup/action.yml +++ b/.github/actions/deploy-setup/action.yml @@ -18,6 +18,13 @@ inputs: aws-role-arn: description: The deploy role to assume over OIDC. required: true + aws-region: + description: >- + The AWS region the deploy's ECS resources live in — `resolveAwsRegion` + for the instance (us-east-1 for us, eu-central-1 for eu). The stack + refuses an AWS_REGION that disagrees with its stage. + required: false + default: us-east-1 ingest-binary: description: >- Download the `maple-ingest` artifact the build-ingest-binary job @@ -56,7 +63,7 @@ runs: uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: ${{ inputs.aws-role-arn }} - aws-region: us-east-1 + aws-region: ${{ inputs.aws-region }} - uses: ./.github/actions/bun-install diff --git a/.github/workflows/deploy-prd-instance.yml b/.github/workflows/deploy-prd-instance.yml new file mode 100644 index 000000000..5e7be9682 --- /dev/null +++ b/.github/workflows/deploy-prd-instance.yml @@ -0,0 +1,138 @@ +name: Deploy one production instance + +# The body of a production deploy, for one geographic instance. `deploy-prd.yml` +# calls it once per instance — `us` on every green CI run, `eu` once the EU +# instance exists — so the two share one set of steps and differ only in the +# alchemy stage (`prd` / `prd-eu`), the GitHub environment, the Infisical +# environment (same variable names, that instance's values) and the AWS region. +# A caller passes the commit to deploy explicitly: inside a reusable workflow +# `github.sha` is still the default-branch head, not the commit CI ran against. + +on: + workflow_call: + inputs: + region: + description: Which instance to deploy, `us` or `eu`. + required: true + type: string + commit-sha: + description: The commit being deployed, stamped onto telemetry as `vcs.ref.head.revision`. + required: true + type: string + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 45 + # `production` holds the us instance's protection rules and variables; + # `production-eu` the EU instance's. The Infisical environment follows the + # same naming: `prod` and `prod-eu`. + environment: ${{ inputs.region == 'us' && 'production' || format('production-{0}', inputs.region) }} + env: + MAPLE_REGION: ${{ inputs.region }} + # `prd` for us, `prd-eu` for eu — the stage string carries the instance, + # and alchemy keys its state by it, so the two never share a plan. + MAPLE_STAGE: ${{ inputs.region == 'us' && 'prd' || format('prd-{0}', inputs.region) }} + INFISICAL_ENV_SLUG: ${{ inputs.region == 'us' && 'prod' || format('prod-{0}', inputs.region) }} + # Must match `resolveAwsRegion` for the instance; the stack refuses a mismatch. + AWS_REGION: ${{ inputs.region == 'us' && 'us-east-1' || 'eu-central-1' }} + API_HOST: ${{ inputs.region == 'us' && 'api.maple.dev' || format('api.{0}.maple.dev', inputs.region) }} + # Stamped onto deployed telemetry as `vcs.ref.head.revision` (server SDK + # reads COMMIT_SHA; web build reads VITE_COMMIT_SHA via Vite define). + COMMIT_SHA: ${{ inputs.commit-sha }} + VITE_COMMIT_SHA: ${{ inputs.commit-sha }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + ref: ${{ inputs.commit-sha }} + + # Toolchain, Infisical secrets, AWS OIDC (after Infisical, so its + # credentials win), dependencies, and the ingest binary the caller's + # `ingest-binary` job compiled — one composite, one order. + - name: Deploy setup + id: setup + uses: ./.github/actions/deploy-setup + with: + infisical-env-slug: ${{ env.INFISICAL_ENV_SLUG }} + infisical-identity-id: ${{ secrets.INFISICAL_MACHINE_IDENTITY_ID }} + infisical-project-slug: ${{ vars.INFISICAL_PROJECT_SLUG }} + aws-role-arn: ${{ vars.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + # NOTE: prod schema migrations are applied OUT OF BAND (manually, via + # `bun run migrate:prod` → `ps:apply-schema main`, against the direct 5432 + # port), NOT by this workflow — so a deploy never touches the prod database + # and needs no MAPLE_PG_URL/admin credential. The worker binds to the + # pre-configured Hyperdrive config for its instance (`MapleDb` in + # packages/infra). apply-schema installs default privileges granting + # PUBLIC before it migrates, so new and rebuilt tables are readable by + # every consumer — including the ingest gateway, which reaches Postgres + # through PSBouncer as a role that does NOT inherit `postgres`. + + # alchemy's env-credential path (CI=true) otherwise discovers the account + # with an STS GetCallerIdentity issued while its own AWSEnvironment is + # still being built, and that call waits on the half-built environment + # for its endpoint resolver — a self-deadlock with no network I/O and no + # log line. That was the "AWS ingest deploy hang" (#378). With the id + # supplied, the lookup is skipped. Reproduced locally with CI=true and + # the id unset on alchemy 2.0.0-beta.64 through beta.74. + # + # One pass, no retry. A stage's first deploy used to fail here — its + # ACM certificates were created PENDING_VALIDATION and their 443 + # listeners refused them — and was recovered by a second step that + # published the validation CNAMEs with `scripts/acm-cert-validate.sh` + # and deployed again. The stack now publishes those records itself and + # waits for ISSUED (`@maple/infra/acm`), so a first deploy completes + # like any other and a failure here is a real failure. + - name: Deploy the ${{ inputs.region }} instance with Alchemy + id: deploy + run: bun run alchemy:deploy:prd + env: + AWS_ACCOUNT_ID: ${{ steps.setup.outputs.aws-account-id }} + + # Alchemy isolates per-resource failures on purpose: a Worker that + # fails to upload never interrupts its siblings, so a deploy can + # leave production serving two commits at once. It did on + # 2026-09-07 — `api` was rejected with `ScriptStartupError` while + # `app`, `landing`, `alerting` and `ingest` all shipped, and prod + # ran a 6h-old api behind a current web until someone read the log. + # + # `always()`: when the deploy step fails this is exactly when the + # answer matters — it names which Worker is stale instead of + # leaving it in a 4000-line log. It also catches the quieter case + # the deploy cannot report at all, where alchemy succeeds but the + # script serving traffic is not the one we just uploaded. + # + # Liveness only. Every other prod Worker is covered by the + # "Prod revision skew" alert, which compares + # `vcs.ref.head.revision` across services from their own telemetry. + # Which services that is, and the fact that it assumes they always + # deploy together, is pinned in `PRD_LOCKSTEP_REVISION_SERVICES` + # (`packages/infra/src/env.ts`) — change what this workflow deploys + # and `env.test.ts` will tell you the rule needs editing too. + - name: Verify the deployed api serves this commit + if: ${{ always() && steps.deploy.outcome != 'skipped' }} + env: + EXPECTED: ${{ env.COMMIT_SHA }} + run: | + set -uo pipefail + # Cloudflare propagates a new script over a few seconds, so a + # single probe races the rollout rather than testing it. + for attempt in 1 2 3 4 5 6; do + served=$(curl -fsS --max-time 10 -D - -o /dev/null "https://$API_HOST/health" 2>/dev/null \ + | tr -d '\r' | awk 'tolower($1) == "x-maple-revision:" { print $2 }') + [ "$served" = "$EXPECTED" ] && break + echo "attempt $attempt: api serves '${served:-}', expected '$EXPECTED'" + sleep 10 + done + if [ "$served" = "$EXPECTED" ]; then + echo "api ($API_HOST) is serving $EXPECTED" + exit 0 + fi + if [ -z "$served" ]; then + echo "::error::api /health on $API_HOST returned no x-maple-revision header. Either the Worker predates this check or it is not answering — check the deploy log for a resource that reported 'fail'." + else + echo "::error::PARTIAL DEPLOY — api on $API_HOST is serving $served but this run deployed $EXPECTED. The api Worker did not update; other Workers likely did. Find the resource that reported 'fail' in the deploy log above." + fi + exit 1 diff --git a/.github/workflows/deploy-prd.yml b/.github/workflows/deploy-prd.yml index 18b1c8d16..cd2fc6b78 100644 --- a/.github/workflows/deploy-prd.yml +++ b/.github/workflows/deploy-prd.yml @@ -3,6 +3,10 @@ name: Deploy PRD (Cloudflare via Alchemy) # Gated on CI rather than `push: main`. Both used to fire on the same push with # separate concurrency groups, so a red CI still shipped to production. # `workflow_dispatch` stays as the manual escape hatch and skips the gate. +# +# One production deploy is one commit shipped to every instance. The steps +# live in `deploy-prd-instance.yml`, called once per instance below; the +# ingest binary is built once and shared by both. on: workflow_run: workflows: ["CI"] @@ -23,115 +27,31 @@ jobs: ingest-binary: uses: ./.github/workflows/build-ingest-binary.yml + # On a workflow_run event `github.sha` is the default branch head, not the + # commit CI actually ran against — so pin everything to head_sha, both for + # what gets deployed and for the telemetry stamp. deploy-prd: needs: ingest-binary - runs-on: ubuntu-latest - timeout-minutes: 45 - # On a workflow_run event `github.sha` is the default branch head, not the - # commit CI actually ran against — so pin everything to head_sha, both for - # what gets deployed and for the telemetry stamp. if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} - environment: production - env: - INFISICAL_ENV_SLUG: prod - # Stamped onto deployed telemetry as `vcs.ref.head.revision` (server SDK - # reads COMMIT_SHA; web build reads VITE_COMMIT_SHA via Vite define). - COMMIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} - VITE_COMMIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 - with: - ref: ${{ github.event.workflow_run.head_sha || github.ref }} - - # Toolchain, Infisical secrets, AWS OIDC (after Infisical, so its - # credentials win), dependencies, and the ingest binary the - # `ingest-binary` job compiled — one composite, one order. - - name: Deploy setup - id: setup - uses: ./.github/actions/deploy-setup - with: - infisical-env-slug: ${{ env.INFISICAL_ENV_SLUG }} - infisical-identity-id: ${{ secrets.INFISICAL_MACHINE_IDENTITY_ID }} - infisical-project-slug: ${{ vars.INFISICAL_PROJECT_SLUG }} - aws-role-arn: ${{ vars.AWS_DEPLOY_ROLE_ARN }} - - # NOTE: prod schema migrations are applied OUT OF BAND (manually, via - # `bun run migrate:prod` → `ps:apply-schema main`, against the direct 5432 - # port), NOT by this workflow — so a deploy never touches the prod database - # and needs no MAPLE_PG_URL/admin credential. The worker binds to the - # pre-configured `maple-prd` Hyperdrive (`MapleDb` in packages/infra). - # apply-schema installs default privileges granting PUBLIC before it - # migrates, so new and rebuilt tables are readable by every consumer — - # including the ingest gateway, which reaches Postgres through PSBouncer as - # a role that does NOT inherit `postgres`. + uses: ./.github/workflows/deploy-prd-instance.yml + secrets: inherit + with: + region: us + commit-sha: ${{ github.event.workflow_run.head_sha || github.sha }} - # alchemy's env-credential path (CI=true) otherwise discovers the account - # with an STS GetCallerIdentity issued while its own AWSEnvironment is - # still being built, and that call waits on the half-built environment - # for its endpoint resolver — a self-deadlock with no network I/O and no - # log line. That was the "AWS ingest deploy hang" (#378). With the id - # supplied, the lookup is skipped. Reproduced locally with CI=true and - # the id unset on alchemy 2.0.0-beta.64 through beta.74. - # - # One pass, no retry. A stage's first deploy used to fail here — its - # ACM certificates were created PENDING_VALIDATION and their 443 - # listeners refused them — and was recovered by a second step that - # published the validation CNAMEs with `scripts/acm-cert-validate.sh` - # and deployed again. The stack now publishes those records itself and - # waits for ISSUED (`@maple/infra/acm`), so a first deploy completes - # like any other and a failure here is a real failure. - - name: Deploy PRD stack with Alchemy - id: deploy - run: bun run alchemy:deploy:prd - env: - AWS_ACCOUNT_ID: ${{ steps.setup.outputs.aws-account-id }} - - # Alchemy isolates per-resource failures on purpose: a Worker that - # fails to upload never interrupts its siblings, so a deploy can - # leave production serving two commits at once. It did on - # 2026-09-07 — `api` was rejected with `ScriptStartupError` while - # `app`, `landing`, `alerting` and `ingest` all shipped, and prod - # ran a 6h-old api behind a current web until someone read the log. - # - # `always()`: when the deploy step fails this is exactly when the - # answer matters — it names which Worker is stale instead of - # leaving it in a 4000-line log. It also catches the quieter case - # the deploy cannot report at all, where alchemy succeeds but the - # script serving traffic is not the one we just uploaded. - # - # Liveness only. Every other prod Worker is covered by the - # "Prod revision skew" alert, which compares - # `vcs.ref.head.revision` across services from their own telemetry. - # Which services that is, and the fact that it assumes they always - # deploy together, is pinned in `PRD_LOCKSTEP_REVISION_SERVICES` - # (`packages/infra/src/env.ts`) — change what this workflow deploys - # and `env.test.ts` will tell you the rule needs editing too. - - name: Verify the deployed api serves this commit - if: ${{ always() && steps.deploy.outcome != 'skipped' }} - env: - EXPECTED: ${{ env.COMMIT_SHA }} - run: | - set -uo pipefail - # Cloudflare propagates a new script over a few seconds, so a - # single probe races the rollout rather than testing it. - for attempt in 1 2 3 4 5 6; do - served=$(curl -fsS --max-time 10 -D - -o /dev/null https://api.maple.dev/health 2>/dev/null \ - | tr -d '\r' | awk 'tolower($1) == "x-maple-revision:" { print $2 }') - [ "$served" = "$EXPECTED" ] && break - echo "attempt $attempt: api serves '${served:-}', expected '$EXPECTED'" - sleep 10 - done - if [ "$served" = "$EXPECTED" ]; then - echo "api is serving $EXPECTED" - exit 0 - fi - if [ -z "$served" ]; then - echo "::error::api /health returned no x-maple-revision header. Either the Worker predates this check or it is not answering — check the deploy log for a resource that reported 'fail'." - else - echo "::error::PARTIAL DEPLOY — api is serving $served but this run deployed $EXPECTED. The api Worker did not update; other Workers likely did. Find the resource that reported 'fail' in the deploy log above." - fi - exit 1 + # The EU instance (`prd-eu`, `*.eu.maple.dev`). Opt-in through the + # `MAPLE_DEPLOY_EU` repository variable until its accounts exist — the stack + # refuses to deploy `eu` without its Hyperdrive configs — after which it + # ships on every green CI run alongside `us`. Sequenced after `us` so a + # change that breaks the deploy stops at one instance. + deploy-prd-eu: + needs: [ingest-binary, deploy-prd] + if: ${{ vars.MAPLE_DEPLOY_EU == '1' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }} + uses: ./.github/workflows/deploy-prd-instance.yml + secrets: inherit + with: + region: eu + commit-sha: ${{ github.event.workflow_run.head_sha || github.sha }} # A skipped job does not fail its run, so with CI red this workflow reported # SUCCESS while nothing deployed — which is how a broken `main` looked like a @@ -139,7 +59,7 @@ jobs: # nobody had noticed was never running took an hour to spot. This job always # runs, so the run's conclusion says what actually happened. deployment-gate: - needs: deploy-prd + needs: [deploy-prd, deploy-prd-eu] if: ${{ always() }} runs-on: ubuntu-latest steps: @@ -151,5 +71,7 @@ jobs: exit 1 fi # A genuine deploy failure already fails its own job and reddens - # the run; there is nothing to add here. + # the run; there is nothing to add here. The EU job is skipped + # by design until MAPLE_DEPLOY_EU is set. echo "deploy-prd: $result" + echo "deploy-prd-eu: ${{ needs.deploy-prd-eu.result }}" diff --git a/alchemy.run.ts b/alchemy.run.ts index 30f222bdc..073d55736 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -16,22 +16,18 @@ import * as Command from "alchemy/Command" import * as Output from "alchemy/Output" import * as Effect from "effect/Effect" import * as Layer from "effect/Layer" -import { - parseMapleRegion, - resolveAwsRegion, - stageDeploysElectric, - stageDeploysIngest, -} from "@maple/infra/aws" +import { resolveAwsRegion, stageDeploysElectric, stageDeploysIngest } from "@maple/infra/aws" import { ApiWorker, AiWorker, SandboxWorker, stageDeploysSandbox, - formatMapleStage, + formatMapleDeployment, ManagedMapleDb, MapleStack, type MapleStackContext, - parseMapleStage, + parseMapleDeployment, + regionHostsSharedApps, resolveDatabaseMode, resolveMapleDomains, } from "@maple/infra/cloudflare" @@ -105,10 +101,13 @@ const devEnv = devApps const MapleStackLive = Layer.effect( MapleStack, Effect.gen(function* () { - const stage = parseMapleStage(yield* Alchemy.Stage) - const domains = resolveMapleDomains(stage) + // `prd` or `prd-eu`: the stage string names the instance too, and alchemy's + // state is keyed by it, so the two instances never share a plan. + const { stage, region } = parseMapleDeployment(yield* Alchemy.Stage) + const domains = resolveMapleDomains(stage, region) const context: MapleStackContext = { stage, + region, domains, urls: { api: devEnv?.MAPLE_API_BASE_URL ?? (yield* resolveUrl(domains.api, "MAPLE_API_BASE_URL")), @@ -180,22 +179,20 @@ export default Alchemy.Stack( state: process.env.ALCHEMY_LOCAL_STATE ? Alchemy.localState() : Cloudflare.state(), }, Effect.gen(function* () { - const { stage, domains, urls } = yield* MapleStack + const { stage, region, domains, urls } = yield* MapleStack - // Geographic instance this deploy belongs to. `us` today; an EU instance is - // the same stack deployed with MAPLE_REGION=eu against that instance's own - // Tinybird workspace and application database. Guarded here because a - // mismatch between MAPLE_REGION and AWS_REGION would put the ACM - // certificate in a different region from the ALB that must use it — and - // worse, would export telemetry across the residency boundary the EU - // instance exists to enforce. - const { MAPLE_REGION } = yield* optionalPlain("MAPLE_REGION") + // Geographic instance this deploy belongs to, from the stage string + // (`prd-eu`): the EU instance is this same stack against its own Tinybird + // workspace, application database and secrets (Infisical `prod-eu`, same + // variable names). Guarded here because an AWS_REGION that disagrees would + // put the ACM certificate in a different region from the ALB that must use + // it — and worse, would export telemetry across the residency boundary the + // EU instance exists to enforce. const { AWS_REGION } = yield* optionalPlain("AWS_REGION") - const region = parseMapleRegion(MAPLE_REGION) const expectedAwsRegion = resolveAwsRegion(region) if (AWS_REGION && AWS_REGION !== expectedAwsRegion) { throw new Error( - `AWS_REGION="${AWS_REGION}" does not match MAPLE_REGION="${region}" (expects "${expectedAwsRegion}").`, + `AWS_REGION="${AWS_REGION}" does not match the "${region}" instance (expects "${expectedAwsRegion}").`, ) } @@ -262,9 +259,12 @@ export default Alchemy.Stack( // Worker (its `API` service binding), handed over as `ApiWorker`. const web = isDevServer ? undefined : yield* Effect.provideService(Web, ApiWorker, api) - const landing = isDevServer ? undefined : yield* Landing + // The marketing site and the local-mode SPA are shared across instances + // and hold no customer data: one `maple.dev`, deployed by `us` alone. + const sharedApps = !isDevServer && regionHostsSharedApps(region) + const landing = sharedApps ? yield* Landing : undefined - const localUi = isDevServer ? undefined : yield* LocalUi + const localUi = sharedApps ? yield* LocalUi : undefined const alerting = yield* Alerting yield* serveWorker("alerting", alerting) @@ -279,7 +279,8 @@ export default Alchemy.Stack( } const summary = { - stage: formatMapleStage(stage), + stage: formatMapleDeployment({ stage, region }), + region, apiUrl: urls.api, ingestUrl: urls.ingest, electricSyncUrl: urls.electricSync, diff --git a/apps/ai/src/worker.ts b/apps/ai/src/worker.ts index 3c7dcaa4c..f34c9bf88 100644 --- a/apps/ai/src/worker.ts +++ b/apps/ai/src/worker.ts @@ -29,10 +29,12 @@ */ import { cachedRecoverable, - CLOUDFLARE_WORKER_PLACEMENT, MapleStack, + type MapleDomains, + type MapleRegion, type MapleStage, resolveWorkerName, + resolveWorkerPlacement, } from "@maple/infra/cloudflare" import { appUrlsEnv, @@ -87,14 +89,14 @@ export type AiWorkerEnv = Partial +const configuredEnv = (stage: MapleStage, region: MapleRegion, domains: MapleDomains) => merge( // The tools query the warehouse as the calling org, and resolve their own // tenants, so this is largely the api's set. tinybirdEnv, authEnv, - appUrlsEnv, - selfObservabilityEnv(stage), + appUrlsEnv(domains), + selfObservabilityEnv(stage, region), ingestKeyCryptoEnv, // Agent LLM path. `MAPLE_LLM_PROVIDER` flips between OpenRouter (default) and // Workers AI; both stay wired, so a switch is this one var plus a redeploy. @@ -117,13 +119,13 @@ const configuredEnv = (stage: MapleStage) => */ const props = Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } - const { stage, workerDev, devEnv } = yield* MapleStack - const env = yield* configuredEnv(stage) + const { stage, region, domains, workerDev, devEnv } = yield* MapleStack + const env = yield* configuredEnv(stage, region, domains) return { main: import.meta.url, - name: resolveWorkerName("ai", stage), + name: resolveWorkerName("ai", stage, region), compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, - placement: CLOUDFLARE_WORKER_PLACEMENT, + placement: resolveWorkerPlacement(region), // Under `bun dev`: a sticky port the app's route follows. dev: workerDev("ai"), // No public hostname. Reached only over the api's service binding, which is diff --git a/apps/alerting/src/worker.ts b/apps/alerting/src/worker.ts index a3d2a20b4..c184dde68 100644 --- a/apps/alerting/src/worker.ts +++ b/apps/alerting/src/worker.ts @@ -14,12 +14,14 @@ */ import { cachedRecoverable, - CLOUDFLARE_WORKER_PLACEMENT, emailBinding, MapleDb, MapleStack, + type MapleDomains, + type MapleRegion, type MapleStage, resolveWorkerName, + resolveWorkerPlacement, } from "@maple/infra/cloudflare" import { apnsEnv, @@ -43,13 +45,13 @@ import { HttpServerResponse } from "effect/unstable/http" * The alerting worker's resource bindings, split from the `Config`-sourced env * so `InferEnv` can derive `AlertingWorkerEnv` below. */ -const makeWorkerBindings = ({ stage }: { stage: MapleStage }) => ({ +const makeWorkerBindings = ({ stage, region }: { stage: MapleStage; region: MapleRegion }) => ({ // Cross-script reference to the chat Durable Object the AI Worker hosts. // Alert, error, and anomaly ticks start an investigation's agent turn on it // when incidents open; `chatSessionStub` reads it off `env` under the class name. ChatSession: Cloudflare.DurableObject("ChatSession", { className: "ChatSession", - scriptName: resolveWorkerName("ai", stage), + scriptName: resolveWorkerName("ai", stage, region), }), ...emailBinding(stage), }) @@ -72,20 +74,20 @@ export type AlertingWorkerEnv = Partial +const configuredEnv = (stage: MapleStage, region: MapleRegion, domains: MapleDomains) => merge( // Alert-rule evaluation runs Tinybird-scoped raw SQL through // TinybirdOrgTokenService, so this is the same set the api worker binds. tinybirdEnv, authEnv, ingestKeyCryptoEnv, - appUrlsEnv, + appUrlsEnv(domains), // MAPLE_ENDPOINT / MAPLE_ENVIRONMENT / COMMIT_SHA / MAPLE_INGEST_KEY. // MAPLE_ENVIRONMENT is stage-derived and NOT env-overridable: it gates both // the non-prod cron skip below and EmailService.emailAllowed, so an override // would open both at once and leave the prd-only EMAIL binding as the sole // guard. - selfObservabilityEnv(stage), + selfObservabilityEnv(stage, region), // Non-prod stages skip all crons (they share live org data via the prod DB); // set to "1" on a stage to deliberately exercise crons there. optionalPlain("MAPLE_ALERTING_ALLOW_NONPROD"), @@ -110,18 +112,18 @@ const configuredEnv = (stage: MapleStage) => */ const props = Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } - const { stage, workerDev, devEnv } = yield* MapleStack - const env = yield* configuredEnv(stage) + const { stage, region, domains, workerDev, devEnv } = yield* MapleStack + const env = yield* configuredEnv(stage, region, domains) return { main: import.meta.url, - name: resolveWorkerName("alerting", stage), + name: resolveWorkerName("alerting", stage, region), compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, - placement: CLOUDFLARE_WORKER_PLACEMENT, + placement: resolveWorkerPlacement(region), // Under `bun dev`: a sticky port the app's route follows. dev: workerDev("alerting"), workersDev: false, // `devEnv` last, so `.env.local` cannot override the inter-app URLs. - env: { ...makeWorkerBindings({ stage }), ...env, ...devEnv }, + env: { ...makeWorkerBindings({ stage, region }), ...env, ...devEnv }, } }) diff --git a/apps/api/src/resources/env.ts b/apps/api/src/resources/env.ts index 6414f1a3e..79ffdb1c6 100644 --- a/apps/api/src/resources/env.ts +++ b/apps/api/src/resources/env.ts @@ -10,7 +10,12 @@ * optional-omit rule, the PR-preview exclusions and the `derived` values the * environment must not override. */ -import { type MapleDomains, type MapleStage, stageDeploysSandbox } from "@maple/infra/cloudflare" +import { + type MapleDomains, + type MapleRegion, + type MapleStage, + stageDeploysSandbox, +} from "@maple/infra/cloudflare" import { apnsEnv, appUrlsEnv, @@ -28,7 +33,7 @@ import { tinybirdEnv, } from "@maple/infra/env" -export const apiConfiguredEnv = (stage: MapleStage, domains: MapleDomains) => +export const apiConfiguredEnv = (stage: MapleStage, region: MapleRegion, domains: MapleDomains) => merge( tinybirdEnv, // ClickHouse (BYO warehouse); `tinybird` unless an org config overrides it. @@ -45,7 +50,7 @@ export const apiConfiguredEnv = (stage: MapleStage, domains: MapleDomains) => authEnv, ingestKeyCryptoEnv, requireSecretEntry("MAPLE_SHARE_TOKEN_HMAC_KEY"), - appUrlsEnv, + appUrlsEnv(domains), // The worker's own canonical origin — everything it publishes about itself // (MCP `server.json`, the discovery index) is built from this rather than // from client-controlled forwarded headers. Stages with a real domain @@ -69,7 +74,7 @@ export const apiConfiguredEnv = (stage: MapleStage, domains: MapleDomains) => plainWithDefault("QE_BUCKET_CACHE_READ_CONCURRENCY", "6"), plainWithDefault("EDGE_CACHE_READ_TIMEOUT_MS", "40"), // MAPLE_ENDPOINT / MAPLE_ENVIRONMENT / COMMIT_SHA / MAPLE_INGEST_KEY. - selfObservabilityEnv(stage), + selfObservabilityEnv(stage, region), // Svix signing secrets for the public webhook receivers (`/webhooks/clerk`, // `/webhooks/autumn`); each route answers 503 until its secret is set. optionalSecret("CLERK_WEBHOOK_SECRET"), diff --git a/apps/api/src/resources/replay-blobs.ts b/apps/api/src/resources/replay-blobs.ts index 2a6cbf390..42b922d54 100644 --- a/apps/api/src/resources/replay-blobs.ts +++ b/apps/api/src/resources/replay-blobs.ts @@ -16,15 +16,21 @@ * Don't add `locationHint`: it is advisory (the bucket stayed `wnam` anyway) * and changing it replaces a name-pinned bucket, which GC then deletes. Took * prd red on 2026-08-24. Colocation needs a new bucket, not a replace. + * + * The EU instance's bucket is created in the `eu` jurisdiction — a hard + * storage pin, unlike a location hint — and, like jurisdiction itself, that is + * fixed at creation. It is a different bucket (`maple-replay-blobs-eu`), so + * nothing here ever replaces the US one. */ -import { stageProps } from "@maple/infra/cloudflare" +import { resolveStorageJurisdiction, stageProps } from "@maple/infra/cloudflare" import * as Cloudflare from "alchemy/Cloudflare" import * as RemovalPolicy from "alchemy/RemovalPolicy" export const ReplayBlobs = Cloudflare.R2.Bucket( "replay-blobs", - stageProps("replay-blobs", (name) => ({ + stageProps("replay-blobs", (name, { region }) => ({ name, + jurisdiction: resolveStorageJurisdiction(region), // Deliberately unprefixed, so the rule covers whatever key scheme is // current. `replay_object_key` is versioned (`v1/…`) precisely so a // format change can write under a new prefix while the old one ages diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 4ce60a5c3..9fea4268a 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -14,13 +14,14 @@ * from those yields. */ import { - CLOUDFLARE_WORKER_PLACEMENT, emailBinding, MapleStack, AiWorker, SandboxWorker, + type MapleRegion, type MapleStage, resolveWorkerName, + resolveWorkerPlacement, } from "@maple/infra/cloudflare" import { isolateContext } from "@maple/infra/worker-http" import { WorkerTelemetry } from "@maple/infra/worker-telemetry" @@ -41,7 +42,7 @@ import ClickHouseSchemaApplyWorkflow from "./workflows/ClickHouseSchemaApplyWork * is bound by stage — alchemy's capabilities have no "on some stages" form — * or read by name by code the Worker does not own (the LLM shim's `AI`). */ -const makeWorkerBindings = ({ stage }: { stage: MapleStage }) => ({ +const makeWorkerBindings = ({ stage, region }: { stage: MapleStage; region: MapleRegion }) => ({ // Workers AI (`env.AI`) behind an AI Gateway, driving the AI-triage agent. // NOTE: the deploy token needs the account-level "AI Gateway: Edit" permission // for this resource. Deployed stages only: the gateway has no local emulation, @@ -55,7 +56,7 @@ const makeWorkerBindings = ({ stage }: { stage: MapleStage }) => ({ // that needs no such ordering. ChatSession: Cloudflare.DurableObject("ChatSession", { className: "ChatSession", - scriptName: resolveWorkerName("ai", stage), + scriptName: resolveWorkerName("ai", stage, region), }), }) @@ -67,7 +68,7 @@ const makeWorkerBindings = ({ stage }: { stage: MapleStage }) => ({ */ const props = Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } - const { stage, domains, workerDev, devEnv } = yield* MapleStack + const { stage, region, domains, workerDev, devEnv } = yield* MapleStack // The agents' repository sandbox, reached only over this binding. Absent on // the stages that do not deploy it, where `SandboxClient` reports the tools // as unavailable rather than failing. @@ -77,12 +78,12 @@ const props = Effect.gen(function* () { const ai = yield* AiWorker // Resolved before any resource is created, so a misconfigured deploy fails // with the full list of missing vars rather than part-way through applying. - const configuredEnv = yield* apiConfiguredEnv(stage, domains) + const configuredEnv = yield* apiConfiguredEnv(stage, region, domains) return { main: import.meta.url, - name: resolveWorkerName("api", stage), + name: resolveWorkerName("api", stage, region), compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, - placement: CLOUDFLARE_WORKER_PLACEMENT, + placement: resolveWorkerPlacement(region), // Under `bun dev`: a sticky port the app's route follows. dev: workerDev("api"), workersDev: true, @@ -104,7 +105,7 @@ const props = Effect.gen(function* () { domain: domains.api, // `devEnv` last, so `.env.local` cannot override the inter-app URLs. env: { - ...makeWorkerBindings({ stage }), + ...makeWorkerBindings({ stage, region }), ...(Option.isSome(sandbox) ? { SANDBOX: sandbox.value } : undefined), AI_WORKER: ai, ...configuredEnv, diff --git a/apps/electric-sync/src/worker.ts b/apps/electric-sync/src/worker.ts index 003a55932..0412289f2 100644 --- a/apps/electric-sync/src/worker.ts +++ b/apps/electric-sync/src/worker.ts @@ -11,10 +11,11 @@ */ import { cachedRecoverable, - CLOUDFLARE_WORKER_PLACEMENT, MapleStack, + type MapleRegion, type MapleStage, resolveWorkerName, + resolveWorkerPlacement, } from "@maple/infra/cloudflare" import { authEnv, merge, optionalPlain, optionalSecret, selfObservabilityEnv } from "@maple/infra/env" import { WorkerTelemetry } from "@maple/infra/worker-telemetry" @@ -22,7 +23,7 @@ import * as Cloudflare from "alchemy/Cloudflare" import { Effect, Layer, Scope } from "effect" import { FetchHttpClient, HttpRouter } from "effect/unstable/http" -const configuredEnv = (stage: MapleStage) => +const configuredEnv = (stage: MapleStage, region: MapleRegion) => merge( // Auth (same AuthEnv subset the api worker sets; no DB). authEnv, @@ -43,7 +44,7 @@ const configuredEnv = (stage: MapleStage) => optionalSecret("ELECTRIC_SECRET"), ]), // Self-observability (OTLP export through the ingest gateway). - selfObservabilityEnv(stage), + selfObservabilityEnv(stage, region), ) /** @@ -54,12 +55,12 @@ const configuredEnv = (stage: MapleStage) => */ const props = Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } - const { stage, domains, workerDev } = yield* MapleStack + const { stage, region, domains, workerDev } = yield* MapleStack return { main: import.meta.url, - name: resolveWorkerName("electric-sync", stage), + name: resolveWorkerName("electric-sync", stage, region), compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, - placement: CLOUDFLARE_WORKER_PLACEMENT, + placement: resolveWorkerPlacement(region), // Under `bun dev`: a sticky port the app's route follows. dev: workerDev("electric-sync"), workersDev: true, @@ -67,7 +68,7 @@ const props = Effect.gen(function* () { // pr-stage hostnames would be authoritative NXDOMAIN. Custom domains // provision DNS + edge certs automatically. domain: domains.sync, - env: yield* configuredEnv(stage), + env: yield* configuredEnv(stage, region), } }) diff --git a/apps/ingest/alchemy.run.ts b/apps/ingest/alchemy.run.ts index 00b1c9d7f..523e2f37e 100644 --- a/apps/ingest/alchemy.run.ts +++ b/apps/ingest/alchemy.run.ts @@ -25,7 +25,11 @@ import { import { ReplayBlobs } from "../api/src/resources/replay-blobs.ts" import { issueCertificateViaCloudflare } from "@maple/infra/acm" import type { MapleDomains, MapleStage } from "@maple/infra/cloudflare" -import { resolveDeploymentEnvironment, resolveWorkerName } from "@maple/infra/cloudflare" +import { + resolveDeploymentEnvironment, + resolveStorageJurisdiction, + resolveWorkerName, +} from "@maple/infra/cloudflare" // Only the primitives. The grouped helpers in that module return Worker-binding // shapes (Redacted secrets inline); these values feed ECS `env:` and Secrets // Manager ARNs instead, so the gateway composes them itself. @@ -111,12 +115,15 @@ const deriveSecretAccessKey = (value: Output.Output>) * (`stageEnablesReplayBlobs`) — the bucket stays bound on the api side either * way, so anything already written keeps playing back. */ -const replayBlobWriterCredentials = (stage: MapleStage) => +const replayBlobWriterCredentials = (stage: MapleStage, region: MapleRegion) => Effect.gen(function* () { if (!stageEnablesReplayBlobs(stage)) return undefined // Yielded so the token is ordered behind the bucket. yield* ReplayBlobs - const bucketName = resolveWorkerName("replay-blobs", stage) + const bucketName = resolveWorkerName("replay-blobs", stage, region) + // A jurisdictional bucket lives under its own S3 endpoint and its own + // token resource segment; `default` is the non-jurisdictional US bucket. + const jurisdiction = resolveStorageJurisdiction(region) ?? "default" // Plan-time: it keys the policy map and the endpoint, neither of which // can take a lazy value. @@ -133,15 +140,18 @@ const replayBlobWriterCredentials = (stage: MapleStage) => permissionGroups: ["Workers R2 Storage Bucket Item Write"], // `__`, `default` = non-jurisdictional. resources: { - [`com.cloudflare.edge.r2.bucket.${accountId}_default_${bucketName}`]: "*", + [`com.cloudflare.edge.r2.bucket.${accountId}_${jurisdiction}_${bucketName}`]: "*", }, }, ], }) return { - /** Account-scoped S3 endpoint. A plan-time string — the account id is env-supplied. */ - endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + /** Account-scoped S3 endpoint, jurisdiction-qualified for a pinned bucket. A plan-time string — the account id is env-supplied. */ + endpoint: + jurisdiction === "default" + ? `https://${accountId}.r2.cloudflarestorage.com` + : `https://${accountId}.${jurisdiction}.r2.cloudflarestorage.com`, bucket: bucketName, /** The API token's id. Only known after the token exists, hence an Output. */ accessKeyId: Output.asOutput(token.tokenId), @@ -177,7 +187,7 @@ const replayBlobWriterCredentials = (stage: MapleStage) => */ export const createMapleIngest = ({ stage, domains, region }: CreateMapleIngestOptions) => Effect.gen(function* () { - const replayBlobs = yield* replayBlobWriterCredentials(stage) + const replayBlobs = yield* replayBlobWriterCredentials(stage, region) const taskSize = resolveIngestTaskSize(stage) const scaling = resolveIngestScaling(stage) const name = (base: string) => resolveAwsResourceName(base, stage, region) diff --git a/apps/landing/src/worker.ts b/apps/landing/src/worker.ts index 50ed6d934..7026afd26 100644 --- a/apps/landing/src/worker.ts +++ b/apps/landing/src/worker.ts @@ -8,9 +8,9 @@ */ import { assetWorkerObservability, - CLOUDFLARE_WORKER_PLACEMENT, MapleStack, resolveWorkerName, + resolveWorkerPlacement, WorkersObservabilityDestinations, } from "@maple/infra/cloudflare" import { plainWithDefault } from "@maple/infra/env" @@ -29,7 +29,7 @@ import { type AssetsBinding, handleRequest } from "./handler" */ const props = Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } - const { stage, domains, urls } = yield* MapleStack + const { stage, region, domains, urls } = yield* MapleStack const destinations = yield* WorkersObservabilityDestinations // Astro static build (memoized on the app's source files, skipped on destroy). const build = yield* Command.Build("landing-build", { @@ -48,7 +48,7 @@ const props = Effect.gen(function* () { }) return { main: import.meta.url, - name: resolveWorkerName("landing", stage), + name: resolveWorkerName("landing", stage, region), // The `assets` prop auto-adds the ASSETS binding the handler reads. assets: { directory: build.outdir, @@ -63,7 +63,7 @@ const props = Effect.gen(function* () { runWorkerFirst: ["/*", "!/_astro/*", "!/*.*"], }, compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, - placement: CLOUDFLARE_WORKER_PLACEMENT, + placement: resolveWorkerPlacement(region), observability: assetWorkerObservability(destinations), workersDev: true, domain: domains.landing, diff --git a/apps/local-ui/src/worker.ts b/apps/local-ui/src/worker.ts index 703417b91..70815a605 100644 --- a/apps/local-ui/src/worker.ts +++ b/apps/local-ui/src/worker.ts @@ -13,9 +13,9 @@ */ import { assetWorkerObservability, - CLOUDFLARE_WORKER_PLACEMENT, MapleStack, resolveWorkerName, + resolveWorkerPlacement, WorkersObservabilityDestinations, } from "@maple/infra/cloudflare" import * as Cloudflare from "alchemy/Cloudflare" @@ -36,7 +36,7 @@ interface AssetsBinding { */ const props = Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } - const { stage, domains } = yield* MapleStack + const { stage, region, domains } = yield* MapleStack const destinations = yield* WorkersObservabilityDestinations // A plain `vite build` to a flat `dist/`, the same tree the binary embeds. const build = yield* Command.Build("local-ui-build", { @@ -46,9 +46,9 @@ const props = Effect.gen(function* () { }) return { main: import.meta.url, - name: resolveWorkerName("local-ui", stage), + name: resolveWorkerName("local-ui", stage, region), assets: { directory: build.outdir, hash: Output.map(build.hash, (h) => h.output ?? "") }, - placement: CLOUDFLARE_WORKER_PLACEMENT, + placement: resolveWorkerPlacement(region), observability: assetWorkerObservability(destinations), workersDev: true, domain: domains.local, diff --git a/apps/sandbox/alchemy.run.ts b/apps/sandbox/alchemy.run.ts index 233e2a635..1a507d309 100644 --- a/apps/sandbox/alchemy.run.ts +++ b/apps/sandbox/alchemy.run.ts @@ -9,11 +9,11 @@ * container-backed in the script metadata, and provisions the application. */ import { - CLOUDFLARE_WORKER_PLACEMENT, MapleStack, WorkersObservabilityDestinations, assetWorkerObservability, resolveWorkerName, + resolveWorkerPlacement, } from "@maple/infra/cloudflare" import { requireSecretEntry } from "@maple/infra/env" import * as Cloudflare from "alchemy/Cloudflare" @@ -29,7 +29,7 @@ const SANDBOX_IMAGE = "docker.io/cloudflare/sandbox:0.12.9" const props = Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return { main: `${import.meta.dirname}/src/worker.ts` } - const { stage } = yield* MapleStack + const { stage, region } = yield* MapleStack const production = stage.kind === "prd" // This Worker carries no OTel SDK — it is a plain module so its `Sandbox` // class export survives — so platform logs are the only way anything it @@ -38,9 +38,11 @@ const props = Effect.gen(function* () { const destinations = yield* WorkersObservabilityDestinations return { main: `${import.meta.dirname}/src/worker.ts`, - name: resolveWorkerName("sandbox", stage), + name: resolveWorkerName("sandbox", stage, region), compatibility: { date: "2026-04-08", flags: ["nodejs_compat"] }, - placement: CLOUDFLARE_WORKER_PLACEMENT, + // The container has no jurisdiction setting of its own, so the clone sits + // under the same best-effort placement as the Workers. + placement: resolveWorkerPlacement(region), // Reached only over the api's service binding: no route, no hostname. workersDev: false, observability: assetWorkerObservability(destinations), diff --git a/apps/web/src/worker.ts b/apps/web/src/worker.ts index efb9c87ae..3b6c9970f 100644 --- a/apps/web/src/worker.ts +++ b/apps/web/src/worker.ts @@ -6,7 +6,7 @@ * SPA shell fallback — which stays a plain function so its tests need no * Worker runtime. */ -import { ApiWorker, CLOUDFLARE_WORKER_PLACEMENT, MapleStack, resolveWorkerName } from "@maple/infra/cloudflare" +import { ApiWorker, MapleStack, resolveWorkerName, resolveWorkerPlacement } from "@maple/infra/cloudflare" import { plainFrom } from "@maple/infra/env" import * as Cloudflare from "alchemy/Cloudflare" import * as Command from "alchemy/Command" @@ -23,7 +23,7 @@ import { handleRequest } from "./handler" */ const props = Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return { main: import.meta.url } - const { stage, domains, urls } = yield* MapleStack + const { stage, region, domains, urls } = yield* MapleStack const api = yield* ApiWorker // The build runs through `Command.Build` so the VITE_* env is part of the // memo hash: a stage's URLs (or the commit) changing re-runs it with no @@ -36,16 +36,25 @@ const props = Effect.gen(function* () { VITE_API_BASE_URL: urls.api, VITE_INGEST_URL: urls.ingest, VITE_ELECTRIC_SYNC_URL: urls.electricSync, - VITE_MAPLE_AUTH_MODE: yield* plainFrom(["VITE_MAPLE_AUTH_MODE", "MAPLE_AUTH_MODE"], "self_hosted"), - VITE_CLERK_PUBLISHABLE_KEY: yield* plainFrom(["VITE_CLERK_PUBLISHABLE_KEY", "CLERK_PUBLISHABLE_KEY"], ""), - VITE_MAPLE_INGEST_KEY: yield* plainFrom(["VITE_MAPLE_INGEST_KEY", "MAPLE_OTEL_PUBLIC_INGEST_KEY"], ""), + VITE_MAPLE_AUTH_MODE: yield* plainFrom( + ["VITE_MAPLE_AUTH_MODE", "MAPLE_AUTH_MODE"], + "self_hosted", + ), + VITE_CLERK_PUBLISHABLE_KEY: yield* plainFrom( + ["VITE_CLERK_PUBLISHABLE_KEY", "CLERK_PUBLISHABLE_KEY"], + "", + ), + VITE_MAPLE_INGEST_KEY: yield* plainFrom( + ["VITE_MAPLE_INGEST_KEY", "MAPLE_OTEL_PUBLIC_INGEST_KEY"], + "", + ), // Stamped onto browser telemetry as `vcs.ref.head.revision` / `service.version`. VITE_COMMIT_SHA: yield* plainFrom(["VITE_COMMIT_SHA", "COMMIT_SHA", "GITHUB_SHA"], ""), }, }) return { main: import.meta.url, - name: resolveWorkerName("web", stage), + name: resolveWorkerName("web", stage, region), assets: { directory: build.outdir, hash: Output.map(build.hash, (h) => h.output ?? ""), @@ -54,7 +63,7 @@ const props = Effect.gen(function* () { // trailing-slash normalization 307s that to "/" on every hard reload. notFoundHandling: "single-page-application" as const, }, - placement: CLOUDFLARE_WORKER_PLACEMENT, + placement: resolveWorkerPlacement(region), workersDev: true, domain: domains.web, // The share-preview lookups ride the service binding; the URL is still diff --git a/docs/eu-region-plan.md b/docs/eu-region-plan.md new file mode 100644 index 000000000..3f74c9e21 --- /dev/null +++ b/docs/eu-region-plan.md @@ -0,0 +1,190 @@ +# EU region: plan + +Goal: a customer whose contract says their data never leaves the EU can run on Maple. That is a +full EU instance under `*.eu.maple.dev`: EU ingest, EU Tinybird, EU Postgres, and every Worker +that touches customer data executing in the EU. An org's region is the instance it was created on. + +This replaced an earlier draft that shared the control plane and routed per org. That shape kept +issue metadata and Worker execution in the US, which is fine under a DPA that says so, and is not +fine for a customer who requires all processing in the EU. The full instance is also less code: +there is no per-org routing anywhere, because each instance knows exactly one region. + +## Where we start (2026-09-16) + +- Prod reads and writes go to the `maple_us` Tinybird workspace in AWS us-east-1. The old + `api.tinybird.co` workspace is GCP Frankfurt and is being wound down. It is not the EU target. +- The ingest gateway (`apps/ingest`) runs on ECS Fargate in us-east-1. Region already exists as a + deploy-time axis: `MapleRegion` in `packages/infra/src/aws/stage.ts` maps `eu` to eu-central-1 + and its own CIDR, resource names take a region suffix with `us` unsuffixed, and the root + `alchemy.run.ts` reads `MAPLE_REGION` and guards it against `AWS_REGION`. +- The Cloudflare half of the stack does not honour the region: `resolveWorkerName` and + `resolveMapleDomains` in `packages/infra/src/cloudflare/stage.ts` know only stage, and + `CLOUDFLARE_WORKER_PLACEMENT` is a constant `aws:us-east-1`. +- Every Worker binds one `MAPLE_DB` Hyperdrive, one Tinybird host, one replay bucket. There is no + per-org region anywhere, and after this plan there still is none. +- The web app is a SPA. Its Worker serves assets; the browser calls the API directly. The only + server-side data paths in the web Worker are OG images and share-link previews. + +## What "never leaves the EU" touches + +| System | Today | EU instance | +| --- | --- | --- | +| Ingest gateway + OTel collector | ECS us-east-1 | ECS eu-central-1 | +| Tinybird | `maple_us`, us-east-1 | `maple_eu`, AWS eu-central-1 | +| Postgres | PlanetScale, US | PlanetScale, eu-central-1, own Hyperdrive config | +| Electric | ECS us-east-1 | ECS eu-central-1, in the EU ingest VPC | +| api / ai / alerting / electric-sync / web Workers | placement us-east-1 | placement eu-central-1 (best effort, see risks) | +| `ChatSession` Durable Object | no jurisdiction | `jurisdiction: "eu"` | +| Replay blobs | R2, non-jurisdictional | R2, `jurisdiction: "eu"` | +| Queues, Workflows | no jurisdiction control | see risks | +| Clerk | one US instance | same instance, `app.eu.maple.dev` as a satellite domain | +| AI features | OpenRouter, Workers AI | off | +| Maple self-telemetry | US internal org | EU internal org, in `maple_eu` | +| Repository sandbox (`apps/sandbox`) | prd Worker, US | per instance, EU Worker | +| Landing, billing, GitHub app | shared | shared, no customer data | + +The landing site stays one site. Billing metadata and the GitHub app installation are not +customer telemetry. The sandbox clones the customer's repository, so it deploys per instance from +day one: `stageDeploysSandbox` already gates it to prd, and the EU deploy is prd with +`MAPLE_REGION=eu`, so the only work is the region-suffixed name. Cloudflare's Sandbox container +has no jurisdiction setting, so it sits under the same best-effort placement as the Workers. + +## Why `app.eu.maple.dev` and not a shared app + +A shared `app.maple.dev` calling a regional API is possible, since the web Worker holds no +customer data. It buys one login URL for a customer with orgs in both regions. It costs runtime +region lookups for the API, sync and ingest URLs, an exception for the OG and share paths, webhook +filtering on both instances, and a compliance story with an asterisk. With `app.eu.maple.dev` the +region is the hostname, the application code has no region logic, and a routing bug cannot leak +across regions because there is no routing. + +## Phase 0. Accounts (no code) + +1. **Tinybird**: `maple_eu` on `https://api.eu-central-1.aws.tinybird.co`. AWS eu-central-1, not + GCP Frankfurt: same-region egress is $0.01/GB against $0.09/GB, and export egress is the ingest + bill. Deploy the schema with the local recipe used for `maple_us`. +2. **Three Tinybird tokens, three roles**: workspace admin token as `TINYBIRD_SIGNING_KEY`, a scoped + runtime read token as `TINYBIRD_TOKEN`, an append-only token for the gateway. Sign a throwaway + JWT to prove the signing key before trusting it (the 2026-09-04 incident). +3. **PlanetScale**: a new database in eu-central-1, migrations applied by the same manual prod + procedure, PSBouncer for the gateway's key reads. A dashboard-managed Hyperdrive config per + Worker, bound by id like prd. +4. **AWS**: eu-central-1 in the existing account. ACM certificates for `ingest.eu.maple.dev` and + `electric.eu.maple.dev`. +5. **Cloudflare**: `replay-blobs-eu` with `jurisdiction: "eu"`. Regional Services would pin + execution to EU data centres but is an Enterprise add-on and is out of reach for now; the EU + Workers rely on placement, which is best effort. See risks for what that means and for the + non-Enterprise upgrade path. +6. **Infisical**: a second environment, `prod-eu`, holding the same variable names with EU values. + Same names is the point: the code reads one set, the deploy picks the environment. +7. **Clerk**: one instance. Add `app.eu.maple.dev` as a satellite domain of the production + instance. Staff names and emails stay in the US; the DPA lists Clerk as a US subprocessor for + account data only. + +## Phase 1. The stack honours the region on Cloudflare (built) + +Built on the `worktree-eu-region` branch; `docs/infra.md` § Regions is the reference. + +- The region rides on the alchemy stage string: `prd` is US, `prd-eu` is the EU instance. + `parseMapleDeployment` in `packages/infra/src/cloudflare/stage.ts` is the one parser, and + because alchemy keys its state by stage the two instances can never plan against each + other's resources. `MAPLE_REGION` as a deploy env var is gone. +- `resolveWorkerName(base, stage, region)` and `resolveMapleDomains(stage, region)`; `us` stays + unsuffixed so nothing in prod renames. EU prd domains: `app`, `api`, `ingest`, `sync`, + `electric` under `eu.maple.dev`; no landing or local-ui (`regionHostsSharedApps`). +- `resolveWorkerPlacement(region)` replaces the placement constant. +- `resolveStorageJurisdiction(region)` pins the EU replay bucket to the `eu` jurisdiction at + creation (a new bucket, never a replace) and the ingest gateway's writer token and S3 endpoint + follow it. The chat Durable Object's jurisdiction is a property of the object id, so it is + applied where ids are minted: `chatSessionStub` reads the stack-derived `MAPLE_REGION`. +- `MapleStack` carries `region`; every Worker module reads it from there. `appUrlsEnv` defaults + to the deploy's own hostnames, so EU emails and share links point at the EU app. +- `resolveHyperdriveRefId(stage, consumer, region)` throws for `eu` until the EU configs exist, + rather than binding nothing and 500ing every DB-backed route. +- `MAPLE_INTERNAL_ORG_ID` comes from the environment already, so the EU value is an org created + on the EU instance. + +## Phase 2. Deploy (built, opt-in) + +- `deploy-prd-instance.yml` is the per-instance body; `deploy-prd.yml` calls it for `us` on + every green CI run and for `eu` only while the `MAPLE_DEPLOY_EU` repository variable is `1`. + One `region` input picks the stage, the `production` / `production-eu` GitHub environment, + the `prod` / `prod-eu` Infisical environment and the AWS region; the composite action took an + `aws-region` input for that, and the stack still refuses an `AWS_REGION` that disagrees. +- Still to do before flipping `MAPLE_DEPLOY_EU`: the `production-eu` GitHub environment, the + `prod-eu` Infisical environment, and the Hyperdrive config ids in `resolveHyperdriveRefId`. +- Tinybird schema deploys target `maple_us` and `maple_eu` (and GCP Frankfurt while it lives). + `tinybird-cd.yml` is disabled, so this joins the manual checklist. +- The EU ingest fleet and Electric come out of the existing factories unchanged. + +## Phase 3. Sign-up and the wrong door + +- Landing sign-up gets a region choice, "United States" or "European Union", and sends the user + to the matching app hostname. It is the only place a user meets the concept. +- On the wrong app, a signed-in user with no org sees one line naming the other region's URL, + rather than the create-org flow. Cheap, and it removes the most likely support ticket. +- Settings shows the region read-only. Orgs do not move between instances in v1; a move is a + Tinybird copy plus an R2 copy plus a Postgres export, and is its own project. +- Guided setup, credentials, SDK snippets and the install modal already print `ingestUrl` from + build-time env, which the EU build sets to `ingest.eu.maple.dev`. No change. +- Docs and the CLI: the EU endpoint is documented; the CLI already accepts an endpoint override. + +## Phase 4. AI features off on the EU instance + +Investigations, chat, the MCP agent tools and AI triage send spans and logs to model providers, +and Workers AI has no region pin. The EU instance ships with all of them off. That is a stack-level +switch, not a per-org one: the AI Worker still deploys (the api forwards `/mcp` and chat to it and +the investigation fan-out reaches it), but its model seam in `apps/ai/src/platform/Llm.ts` has no +provider configured, every LLM-backed surface returns a clear "not available in this region" +failure, and the web hides the entry points behind a build-time flag. Turning them on later is an +EU-hosted provider endpoint in the `prod-eu` environment plus the flag. + +## Phase 5. Operations + +- The "Prod revision skew" alert and its lockstep list cover both instances. +- Token rotation runbook: two instances, each with Worker secret bindings plus an ECS secret. +- A weekly comparison of `GET /v0/datasources` across the two workspaces catches a missed deploy; + the local-schema gate only catches datasource edits. +- The EU instance reports its own telemetry to its own internal org, so operating it means + looking in two places. A US-side read-only view of EU operational metrics would cross the + boundary with Maple's data, not the customer's; acceptable, but decide it explicitly. + +## Risks + +- **Queues and Workflows have no jurisdiction setting.** The four queues and the Workflows carry + customer payloads. Get Cloudflare's written statement on where they store data under Regional + Services, or keep customer content out of them on the EU instance and pass ids instead. +- **Placement is best effort, and that is all we have without Enterprise.** Cloudflare may run + a script outside eu-central-1 when the pinned location is unhealthy, and Regional Services, the + contractual guarantee, is not available on the current plan. The DPA has to say so: storage is + guaranteed EU (Tinybird, Postgres, R2 and the Durable Object are hard-pinned), execution is EU + by placement. If a customer needs a hard execution guarantee, the non-Enterprise path is to + host the request path inside a Durable Object with `jurisdiction: "eu"`, which is a hard + guarantee on every plan: the Worker's fetch handler does nothing but forward to the DO, and the + Effect HTTP graph runs inside it. The class-form Workers already host DOs, so this is a + contained change to the api and ai bridges, not a rewrite. Regional Services can also be bought + later with no code change. +- **Clerk holds staff names and emails in the US.** Decided: one instance. The DPA lists Clerk as + a US subprocessor for account data (name, email, org membership), never telemetry. +- **Alchemy state collision.** Two deploys of the same stage into one state store will plan + against each other's resources. Settle the state key before the first EU deploy. +- **Two of everything drifts.** Same migrations, same Tinybird schema, same secrets by name. The + `prod-eu` environment with identical variable names is what keeps drift visible as a diff. + +## Decisions + +Taken 2026-09-16: + +1. Clerk: one instance, `app.eu.maple.dev` as a satellite domain. +2. Regional Services: not available; EU Workers run on best-effort placement, with the DO-hosted + request path as the upgrade if a customer requires a hard execution guarantee. +3. AI features off on `eu` at launch. + +4. Sandbox deploys per instance from day one. + +## Order and size + +Phase 0 is account work, about two days including the Clerk satellite-domain setup. +Phase 1 is a week and deploys nothing new until `MAPLE_REGION=eu` is set. Phase 2 is three days +plus the first EU deploy, which will take two passes for the certificates. Phase 3 is three days +and is the only user-visible step. Phase 4 is two days: the provider-less AI Worker mode and the web flag. diff --git a/docs/infra.md b/docs/infra.md index dc1c6fffd..41e5fc963 100644 --- a/docs/infra.md +++ b/docs/infra.md @@ -57,8 +57,11 @@ Two things a future change here needs to know: `apps/api/src/resources/replay-blobs.ts`. - `packages/infra` — stage/region/domain/naming logic, the shared deploy-time env groups, and the few resources several Worker modules bind. - - `cloudflare/stage.ts` — `MapleStage`, domains, worker names, Hyperdrive resolution. - Pure functions, unit-tested, no cloud calls. + - `region.ts` — `MapleRegion` (`us` | `eu`), the one axis both clouds key on; see + "Regions" below. + - `cloudflare/stage.ts` — `MapleStage`, `parseMapleDeployment` (stage + region off the + alchemy stage string), domains, worker names, placement, storage jurisdiction, + Hyperdrive resolution. Pure functions, unit-tested, no cloud calls. - `cloudflare/stack.ts` — `MapleStack`, what the root stack tells the Worker classes. - `cloudflare/observability.ts` — the Workers Observability destinations, declared once and yielded from every module that binds them (alchemy registers a resource by id; a @@ -85,6 +88,41 @@ the first, and keeps the failure in the typed error channel. `packages/alchemy-m `MapleEnvironment` is the same pattern inside a provider; the runtime worker env schemas use `@maple/infra/config-helpers`, which `env.ts` builds on. +## Regions: one stack, one instance per deploy + +A geographic instance is the whole stack — every Worker, the ingest fleet, Electric, the +replay bucket, the chat Durable Object — deployed against that instance's own Tinybird +workspace, application database and secrets. There is no per-org routing anywhere: an org's +region is the instance it was created on, and an EU hostname cannot reach a US resource +because the EU Workers are bound to none. Plan and rationale: `docs/eu-region-plan.md`. + +- **The alchemy stage string carries the region**: `prd` is the US instance, `prd-eu` the EU + one (`dev_makisuo-eu` a dev stage of it; PR previews are US-only). Alchemy keys its state + by stage, so the two instances never plan against each other's resources, and nothing has + to set a second variable in lockstep. `parseMapleDeployment` is the one parser; + `MapleStack` carries `region` to every Worker module. +- **`us` is unsuffixed** everywhere — Worker names, AWS names, hostnames — so adding `eu` + renamed nothing: `maple-api` / `maple-api-eu`, `app.maple.dev` / `app.eu.maple.dev`, + `maple-ingest` / `maple-ingest-eu`. `regionSuffix` in `region.ts` is the single rule. +- **Placement is a hint, jurisdiction is a pin.** `resolveWorkerPlacement` steers each + instance's Workers beside its own database (us-east-1 / eu-central-1), best effort. + `resolveStorageJurisdiction` puts the EU instance's R2 bucket and its Durable Objects in + Cloudflare's `eu` jurisdiction, which is a hard storage guarantee on every plan. The DO + jurisdiction is a property of the object id, so it is applied where ids are minted + (`chatSessionStub`, reading the stack-derived `MAPLE_REGION`), not on the binding. + Regional Services, the contractual execution guarantee, is an Enterprise add-on the + account does not carry; the residency claim says so. +- **Shared apps stay on `us`**: the marketing site and the local-mode SPA hold no customer + data and there is one `maple.dev`, so `regionHostsSharedApps` keeps them off the EU + deploy. +- **Secrets** come from a per-instance Infisical environment (`prod`, `prod-eu`) holding the + same variable names with that instance's values; `deploy-prd-instance.yml` picks the + environment, the stage and the AWS region from one `region` input, and the stack refuses + an `AWS_REGION` that disagrees with the stage. The EU deploy is opt-in through the + `MAPLE_DEPLOY_EU` repository variable until its Hyperdrive configs exist — + `resolveHyperdriveRefId` throws for `eu` rather than binding nothing. +- **AI features are off on the EU instance**: the model providers have no EU pin. + ## Local dev: one `alchemy dev` stack `bun dev` (`scripts/dev.ts`) runs the whole local stack as a single `alchemy dev`: diff --git a/package.json b/package.json index 862b0c177..ff253e195 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "lint:fix": "oxlint -c .oxlintrc.effect.json --fix alchemy.run.ts scripts apps packages lib examples", "alchemy:build-deps": "turbo build --filter=@maple-dev/effect-sdk --filter=@maple-dev/alchemy --filter=@maple-dev/browser", "alchemy:deploy": "bun run alchemy:build-deps && alchemy deploy --yes", - "alchemy:deploy:prd": "bun run alchemy:build-deps && alchemy deploy --yes --adopt --stage prd", + "alchemy:deploy:prd": "bun run alchemy:build-deps && alchemy deploy --yes --adopt --stage ${MAPLE_STAGE:-prd}", "alchemy:deploy:pr": "bun run alchemy:build-deps && alchemy deploy --yes --adopt --stage pr-${PR_NUMBER}", "alchemy:destroy": "alchemy destroy --yes", "alchemy:destroy:pr": "alchemy destroy --yes --stage pr-${PR_NUMBER}", diff --git a/packages/backend/src/http/api-cors.ts b/packages/backend/src/http/api-cors.ts index b21b9910b..d750c54af 100644 --- a/packages/backend/src/http/api-cors.ts +++ b/packages/backend/src/http/api-cors.ts @@ -14,7 +14,7 @@ export const API_CORS_OPTIONS = { // Every browser call carries an Authorization header, so every one is // preflighted. Without this the response has no Access-Control-Max-Age and // Chrome falls back to a 5s preflight cache — and since the worker is pinned - // to us-east-1 (CLOUDFLARE_WORKER_PLACEMENT), each expiry costs a full extra + // to one region (resolveWorkerPlacement), each expiry costs a full extra // ~165ms round trip from Europe BEFORE the real request is sent. Those // preflights are invisible in our own traces because the tracer is disabled // for OPTIONS. Browsers clamp this value themselves (Chrome 2h, Firefox 24h). diff --git a/packages/domain/src/chat-session-stub.ts b/packages/domain/src/chat-session-stub.ts index 77c777d09..ce139e90c 100644 --- a/packages/domain/src/chat-session-stub.ts +++ b/packages/domain/src/chat-session-stub.ts @@ -47,6 +47,8 @@ export interface ChatSessionStub { export interface ChatSessionNamespace { readonly idFromName: (name: string) => unknown readonly get: (id: unknown) => ChatSessionStub + /** A namespace restricted to one jurisdiction; ids minted through it are stored only there. */ + readonly jurisdiction?: (jurisdiction: "eu") => ChatSessionNamespace } export const isChatSessionNamespace = (value: unknown): value is ChatSessionNamespace => @@ -55,12 +57,23 @@ export const isChatSessionNamespace = (value: unknown): value is ChatSessionName typeof (value as { get?: unknown }).get === "function" && typeof (value as { idFromName?: unknown }).idFromName === "function" -/** Resolve the `ChatSession` binding (the Durable Object's alchemy name) off a worker env record, or `undefined` if it is missing. */ +/** + * Resolve the `ChatSession` binding (the Durable Object's alchemy name) off a worker env record, + * or `undefined` if it is missing. + * + * On the EU instance (`MAPLE_REGION=eu`, a value the stack derives and the environment cannot + * override) the object is addressed through the namespace's `eu` jurisdiction, so the session's + * transcript — spans, logs and the agent's reasoning over them — is stored only in EU data + * centres. Jurisdiction is a property of the id, so it has to be applied here, where ids are + * minted, and not on the binding. + */ export const chatSessionStub = ( env: Record, sessionId: string, ): ChatSessionStub | undefined => { - const namespace = env.ChatSession - if (!isChatSessionNamespace(namespace)) return undefined + const bound = env.ChatSession + if (!isChatSessionNamespace(bound)) return undefined + const namespace = + env.MAPLE_REGION === "eu" && bound.jurisdiction !== undefined ? bound.jurisdiction("eu") : bound return namespace.get(namespace.idFromName(sessionId)) } diff --git a/packages/domain/src/chat-session.test.ts b/packages/domain/src/chat-session.test.ts index b32887164..42d4ee129 100644 --- a/packages/domain/src/chat-session.test.ts +++ b/packages/domain/src/chat-session.test.ts @@ -18,6 +18,38 @@ import { ChatTurnRetryEvent, type ChatEventInput, } from "./chat-session" +import { type ChatSessionNamespace, type ChatSessionStub, chatSessionStub } from "./chat-session-stub" + +describe("chatSessionStub", () => { + const stub = {} as ChatSessionStub + const namespace = (label: string, seen: string[]): ChatSessionNamespace => ({ + idFromName: (name) => `${label}:${name}`, + get: (id) => { + seen.push(String(id)) + return stub + }, + jurisdiction: (jurisdiction) => namespace(`${label}/${jurisdiction}`, seen), + }) + + it("addresses the object through the eu jurisdiction on the EU instance", () => { + const seen: string[] = [] + expect(chatSessionStub({ ChatSession: namespace("ns", seen), MAPLE_REGION: "eu" }, "org_a:t")).toBe( + stub, + ) + expect(seen).toEqual(["ns/eu:org_a:t"]) + }) + + it("leaves the us instance, and an env with no region, on the plain namespace", () => { + const seen: string[] = [] + chatSessionStub({ ChatSession: namespace("ns", seen), MAPLE_REGION: "us" }, "org_a:t") + chatSessionStub({ ChatSession: namespace("ns", seen) }, "org_a:t") + expect(seen).toEqual(["ns:org_a:t", "ns:org_a:t"]) + }) + + it("is undefined without the binding", () => { + expect(chatSessionStub({ MAPLE_REGION: "eu" }, "org_a:t")).toBeUndefined() + }) +}) describe("chat session ids", () => { it("round-trips org and tab", () => { diff --git a/packages/infra/src/aws/stage.ts b/packages/infra/src/aws/stage.ts index 04ca889bf..5859747a6 100644 --- a/packages/infra/src/aws/stage.ts +++ b/packages/infra/src/aws/stage.ts @@ -1,19 +1,10 @@ import type { RegionName } from "@distilled.cloud/aws/Region" import type { MapleStage } from "../cloudflare/stage.ts" +import { DEFAULT_MAPLE_REGION, type MapleRegion, regionSuffix } from "../region.ts" -/** - * Geographic instance a deployment belongs to. - * - * Orthogonal to `MapleStage`: stage is prd/pr/dev, region is which - * geographic instance. A full EU instance is `region: "eu"` at every stage, - * with its OWN Tinybird workspace, application database, and ingest fleet — - * telemetry that lands in `eu` must never transit `us`, which is the whole - * point of having one. - * - * `us` is deliberately the unsuffixed default so adding `eu` later renames - * nothing (a rename destroys and recreates every resource). - */ -export type MapleRegion = "us" | "eu" +// The region itself lives in `../region.ts`, shared with the Cloudflare half; +// re-exported here so existing `@maple/infra/aws` imports keep resolving. +export * from "../region.ts" /** * The AWS regions Maple deploys into, as the literal union the AWS client @@ -22,19 +13,6 @@ export type MapleRegion = "us" | "eu" */ export type AwsRegionName = Extract -export const DEFAULT_MAPLE_REGION: MapleRegion = "us" - -export function parseMapleRegion(value: string | undefined): MapleRegion { - const normalized = value?.trim().toLowerCase() - if (!normalized) { - return DEFAULT_MAPLE_REGION - } - if (normalized === "us" || normalized === "eu") { - return normalized - } - throw new Error(`Unsupported Maple region "${value}". Expected "us" or "eu".`) -} - /** * AWS region backing each Maple region. * @@ -83,7 +61,7 @@ export function resolveAwsResourceName( stage: MapleStage, region: MapleRegion = DEFAULT_MAPLE_REGION, ): string { - const suffix = region === DEFAULT_MAPLE_REGION ? "" : `-${region}` + const suffix = regionSuffix(region) switch (stage.kind) { case "prd": return `maple-${base}${suffix}` diff --git a/packages/infra/src/cloudflare/index.ts b/packages/infra/src/cloudflare/index.ts index d5161e213..562536f38 100644 --- a/packages/infra/src/cloudflare/index.ts +++ b/packages/infra/src/cloudflare/index.ts @@ -4,3 +4,4 @@ export * from "./maple-db.ts" export * from "./observability.ts" export * from "./stack.ts" export * from "./stage.ts" +export * from "../region.ts" diff --git a/packages/infra/src/cloudflare/maple-db.ts b/packages/infra/src/cloudflare/maple-db.ts index 13e2b0e5a..b72b3d48a 100644 --- a/packages/infra/src/cloudflare/maple-db.ts +++ b/packages/infra/src/cloudflare/maple-db.ts @@ -24,7 +24,7 @@ import * as Schema from "effect/Schema" import { requiredPlain } from "../env.ts" import { type MapleDbConsumer, - parseMapleStage, + parseMapleDeployment, resolveDatabaseMode, resolveHyperdriveRefId, resolveWorkerName, @@ -45,11 +45,11 @@ export const MAPLE_DB_BINDING = "MAPLE_DB" export const ManagedMapleDb = Cloudflare.Hyperdrive.Connection( MAPLE_DB_BINDING, Effect.gen(function* () { - const stage = parseMapleStage(yield* Stage) + const { stage, region } = parseMapleDeployment(yield* Stage) // A dev stage without its database URL cannot be planned: a defect, not a branch. const pgUrl = new URL(yield* Effect.orDie(requiredPlain("MAPLE_PG_URL"))) const props: Cloudflare.Hyperdrive.Props = { - name: resolveWorkerName("db", stage), + name: resolveWorkerName("db", stage, region), origin: { scheme: "postgres", host: pgUrl.hostname, @@ -89,14 +89,14 @@ export const ManagedMapleDb = Cloudflare.Hyperdrive.Connection( export const MapleDb = (consumer: MapleDbConsumer) => Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return - const stage = parseMapleStage(yield* Stage) + const { stage, region } = parseMapleDeployment(yield* Stage) switch (resolveDatabaseMode(stage)) { case "managed": { yield* Cloudflare.Hyperdrive.Connect(ManagedMapleDb) return } case "ref": { - const id = resolveHyperdriveRefId(stage, consumer) + const id = resolveHyperdriveRefId(stage, consumer, region) if (id === undefined) return const host = yield* Cloudflare.Worker yield* host.bind(MAPLE_DB_BINDING, { diff --git a/packages/infra/src/cloudflare/stack.ts b/packages/infra/src/cloudflare/stack.ts index 408475d60..3853d479f 100644 --- a/packages/infra/src/cloudflare/stack.ts +++ b/packages/infra/src/cloudflare/stack.ts @@ -4,7 +4,14 @@ import * as Effect from "effect/Effect" import type { WorkerDev } from "@maple/alchemy-portless" import type { DevApp } from "../dev-urls.ts" import { Stage } from "alchemy/Stage" -import { type MapleDomains, type MapleStage, parseMapleStage, resolveWorkerName } from "./stage.ts" +import type { MapleRegion } from "../region.ts" +import { + type MapleDeployment, + type MapleDomains, + type MapleStage, + parseMapleDeployment, + resolveWorkerName, +} from "./stage.ts" /** * Public origins of the apps the others point at, as plan-time strings: @@ -19,6 +26,8 @@ export interface MapleUrls { export interface MapleStackContext { readonly stage: MapleStage + /** The instance this deploy belongs to; `us` unless the stage string says `-eu`. */ + readonly region: MapleRegion readonly domains: MapleDomains readonly urls: MapleUrls /** A Worker's `dev` block under `bun dev` (served, or left `external`); undefined on a deploy. */ @@ -66,8 +75,9 @@ export class AiWorker extends Context.Service()("@m /** * Props for a resource declared at module scope whose physical name is - * stage-derived (`resolveWorkerName(base, stage)`): `make` receives that name - * and returns the props. Reads alchemy's own `Stage` — one of the platform + * stage-derived (`resolveWorkerName(base, stage, region)`): `make` receives + * that name, and the deployment for anything else region-bound (a bucket's + * jurisdiction), and returns the props. Reads alchemy's own `Stage` — one of the platform * services a Worker's init may require, unlike `MapleStack` — so the * declaration can be yielded from the init as well as from the props. Alchemy * evaluates a resource's props Effect wherever the resource is yielded — the @@ -77,12 +87,12 @@ export class AiWorker extends Context.Service()("@m */ export const stageProps = ( base: string, - make: (name: string) => Props, + make: (name: string, deployment: MapleDeployment) => Props, ): Effect.Effect, never, Stage> => Effect.gen(function* () { if (globalThis.__ALCHEMY_RUNTIME__) return {} - const stage = parseMapleStage(yield* Stage) - return make(resolveWorkerName(base, stage)) + const deployment = parseMapleDeployment(yield* Stage) + return make(resolveWorkerName(base, deployment.stage, deployment.region), deployment) }) /** {@link stageProps} for the common case: a resource whose only stage-derived prop is `name`. */ diff --git a/packages/infra/src/cloudflare/stage.test.ts b/packages/infra/src/cloudflare/stage.test.ts index b0b25b818..14bcc74a6 100644 --- a/packages/infra/src/cloudflare/stage.test.ts +++ b/packages/infra/src/cloudflare/stage.test.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from "vitest" -import { parseMapleStage, resolveDatabaseMode, resolveHyperdriveRefId, stageDeploysSandbox } from "./stage.ts" +import { + formatMapleDeployment, + parseMapleDeployment, + parseMapleStage, + regionHostsSharedApps, + resolveDatabaseMode, + resolveHyperdriveRefId, + resolveMapleDomains, + resolveStorageJurisdiction, + resolveWorkerName, + resolveWorkerPlacement, + stageDeploysSandbox, +} from "./stage.ts" const stage = (name: string) => parseMapleStage(name) @@ -24,6 +36,85 @@ describe("parseMapleStage", () => { }) }) +describe("parseMapleDeployment", () => { + it("reads the region off the stage string, defaulting to us", () => { + expect(parseMapleDeployment("prd")).toEqual({ stage: { kind: "prd" }, region: "us" }) + expect(parseMapleDeployment("prd-eu")).toEqual({ stage: { kind: "prd" }, region: "eu" }) + expect(parseMapleDeployment(" PRD-EU ")).toEqual({ stage: { kind: "prd" }, region: "eu" }) + expect(parseMapleDeployment("dev_makisuo-eu")).toEqual({ + stage: { kind: "dev", name: "dev-makisuo" }, + region: "eu", + }) + }) + + it("keeps PR previews on the us instance", () => { + expect(parseMapleDeployment("pr-12")).toEqual({ stage: { kind: "pr", prNumber: 12 }, region: "us" }) + expect(() => parseMapleDeployment("pr-12-eu")).toThrow(/PR previews deploy to the us instance/) + }) + + it("round-trips through formatMapleDeployment, which is what the deploy summary prints", () => { + for (const raw of ["prd", "prd-eu", "pr-12", "dev-makisuo", "dev-makisuo-eu"]) { + expect(formatMapleDeployment(parseMapleDeployment(raw))).toBe(raw) + } + }) + + it("still rejects the removed stg stage under either region", () => { + expect(() => parseMapleDeployment("stg-eu")).toThrow(/"stg" stage was removed/) + }) +}) + +describe("resolveWorkerName", () => { + it("leaves us unsuffixed so the existing prd Workers keep their names", () => { + expect(resolveWorkerName("api", stage("prd"))).toBe("maple-api") + expect(resolveWorkerName("api", stage("prd"), "us")).toBe("maple-api") + expect(resolveWorkerName("api", stage("pr-12"), "us")).toBe("maple-api-pr-12") + }) + + it("suffixes eu right after the base, mirroring the AWS names", () => { + expect(resolveWorkerName("api", stage("prd"), "eu")).toBe("maple-api-eu") + expect(resolveWorkerName("db", stage("dev_makisuo"), "eu")).toBe("maple-db-eu-dev-dev-makisuo") + }) +}) + +describe("resolveMapleDomains", () => { + it("gives the EU instance its own hostnames under eu.maple.dev, and no shared apps", () => { + const eu = resolveMapleDomains(stage("prd"), "eu") + expect(eu).toEqual({ + web: "app.eu.maple.dev", + api: "api.eu.maple.dev", + ingest: "ingest.eu.maple.dev", + sync: "sync.eu.maple.dev", + electric: "electric.eu.maple.dev", + }) + expect(eu.landing).toBeUndefined() + expect(eu.local).toBeUndefined() + expect(regionHostsSharedApps("eu")).toBe(false) + expect(regionHostsSharedApps("us")).toBe(true) + }) + + it("keeps the us production hostnames exactly as they were", () => { + expect(resolveMapleDomains(stage("prd"))).toEqual(resolveMapleDomains(stage("prd"), "us")) + expect(resolveMapleDomains(stage("prd")).web).toBe("app.maple.dev") + }) + + it("has no eu hostnames for a PR preview", () => { + expect(() => resolveMapleDomains(stage("pr-12"), "eu")).toThrow(/PR previews have no eu hostnames/) + }) +}) + +describe("region-bound Cloudflare settings", () => { + it("steers each instance's Workers beside its own database and warehouse", () => { + expect(resolveWorkerPlacement("us")).toEqual({ region: "aws:us-east-1" }) + expect(resolveWorkerPlacement("eu")).toEqual({ region: "aws:eu-central-1" }) + expect(resolveWorkerPlacement()).toEqual({ region: "aws:us-east-1" }) + }) + + it("pins EU storage to the eu jurisdiction and leaves us non-jurisdictional", () => { + expect(resolveStorageJurisdiction("eu")).toBe("eu") + expect(resolveStorageJurisdiction("us")).toBeUndefined() + }) +}) + describe("stageDeploysSandbox", () => { it("runs the agents' repository sandbox on prd, the only stage with a database", () => { expect(stageDeploysSandbox(stage("prd"))).toBe(true) @@ -51,4 +142,13 @@ describe("resolveHyperdriveRefId", () => { expect(resolveHyperdriveRefId(stage("pr-123"), "api")).toBeUndefined() expect(resolveHyperdriveRefId(stage("dev_makisuo"), "api")).toBeUndefined() }) + + it("refuses to deploy the EU instance without its own configs, rather than binding nothing", () => { + // `undefined` means "no database" and is what a PR preview gets; an EU prd + // that silently took that path would 500 every DB-backed route. + expect(() => resolveHyperdriveRefId({ kind: "prd" }, "api", "eu")).toThrow( + /No Hyperdrive config for the EU instance/, + ) + expect(resolveHyperdriveRefId(stage("dev_makisuo"), "api", "eu")).toBeUndefined() + }) }) diff --git a/packages/infra/src/cloudflare/stage.ts b/packages/infra/src/cloudflare/stage.ts index b897c3035..4e96a8ea5 100644 --- a/packages/infra/src/cloudflare/stage.ts +++ b/packages/infra/src/cloudflare/stage.ts @@ -1,5 +1,16 @@ +import { DEFAULT_MAPLE_REGION, isMapleRegion, type MapleRegion, regionSuffix } from "../region.ts" + export type MapleStage = { kind: "prd" } | { kind: "pr"; prNumber: number } | { kind: "dev"; name: string } +/** What one `alchemy deploy` is: a stage of one geographic instance. */ +export interface MapleDeployment { + readonly stage: MapleStage + readonly region: MapleRegion +} + +/** The alchemy stage suffix that selects the EU instance; absent means `us`. */ +const REGION_STAGE_SUFFIX_RE = /-(eu)$/ + const PR_STAGE_RE = /^pr-(\d+)$/ /** Names of the removed staging stage, in the spellings someone would actually type. */ const REMOVED_STAGE_NAMES = new Set(["stg", "stage", "staging"]) @@ -23,7 +34,49 @@ export interface MapleDomains { local?: string } -export const CLOUDFLARE_WORKER_PLACEMENT = { region: "aws:us-east-1" } as const +/** + * Where a Worker's requests are steered to run. A placement hint is best + * effort — Cloudflare may run the script elsewhere when the pinned location is + * unhealthy — so it is not, on its own, a residency guarantee; the storage + * behind an instance (Tinybird, Postgres, R2 and the Durable Objects, see + * {@link resolveStorageJurisdiction}) is what is hard-pinned. The contractual + * execution guarantee, Regional Services, is an Enterprise add-on the account + * does not carry. `us` pins to us-east-1 so the Workers sit beside the + * production database and the Tinybird workspace; `eu` to eu-central-1 for the + * same reason on the EU instance. + */ +export function resolveWorkerPlacement(region: MapleRegion = DEFAULT_MAPLE_REGION): { + readonly region: "aws:us-east-1" | "aws:eu-central-1" +} { + switch (region) { + case "us": + return { region: "aws:us-east-1" } + case "eu": + return { region: "aws:eu-central-1" } + } +} + +/** + * The R2 / Durable Object jurisdiction for an instance's storage, or + * `undefined` for the non-jurisdictional default. Unlike placement this IS a + * hard guarantee on every Cloudflare plan: a jurisdictional bucket or object + * is stored and served only from data centres in that jurisdiction. + * Jurisdiction is fixed at creation — an existing bucket cannot move — which is + * why the EU instance gets new resources rather than relocated ones. + */ +export function resolveStorageJurisdiction(region: MapleRegion): "eu" | undefined { + return region === "eu" ? "eu" : undefined +} + +/** + * Whether an instance hosts the apps that are shared across regions and hold + * no customer data: the marketing site and the local-mode dashboard SPA. One + * `maple.dev` exists, so only the `us` instance deploys them; the EU instance + * deploys the product Workers alone. + */ +export function regionHostsSharedApps(region: MapleRegion): boolean { + return region === DEFAULT_MAPLE_REGION +} const PRD_DOMAINS: MapleDomains = { web: "app.maple.dev", @@ -35,6 +88,20 @@ const PRD_DOMAINS: MapleDomains = { local: "local.maple.dev", } +/** + * The EU instance's production hostnames, all under `eu.maple.dev` so the + * region is the hostname: no application code routes on it, and a request to + * an EU hostname cannot reach a US resource because the EU Workers are bound + * to none. No landing or local-ui — see {@link regionHostsSharedApps}. + */ +const PRD_DOMAINS_EU: MapleDomains = { + web: "app.eu.maple.dev", + api: "api.eu.maple.dev", + ingest: "ingest.eu.maple.dev", + sync: "sync.eu.maple.dev", + electric: "electric.eu.maple.dev", +} + export function parseMapleStage(stage: string): MapleStage { const normalized = stage.trim().toLowerCase() @@ -74,6 +141,37 @@ export function parseMapleStage(stage: string): MapleStage { ) } +/** + * The alchemy stage string names both the stage and the instance: `prd`, + * `prd-eu`, `pr-12`, `dev_makisuo`, `dev_makisuo-eu`. The region rides on the + * stage rather than on an env var because alchemy keys its state store by + * stage — `prd` and `prd-eu` are therefore two independent stacks that can + * never plan against each other's resources, and nothing has to remember to + * set a second variable in lockstep. A `-eu` suffix always means the region: + * a dev stage cannot be named `*-eu` and mean the US. + * + * PR previews are US-only (`pr-12-eu` is rejected): a preview has no database + * and reviews code, not residency, and a second preview fleet per PR is real + * money for nothing. + */ +export function parseMapleDeployment(raw: string): MapleDeployment { + const normalized = raw.trim().toLowerCase() + const match = normalized.match(REGION_STAGE_SUFFIX_RE) + const suffix = match?.[1] + const region: MapleRegion = suffix !== undefined && isMapleRegion(suffix) ? suffix : DEFAULT_MAPLE_REGION + const stage = parseMapleStage(match ? normalized.slice(0, -match[0].length) : normalized) + if (stage.kind === "pr" && region !== DEFAULT_MAPLE_REGION) { + throw new Error( + `PR previews deploy to the ${DEFAULT_MAPLE_REGION} instance only; "${raw}" asks for "${region}".`, + ) + } + return { stage, region } +} + +export function formatMapleDeployment({ stage, region }: MapleDeployment): string { + return `${formatMapleStage(stage)}${regionSuffix(region)}` +} + export function formatMapleStage(stage: MapleStage): string { switch (stage.kind) { case "prd": @@ -96,11 +194,17 @@ export function resolveDeploymentEnvironment(stage: MapleStage): string { } } -export function resolveMapleDomains(stage: MapleStage): MapleDomains { +export function resolveMapleDomains( + stage: MapleStage, + region: MapleRegion = DEFAULT_MAPLE_REGION, +): MapleDomains { switch (stage.kind) { case "prd": - return PRD_DOMAINS + return region === "eu" ? PRD_DOMAINS_EU : PRD_DOMAINS case "pr": + if (region !== DEFAULT_MAPLE_REGION) { + throw new Error(`PR previews have no ${region} hostnames; see parseMapleDeployment.`) + } // Give PR previews stable, secret-free URLs. The default workers.dev URL // embeds the Cloudflare account subdomain, which Infisical masks as a // secret — GitHub then refuses to set the environment URL. Custom domains @@ -174,9 +278,23 @@ export type MapleDbConsumer = "api" | "ai" | "alerting" * Hyperdrive from MAPLE_PG_URL or no database — `resolveDatabaseMode` decides. * Config IDs are not secrets. */ -export function resolveHyperdriveRefId(stage: MapleStage, consumer: MapleDbConsumer): string | undefined { +export function resolveHyperdriveRefId( + stage: MapleStage, + consumer: MapleDbConsumer, + region: MapleRegion = DEFAULT_MAPLE_REGION, +): string | undefined { switch (stage.kind) { case "prd": + if (region === "eu") { + // Deliberately a defect and not `undefined`: undefined means "no + // database" (a PR preview), and an EU instance that silently deployed + // with no `MAPLE_DB` would 500 every DB-backed route in production. + // Create the configs against the EU PlanetScale database (one per + // consumer, like prd's) and put their ids here. + throw new Error( + `No Hyperdrive config for the EU instance yet (consumer "${consumer}"). Create maple-prd-eu / maple-alerting-prd-eu in the dashboard and add the ids to resolveHyperdriveRefId.`, + ) + } // Both target the PlanetScale `main` branch; their `origin_connection_limit`s // SUM against its `max_connections`. // TODO(ai-worker): `ai` shares `maple-prd` until a dedicated @@ -193,13 +311,24 @@ export function resolveHyperdriveRefId(stage: MapleStage, consumer: MapleDbConsu } } -export function resolveWorkerName(base: string, stage: MapleStage): string { +/** + * Physical Worker (and bucket, and Hyperdrive) name. The region suffix sits + * right after the base, mirroring `resolveAwsResourceName`, so `maple-api`, + * `maple-api-eu`, `maple-api-eu-dev-makisuo` read the same in both consoles. + * `us` carries no suffix — see `MapleRegion`. + */ +export function resolveWorkerName( + base: string, + stage: MapleStage, + region: MapleRegion = DEFAULT_MAPLE_REGION, +): string { + const suffix = regionSuffix(region) switch (stage.kind) { case "prd": - return `maple-${base}` + return `maple-${base}${suffix}` case "pr": - return `maple-${base}-pr-${stage.prNumber}` + return `maple-${base}${suffix}-pr-${stage.prNumber}` case "dev": - return `maple-${base}-dev-${stage.name}` + return `maple-${base}${suffix}-dev-${stage.name}` } } diff --git a/packages/infra/src/env.test.ts b/packages/infra/src/env.test.ts index 88e17a42a..5b4ca60b0 100644 --- a/packages/infra/src/env.test.ts +++ b/packages/infra/src/env.test.ts @@ -124,9 +124,32 @@ describe("primitives", () => { }) }) +describe("appUrlsEnv", () => { + it("defaults the public URLs to the deploy's own hostnames, so the EU instance links to itself", () => { + const eu = run(appUrlsEnv({ web: "app.eu.maple.dev", ingest: "ingest.eu.maple.dev" }), {}) + expect(eu.MAPLE_APP_BASE_URL).toBe("https://app.eu.maple.dev") + expect(eu.MAPLE_INGEST_PUBLIC_URL).toBe("https://ingest.eu.maple.dev") + // A dev stage has no hostnames and falls back to production's. + expect(run(appUrlsEnv({}), {}).MAPLE_APP_BASE_URL).toBe("https://app.maple.dev") + }) + + it("still lets the environment override a default", () => { + const env = { MAPLE_APP_BASE_URL: "https://app.example.test" } + expect(run(appUrlsEnv({ web: "app.eu.maple.dev" }), env).MAPLE_APP_BASE_URL).toBe( + "https://app.example.test", + ) + }) +}) + describe("selfObservabilityEnv", () => { const base = { MAPLE_OTEL_INGEST_KEY: "maple_ak_test" } + it("derives MAPLE_REGION from the deploy and refuses a provider override", () => { + const env = { ...base, MAPLE_REGION: "eu" } + expect(run(selfObservabilityEnv({ kind: "prd" }), env).MAPLE_REGION).toBe("us") + expect(run(selfObservabilityEnv({ kind: "prd" }, "eu"), env).MAPLE_REGION).toBe("eu") + }) + it("derives MAPLE_ENVIRONMENT from the stage and refuses a provider override", () => { const env = { ...base, MAPLE_ENVIRONMENT: "production" } expect(run(selfObservabilityEnv({ kind: "pr", prNumber: 42 }), env).MAPLE_ENVIRONMENT).toBe("pr-42") @@ -282,7 +305,7 @@ describe("parity with the pre-refactor per-worker expressions", () => { MAPLE_APP_BASE_URL: env.MAPLE_APP_BASE_URL?.trim() || "https://app.maple.dev", EMAIL_FROM: env.EMAIL_FROM?.trim() || "Maple ", } - expect(unwrap(run(appUrlsEnv, env))).toEqual(unwrap(old)) + expect(unwrap(run(appUrlsEnv(), env))).toEqual(unwrap(old)) }) it("selfObservabilityEnv", () => { @@ -294,6 +317,7 @@ describe("parity with the pre-refactor per-worker expressions", () => { MAPLE_INGEST_KEY: Redacted.make(oldRequireEnv(env, "MAPLE_OTEL_INGEST_KEY")), ...oldOptionalPlain(env, "MAPLE_ENDPOINT"), MAPLE_ENVIRONMENT: "production", + MAPLE_REGION: "us", ...oldOptionalPlain(env, "COMMIT_SHA", env.GITHUB_SHA?.trim()), } expect(unwrap(run(selfObservabilityEnv({ kind: "prd" }), env))).toEqual(unwrap(old)) diff --git a/packages/infra/src/env.ts b/packages/infra/src/env.ts index cb9920d82..507529026 100644 --- a/packages/infra/src/env.ts +++ b/packages/infra/src/env.ts @@ -3,8 +3,9 @@ import * as Option from "effect/Option" import * as Redacted from "effect/Redacted" import * as Schema from "effect/Schema" import { optionalString } from "./config-helpers.ts" -import type { MapleStage } from "./cloudflare/stage.ts" +import type { MapleDomains, MapleStage } from "./cloudflare/stage.ts" import { resolveDeploymentEnvironment } from "./cloudflare/stage.ts" +import { DEFAULT_MAPLE_REGION, type MapleRegion } from "./region.ts" /** * Deploy-time environment for the Cloudflare workers. @@ -173,12 +174,17 @@ export const ingestKeyCryptoEnv: Config.Config = merge( requireSecretEntry("MAPLE_INGEST_KEY_LOOKUP_HMAC_KEY"), ) -/** Public URLs the workers build links with (emails, share links, quick-start snippets). */ -export const appUrlsEnv: Config.Config = merge( - plainWithDefault("MAPLE_INGEST_PUBLIC_URL", "https://ingest.maple.dev"), - plainWithDefault("MAPLE_APP_BASE_URL", "https://app.maple.dev"), - plainWithDefault("EMAIL_FROM", "Maple "), -) +/** + * Public URLs the workers build links with (emails, share links, quick-start + * snippets). Defaults follow the deploy's own hostnames, so the EU instance + * links to itself; a dev stage has none and falls back to production's. + */ +export const appUrlsEnv = (domains: MapleDomains = {}): Config.Config => + merge( + plainWithDefault("MAPLE_INGEST_PUBLIC_URL", `https://${domains.ingest ?? "ingest.maple.dev"}`), + plainWithDefault("MAPLE_APP_BASE_URL", `https://${domains.web ?? "app.maple.dev"}`), + plainWithDefault("EMAIL_FROM", "Maple "), + ) /** * The worker's own OTLP export, through the ingest gateway. @@ -190,7 +196,10 @@ export const appUrlsEnv: Config.Config = merge( * missing binding quietly restores the behaviour where any occurrence reopens a * fixed issue. */ -export const selfObservabilityEnv = (stage: MapleStage): Config.Config => +export const selfObservabilityEnv = ( + stage: MapleStage, + region: MapleRegion = DEFAULT_MAPLE_REGION, +): Config.Config => merge( // Bound under a different name than it is read from. Optional on dev stages // only: no developer has a real ingest key, and absent means self-observability off. @@ -207,6 +216,10 @@ export const selfObservabilityEnv = (stage: MapleStage): Config.Config = ["us", "eu"] + +export const DEFAULT_MAPLE_REGION: MapleRegion = "us" + +export function isMapleRegion(value: string): value is MapleRegion { + return value === "us" || value === "eu" +} + +export function parseMapleRegion(value: string | undefined): MapleRegion { + const normalized = value?.trim().toLowerCase() + if (!normalized) { + return DEFAULT_MAPLE_REGION + } + if (isMapleRegion(normalized)) { + return normalized + } + throw new Error(`Unsupported Maple region "${value}". Expected "us" or "eu".`) +} + +/** + * The suffix a region contributes to a physical name — Worker, bucket, ECS + * service, Cloud Map namespace. Empty for `us`, see {@link MapleRegion}. + */ +export function regionSuffix(region: MapleRegion): string { + return region === DEFAULT_MAPLE_REGION ? "" : `-${region}` +}