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
3 changes: 2 additions & 1 deletion apps/backend/src/http.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Layer } from "effect"
import { HttpApiBuilder } from "effect/http-api"
import { HazelApi } from "./api"
import { HttpApiV1IntegrationsLive, HttpMessagesApiLive } from "./routes/api-v1"
import { HttpApiV1BotsLive, HttpApiV1IntegrationsLive, HttpMessagesApiLive } from "./routes/api-v1"
import { HttpKlipyLive } from "./routes/klipy.http"
import { HttpBotCommandsLive } from "./routes/bot-commands.http"
import { HttpIncomingWebhookLive } from "./routes/incoming-webhooks.http"
Expand All @@ -18,6 +18,7 @@ export const HttpApiRoutes = HttpApiBuilder.layer(HazelApi).pipe(
Layer.provide(HttpRootLive),
Layer.provide(HttpMessagesApiLive),
Layer.provide(HttpApiV1IntegrationsLive),
Layer.provide(HttpApiV1BotsLive),
Layer.provide(HttpBotCommandsLive),
Layer.provide(HttpIntegrationLive),
Layer.provide(HttpT3CodeLive),
Expand Down
64 changes: 64 additions & 0 deletions apps/backend/src/routes/api-v1/bot-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* The public bot API's authentication: `Authorization: Bearer hzl_bot_…`, matched by its SHA-256
* against the directory's `bots`. A bot acts as its machine user.
*/
import { HttpServerRequest } from "effect/http"
import { BotRepo } from "@hazel/backend-core"
import { CurrentUser, UnauthorizedError } from "@hazel/domain"
import { ApiScope } from "@hazel/domain/scopes"
import type { UserId } from "@hazel/schema"
import { Effect, Option, Schema } from "effect"

/** Hash a token using SHA-256 (Web Crypto API). */
export async function hashToken(token: string): Promise<string> {
const data = new TextEncoder().encode(token)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}

/** Authenticate the bot from its Bearer token. */
export const authenticateBotFromToken = Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return yield* new UnauthorizedError({
message: "Missing or invalid API token",
detail: "Authorization header must be 'Bearer <token>'",
})
}
const tokenHash = yield* Effect.promise(() => hashToken(authHeader.slice(7)))
const bot = yield* (yield* BotRepo).findByTokenHash(tokenHash)
if (Option.isNone(bot)) {
return yield* new UnauthorizedError({
message: "Invalid API token",
detail: "No bot found with this token",
})
}
return bot.value
})

const isApiScope = Schema.is(ApiScope)

/**
* Bots act as their user, held to the scopes they declared — as on the RPC path
* (`rpc/middleware/auth.ts`), so a bot can do the same things over either. A bot row with no
* scopes recorded keeps a member's, as this API always gave it.
*/
export const botPrincipal = (bot: {
readonly userId: UserId
readonly name: string
readonly scopes: ReadonlyArray<string> | null
}) => ({
user: new CurrentUser.Schema({
id: bot.userId,
role: "member",
email: `bot-${bot.name}@hazel.bot`,
isOnboarded: true,
timezone: null,
organizationId: null,
settings: null,
}),
botScopes: bot.scopes === null ? null : bot.scopes.filter(isApiScope),
})
156 changes: 156 additions & 0 deletions apps/backend/src/routes/api-v1/bots.http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* 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
* authenticated by its token (`./bot-auth.ts`).
*/
import { HttpApiBuilder } from "effect/http-api"
import {
InternalServerError,
InvalidChannelRequestError,
MessageNotFoundError,
NestedThreadError,
UnauthorizedError,
} from "@hazel/domain"
import {
BotInstallGrantInvalidError,
BotInstallRedeemedResponse,
ChannelNotFoundError,
ApiV1UsersResponse,
ThreadResponse,
} from "@hazel/domain/http"
import { type ChannelResponse, CreateThreadRequest } from "@hazel/domain/rpc"
import { UserId } from "@hazel/schema"
import { Effect, Option, Schema } from "effect"
import { HazelApi } from "../../api"
import { Directory } from "../../services/directory"
import { checkMessageRateLimit } from "../../services/rate-limit-helpers"
import { callObject } from "../../worker/objects/calls"
import { forwardRpc } from "../../worker/objects/forward"
import { channelTarget, orgTarget } from "../../worker/objects/kinds"
import { MessageThreadCall } from "../../worker/objects/channel/api-messages"
import {
hashInstallCode,
ORGANIZATION_USERS_MAX_IDS,
organizationOfInstallCode,
OrganizationUsersCall,
RedeemBotInstallGrantCall,
} from "../../worker/objects/org/bot-install-grants"
import { providePrincipal } from "../../worker/objects/principal"
import { authenticateBotFromToken, botPrincipal } from "./bot-auth"

