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
30 changes: 30 additions & 0 deletions apps/chat-bot/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion apps/chat-bot/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,14 @@ export const resolveConnectorConfig = (connector: IngressConnector): Effect.Effe
Effect.map((entries): ConnectorConfigResult => {
const config = new Map<string, string>()
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 }
}),
Expand Down
36 changes: 26 additions & 10 deletions packages/chat-platform/src/connectors/hazel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<eu api origin>/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

Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/chat-platform/src/connectors/hazel/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
22 changes: 21 additions & 1 deletion packages/chat-platform/src/connectors/hazel/gateway.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 }),
Expand Down
22 changes: 16 additions & 6 deletions packages/chat-platform/src/connectors/hazel/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
},
)
}

Expand Down Expand Up @@ -150,7 +160,7 @@ const heartbeat = (state: GatewayState, now: number): SocketStep<GatewayState> =
}

export const gatewayProtocol: SocketIngressDefinition<GatewayState> = {
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,
Expand Down
40 changes: 38 additions & 2 deletions packages/chat-platform/src/connectors/hazel/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand Down Expand Up @@ -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,
Expand Down
37 changes: 33 additions & 4 deletions packages/chat-platform/src/connectors/hazel/install.ts
Original file line number Diff line number Diff line change
@@ -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"

/**
Expand All @@ -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),
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
)

/** The redemption POST, as one client span on the service map. */
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions packages/chat-platform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <R>(
connector: ChatConnector<R>,
config: ReadonlyMap<string, unknown>,
): 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))
5 changes: 5 additions & 0 deletions packages/chat-platform/src/ingress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
Loading