From 5e3146fb99edfd8cd5e8f40b3bcd9412bc81688a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 17:39:48 +0100 Subject: [PATCH 1/4] feat(api): list the channels a bot can post in GET /api/v1/organizations/:organizationId/channels answers an installed bot with the public channels of the org and the private ones it was added to, read from its channel_access. Threads, direct messages and deleted channels are left out. Needs the channels:read scope when the bot records scopes. Maple uses it to let an org pick Hazel channels as alert destinations through its bot, so alerts no longer need a separate OAuth webhook connection. --- .../src/routes/api-v1/bot-auth.test.ts | 24 ++++++ apps/backend/src/routes/api-v1/bot-auth.ts | 11 +++ apps/backend/src/routes/api-v1/bots.http.ts | 24 +++++- .../src/worker/objects/call-receivers.ts | 3 + .../src/worker/objects/directory-path.test.ts | 57 ++++++++++++- .../worker/objects/org/bot-install-grants.ts | 83 +++++++++++++++---- packages/domain/src/http/api-v1/bots.ts | 29 ++++++- 7 files changed, 209 insertions(+), 22 deletions(-) create mode 100644 apps/backend/src/routes/api-v1/bot-auth.test.ts diff --git a/apps/backend/src/routes/api-v1/bot-auth.test.ts b/apps/backend/src/routes/api-v1/bot-auth.test.ts new file mode 100644 index 000000000..c210f0b11 --- /dev/null +++ b/apps/backend/src/routes/api-v1/bot-auth.test.ts @@ -0,0 +1,24 @@ +import { Effect, Exit } from "effect" +import { describe, expect, it } from "vitest" +import { requireBotScope } from "./bot-auth" + +const check = (scopes: ReadonlyArray | null) => + Effect.runSync(Effect.exit(requireBotScope({ scopes }, "channels:read"))) + +describe("requireBotScope", () => { + it("lets a bot holding the scope through", () => { + expect(Exit.isSuccess(check(["messages:read", "channels:read"]))).toBe(true) + }) + + it("lets a bot with no scopes recorded through, as a member", () => { + expect(Exit.isSuccess(check(null))).toBe(true) + }) + + it("refuses a bot whose scopes leave it out", () => { + const error = Effect.runSync( + Effect.flip(requireBotScope({ scopes: ["messages:read", "channels:write"] }, "channels:read")), + ) + expect(error._tag).toBe("UnauthorizedError") + expect(error.message).toContain("channels:read") + }) +}) diff --git a/apps/backend/src/routes/api-v1/bot-auth.ts b/apps/backend/src/routes/api-v1/bot-auth.ts index d421f79ae..a38827514 100644 --- a/apps/backend/src/routes/api-v1/bot-auth.ts +++ b/apps/backend/src/routes/api-v1/bot-auth.ts @@ -62,3 +62,14 @@ export const botPrincipal = (bot: { }), botScopes: bot.scopes === null ? null : bot.scopes.filter(isApiScope), }) + +/** A bot with scopes recorded must hold `scope`; one without keeps a member's, as in `botPrincipal`. */ +export const requireBotScope = (bot: { readonly scopes: ReadonlyArray | null }, scope: ApiScope) => + bot.scopes === null || bot.scopes.includes(scope) + ? Effect.void + : Effect.fail( + new UnauthorizedError({ + message: `This bot lacks the ${scope} scope`, + detail: `Add ${scope} to the bot's scopes`, + }), + ) diff --git a/apps/backend/src/routes/api-v1/bots.http.ts b/apps/backend/src/routes/api-v1/bots.http.ts index 2cfa38c56..2e4f8a627 100644 --- a/apps/backend/src/routes/api-v1/bots.http.ts +++ b/apps/backend/src/routes/api-v1/bots.http.ts @@ -1,7 +1,7 @@ /** * The public bot API for bots that live outside Hazel (`@hazel/domain/http` `api-v1-bots`): * redeeming an install grant in the `OrgObject` its code names, opening a thread through - * `channel.createThread` in the channel's org, and the org's user lookup. Each runs as the bot + * `channel.createThread` in the channel's org, and the org's user and channel lookups. Each runs as the bot * authenticated by its token (`./bot-auth.ts`). */ import { HttpApiBuilder } from "effect/http-api" @@ -16,6 +16,7 @@ import { BotInstallGrantInvalidError, BotInstallRedeemedResponse, ChannelNotFoundError, + ApiV1ChannelsResponse, ApiV1UsersResponse, ThreadResponse, } from "@hazel/domain/http" @@ -33,11 +34,12 @@ import { hashInstallCode, ORGANIZATION_USERS_MAX_IDS, organizationOfInstallCode, + OrganizationChannelsCall, OrganizationUsersCall, RedeemBotInstallGrantCall, } from "../../worker/objects/org/bot-install-grants" import { providePrincipal } from "../../worker/objects/principal" -import { authenticateBotFromToken, botPrincipal } from "./bot-auth" +import { authenticateBotFromToken, botPrincipal, requireBotScope } from "./bot-auth" const internal = (message: string) => (cause: unknown) => Effect.logError(message, { cause }).pipe( @@ -151,6 +153,24 @@ export const HttpApiV1BotsLive = HttpApiBuilder.group(HazelApi, "api-v1-bots", ( }), Effect.withSpan("ApiV1.listOrganizationUsers"), ), + ) + .handle("listOrganizationChannels", ({ params }) => + Effect.gen(function* () { + const bot = yield* authenticateBotFromToken + yield* requireBotScope(bot, "channels:read") + const channels = yield* callObject( + OrganizationChannelsCall, + orgTarget(params.organizationId), + { botId: bot.id, botUserId: bot.userId }, + ) + return new ApiV1ChannelsResponse({ data: channels }) + }).pipe( + Effect.catchTags({ + DatabaseError: internal("Database error while listing channels"), + ObjectCallError: internal("The organization is unavailable"), + }), + Effect.withSpan("ApiV1.listOrganizationChannels"), + ), ), ), ) diff --git a/apps/backend/src/worker/objects/call-receivers.ts b/apps/backend/src/worker/objects/call-receivers.ts index 4dd7d4e39..9482d8e65 100644 --- a/apps/backend/src/worker/objects/call-receivers.ts +++ b/apps/backend/src/worker/objects/call-receivers.ts @@ -21,6 +21,8 @@ import { listMessages, ListMessagesCall, messageThread, MessageThreadCall } from import { liveAccess, LiveAccessCall } from "./channel/live-access" import type { ObjectKind } from "./kinds" import { + organizationChannels, + OrganizationChannelsCall, organizationUsers, OrganizationUsersCall, redeemBotInstallGrant, @@ -115,6 +117,7 @@ export const callReceivers: Record> = receiver(IsOrgAdminCall, isOrgAdmin), receiver(RedeemBotInstallGrantCall, redeemBotInstallGrant), receiver(OrganizationUsersCall, organizationUsers), + receiver(OrganizationChannelsCall, organizationChannels), ]), channel: byName([ receiver(ListMessagesCall, listMessages), diff --git a/apps/backend/src/worker/objects/directory-path.test.ts b/apps/backend/src/worker/objects/directory-path.test.ts index bdd071401..88400983c 100644 --- a/apps/backend/src/worker/objects/directory-path.test.ts +++ b/apps/backend/src/worker/objects/directory-path.test.ts @@ -6,7 +6,7 @@ */ import { Database } from "@hazel/db" import { CurrentUser } from "@hazel/domain" -import type { ChannelId, NotificationId, OrganizationId, UserId } from "@hazel/schema" +import type { BotId, ChannelId, NotificationId, OrganizationId, UserId } from "@hazel/schema" import { makeFakeSocket } from "@hazel/sync-do/testing" import { Cause, Effect, Exit, Option, type Scope } from "effect" import { Hex } from "effect/encoding" @@ -15,7 +15,12 @@ import { decodeExit, encodePayload, type HazelRpcTag, rpcFor } from "../../rpc/d import { Directory } from "../../services/directory" import { callObject } from "./calls" import { channelTarget, orgTarget, userTarget } from "./kinds" -import { hashInstallCode, OrganizationUsersCall, RedeemBotInstallGrantCall } from "./org/bot-install-grants" +import { + hashInstallCode, + OrganizationChannelsCall, + OrganizationUsersCall, + RedeemBotInstallGrantCall, +} from "./org/bot-install-grants" import { ObjectStubs } from "./protocol" import { encodeInvokePrincipal } from "./principal" import { makeTestObjects, type TestObject, type TestObjects } from "./testing" @@ -128,7 +133,7 @@ const directoryRows = (objects: TestObjects, statement: string) => Effect.orDie, ) -const createChannel = (org: TestObject, s: Seed, name: string) => +const createChannel = (org: TestObject, s: Seed, name: string, type: "public" | "private" = "public") => Effect.map( call( org, @@ -137,7 +142,7 @@ const createChannel = (org: TestObject, s: Seed, name: string) => { name, icon: null, - type: "public", + type, organizationId: s.orgId, parentChannelId: null, sectionId: null, @@ -408,6 +413,50 @@ describe("directory path", () => { }), )) + it("a bot lists the channels it can post in: public ones, and private ones it is in", () => + run( + Effect.gen(function* () { + const objects = yield* makeTestObjects + const { seed: s, org } = yield* seed(objects) + const created = ok<{ data: { id: string; userId: string } }>( + yield* call( + org, + s.orgId, + "bot.create", + { name: "Maple", scopes: ["channels:read"], organizationId: s.orgId }, + s.owner, + ), + ) + const botId = created.data.id as BotId + const botUserId = created.data.userId as UserId + const general = yield* createChannel(org, s, "general") + // The bot is added to one private channel; the update recomputes its access. + const incidents = yield* createChannel(org, s, "incidents", "private") + org.storage.db + .prepare(`INSERT INTO channel_members (channelId, userId) VALUES (?, ?)`) + .run(incidents, botUserId) + ok(yield* call(org, s.orgId, "channel.update", { id: incidents, name: "incidents" }, s.owner)) + yield* createChannel(org, s, "leadership", "private") + const gone = yield* createChannel(org, s, "archive") + ok(yield* call(org, s.orgId, "channel.delete", { id: gone }, s.owner)) + + const list = (orgId: OrganizationId) => + callObject(OrganizationChannelsCall, orgTarget(orgId), { botId, botUserId }).pipe( + Effect.provideService(ObjectStubs, objects.stubs), + Effect.provideContext(objects.directory), + Effect.exit, + ) + expect(ok(yield* list(s.orgId))).toEqual([ + { id: general, name: "general", private: false }, + { id: incidents, name: "incidents", private: true }, + ]) + + // An org the bot is not installed in refuses. + const stranger = yield* seed(objects) + expect(errorTag(yield* list(stranger.seed.orgId))).toBe("BotNotInstalledError") + }), + )) + it("invitation.accept joins the org and #general, and only once", () => run( Effect.gen(function* () { diff --git a/apps/backend/src/worker/objects/org/bot-install-grants.ts b/apps/backend/src/worker/objects/org/bot-install-grants.ts index c19242428..1e1d99e76 100644 --- a/apps/backend/src/worker/objects/org/bot-install-grants.ts +++ b/apps/backend/src/worker/objects/org/bot-install-grants.ts @@ -5,11 +5,17 @@ * org's own store holds the grant, so a code works the moment the consent page hands it out. * * Also the public bot API's user lookup (`GET /api/v1/organizations/:id/users`), read from the - * org's `users` projection. + * org's `users` projection, and its channel list (`GET /api/v1/organizations/:id/channels`), read + * from the bot user's `channel_access`. */ import { Database, SQLite, schema } from "@hazel/db" import { BOT_INSTALL_GRANT_TTL_MS } from "@hazel/domain/bot-install" -import { ApiV1User, BotInstallGrantInvalidError, BotNotInstalledError } from "@hazel/domain/http" +import { + ApiV1Channel, + ApiV1User, + BotInstallGrantInvalidError, + BotNotInstalledError, +} from "@hazel/domain/http" import { BotId, OrganizationId, UserId } from "@hazel/schema" import { Effect, Option, Schema } from "effect" import { Hex } from "effect/encoding" @@ -121,23 +127,33 @@ export const OrganizationUsersCall = defineCall("org.botUsers", "org", { error: BotNotInstalledError, }) +/** The bot's user is a member here: the bot is installed in this org. */ +const requireBotMember = Effect.fn("OrgObject.requireBotMember")(function* (input: { + readonly botId: BotId + readonly botUserId: UserId +}) { + const organizationId = yield* requireOrganizationId + const db = yield* Database.Database + const membership = yield* db.first( + SQLite.from(schema.organizationMembersTable) + .select("id") + .where(($) => [ + $.organizationId.eq(organizationId), + $.userId.eq(input.botUserId), + $.deletedAt.isNull(), + ]) + .limit(1), + ) + if (Option.isNone(membership)) + return yield* new BotNotInstalledError({ botId: input.botId, orgId: organizationId }) + return organizationId +}) + /** The org's people (and bots) among `ids`, for a bot that is a member here. */ export const organizationUsers = Effect.fn("OrgObject.botUsers")( function* (payload: typeof OrganizationUsersCall.payload.Type) { - const organizationId = yield* requireOrganizationId + yield* requireBotMember(payload) const db = yield* Database.Database - const membership = yield* db.first( - SQLite.from(schema.organizationMembersTable) - .select("id") - .where(($) => [ - $.organizationId.eq(organizationId), - $.userId.eq(payload.botUserId), - $.deletedAt.isNull(), - ]) - .limit(1), - ) - if (Option.isNone(membership)) - return yield* new BotNotInstalledError({ botId: payload.botId, orgId: organizationId }) if (payload.ids.length === 0) return [] const users = yield* db.run( SQLite.from(schema.usersTable) @@ -154,3 +170,40 @@ export const organizationUsers = Effect.fn("OrgObject.botUsers")( }, Effect.catchTag("DatabaseError", (error) => Effect.die(error)), ) + +export const OrganizationChannelsCall = defineCall("org.botChannels", "org", { + payload: Schema.Struct({ botId: BotId, botUserId: UserId }), + success: Schema.Array(ApiV1Channel), + error: BotNotInstalledError, +}) + +/** + * The channels the bot can post in here, by name: its `channel_access` (every public channel, and + * the private ones it is a member of), without threads, direct messages or deleted channels. + */ +export const organizationChannels = Effect.fn("OrgObject.botChannels")( + function* (payload: typeof OrganizationChannelsCall.payload.Type) { + const organizationId = yield* requireBotMember(payload) + const db = yield* Database.Database + const access = yield* db.run( + SQLite.from(schema.channelAccessTable) + .select("channelId") + .where(($) => [$.organizationId.eq(organizationId), $.userId.eq(payload.botUserId)]), + ) + if (access.length === 0) return [] + const channels = yield* db.run( + SQLite.from(schema.channelsTable) + .select("id", "name", "type") + .where(($) => [ + $.id.in_(...access.map((row) => row.channelId)), + $.type.in_("public", "private"), + $.deletedAt.isNull(), + ]), + ) + yield* Effect.annotateCurrentSpan({ "org.bot_channels": channels.length }) + return channels + .map((channel) => ({ id: channel.id, name: channel.name, private: channel.type === "private" })) + .sort((a, b) => a.name.localeCompare(b.name)) + }, + Effect.catchTag("DatabaseError", (error) => Effect.die(error)), +) diff --git a/packages/domain/src/http/api-v1/bots.ts b/packages/domain/src/http/api-v1/bots.ts index 4303025e0..8391a8d7a 100644 --- a/packages/domain/src/http/api-v1/bots.ts +++ b/packages/domain/src/http/api-v1/bots.ts @@ -9,7 +9,7 @@ import { ChannelNotFoundError } from "./messages" /** * Public API v1 for bots that live outside Hazel (a service holding a bot token): redeeming an * install grant (`@hazel/domain/bot-install`), opening a thread on a message, and reading the - * people in an organization the bot is installed in. Auth: `Authorization: Bearer hzl_bot_…`. + * people and channels of an organization the bot is installed in. Auth: `Authorization: Bearer hzl_bot_…`. */ // ============ MODELS ============ @@ -75,6 +75,19 @@ export class ApiV1UsersResponse extends Schema.Class("ApiV1U data: Schema.Array(ApiV1User), }) {} +export const ApiV1Channel = Schema.Struct({ + id: ChannelId, + name: Schema.String, + /** Private channels are listed only when the bot has been added to them. */ + private: Schema.Boolean, +}) +export type ApiV1Channel = typeof ApiV1Channel.Type + +export class ApiV1ChannelsResponse extends Schema.Class("ApiV1ChannelsResponse")({ + /** The channels the bot can post in, by name. Threads and direct messages are left out. */ + data: Schema.Array(ApiV1Channel), +}) {} + // ============ API GROUP ============ export class BotsApiGroup extends HttpApiGroup.make("api-v1-bots") @@ -122,4 +135,18 @@ export class BotsApiGroup extends HttpApiGroup.make("api-v1-bots") }), ), ) + .add( + HttpApiEndpoint.get("listOrganizationChannels", "/organizations/:organizationId/channels", { + params: { organizationId: OrganizationId }, + success: ApiV1ChannelsResponse, + error: [UnauthorizedError, BotNotInstalledError, InternalServerError], + }).annotateMerge( + OpenApi.annotations({ + title: "List Organization Channels", + description: + "The channels the bot can post in, in an organization it is installed in: every public channel, and the private ones it was added to. Needs the `channels:read` scope.", + summary: "List organization channels", + }), + ), + ) .prefix("/api/v1") {} From 9ae2276ed231e317698d5484f7a27563e78e9b90 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 18:13:16 +0100 Subject: [PATCH 2/4] fix(api): give the bot channel list its own path, read it in one join GET /organizations/:organizationId/channels is already the integrations group's OAuth endpoint, so the bot's list moves to /organizations/:organizationId/bot-channels. A test now fails when two endpoints in HazelApi share a method and path. The list was an IN over the bot's channel ids, which a Durable Object refuses past 100 bound parameters; it is one join over channel_access and channels now. --- .../worker/objects/org/bot-install-grants.ts | 22 +++++++++---------- packages/domain/src/http/api-v1/bots.ts | 2 +- packages/domain/src/http/api.test.ts | 19 ++++++++++++++++ 3 files changed, 30 insertions(+), 13 deletions(-) create mode 100644 packages/domain/src/http/api.test.ts diff --git a/apps/backend/src/worker/objects/org/bot-install-grants.ts b/apps/backend/src/worker/objects/org/bot-install-grants.ts index 1e1d99e76..ab0354204 100644 --- a/apps/backend/src/worker/objects/org/bot-install-grants.ts +++ b/apps/backend/src/worker/objects/org/bot-install-grants.ts @@ -5,7 +5,7 @@ * org's own store holds the grant, so a code works the moment the consent page hands it out. * * Also the public bot API's user lookup (`GET /api/v1/organizations/:id/users`), read from the - * org's `users` projection, and its channel list (`GET /api/v1/organizations/:id/channels`), read + * org's `users` projection, and its channel list (`GET /api/v1/organizations/:id/bot-channels`), read * from the bot user's `channel_access`. */ import { Database, SQLite, schema } from "@hazel/db" @@ -185,19 +185,17 @@ export const organizationChannels = Effect.fn("OrgObject.botChannels")( function* (payload: typeof OrganizationChannelsCall.payload.Type) { const organizationId = yield* requireBotMember(payload) const db = yield* Database.Database - const access = yield* db.run( - SQLite.from(schema.channelAccessTable) - .select("channelId") - .where(($) => [$.organizationId.eq(organizationId), $.userId.eq(payload.botUserId)]), - ) - if (access.length === 0) return [] + // One join, not an `IN` over the ids: a Durable Object binds at most 100 parameters. const channels = yield* db.run( - SQLite.from(schema.channelsTable) - .select("id", "name", "type") + SQLite.from(schema.channelAccessTable, "ca") + .innerJoin(schema.channelsTable, "c", (ca, c) => + SQLite.and(c.id.eq(ca.channelId), c.deletedAt.isNull()), + ) + .select(($) => ({ id: $.c.id, name: $.c.name, type: $.c.type })) .where(($) => [ - $.id.in_(...access.map((row) => row.channelId)), - $.type.in_("public", "private"), - $.deletedAt.isNull(), + $.organizationId.eq(organizationId), + $.userId.eq(payload.botUserId), + $.c.type.in_("public", "private"), ]), ) yield* Effect.annotateCurrentSpan({ "org.bot_channels": channels.length }) diff --git a/packages/domain/src/http/api-v1/bots.ts b/packages/domain/src/http/api-v1/bots.ts index 8391a8d7a..90a63b1ec 100644 --- a/packages/domain/src/http/api-v1/bots.ts +++ b/packages/domain/src/http/api-v1/bots.ts @@ -136,7 +136,7 @@ export class BotsApiGroup extends HttpApiGroup.make("api-v1-bots") ), ) .add( - HttpApiEndpoint.get("listOrganizationChannels", "/organizations/:organizationId/channels", { + HttpApiEndpoint.get("listOrganizationChannels", "/organizations/:organizationId/bot-channels", { params: { organizationId: OrganizationId }, success: ApiV1ChannelsResponse, error: [UnauthorizedError, BotNotInstalledError, InternalServerError], diff --git a/packages/domain/src/http/api.test.ts b/packages/domain/src/http/api.test.ts new file mode 100644 index 000000000..17a7fd8c1 --- /dev/null +++ b/packages/domain/src/http/api.test.ts @@ -0,0 +1,19 @@ +import { HttpApi } from "effect/http-api" +import { describe, expect, it } from "vitest" +import { HazelApi } from "./api" + +describe("HazelApi", () => { + // A router keeps one handler per route, so a second group declaring the same one is unreachable. + it("declares each method and path once", () => { + const routes: Array = [] + HttpApi.reflect(HazelApi, { + onGroup: () => {}, + onEndpoint: ({ endpoint }) => { + routes.push(`${endpoint.method} ${endpoint.path}`) + }, + }) + const repeated = routes.filter((route, index) => routes.indexOf(route) !== index) + expect(repeated).toEqual([]) + expect(routes).toContain("GET /api/v1/organizations/:organizationId/bot-channels") + }) +}) From 30a1624b8416654a61f2663c2567aac96a2e8fa0 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 18:18:53 +0100 Subject: [PATCH 3/4] test(api): drive the bot channel list over HTTP The bot's token against /api/v1/organizations/:id/bot-channels answers the channels it can post in, and a bot without channels:read gets a 401. --- apps/backend/src/routes/object-routes.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/backend/src/routes/object-routes.test.ts b/apps/backend/src/routes/object-routes.test.ts index 97a3d8cee..6413d14f7 100644 --- a/apps/backend/src/routes/object-routes.test.ts +++ b/apps/backend/src/routes/object-routes.test.ts @@ -494,6 +494,43 @@ describe("public API (v1) for bots outside Hazel", () => { )) }) +describe("public API (v1) bot channels", () => { + it("lists the channels a bot can post in, and refuses a bot without channels:read", () => + run( + Effect.gen(function* () { + const reader = { + botId: uuid() as BotId, + botUserId: uuid() as UserId, + token: `hzl_bot_${uuid()}`, + } + const writer = { + botId: uuid() as BotId, + botUserId: uuid() as UserId, + token: `hzl_bot_${uuid()}`, + } + const world = yield* makeWorld([reader.botUserId, writer.botUserId]) + // The org's computed access for each bot user, as `syncChannelAccess` writes it. + for (const bot of [reader, writer]) { + world.org.storage.db + .prepare( + `INSERT INTO channel_access (userId, channelId, organizationId) VALUES (?, ?, ?)`, + ) + .run(bot.botUserId, world.channelId, world.orgId) + } + yield* insertDirectoryBot(world, { ...reader, scopes: ["channels:read", "messages:write"] }) + yield* insertDirectoryBot(world, { ...writer, scopes: ["messages:write", "channels:write"] }) + const path = `/api/v1/organizations/${world.orgId}/bot-channels` + + const allowed = yield* botHttp(world, reader.token) + expect(yield* allowed.json(yield* allowed.request(path))).toEqual({ + data: [{ id: world.channelId, name: "general", private: false }], + }) + const refused = yield* botHttp(world, writer.token) + expect((yield* refused.request(path)).status).toBe(401) + }), + )) +}) + describe("incoming webhooks", () => { const seedWebhook = (world: World, options: { readonly enabled: boolean }) => Effect.gen(function* () { From d8531cc7572154fe085d0ba37d71940007ddfbdf Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 18:24:55 +0100 Subject: [PATCH 4/4] fix(api): a deleted org answers a bot as not installed organization.delete soft-deletes the org but leaves its member and channel_access rows, so the bot lookups could still read a deleted org's channel names and people. requireBotMember now checks the org is live. --- .../backend/src/worker/objects/directory-path.test.ts | 6 ++++++ .../src/worker/objects/org/bot-install-grants.ts | 11 +++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/worker/objects/directory-path.test.ts b/apps/backend/src/worker/objects/directory-path.test.ts index 88400983c..8472a2648 100644 --- a/apps/backend/src/worker/objects/directory-path.test.ts +++ b/apps/backend/src/worker/objects/directory-path.test.ts @@ -454,6 +454,12 @@ describe("directory path", () => { // An org the bot is not installed in refuses. const stranger = yield* seed(objects) expect(errorTag(yield* list(stranger.seed.orgId))).toBe("BotNotInstalledError") + + // So does a deleted org, whose member and access rows are left behind. + org.storage.db + .prepare(`UPDATE organizations SET deletedAt = '2026-10-31 00:00:00.000' WHERE id = ?`) + .run(s.orgId) + expect(errorTag(yield* list(s.orgId))).toBe("BotNotInstalledError") }), )) diff --git a/apps/backend/src/worker/objects/org/bot-install-grants.ts b/apps/backend/src/worker/objects/org/bot-install-grants.ts index ab0354204..da5592cc4 100644 --- a/apps/backend/src/worker/objects/org/bot-install-grants.ts +++ b/apps/backend/src/worker/objects/org/bot-install-grants.ts @@ -127,13 +127,20 @@ export const OrganizationUsersCall = defineCall("org.botUsers", "org", { error: BotNotInstalledError, }) -/** The bot's user is a member here: the bot is installed in this org. */ +/** The org is live and the bot's user is a member here: the bot is installed in this org. */ const requireBotMember = Effect.fn("OrgObject.requireBotMember")(function* (input: { readonly botId: BotId readonly botUserId: UserId }) { const organizationId = yield* requireOrganizationId const db = yield* Database.Database + // A deleted org keeps its member and access rows, so it is checked on its own. + const organization = yield* db.first( + SQLite.from(schema.organizationsTable) + .select("id") + .where(($) => [$.id.eq(organizationId), $.deletedAt.isNull()]) + .limit(1), + ) const membership = yield* db.first( SQLite.from(schema.organizationMembersTable) .select("id") @@ -144,7 +151,7 @@ const requireBotMember = Effect.fn("OrgObject.requireBotMember")(function* (inpu ]) .limit(1), ) - if (Option.isNone(membership)) + if (Option.isNone(organization) || Option.isNone(membership)) return yield* new BotNotInstalledError({ botId: input.botId, orgId: organizationId }) return organizationId })