const internal = (message: string) => (cause: unknown) =>
Effect.logError(message, { cause }).pipe(
Effect.andThen(Effect.fail(new InternalServerError({ message, cause }))),
)

/** Each id on its own: a malformed one is left out like an unknown one, not the whole lookup. */
const isUserId = Schema.is(UserId)

export const HttpApiV1BotsLive = HttpApiBuilder.group(HazelApi, "api-v1-bots", (handlers) =>
Effect.succeed(
handlers
.handle("redeemBotInstall", ({ payload }) =>
Effect.gen(function* () {
const bot = yield* authenticateBotFromToken
const organizationId = organizationOfInstallCode(payload.code)
if (Option.isNone(organizationId)) {
return yield* new BotInstallGrantInvalidError({
message: "The install code is invalid, expired or already used",
})
}
const redeemed = yield* callObject(
RedeemBotInstallGrantCall,
orgTarget(organizationId.value),
{
botId: bot.id,
codeHash: yield* hashInstallCode(payload.code),
redirectUri: payload.redirectUri,
},
)
return new BotInstallRedeemedResponse(redeemed)
}).pipe(
Effect.catchTags({
DatabaseError: internal("Database error while redeeming a bot install"),
ObjectCallError: internal("The organization is unavailable"),
}),
Effect.withSpan("ApiV1.redeemBotInstall"),
),
)
.handle("createThread", ({ params, payload }) =>
Effect.gen(function* () {
const bot = yield* authenticateBotFromToken
yield* checkMessageRateLimit(bot.userId)
const index = yield* (yield* Directory).channel(params.channelId)
if (Option.isNone(index) || index.value.deletedAt !== null) {
return yield* new ChannelNotFoundError({ channelId: params.channelId })
}
const organizationId = index.value.organizationId
const host = channelTarget(index.value.hostChannelId ?? params.channelId)
const existing = yield* callObject(MessageThreadCall, host, {
userId: bot.userId,
messageId: payload.messageId,
})
if (existing !== null) {
return new ThreadResponse({
data: { id: existing, parentChannelId: params.channelId, organizationId },
})
}
// No id: `channel.createThread` derives one from the message, so racing calls open one.
const response = yield* forwardRpc<
ChannelResponse,
| MessageNotFoundError
| NestedThreadError
| InvalidChannelRequestError
| UnauthorizedError
>(
orgTarget(organizationId),
"channel.createThread",
new CreateThreadRequest({
messageId: payload.messageId,
channelId: params.channelId,
organizationId,
}),
).pipe(providePrincipal(botPrincipal(bot)))
return new ThreadResponse({
data: { id: response.data.id, parentChannelId: params.channelId, organizationId },
})
}).pipe(
Effect.catchTags({
DatabaseError: internal("Database error while creating a thread"),
ObjectCallError: internal("The channel is unavailable"),
// The message is not in this channel, or is itself in a thread.
MessageNotFoundError: () =>
Effect.fail(new ChannelNotFoundError({ channelId: params.channelId })),
NestedThreadError: () =>
Effect.fail(new ChannelNotFoundError({ channelId: params.channelId })),
InvalidChannelRequestError: () =>
Effect.fail(new ChannelNotFoundError({ channelId: params.channelId })),
}),
Effect.withSpan("ApiV1.createThread"),
),
)
.handle("listOrganizationUsers", ({ params, query }) =>
Effect.gen(function* () {
const bot = yield* authenticateBotFromToken
const raw = query.ids
.split(",")
.map((id) => id.trim())
.filter((id) => id.length > 0)
const ids = [...new Set(raw)].filter(isUserId)
const users = yield* callObject(OrganizationUsersCall, orgTarget(params.organizationId), {
botId: bot.id,
botUserId: bot.userId,
ids: ids.slice(0, ORGANIZATION_USERS_MAX_IDS),
})
return new ApiV1UsersResponse({ data: users })
}).pipe(
Effect.catchTags({
DatabaseError: internal("Database error while listing users"),
ObjectCallError: internal("The organization is unavailable"),
}),
Effect.withSpan("ApiV1.listOrganizationUsers"),
),
),
),
)
1 change: 1 addition & 0 deletions apps/backend/src/routes/api-v1/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { HttpMessagesApiLive } from "./messages.http"
export { HttpApiV1IntegrationsLive } from "./integrations.http"
export { HttpApiV1BotsLive } from "./bots.http"
51 changes: 3 additions & 48 deletions apps/backend/src/routes/api-v1/messages.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@
* routed.
*/
import { HttpApiBuilder } from "effect/http-api"
import { HttpServerRequest } from "effect/http"
import { BotRepo } from "@hazel/backend-core"
import { CurrentUser, InternalServerError, MessageNotFoundError, UnauthorizedError } from "@hazel/domain"
import { InternalServerError, MessageNotFoundError } from "@hazel/domain"
import {
ChannelNotFoundError,
DeleteMessageResponse,
Expand All @@ -23,9 +21,10 @@ import {
} from "@hazel/domain/http"
import type { MessageResponse as RpcMessageResponse } from "@hazel/domain/rpc"
import type { MessageReaction } from "@hazel/domain/models"
import type { ChannelId, MessageId, TransactionId, UserId } from "@hazel/schema"
import type { ChannelId, MessageId, TransactionId } from "@hazel/schema"
import { Effect, Option } from "effect"
import { HazelApi } from "../../api"
import { authenticateBotFromToken, botPrincipal } from "./bot-auth"
import { Directory } from "../../services/directory"
import { HtmlRenders } from "../../services/html-renders"
import { checkMessageRateLimit } from "../../services/rate-limit-helpers"
Expand All @@ -36,50 +35,6 @@ import { forwardRpc } from "../../worker/objects/forward"
import { channelTarget } from "../../worker/objects/kinds"
import { providePrincipal } from "../../worker/objects/principal"

/** Hash a token using SHA-256 (Web Crypto API). */
async function hashToken(token: string): Promise<string> {
const data = new TextEncoder().encode(token)
const hashBuffer = await crypto.subtle.digest("SHA-256", data)
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
}

/** Authenticate the bot from its Bearer token. */
const authenticateBotFromToken = Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const authHeader = request.headers.authorization
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return yield* new UnauthorizedError({
message: "Missing or invalid API token",
detail: "Authorization header must be 'Bearer <token>'",
})
}
const tokenHash = yield* Effect.promise(() => hashToken(authHeader.slice(7)))
const bot = yield* (yield* BotRepo).findByTokenHash(tokenHash)
if (Option.isNone(bot)) {
return yield* new UnauthorizedError({
message: "Invalid API token",
detail: "No bot found with this token",
})
}
return bot.value
})

/** Bots act as their user, with a member's scopes (as the API always did). */
const botPrincipal = (bot: { readonly userId: UserId; readonly name: string }) => ({
user: new CurrentUser.Schema({
id: bot.userId,
role: "member",
email: `bot-${bot.name}@hazel.bot`,
isOnboarded: true,
timezone: null,
organizationId: null,
settings: null,
}),
botScopes: null,
})

/** The `ChannelObject` holding `channelId`'s messages (a Connect mount's host). */
const hostOf = (channelId: ChannelId) =>
Effect.gen(function* () {
Expand Down
Loading
Loading