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/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..df85cc250b 100644 --- a/packages/chat-platform/src/connectors/hazel/README.md +++ b/packages/chat-platform/src/connectors/hazel/README.md @@ -14,22 +14,37 @@ 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 + `https://api.maple.dev/oauth/chat/hazel/callback`, the EU instance's with its region + (`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 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. +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 +(`?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 +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: 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 @@ -95,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/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..1f364d3485 100644 --- a/packages/chat-platform/src/connectors/hazel/gateway.ts +++ b/packages/chat-platform/src/connectors/hazel/gateway.ts @@ -11,13 +11,14 @@ * 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" 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 +60,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 +160,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..d3b346813b 100644 --- a/packages/chat-platform/src/connectors/hazel/install.test.ts +++ b/packages/chat-platform/src/connectors/hazel/install.test.ts @@ -2,8 +2,10 @@ 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 { hazelInstall, hazelInstallUrl } from "./install" +import { BOT_ID_CONFIG, BOT_TOKEN_CONFIG, REGION_CONFIG } from "./api" +import { isConnectorConfigured } from "../../index" +import { hazel } from "./index" +import { hazelInstall, hazelInstallUrl, hazelRedirectUri } from "./install" const config: ChatConnectorConfig = new Map([ [BOT_ID_CONFIG, Redacted.make("bot-1")], @@ -46,6 +48,40 @@ 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.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}?hazel_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", hazel_region: "eu" }), + redirectUri, + }) + 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( 200, diff --git a/packages/chat-platform/src/connectors/hazel/install.ts b/packages/chat-platform/src/connectors/hazel/install.ts index 91444c1ffb..4602bca86f 100644 --- a/packages/chat-platform/src/connectors/hazel/install.ts +++ b/packages/chat-platform/src/connectors/hazel/install.ts @@ -1,15 +1,25 @@ -import { Effect, Schema } from "effect" +import { Effect, Redacted, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/http" import { ChatInstallFailed, ChatSettingsRejected, requireConfig, + type ChatConnectorConfig, type ChatConnectorInstall, type ChatInstallCallback, 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" /** @@ -32,9 +42,26 @@ export const hazelInstallUrl = (botId: string, input: { state: string; redirectU return `${INSTALL_URL}?${params.toString()}` } +/** + * 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. + */ +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("hazel_region", Redacted.value(region)) + return url.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 }), + hazelInstallUrl(botId, { + state: input.state, + redirectUri: hazelRedirectUri(input.redirectUri, input.config), + }), ) /** The redemption POST, as one client span on the service map. */ @@ -112,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") } @@ -147,6 +175,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 } /**