From 5d886bbb6ce5dc8b826e1526f5422e6cf08daaf2 Mon Sep 17 00:00:00 2001 From: Ben Sabic <27636870+bensabic@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:46:56 +1000 Subject: [PATCH] Add credential resolvers to Liveblocks Chat SDK adapter (#3670) Co-authored-by: Ben Sabic Co-authored-by: Nimesh Nayaju --- CHANGELOG.md | 6 + .../liveblocks-chat-sdk-adapter/README.md | 57 +++- .../src/__tests__/index.test.ts | 293 +++++++++++++++++- .../src/adapter.ts | 166 ++++++++-- .../liveblocks-chat-sdk-adapter/src/index.ts | 2 + 5 files changed, 474 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78178fa9dcf..bd1c55742b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## vNEXT (not yet released) +### `@liveblocks/chat-sdk-adapter` + +- Allow `apiKey` and `webhookSecret` to be resolved per request, and add custom + `webhookVerifier` support for rotated credentials and webhook-forwarding + infrastructure such as Vercel Connect. + ## v3.24.1 ### `@liveblocks/react` diff --git a/packages/liveblocks-chat-sdk-adapter/README.md b/packages/liveblocks-chat-sdk-adapter/README.md index 80298dfd888..80d65df5f38 100644 --- a/packages/liveblocks-chat-sdk-adapter/README.md +++ b/packages/liveblocks-chat-sdk-adapter/README.md @@ -35,19 +35,54 @@ const adapter = createLiveblocksAdapter({ ## Configuration -| Option | Type | Default | Description | -| ------------------- | ---------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `apiKey` | `string` | — | Liveblocks secret key (`sk_...`) for REST API calls | -| `webhookSecret` | `string` | — | Webhook signing secret (`whsec_...`) from the dashboard | -| `botUserId` | `string` | — | User ID used when the bot creates, edits, or reacts to comments; must match your app’s user identifiers | -| `botUserName` | `string` | `"liveblocks-bot"` | Display name for the bot | -| `resolveUsers` | `function` | — | Resolves user IDs for @mentions; return one entry per input id in order, or `undefined` to skip (see TSDoc types) | -| `resolveGroupsInfo` | `function` | — | Resolves group IDs for @mentions; same ordering rules as `resolveUsers` | -| `logger` | `Logger` | `ConsoleLogger("info")` child | Chat SDK–compatible logger | +| Option | Type | Default | Description | +| ------------------- | -------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `apiKey` | `string \| function` | — | Liveblocks secret key (`sk_...`) or resolver invoked for every REST API call | +| `webhookSecret` | `string \| function` | — | Webhook signing secret (`whsec_...`) or resolver invoked for every webhook; required without `webhookVerifier` | +| `webhookVerifier` | `function` | — | Custom inbound verifier used instead of `webhookSecret` | +| `botUserId` | `string` | — | User ID used when the bot creates, edits, or reacts to comments; must match your app’s user identifiers | +| `botUserName` | `string` | `"liveblocks-bot"` | Display name for the bot | +| `resolveUsers` | `function` | — | Resolves user IDs for @mentions; return one entry per input id in order, or `undefined` to skip (see TSDoc types) | +| `resolveGroupsInfo` | `function` | — | Resolves group IDs for @mentions; same ordering rules as `resolveUsers` | +| `logger` | `Logger` | `ConsoleLogger("info")` child | Chat SDK–compatible logger | Resolver return types follow `@liveblocks/core` user and group metadata shapes (`U["info"]`, `DGI`). +### Dynamic credentials and custom webhook verification + +`apiKey` and `webhookSecret` accept synchronous or asynchronous resolver +functions. The adapter invokes them for every outbound REST API call or inbound +webhook request, so credentials can be fetched lazily or rotated: + +```typescript +const adapter = createLiveblocksAdapter({ + apiKey: () => secrets.get("liveblocks-api-key"), + webhookSecret: () => secrets.get("liveblocks-webhook-secret"), + botUserId: "my-bot-user", +}); +``` + +For webhook-forwarding infrastructure that verifies requests itself, pass a +`webhookVerifier` instead of `webhookSecret`: + +```typescript +const adapter = createLiveblocksAdapter({ + apiKey: () => secrets.get("liveblocks-api-key"), + webhookVerifier: async (request, body) => { + return verifyForwardedWebhook(request, body); + }, + botUserId: "my-bot-user", +}); +``` + +The verifier receives the incoming `Request` and raw body. Return a truthy value +to accept the request. A returned string replaces the body used for event +parsing; throwing or returning a falsy value rejects the request with **401**. +When both options are provided, `webhookVerifier` takes precedence over +`webhookSecret`. This supports custom secret stores and forwarding services, +including a future Vercel Connect integration. + ### Resolving mentions When comments contain @mentions, provide `resolveUsers` and optional @@ -110,8 +145,8 @@ export async function POST(request: Request) { } ``` -The adapter verifies signatures with `webhookSecret`; invalid requests get -**401**. +The adapter verifies signatures with `webhookSecret`, or uses `webhookVerifier` +when configured. Invalid requests get **401**. > **Serverless:** Passing `waitUntil` (e.g. on Vercel) lets work continue after > the response is sent. diff --git a/packages/liveblocks-chat-sdk-adapter/src/__tests__/index.test.ts b/packages/liveblocks-chat-sdk-adapter/src/__tests__/index.test.ts index 57e62a42298..6afe09af75e 100644 --- a/packages/liveblocks-chat-sdk-adapter/src/__tests__/index.test.ts +++ b/packages/liveblocks-chat-sdk-adapter/src/__tests__/index.test.ts @@ -17,6 +17,8 @@ import { getRoomIdFromChannelId, LiveblocksAdapter, type LiveblocksAdapterConfig, + type LiveblocksSecretValue, + type LiveblocksWebhookVerifier, } from "../adapter"; type AdapterConfig = LiveblocksAdapterConfig; @@ -31,6 +33,8 @@ const mocks = vi.hoisted(() => { } return { MockLiveblocksError, + mockLiveblocksConstructor: vi.fn(), + mockWebhookHandlerConstructor: vi.fn(), mockVerifyRequest: vi.fn(), mockGetComment: vi.fn(), mockGetThread: vi.fn(), @@ -47,7 +51,8 @@ const mocks = vi.hoisted(() => { }); vi.mock("@liveblocks/node", () => ({ - Liveblocks: vi.fn(function () { + Liveblocks: vi.fn(function (options: { secret: string }) { + mocks.mockLiveblocksConstructor(options); return { getComment: mocks.mockGetComment, getThread: mocks.mockGetThread, @@ -62,7 +67,8 @@ vi.mock("@liveblocks/node", () => ({ getAttachment: mocks.mockGetAttachment, }; }), - WebhookHandler: vi.fn(function () { + WebhookHandler: vi.fn(function (secret: string) { + mocks.mockWebhookHandlerConstructor(secret); return { verifyRequest: mocks.mockVerifyRequest, }; @@ -71,17 +77,32 @@ vi.mock("@liveblocks/node", () => ({ })); function createDummyAdapter(options?: { + apiKey?: LiveblocksSecretValue; botUserId?: string; resolveUsers?: AdapterConfig["resolveUsers"]; resolveGroupsInfo?: AdapterConfig["resolveGroupsInfo"]; + webhookSecret?: LiveblocksSecretValue; + webhookVerifier?: LiveblocksWebhookVerifier; }) { - return new LiveblocksAdapter({ - apiKey: "sk_test_xxx", - webhookSecret: "whsec_test_xxx", + const baseConfig = { + apiKey: options?.apiKey ?? "sk_test_xxx", botUserId: options?.botUserId ?? "bot-user-id", botUserName: "Bot", resolveUsers: options?.resolveUsers, resolveGroupsInfo: options?.resolveGroupsInfo, + }; + + if (options?.webhookVerifier !== undefined) { + return new LiveblocksAdapter({ + ...baseConfig, + webhookSecret: options.webhookSecret, + webhookVerifier: options.webhookVerifier, + }); + } + + return new LiveblocksAdapter({ + ...baseConfig, + webhookSecret: options?.webhookSecret ?? "whsec_test_xxx", }); } @@ -130,6 +151,8 @@ function createDummyThread( describe("LiveblocksAdapter", () => { beforeEach(() => { + mocks.mockLiveblocksConstructor.mockReset(); + mocks.mockWebhookHandlerConstructor.mockReset(); mocks.mockVerifyRequest.mockReset(); mocks.mockGetComment.mockReset(); mocks.mockGetThread.mockReset(); @@ -266,7 +289,267 @@ describe("LiveblocksAdapter", () => { }); }); + describe("credential resolvers", () => { + test("resolves a fresh synchronous API key for every REST call", async () => { + const apiKey = vi + .fn<() => string>() + .mockReturnValueOnce("sk_first") + .mockReturnValueOnce("sk_second"); + const adapter = createDummyAdapter({ apiKey }); + const returnedComment = createDummyComment({ + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "Hello" }] }], + }, + }); + mocks.mockCreateComment.mockResolvedValue(returnedComment); + + await adapter.postMessage("liveblocks:room_1:th_1", "First"); + await adapter.postMessage("liveblocks:room_1:th_1", "Second"); + + expect(apiKey).toHaveBeenCalledTimes(2); + expect(mocks.mockLiveblocksConstructor).toHaveBeenNthCalledWith(1, { + secret: "sk_first", + }); + expect(mocks.mockLiveblocksConstructor).toHaveBeenNthCalledWith(2, { + secret: "sk_second", + }); + }); + + test("awaits an asynchronous API key resolver", async () => { + const apiKey = vi.fn(() => Promise.resolve("sk_async")); + const adapter = createDummyAdapter({ apiKey }); + + await adapter.deleteMessage("liveblocks:room_1:th_1", "cm_1"); + + expect(apiKey).toHaveBeenCalledTimes(1); + expect(mocks.mockLiveblocksConstructor).toHaveBeenCalledWith({ + secret: "sk_async", + }); + }); + + test("propagates API key resolver errors", async () => { + const error = new Error("could not resolve API key"); + const adapter = createDummyAdapter({ + apiKey: () => Promise.reject(error), + }); + + await expect( + adapter.deleteMessage("liveblocks:room_1:th_1", "cm_1") + ).rejects.toBe(error); + expect(mocks.mockDeleteComment).not.toHaveBeenCalled(); + }); + + test("resolves a fresh API key when attachment data is fetched", async () => { + const apiKey = vi + .fn<() => string>() + .mockReturnValueOnce("sk_thread") + .mockReturnValueOnce("sk_attachment"); + const adapter = createDummyAdapter({ apiKey }); + mocks.mockGetThread.mockResolvedValue( + createDummyThread([ + createDummyComment({ + attachments: [ + { + type: "attachment", + id: "att_1", + name: "test.txt", + mimeType: "text/plain", + size: 5, + }, + ], + body: { + version: 1, + content: [{ type: "paragraph", children: [{ text: "x" }] }], + }, + }), + ]) + ); + mocks.mockGetAttachment.mockResolvedValue({ + url: "https://storage.example.com/att_1", + }); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("hello", { status: 200 })); + + const { messages } = await adapter.fetchMessages( + "liveblocks:room_1:th_1" + ); + await messages[0]!.attachments[0]!.fetchData!(); + + expect(apiKey).toHaveBeenCalledTimes(2); + expect(mocks.mockLiveblocksConstructor).toHaveBeenNthCalledWith(2, { + secret: "sk_attachment", + }); + fetchSpy.mockRestore(); + }); + + test("resolves a fresh synchronous webhook secret for every request", async () => { + const webhookSecret = vi + .fn<() => string>() + .mockReturnValueOnce("whsec_first") + .mockReturnValueOnce("whsec_second"); + const adapter = createDummyAdapter({ webhookSecret }); + mocks.mockVerifyRequest.mockReturnValue({ + type: "userEntered", + data: {}, + }); + + await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + + expect(webhookSecret).toHaveBeenCalledTimes(2); + expect(mocks.mockWebhookHandlerConstructor).toHaveBeenNthCalledWith( + 1, + "whsec_first" + ); + expect(mocks.mockWebhookHandlerConstructor).toHaveBeenNthCalledWith( + 2, + "whsec_second" + ); + }); + + test("awaits an asynchronous webhook secret resolver", async () => { + const webhookSecret = vi.fn(() => Promise.resolve("whsec_async")); + const adapter = createDummyAdapter({ webhookSecret }); + mocks.mockVerifyRequest.mockReturnValue({ + type: "userEntered", + data: {}, + }); + + const response = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + + expect(response.status).toBe(200); + expect(mocks.mockWebhookHandlerConstructor).toHaveBeenCalledWith( + "whsec_async" + ); + }); + }); + describe("handleWebhook", () => { + test("accepts a request verified by a custom webhook verifier", async () => { + const body = JSON.stringify({ type: "userEntered", data: {} }); + const request = new Request("https://example.com/webhook", { + method: "POST", + body, + }); + const webhookVerifier = vi.fn(() => true); + const adapter = createDummyAdapter({ webhookVerifier }); + + const response = await adapter.handleWebhook(request); + + expect(response.status).toBe(200); + expect(webhookVerifier).toHaveBeenCalledWith(request, body); + }); + + test("awaits an asynchronous webhook verifier", async () => { + const adapter = createDummyAdapter({ + webhookVerifier: () => Promise.resolve(true), + }); + + const response = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify({ type: "userEntered", data: {} }), + }) + ); + + expect(response.status).toBe(200); + }); + + test("returns 401 when the webhook verifier returns a falsy value", async () => { + const adapter = createDummyAdapter({ webhookVerifier: () => false }); + + const response = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + + expect(response.status).toBe(401); + }); + + test("returns 401 when the webhook verifier throws", async () => { + const adapter = createDummyAdapter({ + webhookVerifier: () => { + throw new Error("invalid token"); + }, + }); + + const response = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "{}", + }) + ); + + expect(response.status).toBe(401); + }); + + test("uses the webhook verifier instead of a configured secret", async () => { + const webhookSecret = vi.fn(() => "whsec_unused"); + const adapter = createDummyAdapter({ + webhookSecret, + webhookVerifier: () => true, + }); + + const response = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: JSON.stringify({ type: "userEntered", data: {} }), + }) + ); + + expect(response.status).toBe(200); + expect(webhookSecret).not.toHaveBeenCalled(); + expect(mocks.mockWebhookHandlerConstructor).not.toHaveBeenCalled(); + }); + + test("uses a string returned by the webhook verifier as the event body", async () => { + const adapter = createDummyAdapter({ + webhookVerifier: () => + JSON.stringify({ type: "userEntered", data: {} }), + }); + + const response = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "not JSON", + }) + ); + + expect(response.status).toBe(200); + }); + + test("returns 400 when a verified webhook body is invalid JSON", async () => { + const adapter = createDummyAdapter({ webhookVerifier: () => true }); + + const response = await adapter.handleWebhook( + new Request("https://example.com/webhook", { + method: "POST", + body: "not JSON", + }) + ); + + expect(response.status).toBe(400); + }); + test("returns 401 when verification fails", async () => { const adapter = createDummyAdapter(); mocks.mockVerifyRequest.mockImplementation(() => { diff --git a/packages/liveblocks-chat-sdk-adapter/src/adapter.ts b/packages/liveblocks-chat-sdk-adapter/src/adapter.ts index 20f54f4b196..9ab539d1bdd 100644 --- a/packages/liveblocks-chat-sdk-adapter/src/adapter.ts +++ b/packages/liveblocks-chat-sdk-adapter/src/adapter.ts @@ -56,14 +56,25 @@ import { type PhrasingContent = Paragraph["children"][number]; const ADAPTER_PREFIX = "liveblocks"; + +function normalizeSecretProvider( + value: LiveblocksSecretValue +): () => Promise { + if (typeof value === "function") { + return async () => await value(); + } + return () => Promise.resolve(value); +} + export class LiveblocksAdapter< U extends BaseUserMeta = BaseUserMeta, DGI extends BaseGroupInfo = BaseGroupInfo, > implements Adapter<{ roomId: string; threadId: string }, CommentData> { readonly name = "liveblocks"; readonly userName: string; - readonly #client: Liveblocks; - readonly #webhookHandler: WebhookHandler; + readonly #apiKey: () => Promise; + readonly #webhookSecret: (() => Promise) | undefined; + readonly #webhookVerifier: LiveblocksWebhookVerifier | undefined; readonly #resolveUsers: | (( args: ResolveUsersArgs @@ -78,8 +89,12 @@ export class LiveblocksAdapter< readonly #botUserId: string; #chat: ChatInstance | null = null; constructor(config: LiveblocksAdapterConfig) { - this.#client = new Liveblocks({ secret: config.apiKey }); - this.#webhookHandler = new WebhookHandler(config.webhookSecret); + this.#apiKey = normalizeSecretProvider(config.apiKey); + this.#webhookSecret = + config.webhookSecret === undefined + ? undefined + : normalizeSecretProvider(config.webhookSecret); + this.#webhookVerifier = config.webhookVerifier; this.#resolveUsers = config.resolveUsers; this.#resolveGroupsInfo = config.resolveGroupsInfo; this.#botUserId = config.botUserId; @@ -96,15 +111,42 @@ export class LiveblocksAdapter< request: Request, options?: WebhookOptions ): Promise { + const body = await request.text(); let event: WebhookEvent; - try { - event = this.#webhookHandler.verifyRequest({ - headers: request.headers, - rawBody: await request.text(), - }); - } catch (error) { - this.#logger.error("Failed to verify webhook request", { error }); - return new Response("Invalid webhook request", { status: 401 }); + if (this.#webhookVerifier !== undefined) { + let verified: unknown; + try { + verified = await this.#webhookVerifier(request, body); + } catch (error) { + this.#logger.error("Failed to verify webhook request", { error }); + return new Response("Invalid webhook request", { status: 401 }); + } + if (!verified) { + return new Response("Invalid webhook request", { status: 401 }); + } + + try { + event = JSON.parse( + typeof verified === "string" ? verified : body + ) as WebhookEvent; + } catch (error) { + this.#logger.error("Failed to parse webhook request", { error }); + return new Response("Invalid webhook request", { status: 400 }); + } + } else { + try { + const webhookSecret = await this.#webhookSecret?.(); + if (webhookSecret === undefined) { + throw new Error("Webhook secret is required"); + } + event = new WebhookHandler(webhookSecret).verifyRequest({ + headers: request.headers, + rawBody: body, + }); + } catch (error) { + this.#logger.error("Failed to verify webhook request", { error }); + return new Response("Invalid webhook request", { status: 401 }); + } } if (event.type === "commentCreated") { @@ -113,7 +155,8 @@ export class LiveblocksAdapter< threadId: event.data.threadId, }); - const comment = await this.#client.getComment({ + const client = await this.#getClient(); + const comment = await client.getComment({ roomId: event.data.roomId, threadId: event.data.threadId, commentId: event.data.commentId, @@ -177,7 +220,8 @@ export class LiveblocksAdapter< ): Promise> { const { roomId, threadId: threadId_liveblocks } = this.decodeThreadId(threadId); - const comment = await this.#client.createComment({ + const client = await this.#getClient(); + const comment = await client.createComment({ roomId, threadId: threadId_liveblocks, data: { @@ -196,7 +240,8 @@ export class LiveblocksAdapter< const { roomId, threadId: threadId_liveblocks } = this.decodeThreadId(threadId); - const comment = await this.#client.editComment({ + const client = await this.#getClient(); + const comment = await client.editComment({ roomId, threadId: threadId_liveblocks, commentId: messageId, @@ -211,7 +256,8 @@ export class LiveblocksAdapter< async deleteMessage(threadId: string, messageId: string): Promise { const { roomId, threadId: threadId_liveblocks } = this.decodeThreadId(threadId); - await this.#client.deleteComment({ + const client = await this.#getClient(); + await client.deleteComment({ roomId, threadId: threadId_liveblocks, commentId: messageId, @@ -226,7 +272,8 @@ export class LiveblocksAdapter< const { roomId, threadId: threadId_liveblocks } = this.decodeThreadId(threadId); - await this.#client.addCommentReaction({ + const client = await this.#getClient(); + await client.addCommentReaction({ roomId, threadId: threadId_liveblocks, commentId: messageId, @@ -247,7 +294,8 @@ export class LiveblocksAdapter< const { roomId, threadId: threadId_liveblocks } = this.decodeThreadId(threadId); - await this.#client.removeCommentReaction({ + const client = await this.#getClient(); + await client.removeCommentReaction({ roomId, threadId: threadId_liveblocks, commentId: messageId, @@ -265,7 +313,8 @@ export class LiveblocksAdapter< const { roomId, threadId: threadId_liveblocks } = this.decodeThreadId(threadId); - const thread = await this.#client.getThread({ + const client = await this.#getClient(); + const thread = await client.getThread({ roomId, threadId: threadId_liveblocks, }); @@ -299,7 +348,8 @@ export class LiveblocksAdapter< const { roomId, threadId: threadId_liveblocks } = this.decodeThreadId(threadId); - const thread = await this.#client.getThread({ + const client = await this.#getClient(); + const thread = await client.getThread({ roomId, threadId: threadId_liveblocks, }); @@ -324,7 +374,8 @@ export class LiveblocksAdapter< const { roomId, threadId: threadId_liveblocks } = this.decodeThreadId(threadId); - const comment = await this.#client.getComment({ + const client = await this.#getClient(); + const comment = await client.getComment({ roomId, threadId: threadId_liveblocks, commentId: messageId, @@ -346,7 +397,8 @@ export class LiveblocksAdapter< options?: ListThreadsOptions ): Promise> { const roomId = getRoomIdFromChannelId(channelId); - const { data } = await this.#client.getThreads({ roomId }); + const client = await this.#getClient(); + const { data } = await client.getThreads({ roomId }); const threads = data .map((thread) => { const nonDeletedComments = thread.comments @@ -394,7 +446,8 @@ export class LiveblocksAdapter< } async fetchChannelInfo(channelId: string): Promise { - const room = await this.#client.getRoom(getRoomIdFromChannelId(channelId)); + const client = await this.#getClient(); + const room = await client.getRoom(getRoomIdFromChannelId(channelId)); return { id: room.id, name: room.id, @@ -408,7 +461,8 @@ export class LiveblocksAdapter< options?: FetchOptions ): Promise> { const roomId = getRoomIdFromChannelId(channelId); - const { data } = await this.#client.getThreads({ roomId }); + const client = await this.#getClient(); + const { data } = await client.getThreads({ roomId }); const comments = data .map((thread) => { @@ -450,7 +504,8 @@ export class LiveblocksAdapter< message: AdapterPostableMessage ): Promise> { const roomId = getRoomIdFromChannelId(channelId); - const thread = await this.#client.createThread({ + const client = await this.#getClient(); + const thread = await client.createThread({ roomId, data: { comment: { @@ -524,13 +579,13 @@ export class LiveblocksAdapter< roomId: string, attachment: CommentData["attachments"][number] ): Attachment { - const client = this.#client; return { type: getAttachmentType(attachment.mimeType), name: attachment.name, mimeType: attachment.mimeType, size: attachment.size, fetchData: async () => { + const client = await this.#getClient(); const { url } = await client.getAttachment({ roomId, attachmentId: attachment.id, @@ -546,6 +601,10 @@ export class LiveblocksAdapter< }; } + async #getClient(): Promise { + return new Liveblocks({ secret: await this.#apiKey() }); + } + /** * Encodes a Liveblocks room ID and thread ID into a single thread ID string. * @@ -1311,19 +1370,29 @@ function getAttachmentType(mimeType: string): Attachment["type"] { return "file"; } -export interface LiveblocksAdapterConfig< +export type LiveblocksSecretValue = string | (() => string | Promise); + +/** + * Custom webhook verifier used in place of the Liveblocks webhook secret. + * + * Return a truthy value to accept the request. Returning a string substitutes + * the body used for event parsing. Throw or return a falsy value to reject the + * request with a 401 response. + */ +export type LiveblocksWebhookVerifier = ( + request: Request, + body: string +) => Awaitable; + +interface LiveblocksAdapterBaseConfig< U extends BaseUserMeta, DGI extends BaseGroupInfo, > { /** - * The Liveblocks secret key. Must start with "sk_". Get it from the Liveblocks dashboard: https://liveblocks.io/dashboard/apikeys + * The Liveblocks secret key, or a resolver invoked for every REST API call. + * The resolved value must start with "sk_". Get it from the Liveblocks dashboard: https://liveblocks.io/dashboard/apikeys */ - apiKey: string; - /** - * The Liveblocks webhook signing secret. Get it from the Liveblocks dashboard: https://liveblocks.io/dashboard/webhooks - * @example "whsec_wPbvQ+u3VtN2e2tRPDKchQ1tBZ3svaHLm" - */ - webhookSecret: string; + apiKey: LiveblocksSecretValue; /** * A function that returns user info from user IDs; used to resolve @user mentions in comment bodies. * This function should return an array of user info in the same order as the input user IDs, or `undefined` to skip resolution. @@ -1355,6 +1424,35 @@ export interface LiveblocksAdapterConfig< logger?: Logger; } +export type LiveblocksAdapterConfig< + U extends BaseUserMeta, + DGI extends BaseGroupInfo, +> = LiveblocksAdapterBaseConfig & + ( + | { + /** + * The Liveblocks webhook signing secret, or a resolver invoked for + * every webhook request. Get it from the Liveblocks dashboard: + * https://liveblocks.io/dashboard/webhooks + * @example "whsec_wPbvQ+u3VtN2e2tRPDKchQ1tBZ3svaHLm" + */ + webhookSecret: LiveblocksSecretValue; + /** + * A custom webhook verifier. When provided, it takes precedence over + * `webhookSecret`. + */ + webhookVerifier?: LiveblocksWebhookVerifier; + } + | { + webhookSecret?: LiveblocksSecretValue; + /** + * A custom webhook verifier used instead of native Liveblocks + * signature verification. + */ + webhookVerifier: LiveblocksWebhookVerifier; + } + ); + /** * Creates a {@link LiveblocksAdapter} configured for Liveblocks Comments. */ diff --git a/packages/liveblocks-chat-sdk-adapter/src/index.ts b/packages/liveblocks-chat-sdk-adapter/src/index.ts index 5d700624fa2..22e91f3b149 100644 --- a/packages/liveblocks-chat-sdk-adapter/src/index.ts +++ b/packages/liveblocks-chat-sdk-adapter/src/index.ts @@ -2,4 +2,6 @@ export { createLiveblocksAdapter, type LiveblocksAdapter, type LiveblocksAdapterConfig, + type LiveblocksSecretValue, + type LiveblocksWebhookVerifier, } from "./adapter";