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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/api/src/routes/discovery.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ describe("DiscoveryRouter", () => {
const doc = await response.json()
expect(doc.openapi).toMatch(/^3\.1\./)
expect(doc.info.title).toBe("Maple API")
expect(doc.servers).toEqual([{ url: "https://api.maple.dev", description: "Production" }])
expect(doc.servers).toEqual([{ url: API_ORIGIN, description: "Production" }])
expect(doc.externalDocs.url).toBe(`${API_ORIGIN}/v2/docs`)
expect(doc.info.contact.email).toBe("support@maple.dev")
expect(Object.keys(doc.paths).length).toBeGreaterThan(20)
const operations = Object.values(doc.paths).flatMap((item) =>
Object.values(item as Record<string, { operationId?: string; description?: string }>),
Expand Down
37 changes: 28 additions & 9 deletions apps/api/src/routes/discovery.http.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MapleApiV2, v2RouteNotFoundBody } from "@maple/domain/http/v2"
import { mapleMcpServerManifest } from "@maple/domain/mcp-manifest"
import { Effect } from "effect"
import { Context, Effect, identity, Option } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
import { Env } from "@maple/backend/platform/Env"
Expand Down Expand Up @@ -38,11 +38,30 @@ const untrustedRequestOrigin = (request: HttpServerRequest.HttpServerRequest) =>
return host ? `${proto}://${host}` : ""
}

const baseSpecTransform = Option.getOrElse(
Context.getOption(MapleApiV2.annotations, OpenApi.Transform),
() => identity,
)

/**
* The v2 API as this instance publishes it: `servers` and `externalDocs` name the configured
* origin, so the EU instance's document and `/v2/docs` point at `api.eu.maple.dev`, not the US.
*/
export const instanceApiV2 = (origin: string) =>
MapleApiV2.annotate(OpenApi.Servers, [{ url: origin, description: "Production" }]).annotate(
OpenApi.Transform,
(spec) => ({
...baseSpecTransform(spec),
externalDocs: { url: `${origin}/v2/docs`, description: "Interactive Maple API reference" },
}),
)

let openApiDocument: string | undefined
const openApiJson = Effect.sync(() => {
openApiDocument ??= JSON.stringify(OpenApi.fromApi(MapleApiV2))
return openApiDocument
})
const openApiJson = (origin: string) =>
Effect.sync(() => {
openApiDocument ??= JSON.stringify(OpenApi.fromApi(instanceApiV2(origin)))
return openApiDocument
})

const apiIndex = (origin: string) => ({
name: "Maple API",
Expand All @@ -56,7 +75,10 @@ const apiIndex = (origin: string) => ({

export const DiscoveryRouter = HttpRouter.use((router) =>
Effect.gen(function* () {
const serveOpenApi = Effect.map(openApiJson, (body) =>
const env = yield* Env
const origin = env.MAPLE_API_BASE_URL.replace(/\/+$/, "")

const serveOpenApi = Effect.map(openApiJson(origin), (body) =>
HttpServerResponse.text(body, {
status: 200,
contentType: "application/json; charset=utf-8",
Expand All @@ -66,9 +88,6 @@ export const DiscoveryRouter = HttpRouter.use((router) =>
yield* router.add("GET", "/openapi.json", serveOpenApi)
yield* router.add("GET", "/v2/openapi.json", serveOpenApi)

const env = yield* Env
const origin = env.MAPLE_API_BASE_URL.replace(/\/+$/, "")

const manifest = HttpServerResponse.jsonUnsafe(mapleMcpServerManifest({ apiBaseUrl: origin }), {
headers: PUBLIC_CACHE,
})
Expand Down
15 changes: 10 additions & 5 deletions apps/api/src/runtime/http-graph.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MapleApi, MapleInternalApi } from "@maple/domain/http"
import { MapleApiV2 } from "@maple/domain/http/v2"
import { Layer } from "effect"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiScalar } from "effect/unstable/httpapi"
import { API_CORS_OPTIONS } from "@maple/backend/http/api-cors"
Expand All @@ -14,7 +14,7 @@ import { HttpBillingPublicLive } from "@/routes/v1/billing-public.http"
import { HttpV2SharePublicLive } from "@/routes/v2/share.http"
import { V1ErrorBoundaryLive } from "@maple/backend/http/error-boundary"
import { HttpDemoLive } from "@/routes/internal/demo.http"
import { DiscoveryRouter, NotFoundRouter } from "@/routes/discovery.http"
import { DiscoveryRouter, instanceApiV2, NotFoundRouter } from "@/routes/discovery.http"
import { HttpDigestLive } from "@/routes/internal/digest.http"
import { HttpErrorsLive } from "@/routes/v1/errors.http"
import { HttpIntegrationsLive, IntegrationsCallbackRouter } from "@/routes/v1/integrations.http"
Expand Down Expand Up @@ -96,9 +96,14 @@ const DocsRoute = HttpApiScalar.layerCdn(MapleApi, {
})

// Public v2 API reference (only v2 groups — the internal v1 surface stays on /docs).
const DocsV2Route = HttpApiScalar.layerCdn(MapleApiV2, {
path: "/v2/docs",
})
const DocsV2Route = Layer.unwrap(
Effect.gen(function* () {
const env = yield* Env
return HttpApiScalar.layerCdn(instanceApiV2(env.MAPLE_API_BASE_URL.replace(/\/+$/, "")), {
path: "/v2/docs",
})
}),
)

const ApiRoutes = HttpApiBuilder.layer(MapleApi).pipe(
Layer.provide(HttpAuthPublicLive),
Expand Down
51 changes: 45 additions & 6 deletions docs/eu-region-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,18 @@ there is no per-org routing anywhere, because each instance knows exactly one re
| 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
| Landing, billing | shared | shared, no customer data |
| GitHub App, Slack app | US apps | EU apps of their own, see Phase 0 step 8 |
| OAuth clients (Cloudflare, PlanetScale, Hazel) | US redirect URIs | same clients, EU redirect URIs added |
| Scraper (Railway) | US service | EU service of its own |

The landing site stays one site. Billing metadata is not customer telemetry. The GitHub App was
first listed here as shared, which does not work: an App has one webhook URL and one post-install
callback, both on `api.maple.dev`, so an EU install lands on the US API (whose database has no
connect session for it) and EU repositories' push and pull request events are delivered to the US.

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
Expand Down Expand Up @@ -88,6 +94,39 @@ across regions because there is no routing.
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.
8. **Integrations.** Every callback Maple builds comes from the API's own origin, so the EU
instance already asks providers to return to `api.eu.maple.dev`. What differs is what each
provider has registered, plus one service that only the US runs:
- **Per-request redirect URI** (the client sends it, the provider checks it against an
allowlist). The US client is reusable; add the EU URL to its allowlist:
- Cloudflare OAuth: `https://api.eu.maple.dev/api/integrations/cloudflare/callback`
- PlanetScale OAuth: `https://api.eu.maple.dev/api/integrations/planetscale/callback`
- Hazel OAuth: `https://api.eu.maple.dev/api/integrations/hazel/callback`
- **One URL per app** (registered on the app, not sent per request). These need an EU app:
- **GitHub App.** Register "Maple EU" per `docs/github-app-setup.md` with webhook
`https://api.eu.maple.dev/api/integrations/github/webhook` and callback
`https://api.eu.maple.dev/api/integrations/github/callback`, then replace all six
`GITHUB_APP_*` values in `prod-eu`. A repository can then be installed on both Apps, one
per instance, which is the point.
- **Slack app.** Redirect URLs alone would allow a shared app, but its Events URL goes to
the one `slack-agent`, which resolves workspaces against the US API and answers an EU
workspace with "not connected". An EU app with no event subscription (AI is off there)
keeps alert delivery and nothing else; redirect
`https://api.eu.maple.dev/oauth/slack/callback`, new `SLACK_CLIENT_ID`/`SECRET` in
`prod-eu`.
- **Discord** (not configured on `prod-eu` yet). The install is the bot invite, and a bot
token opens a Gateway session that receives every mention, so a shared application would
have both instances answer. Create an EU application, register
`https://api.eu.maple.dev/oauth/chat/discord/callback` on it, and set its
`MAPLE_DISCORD_CLIENT_ID`, `MAPLE_DISCORD_CLIENT_SECRET` and `MAPLE_DISCORD_BOT_TOKEN`
in `prod-eu` together.
- **Scraper.** `apps/scraper` is one Railway service polling the US API's target list, so EU
Prometheus and PlanetScale metrics targets are never scraped. Deploy a second service in an
EU Railway region with `MAPLE_API_URL=https://api.eu.maple.dev`,
`MAPLE_INGEST_URL=https://ingest.eu.maple.dev` and `prod-eu`'s `SD_INTERNAL_TOKEN`.

Until the EU GitHub and Slack apps exist, remove their keys from `prod-eu`: connecting then fails
up front with "not configured" instead of sending the user through a flow that cannot finish.

## Phase 1. The stack honours the region on Cloudflare (built)

Expand Down
2 changes: 2 additions & 0 deletions docs/github-app-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ Two URLs derive from it, and you will paste both into GitHub later. Note them no
| Webhook delivery | `https://YOUR_MAPLE_DOMAIN/api/integrations/github/webhook` |
| User authorization / post-install redirect ("Callback URL") | `https://YOUR_MAPLE_DOMAIN/api/integrations/github/callback` |

> **One App per Maple instance.** Both URLs are registered on the App itself rather than sent per request, so an App serves exactly one deployment. Maple Cloud's EU instance runs its own App pointed at `https://api.eu.maple.dev`; reusing the US App sends EU installs and webhooks to the US API.

> These paths are fixed in Maple's code. Do not change them — Maple receives webhooks at `/api/integrations/github/webhook` and completes the install flow at `/api/integrations/github/callback`.

---
Expand Down
6 changes: 5 additions & 1 deletion packages/chat-platform/src/connectors/discord/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,14 @@ Worker runs.
3. **Bot tab → Requires OAuth2 Code Grant: ON.** An install can then only complete through the code
exchange the install half performs.
4. **OAuth2 tab → Redirects.** Add `https://api.maple.dev/oauth/chat/discord/callback`, and the
equivalent origin for any other stage.
equivalent origin for any other stage that shares this application.
5. **Installation tab → Scopes `bot` + `applications.commands`, permissions "Send Messages", "Read
Message History", "Create Public Threads".**

**One application per instance.** Two instances opening a Gateway session with one bot token both
receive every mention and both answer, so the EU instance runs its own application: its redirect is
`https://api.eu.maple.dev/oauth/chat/discord/callback`, and all three secrets above come from it.

Nothing else is needed: the Gateway connection is outbound, so there is no public URL to register
and no request signature to verify.

Expand Down
Loading