From ca0c6b105b57764dd5249f7fc3fbfbac6b0fa646 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 4 Nov 2026 19:28:22 +0100 Subject: [PATCH 1/6] feat(chat-platform): one Hazel bot for every Maple region Hazel's bot gateway now holds one session per bot and region, and routes each install's events to the region that redeemed it. So Maple's EU instance no longer needs its own Hazel bot: - MAPLE_HAZEL_REGION (optional; `eu` on the EU instance) is sent with the gateway IDENTIFY/RESUME and with the install redemption. - Connector config keys can be marked `optional`: their absence leaves the connector configured. - README: one bot for every instance, and how to move EU onto it. Co-Authored-By: Claude Opus 5.5 --- apps/chat-bot/src/config.ts | 7 ++++- .../src/connectors/hazel/README.md | 27 +++++++++++------ .../chat-platform/src/connectors/hazel/api.ts | 10 +++++++ .../src/connectors/hazel/gateway.test.ts | 22 +++++++++++++- .../src/connectors/hazel/gateway.ts | 17 ++++++++--- .../src/connectors/hazel/install.test.ts | 17 ++++++++++- .../src/connectors/hazel/install.ts | 30 ++++++++++++++++--- packages/chat-platform/src/index.ts | 5 ++-- packages/chat-platform/src/ingress.ts | 5 ++++ 9 files changed, 118 insertions(+), 22 deletions(-) diff --git a/apps/chat-bot/src/config.ts b/apps/chat-bot/src/config.ts index 3be32ce059..1183b35dd7 100644 --- a/apps/chat-bot/src/config.ts +++ b/apps/chat-bot/src/config.ts @@ -60,9 +60,14 @@ export const resolveConnectorConfig = (connector: IngressConnector): Effect.Effe Effect.map((entries): ConnectorConfigResult => { const config = new Map() const missing: string[] = [] + const optional = new Set( + connector.ingress.requiredConfig + .filter((key) => key.optional === true) + .map((key) => key.name), + ) for (const [name, value] of entries) { if (Option.isSome(value)) config.set(name, value.value) - else missing.push(name) + else if (!optional.has(name)) missing.push(name) } return missing.length === 0 ? { _tag: "ready", config } : { _tag: "missing", names: missing } }), diff --git a/packages/chat-platform/src/connectors/hazel/README.md b/packages/chat-platform/src/connectors/hazel/README.md index 199b0950a4..5e6213116e 100644 --- a/packages/chat-platform/src/connectors/hazel/README.md +++ b/packages/chat-platform/src/connectors/hazel/README.md @@ -14,22 +14,31 @@ declared once, in `api.ts`. | ----------------------- | -------------------------------------------- | ------------------------------- | | `MAPLE_HAZEL_BOT_TOKEN` | The token shown once when the bot is created | ingress, outbound and install | | `MAPLE_HAZEL_BOT_ID` | The bot's id (not a secret) | install, as the link's `bot_id` | +| `MAPLE_HAZEL_REGION` | `eu` on the EU instance; unset on US | ingress and install (optional) | A half whose names are unset is skipped rather than fatal, as for every connector. 1. **Create the bot** with the scopes `messages:read`, `messages:write`, `channels:write` (opens threads) and `channels:read` (lists the channels an alert can go to), mark it **public**, and make it **mentionable**. - A bot created before alerts came through it lacks `channels:read`: add it on each instance's - bot (US and EU), or its channel list answers `auth` and no Hazel channel can be picked. + A bot created before alerts came through it lacks `channels:read`: add it, or its channel list + answers `auth` and no Hazel channel can be picked. 2. **Register the install redirect URIs** on the bot (Edit Application → Install redirect URIs): - `https://api.maple.dev/oauth/chat/hazel/callback`, plus the equivalent origin of any other - stage that shares this bot. Hazel compares them exactly. -3. **Put the two values on the deployment.** The token reaches `chat-bot` and `api`, the id reaches - `api`. - -**One bot per instance.** Hazel allows one gateway session per bot token, so the EU instance runs -its own bot, with its own redirect URI. + `https://api.maple.dev/oauth/chat/hazel/callback`, the EU instance's equivalent, plus the + origin of any other stage that shares this bot. Hazel compares them exactly. +3. **Put the values on the deployment.** The token reaches `chat-bot` and `api`, the id reaches + `api`, and the EU instance also sets `MAPLE_HAZEL_REGION=eu` on both. + +**One bot for every instance.** Hazel's gateway holds one session per bot and region. Each +instance identifies with its own region (none for US), and redeems installs with it, so a +workspace installed from the EU instance sends its events only to the EU session: EU data never +passes through the US instance, and neither depends on the other being up. In Hazel the bot is +the Maple integration (Settings → Integrations → Maple), not an entry in Installed Apps. + +Moving an instance onto the shared bot: set its token, id and region, then reconnect its Hazel +workspaces (Settings → Integrations → Hazel → Connect), which redeems each install with the region. +The instance's saved gateway session and offset belong to its old bot's log: clear them when +switching, so its first session on the shared bot identifies from new events (`now`). ## Trying it end to end diff --git a/packages/chat-platform/src/connectors/hazel/api.ts b/packages/chat-platform/src/connectors/hazel/api.ts index 236e84f00c..8d12991238 100644 --- a/packages/chat-platform/src/connectors/hazel/api.ts +++ b/packages/chat-platform/src/connectors/hazel/api.ts @@ -33,3 +33,13 @@ export const BOT_TOKEN_CONFIG = "MAPLE_HAZEL_BOT_TOKEN" /** The bot's id, the `bot_id` of its install link. Not a secret: it rides the URL the browser opens. */ export const BOT_ID_CONFIG = "MAPLE_HAZEL_BOT_ID" + +/** + * The Hazel gateway region this deployment serves (`eu` on Maple's EU instance), optional. Every + * Maple instance runs the same bot: the gateway holds one session per region, and each install's + * events go to the region that redeemed it. Unset is the default region. + */ +export const REGION_CONFIG = "MAPLE_HAZEL_REGION" + +/** The declaration every half that reads {@link REGION_CONFIG} lists. */ +export const REGION_CONFIG_KEY = { name: REGION_CONFIG, secret: false, optional: true } as const diff --git a/packages/chat-platform/src/connectors/hazel/gateway.test.ts b/packages/chat-platform/src/connectors/hazel/gateway.test.ts index 3db4bb2d23..487efb45b5 100644 --- a/packages/chat-platform/src/connectors/hazel/gateway.test.ts +++ b/packages/chat-platform/src/connectors/hazel/gateway.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest" import type { ConnectorConfig } from "../../ingress.ts" import { socketIngress } from "../../ingress.ts" -import { BOT_TOKEN_CONFIG, GATEWAY_URL } from "./api.ts" +import { BOT_TOKEN_CONFIG, GATEWAY_URL, REGION_CONFIG } from "./api.ts" import { gatewayProtocol, type GatewayState } from "./gateway.ts" import { SESSION_CONFLICT_REASON } from "./gateway-payloads.ts" import { Array as Arr } from "effect" @@ -69,6 +69,26 @@ describe("the handshake", () => { expect(gatewayProtocol.connectUrl(hello.state, config)).toBe(GATEWAY_URL) }) + it("identifies and resumes as its region's session when the deployment names one", () => { + const regional: ConnectorConfig = new Map([...config, [REGION_CONFIG, "eu"]]) + const fresh = gatewayProtocol.onFrame( + { awaitingAck: false }, + frame({ op: "HELLO", heartbeatIntervalMs: 25_000 }), + NOW, + regional, + ) + expect(fresh.send?.map(sent)).toEqual([ + { op: "IDENTIFY", botToken: "hzl_bot_token", resumeOffset: "now", region: "eu" }, + ]) + const resumed = gatewayProtocol.onFrame( + ready({ heartbeatIntervalMs: undefined }), + frame({ op: "HELLO", heartbeatIntervalMs: 25_000 }), + NOW, + regional, + ) + expect(resumed.send?.map(sent)).toMatchObject([{ op: "RESUME", region: "eu" }]) + }) + it("resumes a held session from the last acknowledged offset", () => { const hello = gatewayProtocol.onFrame( ready({ heartbeatIntervalMs: undefined }), diff --git a/packages/chat-platform/src/connectors/hazel/gateway.ts b/packages/chat-platform/src/connectors/hazel/gateway.ts index e584146d68..e434207fb4 100644 --- a/packages/chat-platform/src/connectors/hazel/gateway.ts +++ b/packages/chat-platform/src/connectors/hazel/gateway.ts @@ -17,7 +17,7 @@ */ import { Option, Schema } from "effect" import type { ConnectorConfig, SocketDirective, SocketIngressDefinition, SocketStep } from "../../ingress.ts" -import { BOT_TOKEN_CONFIG, GATEWAY_URL } from "./api.ts" +import { BOT_TOKEN_CONFIG, GATEWAY_URL, REGION_CONFIG, REGION_CONFIG_KEY } from "./api.ts" import { mapEvents } from "./gateway-events.ts" import { decodeServerFrame, @@ -59,10 +59,19 @@ const token = (config: ConnectorConfig): string => config.get(BOT_TOKEN_CONFIG) const handshakeFrame = (state: GatewayState, config: ConnectorConfig): string => { const resumeOffset = state.offset ?? FIRST_OFFSET + // This instance's session: the gateway sends it only the installs this region redeemed. + const region = config.get(REGION_CONFIG) + const regional = region === undefined ? {} : { region } return encodeJson( state.sessionId === undefined - ? { op: "IDENTIFY", botToken: token(config), resumeOffset } - : { op: "RESUME", botToken: token(config), sessionId: state.sessionId, resumeOffset }, + ? { op: "IDENTIFY", botToken: token(config), resumeOffset, ...regional } + : { + op: "RESUME", + botToken: token(config), + sessionId: state.sessionId, + resumeOffset, + ...regional, + }, ) } @@ -150,7 +159,7 @@ const heartbeat = (state: GatewayState, now: number): SocketStep = } export const gatewayProtocol: SocketIngressDefinition = { - requiredConfig: [{ name: BOT_TOKEN_CONFIG, secret: true }], + requiredConfig: [{ name: BOT_TOKEN_CONFIG, secret: true }, REGION_CONFIG_KEY], stateSchema: Schema.fromJsonString(GatewayState), initialState, connectUrl: () => GATEWAY_URL, diff --git a/packages/chat-platform/src/connectors/hazel/install.test.ts b/packages/chat-platform/src/connectors/hazel/install.test.ts index f4b705eb7c..3b96127f7e 100644 --- a/packages/chat-platform/src/connectors/hazel/install.test.ts +++ b/packages/chat-platform/src/connectors/hazel/install.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest" import { Effect, Layer, Redacted, Record as Rec } from "effect" import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http" import type { ChatConnectorConfig } from "../../install" -import { BOT_ID_CONFIG, BOT_TOKEN_CONFIG } from "./api" +import { BOT_ID_CONFIG, BOT_TOKEN_CONFIG, REGION_CONFIG } from "./api" import { hazelInstall, hazelInstallUrl } from "./install" const config: ChatConnectorConfig = new Map([ @@ -69,6 +69,21 @@ describe("hazel install", () => { }).pipe(Effect.provide(http.layer)) }) + it.effect("redeems with this instance's region, so the workspace's events reach its session", () => { + const http = stub( + 200, + JSON.stringify({ organizationId: "org-1", organizationName: "Acme", installedBy: "u" }), + ) + return Effect.gen(function* () { + yield* hazelInstall.complete({ + config: new Map([...config, [REGION_CONFIG, Redacted.make("eu")]]), + params: new URLSearchParams({ code: "org-1.secret", state: "nonce" }), + redirectUri, + }) + expect(sentBody(http.seen[0]!)).toEqual({ code: "org-1.secret", redirectUri, region: "eu" }) + }).pipe(Effect.provide(http.layer)) + }) + it.effect("makes the bot mentionable with its own token once the install is redeemed", () => { const http = stub(200, JSON.stringify({ organizationId: "org-1", organizationName: "Acme" })) return Effect.gen(function* () { diff --git a/packages/chat-platform/src/connectors/hazel/install.ts b/packages/chat-platform/src/connectors/hazel/install.ts index 91444c1ffb..99873ae244 100644 --- a/packages/chat-platform/src/connectors/hazel/install.ts +++ b/packages/chat-platform/src/connectors/hazel/install.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect" +import { Effect, Redacted, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/http" import { ChatInstallFailed, @@ -9,7 +9,16 @@ import { type ChatInstallStart, type ChatWorkspaceSettings, } from "../../install" -import { API_BASE, API_HOST, BOT_ID_CONFIG, BOT_SETTINGS_URL, BOT_TOKEN_CONFIG, INSTALL_URL } from "./api" +import { + API_BASE, + API_HOST, + BOT_ID_CONFIG, + BOT_SETTINGS_URL, + BOT_TOKEN_CONFIG, + INSTALL_URL, + REGION_CONFIG, + REGION_CONFIG_KEY, +} from "./api" import { HAZEL_CONNECTOR_ID } from "./id" /** @@ -42,6 +51,7 @@ const redeem = Effect.fn("Hazel.request", { kind: "client" })(function* ( token: string, code: string, redirectUri: string, + region: string | undefined, ) { yield* Effect.annotateCurrentSpan({ "peer.service": "hazel", @@ -54,7 +64,11 @@ const redeem = Effect.fn("Hazel.request", { kind: "client" })(function* ( .execute( HttpClientRequest.post(`${API_BASE}/bot-installs/token`, { headers: { accept: "application/json", authorization: `Bearer ${token}` }, - }).pipe(HttpClientRequest.bodyJsonUnsafe({ code, redirectUri })), + }).pipe( + HttpClientRequest.bodyJsonUnsafe( + region === undefined ? { code, redirectUri } : { code, redirectUri, region }, + ), + ), ) // The message only: the request carries the bot token. .pipe( @@ -112,7 +126,14 @@ const complete = Effect.fnUntraced(function* (input: ChatInstallCallback) { const code = input.params.get("code") if (code === null) return yield* installFailed("Hazel's callback carried no install code") const token = yield* requireConfig(input.config, HAZEL_CONNECTOR_ID, BOT_TOKEN_CONFIG) - const response = yield* redeem(token, code, input.redirectUri) + // Redeeming with this instance's region routes the workspace's events to its gateway session. + const region = input.config.get(REGION_CONFIG) + const response = yield* redeem( + token, + code, + input.redirectUri, + region === undefined ? undefined : Redacted.value(region), + ) if (response.status === 400) { return yield* installFailed("Hazel refused the install code: it expired or was already used") } @@ -147,6 +168,7 @@ export const hazelInstall: ChatConnectorInstall = { requiredConfig: [ { name: BOT_ID_CONFIG, secret: false }, { name: BOT_TOKEN_CONFIG, secret: true }, + REGION_CONFIG_KEY, ], authorizeUrl, complete, diff --git a/packages/chat-platform/src/index.ts b/packages/chat-platform/src/index.ts index 7730cd6013..823cf1a97c 100644 --- a/packages/chat-platform/src/index.ts +++ b/packages/chat-platform/src/index.ts @@ -11,8 +11,9 @@ export * from "./install" export * from "./outbound" export * from "./render" -/** Whether a deployment supplied every config value this connector's INSTALL half declared. */ +/** Whether a deployment supplied every non-optional config value this connector's INSTALL half declared. */ export const isConnectorConfigured = ( connector: ChatConnector, config: ReadonlyMap, -): boolean => Arr.every(connector.install.requiredConfig, (key) => config.has(key.name)) +): boolean => + Arr.every(connector.install.requiredConfig, (key) => key.optional === true || config.has(key.name)) diff --git a/packages/chat-platform/src/ingress.ts b/packages/chat-platform/src/ingress.ts index 62b2f998f0..d62ecaedb1 100644 --- a/packages/chat-platform/src/ingress.ts +++ b/packages/chat-platform/src/ingress.ts @@ -46,6 +46,11 @@ import { ChatConnectorId } from "./connector.ts" export interface ConnectorConfigKey { readonly name: string readonly secret: boolean + /** + * A setting the connector works without (a region, a tuning knob): its absence leaves the + * connector configured. Unset means required. + */ + readonly optional?: boolean } /** From c3f9f1835624c289b112531e5b013368202f1cb2 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 4 Nov 2026 19:53:48 +0100 Subject: [PATCH 2/6] fix(chat-platform): name the region on the Hazel install link Review on #2244: redeeming with the region left a window, between Hazel installing the bot and the redemption, in which the workspace's events went to the default (US) session. The region now rides the install link, and Hazel routes the install before it exists; redemption is back to code and redirect URI. Hazel also refuses a second region's link instead of moving the install. Co-Authored-By: Claude Opus 5.5 --- .../src/connectors/hazel/README.md | 9 +++-- .../src/connectors/hazel/install.test.ts | 34 ++++++++++-------- .../src/connectors/hazel/install.ts | 36 +++++++++---------- 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/packages/chat-platform/src/connectors/hazel/README.md b/packages/chat-platform/src/connectors/hazel/README.md index 5e6213116e..0c92250fb7 100644 --- a/packages/chat-platform/src/connectors/hazel/README.md +++ b/packages/chat-platform/src/connectors/hazel/README.md @@ -30,13 +30,16 @@ A half whose names are unset is skipped rather than fatal, as for every connecto `api`, and the EU instance also sets `MAPLE_HAZEL_REGION=eu` on both. **One bot for every instance.** Hazel's gateway holds one session per bot and region. Each -instance identifies with its own region (none for US), and redeems installs with it, so a -workspace installed from the EU instance sends its events only to the EU session: EU data never +instance identifies with its own region (none for US) and puts it on its install links +(`®ion=eu`), so Hazel routes a workspace installed from the EU instance to the EU session +before the install even exists. A workspace can be installed from one region at a time: Hazel +refuses a link from another region until the bot is removed there. Its events go only to the EU +session: EU data never passes through the US instance, and neither depends on the other being up. In Hazel the bot is the Maple integration (Settings → Integrations → Maple), not an entry in Installed Apps. Moving an instance onto the shared bot: set its token, id and region, then reconnect its Hazel -workspaces (Settings → Integrations → Hazel → Connect), which redeems each install with the region. +workspaces (Settings → Integrations → Hazel → Connect), which installs the shared bot for the region. The instance's saved gateway session and offset belong to its old bot's log: clear them when switching, so its first session on the shared bot identifies from new events (`now`). diff --git a/packages/chat-platform/src/connectors/hazel/install.test.ts b/packages/chat-platform/src/connectors/hazel/install.test.ts index 3b96127f7e..f289f06b5a 100644 --- a/packages/chat-platform/src/connectors/hazel/install.test.ts +++ b/packages/chat-platform/src/connectors/hazel/install.test.ts @@ -46,6 +46,25 @@ describe("hazel install", () => { }) }) + it("names this instance's region on the link, so Hazel routes the workspace to it", () => { + const url = new URL(hazelInstallUrl("bot-1", { state: "nonce", redirectUri, region: "eu" })) + expect(url.searchParams.get("region")).toBe("eu") + expect( + new URL(hazelInstallUrl("bot-1", { state: "nonce", redirectUri })).searchParams.has("region"), + ).toBe(false) + }) + + it.effect("builds the link with the deployment's region", () => + Effect.gen(function* () { + const link = yield* hazelInstall.authorizeUrl({ + config: new Map([...config, [REGION_CONFIG, Redacted.make("eu")]]), + state: "nonce", + redirectUri, + }) + expect(new URL(link).searchParams.get("region")).toBe("eu") + }), + ) + it.effect("redeems the code with the bot's own token for the workspace it names", () => { const http = stub( 200, @@ -69,21 +88,6 @@ describe("hazel install", () => { }).pipe(Effect.provide(http.layer)) }) - it.effect("redeems with this instance's region, so the workspace's events reach its session", () => { - const http = stub( - 200, - JSON.stringify({ organizationId: "org-1", organizationName: "Acme", installedBy: "u" }), - ) - return Effect.gen(function* () { - yield* hazelInstall.complete({ - config: new Map([...config, [REGION_CONFIG, Redacted.make("eu")]]), - params: new URLSearchParams({ code: "org-1.secret", state: "nonce" }), - redirectUri, - }) - expect(sentBody(http.seen[0]!)).toEqual({ code: "org-1.secret", redirectUri, region: "eu" }) - }).pipe(Effect.provide(http.layer)) - }) - it.effect("makes the bot mentionable with its own token once the install is redeemed", () => { const http = stub(200, JSON.stringify({ organizationId: "org-1", organizationName: "Acme" })) return Effect.gen(function* () { diff --git a/packages/chat-platform/src/connectors/hazel/install.ts b/packages/chat-platform/src/connectors/hazel/install.ts index 99873ae244..5237966d7d 100644 --- a/packages/chat-platform/src/connectors/hazel/install.ts +++ b/packages/chat-platform/src/connectors/hazel/install.ts @@ -34,24 +34,33 @@ const decodeRedeemedInstall = Schema.decodeUnknownEffect(RedeemedInstall) /** * The install link. Hazel's consent page checks `redirect_uri` against the URIs the bot's owner * registered on the bot (exactly), so the callback has to be one of them; `state` is the host's - * single-use nonce and comes back untouched. + * single-use nonce and comes back untouched. `region` (this instance's, when it has one) makes + * Hazel route the workspace's events to this instance's gateway session before the install exists. */ -export const hazelInstallUrl = (botId: string, input: { state: string; redirectUri: string }): string => { +export const hazelInstallUrl = ( + botId: string, + input: { state: string; redirectUri: string; region?: string | undefined }, +): string => { const params = new URLSearchParams({ bot_id: botId, redirect_uri: input.redirectUri, state: input.state }) + if (input.region !== undefined) params.set("region", input.region) return `${INSTALL_URL}?${params.toString()}` } const authorizeUrl = (input: ChatInstallStart) => - Effect.map(requireConfig(input.config, HAZEL_CONNECTOR_ID, BOT_ID_CONFIG), (botId) => - hazelInstallUrl(botId, { state: input.state, redirectUri: input.redirectUri }), - ) + Effect.map(requireConfig(input.config, HAZEL_CONNECTOR_ID, BOT_ID_CONFIG), (botId) => { + const region = input.config.get(REGION_CONFIG) + return hazelInstallUrl(botId, { + state: input.state, + redirectUri: input.redirectUri, + region: region === undefined ? undefined : Redacted.value(region), + }) + }) /** The redemption POST, as one client span on the service map. */ const redeem = Effect.fn("Hazel.request", { kind: "client" })(function* ( token: string, code: string, redirectUri: string, - region: string | undefined, ) { yield* Effect.annotateCurrentSpan({ "peer.service": "hazel", @@ -64,11 +73,7 @@ const redeem = Effect.fn("Hazel.request", { kind: "client" })(function* ( .execute( HttpClientRequest.post(`${API_BASE}/bot-installs/token`, { headers: { accept: "application/json", authorization: `Bearer ${token}` }, - }).pipe( - HttpClientRequest.bodyJsonUnsafe( - region === undefined ? { code, redirectUri } : { code, redirectUri, region }, - ), - ), + }).pipe(HttpClientRequest.bodyJsonUnsafe({ code, redirectUri })), ) // The message only: the request carries the bot token. .pipe( @@ -126,14 +131,7 @@ const complete = Effect.fnUntraced(function* (input: ChatInstallCallback) { const code = input.params.get("code") if (code === null) return yield* installFailed("Hazel's callback carried no install code") const token = yield* requireConfig(input.config, HAZEL_CONNECTOR_ID, BOT_TOKEN_CONFIG) - // Redeeming with this instance's region routes the workspace's events to its gateway session. - const region = input.config.get(REGION_CONFIG) - const response = yield* redeem( - token, - code, - input.redirectUri, - region === undefined ? undefined : Redacted.value(region), - ) + const response = yield* redeem(token, code, input.redirectUri) if (response.status === 400) { return yield* installFailed("Hazel refused the install code: it expired or was already used") } From fe22a6688ff08e74932b7a849d44f0741ff64ec5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 4 Nov 2026 19:57:38 +0100 Subject: [PATCH 3/6] test(chat-platform): an optional config key leaves a connector configured Co-Authored-By: Claude Opus 5.5 --- apps/chat-bot/src/config.test.ts | 30 +++++++++++++++++++ .../src/connectors/hazel/install.test.ts | 10 +++++++ 2 files changed, 40 insertions(+) diff --git a/apps/chat-bot/src/config.test.ts b/apps/chat-bot/src/config.test.ts index 0f8758758b..bd8c58fe67 100644 --- a/apps/chat-bot/src/config.test.ts +++ b/apps/chat-bot/src/config.test.ts @@ -24,6 +24,36 @@ describe("resolving a connector's configuration", () => { }) }) + it("leaves an optional setting out without calling the connector unconfigured", () => { + const base = testSocketConnector() + const connector: IngressConnector = { + ...base, + ingress: { + ...base.ingress, + requiredConfig: [ + ...base.ingress.requiredConfig, + { name: "TEST_REGION", secret: false, optional: true }, + ], + }, + } + expect(resolveConnectorConfig({ [TEST_TOKEN_KEY]: "t" }, connector)).toEqual({ + _tag: "ready", + config: new Map([[TEST_TOKEN_KEY, "t"]]), + }) + expect(resolveConnectorConfig({ [TEST_TOKEN_KEY]: "t", TEST_REGION: "eu" }, connector)).toEqual({ + _tag: "ready", + config: new Map([ + [TEST_TOKEN_KEY, "t"], + ["TEST_REGION", "eu"], + ]), + }) + // The required one still is. + expect(resolveConnectorConfig({ TEST_REGION: "eu" }, connector)).toEqual({ + _tag: "missing", + names: [TEST_TOKEN_KEY], + }) + }) + it("treats a blank value as absent rather than binding an empty string", () => { expect(resolveConnectorConfig({ [TEST_TOKEN_KEY]: " " }, testSocketConnector())).toEqual({ _tag: "missing", diff --git a/packages/chat-platform/src/connectors/hazel/install.test.ts b/packages/chat-platform/src/connectors/hazel/install.test.ts index f289f06b5a..0907e3f5c0 100644 --- a/packages/chat-platform/src/connectors/hazel/install.test.ts +++ b/packages/chat-platform/src/connectors/hazel/install.test.ts @@ -3,6 +3,8 @@ import { Effect, Layer, Redacted, Record as Rec } from "effect" import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http" import type { ChatConnectorConfig } from "../../install" import { BOT_ID_CONFIG, BOT_TOKEN_CONFIG, REGION_CONFIG } from "./api" +import { isConnectorConfigured } from "../../index" +import { hazel } from "./index" import { hazelInstall, hazelInstallUrl } from "./install" const config: ChatConnectorConfig = new Map([ @@ -46,6 +48,14 @@ describe("hazel install", () => { }) }) + it("is configured without a region, and not without its token", () => { + expect(isConnectorConfigured(hazel, config)).toBe(true) + expect(isConnectorConfigured(hazel, new Map([...config, [REGION_CONFIG, Redacted.make("eu")]]))).toBe( + true, + ) + expect(isConnectorConfigured(hazel, new Map([[BOT_ID_CONFIG, Redacted.make("bot-1")]]))).toBe(false) + }) + it("names this instance's region on the link, so Hazel routes the workspace to it", () => { const url = new URL(hazelInstallUrl("bot-1", { state: "nonce", redirectUri, region: "eu" })) expect(url.searchParams.get("region")).toBe("eu") From 92fc50b4846ff694b7795edd4651436e6a352651 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 4 Nov 2026 20:06:28 +0100 Subject: [PATCH 4/6] fix(chat-platform): carry the region in the registered Hazel callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #2244: a free `region` on the install link could be edited. The EU instance's callback now carries it (`…/callback?region=eu`), registered on the bot and matched exactly by Hazel, on the link and the redemption alike. README: register that URI, deploy Hazel first, clear the old session before reconnecting. Co-Authored-By: Claude Opus 5.5 --- .../src/connectors/hazel/README.md | 27 ++++++------ .../src/connectors/hazel/install.test.ts | 41 +++++++++++-------- .../src/connectors/hazel/install.ts | 39 +++++++++++------- 3 files changed, 63 insertions(+), 44 deletions(-) diff --git a/packages/chat-platform/src/connectors/hazel/README.md b/packages/chat-platform/src/connectors/hazel/README.md index 0c92250fb7..0a66ee3291 100644 --- a/packages/chat-platform/src/connectors/hazel/README.md +++ b/packages/chat-platform/src/connectors/hazel/README.md @@ -24,24 +24,27 @@ A half whose names are unset is skipped rather than fatal, as for every connecto A bot created before alerts came through it lacks `channels:read`: add it, or its channel list answers `auth` and no Hazel channel can be picked. 2. **Register the install redirect URIs** on the bot (Edit Application → Install redirect URIs): - `https://api.maple.dev/oauth/chat/hazel/callback`, the EU instance's equivalent, plus the - origin of any other stage that shares this bot. Hazel compares them exactly. + `https://api.maple.dev/oauth/chat/hazel/callback`, the EU instance's with its region + (`https:///oauth/chat/hazel/callback?region=eu`), plus the origin of any other + stage that shares this bot. Hazel compares them exactly. 3. **Put the values on the deployment.** The token reaches `chat-bot` and `api`, the id reaches `api`, and the EU instance also sets `MAPLE_HAZEL_REGION=eu` on both. **One bot for every instance.** Hazel's gateway holds one session per bot and region. Each -instance identifies with its own region (none for US) and puts it on its install links -(`®ion=eu`), so Hazel routes a workspace installed from the EU instance to the EU session -before the install even exists. A workspace can be installed from one region at a time: Hazel -refuses a link from another region until the bot is removed there. Its events go only to the EU -session: EU data never -passes through the US instance, and neither depends on the other being up. In Hazel the bot is +instance identifies with its own region (none for US), and its callback carries the region +(`?region=eu`, `hazelRedirectUri`). That URI is registered on the bot and matched exactly, so +Hazel routes a workspace installed from the EU instance to the EU session before the install +even exists, and no edited link can name another region. A workspace can be installed from one +region at a time: Hazel refuses another region's callback until the bot is removed there. Its +events go only to the EU session: EU data never passes through the US instance, and neither depends on the other being up. In Hazel the bot is the Maple integration (Settings → Integrations → Maple), not an entry in Installed Apps. -Moving an instance onto the shared bot: set its token, id and region, then reconnect its Hazel -workspaces (Settings → Integrations → Hazel → Connect), which installs the shared bot for the region. -The instance's saved gateway session and offset belong to its old bot's log: clear them when -switching, so its first session on the shared bot identifies from new events (`now`). +Moving an instance onto the shared bot: deploy Hazel with regional install support first. Then set +the instance's token, id and region, and clear its saved gateway session and offset (they belong to +the old bot's log) before reconnecting its Hazel workspaces (Settings → Integrations → Hazel → +Connect), which installs the shared bot for the region. The chat-bot reconnects on its own, so do +not count on the instance staying disconnected meanwhile; its first session on the shared bot +identifies from new events (`now`). ## Trying it end to end diff --git a/packages/chat-platform/src/connectors/hazel/install.test.ts b/packages/chat-platform/src/connectors/hazel/install.test.ts index 0907e3f5c0..e791cf8f4d 100644 --- a/packages/chat-platform/src/connectors/hazel/install.test.ts +++ b/packages/chat-platform/src/connectors/hazel/install.test.ts @@ -5,7 +5,7 @@ import type { ChatConnectorConfig } from "../../install" import { BOT_ID_CONFIG, BOT_TOKEN_CONFIG, REGION_CONFIG } from "./api" import { isConnectorConfigured } from "../../index" import { hazel } from "./index" -import { hazelInstall, hazelInstallUrl } from "./install" +import { hazelInstall, hazelInstallUrl, hazelRedirectUri } from "./install" const config: ChatConnectorConfig = new Map([ [BOT_ID_CONFIG, Redacted.make("bot-1")], @@ -56,24 +56,31 @@ describe("hazel install", () => { expect(isConnectorConfigured(hazel, new Map([[BOT_ID_CONFIG, Redacted.make("bot-1")]]))).toBe(false) }) - it("names this instance's region on the link, so Hazel routes the workspace to it", () => { - const url = new URL(hazelInstallUrl("bot-1", { state: "nonce", redirectUri, region: "eu" })) - expect(url.searchParams.get("region")).toBe("eu") - expect( - new URL(hazelInstallUrl("bot-1", { state: "nonce", redirectUri })).searchParams.has("region"), - ).toBe(false) - }) - - it.effect("builds the link with the deployment's region", () => - Effect.gen(function* () { - const link = yield* hazelInstall.authorizeUrl({ - config: new Map([...config, [REGION_CONFIG, Redacted.make("eu")]]), - state: "nonce", + it.effect("names this instance's region in its callback, on the link and the redemption", () => { + const http = stub( + 200, + JSON.stringify({ organizationId: "org-1", organizationName: "Acme", installedBy: "u" }), + ) + const regional = new Map([...config, [REGION_CONFIG, Redacted.make("eu")]]) + const regionalUri = `${redirectUri}?region=eu` + return Effect.gen(function* () { + const link = new URL( + yield* hazelInstall.authorizeUrl({ config: regional, state: "nonce", redirectUri }), + ) + expect(link.searchParams.get("redirect_uri")).toBe(regionalUri) + expect(link.searchParams.has("region")).toBe(false) + yield* hazelInstall.complete({ + config: regional, + params: new URLSearchParams({ code: "org-1.secret", state: "nonce", region: "eu" }), redirectUri, }) - expect(new URL(link).searchParams.get("region")).toBe("eu") - }), - ) + expect(sentBody(http.seen[0]!)).toEqual({ code: "org-1.secret", redirectUri: regionalUri }) + }).pipe(Effect.provide(http.layer)) + }) + + it("leaves the callback alone without a region", () => { + expect(hazelRedirectUri(redirectUri, config)).toBe(redirectUri) + }) it.effect("redeems the code with the bot's own token for the workspace it names", () => { const http = stub( diff --git a/packages/chat-platform/src/connectors/hazel/install.ts b/packages/chat-platform/src/connectors/hazel/install.ts index 5237966d7d..be2cf9e412 100644 --- a/packages/chat-platform/src/connectors/hazel/install.ts +++ b/packages/chat-platform/src/connectors/hazel/install.ts @@ -4,6 +4,7 @@ import { ChatInstallFailed, ChatSettingsRejected, requireConfig, + type ChatConnectorConfig, type ChatConnectorInstall, type ChatInstallCallback, type ChatInstallStart, @@ -34,27 +35,34 @@ const decodeRedeemedInstall = Schema.decodeUnknownEffect(RedeemedInstall) /** * The install link. Hazel's consent page checks `redirect_uri` against the URIs the bot's owner * registered on the bot (exactly), so the callback has to be one of them; `state` is the host's - * single-use nonce and comes back untouched. `region` (this instance's, when it has one) makes - * Hazel route the workspace's events to this instance's gateway session before the install exists. + * single-use nonce and comes back untouched. */ -export const hazelInstallUrl = ( - botId: string, - input: { state: string; redirectUri: string; region?: string | undefined }, -): string => { +export const hazelInstallUrl = (botId: string, input: { state: string; redirectUri: string }): string => { const params = new URLSearchParams({ bot_id: botId, redirect_uri: input.redirectUri, state: input.state }) - if (input.region !== undefined) params.set("region", input.region) return `${INSTALL_URL}?${params.toString()}` } +/** + * This instance's callback as Hazel knows it: with `?region=` when the deployment has one + * ({@link REGION_CONFIG}). The region is part of the URI registered on the bot, and Hazel matches + * that URI exactly, so it routes the workspace to this instance's gateway session before the + * install exists, and no link can name a region its callback was not registered for. + */ +export const hazelRedirectUri = (redirectUri: string, config: ChatConnectorConfig): string => { + const region = config.get(REGION_CONFIG) + if (region === undefined) return redirectUri + const url = new URL(redirectUri) + url.searchParams.set("region", Redacted.value(region)) + return url.toString() +} + const authorizeUrl = (input: ChatInstallStart) => - Effect.map(requireConfig(input.config, HAZEL_CONNECTOR_ID, BOT_ID_CONFIG), (botId) => { - const region = input.config.get(REGION_CONFIG) - return hazelInstallUrl(botId, { + Effect.map(requireConfig(input.config, HAZEL_CONNECTOR_ID, BOT_ID_CONFIG), (botId) => + hazelInstallUrl(botId, { state: input.state, - redirectUri: input.redirectUri, - region: region === undefined ? undefined : Redacted.value(region), - }) - }) + redirectUri: hazelRedirectUri(input.redirectUri, input.config), + }), + ) /** The redemption POST, as one client span on the service map. */ const redeem = Effect.fn("Hazel.request", { kind: "client" })(function* ( @@ -131,7 +139,8 @@ const complete = Effect.fnUntraced(function* (input: ChatInstallCallback) { const code = input.params.get("code") if (code === null) return yield* installFailed("Hazel's callback carried no install code") const token = yield* requireConfig(input.config, HAZEL_CONNECTOR_ID, BOT_TOKEN_CONFIG) - const response = yield* redeem(token, code, input.redirectUri) + // The grant is bound to the URI the link named, region included. + const response = yield* redeem(token, code, hazelRedirectUri(input.redirectUri, input.config)) if (response.status === 400) { return yield* installFailed("Hazel refused the install code: it expired or was already used") } From 36e31443a60f5601d3e752551fb8a00247d38480 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 4 Nov 2026 20:26:04 +0100 Subject: [PATCH 5/6] docs(chat-platform): one Hazel gateway session per bot and region Co-Authored-By: Claude Opus 5.5 --- packages/chat-platform/src/connectors/hazel/README.md | 5 +++-- packages/chat-platform/src/connectors/hazel/gateway.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/chat-platform/src/connectors/hazel/README.md b/packages/chat-platform/src/connectors/hazel/README.md index 0a66ee3291..2d9915593f 100644 --- a/packages/chat-platform/src/connectors/hazel/README.md +++ b/packages/chat-platform/src/connectors/hazel/README.md @@ -110,8 +110,9 @@ sides, which is what this rests on. this bot. A gateway that predates `botUserId` delivers nothing until it names it. - Events arrive in DISPATCH batches, one in flight at a time; each is acknowledged in the same step that hands its events to the host. -- One session per token: a second IDENTIFY is refused while one is live, and a RESUME of the live - session replaces it. So the session is kept across every reconnect and deploy, and only a refused +- One session per bot and region (`MAPLE_HAZEL_REGION`; none for US): a second IDENTIFY for the + same region is refused while one is live, and a RESUME of the live session replaces it. The US + and EU instances share the token, each holding its own region's session. So the session is kept across every reconnect and deploy, and only a refused token (`Invalid bot token`) stops the loop. Hazel delivers every message in a workspace to an installed bot. The connector drops its own diff --git a/packages/chat-platform/src/connectors/hazel/gateway.ts b/packages/chat-platform/src/connectors/hazel/gateway.ts index e434207fb4..1f364d3485 100644 --- a/packages/chat-platform/src/connectors/hazel/gateway.ts +++ b/packages/chat-platform/src/connectors/hazel/gateway.ts @@ -11,8 +11,9 @@ * redelivered on the next connection, and the gateway asks for a reconnect after a minute without * one. HEARTBEATs keep the connection; RECONNECT and INVALID_SESSION end it. * - * One session per bot token: a second IDENTIFY is refused while a session is live, and only a - * RESUME of the live session's own id replaces it. So the session id is kept across every reconnect + * One session per bot and region (the deployment's `MAPLE_HAZEL_REGION`, none for US): a second + * IDENTIFY for the same region is refused while a session is live, and only a RESUME of the live + * session's own id replaces it. So the session id is kept across every reconnect * and deploy, and a fresh IDENTIFY happens only when there is none. */ import { Option, Schema } from "effect" From c52178cfd93037a55c108862a84168615ce66ddd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 4 Nov 2026 20:30:12 +0100 Subject: [PATCH 6/6] fix(chat-platform): name the Hazel callback's region hazel_region Hazel reads a namespaced parameter (hazel#396, review F9), so no service's own `region` parameter becomes a routing key. Co-Authored-By: Claude Opus 5.5 --- packages/chat-platform/src/connectors/hazel/README.md | 4 ++-- packages/chat-platform/src/connectors/hazel/install.test.ts | 4 ++-- packages/chat-platform/src/connectors/hazel/install.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/chat-platform/src/connectors/hazel/README.md b/packages/chat-platform/src/connectors/hazel/README.md index 2d9915593f..df85cc250b 100644 --- a/packages/chat-platform/src/connectors/hazel/README.md +++ b/packages/chat-platform/src/connectors/hazel/README.md @@ -25,14 +25,14 @@ A half whose names are unset is skipped rather than fatal, as for every connecto answers `auth` and no Hazel channel can be picked. 2. **Register the install redirect URIs** on the bot (Edit Application → Install redirect URIs): `https://api.maple.dev/oauth/chat/hazel/callback`, the EU instance's with its region - (`https:///oauth/chat/hazel/callback?region=eu`), plus the origin of any other + (`https:///oauth/chat/hazel/callback?hazel_region=eu`), plus the origin of any other stage that shares this bot. Hazel compares them exactly. 3. **Put the values on the deployment.** The token reaches `chat-bot` and `api`, the id reaches `api`, and the EU instance also sets `MAPLE_HAZEL_REGION=eu` on both. **One bot for every instance.** Hazel's gateway holds one session per bot and region. Each instance identifies with its own region (none for US), and its callback carries the region -(`?region=eu`, `hazelRedirectUri`). That URI is registered on the bot and matched exactly, so +(`?hazel_region=eu`, `hazelRedirectUri`). That URI is registered on the bot and matched exactly, so Hazel routes a workspace installed from the EU instance to the EU session before the install even exists, and no edited link can name another region. A workspace can be installed from one region at a time: Hazel refuses another region's callback until the bot is removed there. Its diff --git a/packages/chat-platform/src/connectors/hazel/install.test.ts b/packages/chat-platform/src/connectors/hazel/install.test.ts index e791cf8f4d..d3b346813b 100644 --- a/packages/chat-platform/src/connectors/hazel/install.test.ts +++ b/packages/chat-platform/src/connectors/hazel/install.test.ts @@ -62,7 +62,7 @@ describe("hazel install", () => { JSON.stringify({ organizationId: "org-1", organizationName: "Acme", installedBy: "u" }), ) const regional = new Map([...config, [REGION_CONFIG, Redacted.make("eu")]]) - const regionalUri = `${redirectUri}?region=eu` + const regionalUri = `${redirectUri}?hazel_region=eu` return Effect.gen(function* () { const link = new URL( yield* hazelInstall.authorizeUrl({ config: regional, state: "nonce", redirectUri }), @@ -71,7 +71,7 @@ describe("hazel install", () => { expect(link.searchParams.has("region")).toBe(false) yield* hazelInstall.complete({ config: regional, - params: new URLSearchParams({ code: "org-1.secret", state: "nonce", region: "eu" }), + params: new URLSearchParams({ code: "org-1.secret", state: "nonce", hazel_region: "eu" }), redirectUri, }) expect(sentBody(http.seen[0]!)).toEqual({ code: "org-1.secret", redirectUri: regionalUri }) diff --git a/packages/chat-platform/src/connectors/hazel/install.ts b/packages/chat-platform/src/connectors/hazel/install.ts index be2cf9e412..4602bca86f 100644 --- a/packages/chat-platform/src/connectors/hazel/install.ts +++ b/packages/chat-platform/src/connectors/hazel/install.ts @@ -43,7 +43,7 @@ export const hazelInstallUrl = (botId: string, input: { state: string; redirectU } /** - * This instance's callback as Hazel knows it: with `?region=` when the deployment has one + * This instance's callback as Hazel knows it: with `?hazel_region=` when the deployment has one * ({@link REGION_CONFIG}). The region is part of the URI registered on the bot, and Hazel matches * that URI exactly, so it routes the workspace to this instance's gateway session before the * install exists, and no link can name a region its callback was not registered for. @@ -52,7 +52,7 @@ export const hazelRedirectUri = (redirectUri: string, config: ChatConnectorConfi const region = config.get(REGION_CONFIG) if (region === undefined) return redirectUri const url = new URL(redirectUri) - url.searchParams.set("region", Redacted.value(region)) + url.searchParams.set("hazel_region", Redacted.value(region)) return url.toString() }