diff --git a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap index 38ddcdf85f..3df7eb619f 100644 --- a/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap +++ b/apps/builder/__tests__/__snapshots__/public-spec-operations.test.ts.snap @@ -402,6 +402,36 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "analytics.uniqueConversationsByAdmin", "path": "/v1/analytics/unique-conversations-by-admin", }, + { + "method": "POST", + "operationId": "apiTokens.create", + "path": "/v1/api-tokens", + }, + { + "method": "DELETE", + "operationId": "apiTokens.delete", + "path": "/v1/api-tokens/{id}", + }, + { + "method": "GET", + "operationId": "apiTokens.get", + "path": "/v1/api-tokens/{id}", + }, + { + "method": "GET", + "operationId": "apiTokens.list", + "path": "/v1/api-tokens", + }, + { + "method": "POST", + "operationId": "apiTokens.rotate", + "path": "/v1/api-tokens/{id}/rotate", + }, + { + "method": "PATCH", + "operationId": "apiTokens.update", + "path": "/v1/api-tokens/{id}", + }, { "method": "POST", "operationId": "appointmentCalendars.create", @@ -482,6 +512,11 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "appointments.list", "path": "/v1/appointments", }, + { + "method": "GET", + "operationId": "auditLogs.list", + "path": "/v1/audit-logs", + }, { "method": "PUT", "operationId": "botFields.bulkUpdate", @@ -1827,6 +1862,56 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "templateMessages.list", "path": "/v1/template-messages", }, + { + "method": "POST", + "operationId": "templates.create", + "path": "/v1/templates", + }, + { + "method": "DELETE", + "operationId": "templates.delete", + "path": "/v1/templates/{id}", + }, + { + "method": "GET", + "operationId": "templates.get", + "path": "/v1/templates/{id}", + }, + { + "method": "POST", + "operationId": "templates.install", + "path": "/v1/templates/installations", + }, + { + "method": "GET", + "operationId": "templates.list", + "path": "/v1/templates", + }, + { + "method": "GET", + "operationId": "templates.listInstallations", + "path": "/v1/templates/installations", + }, + { + "method": "GET", + "operationId": "templates.listSelectableResources", + "path": "/v1/templates/selectable-resources", + }, + { + "method": "PATCH", + "operationId": "templates.update", + "path": "/v1/templates/{id}", + }, + { + "method": "PATCH", + "operationId": "templates.updateInstallationAutoUpdate", + "path": "/v1/templates/installations/{id}/auto-update", + }, + { + "method": "PATCH", + "operationId": "templates.updateShareSettings", + "path": "/v1/templates/{id}/share-settings", + }, { "method": "POST", "operationId": "triggers.create", @@ -1922,16 +2007,66 @@ exports[`public API spec — operation naming guard > operation list (operationI "operationId": "webhooks.list", "path": "/v1/webhooks", }, + { + "method": "DELETE", + "operationId": "workspace.cancelDeletion", + "path": "/v1/workspace/deletion", + }, + { + "method": "GET", + "operationId": "workspace.get", + "path": "/v1/workspace", + }, + { + "method": "POST", + "operationId": "workspace.refreshChannelTokens", + "path": "/v1/workspace/channel-tokens/refresh", + }, + { + "method": "POST", + "operationId": "workspace.scheduleDeletion", + "path": "/v1/workspace/deletion", + }, + { + "method": "PATCH", + "operationId": "workspace.update", + "path": "/v1/workspace", + }, + { + "method": "PUT", + "operationId": "workspace.updateStatus", + "path": "/v1/workspace/status", + }, + { + "method": "PUT", + "operationId": "workspace.updateSupportAccess", + "path": "/v1/workspace/support-access", + }, { "method": "GET", "operationId": "workspaceMembers.get", "path": "/v1/members/{memberId}", }, + { + "method": "POST", + "operationId": "workspaceMembers.invite", + "path": "/v1/members/invitations", + }, { "method": "GET", "operationId": "workspaceMembers.list", "path": "/v1/members", }, + { + "method": "DELETE", + "operationId": "workspaceMembers.remove", + "path": "/v1/members/{memberId}", + }, + { + "method": "PUT", + "operationId": "workspaceMembers.update", + "path": "/v1/members/{memberId}", + }, { "method": "PATCH", "operationId": "zaloChannels.updateTagSync", diff --git a/apps/builder/__tests__/api-tokens-public-api.test.ts b/apps/builder/__tests__/api-tokens-public-api.test.ts new file mode 100644 index 0000000000..7a7c3b7be5 --- /dev/null +++ b/apps/builder/__tests__/api-tokens-public-api.test.ts @@ -0,0 +1,284 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import { z } from "zod" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedHandler = (args: { + context: { workspace: { id: string } } + input: unknown +}) => Promise + +type CapturedProcedure = { + route: RouteConfig + handler?: CapturedHandler +} + +const { + workspaceTokenAuthAPIForScope, + workspaceTokenAdminAPI, + capturedProcedures, +} = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: CapturedHandler) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + const workspaceTokenAuthAPIForScope = vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ) + + return { + workspaceTokenAuthAPIForScope, + workspaceTokenAdminAPI: workspaceTokenAuthAPIForScope("workspace"), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ + workspaceTokenAuthAPIForScope, + workspaceTokenAdminAPI, +})) + +const workspaceApiTokenService = { + listTokens: vi.fn(), + findTokenOrFail: vi.fn(), + createToken: vi.fn(), + updateToken: vi.fn(), + rotateToken: vi.fn(), + deleteToken: vi.fn(), +} +vi.mock("@chatbotx.io/business", () => ({ workspaceApiTokenService })) + +const generateWorkspaceToken = vi.fn() +vi.mock("@chatbotx.io/business/workspace-api-token/credentials", () => ({ + generateWorkspaceToken, +})) + +const toPublicWorkspaceApiToken = vi.fn((token: Record) => ({ + id: token.id, + name: token.name, + permission: token.permission, + tokenPrefix: token.tokenPrefix, + isDefault: token.isDefault, + scopes: token.scopes, + createdAt: token.createdAt, +})) +vi.mock("@/features/workspaces/schema/public", () => ({ + createWorkspaceApiTokenPublicRequest: z.object({}), + createWorkspaceApiTokenPublicResponse: z.object({}), + getWorkspaceApiTokenPublicRequest: z.object({}), + toPublicWorkspaceApiToken, + updateWorkspaceApiTokenPublicRequest: z.object({}), + workspaceApiTokenPublicResource: z.object({}), +})) + +vi.mock("@/lib/orpc/orpc-error-helper", () => ({ + possibleErrorsOnCreatingWorkspaceApiToken: {}, + possibleErrorsOnFindingResource: {}, + possibleErrorsOnListingResource: {}, + possibleErrorsOnMutatingWorkspaceApiToken: {}, +})) + +await import("@/features/workspaces/api/public/api-tokens") +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] +const adminRouteCountAtImport = workspaceTokenAdminAPI.route.mock.calls.length + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (procedure) => + procedure.route.method === method && procedure.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const context = { workspace: { id: "workspace-1" } } +const API_TOKEN = { + id: "1", + name: "Managed token", + permission: "full", + tokenPrefix: "cbx_ws_man", + isDefault: false, + scopes: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the API-token public router under the unrestricted workspace administration scope", () => { + expect(scopeArgAtImport).toBe("workspace") + expect(adminRouteCountAtImport).toBe(6) +}) + +describe("GET /v1/api-tokens", () => { + const procedure = findProcedure("GET", "/v1/api-tokens") + + test("lists tokens in the authenticated workspace", async () => { + workspaceApiTokenService.listTokens.mockResolvedValueOnce([API_TOKEN]) + + await expect( + procedure.handler?.({ context, input: { page: 1, perPage: 50 } }), + ).resolves.toEqual({ data: [API_TOKEN], pageCount: 1 }) + + expect(workspaceApiTokenService.listTokens).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + }) + }) +}) + +describe("GET /v1/api-tokens/{id}", () => { + const procedure = findProcedure("GET", "/v1/api-tokens/{id}") + + test("gets a token scoped to the authenticated workspace", async () => { + workspaceApiTokenService.findTokenOrFail.mockResolvedValueOnce(API_TOKEN) + + await expect( + procedure.handler?.({ context, input: { id: "1" } }), + ).resolves.toEqual(API_TOKEN) + + expect(workspaceApiTokenService.findTokenOrFail).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "1", + }) + }) +}) + +describe("POST /v1/api-tokens", () => { + const procedure = findProcedure("POST", "/v1/api-tokens") + + test("creates a token with server-generated credentials in the authenticated workspace", async () => { + const credentials = { + token: "cbx_ws_created_plaintext", + tokenHash: "created-hash", + tokenPrefix: "cbx_ws_crea", + } + generateWorkspaceToken.mockResolvedValueOnce(credentials) + workspaceApiTokenService.createToken.mockResolvedValueOnce(API_TOKEN) + + await expect( + procedure.handler?.({ + context, + input: { + name: "Created token", + permission: "read_only", + scopes: ["contacts"], + }, + }), + ).resolves.toEqual({ apiToken: API_TOKEN, token: credentials.token }) + + expect(workspaceApiTokenService.createToken).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + name: "Created token", + permission: "read_only", + scopes: ["contacts"], + tokenHash: "created-hash", + tokenPrefix: "cbx_ws_crea", + }) + }) +}) + +describe("PATCH /v1/api-tokens/{id}", () => { + const procedure = findProcedure("PATCH", "/v1/api-tokens/{id}") + + test("updates only the submitted fields in the authenticated workspace", async () => { + const updatedToken = { ...API_TOKEN, name: "Renamed token" } + workspaceApiTokenService.updateToken.mockResolvedValueOnce(updatedToken) + + await expect( + procedure.handler?.({ + context, + input: { id: "1", name: "Renamed token" }, + }), + ).resolves.toEqual(updatedToken) + + expect(workspaceApiTokenService.updateToken).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "1", + name: "Renamed token", + }) + }) +}) + +describe("POST /v1/api-tokens/{id}/rotate", () => { + const procedure = findProcedure("POST", "/v1/api-tokens/{id}/rotate") + + test("returns the newly generated plaintext token instead of a prior credential", async () => { + const previousToken = "cbx_ws_previous_plaintext" + const credentials = { + token: "cbx_ws_rotated_plaintext", + tokenHash: "rotated-hash", + tokenPrefix: "cbx_ws_rota", + } + generateWorkspaceToken.mockResolvedValueOnce(credentials) + workspaceApiTokenService.rotateToken.mockResolvedValueOnce(API_TOKEN) + + await expect( + procedure.handler?.({ context, input: { id: "1" } }), + ).resolves.toEqual({ apiToken: API_TOKEN, token: credentials.token }) + + expect(workspaceApiTokenService.rotateToken).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "1", + tokenHash: "rotated-hash", + tokenPrefix: "cbx_ws_rota", + }) + expect(credentials.token).not.toBe(previousToken) + }) +}) + +describe("DELETE /v1/api-tokens/{id}", () => { + const procedure = findProcedure("DELETE", "/v1/api-tokens/{id}") + + test("rejects a default token before calling deleteToken", async () => { + workspaceApiTokenService.findTokenOrFail.mockResolvedValueOnce({ + ...API_TOKEN, + isDefault: true, + }) + + await expect( + procedure.handler?.({ context, input: { id: "1" } }), + ).rejects.toMatchObject({ code: "workspaceApiTokenImmutable" }) + + expect(workspaceApiTokenService.deleteToken).not.toHaveBeenCalled() + }) + + test("deletes a non-default token in the authenticated workspace", async () => { + workspaceApiTokenService.findTokenOrFail.mockResolvedValueOnce(API_TOKEN) + workspaceApiTokenService.deleteToken.mockResolvedValueOnce(true) + + await expect( + procedure.handler?.({ context, input: { id: "1" } }), + ).resolves.toBeUndefined() + + expect(workspaceApiTokenService.deleteToken).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + id: "1", + }) + }) +}) diff --git a/apps/builder/__tests__/api-tokens-public-scope.test.ts b/apps/builder/__tests__/api-tokens-public-scope.test.ts new file mode 100644 index 0000000000..e9e52eb658 --- /dev/null +++ b/apps/builder/__tests__/api-tokens-public-scope.test.ts @@ -0,0 +1,179 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + findWorkspaceByTokenHash, + isWorkspaceScheduledForDeletion, + getAccessState, + isAtLimit, + assertApiNotRateLimited, +} = vi.hoisted(() => ({ + findWorkspaceByTokenHash: vi.fn(), + isWorkspaceScheduledForDeletion: vi.fn().mockReturnValue(false), + getAccessState: vi.fn().mockResolvedValue({ blocked: false }), + isAtLimit: vi.fn().mockResolvedValue(false), + assertApiNotRateLimited: vi.fn().mockResolvedValue(undefined), +})) + +const workspaceApiTokenService = { + findWorkspaceByTokenHash, + listTokens: vi.fn(), + findTokenOrFail: vi.fn(), + createToken: vi.fn(), + updateToken: vi.fn(), + rotateToken: vi.fn(), + deleteToken: vi.fn(), +} + +vi.mock("@chatbotx.io/business", () => ({ + workspaceApiTokenService, + isWorkspaceScheduledForDeletion, + userQuotaService: { getAccessState }, + quotaEnforcementService: { isAtLimit }, +})) + +vi.mock("@/lib/log", () => ({ + logger: { warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock("@/lib/rate-limit/api-rate-limit", () => ({ + assertApiNotRateLimited, +})) + +vi.mock("@/lib/rate-limit/guest-rate-limit", () => ({ + getGuestClientIp: () => "203.0.113.9", +})) + +vi.mock("@/env", () => ({ isCloud: () => true })) + +vi.mock("@/middlewares/auth", () => ({ + authMiddleware: vi.fn(), +})) + +const { call } = await import("@orpc/server") +const { apiTokensPublicRouter } = await import( + "../src/features/workspaces/api/public/api-tokens" +) + +const TOKEN = "cbx_ws_fixture" +const API_TOKEN = { + id: "1", + name: "Managed token", + permission: "full", + tokenPrefix: "cbx_ws_man", + isDefault: false, + scopes: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), +} + +const authResult = (scopes: string[] | null) => ({ + workspace: { id: "ws-1", ownerId: "owner-1" }, + apiToken: { id: "token-1", permission: "full" as const, scopes }, +}) + +const invoke = (procedure: unknown, input: unknown = {}) => + call(procedure as Parameters[0], input, { + context: { headers: new Headers({ Authorization: `Bearer ${TOKEN}` }) }, + }) + +beforeEach(() => { + vi.clearAllMocks() + isWorkspaceScheduledForDeletion.mockReturnValue(false) + getAccessState.mockResolvedValue({ blocked: false }) + isAtLimit.mockResolvedValue(false) + assertApiNotRateLimited.mockResolvedValue(undefined) +}) + +describe("real router: API tokens public administration scope wiring", () => { + test("a contacts-scoped token is denied the real GET /v1/api-tokens route with FORBIDDEN", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["contacts"])) + + await expect(invoke(apiTokensPublicRouter.list)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'workspace' scope", + }) + }) + + test("a workspace-scoped token is denied because API-token administration requires unrestricted access", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["workspace"])) + + await expect(invoke(apiTokensPublicRouter.list)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: + "Only an unrestricted (All scopes) token can manage workspace API tokens", + }) + }) + + test("null scopes (unrestricted) passes the real GET /v1/api-tokens route", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(null)) + workspaceApiTokenService.listTokens.mockResolvedValue([API_TOKEN]) + + await expect(invoke(apiTokensPublicRouter.list)).resolves.toMatchObject({ + data: [expect.objectContaining({ id: "1", name: "Managed token" })], + pageCount: 1, + }) + }) + + test.each([ + [ + "POST /v1/api-tokens", + () => + invoke(apiTokensPublicRouter.create, { + name: "New token", + permission: "full", + scopes: null, + }), + ], + [ + "PATCH /v1/api-tokens/{id}", + () => + invoke(apiTokensPublicRouter.update, { + id: "1", + name: "Renamed token", + }), + ], + [ + "POST /v1/api-tokens/{id}/rotate", + () => invoke(apiTokensPublicRouter.rotate, { id: "1" }), + ], + [ + "DELETE /v1/api-tokens/{id}", + () => invoke(apiTokensPublicRouter.delete, { id: "1" }), + ], + ])("a read_only token is denied %s before any write service call", async (_label, run) => { + findWorkspaceByTokenHash.mockResolvedValue({ + workspace: { id: "ws-1", ownerId: "owner-1" }, + apiToken: { + id: "token-1", + permission: "read_only" as const, + scopes: null, + }, + }) + + await expect(run()).rejects.toMatchObject({ code: "FORBIDDEN" }) + + expect(workspaceApiTokenService.createToken).not.toHaveBeenCalled() + expect(workspaceApiTokenService.updateToken).not.toHaveBeenCalled() + expect(workspaceApiTokenService.rotateToken).not.toHaveBeenCalled() + expect(workspaceApiTokenService.deleteToken).not.toHaveBeenCalled() + }) + + test("update scopes the token id to the authenticated workspace, never a workspace implied by input", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(null)) + workspaceApiTokenService.updateToken.mockResolvedValue(API_TOKEN) + + await invoke(apiTokensPublicRouter.update, { + id: "999999", + name: "Renamed token", + }) + + expect(workspaceApiTokenService.updateToken).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "ws-1", + id: "999999", + name: "Renamed token", + }), + ) + }) +}) diff --git a/apps/builder/__tests__/audit-logs-public-api.test.ts b/apps/builder/__tests__/audit-logs-public-api.test.ts new file mode 100644 index 0000000000..5f4db6a1bd --- /dev/null +++ b/apps/builder/__tests__/audit-logs-public-api.test.ts @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import { z } from "zod" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedHandler = (args: { + context: { workspace: { id: string } } + input: unknown +}) => Promise + +type CapturedProcedure = { + route: RouteConfig + handler?: CapturedHandler +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: CapturedHandler) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const assertEnterpriseFeatures = vi.fn() +const listAuditLogs = vi.fn() +const parseAuditLogsDateRange = vi.hoisted(() => vi.fn()) + +vi.mock("@chatbotx.io/business", () => ({ assertEnterpriseFeatures })) + +vi.mock("@chatbotx.io/business/audit", () => ({ listAuditLogs })) + +vi.mock("@/enterprise/features/audit-logs/schema/public", () => ({ + listAuditLogsPublicRequest: z.object({}), + listAuditLogsPublicResponse: z.object({}), +})) + +vi.mock("@/enterprise/features/audit-logs/schema/query", () => ({ + parseAuditLogsDateRange, +})) + +vi.mock("@/lib/orpc/orpc-error-helper", () => ({ + possibleErrorsOnListingEnterpriseResource: {}, +})) + +await import("@/enterprise/features/audit-logs/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (procedure) => + procedure.route.method === method && procedure.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] +const context = { workspace: { id: "workspace-1" } } + +beforeEach(() => { + vi.clearAllMocks() + assertEnterpriseFeatures.mockResolvedValue(undefined) +}) + +test("registers the audit logs public router under the workspace scope", () => { + expect(scopeArgAtImport).toBe("workspace") +}) + +describe("GET /v1/audit-logs", () => { + const procedure = findProcedure("GET", "/v1/audit-logs") + + test("lists audit logs in the authenticated workspace with the parsed date range", async () => { + const dateRange = { + from: "2026-08-01", + to: "2026-08-14", + start: new Date("2026-08-01T00:00:00.000Z"), + end: new Date("2026-08-14T23:59:59.999Z"), + } + const serviceResult = { + data: [ + { + id: "log-1", + workspaceId: "workspace-1", + action: "workspace.updated", + }, + ], + pageCount: 1, + } + parseAuditLogsDateRange.mockReturnValueOnce(dateRange) + listAuditLogs.mockResolvedValueOnce(serviceResult) + + await expect( + procedure.handler?.({ + context, + input: { + workspaceId: "workspace-other", + page: 2, + perPage: 20, + from: "2026-08-01", + to: "2026-08-14", + sort: [{ id: "createdAt", desc: true }], + keyword: "updated", + userId: "user-1", + }, + }), + ).resolves.toEqual(serviceResult) + + expect(assertEnterpriseFeatures).toHaveBeenCalledTimes(1) + expect(parseAuditLogsDateRange).toHaveBeenCalledWith({ + workspaceId: "workspace-other", + page: 2, + perPage: 20, + from: "2026-08-01", + to: "2026-08-14", + sort: [{ id: "createdAt", desc: true }], + keyword: "updated", + userId: "user-1", + }) + expect(listAuditLogs).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + page: 2, + perPage: 20, + sort: [{ id: "createdAt", desc: true }], + keyword: "updated", + userId: "user-1", + dateRange: { + start: dateRange.start, + end: dateRange.end, + }, + }) + }) + + test("returns the service result unchanged, leaving workspaceId stripping to the output schema", async () => { + const serviceResult = { + data: [{ id: "log-1", workspaceId: "workspace-1" }], + pageCount: 1, + } + parseAuditLogsDateRange.mockReturnValueOnce({ + from: "2026-08-01", + to: "2026-08-14", + start: new Date("2026-08-01T00:00:00.000Z"), + end: new Date("2026-08-14T23:59:59.999Z"), + }) + listAuditLogs.mockResolvedValueOnce(serviceResult) + + await expect( + procedure.handler?.({ + context, + input: { + page: 1, + perPage: 50, + from: "2026-08-01", + to: "2026-08-14", + sort: [{ id: "createdAt", desc: true }], + }, + }), + ).resolves.toMatchObject({ + data: [{ workspaceId: "workspace-1" }], + pageCount: 1, + }) + }) +}) diff --git a/apps/builder/__tests__/audit-logs-public-scope.test.ts b/apps/builder/__tests__/audit-logs-public-scope.test.ts new file mode 100644 index 0000000000..4d2e84379a --- /dev/null +++ b/apps/builder/__tests__/audit-logs-public-scope.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + findWorkspaceByTokenHash, + isWorkspaceScheduledForDeletion, + getAccessState, + isAtLimit, + assertApiNotRateLimited, + assertEnterpriseFeatures, +} = vi.hoisted(() => ({ + findWorkspaceByTokenHash: vi.fn(), + isWorkspaceScheduledForDeletion: vi.fn().mockReturnValue(false), + getAccessState: vi.fn().mockResolvedValue({ blocked: false }), + isAtLimit: vi.fn().mockResolvedValue(false), + assertApiNotRateLimited: vi.fn().mockResolvedValue(undefined), + assertEnterpriseFeatures: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("@chatbotx.io/business", () => ({ + workspaceApiTokenService: { findWorkspaceByTokenHash }, + isWorkspaceScheduledForDeletion, + userQuotaService: { getAccessState }, + quotaEnforcementService: { isAtLimit }, + assertEnterpriseFeatures, +})) + +const listAuditLogs = vi.fn() +const withAuditContext = vi.fn( + (_context: unknown, next: () => Promise) => next(), +) + +vi.mock("@chatbotx.io/business/audit", () => ({ + listAuditLogs, + withAuditContext, +})) + +vi.mock("@/lib/log", () => ({ + logger: { warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock("@/lib/rate-limit/api-rate-limit", () => ({ + assertApiNotRateLimited, +})) + +vi.mock("@/lib/rate-limit/guest-rate-limit", () => ({ + getGuestClientIp: () => "203.0.113.9", +})) + +vi.mock("@/env", () => ({ isCloud: () => true })) + +// `@/orpc` also exports `authorizedAPI`, which pulls in the full better-auth +// stack via `authMiddleware` — irrelevant here and unsafe to initialize in a +// unit test. Same stub as workspace-token-scope-enforcement.test.ts. +vi.mock("@/middlewares/auth", () => ({ + authMiddleware: vi.fn(), +})) + +const { call } = await import("@orpc/server") +const { auditLogsPublicRouter } = await import( + "../src/enterprise/features/audit-logs/api/public" +) + +const TOKEN = "cbx_ws_fixture" + +const authResult = (scopes: string[] | null) => ({ + workspace: { id: "ws-1", ownerId: "owner-1" }, + apiToken: { id: "token-1", permission: "full" as const, scopes }, +}) + +const invoke = (input: Record = {}) => + call(auditLogsPublicRouter.list, input, { + context: { headers: new Headers({ Authorization: `Bearer ${TOKEN}` }) }, + }) + +const auditLog = { + id: "log-1", + workspaceId: "ws-1", + createdAt: new Date("2026-08-01T00:00:00.000Z"), + updatedAt: new Date("2026-08-01T00:00:00.000Z"), + action: "workspace.updated", + detail: "Workspace updated", + ipAddress: "203.0.113.9", + userAgent: "Vitest", + source: "api", + userId: "user-1", + user: { id: "user-1", name: "Admin", image: null }, +} + +beforeEach(() => { + vi.clearAllMocks() + isWorkspaceScheduledForDeletion.mockReturnValue(false) + getAccessState.mockResolvedValue({ blocked: false }) + isAtLimit.mockResolvedValue(false) + assertApiNotRateLimited.mockResolvedValue(undefined) + assertEnterpriseFeatures.mockResolvedValue(undefined) +}) + +describe("real router: audit logs public API scope wiring", () => { + test("a contacts-scoped token is denied the real GET /v1/audit-logs route with FORBIDDEN", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["contacts"])) + + await expect(invoke()).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'workspace' scope", + }) + + expect(assertEnterpriseFeatures).not.toHaveBeenCalled() + expect(listAuditLogs).not.toHaveBeenCalled() + }) + + test("null scopes (unrestricted) list only the authenticated workspace and hide workspaceId", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(null)) + listAuditLogs.mockResolvedValue({ data: [auditLog], pageCount: 1 }) + + const result = await invoke({ + workspaceId: "ws-other", + page: 2, + perPage: 20, + from: "2026-08-01", + to: "2026-08-14", + sort: [{ id: "createdAt", desc: true }], + keyword: "updated", + userId: "user-1", + }) + + expect(result).toMatchObject({ data: [{ id: "log-1" }], pageCount: 1 }) + expect(result.data[0]).not.toHaveProperty("workspaceId") + expect(assertEnterpriseFeatures).toHaveBeenCalledTimes(1) + expect(listAuditLogs).toHaveBeenCalledWith({ + workspaceId: "ws-1", + page: 2, + perPage: 20, + sort: [{ id: "createdAt", desc: true }], + keyword: "updated", + userId: "user-1", + dateRange: { + start: new Date("2026-08-01T00:00:00.000Z"), + end: new Date("2026-08-14T23:59:59.999Z"), + }, + }) + }) + + test("surfaces the declared enterpriseFeatureRequired 403 error before listing audit logs", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(null)) + assertEnterpriseFeatures.mockRejectedValueOnce( + Object.assign(new Error("This feature requires an enterprise license"), { + code: "enterpriseFeatureRequired", + httpStatusCode: 403, + }), + ) + + await expect(invoke()).rejects.toMatchObject({ + code: "enterpriseFeatureRequired", + httpStatusCode: 403, + message: "This feature requires an enterprise license", + }) + + expect(listAuditLogs).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/rpc-router-auth-surface.test.ts b/apps/builder/__tests__/rpc-router-auth-surface.test.ts index be8388aa42..b630491fa9 100644 --- a/apps/builder/__tests__/rpc-router-auth-surface.test.ts +++ b/apps/builder/__tests__/rpc-router-auth-surface.test.ts @@ -24,7 +24,7 @@ const RAW_BASE_ALLOWLIST = new Set([ ]) const AUTHENTICATED_BASE = - /\b(authorizedAPI|workspaceTokenAuthAPIForScope|channelApiTokenAPI)\b/ + /\b(authorizedAPI|workspaceTokenAuthAPIForScope|workspaceTokenAdminAPI|channelApiTokenAPI)\b/ const PROCEDURE_HANDLER = /\.handler\(/ @@ -61,11 +61,12 @@ describe("/rpc router auth surface", () => { ) // Adding an export here means adding a way to mount a procedure. If it is - // not one of these three, it must carry its own auth middleware — and this + // not one of these four, it must carry its own auth middleware — and this // test is where that decision gets recorded. expect(exported.sort()).toEqual([ "authorizedAPI", "channelApiTokenAPI", + "workspaceTokenAdminAPI", "workspaceTokenAuthAPIForScope", ]) }) diff --git a/apps/builder/__tests__/templates-public-api.test.ts b/apps/builder/__tests__/templates-public-api.test.ts new file mode 100644 index 0000000000..d1d33131db --- /dev/null +++ b/apps/builder/__tests__/templates-public-api.test.ts @@ -0,0 +1,425 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import { z } from "zod" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedHandler = (args: { + context: { workspace: { id: string; tenantId: string } } + input: unknown +}) => Promise + +type CapturedProcedure = { + route: RouteConfig + handler?: CapturedHandler +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: CapturedHandler) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const templateService = { + list: vi.fn(), + listSelectableResources: vi.fn(), + listInstallations: vi.fn(), + createOrUpdate: vi.fn(), + enqueueInstallation: vi.fn(), + findByIdOrFail: vi.fn(), + softDelete: vi.fn(), + updateShareSettings: vi.fn(), + updateInstallationAutoUpdate: vi.fn(), +} + +vi.mock("@chatbotx.io/business", () => ({ templateService })) + +vi.mock("@/features/templates/schema/public", () => ({ + createTemplatePublicRequest: z.object({}), + installTemplatePublicRequest: z.object({}), + installTemplatePublicResponse: z.object({}), + listSelectableTemplateResourcesPublicRequest: z.object({}), + listSelectableTemplateResourcesPublicResponse: z.object({}), + listTemplateInstallationsPublicResponse: z.object({}), + listTemplatesPublicResponse: z.object({}), + templateInstallationPublicResource: z.object({}), + templatePublicRequestParams: z.object({}), + templatePublicResource: z.object({}), + updateTemplateInstallationAutoUpdatePublicRequest: z.object({}), + updateTemplatePublicRequest: z.object({}), + updateTemplateShareSettingsPublicRequest: z.object({}), +})) + +vi.mock("@/lib/orpc/orpc-error-helper", () => ({ + possibleErrorsOnCreatingResource: {}, + possibleErrorsOnDeletingResource: {}, + possibleErrorsOnFindingResource: {}, + possibleErrorsOnListingResource: {}, + possibleErrorsOnMutatingResource: {}, +})) + +await import("@/features/templates/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (procedure) => + procedure.route.method === method && procedure.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] +const context = { workspace: { id: "workspace-1", tenantId: "tenant-1" } } +const timestamp = new Date("2026-01-01T00:00:00.000Z") + +const template = { + id: "template-1", + workspaceId: "publisher-workspace", + tenantId: "publisher-tenant", + createdBy: "user-1", + payload: { formatVersion: 1 }, + name: "Welcome template", + description: "A reusable welcome flow", + imageUrl: null, + publisherName: null, + youtubeVideoId: null, + testLink: null, + shareEnabled: true, + shareToken: "share-token", + shareExpiresAt: null, + categoryCounts: { flows: 1 }, + createInstallFolder: true, + defaultAutoUpdate: false, + createdAt: timestamp, + updatedAt: timestamp, +} + +const publicTemplate = { + id: template.id, + name: template.name, + description: template.description, + imageUrl: template.imageUrl, + publisherName: template.publisherName, + youtubeVideoId: template.youtubeVideoId, + testLink: template.testLink, + shareEnabled: template.shareEnabled, + shareToken: template.shareToken, + shareExpiresAt: template.shareExpiresAt, + categoryCounts: template.categoryCounts, + createInstallFolder: template.createInstallFolder, + defaultAutoUpdate: template.defaultAutoUpdate, + createdAt: template.createdAt, + updatedAt: template.updatedAt, +} + +const installation = { + id: "installation-1", + workspaceId: "workspace-1", + templateId: "template-1", + templateName: "Welcome template", + status: "pending", + warningCount: 0, + errorMessage: null, + resourceCount: 0, + installFolderId: null, + autoUpdate: false, + sourceUpdatedAt: null, + completedAt: null, + createdAt: timestamp, + updatedAt: timestamp, +} + +const publicInstallation = { + id: installation.id, + templateId: installation.templateId, + templateName: installation.templateName, + status: installation.status, + warningCount: installation.warningCount, + errorMessage: installation.errorMessage, + resourceCount: installation.resourceCount, + installFolderId: installation.installFolderId, + autoUpdate: installation.autoUpdate, + sourceUpdatedAt: installation.sourceUpdatedAt, + completedAt: installation.completedAt, + createdAt: installation.createdAt, + updatedAt: installation.updatedAt, +} + +const templateInput = { + name: "Welcome template", + description: "A reusable welcome flow", + selection: { flows: { mode: "all" } }, + defaultPermissions: { allowEdit: true, allowDelete: false }, + createInstallFolder: true, + defaultAutoUpdate: false, +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the templates public router under the workspace scope", () => { + expect(scopeArgAtImport).toBe("workspace") +}) + +describe("GET /v1/templates", () => { + const procedure = findProcedure("GET", "/v1/templates") + + test("lists templates in the authenticated workspace", async () => { + templateService.list.mockResolvedValueOnce([template]) + + await expect( + procedure.handler?.({ context, input: { page: 1, perPage: 50 } }), + ).resolves.toEqual({ data: [publicTemplate], pageCount: 1 }) + + expect(templateService.list).toHaveBeenCalledWith("workspace-1") + }) +}) + +describe("GET /v1/templates/selectable-resources", () => { + const procedure = findProcedure("GET", "/v1/templates/selectable-resources") + + test("lists selectable resources in the authenticated workspace", async () => { + const result = { + items: [{ id: "flow-1", name: "Welcome flow", folderName: "Flows" }], + nextCursor: "next-page", + total: 2, + allIds: ["flow-1", "flow-2"], + } + templateService.listSelectableResources.mockResolvedValueOnce(result) + + await expect( + procedure.handler?.({ + context, + input: { + category: "flows", + keyword: "Welcome", + cursor: "10", + limit: 20, + }, + }), + ).resolves.toEqual(result) + + expect(templateService.listSelectableResources).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + category: "flows", + keyword: "Welcome", + cursor: "10", + limit: 20, + }) + }) +}) + +describe("GET /v1/templates/installations", () => { + const procedure = findProcedure("GET", "/v1/templates/installations") + + test("lists installation records in the authenticated workspace", async () => { + templateService.listInstallations.mockResolvedValueOnce([installation]) + + await expect( + procedure.handler?.({ context, input: { page: 1, perPage: 50 } }), + ).resolves.toEqual({ data: [publicInstallation], pageCount: 1 }) + + expect(templateService.listInstallations).toHaveBeenCalledWith( + "workspace-1", + ) + }) +}) + +describe("POST /v1/templates", () => { + const procedure = findProcedure("POST", "/v1/templates") + + test("creates a template in the authenticated workspace without leaking internal fields", async () => { + templateService.createOrUpdate.mockResolvedValueOnce(template) + + const result = await procedure.handler?.({ context, input: templateInput }) + + expect(templateService.createOrUpdate).toHaveBeenCalledWith({ + ...templateInput, + workspaceId: "workspace-1", + tenantId: "tenant-1", + createdBy: null, + }) + expect(result).toEqual(publicTemplate) + expect(result).not.toHaveProperty("payload") + expect(result).not.toHaveProperty("tenantId") + expect(result).not.toHaveProperty("workspaceId") + expect(result).not.toHaveProperty("createdBy") + }) +}) + +describe("POST /v1/templates/installations", () => { + const procedure = findProcedure("POST", "/v1/templates/installations") + + test("registers HTTP 202 semantics and forwards to the install-lifecycle service method", async () => { + templateService.enqueueInstallation.mockResolvedValueOnce(installation) + + await expect( + procedure.handler?.({ context, input: { shareToken: "share-token" } }), + ).resolves.toEqual({ installationId: "installation-1", status: "pending" }) + + expect(procedure.route.successStatus).toBe(202) + expect(templateService.enqueueInstallation).toHaveBeenCalledWith({ + shareToken: "share-token", + workspaceId: "workspace-1", + installedBy: null, + }) + }) + + test("propagates an install-lifecycle failure instead of swallowing it", async () => { + templateService.enqueueInstallation.mockRejectedValueOnce( + new Error("Unable to queue template install"), + ) + + await expect( + procedure.handler?.({ context, input: { shareToken: "share-token" } }), + ).rejects.toThrow("Unable to queue template install") + }) +}) + +describe("GET /v1/templates/{id}", () => { + const procedure = findProcedure("GET", "/v1/templates/{id}") + + test("gets a template scoped to the authenticated workspace", async () => { + templateService.findByIdOrFail.mockResolvedValueOnce(template) + + await expect( + procedure.handler?.({ context, input: { id: "template-1" } }), + ).resolves.toEqual(publicTemplate) + + expect(templateService.findByIdOrFail).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + templateId: "template-1", + }) + }) +}) + +describe("PATCH /v1/templates/{id}", () => { + const procedure = findProcedure("PATCH", "/v1/templates/{id}") + + test("updates a template in the authenticated workspace without leaking internal fields", async () => { + templateService.createOrUpdate.mockResolvedValueOnce(template) + + const result = await procedure.handler?.({ + context, + input: { id: "template-1", ...templateInput }, + }) + + expect(templateService.createOrUpdate).toHaveBeenCalledWith({ + ...templateInput, + workspaceId: "workspace-1", + tenantId: "tenant-1", + createdBy: null, + existingTemplateId: "template-1", + }) + expect(result).toEqual(publicTemplate) + expect(result).not.toHaveProperty("payload") + expect(result).not.toHaveProperty("tenantId") + expect(result).not.toHaveProperty("workspaceId") + expect(result).not.toHaveProperty("createdBy") + }) +}) + +describe("DELETE /v1/templates/{id}", () => { + const procedure = findProcedure("DELETE", "/v1/templates/{id}") + + test("deletes the selected template in the authenticated workspace", async () => { + templateService.softDelete.mockResolvedValueOnce(undefined) + + await expect( + procedure.handler?.({ context, input: { id: "template-1" } }), + ).resolves.toBeUndefined() + + expect(templateService.softDelete).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + templateId: "template-1", + }) + }) +}) + +describe("PATCH /v1/templates/{id}/share-settings", () => { + const procedure = findProcedure("PATCH", "/v1/templates/{id}/share-settings") + + test("updates sharing settings in the authenticated workspace", async () => { + templateService.updateShareSettings.mockResolvedValueOnce(template) + + await expect( + procedure.handler?.({ + context, + input: { + id: "template-1", + shareEnabled: true, + shareExpiresAt: "2026-12-31T00:00:00.000Z", + }, + }), + ).resolves.toEqual(publicTemplate) + + expect(templateService.updateShareSettings).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + templateId: "template-1", + shareEnabled: true, + shareExpiresAt: new Date("2026-12-31T00:00:00.000Z"), + }) + }) +}) + +describe("PATCH /v1/templates/installations/{id}/auto-update", () => { + const procedure = findProcedure( + "PATCH", + "/v1/templates/installations/{id}/auto-update", + ) + + test("updates an installation's auto-update setting in the authenticated workspace", async () => { + templateService.updateInstallationAutoUpdate.mockResolvedValueOnce( + undefined, + ) + + await expect( + procedure.handler?.({ + context, + input: { id: "installation-1", autoUpdate: true }, + }), + ).resolves.toBeUndefined() + + expect(templateService.updateInstallationAutoUpdate).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + installationId: "installation-1", + autoUpdate: true, + }) + }) +}) diff --git a/apps/builder/__tests__/templates-public-scope.test.ts b/apps/builder/__tests__/templates-public-scope.test.ts new file mode 100644 index 0000000000..7c0b0ace58 --- /dev/null +++ b/apps/builder/__tests__/templates-public-scope.test.ts @@ -0,0 +1,324 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + findWorkspaceByTokenHash, + isWorkspaceScheduledForDeletion, + getAccessState, + isAtLimit, + assertApiNotRateLimited, +} = vi.hoisted(() => ({ + findWorkspaceByTokenHash: vi.fn(), + isWorkspaceScheduledForDeletion: vi.fn().mockReturnValue(false), + getAccessState: vi.fn().mockResolvedValue({ blocked: false }), + isAtLimit: vi.fn().mockResolvedValue(false), + assertApiNotRateLimited: vi.fn().mockResolvedValue(undefined), +})) + +const templateService = { + list: vi.fn(), + listSelectableResources: vi.fn(), + listInstallations: vi.fn(), + createOrUpdate: vi.fn(), + enqueueInstallation: vi.fn(), + findByIdOrFail: vi.fn(), + softDelete: vi.fn(), + updateShareSettings: vi.fn(), + updateInstallationAutoUpdate: vi.fn(), +} + +vi.mock("@chatbotx.io/business", () => ({ + workspaceApiTokenService: { findWorkspaceByTokenHash }, + isWorkspaceScheduledForDeletion, + userQuotaService: { getAccessState }, + quotaEnforcementService: { isAtLimit }, + templateService, +})) + +vi.mock("@/lib/log", () => ({ + logger: { warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock("@/lib/rate-limit/api-rate-limit", () => ({ + assertApiNotRateLimited, +})) + +vi.mock("@/lib/rate-limit/guest-rate-limit", () => ({ + getGuestClientIp: () => "203.0.113.9", +})) + +vi.mock("@/env", () => ({ isCloud: () => true })) + +// `@/orpc` also exports `authorizedAPI`, which pulls in the full better-auth +// stack via `authMiddleware` — irrelevant here and unsafe to initialize in a +// unit test. Same stub as workspace-token-scope-enforcement.test.ts. +vi.mock("@/middlewares/auth", () => ({ + authMiddleware: vi.fn(), +})) + +const { call } = await import("@orpc/server") +const { templatesPublicRouter } = await import( + "../src/features/templates/api/public" +) + +const TOKEN = "cbx_ws_fixture" +const timestamp = new Date("2026-01-01T00:00:00.000Z") + +const authResult = (scopes: string[] | null) => ({ + workspace: { id: "ws-1", ownerId: "owner-1", tenantId: "tenant-1" }, + apiToken: { id: "token-1", permission: "full" as const, scopes }, +}) + +const invoke = (procedure: unknown, input: unknown = {}) => + call(procedure as Parameters[0], input, { + context: { headers: new Headers({ Authorization: `Bearer ${TOKEN}` }) }, + }) + +const template = { + id: "1", + workspaceId: "publisher-workspace", + tenantId: "publisher-tenant", + createdBy: "user-1", + payload: { formatVersion: 1 }, + name: "Welcome template", + description: "A reusable welcome flow", + imageUrl: null, + publisherName: null, + youtubeVideoId: null, + testLink: null, + shareEnabled: true, + shareToken: "share-token", + shareExpiresAt: null, + categoryCounts: { + flows: 1, + products: 0, + aiFunctions: 0, + aiAgents: 0, + calendars: 0, + webchats: 0, + keywords: 0, + entryPointLinks: 0, + triggers: 0, + fbCommentAutomations: 0, + settings: 0, + customFields: 0, + tags: 0, + productCategories: 0, + }, + createInstallFolder: true, + defaultAutoUpdate: false, + createdAt: timestamp, + updatedAt: timestamp, +} + +const installation = { + id: "2", + workspaceId: "ws-1", + templateId: "1", + templateName: "Welcome template", + status: "pending", + warningCount: 0, + errorMessage: null, + resourceCount: 0, + installFolderId: null, + autoUpdate: false, + sourceUpdatedAt: null, + completedAt: null, + createdAt: timestamp, + updatedAt: timestamp, +} + +const templateInput = { + name: "Welcome template", + description: "A reusable welcome flow", + selection: { flows: { mode: "all" } }, + defaultPermissions: { allowEdit: true, allowDelete: false }, + createInstallFolder: true, + defaultAutoUpdate: false, +} + +beforeEach(() => { + vi.clearAllMocks() + isWorkspaceScheduledForDeletion.mockReturnValue(false) + getAccessState.mockResolvedValue({ blocked: false }) + isAtLimit.mockResolvedValue(false) + assertApiNotRateLimited.mockResolvedValue(undefined) +}) + +describe("real router: templates public API scope wiring", () => { + test("a contacts-scoped token is denied the real GET /v1/templates route with FORBIDDEN", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["contacts"])) + + await expect(invoke(templatesPublicRouter.list)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'workspace' scope", + }) + }) + + test("null scopes (unrestricted) passes the real GET /v1/templates route", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(null)) + templateService.list.mockResolvedValue([template]) + + await expect(invoke(templatesPublicRouter.list)).resolves.toMatchObject({ + data: [expect.objectContaining({ id: "1" })], + pageCount: 1, + }) + }) + + test.each([ + [ + "POST /v1/templates", + () => invoke(templatesPublicRouter.create, templateInput), + ], + [ + "POST /v1/templates/installations", + () => + invoke(templatesPublicRouter.install, { shareToken: "share-token" }), + ], + [ + "PATCH /v1/templates/{id}", + () => + invoke(templatesPublicRouter.update, { + id: "1", + ...templateInput, + }), + ], + [ + "DELETE /v1/templates/{id}", + () => invoke(templatesPublicRouter.delete, { id: "1" }), + ], + [ + "PATCH /v1/templates/{id}/share-settings", + () => + invoke(templatesPublicRouter.updateShareSettings, { + id: "1", + shareEnabled: true, + }), + ], + [ + "PATCH /v1/templates/installations/{id}/auto-update", + () => + invoke(templatesPublicRouter.updateInstallationAutoUpdate, { + id: "2", + autoUpdate: true, + }), + ], + ])("a read_only token is denied %s before any service call", async (_label, run) => { + findWorkspaceByTokenHash.mockResolvedValue({ + workspace: { id: "ws-1", ownerId: "owner-1", tenantId: "tenant-1" }, + apiToken: { + id: "token-1", + permission: "read_only" as const, + scopes: null, + }, + }) + + await expect(run()).rejects.toMatchObject({ code: "FORBIDDEN" }) + + expect(templateService.createOrUpdate).not.toHaveBeenCalled() + expect(templateService.enqueueInstallation).not.toHaveBeenCalled() + expect(templateService.softDelete).not.toHaveBeenCalled() + expect(templateService.updateShareSettings).not.toHaveBeenCalled() + expect(templateService.updateInstallationAutoUpdate).not.toHaveBeenCalled() + }) + + describe("cross-workspace isolation: workspaceId always comes from the token", () => { + beforeEach(() => { + findWorkspaceByTokenHash.mockResolvedValue( + authResult(null) /* unrestricted scope, full permission */, + ) + templateService.list.mockResolvedValue([template]) + templateService.listSelectableResources.mockResolvedValue({ + items: [{ id: "100", name: "Welcome flow" }], + nextCursor: null, + total: 1, + }) + templateService.listInstallations.mockResolvedValue([installation]) + templateService.createOrUpdate.mockResolvedValue(template) + templateService.enqueueInstallation.mockResolvedValue(installation) + templateService.findByIdOrFail.mockResolvedValue(template) + templateService.softDelete.mockResolvedValue(undefined) + templateService.updateShareSettings.mockResolvedValue(template) + templateService.updateInstallationAutoUpdate.mockResolvedValue(undefined) + }) + + test("scopes every templates service call to the authenticated workspace, not path ids", async () => { + await invoke(templatesPublicRouter.list) + await invoke(templatesPublicRouter.listSelectableResources, { + category: "flows", + keyword: "Welcome", + cursor: "10", + limit: 20, + }) + await invoke(templatesPublicRouter.listInstallations) + await invoke(templatesPublicRouter.create, templateInput) + await invoke(templatesPublicRouter.install, { shareToken: "share-token" }) + await invoke(templatesPublicRouter.get, { id: "999999" }) + await invoke(templatesPublicRouter.update, { + id: "999999", + ...templateInput, + }) + await invoke(templatesPublicRouter.delete, { + id: "999999", + }) + await invoke(templatesPublicRouter.updateShareSettings, { + id: "999999", + shareEnabled: true, + }) + await invoke(templatesPublicRouter.updateInstallationAutoUpdate, { + id: "888888", + autoUpdate: true, + }) + + expect(templateService.list).toHaveBeenCalledWith("ws-1") + expect(templateService.listSelectableResources).toHaveBeenCalledWith({ + workspaceId: "ws-1", + category: "flows", + keyword: "Welcome", + cursor: "10", + limit: 20, + }) + expect(templateService.listInstallations).toHaveBeenCalledWith("ws-1") + expect(templateService.createOrUpdate).toHaveBeenNthCalledWith(1, { + ...templateInput, + workspaceId: "ws-1", + tenantId: "tenant-1", + createdBy: null, + }) + expect(templateService.enqueueInstallation).toHaveBeenCalledWith({ + shareToken: "share-token", + workspaceId: "ws-1", + installedBy: null, + }) + expect(templateService.findByIdOrFail).toHaveBeenCalledWith({ + workspaceId: "ws-1", + templateId: "999999", + }) + expect(templateService.createOrUpdate).toHaveBeenNthCalledWith(2, { + ...templateInput, + workspaceId: "ws-1", + tenantId: "tenant-1", + createdBy: null, + existingTemplateId: "999999", + }) + expect(templateService.softDelete).toHaveBeenCalledWith({ + workspaceId: "ws-1", + templateId: "999999", + }) + expect(templateService.updateShareSettings).toHaveBeenCalledWith({ + workspaceId: "ws-1", + templateId: "999999", + shareEnabled: true, + shareExpiresAt: null, + }) + expect(templateService.updateInstallationAutoUpdate).toHaveBeenCalledWith( + { + workspaceId: "ws-1", + installationId: "888888", + autoUpdate: true, + }, + ) + }) + }) +}) diff --git a/apps/builder/__tests__/workspace-members-actions.test.ts b/apps/builder/__tests__/workspace-members-actions.test.ts index 7b79fce441..de633fc1cd 100644 --- a/apps/builder/__tests__/workspace-members-actions.test.ts +++ b/apps/builder/__tests__/workspace-members-actions.test.ts @@ -3,42 +3,22 @@ import { beforeEach, describe, expect, test, vi } from "vitest" const { - mockAuditRecord, - mockDbInsert, - mockFindOrFail, mockFindByIdOrFail, - mockFindNameAndEmail, mockGetCurrentUserAndTargetWorkspace, - mockInsertReturning, - mockInsertValues, + mockInvitationCreate, mockInvalidateCacheByTags, - mockIsCommunity, - mockQuotaHasReachedLimit, + mockNormalizeUpdateData, mockUpdateMember, - mockWorkspaceFindById, mockWorkspaceMemberServiceDelete, -} = vi.hoisted(() => { - const mockInsertReturning = vi.fn() - const mockInsertValues = vi.fn(() => ({ returning: mockInsertReturning })) - const mockDbInsert = vi.fn(() => ({ values: mockInsertValues })) - - return { - mockAuditRecord: vi.fn(), - mockDbInsert, - mockFindOrFail: vi.fn(), - mockFindByIdOrFail: vi.fn(), - mockFindNameAndEmail: vi.fn(), - mockGetCurrentUserAndTargetWorkspace: vi.fn(), - mockInsertReturning, - mockWorkspaceMemberServiceDelete: vi.fn(), - mockInsertValues, - mockInvalidateCacheByTags: vi.fn(), - mockIsCommunity: vi.fn(), - mockQuotaHasReachedLimit: vi.fn(), - mockUpdateMember: vi.fn(), - mockWorkspaceFindById: vi.fn(), - } -}) +} = vi.hoisted(() => ({ + mockFindByIdOrFail: vi.fn(), + mockGetCurrentUserAndTargetWorkspace: vi.fn(), + mockInvitationCreate: vi.fn(), + mockInvalidateCacheByTags: vi.fn(), + mockNormalizeUpdateData: vi.fn(), + mockUpdateMember: vi.fn(), + mockWorkspaceMemberServiceDelete: vi.fn(), +})) vi.mock("@/lib/safe-action", () => { const chain: Record = {} @@ -51,67 +31,28 @@ vi.mock("@/lib/safe-action", () => { } }) -vi.mock("@/env", () => ({ - isCommunity: mockIsCommunity, -})) - vi.mock("@/lib/auth/utils", () => ({ getCurrentUserAndTargetWorkspace: mockGetCurrentUserAndTargetWorkspace, })) vi.mock("@chatbotx.io/business", () => ({ + invitationService: { + create: mockInvitationCreate, + }, workspaceMemberCacheTag: (userId: string) => `users:${userId}:workspace-members`, - quotaEnforcementService: { - hasReachedLimit: mockQuotaHasReachedLimit, - }, - userService: { - findNameAndEmail: mockFindNameAndEmail, - }, workspaceMemberService: { delete: mockWorkspaceMemberServiceDelete, findByIdOrFail: mockFindByIdOrFail, - update: mockUpdateMember, - }, - workspaceService: { - findById: mockWorkspaceFindById, + normalizeUpdateData: mockNormalizeUpdateData, + updateMember: mockUpdateMember, }, })) -vi.mock("@chatbotx.io/database/client", () => ({ - db: { - insert: mockDbInsert, - }, - eq: (col: unknown, val: unknown) => ({ eq: [col, val] }), - findOrFail: mockFindOrFail, -})) - vi.mock("@chatbotx.io/redis", () => ({ invalidateCacheByTags: mockInvalidateCacheByTags, })) -vi.mock("@chatbotx.io/business/audit", () => ({ - auditService: { record: mockAuditRecord }, -})) - -vi.mock("@chatbotx.io/database/schema", () => ({ - invitationModel: { _: "invitationModel" }, - workspaceMemberModel: { - id: "workspaceMember.id", - }, -})) - -vi.mock("@chatbotx.io/utils", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - createId: () => "invitation-id", - SymbolicSnowflakeIDs: { - generate: () => "invite-code", - }, - } -}) - const { inviteWorkspaceMemberAction } = await import( "../src/features/workspace-members/actions/invite-workspace-member.action" ) @@ -165,28 +106,18 @@ const updateInput = { }, } -function actionCtx(permissions = granularPermissions) { - return { - ctx: { user: { id: "user-1" } }, - bindArgsParsedInputs: [WORKSPACE_ID], - parsedInput: { permissions }, - } -} - -function updateActionCtx(permissions = granularPermissions) { - return { - bindArgsParsedInputs: [WORKSPACE_ID, MEMBER_ID], - parsedInput: { ...updateInput, permissions }, - } -} +const actionCtx = (permissions = granularPermissions) => ({ + ctx: { user: { id: "user-1" } }, + bindArgsParsedInputs: [WORKSPACE_ID], + parsedInput: { permissions }, +}) -function getInsertedValues() { - return ( - mockInsertValues.mock.calls as unknown as [[{ permissions: unknown }]] - )[0][0] -} +const updateActionCtx = (permissions = granularPermissions) => ({ + bindArgsParsedInputs: [WORKSPACE_ID, MEMBER_ID], + parsedInput: { ...updateInput, permissions }, +}) -function mockCurrentMember(permissions = fullPermissions) { +const mockCurrentMember = (permissions = fullPermissions) => { mockGetCurrentUserAndTargetWorkspace.mockResolvedValue({ user: { id: "user-1" }, targetWorkspace: { id: WORKSPACE_ID, ownerId: "owner-1" }, @@ -210,15 +141,10 @@ describe("workspace member permission helpers", () => { describe("inviteWorkspaceMemberAction", () => { beforeEach(() => { vi.clearAllMocks() - mockWorkspaceFindById.mockResolvedValue({ - id: WORKSPACE_ID, - ownerId: "owner-1", + mockInvitationCreate.mockResolvedValue({ + id: "invitation-id", + code: "invite-code", }) - mockQuotaHasReachedLimit.mockResolvedValue(false) - mockInsertReturning.mockResolvedValue([ - { id: "invitation-id", code: "invite-code" }, - ]) - mockIsCommunity.mockReturnValue(false) }) test("rejects non-super-admin members before creating invitations", async () => { @@ -232,54 +158,62 @@ describe("inviteWorkspaceMemberAction", () => { "You are not authorized to invite a workspace member. You need to be a super admin to do this.", ) - expect(mockQuotaHasReachedLimit).not.toHaveBeenCalled() - expect(mockDbInsert).not.toHaveBeenCalled() + expect(mockInvitationCreate).not.toHaveBeenCalled() }) - test("forces full super-admin permissions for community invitations", async () => { + test("delegates community-edition permission forcing to invitationService", async () => { mockCurrentMember() - mockIsCommunity.mockReturnValue(true) await (inviteWorkspaceMemberAction as (props: unknown) => Promise)( actionCtx(), ) - const insertedValues = getInsertedValues() - expect(insertedValues.permissions).toEqual(fullPermissions) + expect(mockInvitationCreate).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + permissions: granularPermissions, + invitedBy: "user-1", + }) }) - test("normalizes full contacts permissions outside community edition", async () => { + test("delegates contacts-permission normalization to invitationService", async () => { mockCurrentMember() await (inviteWorkspaceMemberAction as (props: unknown) => Promise)( actionCtx(), ) - const insertedValues = getInsertedValues() - expect(insertedValues.permissions).toEqual(normalizedGranularPermissions) + expect(mockInvitationCreate).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + permissions: granularPermissions, + invitedBy: "user-1", + }) }) - test("preserves assigned-only contacts permissions outside community edition", async () => { + test("passes assigned-only contacts permissions to invitationService", async () => { mockCurrentMember() await (inviteWorkspaceMemberAction as (props: unknown) => Promise)( actionCtx(assignedOnlyPermissions), ) - const insertedValues = getInsertedValues() - expect(insertedValues.permissions).toEqual(assignedOnlyPermissions) + expect(mockInvitationCreate).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + permissions: assignedOnlyPermissions, + invitedBy: "user-1", + }) }) - test("records an invite audit event labeled with the granted role", async () => { + test("delegates invitation audit labels and quota enforcement to invitationService", async () => { mockCurrentMember() await (inviteWorkspaceMemberAction as (props: unknown) => Promise)( actionCtx(), ) - expect(mockAuditRecord).toHaveBeenCalledWith({ - action: "invite", - detail: "invited a new member", + expect(mockInvitationCreate).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + permissions: granularPermissions, + invitedBy: "user-1", }) }) }) @@ -294,31 +228,16 @@ describe("updateWorkspaceMemberAction", () => { permissions: fullPermissions, }) mockCurrentMember() - mockWorkspaceFindById.mockResolvedValue({ - id: WORKSPACE_ID, - ownerId: "owner-1", - }) - mockIsCommunity.mockReturnValue(false) - mockFindNameAndEmail.mockResolvedValue({ - name: "Target User", - email: "target@example.com", - }) + mockNormalizeUpdateData.mockImplementation((data) => ({ + ...data, + permissions: normalizeContactsPermissions(data.permissions), + })) mockUpdateMember.mockResolvedValue({ id: MEMBER_ID }) }) - test("records a role_change audit event with the target member's name", async () => { - await (updateWorkspaceMemberAction as (props: unknown) => Promise)( - updateActionCtx(), - ) - - expect(mockAuditRecord).toHaveBeenCalledWith({ - action: "role_change", - detail: "changed role of Target User to member", - }) - }) - - test("forces full super-admin permissions for community updates", async () => { - mockIsCommunity.mockReturnValue(true) + test("uses the service normalizer before updating community-edition permissions", async () => { + const communityData = { ...updateInput, permissions: fullPermissions } + mockNormalizeUpdateData.mockReturnValueOnce(communityData) mockFindByIdOrFail.mockResolvedValue({ id: MEMBER_ID, userId: MEMBER_USER_ID, @@ -330,18 +249,20 @@ describe("updateWorkspaceMemberAction", () => { updateActionCtx(), ) + expect(mockNormalizeUpdateData).toHaveBeenCalledWith(updateInput) expect(mockUpdateMember).toHaveBeenCalledWith({ id: MEMBER_ID, workspaceId: WORKSPACE_ID, - data: { ...updateInput, permissions: fullPermissions }, + data: communityData, }) }) - test("normalizes full contacts permissions outside community edition", async () => { + test("normalizes full contacts permissions and delegates to workspaceMemberService.updateMember", async () => { await (updateWorkspaceMemberAction as (props: unknown) => Promise)( updateActionCtx(), ) + expect(mockNormalizeUpdateData).toHaveBeenCalledWith(updateInput) expect(mockUpdateMember).toHaveBeenCalledWith({ id: MEMBER_ID, workspaceId: WORKSPACE_ID, @@ -349,7 +270,7 @@ describe("updateWorkspaceMemberAction", () => { }) }) - test("preserves assigned-only contacts permissions outside community edition", async () => { + test("preserves assigned-only contacts permissions through workspaceMemberService", async () => { await (updateWorkspaceMemberAction as (props: unknown) => Promise)( updateActionCtx(assignedOnlyPermissions), ) @@ -361,11 +282,7 @@ describe("updateWorkspaceMemberAction", () => { }) }) - // Cache invalidation on a successful update now lives inside - // `workspaceMemberService.update` itself (see - // packages/business/__tests__/workspace-member.update.test.ts) — this - // action only has to call the service with the right id/workspaceId. - test("calls workspaceMemberService.update, which owns cache invalidation on success", async () => { + test("calls workspaceMemberService.updateMember, which owns cache invalidation and auditing on success", async () => { await (updateWorkspaceMemberAction as (props: unknown) => Promise)( updateActionCtx(), ) @@ -375,9 +292,10 @@ describe("updateWorkspaceMemberAction", () => { workspaceId: WORKSPACE_ID, data: { ...updateInput, permissions: normalizedGranularPermissions }, }) + expect(mockInvalidateCacheByTags).not.toHaveBeenCalled() }) - test("skips DB update, cache invalidation, and audit when nothing changed", async () => { + test("skips updateMember entirely when nothing changed", async () => { mockFindByIdOrFail.mockResolvedValue({ id: MEMBER_ID, userId: MEMBER_USER_ID, @@ -392,18 +310,13 @@ describe("updateWorkspaceMemberAction", () => { ) expect(mockUpdateMember).not.toHaveBeenCalled() - expect(mockInvalidateCacheByTags).not.toHaveBeenCalled() - expect(mockFindNameAndEmail).not.toHaveBeenCalled() - expect(mockAuditRecord).not.toHaveBeenCalled() }) - test("still writes the update when only notification settings change, without auditing a role change", async () => { + test("writes notification-only updates even without a permissions diff", async () => { mockFindByIdOrFail.mockResolvedValue({ id: MEMBER_ID, userId: MEMBER_USER_ID, workspaceId: WORKSPACE_ID, - // Same permissions as the submitted payload — only notification - // fields differ from what's stored. permissions: normalizedGranularPermissions, notificationTypes: { notifyAdmin: false, @@ -427,52 +340,29 @@ describe("updateWorkspaceMemberAction", () => { workspaceId: WORKSPACE_ID, data: { ...updateInput, permissions: normalizedGranularPermissions }, }) - // Permissions didn't actually change, so this must not be recorded as - // a "changed role" audit event. - expect(mockFindNameAndEmail).not.toHaveBeenCalled() - expect(mockAuditRecord).not.toHaveBeenCalled() - }) - - test("records role change for a real permission change", async () => { - await (updateWorkspaceMemberAction as (props: unknown) => Promise)( - updateActionCtx(), - ) - - expect(mockUpdateMember).toHaveBeenCalledWith({ - id: MEMBER_ID, - workspaceId: WORKSPACE_ID, - data: { ...updateInput, permissions: normalizedGranularPermissions }, - }) - expect(mockAuditRecord).toHaveBeenCalledWith({ - action: "role_change", - detail: "changed role of Target User to member", - }) }) - test("skips cache invalidation and audit when update races a concurrent delete", async () => { + test("does not throw when updateMember reports no row updated (concurrent delete)", async () => { mockUpdateMember.mockResolvedValue(undefined) - await (updateWorkspaceMemberAction as (props: unknown) => Promise)( - updateActionCtx(), - ) + await expect( + (updateWorkspaceMemberAction as (props: unknown) => Promise)( + updateActionCtx(), + ), + ).resolves.toBeUndefined() expect(mockUpdateMember).toHaveBeenCalled() - expect(mockInvalidateCacheByTags).not.toHaveBeenCalled() - expect(mockFindNameAndEmail).not.toHaveBeenCalled() - expect(mockAuditRecord).not.toHaveBeenCalled() }) }) -function deleteActionCtx() { - return { - bindArgsParsedInputs: [WORKSPACE_ID, MEMBER_ID], - } -} +const deleteActionCtx = () => ({ + bindArgsParsedInputs: [WORKSPACE_ID, MEMBER_ID], +}) describe("deleteWorkspaceMemberAction", () => { beforeEach(() => { vi.clearAllMocks() - mockFindOrFail.mockResolvedValue({ + mockFindByIdOrFail.mockResolvedValue({ id: MEMBER_ID, userId: MEMBER_USER_ID, workspaceId: WORKSPACE_ID, @@ -482,7 +372,7 @@ describe("deleteWorkspaceMemberAction", () => { }) test("rejects deleting the workspace owner", async () => { - mockFindOrFail.mockResolvedValue({ + mockFindByIdOrFail.mockResolvedValue({ id: MEMBER_ID, userId: MEMBER_USER_ID, workspaceId: WORKSPACE_ID, diff --git a/apps/builder/__tests__/workspace-members-public-api.test.ts b/apps/builder/__tests__/workspace-members-public-api.test.ts new file mode 100644 index 0000000000..bd56e961aa --- /dev/null +++ b/apps/builder/__tests__/workspace-members-public-api.test.ts @@ -0,0 +1,287 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import { z } from "zod" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedHandler = (args: { + context: { workspace: { id: string; ownerId: string } } + input: unknown +}) => Promise + +type CapturedProcedure = { + route: RouteConfig + handler?: CapturedHandler +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: CapturedHandler) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const invitationService = { create: vi.fn() } +const workspaceMemberService = { + delete: vi.fn(), + findByIdOrFail: vi.fn(), + updateMember: vi.fn(), +} + +vi.mock("@chatbotx.io/business", () => ({ + invitationService, + workspaceMemberService, +})) + +const listWorkspaceMembers = vi.fn() +const getWorkspaceMember = vi.fn() + +vi.mock("@/features/workspace-members/queries", () => ({ + getWorkspaceMember, + listWorkspaceMembers, +})) + +vi.mock("@/features/workspace-members/schema/public", () => ({ + inviteWorkspaceMemberPublicRequest: z.object({}), + removeWorkspaceMemberPublicRequest: z.object({}), + updateWorkspaceMemberPublicRequest: z.object({}), + workspaceInvitationPublicResource: z.object({}), + workspaceMemberPublicResource: z.object({}), +})) + +vi.mock("@/features/workspace-members/schema/query", () => ({ + getWorkspaceMemberRequest: z.object({ workspaceId: z.string() }), + getWorkspaceMemberResponse: z.object({}), + listWorkspaceMembersRequest: z.object({ + workspaceId: z.string(), + page: z.number(), + perPage: z.number(), + }), + listWorkspaceMembersResponse: z.object({}), +})) + +vi.mock("@/lib/orpc/orpc-error-helper", () => ({ + possibleErrorsOnCreatingResource: {}, + possibleErrorsOnDeletingResource: {}, + possibleErrorsOnFindingResource: {}, + possibleErrorsOnListingResource: {}, + possibleErrorsOnMutatingResource: {}, +})) + +await import("@/features/workspace-members/api/public") + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (procedure) => + procedure.route.method === method && procedure.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgsAtImport = workspaceTokenAuthAPIForScope.mock.calls.map( + ([scope]) => scope, +) +const context = { workspace: { id: "workspace-1", ownerId: "owner-1" } } +const permissions = { + superAdmin: false, + analytics: true, + flows: false, + contacts: true, + onlyAssignedContacts: false, + emailAndPhone: false, + broadcast: false, + ecommerce: false, +} +const updateInput = { + memberId: "member-1", + permissions, + notificationTypes: { + notifyAdmin: true, + newMessageToHuman: false, + newOrder: false, + }, + notificationChannels: { + messenger: false, + email: true, + telegram: false, + browser: true, + }, +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers list and get under inbox and writes under workspace", () => { + expect(scopeArgsAtImport).toEqual(["inbox", "workspace"]) +}) + +describe("GET /v1/members", () => { + const procedure = findProcedure("GET", "/v1/members") + + test("lists members in the authenticated workspace", async () => { + const result = { data: [{ id: "member-1" }], pageCount: 2 } + listWorkspaceMembers.mockResolvedValueOnce(result) + + await expect( + procedure.handler?.({ + context, + input: { page: 2, perPage: 25, keyword: "Ada" }, + }), + ).resolves.toEqual(result) + + expect(listWorkspaceMembers).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + page: 2, + perPage: 25, + keyword: "Ada", + }) + }) +}) + +describe("GET /v1/members/{memberId}", () => { + const procedure = findProcedure("GET", "/v1/members/{memberId}") + + test("gets a member in the authenticated workspace", async () => { + const member = { id: "member-1", user: { id: "user-1" } } + getWorkspaceMember.mockResolvedValueOnce(member) + + await expect( + procedure.handler?.({ context, input: { memberId: "member-1" } }), + ).resolves.toEqual(member) + + expect(getWorkspaceMember).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + memberId: "member-1", + }) + }) + + test("rejects a missing member instead of returning an empty response", async () => { + getWorkspaceMember.mockResolvedValueOnce(undefined) + + await expect( + procedure.handler?.({ context, input: { memberId: "missing" } }), + ).rejects.toThrow("Member not found") + }) +}) + +describe("POST /v1/members/invitations", () => { + const procedure = findProcedure("POST", "/v1/members/invitations") + + test("creates an invitation attributed to the workspace owner", async () => { + const invitation = { id: "invitation-1", code: "invite-code" } + invitationService.create.mockResolvedValueOnce(invitation) + + await expect( + procedure.handler?.({ context, input: { permissions } }), + ).resolves.toEqual(invitation) + + expect(invitationService.create).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + permissions, + invitedBy: "owner-1", + }) + }) +}) + +describe("PUT /v1/members/{memberId}", () => { + const procedure = findProcedure("PUT", "/v1/members/{memberId}") + + test("updates and re-reads the member in the authenticated workspace", async () => { + const updatedMember = { id: "member-1", user: { id: "user-1" } } + workspaceMemberService.updateMember.mockResolvedValueOnce({ + id: "member-1", + }) + workspaceMemberService.findByIdOrFail.mockResolvedValueOnce(updatedMember) + + await expect( + procedure.handler?.({ context, input: updateInput }), + ).resolves.toEqual(updatedMember) + + expect(workspaceMemberService.updateMember).toHaveBeenCalledWith({ + id: "member-1", + workspaceId: "workspace-1", + data: { + permissions, + notificationTypes: updateInput.notificationTypes, + notificationChannels: updateInput.notificationChannels, + }, + }) + expect(workspaceMemberService.findByIdOrFail).toHaveBeenCalledWith({ + id: "member-1", + workspaceId: "workspace-1", + }) + }) +}) + +describe("DELETE /v1/members/{memberId}", () => { + const procedure = findProcedure("DELETE", "/v1/members/{memberId}") + + test("removes a non-owner member in the authenticated workspace", async () => { + workspaceMemberService.findByIdOrFail.mockResolvedValueOnce({ + id: "member-1", + role: "agent", + }) + workspaceMemberService.delete.mockResolvedValueOnce(undefined) + + await expect( + procedure.handler?.({ context, input: { memberId: "member-1" } }), + ).resolves.toBeUndefined() + + expect(workspaceMemberService.findByIdOrFail).toHaveBeenCalledWith({ + id: "member-1", + workspaceId: "workspace-1", + }) + expect(workspaceMemberService.delete).toHaveBeenCalledWith({ + id: "member-1", + workspaceId: "workspace-1", + }) + }) + + test("rejects removing the owner before calling workspaceMemberService.delete", async () => { + workspaceMemberService.findByIdOrFail.mockResolvedValueOnce({ + id: "owner-member-1", + role: "owner", + }) + + await expect( + procedure.handler?.({ context, input: { memberId: "owner-member-1" } }), + ).rejects.toThrow("You cannot delete the owner of the workspace") + + expect(workspaceMemberService.delete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/builder/__tests__/workspace-members-public-scope.test.ts b/apps/builder/__tests__/workspace-members-public-scope.test.ts new file mode 100644 index 0000000000..21aff3d0ff --- /dev/null +++ b/apps/builder/__tests__/workspace-members-public-scope.test.ts @@ -0,0 +1,309 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + findWorkspaceByTokenHash, + isWorkspaceScheduledForDeletion, + getAccessState, + isAtLimit, + assertApiNotRateLimited, +} = vi.hoisted(() => ({ + findWorkspaceByTokenHash: vi.fn(), + isWorkspaceScheduledForDeletion: vi.fn().mockReturnValue(false), + getAccessState: vi.fn().mockResolvedValue({ blocked: false }), + isAtLimit: vi.fn().mockResolvedValue(false), + assertApiNotRateLimited: vi.fn().mockResolvedValue(undefined), +})) + +const invitationService = { create: vi.fn() } +const workspaceMemberService = { + delete: vi.fn(), + findByIdOrFail: vi.fn(), + findByIdWithUser: vi.fn(), + listPaginated: vi.fn(), + updateMember: vi.fn(), +} + +vi.mock("@chatbotx.io/business", () => ({ + invitationService, + workspaceApiTokenService: { findWorkspaceByTokenHash }, + isWorkspaceScheduledForDeletion, + quotaEnforcementService: { isAtLimit }, + userQuotaService: { getAccessState }, + workspaceMemberService, +})) + +vi.mock("@/lib/log", () => ({ + logger: { warn: vi.fn(), error: vi.fn() }, +})) + +vi.mock("@/lib/rate-limit/api-rate-limit", () => ({ + assertApiNotRateLimited, +})) + +vi.mock("@/lib/rate-limit/guest-rate-limit", () => ({ + getGuestClientIp: () => "203.0.113.9", +})) + +vi.mock("@/env", () => ({ isCloud: () => true })) + +vi.mock("@/middlewares/auth", () => ({ + authMiddleware: vi.fn(), +})) + +const { call } = await import("@orpc/server") +const { workspaceMembersPublicRouter } = await import( + "../src/features/workspace-members/api/public" +) + +const TOKEN = "cbx_ws_fixture" +const MEMBER_ID = "999999" + +const permissions = { + superAdmin: true, + analytics: true, + flows: true, + contacts: true, + onlyAssignedContacts: true, + emailAndPhone: true, + broadcast: true, + ecommerce: true, +} + +const updateInput = { + memberId: MEMBER_ID, + permissions, + notificationTypes: { + notifyAdmin: true, + newMessageToHuman: false, + newOrder: false, + }, + notificationChannels: { + messenger: false, + email: true, + telegram: false, + browser: true, + }, +} + +const authResult = ( + scopes: string[] | null, + permission: "full" | "read_only" = "full", +) => ({ + workspace: { id: "ws-1", ownerId: "owner-1" }, + apiToken: { id: "token-1", permission, scopes }, +}) + +const invoke = (procedure: unknown, input: unknown = {}) => + call(procedure as Parameters[0], input, { + context: { headers: new Headers({ Authorization: `Bearer ${TOKEN}` }) }, + }) + +beforeEach(() => { + vi.clearAllMocks() + isWorkspaceScheduledForDeletion.mockReturnValue(false) + getAccessState.mockResolvedValue({ blocked: false }) + isAtLimit.mockResolvedValue(false) + assertApiNotRateLimited.mockResolvedValue(undefined) +}) + +describe("real router: workspace members public API scope wiring", () => { + test("an unrelated scope is denied list with the inbox scope error", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["contacts"])) + + await expect( + invoke(workspaceMembersPublicRouter.list), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'inbox' scope", + }) + }) + + test("an inbox-scoped token can call list", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["inbox"])) + workspaceMemberService.listPaginated.mockResolvedValue({ + data: [], + pageCount: 1, + }) + + await expect(invoke(workspaceMembersPublicRouter.list)).resolves.toEqual({ + data: [], + pageCount: 1, + }) + + expect(workspaceMemberService.listPaginated).toHaveBeenCalledWith({ + workspaceId: "ws-1", + page: 1, + perPage: 50, + keyword: null, + }) + }) + + test("an inbox-scoped token can call get", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["inbox"])) + workspaceMemberService.findByIdWithUser.mockResolvedValue(undefined) + + await expect( + invoke(workspaceMembersPublicRouter.get, { memberId: MEMBER_ID }), + ).rejects.toThrow("Member not found") + + expect(workspaceMemberService.findByIdWithUser).toHaveBeenCalledWith({ + id: MEMBER_ID, + workspaceId: "ws-1", + }) + }) + + test.each([ + [ + "POST /v1/members/invitations", + () => invoke(workspaceMembersPublicRouter.invite, { permissions }), + ], + [ + "PUT /v1/members/{memberId}", + () => invoke(workspaceMembersPublicRouter.update, updateInput), + ], + [ + "DELETE /v1/members/{memberId}", + () => + invoke(workspaceMembersPublicRouter.remove, { memberId: MEMBER_ID }), + ], + ])("an inbox-scoped token is denied %s with the workspace scope error", async (_label, run) => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["inbox"])) + + await expect(run()).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'workspace' scope", + }) + + expect(invitationService.create).not.toHaveBeenCalled() + expect(workspaceMemberService.updateMember).not.toHaveBeenCalled() + expect(workspaceMemberService.delete).not.toHaveBeenCalled() + }) + + test.each([ + [ + "POST /v1/members/invitations", + () => invoke(workspaceMembersPublicRouter.invite, { permissions }), + ], + [ + "PUT /v1/members/{memberId}", + () => invoke(workspaceMembersPublicRouter.update, updateInput), + ], + [ + "DELETE /v1/members/{memberId}", + () => + invoke(workspaceMembersPublicRouter.remove, { memberId: MEMBER_ID }), + ], + ])("a read_only token is denied %s before any write service call", async (_label, run) => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(null, "read_only")) + + await expect(run()).rejects.toMatchObject({ code: "FORBIDDEN" }) + + expect(invitationService.create).not.toHaveBeenCalled() + expect(workspaceMemberService.updateMember).not.toHaveBeenCalled() + expect(workspaceMemberService.delete).not.toHaveBeenCalled() + }) + + test("a workspace-scoped token can call invite", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["workspace"])) + invitationService.create.mockResolvedValue({ + id: "123456", + code: "invite-code", + permissions, + workspaceId: "ws-1", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }) + + const invitation = await invoke(workspaceMembersPublicRouter.invite, { + permissions, + }) + + expect(invitation).toMatchObject({ id: "123456", code: "invite-code" }) + expect(invitation).not.toHaveProperty("workspaceId") + + expect(invitationService.create).toHaveBeenCalledWith({ + workspaceId: "ws-1", + permissions, + invitedBy: "owner-1", + }) + }) + + test("a workspace-scoped token can call update", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["workspace"])) + workspaceMemberService.updateMember.mockRejectedValue( + new Error("Workspace member update failed"), + ) + + await expect( + invoke(workspaceMembersPublicRouter.update, updateInput), + ).rejects.toThrow("Workspace member update failed") + + expect(workspaceMemberService.updateMember).toHaveBeenCalledWith({ + id: MEMBER_ID, + workspaceId: "ws-1", + data: { + permissions, + notificationTypes: updateInput.notificationTypes, + notificationChannels: updateInput.notificationChannels, + }, + }) + // A failed update must never reach the post-update read. + expect(workspaceMemberService.findByIdOrFail).not.toHaveBeenCalled() + }) + + test("a workspace-scoped token can call remove", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["workspace"])) + workspaceMemberService.findByIdOrFail.mockRejectedValue( + new Error("Workspace member not found"), + ) + + await expect( + invoke(workspaceMembersPublicRouter.remove, { memberId: MEMBER_ID }), + ).rejects.toThrow("Workspace member not found") + + expect(workspaceMemberService.findByIdOrFail).toHaveBeenCalledWith({ + id: MEMBER_ID, + workspaceId: "ws-1", + }) + }) + + test("a workspace-scoped token is denied list and get with the inbox scope error", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["workspace"])) + + await expect( + invoke(workspaceMembersPublicRouter.list), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'inbox' scope", + }) + await expect( + invoke(workspaceMembersPublicRouter.get, { memberId: MEMBER_ID }), + ).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'inbox' scope", + }) + }) + + test("remove scopes the client member id to the token workspace", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(null)) + workspaceMemberService.findByIdOrFail.mockResolvedValue({ + id: MEMBER_ID, + role: "agent", + }) + workspaceMemberService.delete.mockResolvedValue(undefined) + + await invoke(workspaceMembersPublicRouter.remove, { memberId: MEMBER_ID }) + + expect(workspaceMemberService.findByIdOrFail).toHaveBeenCalledWith({ + id: MEMBER_ID, + workspaceId: "ws-1", + }) + expect(workspaceMemberService.delete).toHaveBeenCalledWith({ + id: MEMBER_ID, + workspaceId: "ws-1", + }) + }) +}) diff --git a/apps/builder/__tests__/workspace-public-api.test.ts b/apps/builder/__tests__/workspace-public-api.test.ts new file mode 100644 index 0000000000..16dece4faf --- /dev/null +++ b/apps/builder/__tests__/workspace-public-api.test.ts @@ -0,0 +1,268 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import { z } from "zod" + +type RouteConfig = { + method: string + path: string + summary: string + tags: string[] + successStatus?: number +} + +type CapturedHandler = (args: { + context: { workspace: { id: string } } + input: unknown +}) => Promise + +type CapturedProcedure = { + route: RouteConfig + handler?: CapturedHandler +} + +const { workspaceTokenAuthAPIForScope, capturedProcedures } = vi.hoisted(() => { + const capturedProcedures: CapturedProcedure[] = [] + + const makeProcedure = (route: RouteConfig) => { + const record: CapturedProcedure = { route } + capturedProcedures.push(record) + + const chain = { + input: vi.fn(() => chain), + output: vi.fn(() => chain), + errors: vi.fn(() => chain), + handler: vi.fn((fn: CapturedHandler) => { + record.handler = fn + return { handler: fn } + }), + } + return chain + } + + const workspaceTokenAuthAPI = { + route: vi.fn((config: RouteConfig) => makeProcedure(config)), + } + + return { + workspaceTokenAuthAPIForScope: vi.fn( + (_scope: string) => workspaceTokenAuthAPI, + ), + capturedProcedures, + } +}) + +vi.mock("@/orpc", () => ({ workspaceTokenAuthAPIForScope })) + +const channelTokenRefreshService = { refreshWorkspace: vi.fn() } +const workspaceLifecycleService = { freezeWorkspaceRuntime: vi.fn() } +const workspaceService = { + findById: vi.fn(), + update: vi.fn(), + scheduleDeletion: vi.fn(), + cancelDeletion: vi.fn(), +} +const workspaceSupportAccessService = { enable: vi.fn(), disable: vi.fn() } + +vi.mock("@chatbotx.io/business", () => ({ + channelTokenRefreshService, + workspaceLifecycleService, + workspaceService, + workspaceSupportAccessService, +})) + +vi.mock("@chatbotx.io/integration-instagram", () => ({ integration: {} })) +vi.mock("@chatbotx.io/integration-instagram-facebook", () => ({ + integration: {}, +})) +vi.mock("@chatbotx.io/integration-messenger", () => ({ integration: {} })) +vi.mock("@chatbotx.io/integration-whatsapp", () => ({ integration: {} })) + +vi.mock("@/features/workspaces/schema/public", () => ({ + refreshChannelTokensPublicResponse: z.object({}), + updateWorkspacePublicRequest: z.object({}), + updateWorkspaceStatusPublicRequest: z.object({}), + updateWorkspaceSupportAccessPublicRequest: z.object({}), + workspacePublicResource: z.object({}), +})) + +vi.mock("@/lib/orpc/orpc-error-helper", () => ({ + possibleErrorsOnCreatingResource: {}, + possibleErrorsOnDeletingResource: {}, + possibleErrorsOnFindingResource: {}, + possibleErrorsOnMutatingResource: {}, +})) + +await import("@/features/workspaces/api/public") +const { channelTokenRefreshCallbacks } = await import( + "@/features/workspaces/lib/channel-refresh-callbacks" +) + +const findProcedure = (method: string, path: string) => { + const found = capturedProcedures.find( + (procedure) => + procedure.route.method === method && procedure.route.path === path, + ) + if (!found) { + throw new Error(`No procedure registered for ${method} ${path}`) + } + return found +} + +const scopeArgAtImport = workspaceTokenAuthAPIForScope.mock.calls[0]?.[0] +const context = { workspace: { id: "workspace-1" } } + +beforeEach(() => { + vi.clearAllMocks() +}) + +test("registers the workspace public router under the workspace scope", () => { + expect(scopeArgAtImport).toBe("workspace") +}) + +describe("GET /v1/workspace", () => { + const procedure = findProcedure("GET", "/v1/workspace") + + test("gets the authenticated workspace", async () => { + const workspace = { id: "workspace-1", name: "Workspace" } + workspaceService.findById.mockResolvedValueOnce(workspace) + + await expect(procedure.handler?.({ context, input: {} })).resolves.toEqual( + workspace, + ) + + expect(workspaceService.findById).toHaveBeenCalledWith({ + id: "workspace-1", + }) + }) +}) + +describe("PATCH /v1/workspace", () => { + const procedure = findProcedure("PATCH", "/v1/workspace") + + test("updates the authenticated workspace with the submitted settings", async () => { + const input = { name: "Renamed" } + const workspace = { id: "workspace-1", name: "Renamed" } + workspaceService.update.mockResolvedValueOnce(workspace) + + await expect(procedure.handler?.({ context, input })).resolves.toEqual( + workspace, + ) + + expect(workspaceService.update).toHaveBeenCalledWith({ + id: "workspace-1", + data: input, + }) + }) +}) + +describe("PUT /v1/workspace/status", () => { + const procedure = findProcedure("PUT", "/v1/workspace/status") + + test("updates the authenticated workspace status and hours", async () => { + const input = { + isActive: false, + startTime: "09:00", + endTime: "17:00", + } + const workspace = { id: "workspace-1", ...input } + workspaceService.update.mockResolvedValueOnce(workspace) + + await expect(procedure.handler?.({ context, input })).resolves.toEqual( + workspace, + ) + + expect(workspaceService.update).toHaveBeenCalledWith({ + id: "workspace-1", + data: input, + }) + }) +}) + +describe("POST /v1/workspace/deletion", () => { + const procedure = findProcedure("POST", "/v1/workspace/deletion") + + test("schedules deletion and freezes the authenticated workspace runtime", async () => { + const workspace = { id: "workspace-1", scheduledDeletionAt: new Date() } + workspaceService.scheduleDeletion.mockResolvedValueOnce(workspace) + workspaceLifecycleService.freezeWorkspaceRuntime.mockResolvedValueOnce( + undefined, + ) + + await expect(procedure.handler?.({ context, input: {} })).resolves.toEqual( + workspace, + ) + + expect(workspaceService.scheduleDeletion).toHaveBeenCalledWith({ + id: "workspace-1", + }) + expect( + workspaceLifecycleService.freezeWorkspaceRuntime, + ).toHaveBeenCalledWith("workspace-1") + }) +}) + +describe("DELETE /v1/workspace/deletion", () => { + const procedure = findProcedure("DELETE", "/v1/workspace/deletion") + + test("cancels deletion for the authenticated workspace", async () => { + workspaceService.cancelDeletion.mockResolvedValueOnce(undefined) + + await expect( + procedure.handler?.({ context, input: {} }), + ).resolves.toBeUndefined() + + expect(workspaceService.cancelDeletion).toHaveBeenCalledWith({ + id: "workspace-1", + }) + }) +}) + +describe("PUT /v1/workspace/support-access", () => { + const procedure = findProcedure("PUT", "/v1/workspace/support-access") + + test("enables support access for the authenticated workspace", async () => { + workspaceSupportAccessService.enable.mockResolvedValueOnce(undefined) + + await expect( + procedure.handler?.({ context, input: { enabled: true } }), + ).resolves.toBeUndefined() + + expect(workspaceSupportAccessService.enable).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + actorUserId: null, + }) + }) + + test("disables support access for the authenticated workspace", async () => { + workspaceSupportAccessService.disable.mockResolvedValueOnce(undefined) + + await expect( + procedure.handler?.({ context, input: { enabled: false } }), + ).resolves.toBeUndefined() + + expect(workspaceSupportAccessService.disable).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + actorUserId: null, + }) + }) +}) + +describe("POST /v1/workspace/channel-tokens/refresh", () => { + const procedure = findProcedure( + "POST", + "/v1/workspace/channel-tokens/refresh", + ) + + test("refreshes channel tokens for the authenticated workspace", async () => { + const result = { refreshed: 2, failed: 1 } + channelTokenRefreshService.refreshWorkspace.mockResolvedValueOnce(result) + + await expect(procedure.handler?.({ context, input: {} })).resolves.toEqual( + result, + ) + + expect(channelTokenRefreshService.refreshWorkspace).toHaveBeenCalledWith({ + workspaceId: "workspace-1", + ...channelTokenRefreshCallbacks, + }) + }) +}) diff --git a/apps/builder/__tests__/workspace-public-scope.test.ts b/apps/builder/__tests__/workspace-public-scope.test.ts new file mode 100644 index 0000000000..779967eacf --- /dev/null +++ b/apps/builder/__tests__/workspace-public-scope.test.ts @@ -0,0 +1,256 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest" + +const { + findWorkspaceByTokenHash, + isWorkspaceScheduledForDeletion, + getAccessState, + isAtLimit, + assertApiNotRateLimited, +} = vi.hoisted(() => ({ + findWorkspaceByTokenHash: vi.fn(), + isWorkspaceScheduledForDeletion: vi.fn().mockReturnValue(false), + getAccessState: vi.fn().mockResolvedValue({ blocked: false }), + isAtLimit: vi.fn().mockResolvedValue(false), + assertApiNotRateLimited: vi.fn().mockResolvedValue(undefined), +})) + +const channelTokenRefreshService = { refreshWorkspace: vi.fn() } +const workspaceLifecycleService = { freezeWorkspaceRuntime: vi.fn() } +const workspaceService = { + findById: vi.fn(), + update: vi.fn(), + scheduleDeletion: vi.fn(), + cancelDeletion: vi.fn(), +} +const workspaceSupportAccessService = { enable: vi.fn(), disable: vi.fn() } + +vi.mock("@chatbotx.io/business", () => ({ + workspaceApiTokenService: { findWorkspaceByTokenHash }, + isWorkspaceScheduledForDeletion, + userQuotaService: { getAccessState }, + quotaEnforcementService: { isAtLimit }, + channelTokenRefreshService, + workspaceLifecycleService, + workspaceService, + workspaceSupportAccessService, +})) + +vi.mock("@chatbotx.io/integration-instagram", () => ({ integration: {} })) +vi.mock("@chatbotx.io/integration-instagram-facebook", () => ({ + integration: {}, +})) +vi.mock("@chatbotx.io/integration-messenger", () => ({ integration: {} })) +vi.mock("@chatbotx.io/integration-whatsapp", () => ({ integration: {} })) + +vi.mock("@/lib/log", () => ({ + logger: { warn: vi.fn(), error: vi.fn() }, +})) +vi.mock("@/lib/rate-limit/api-rate-limit", () => ({ + assertApiNotRateLimited, +})) +vi.mock("@/lib/rate-limit/guest-rate-limit", () => ({ + getGuestClientIp: () => "203.0.113.9", +})) +vi.mock("@/env", () => ({ isCloud: () => true })) + +// `@/orpc` also exports `authorizedAPI`, which pulls in the full better-auth +// stack via `authMiddleware` — irrelevant here and unsafe to initialize in a +// unit test. Same stub as workspace-token-scope-enforcement.test.ts. +vi.mock("@/middlewares/auth", () => ({ + authMiddleware: vi.fn(), +})) + +const { call } = await import("@orpc/server") +const { workspacePublicRouter } = await import( + "../src/features/workspaces/api/public" +) + +const TOKEN = "cbx_ws_fixture" + +const authResult = ( + scopes: string[] | null, + permission: "full" | "read_only" = "full", +) => ({ + workspace: { id: "ws-1", ownerId: "owner-1" }, + apiToken: { id: "token-1", permission, scopes }, +}) + +const invoke = (procedure: unknown, input: unknown = {}) => + call(procedure as Parameters[0], input, { + context: { headers: new Headers({ Authorization: `Bearer ${TOKEN}` }) }, + }) + +const workspaceResponse = () => ({ + id: "1", + createdAt: new Date("2026-01-01T00:00:00.000Z"), + updatedAt: new Date("2026-01-02T00:00:00.000Z"), + name: "Workspace", + defaultReply: null, + defaultReplyFrequency: "allTime", + targetCountry: null, + language: "en", + timezone: "UTC", + brandColor: "#016DFF", + developmentMode: false, + smartResponseDelaySeconds: null, + isActive: true, + startTime: null, + endTime: null, + logo: null, + scheduledDeletionAt: null, + supportAccessUntil: null, + capiLimitedDataUse: false, + ownerId: "owner-1", + tenantId: "tenant-1", + token: "legacy-token", + workspaceId: "untrusted-workspace", +}) + +beforeEach(() => { + vi.clearAllMocks() + isWorkspaceScheduledForDeletion.mockReturnValue(false) + getAccessState.mockResolvedValue({ blocked: false }) + isAtLimit.mockResolvedValue(false) + assertApiNotRateLimited.mockResolvedValue(undefined) +}) + +describe("real router: workspace public API scope wiring", () => { + test("a contacts-scoped token is denied the real GET /v1/workspace route with FORBIDDEN", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(["contacts"])) + + await expect(invoke(workspacePublicRouter.get)).rejects.toMatchObject({ + code: "FORBIDDEN", + message: "Token is not authorized for the 'workspace' scope", + }) + }) + + test("null scopes (unrestricted) passes the real DELETE /v1/workspace/deletion route", async () => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(null)) + workspaceService.cancelDeletion.mockResolvedValueOnce(undefined) + + await expect( + invoke(workspacePublicRouter.cancelDeletion), + ).resolves.toBeUndefined() + + expect(workspaceService.cancelDeletion).toHaveBeenCalledWith({ id: "ws-1" }) + }) + + test.each([ + [ + "PATCH /v1/workspace", + () => invoke(workspacePublicRouter.update, { name: "Renamed" }), + ], + [ + "PUT /v1/workspace/status", + () => + invoke(workspacePublicRouter.updateStatus, { + isActive: false, + startTime: null, + endTime: null, + }), + ], + [ + "POST /v1/workspace/deletion", + () => invoke(workspacePublicRouter.scheduleDeletion), + ], + [ + "DELETE /v1/workspace/deletion", + () => invoke(workspacePublicRouter.cancelDeletion), + ], + [ + "PUT /v1/workspace/support-access", + () => + invoke(workspacePublicRouter.updateSupportAccess, { enabled: true }), + ], + [ + "POST /v1/workspace/channel-tokens/refresh", + () => invoke(workspacePublicRouter.refreshChannelTokens), + ], + ])("a read_only token is denied %s before any write service call", async (_label, run) => { + findWorkspaceByTokenHash.mockResolvedValue(authResult(null, "read_only")) + + await expect(run()).rejects.toMatchObject({ code: "FORBIDDEN" }) + + expect(workspaceService.update).not.toHaveBeenCalled() + expect(workspaceService.scheduleDeletion).not.toHaveBeenCalled() + expect(workspaceService.cancelDeletion).not.toHaveBeenCalled() + expect( + workspaceLifecycleService.freezeWorkspaceRuntime, + ).not.toHaveBeenCalled() + expect(workspaceSupportAccessService.enable).not.toHaveBeenCalled() + expect(workspaceSupportAccessService.disable).not.toHaveBeenCalled() + expect(channelTokenRefreshService.refreshWorkspace).not.toHaveBeenCalled() + }) + + describe("cross-workspace isolation: workspace identity always comes from the token", () => { + beforeEach(() => { + findWorkspaceByTokenHash.mockResolvedValue( + authResult(null) /* unrestricted scope, full permission */, + ) + }) + + test("gets the authenticated workspace and removes internal fields from its response", async () => { + workspaceService.findById.mockResolvedValueOnce(workspaceResponse()) + + const result = await invoke(workspacePublicRouter.get) + + expect(workspaceService.findById).toHaveBeenCalledWith({ id: "ws-1" }) + expect(result).toMatchObject({ id: "1", name: "Workspace" }) + expect(result).not.toHaveProperty("workspaceId") + expect(result).not.toHaveProperty("token") + expect(result).not.toHaveProperty("ownerId") + expect(result).not.toHaveProperty("tenantId") + }) + + test("updates only the authenticated workspace", async () => { + const input = { name: "Renamed" } + workspaceService.update.mockResolvedValueOnce(workspaceResponse()) + + await invoke(workspacePublicRouter.update, input) + + expect(workspaceService.update).toHaveBeenCalledWith({ + id: "ws-1", + data: input, + }) + }) + + test("enables support access only in the authenticated workspace", async () => { + workspaceSupportAccessService.enable.mockResolvedValueOnce(undefined) + + await invoke(workspacePublicRouter.updateSupportAccess, { enabled: true }) + + expect(workspaceSupportAccessService.enable).toHaveBeenCalledWith({ + workspaceId: "ws-1", + actorUserId: null, + }) + }) + + test("disables support access only in the authenticated workspace", async () => { + workspaceSupportAccessService.disable.mockResolvedValueOnce(undefined) + + await invoke(workspacePublicRouter.updateSupportAccess, { + enabled: false, + }) + + expect(workspaceSupportAccessService.disable).toHaveBeenCalledWith({ + workspaceId: "ws-1", + actorUserId: null, + }) + }) + + test("refreshes channel tokens only in the authenticated workspace", async () => { + channelTokenRefreshService.refreshWorkspace.mockResolvedValueOnce({ + refreshed: 1, + failed: 0, + }) + + await invoke(workspacePublicRouter.refreshChannelTokens) + + expect(channelTokenRefreshService.refreshWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "ws-1" }), + ) + }) + }) +}) diff --git a/apps/builder/__tests__/workspace-token-scope-registry.test.ts b/apps/builder/__tests__/workspace-token-scope-registry.test.ts index c11160f284..1672410f72 100644 --- a/apps/builder/__tests__/workspace-token-scope-registry.test.ts +++ b/apps/builder/__tests__/workspace-token-scope-registry.test.ts @@ -11,20 +11,21 @@ const NEW_SCOPES = [ "appointments", "media", "ads", + "workspace", ] as const describe("workspaceApiTokenScopes", () => { - test("includes the 5 newly named resource-area scopes", () => { + test("includes the 6 newly named resource-area scopes", () => { for (const scope of NEW_SCOPES) { expect(workspaceApiTokenScopes.options).toContain(scope) } - expect(workspaceApiTokenScopes.options).toHaveLength(12) + expect(workspaceApiTokenScopes.options).toHaveLength(13) }) }) describe("orderedWorkspaceApiTokenScopes", () => { - test("has 12 entries with unique, contiguous orders", () => { - expect(orderedWorkspaceApiTokenScopes).toHaveLength(12) + test("has 13 entries with unique, contiguous orders", () => { + expect(orderedWorkspaceApiTokenScopes).toHaveLength(13) const orders = orderedWorkspaceApiTokenScopes .map((scope) => workspaceApiTokenScopeRegistry[scope].order) diff --git a/apps/builder/messages/ar.json b/apps/builder/messages/ar.json index 7525ec609b..fd91cda08c 100644 --- a/apps/builder/messages/ar.json +++ b/apps/builder/messages/ar.json @@ -2309,7 +2309,8 @@ "minigames": "الألعاب المصغرة", "appointments": "المواعيد", "media": "الوسائط", - "ads": "الإعلانات" + "ads": "الإعلانات", + "workspace": "مساحة العمل" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/da.json b/apps/builder/messages/da.json index 503b7cbc7e..fc4bc551e2 100644 --- a/apps/builder/messages/da.json +++ b/apps/builder/messages/da.json @@ -2172,7 +2172,8 @@ "minigames": "Minispil", "appointments": "Aftaler", "media": "Medier", - "ads": "Annoncer" + "ads": "Annoncer", + "workspace": "Arbejdsområde" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/de.json b/apps/builder/messages/de.json index a35b1d092e..ef68a54095 100644 --- a/apps/builder/messages/de.json +++ b/apps/builder/messages/de.json @@ -2172,7 +2172,8 @@ "minigames": "Minispiele", "appointments": "Termine", "media": "Medien", - "ads": "Anzeigen" + "ads": "Anzeigen", + "workspace": "Arbeitsbereich" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/en.json b/apps/builder/messages/en.json index b8b950e9c9..1abe639fe1 100644 --- a/apps/builder/messages/en.json +++ b/apps/builder/messages/en.json @@ -2309,7 +2309,8 @@ "minigames": "Minigames", "appointments": "Appointments", "media": "Media", - "ads": "Ads" + "ads": "Ads", + "workspace": "Workspace" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/es.json b/apps/builder/messages/es.json index 72604ffa44..cda800f174 100644 --- a/apps/builder/messages/es.json +++ b/apps/builder/messages/es.json @@ -2309,7 +2309,8 @@ "minigames": "Minijuegos", "appointments": "Citas", "media": "Medios", - "ads": "Anuncios" + "ads": "Anuncios", + "workspace": "Espacio de trabajo" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/fi.json b/apps/builder/messages/fi.json index d74097fc9e..ae29dfdf3e 100644 --- a/apps/builder/messages/fi.json +++ b/apps/builder/messages/fi.json @@ -2172,7 +2172,8 @@ "minigames": "Minipelit", "appointments": "Tapaamiset", "media": "Media", - "ads": "Mainokset" + "ads": "Mainokset", + "workspace": "Työtila" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/fr.json b/apps/builder/messages/fr.json index a66bcfeddf..2fbce4762b 100644 --- a/apps/builder/messages/fr.json +++ b/apps/builder/messages/fr.json @@ -2172,7 +2172,8 @@ "minigames": "Mini-jeux", "appointments": "Rendez-vous", "media": "Médias", - "ads": "Publicités" + "ads": "Publicités", + "workspace": "Espace de travail" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/he.json b/apps/builder/messages/he.json index 95a92eb866..60f8e9fda2 100644 --- a/apps/builder/messages/he.json +++ b/apps/builder/messages/he.json @@ -4828,7 +4828,8 @@ "minigames": "משחונים", "appointments": "פגישות", "media": "מדיה", - "ads": "מודעות" + "ads": "מודעות", + "workspace": "סביבת עבודה" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/id.json b/apps/builder/messages/id.json index e73a2f9714..f7e98d3401 100644 --- a/apps/builder/messages/id.json +++ b/apps/builder/messages/id.json @@ -2172,7 +2172,8 @@ "minigames": "Minigame", "appointments": "Janji Temu", "media": "Media", - "ads": "Iklan" + "ads": "Iklan", + "workspace": "Workspace" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/it.json b/apps/builder/messages/it.json index 7a48bdc14d..f53894388b 100644 --- a/apps/builder/messages/it.json +++ b/apps/builder/messages/it.json @@ -1445,7 +1445,8 @@ "minigames": "Minigiochi", "appointments": "Appuntamenti", "media": "Media", - "ads": "Annunci" + "ads": "Annunci", + "workspace": "Spazio di lavoro" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/ja.json b/apps/builder/messages/ja.json index 1649dfc53f..831a083cb0 100644 --- a/apps/builder/messages/ja.json +++ b/apps/builder/messages/ja.json @@ -2172,7 +2172,8 @@ "minigames": "ミニゲーム", "appointments": "予約", "media": "メディア", - "ads": "広告" + "ads": "広告", + "workspace": "ワークスペース" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/nl.json b/apps/builder/messages/nl.json index e3003401cf..6fe9c8f89d 100644 --- a/apps/builder/messages/nl.json +++ b/apps/builder/messages/nl.json @@ -1985,7 +1985,8 @@ "minigames": "Minigames", "appointments": "Afspraken", "media": "Media", - "ads": "Advertenties" + "ads": "Advertenties", + "workspace": "Werkruimte" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/pt-BR.json b/apps/builder/messages/pt-BR.json index 3e31d80fb4..06f1b4a2cf 100644 --- a/apps/builder/messages/pt-BR.json +++ b/apps/builder/messages/pt-BR.json @@ -2172,7 +2172,8 @@ "minigames": "Minijogos", "appointments": "Agendamentos", "media": "Mídia", - "ads": "Anúncios" + "ads": "Anúncios", + "workspace": "Espaço de trabalho" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/pt-PT.json b/apps/builder/messages/pt-PT.json index 82b510891e..a7939b142e 100644 --- a/apps/builder/messages/pt-PT.json +++ b/apps/builder/messages/pt-PT.json @@ -2172,7 +2172,8 @@ "minigames": "Minijogos", "appointments": "Marcações", "media": "Multimédia", - "ads": "Anúncios" + "ads": "Anúncios", + "workspace": "Espaço de trabalho" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/ro.json b/apps/builder/messages/ro.json index 1d97e02ed6..0bfa7d47cb 100644 --- a/apps/builder/messages/ro.json +++ b/apps/builder/messages/ro.json @@ -2956,7 +2956,8 @@ "minigames": "Minijocuri", "appointments": "Programări", "media": "Media", - "ads": "Reclame" + "ads": "Reclame", + "workspace": "Spațiu de lucru" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/sv.json b/apps/builder/messages/sv.json index fc2acd3eb6..f68c8a9783 100644 --- a/apps/builder/messages/sv.json +++ b/apps/builder/messages/sv.json @@ -3007,7 +3007,8 @@ "minigames": "Minispel", "appointments": "Bokningar", "media": "Media", - "ads": "Annonser" + "ads": "Annonser", + "workspace": "Arbetsyta" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/tr.json b/apps/builder/messages/tr.json index 25743dae84..6f7b8fc689 100644 --- a/apps/builder/messages/tr.json +++ b/apps/builder/messages/tr.json @@ -2309,7 +2309,8 @@ "minigames": "Mini Oyunlar", "appointments": "Randevular", "media": "Medya", - "ads": "Reklamlar" + "ads": "Reklamlar", + "workspace": "Çalışma alanı" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/vi.json b/apps/builder/messages/vi.json index a055eb2ce7..dcb0711c67 100644 --- a/apps/builder/messages/vi.json +++ b/apps/builder/messages/vi.json @@ -2309,7 +2309,8 @@ "minigames": "Minigame", "appointments": "Lịch hẹn", "media": "Phương tiện", - "ads": "Quảng cáo" + "ads": "Quảng cáo", + "workspace": "Không gian làm việc" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/zh-CN.json b/apps/builder/messages/zh-CN.json index 550ab52926..89e8b907f6 100644 --- a/apps/builder/messages/zh-CN.json +++ b/apps/builder/messages/zh-CN.json @@ -3010,7 +3010,8 @@ "minigames": "小游戏", "appointments": "预约", "media": "媒体", - "ads": "广告" + "ads": "广告", + "workspace": "工作区" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/messages/zh-TW.json b/apps/builder/messages/zh-TW.json index 62f7f09300..cedae92dc1 100644 --- a/apps/builder/messages/zh-TW.json +++ b/apps/builder/messages/zh-TW.json @@ -1445,7 +1445,8 @@ "minigames": "小遊戲", "appointments": "預約", "media": "媒體", - "ads": "廣告" + "ads": "廣告", + "workspace": "工作區" }, "adReferral": { "label": "Ads", diff --git a/apps/builder/src/enterprise/features/audit-logs/api/public.ts b/apps/builder/src/enterprise/features/audit-logs/api/public.ts new file mode 100644 index 0000000000..2b5d2aee1d --- /dev/null +++ b/apps/builder/src/enterprise/features/audit-logs/api/public.ts @@ -0,0 +1,44 @@ +import { assertEnterpriseFeatures } from "@chatbotx.io/business" +import { listAuditLogs } from "@chatbotx.io/business/audit" +import { possibleErrorsOnListingEnterpriseResource } from "@/lib/orpc/orpc-error-helper" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { + listAuditLogsPublicRequest, + listAuditLogsPublicResponse, +} from "../schema/public" +import { parseAuditLogsDateRange } from "../schema/query" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("workspace") + +const tags = ["Audit Logs"] + +export const auditLogsPublicRouter = { + list: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/audit-logs", + summary: "List audit logs", + tags, + }) + .input(listAuditLogsPublicRequest) + .output(listAuditLogsPublicResponse) + .errors(possibleErrorsOnListingEnterpriseResource) + .handler(async ({ context, input }) => { + await assertEnterpriseFeatures() + + const dateRange = parseAuditLogsDateRange(input) + + return await listAuditLogs({ + workspaceId: context.workspace.id, + page: input.page, + perPage: input.perPage, + sort: input.sort, + keyword: input.keyword, + userId: input.userId, + dateRange: { + start: dateRange.start, + end: dateRange.end, + }, + }) + }), +} diff --git a/apps/builder/src/enterprise/features/audit-logs/schema/public.ts b/apps/builder/src/enterprise/features/audit-logs/schema/public.ts new file mode 100644 index 0000000000..fd391ba515 --- /dev/null +++ b/apps/builder/src/enterprise/features/audit-logs/schema/public.ts @@ -0,0 +1,43 @@ +import { z } from "zod" +import { publicListRequest, publicListResponse } from "@/lib/public-api/list" +import { getDefaultAuditLogsRange } from "./query" + +const defaultAuditLogsRange = getDefaultAuditLogsRange() + +const auditLogUserPublicResource = z.object({ + id: z.string(), + name: z.string().nullable(), + image: z.string().nullable(), +}) + +export const auditLogPublicResource = z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + action: z.string(), + detail: z.string(), + ipAddress: z.string().nullable(), + userAgent: z.string().nullable(), + source: z.string().nullable(), + userId: z.string().nullable(), + user: auditLogUserPublicResource.nullable(), +}) + +export const listAuditLogsPublicRequest = publicListRequest.extend({ + from: z.string().default(defaultAuditLogsRange.from), + to: z.string().default(defaultAuditLogsRange.to), + sort: z + .array( + z.object({ + id: z.string(), + desc: z.boolean(), + }), + ) + .default([{ id: "createdAt", desc: true }]), + keyword: z.string().optional(), + userId: z.string().optional(), +}) + +export const listAuditLogsPublicResponse = publicListResponse( + auditLogPublicResource, +) diff --git a/apps/builder/src/features/templates/actions/install-template.action.ts b/apps/builder/src/features/templates/actions/install-template.action.ts index 9ec05ab2be..23f4c792a1 100644 --- a/apps/builder/src/features/templates/actions/install-template.action.ts +++ b/apps/builder/src/features/templates/actions/install-template.action.ts @@ -3,7 +3,6 @@ import { templateService } from "@chatbotx.io/business" import { ChatbotXException } from "@chatbotx.io/business/errors" import { zodBigintAsString } from "@chatbotx.io/utils" -import { DefaultJobAction, defaultQueue } from "@chatbotx.io/worker-config" import { hasWorkspacePermission } from "@/lib/auth/permission-routes" import { workspaceActionClient } from "@/lib/safe-action" import { installTemplateRequest } from "../schema/mutation" @@ -17,9 +16,9 @@ import { installTemplateRequest } from "../schema/mutation" * `templateService.assertInstallable` then enforces the same-tenant gate on * top of that membership check. * - * Follows the `import-products.action.ts` shape: create the tracking row - * first, enqueue inside a try, and mark the row failed on enqueue failure - * so it is never left stuck at `pending`. + * The install/enqueue/failure-compensation sequence itself lives in + * `templateService.enqueueInstallation`, shared with the public + * `POST /v1/templates/installations` route. */ export const installTemplateAction = workspaceActionClient .bindArgsSchemas([zodBigintAsString()]) @@ -42,37 +41,12 @@ export const installTemplateAction = workspaceActionClient ) } - const { template } = await templateService.assertInstallable({ + const installation = await templateService.enqueueInstallation({ shareToken: parsedInput.shareToken, - targetWorkspaceId, - }) - - const installation = await templateService.createInstallationRecord({ workspaceId: targetWorkspaceId, installedBy: user.id, - template, }) - try { - await defaultQueue.add( - DefaultJobAction.installTemplate, - { - type: DefaultJobAction.installTemplate, - data: { - installationId: installation.id, - workspaceId: targetWorkspaceId, - }, - }, - { jobId: `install-template-${installation.id}` }, - ) - } catch (error) { - await templateService.markInstallationFailed({ - installationId: installation.id, - errorMessage: "Unable to queue template install", - }) - throw error - } - return { installationId: installation.id } }, ) diff --git a/apps/builder/src/features/templates/api/public.ts b/apps/builder/src/features/templates/api/public.ts new file mode 100644 index 0000000000..7deb178d0b --- /dev/null +++ b/apps/builder/src/features/templates/api/public.ts @@ -0,0 +1,276 @@ +import { templateService } from "@chatbotx.io/business" +import type { + TemplateInstallationModel, + TemplateModel, +} from "@chatbotx.io/database/types" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" +import { paginateInMemory, publicListRequest } from "@/lib/public-api/list" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { + createTemplatePublicRequest, + installTemplatePublicRequest, + installTemplatePublicResponse, + listSelectableTemplateResourcesPublicRequest, + listSelectableTemplateResourcesPublicResponse, + listTemplateInstallationsPublicResponse, + listTemplatesPublicResponse, + templatePublicRequestParams, + templatePublicResource, + updateTemplateInstallationAutoUpdatePublicRequest, + updateTemplatePublicRequest, + updateTemplateShareSettingsPublicRequest, +} from "../schema/public" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("workspace") + +const tags = ["Templates"] + +const toPublicTemplateResource = (template: TemplateModel) => ({ + id: template.id, + name: template.name, + description: template.description, + imageUrl: template.imageUrl, + publisherName: template.publisherName, + youtubeVideoId: template.youtubeVideoId, + testLink: template.testLink, + shareEnabled: template.shareEnabled, + shareToken: template.shareToken, + shareExpiresAt: template.shareExpiresAt, + categoryCounts: template.categoryCounts, + createInstallFolder: template.createInstallFolder, + defaultAutoUpdate: template.defaultAutoUpdate, + createdAt: template.createdAt, + updatedAt: template.updatedAt, +}) + +const toPublicTemplateInstallationResource = ( + installation: TemplateInstallationModel, +) => ({ + id: installation.id, + templateId: installation.templateId, + templateName: installation.templateName, + status: installation.status, + warningCount: installation.warningCount, + errorMessage: installation.errorMessage, + resourceCount: installation.resourceCount, + installFolderId: installation.installFolderId, + autoUpdate: installation.autoUpdate, + sourceUpdatedAt: installation.sourceUpdatedAt, + completedAt: installation.completedAt, + createdAt: installation.createdAt, + updatedAt: installation.updatedAt, +}) + +export const templatesPublicRouter = { + list: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/templates", + summary: "List templates", + tags, + }) + .input(publicListRequest) + .output(listTemplatesPublicResponse) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => { + const page = paginateInMemory( + await templateService.list(context.workspace.id), + input, + ) + return { + ...page, + data: page.data.map(toPublicTemplateResource), + } + }), + + listSelectableResources: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/templates/selectable-resources", + summary: "List resources selectable for a template", + tags, + }) + .input(listSelectableTemplateResourcesPublicRequest) + .output(listSelectableTemplateResourcesPublicResponse) + .errors(possibleErrorsOnListingResource) + .handler( + async ({ context, input }) => + await templateService.listSelectableResources({ + ...input, + workspaceId: context.workspace.id, + }), + ), + + listInstallations: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/templates/installations", + summary: "List template installations", + tags, + }) + .input(publicListRequest) + .output(listTemplateInstallationsPublicResponse) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => { + const page = paginateInMemory( + await templateService.listInstallations(context.workspace.id), + input, + ) + return { + ...page, + data: page.data.map(toPublicTemplateInstallationResource), + } + }), + + create: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/templates", + summary: "Create a template", + successStatus: 201, + tags, + }) + .input(createTemplatePublicRequest) + .output(templatePublicResource) + .errors(possibleErrorsOnCreatingResource) + .handler(async ({ context, input }) => + toPublicTemplateResource( + await templateService.createOrUpdate({ + ...input, + workspaceId: context.workspace.id, + tenantId: context.workspace.tenantId, + createdBy: null, + }), + ), + ), + + install: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/templates/installations", + summary: "Queue a template installation", + successStatus: 202, + tags, + }) + .input(installTemplatePublicRequest) + .output(installTemplatePublicResponse) + .errors(possibleErrorsOnCreatingResource) + .handler(async ({ context, input }) => { + const installation = await templateService.enqueueInstallation({ + shareToken: input.shareToken, + workspaceId: context.workspace.id, + installedBy: null, + }) + + return { + installationId: installation.id, + status: installation.status, + } + }), + + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/templates/{id}", + summary: "Get a template", + tags, + }) + .input(templatePublicRequestParams) + .output(templatePublicResource) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => + toPublicTemplateResource( + await templateService.findByIdOrFail({ + workspaceId: context.workspace.id, + templateId: input.id, + }), + ), + ), + + update: workspaceTokenAuthAPI + .route({ + method: "PATCH", + path: "/v1/templates/{id}", + summary: "Update a template", + tags, + }) + .input(updateTemplatePublicRequest) + .output(templatePublicResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { id, ...template } = input + return toPublicTemplateResource( + await templateService.createOrUpdate({ + ...template, + workspaceId: context.workspace.id, + tenantId: context.workspace.tenantId, + createdBy: null, + existingTemplateId: id, + }), + ) + }), + + delete: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/templates/{id}", + summary: "Delete a template", + successStatus: 204, + tags, + }) + .input(templatePublicRequestParams) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context, input }) => { + await templateService.softDelete({ + workspaceId: context.workspace.id, + templateId: input.id, + }) + }), + + updateShareSettings: workspaceTokenAuthAPI + .route({ + method: "PATCH", + path: "/v1/templates/{id}/share-settings", + summary: "Update a template's share settings", + tags, + }) + .input(updateTemplateShareSettingsPublicRequest) + .output(templatePublicResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => + toPublicTemplateResource( + await templateService.updateShareSettings({ + workspaceId: context.workspace.id, + templateId: input.id, + shareEnabled: input.shareEnabled, + shareExpiresAt: input.shareExpiresAt + ? new Date(input.shareExpiresAt) + : null, + }), + ), + ), + + updateInstallationAutoUpdate: workspaceTokenAuthAPI + .route({ + method: "PATCH", + path: "/v1/templates/installations/{id}/auto-update", + summary: "Update a template installation's automatic update setting", + successStatus: 204, + tags, + }) + .input(updateTemplateInstallationAutoUpdatePublicRequest) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + await templateService.updateInstallationAutoUpdate({ + workspaceId: context.workspace.id, + installationId: input.id, + autoUpdate: input.autoUpdate, + }) + }), +} diff --git a/apps/builder/src/features/templates/schema/public.ts b/apps/builder/src/features/templates/schema/public.ts new file mode 100644 index 0000000000..51019da6ee --- /dev/null +++ b/apps/builder/src/features/templates/schema/public.ts @@ -0,0 +1,106 @@ +import { + templateCategories, + templateCategoryCountsSchema, + templateInstallationStatuses, +} from "@chatbotx.io/database/partials" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" +import { publicListResponse } from "@/lib/public-api/list" +import { + installTemplateRequest, + saveTemplateRequest, + updateShareSettingsRequest, +} from "./mutation" + +export const templatePublicResource = z.object({ + id: zodBigintAsString(), + name: z.string(), + description: z.string().nullable(), + imageUrl: z.string().nullable(), + publisherName: z.string().nullable(), + youtubeVideoId: z.string().nullable(), + testLink: z.string().nullable(), + shareEnabled: z.boolean(), + shareToken: z.string(), + shareExpiresAt: z.date().nullable(), + categoryCounts: templateCategoryCountsSchema, + createInstallFolder: z.boolean(), + defaultAutoUpdate: z.boolean(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export const listTemplatesPublicResponse = publicListResponse( + templatePublicResource, +) + +export const templatePublicRequestParams = z.object({ + id: zodBigintAsString(), +}) + +const saveTemplatePublicRequest = saveTemplateRequest.omit({ + templateId: true, +}) + +export const createTemplatePublicRequest = saveTemplatePublicRequest + +export const updateTemplatePublicRequest = saveTemplatePublicRequest.extend({ + id: zodBigintAsString(), +}) + +export const updateTemplateShareSettingsPublicRequest = + updateShareSettingsRequest + .omit({ templateId: true }) + .extend({ id: zodBigintAsString() }) + +export const listSelectableTemplateResourcesPublicRequest = z.object({ + category: templateCategories, + keyword: z.string().trim().max(255).optional(), + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), +}) + +const selectableTemplateResourceItem = z.object({ + id: zodBigintAsString(), + name: z.string(), + folderName: z.string().optional(), +}) + +export const listSelectableTemplateResourcesPublicResponse = z.object({ + items: z.array(selectableTemplateResourceItem), + nextCursor: z.string().nullable(), + total: z.number().int().nonnegative(), + allIds: z.array(zodBigintAsString()).optional(), +}) + +export const templateInstallationPublicResource = z.object({ + id: zodBigintAsString(), + templateId: zodBigintAsString().nullable(), + templateName: z.string(), + status: templateInstallationStatuses, + warningCount: z.number().int().nonnegative(), + errorMessage: z.string().nullable(), + resourceCount: z.number().int().nonnegative(), + installFolderId: zodBigintAsString().nullable(), + autoUpdate: z.boolean(), + sourceUpdatedAt: z.date().nullable(), + completedAt: z.date().nullable(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export const listTemplateInstallationsPublicResponse = publicListResponse( + templateInstallationPublicResource, +) + +export const installTemplatePublicRequest = installTemplateRequest + +export const installTemplatePublicResponse = z.object({ + installationId: zodBigintAsString(), + status: templateInstallationStatuses, +}) + +export const updateTemplateInstallationAutoUpdatePublicRequest = z.object({ + id: zodBigintAsString(), + autoUpdate: z.boolean(), +}) diff --git a/apps/builder/src/features/workspace-members/actions/delete-workspace-member.action.ts b/apps/builder/src/features/workspace-members/actions/delete-workspace-member.action.ts index 76a7adb6ef..a776d9855b 100644 --- a/apps/builder/src/features/workspace-members/actions/delete-workspace-member.action.ts +++ b/apps/builder/src/features/workspace-members/actions/delete-workspace-member.action.ts @@ -5,8 +5,6 @@ import { workspaceMemberService, } from "@chatbotx.io/business" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { findOrFail } from "@chatbotx.io/database/client" -import { workspaceMemberModel } from "@chatbotx.io/database/schema" import { invalidateCacheByTags } from "@chatbotx.io/redis" import { zodBigintAsString } from "@chatbotx.io/utils" import { hasWorkspacePermission } from "@/lib/auth/permission-routes" @@ -20,10 +18,9 @@ export const deleteWorkspaceMemberAction = workspaceActionClientAllowExpired bindArgsParsedInputs: [workspaceId, id], } = props - const workspaceMember = await findOrFail({ - table: workspaceMemberModel, - where: { id, workspaceId }, - message: "Workspace member not found", + const workspaceMember = await workspaceMemberService.findByIdOrFail({ + id, + workspaceId, }) if (workspaceMember.role === "owner") { diff --git a/apps/builder/src/features/workspace-members/actions/invite-workspace-member.action.ts b/apps/builder/src/features/workspace-members/actions/invite-workspace-member.action.ts index 64fd9f40e5..171bd087e8 100644 --- a/apps/builder/src/features/workspace-members/actions/invite-workspace-member.action.ts +++ b/apps/builder/src/features/workspace-members/actions/invite-workspace-member.action.ts @@ -1,33 +1,17 @@ "use server" -import { - quotaEnforcementService, - workspaceService, -} from "@chatbotx.io/business" -import { auditService } from "@chatbotx.io/business/audit" +import { invitationService } from "@chatbotx.io/business" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { db } from "@chatbotx.io/database/client" -import { invitationModel } from "@chatbotx.io/database/schema" -import { createId, SymbolicSnowflakeIDs } from "@chatbotx.io/utils" -import { addDays } from "date-fns" -import { isCommunity } from "@/env" import { workspaceIdrequestParams } from "@/features/common/schema" import { hasWorkspacePermission } from "@/lib/auth/permission-routes" import { getCurrentUserAndTargetWorkspace } from "@/lib/auth/utils" import { workspaceActionClient } from "@/lib/safe-action" -import { - getSuperAdminPermissions, - normalizeContactsPermissions, -} from "../helpers" import { inviteWorkspaceMemberRequest } from "../schema/mutation" export const inviteWorkspaceMemberAction = workspaceActionClient .bindArgsSchemas(workspaceIdrequestParams) .inputSchema(inviteWorkspaceMemberRequest) .action(async ({ ctx, parsedInput, bindArgsParsedInputs: [workspaceId] }) => { - // Read-only gate: team-member usage is reconcile-counted after acceptance, - // so block issuing an invitation once the owner is already at the limit. - const workspace = await workspaceService.findById({ id: workspaceId }) const currentUserAndTargetChatbot = await getCurrentUserAndTargetWorkspace(workspaceId) if (!currentUserAndTargetChatbot) { @@ -44,39 +28,10 @@ export const inviteWorkspaceMemberAction = workspaceActionClient ) } - const atLimit = await quotaEnforcementService.hasReachedLimit({ - userId: workspace.ownerId, - metric: "teamMembers", - }) - if (atLimit) { - throw new ChatbotXException( - "Team member limit reached for this workspace plan", - ) - } - - const permissions = isCommunity() - ? getSuperAdminPermissions() - : normalizeContactsPermissions(parsedInput.permissions) - - const invitation = await db - .insert(invitationModel) - .values({ - id: createId(), - code: SymbolicSnowflakeIDs.generate(), - permissions, - expiresAt: addDays(new Date(), 1), - workspaceId, - invitedBy: ctx.user.id, - }) - .returning() - .then((result) => result[0]) - - // No email/name is captured at invite time (invite is a shareable - // code/link, not addressed to a specific person), so the detail can only - // name the role being granted. - await auditService.record({ - action: "invite", - detail: `invited a new ${permissions.superAdmin ? "admin" : "member"}`, + const invitation = await invitationService.create({ + workspaceId, + permissions: parsedInput.permissions, + invitedBy: ctx.user.id, }) return invitation diff --git a/apps/builder/src/features/workspace-members/actions/update-workspace-member.action.ts b/apps/builder/src/features/workspace-members/actions/update-workspace-member.action.ts index fb9ccf5614..765c89bb7f 100644 --- a/apps/builder/src/features/workspace-members/actions/update-workspace-member.action.ts +++ b/apps/builder/src/features/workspace-members/actions/update-workspace-member.action.ts @@ -1,18 +1,12 @@ "use server" import { isDeepStrictEqual } from "node:util" -import { userService, workspaceMemberService } from "@chatbotx.io/business" -import { auditService } from "@chatbotx.io/business/audit" +import { workspaceMemberService } from "@chatbotx.io/business" import { ChatbotXException } from "@chatbotx.io/business/errors" -import { isCommunity } from "@/env" import { workspaceIdAndIdRequestParams } from "@/features/common/schema" import { hasWorkspacePermission } from "@/lib/auth/permission-routes" import { getCurrentUserAndTargetWorkspace } from "@/lib/auth/utils" import { workspaceActionClient } from "@/lib/safe-action" -import { - getSuperAdminPermissions, - normalizeContactsPermissions, -} from "../helpers" import { updateWorkspaceMemberRequest } from "../schema/mutation" export const updateWorkspaceMemberAction = workspaceActionClient @@ -40,15 +34,7 @@ export const updateWorkspaceMemberAction = workspaceActionClient ) } - const updateInput = isCommunity() - ? { - ...parsedInput, - permissions: getSuperAdminPermissions(), - } - : { - ...parsedInput, - permissions: normalizeContactsPermissions(parsedInput.permissions), - } + const updateInput = workspaceMemberService.normalizeUpdateData(parsedInput) const permissionsChanged = !isDeepStrictEqual( workspaceMember.permissions, @@ -73,7 +59,7 @@ export const updateWorkspaceMemberAction = workspaceActionClient return } - const updated = await workspaceMemberService.update({ + const updated = await workspaceMemberService.updateMember({ id: workspaceMember.id, workspaceId, data: updateInput, @@ -82,18 +68,4 @@ export const updateWorkspaceMemberAction = workspaceActionClient if (!updated) { return } - - // Only a real permissions/role change is in the audit-log spec for this - // action — a save that only touches notification settings must not be - // recorded as a "changed role" event. - if (permissionsChanged) { - const targetUser = await userService.findNameAndEmail( - workspaceMember.userId, - ) - - await auditService.record({ - action: "role_change", - detail: `changed role of ${targetUser?.name ?? targetUser?.email ?? "a member"} to ${updateInput.permissions.superAdmin ? "admin" : "member"}`, - }) - } }) diff --git a/apps/builder/src/features/workspace-members/api/public.ts b/apps/builder/src/features/workspace-members/api/public.ts index 411f9287e0..62bc08f56a 100644 --- a/apps/builder/src/features/workspace-members/api/public.ts +++ b/apps/builder/src/features/workspace-members/api/public.ts @@ -1,11 +1,28 @@ -import { notFoundException } from "@chatbotx.io/business/errors" import { + invitationService, + workspaceMemberService, +} from "@chatbotx.io/business" +import { + ChatbotXException, + notFoundException, +} from "@chatbotx.io/business/errors" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, possibleErrorsOnFindingResource, possibleErrorsOnListingResource, + possibleErrorsOnMutatingResource, } from "@/lib/orpc/orpc-error-helper" import { withPublicPaging } from "@/lib/public-api/list" import { workspaceTokenAuthAPIForScope } from "@/orpc" import { getWorkspaceMember, listWorkspaceMembers } from "../queries" +import { + inviteWorkspaceMemberPublicRequest, + removeWorkspaceMemberPublicRequest, + updateWorkspaceMemberPublicRequest, + workspaceInvitationPublicResource, + workspaceMemberPublicResource, +} from "../schema/public" import { getWorkspaceMemberRequest, getWorkspaceMemberResponse, @@ -15,13 +32,18 @@ import { const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("inbox") +const workspaceTokenWorkspaceAuthAPI = + workspaceTokenAuthAPIForScope("workspace") + +const tags = ["Members"] + export const workspaceMembersPublicRouter = { list: workspaceTokenAuthAPI .route({ method: "GET", path: "/v1/members", summary: "List workspace members", - tags: ["Members"], + tags, }) .input( withPublicPaging(listWorkspaceMembersRequest.omit({ workspaceId: true })), @@ -41,7 +63,7 @@ export const workspaceMembersPublicRouter = { method: "GET", path: "/v1/members/{memberId}", summary: "Get workspace member by id", - tags: ["Members"], + tags, }) .input(getWorkspaceMemberRequest.omit({ workspaceId: true })) .output(getWorkspaceMemberResponse) @@ -56,4 +78,74 @@ export const workspaceMembersPublicRouter = { } return member }), + + invite: workspaceTokenWorkspaceAuthAPI + .route({ + method: "POST", + path: "/v1/members/invitations", + summary: "Invite a workspace member", + successStatus: 201, + tags, + }) + .input(inviteWorkspaceMemberPublicRequest) + .output(workspaceInvitationPublicResource) + .errors(possibleErrorsOnCreatingResource) + .handler(async ({ context, input }) => { + return await invitationService.create({ + workspaceId: context.workspace.id, + permissions: input.permissions, + // Invitation.invitedBy is not nullable; attribute token-created invites + // to the workspace owner rather than fabricate a human actor. + invitedBy: context.workspace.ownerId, + }) + }), + + update: workspaceTokenWorkspaceAuthAPI + .route({ + method: "PUT", + path: "/v1/members/{memberId}", + summary: "Update a workspace member", + tags, + }) + .input(updateWorkspaceMemberPublicRequest) + .output(workspaceMemberPublicResource) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + const { memberId, ...data } = input + await workspaceMemberService.updateMember({ + id: memberId, + workspaceId: context.workspace.id, + data, + }) + return await workspaceMemberService.findByIdOrFail({ + id: memberId, + workspaceId: context.workspace.id, + }) + }), + + remove: workspaceTokenWorkspaceAuthAPI + .route({ + method: "DELETE", + path: "/v1/members/{memberId}", + summary: "Remove a workspace member", + successStatus: 204, + tags, + }) + .input(removeWorkspaceMemberPublicRequest) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context, input }) => { + const member = await workspaceMemberService.findByIdOrFail({ + id: input.memberId, + workspaceId: context.workspace.id, + }) + if (member.role === "owner") { + throw new ChatbotXException( + "You cannot delete the owner of the workspace", + ) + } + await workspaceMemberService.delete({ + id: member.id, + workspaceId: context.workspace.id, + }) + }), } diff --git a/apps/builder/src/features/workspace-members/schema/public.ts b/apps/builder/src/features/workspace-members/schema/public.ts new file mode 100644 index 0000000000..102a9b1ab4 --- /dev/null +++ b/apps/builder/src/features/workspace-members/schema/public.ts @@ -0,0 +1,32 @@ +import { workspaceMemberPermissionsSchema } from "@chatbotx.io/database/partials" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" +import { + inviteWorkspaceMemberRequest, + updateWorkspaceMemberRequest, +} from "./mutation" +import { workspaceMemberResource } from "./resource" + +export const workspaceMemberPublicResource = workspaceMemberResource.omit({ + workspaceId: true, +}) + +export const workspaceInvitationPublicResource = z.object({ + id: zodBigintAsString(), + code: z.string(), + permissions: workspaceMemberPermissionsSchema, + expiresAt: z.date(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export const inviteWorkspaceMemberPublicRequest = inviteWorkspaceMemberRequest + +export const updateWorkspaceMemberPublicRequest = + updateWorkspaceMemberRequest.extend({ + memberId: zodBigintAsString(), + }) + +export const removeWorkspaceMemberPublicRequest = z.object({ + memberId: zodBigintAsString(), +}) diff --git a/apps/builder/src/features/workspaces/actions/refresh-all-channel-tokens.action.ts b/apps/builder/src/features/workspaces/actions/refresh-all-channel-tokens.action.ts index b65278e10b..267b0b8c4f 100644 --- a/apps/builder/src/features/workspaces/actions/refresh-all-channel-tokens.action.ts +++ b/apps/builder/src/features/workspaces/actions/refresh-all-channel-tokens.action.ts @@ -1,449 +1,17 @@ "use server" import { - instagramIntegrationService, - integrationWhatsappService, + channelTokenRefreshService, isWorkspaceScheduledForDeletion, - messengerIntegrationService, - tiktokIntegrationService, - zaloIntegrationService, } from "@chatbotx.io/business" -import { auditService } from "@chatbotx.io/business/audit" -import { - type InstagramAuthValue, - integration as integrationInstagram, -} from "@chatbotx.io/integration-instagram" -import { integration as integrationInstagramFacebook } from "@chatbotx.io/integration-instagram-facebook" -import { - integration as integrationMessenger, - type MessengerAuthValue, -} from "@chatbotx.io/integration-messenger" -import type { TiktokAuthValue } from "@chatbotx.io/integration-tiktok" -import { refreshAccessToken as refreshTiktokAccessToken } from "@chatbotx.io/integration-tiktok/apis/auth" -import { buildTokenTimestamps } from "@chatbotx.io/integration-tiktok/lib/token-utils" -import { - integration as integrationWhatsapp, - type WhatsappAuthValue, -} from "@chatbotx.io/integration-whatsapp" -import { - calculateExpiresAt, - refreshAccessToken as refreshZaloAccessToken, - type ZaloAuthValue, -} from "@chatbotx.io/integration-zalo" -import { distributedLock } from "@chatbotx.io/redis" import { isCloud } from "@/env" import { getAllWorkspaceMembers } from "@/features/workspace-members/queries" import { authActionClient } from "@/lib/safe-action" import { resolveWorkspaceBlockState } from "@/lib/workspace-quota" +import { channelTokenRefreshCallbacks } from "../lib/channel-refresh-callbacks" -const BATCH_SIZE = 50 -// Must outlive the channel APIs' HTTP timeouts (Zalo's OAuth client allows -// 30s): the Zalo refresh token is single-use, so if the lock expired mid-call -// the daily cron could consume the same refresh token concurrently and -// clobber the rotated tokens. -const REFRESH_LOCK_TIMEOUT_SECONDS = 60 - -type RefreshResult = "failed" | "refreshed" | "skipped" type RefreshSummary = { refreshed: number; failed: number } -function toSummary(results: RefreshResult[]): RefreshSummary { - return { - refreshed: results.filter((result) => result === "refreshed").length, - failed: results.filter((result) => result === "failed").length, - } -} - -const sumSummaries = (summaries: RefreshSummary[]): RefreshSummary => - summaries.reduce( - (acc, summary) => ({ - refreshed: acc.refreshed + summary.refreshed, - failed: acc.failed + summary.failed, - }), - { refreshed: 0, failed: 0 }, - ) - -async function runInBatches( - items: T[], - worker: (item: T) => Promise, -): Promise { - const results: RefreshResult[] = [] - for (let i = 0; i < items.length; i += BATCH_SIZE) { - const batch = items.slice(i, i + BATCH_SIZE) - results.push( - ...(await Promise.all( - // A failed lock acquisition (the daily cron already refreshing this - // row, redis hiccup) throws outside the worker's own try/catch; it - // must not reject the whole batch and abort the remaining rows. - batch.map((item) => worker(item).catch((): RefreshResult => "failed")), - )), - ) - } - return results -} - -async function refreshOneZalo( - id: string, - workspaceId: string, -): Promise { - return await distributedLock.runExclusive({ - key: `auth:refresh:zalo:${id}`, - timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, - fn: async () => { - try { - const integration = await zaloIntegrationService.findById({ - id, - workspaceId, - }) - const auth = integration.auth as ZaloAuthValue - if (!auth.tokens.refreshToken) { - return "skipped" - } - - const newTokens = await refreshZaloAccessToken( - auth, - auth.tokens.refreshToken, - ) - await zaloIntegrationService.updateAuth(id, { - ...auth, - tokens: { - ...auth.tokens, - accessToken: newTokens.access_token, - refreshToken: newTokens.refresh_token, - expiresAt: calculateExpiresAt(newTokens.expires_in), - }, - }) - await auditService.record({ - workspaceId, - action: "refresh", - detail: "refreshed the Zalo channel permissions", - }) - return "refreshed" - } catch (error) { - await zaloIntegrationService.markTokenRefreshError( - id, - error instanceof Error ? error.message : String(error), - ) - return "failed" - } - }, - }) -} - -async function refreshZaloIntegrations( - workspaceIds: string[], -): Promise { - const integrations = - await zaloIntegrationService.findAllByWorkspaceIds(workspaceIds) - const results = await runInBatches(integrations, (integration) => - refreshOneZalo(integration.id, integration.workspaceId), - ) - return toSummary(results) -} - -async function refreshOneTiktok( - id: string, - workspaceId: string, -): Promise { - return await distributedLock.runExclusive({ - key: `auth:refresh:tiktok:${id}`, - timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, - fn: async () => { - try { - const integration = await tiktokIntegrationService.findById({ - id, - workspaceId, - }) - const auth = integration.auth as TiktokAuthValue - if (!auth.tokens.refreshToken) { - return "skipped" - } - - const newTokens = await refreshTiktokAccessToken( - { clientId: auth.clientId, clientSecret: auth.clientSecret }, - auth.tokens.refreshToken, - ) - await tiktokIntegrationService.updateAuth(id, { - ...auth, - tokens: { - ...auth.tokens, - accessToken: newTokens.access_token, - refreshToken: newTokens.refresh_token, - ...buildTokenTimestamps( - newTokens.expires_in, - newTokens.refresh_expires_in, - ), - }, - }) - await auditService.record({ - workspaceId, - action: "refresh", - detail: "refreshed the TikTok channel token", - }) - return "refreshed" - } catch (error) { - await tiktokIntegrationService.markTokenRefreshError( - id, - error instanceof Error ? error.message : String(error), - ) - return "failed" - } - }, - }) -} - -async function refreshTiktokIntegrations( - workspaceIds: string[], -): Promise { - const integrations = - await tiktokIntegrationService.findAllByWorkspaceIds(workspaceIds) - const results = await runInBatches(integrations, (integration) => - refreshOneTiktok(integration.id, integration.workspaceId), - ) - return toSummary(results) -} - -async function refreshOneInstagram( - id: string, - workspaceId: string, -): Promise { - if (!integrationInstagram.refreshAuth) { - return "skipped" - } - - return await distributedLock.runExclusive({ - key: `auth:refresh:instagram:${id}`, - timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, - fn: async () => { - try { - const integration = - await instagramIntegrationService.findByIdForWorkspace({ - id, - workspaceId, - }) - if (!integration) { - return "skipped" - } - - const auth = integration.auth as InstagramAuthValue - const newAuth = await integrationInstagram.refreshAuth?.({ auth }) - await instagramIntegrationService.updateAuth({ - id, - workspaceId, - auth: newAuth as InstagramAuthValue, - }) - await auditService.record({ - workspaceId, - action: "refresh", - detail: "refreshed the Instagram channel token", - }) - return "refreshed" - } catch (error) { - await instagramIntegrationService.markTokenRefreshError( - id, - error instanceof Error ? error.message : String(error), - ) - return "failed" - } - }, - }) -} - -async function refreshInstagramIntegrations( - workspaceIds: string[], -): Promise { - const integrations = - await instagramIntegrationService.findForTokenRefreshByWorkspaceIds( - workspaceIds, - ) - const results = await runInBatches(integrations, (integration) => - refreshOneInstagram(integration.id, integration.workspaceId), - ) - return toSummary(results) -} - -async function refreshOneInstagramFacebook( - id: string, - workspaceId: string, -): Promise { - if (!integrationInstagramFacebook.refreshAuth) { - return "skipped" - } - - return await distributedLock.runExclusive({ - key: `auth:refresh:instagramFacebook:${id}`, - timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, - fn: async () => { - try { - const integration = - await instagramIntegrationService.findByIdForWorkspace({ - id, - workspaceId, - }) - if (!integration) { - return "skipped" - } - - const auth = integration.auth as InstagramAuthValue - const newAuth = await integrationInstagramFacebook.refreshAuth?.({ - auth, - }) - await instagramIntegrationService.updateAuth({ - id, - workspaceId, - auth: newAuth as InstagramAuthValue, - }) - await auditService.record({ - workspaceId, - action: "refresh", - detail: "refreshed the Instagram channel token", - }) - return "refreshed" - } catch (error) { - await instagramIntegrationService.markTokenRefreshError( - id, - error instanceof Error ? error.message : String(error), - ) - return "failed" - } - }, - }) -} - -async function refreshInstagramFacebookIntegrations( - workspaceIds: string[], -): Promise { - const integrations = - await instagramIntegrationService.findFacebookForTokenRefreshByWorkspaceIds( - workspaceIds, - ) - const results = await runInBatches(integrations, (integration) => - refreshOneInstagramFacebook(integration.id, integration.workspaceId), - ) - return toSummary(results) -} - -async function refreshOneMessenger( - id: string, - workspaceId: string, -): Promise { - if (!integrationMessenger.refreshAuth) { - return "skipped" - } - - return await distributedLock.runExclusive({ - key: `auth:refresh:messenger:${id}`, - timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, - fn: async () => { - try { - const integration = - await messengerIntegrationService.findByIdForWorkspace({ - id, - workspaceId, - }) - if (!integration) { - return "skipped" - } - - const auth = integration.auth as MessengerAuthValue - const newAuth = await integrationMessenger.refreshAuth?.({ auth }) - await messengerIntegrationService.updateAuth({ - id, - workspaceId, - auth: newAuth as MessengerAuthValue, - }) - await auditService.record({ - workspaceId, - action: "refresh", - detail: "refreshed the Messenger channel token", - }) - return "refreshed" - } catch (error) { - await messengerIntegrationService.markTokenRefreshError( - id, - error instanceof Error ? error.message : String(error), - ) - return "failed" - } - }, - }) -} - -async function refreshMessengerIntegrations( - workspaceIds: string[], -): Promise { - const integrations = - await messengerIntegrationService.findForTokenRefreshByWorkspaceIds( - workspaceIds, - ) - const results = await runInBatches(integrations, (integration) => - refreshOneMessenger(integration.id, integration.workspaceId), - ) - return toSummary(results) -} - -async function refreshOneWhatsapp( - id: string, - workspaceId: string, -): Promise { - if (!integrationWhatsapp.refreshAuth) { - return "skipped" - } - - return await distributedLock.runExclusive({ - key: `auth:refresh:whatsapp:${id}`, - timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, - fn: async () => { - try { - const integration = - await integrationWhatsappService.findByIdForWorkspace({ - id, - workspaceId, - }) - if (!integration) { - return "skipped" - } - - const auth = integration.auth as WhatsappAuthValue - if (auth.metadata.isManual) { - return "skipped" - } - - const newAuth = await integrationWhatsapp.refreshAuth?.({ auth }) - await integrationWhatsappService.updateAuth({ - id, - workspaceId, - auth: newAuth as WhatsappAuthValue, - }) - await auditService.record({ - workspaceId, - action: "refresh", - detail: "refreshed the WhatsApp channel token", - }) - return "refreshed" - } catch (error) { - await integrationWhatsappService.markTokenRefreshError( - id, - error instanceof Error ? error.message : String(error), - ) - return "failed" - } - }, - }) -} - -async function refreshWhatsappIntegrations( - workspaceIds: string[], -): Promise { - const integrations = - await integrationWhatsappService.findForTokenRefreshByWorkspaceIds( - workspaceIds, - ) - const results = await runInBatches(integrations, (integration) => - refreshOneWhatsapp(integration.id, integration.workspaceId), - ) - return toSummary(results) -} - /** * Excludes workspaces mid-deletion-grace-window or blocked for trial/quota * reasons (AGENTS.md invariant #14) from the bulk refresh, matching the gates @@ -482,15 +50,9 @@ export const refreshAllChannelTokensAction = authActionClient.action( return { refreshed: 0, failed: 0 } } - const summaries = await Promise.all([ - refreshZaloIntegrations(workspaceIds), - refreshTiktokIntegrations(workspaceIds), - refreshInstagramIntegrations(workspaceIds), - refreshInstagramFacebookIntegrations(workspaceIds), - refreshMessengerIntegrations(workspaceIds), - refreshWhatsappIntegrations(workspaceIds), - ]) - - return sumSummaries(summaries) + return await channelTokenRefreshService.refreshWorkspaces({ + workspaceIds, + ...channelTokenRefreshCallbacks, + }) }, ) diff --git a/apps/builder/src/features/workspaces/api/public.ts b/apps/builder/src/features/workspaces/api/public.ts new file mode 100644 index 0000000000..52c5204d7f --- /dev/null +++ b/apps/builder/src/features/workspaces/api/public.ts @@ -0,0 +1,151 @@ +import { + channelTokenRefreshService, + workspaceLifecycleService, + workspaceService, + workspaceSupportAccessService, +} from "@chatbotx.io/business" +import { + possibleErrorsOnCreatingResource, + possibleErrorsOnDeletingResource, + possibleErrorsOnFindingResource, + possibleErrorsOnMutatingResource, +} from "@/lib/orpc/orpc-error-helper" +import { workspaceTokenAuthAPIForScope } from "@/orpc" +import { channelTokenRefreshCallbacks } from "../lib/channel-refresh-callbacks" +import { + refreshChannelTokensPublicResponse, + updateWorkspacePublicRequest, + updateWorkspaceStatusPublicRequest, + updateWorkspaceSupportAccessPublicRequest, + workspacePublicResource, +} from "../schema/public" + +const workspaceTokenAuthAPI = workspaceTokenAuthAPIForScope("workspace") + +const tags = ["Workspace"] + +export const workspacePublicRouter = { + get: workspaceTokenAuthAPI + .route({ + method: "GET", + path: "/v1/workspace", + summary: "Get workspace settings", + tags, + }) + .output(workspacePublicResource) + .errors(possibleErrorsOnFindingResource) + .handler( + async ({ context }) => + await workspaceService.findById({ id: context.workspace.id }), + ), + + update: workspaceTokenAuthAPI + .route({ + method: "PATCH", + path: "/v1/workspace", + summary: "Update workspace settings", + tags, + }) + .input(updateWorkspacePublicRequest) + .output(workspacePublicResource) + .errors(possibleErrorsOnMutatingResource) + .handler( + async ({ context, input }) => + await workspaceService.update({ + id: context.workspace.id, + data: input, + }), + ), + + updateStatus: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/workspace/status", + summary: "Update workspace active status and hours", + tags, + }) + .input(updateWorkspaceStatusPublicRequest) + .output(workspacePublicResource) + .errors(possibleErrorsOnMutatingResource) + .handler( + async ({ context, input }) => + await workspaceService.update({ + id: context.workspace.id, + data: input, + }), + ), + + scheduleDeletion: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/workspace/deletion", + summary: "Schedule workspace deletion", + tags, + }) + .output(workspacePublicResource) + .errors(possibleErrorsOnCreatingResource) + .handler(async ({ context }) => { + const workspace = await workspaceService.scheduleDeletion({ + id: context.workspace.id, + }) + await workspaceLifecycleService.freezeWorkspaceRuntime( + context.workspace.id, + ) + return workspace + }), + + cancelDeletion: workspaceTokenAuthAPI + .route({ + method: "DELETE", + path: "/v1/workspace/deletion", + summary: "Cancel scheduled workspace deletion", + successStatus: 204, + tags, + }) + .errors(possibleErrorsOnDeletingResource) + .handler(async ({ context }) => { + await workspaceService.cancelDeletion({ id: context.workspace.id }) + }), + + updateSupportAccess: workspaceTokenAuthAPI + .route({ + method: "PUT", + path: "/v1/workspace/support-access", + summary: "Enable or disable platform support access", + successStatus: 204, + tags, + }) + .input(updateWorkspaceSupportAccessPublicRequest) + .errors(possibleErrorsOnMutatingResource) + .handler(async ({ context, input }) => { + if (input.enabled) { + await workspaceSupportAccessService.enable({ + workspaceId: context.workspace.id, + actorUserId: null, + }) + return + } + + await workspaceSupportAccessService.disable({ + workspaceId: context.workspace.id, + actorUserId: null, + }) + }), + + refreshChannelTokens: workspaceTokenAuthAPI + .route({ + method: "POST", + path: "/v1/workspace/channel-tokens/refresh", + summary: "Refresh channel access tokens", + tags, + }) + .output(refreshChannelTokensPublicResponse) + .errors(possibleErrorsOnCreatingResource) + .handler( + async ({ context }) => + await channelTokenRefreshService.refreshWorkspace({ + workspaceId: context.workspace.id, + ...channelTokenRefreshCallbacks, + }), + ), +} diff --git a/apps/builder/src/features/workspaces/api/public/api-tokens.ts b/apps/builder/src/features/workspaces/api/public/api-tokens.ts new file mode 100644 index 0000000000..ba3ce6d572 --- /dev/null +++ b/apps/builder/src/features/workspaces/api/public/api-tokens.ts @@ -0,0 +1,163 @@ +import { workspaceApiTokenService } from "@chatbotx.io/business" +import { + ChatbotXException, + notFoundException, +} from "@chatbotx.io/business/errors" +import { generateWorkspaceToken } from "@chatbotx.io/business/workspace-api-token/credentials" +import { + possibleErrorsOnCreatingWorkspaceApiToken, + possibleErrorsOnFindingResource, + possibleErrorsOnListingResource, + possibleErrorsOnMutatingWorkspaceApiToken, +} from "@/lib/orpc/orpc-error-helper" +import { + paginateInMemory, + publicListRequest, + publicListResponse, +} from "@/lib/public-api/list" +import { workspaceTokenAdminAPI } from "@/orpc" +import { + createWorkspaceApiTokenPublicRequest, + createWorkspaceApiTokenPublicResponse, + getWorkspaceApiTokenPublicRequest, + toPublicWorkspaceApiToken, + updateWorkspaceApiTokenPublicRequest, + workspaceApiTokenPublicResource, +} from "../../schema/public" + +const tags = ["API Tokens"] + +export const apiTokensPublicRouter = { + list: workspaceTokenAdminAPI + .route({ + method: "GET", + path: "/v1/api-tokens", + summary: "List workspace API tokens", + tags, + }) + .input(publicListRequest) + .output(publicListResponse(workspaceApiTokenPublicResource)) + .errors(possibleErrorsOnListingResource) + .handler(async ({ context, input }) => { + const tokens = await workspaceApiTokenService.listTokens({ + workspaceId: context.workspace.id, + }) + return paginateInMemory(tokens.map(toPublicWorkspaceApiToken), input) + }), + + get: workspaceTokenAdminAPI + .route({ + method: "GET", + path: "/v1/api-tokens/{id}", + summary: "Get a workspace API token", + tags, + }) + .input(getWorkspaceApiTokenPublicRequest) + .output(workspaceApiTokenPublicResource) + .errors(possibleErrorsOnFindingResource) + .handler(async ({ context, input }) => + toPublicWorkspaceApiToken( + await workspaceApiTokenService.findTokenOrFail({ + workspaceId: context.workspace.id, + id: input.id, + }), + ), + ), + + create: workspaceTokenAdminAPI + .route({ + method: "POST", + path: "/v1/api-tokens", + summary: "Create a workspace API token", + successStatus: 201, + tags, + }) + .input(createWorkspaceApiTokenPublicRequest) + .output(createWorkspaceApiTokenPublicResponse) + .errors(possibleErrorsOnCreatingWorkspaceApiToken) + .handler(async ({ context, input }) => { + const { token, tokenHash, tokenPrefix } = await generateWorkspaceToken() + const apiToken = await workspaceApiTokenService.createToken({ + workspaceId: context.workspace.id, + ...input, + tokenHash, + tokenPrefix, + }) + + return { apiToken: toPublicWorkspaceApiToken(apiToken), token } + }), + + update: workspaceTokenAdminAPI + .route({ + method: "PATCH", + path: "/v1/api-tokens/{id}", + summary: "Update a workspace API token", + tags, + }) + .input(updateWorkspaceApiTokenPublicRequest) + .output(workspaceApiTokenPublicResource) + .errors(possibleErrorsOnMutatingWorkspaceApiToken) + .handler(async ({ context, input }) => { + const { id, ...data } = input + return toPublicWorkspaceApiToken( + await workspaceApiTokenService.updateToken({ + workspaceId: context.workspace.id, + id, + ...data, + }), + ) + }), + + rotate: workspaceTokenAdminAPI + .route({ + method: "POST", + path: "/v1/api-tokens/{id}/rotate", + summary: "Rotate a workspace API token", + tags, + }) + .input(getWorkspaceApiTokenPublicRequest) + .output(createWorkspaceApiTokenPublicResponse) + .errors(possibleErrorsOnMutatingWorkspaceApiToken) + .handler(async ({ context, input }) => { + const { token, tokenHash, tokenPrefix } = await generateWorkspaceToken() + const apiToken = await workspaceApiTokenService.rotateToken({ + workspaceId: context.workspace.id, + id: input.id, + tokenHash, + tokenPrefix, + }) + + return { apiToken: toPublicWorkspaceApiToken(apiToken), token } + }), + + delete: workspaceTokenAdminAPI + .route({ + method: "DELETE", + path: "/v1/api-tokens/{id}", + summary: "Delete a workspace API token", + successStatus: 204, + tags, + }) + .input(getWorkspaceApiTokenPublicRequest) + .errors(possibleErrorsOnMutatingWorkspaceApiToken) + .handler(async ({ context, input }) => { + const apiToken = await workspaceApiTokenService.findTokenOrFail({ + workspaceId: context.workspace.id, + id: input.id, + }) + if (apiToken.isDefault) { + throw new ChatbotXException( + "The default workspace API token cannot be modified", + "workspaceApiTokenImmutable", + ) + } + + const deleted = await workspaceApiTokenService.deleteToken({ + workspaceId: context.workspace.id, + id: input.id, + }) + if (!deleted) { + throw notFoundException("Workspace API token not found") + } + }), +} diff --git a/apps/builder/src/features/workspaces/lib/api-paths.ts b/apps/builder/src/features/workspaces/lib/api-paths.ts new file mode 100644 index 0000000000..319967408e --- /dev/null +++ b/apps/builder/src/features/workspaces/lib/api-paths.ts @@ -0,0 +1,10 @@ +/** + * Public API route path constants shared across layers that must agree on a + * route's identity without importing each other (the route declarations in + * `../api/public.ts` and the deletion-lifecycle exemptions in + * `lib/workspace/authorize-workspace-access.ts` and + * `middlewares/workspace-token-auth.ts`, which sit upstream of `@/orpc` and + * cannot import the feature router without a cycle). Keep this file free of + * imports so all sides can depend on it safely. + */ +export const WORKSPACE_DELETION_PATH = "/v1/workspace/deletion" diff --git a/apps/builder/src/features/workspaces/lib/channel-refresh-callbacks.ts b/apps/builder/src/features/workspaces/lib/channel-refresh-callbacks.ts new file mode 100644 index 0000000000..5c012394cb --- /dev/null +++ b/apps/builder/src/features/workspaces/lib/channel-refresh-callbacks.ts @@ -0,0 +1,42 @@ +import type { ChannelTokenRefreshCallbacks } from "@chatbotx.io/business" +import { + type InstagramAuthValue, + integration as integrationInstagram, +} from "@chatbotx.io/integration-instagram" +import { integration as integrationInstagramFacebook } from "@chatbotx.io/integration-instagram-facebook" +import { + integration as integrationMessenger, + type MessengerAuthValue, +} from "@chatbotx.io/integration-messenger" +import { + integration as integrationWhatsapp, + type WhatsappAuthValue, +} from "@chatbotx.io/integration-whatsapp" + +/** + * `channelTokenRefreshService.refreshWorkspace` (packages/business) does not + * depend on `@chatbotx.io/integration-instagram`, `-instagram-facebook`, + * `-messenger`, or `-whatsapp` — each already depends on + * `@chatbotx.io/business`, so a reverse dependency would create a workspace + * cycle (see that service's own doc comment). `apps/builder` has no such + * constraint, so every caller of `refreshWorkspace` wires the concrete + * provider calls once, here, and injects them as primitives. + */ +export const channelTokenRefreshCallbacks: ChannelTokenRefreshCallbacks = { + refreshInstagramAuth: (auth) => + integrationInstagram.refreshAuth?.({ + auth: auth as InstagramAuthValue, + }) as Promise>, + refreshInstagramFacebookAuth: (auth) => + integrationInstagramFacebook.refreshAuth?.({ + auth: auth as InstagramAuthValue, + }) as Promise>, + refreshMessengerAuth: (auth) => + integrationMessenger.refreshAuth?.({ + auth: auth as MessengerAuthValue, + }) as Promise>, + refreshWhatsappAuth: (auth) => + integrationWhatsapp.refreshAuth?.({ + auth: auth as WhatsappAuthValue, + }) as Promise>, +} diff --git a/apps/builder/src/features/workspaces/lib/workspace-token-scopes.ts b/apps/builder/src/features/workspaces/lib/workspace-token-scopes.ts index 819691fa8b..49ee88d660 100644 --- a/apps/builder/src/features/workspaces/lib/workspace-token-scopes.ts +++ b/apps/builder/src/features/workspaces/lib/workspace-token-scopes.ts @@ -23,6 +23,7 @@ export const workspaceApiTokenScopeRegistry: Record< appointments: { labelKey: "fields.tokenScopes.appointments", order: 9 }, media: { labelKey: "fields.tokenScopes.media", order: 10 }, ads: { labelKey: "fields.tokenScopes.ads", order: 11 }, + workspace: { labelKey: "fields.tokenScopes.workspace", order: 12 }, } export const orderedWorkspaceApiTokenScopes = ( diff --git a/apps/builder/src/features/workspaces/schema/public.ts b/apps/builder/src/features/workspaces/schema/public.ts new file mode 100644 index 0000000000..200007fa7c --- /dev/null +++ b/apps/builder/src/features/workspaces/schema/public.ts @@ -0,0 +1,92 @@ +import { + workspaceApiTokenPermissions, + workspaceApiTokenScopes, +} from "@chatbotx.io/database/partials" +import type { WorkspaceApiTokenModel } from "@chatbotx.io/database/types" +import { zodBigintAsString } from "@chatbotx.io/utils" +import { z } from "zod" +import { workspaceResource } from "./resource" +import { + toggleSupportAccessRequest, + updateSmartResponseDelayRequest, + updateWorkspaceAdvancedRequest, + updateWorkspaceBasicRequest, + updateWorkspaceStatusRequest, +} from "./update-workspace-schema" +import { toWorkspaceApiTokenDto } from "./workspace-token-dto" + +export const workspacePublicResource = workspaceResource.pick({ + id: true, + createdAt: true, + updatedAt: true, + name: true, + defaultReply: true, + defaultReplyFrequency: true, + targetCountry: true, + language: true, + timezone: true, + brandColor: true, + developmentMode: true, + smartResponseDelaySeconds: true, + isActive: true, + startTime: true, + endTime: true, + logo: true, + scheduledDeletionAt: true, + supportAccessUntil: true, + capiLimitedDataUse: true, +}) + +export const updateWorkspacePublicRequest = updateWorkspaceBasicRequest + .merge(updateWorkspaceAdvancedRequest) + .merge(updateSmartResponseDelayRequest) + .partial() + +export const updateWorkspaceStatusPublicRequest = updateWorkspaceStatusRequest + +export const updateWorkspaceSupportAccessPublicRequest = + toggleSupportAccessRequest + +export const refreshChannelTokensPublicResponse = z.object({ + refreshed: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), +}) + +export const workspaceApiTokenPublicResource = z.object({ + id: zodBigintAsString(), + name: z.string(), + permission: workspaceApiTokenPermissions, + tokenPrefix: z.string().nullable(), + isDefault: z.boolean(), + scopes: z.array(workspaceApiTokenScopes).nullable(), + createdAt: workspaceResource.shape.createdAt, +}) + +export const toPublicWorkspaceApiToken = (token: WorkspaceApiTokenModel) => ({ + ...toWorkspaceApiTokenDto(token), + permission: workspaceApiTokenPermissions.parse(token.permission), +}) + +export const createWorkspaceApiTokenPublicRequest = z.object({ + name: z.string().min(1).max(100), + permission: workspaceApiTokenPermissions, + scopes: z.array(workspaceApiTokenScopes).min(1).nullable(), +}) + +export const getWorkspaceApiTokenPublicRequest = z.object({ + id: zodBigintAsString(), +}) + +export const updateWorkspaceApiTokenPublicRequest = + createWorkspaceApiTokenPublicRequest.partial().extend({ + id: zodBigintAsString(), + }) + +export const createWorkspaceApiTokenPublicResponse = z.object({ + apiToken: workspaceApiTokenPublicResource, + token: z + .string() + .describe( + "Plaintext token. Returned only in this response — only a SHA-256 hash is stored.", + ), +}) diff --git a/apps/builder/src/lib/orpc/orpc-error-helper.ts b/apps/builder/src/lib/orpc/orpc-error-helper.ts index 0a41989b3f..3dd8a5c0dd 100644 --- a/apps/builder/src/lib/orpc/orpc-error-helper.ts +++ b/apps/builder/src/lib/orpc/orpc-error-helper.ts @@ -97,6 +97,16 @@ export const possibleErrorsOnListingResource = { businessError, } satisfies ErrorMap +const enterpriseFeatureRequired = { + message: "This feature requires an enterprise license", + status: 403, +} + +export const possibleErrorsOnListingEnterpriseResource = { + businessError, + enterpriseFeatureRequired, +} satisfies ErrorMap + /** * Per-router sets carry only what varies by operation shape. The auth, * rate-limit, and both validation codes come from `commonApiErrors`, attached @@ -116,6 +126,26 @@ export const possibleErrorsOnDeletingResource = { businessError, } satisfies ErrorMap +const workspaceApiTokenLimitReached = { + message: "Workspace has reached the maximum number of API tokens", + status: 400, +} +const workspaceApiTokenImmutable = { + message: "The default workspace API token cannot be modified", + status: 400, +} + +export const possibleErrorsOnCreatingWorkspaceApiToken = { + businessError, + workspaceApiTokenLimitReached, +} satisfies ErrorMap + +export const possibleErrorsOnMutatingWorkspaceApiToken = { + notFound, + businessError, + workspaceApiTokenImmutable, +} satisfies ErrorMap + /** * Booking/cancel/delete on appointments can throw five `ChatbotXException` * codes at status 409 that no other route set covers — `slotUnavailable`, diff --git a/apps/builder/src/lib/workspace/authorize-workspace-access.ts b/apps/builder/src/lib/workspace/authorize-workspace-access.ts index 0fb98783e7..7570ff09e2 100644 --- a/apps/builder/src/lib/workspace/authorize-workspace-access.ts +++ b/apps/builder/src/lib/workspace/authorize-workspace-access.ts @@ -7,6 +7,7 @@ import type { HTTPMethod } from "@orpc/server" import { ORPCError } from "@orpc/server" import { isCloud } from "@/env" import { ADS_CAMPAIGNS_INSIGHTS_PATH } from "@/features/ads-campaign/lib/api-paths" +import { WORKSPACE_DELETION_PATH } from "@/features/workspaces/lib/api-paths" export type WorkspaceAccessDenialReason = "trialExpired" | "macLimitReached" @@ -53,6 +54,18 @@ const READ_ONLY_TOKEN_ALLOWED_POST_PATHS = new Set([ ADS_CAMPAIGNS_INSIGHTS_PATH, ]) +/** + * Deletion-lifecycle paths stay reachable regardless of the owner's + * quota/trial state, mirroring `workspaceActionClientAllowExpired` + * (AGENTS.md invariant #14: delete/lifecycle actions stay available after + * expiry). `POST /v1/workspace/deletion` (schedule) is the only mutation + * method this affects — DELETE (cancel) is already exempt via + * `isWorkspaceMutationMethod`. + */ +const OWNER_ACCESS_EXEMPT_PATHS: Record = { + [WORKSPACE_DELETION_PATH]: true, +} + /** * Distinct from `isWorkspaceMutationMethod`: that predicate treats DELETE as * non-mutation for the trial-expired invariant above, but a read_only @@ -142,11 +155,16 @@ export const workspaceAccessDenialOrpcError = ( export async function assertWorkspaceOwnerAccessForMethod(props: { method: HTTPMethod | undefined ownerId: string + path?: string }): Promise { if (!isWorkspaceMutationMethod(props.method)) { return } + if (props.path && Object.hasOwn(OWNER_ACCESS_EXEMPT_PATHS, props.path)) { + return + } + const denialReason = await checkWorkspaceOwnerAccess({ ownerId: props.ownerId, }) diff --git a/apps/builder/src/middlewares/__tests__/workspace-token-auth.test.ts b/apps/builder/src/middlewares/__tests__/workspace-token-auth.test.ts new file mode 100644 index 0000000000..6a30aef559 --- /dev/null +++ b/apps/builder/src/middlewares/__tests__/workspace-token-auth.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment node +import type { HTTPMethod } from "@orpc/server" +import { beforeEach, describe, expect, test, vi } from "vitest" +import { WORKSPACE_DELETION_PATH } from "@/features/workspaces/lib/api-paths" + +// Every test-observable behavior below is decided by `isWorkspaceScheduledForDeletion` +// (mocked, per test) and `assertWorkspaceOwnerAccessForMethod` (real — the +// regression under test is exercised through it, backed by mocked quota +// services below). +const isWorkspaceScheduledForDeletion = vi.fn((_workspace: unknown) => false) +const findWorkspaceByTokenHash = vi.fn( + async (_props: unknown) => undefined as unknown, +) +const getAccessState = vi.fn(async (_userId: string) => undefined as unknown) +const isAtLimit = vi.fn(async (_props: unknown) => false) + +vi.mock("@chatbotx.io/business", () => ({ + isWorkspaceScheduledForDeletion: (workspace: unknown) => + isWorkspaceScheduledForDeletion(workspace), + workspaceApiTokenService: { + findWorkspaceByTokenHash: (props: unknown) => + findWorkspaceByTokenHash(props), + }, + userQuotaService: { + getAccessState: (userId: string) => getAccessState(userId), + }, + quotaEnforcementService: { + isAtLimit: (props: unknown) => isAtLimit(props), + }, +})) + +vi.mock("@chatbotx.io/business/audit", () => ({ + withAuditContext: (_ctx: unknown, fn: () => unknown) => fn(), +})) + +const isCloud = vi.fn(() => false) +vi.mock("@/env", () => ({ isCloud: () => isCloud() })) + +vi.mock("@/lib/log", () => ({ + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, +})) + +vi.mock("@/lib/rate-limit/api-rate-limit", () => ({ + assertApiNotRateLimited: vi.fn(async () => undefined), +})) + +vi.mock("@/lib/rate-limit/guest-rate-limit", () => ({ + getGuestClientIp: () => "203.0.113.1", +})) + +// Imported after the `vi.mock` calls above; Vitest hoists `vi.mock` to the +// top of the module regardless of declaration order, so this static import +// resolves against the mocked dependency graph. +import { workspaceTokenAuthMidddleware } from "../workspace-token-auth" + +type CallOptions = { + method: HTTPMethod + path: string +} + +const NEXT_SENTINEL = { ok: true } + +const callMiddleware = ({ method, path }: CallOptions) => + workspaceTokenAuthMidddleware( + { + context: { + headers: new Headers({ Authorization: "Bearer cbx_ws_test-token" }), + }, + // The middleware only ever calls `next({ context })`; returning a + // sentinel lets tests assert the middleware reached the end of its + // chain instead of throwing earlier. + next: async () => NEXT_SENTINEL, + procedure: { "~orpc": { route: { method, path } } }, + } as never, + undefined as never, + (() => undefined) as never, + ) + +const workspaceFixture = { id: "workspace-1", ownerId: "owner-1" } +const apiTokenFixture = { + id: "token-1", + workspaceId: "workspace-1", + permission: "full" as const, + scopes: null, + isDefault: true, +} + +beforeEach(() => { + vi.clearAllMocks() + isWorkspaceScheduledForDeletion.mockReturnValue(false) + isCloud.mockReturnValue(false) + isAtLimit.mockResolvedValue(false) + findWorkspaceByTokenHash.mockImplementation(async () => ({ + workspace: workspaceFixture, + apiToken: apiTokenFixture, + })) +}) + +describe("workspaceTokenAuthMidddleware — deletion lifecycle", () => { + test("cancelDeletion (DELETE /v1/workspace/deletion) succeeds on an already-scheduled workspace", async () => { + isWorkspaceScheduledForDeletion.mockReturnValue(true) + + await expect( + callMiddleware({ method: "DELETE", path: WORKSPACE_DELETION_PATH }), + ).resolves.toBe(NEXT_SENTINEL) + }) + + test("every other route stays locked out once deletion is scheduled", async () => { + isWorkspaceScheduledForDeletion.mockReturnValue(true) + + await expect( + callMiddleware({ method: "GET", path: "/v1/workspace" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }) + }) + + test("scheduleDeletion (POST /v1/workspace/deletion) succeeds for a trial-expired owner", async () => { + isCloud.mockReturnValue(true) + getAccessState.mockResolvedValue({ + blocked: true, + planName: null, + reason: "status", + status: "expired", + trialEndsAt: null, + }) + + await expect( + callMiddleware({ method: "POST", path: WORKSPACE_DELETION_PATH }), + ).resolves.toBe(NEXT_SENTINEL) + }) + + test("other mutations stay blocked for a trial-expired owner", async () => { + isCloud.mockReturnValue(true) + getAccessState.mockResolvedValue({ + blocked: true, + planName: null, + reason: "status", + status: "expired", + trialEndsAt: null, + }) + + await expect( + callMiddleware({ method: "POST", path: "/v1/workspace" }), + ).rejects.toMatchObject({ code: "trialExpired" }) + }) +}) diff --git a/apps/builder/src/middlewares/workspace-token-auth.ts b/apps/builder/src/middlewares/workspace-token-auth.ts index 377b91d79d..0f9d57d1f3 100644 --- a/apps/builder/src/middlewares/workspace-token-auth.ts +++ b/apps/builder/src/middlewares/workspace-token-auth.ts @@ -6,6 +6,7 @@ import { withAuditContext } from "@chatbotx.io/business/audit" import { ChatbotXException } from "@chatbotx.io/business/errors" import { hashToken } from "@chatbotx.io/business/workspace-api-token/credentials" import { ORPCError } from "@orpc/server" +import { WORKSPACE_DELETION_PATH } from "@/features/workspaces/lib/api-paths" import { logger } from "@/lib/log" import { assertApiNotRateLimited } from "@/lib/rate-limit/api-rate-limit" import { getGuestClientIp } from "@/lib/rate-limit/guest-rate-limit" @@ -96,15 +97,25 @@ export const workspaceTokenAuthMidddleware = base.middleware( await assertNotRateLimited(workspace.id) - if (isWorkspaceScheduledForDeletion(workspace)) { + const method = procedure["~orpc"].route.method + const path = procedure["~orpc"].route.path + + // The cancel-deletion route must stay reachable on an already-scheduled + // workspace — it is the only way a workspace-token caller can reach + // `DELETE /v1/workspace/deletion` at all, since every other route is + // correctly locked out once deletion is scheduled. + const isCancelDeletionRequest = + method === "DELETE" && path === WORKSPACE_DELETION_PATH + + if ( + !isCancelDeletionRequest && + isWorkspaceScheduledForDeletion(workspace) + ) { throw new ORPCError("FORBIDDEN", { message: "Workspace deletion scheduled", }) } - const method = procedure["~orpc"].route.method - const path = procedure["~orpc"].route.path - // Read-only tokens may only GET/HEAD (plus a narrow, explicit allowlist // of POST-for-read routes — see `READ_ONLY_TOKEN_ALLOWED_POST_PATHS`) — // unlike the owner-quota gate below, DELETE is not exempt here: a @@ -120,10 +131,13 @@ export const workspaceTokenAuthMidddleware = base.middleware( } // Owner-quota/trial gate — mirrors workspaceActionClient in safe-action.ts. - // Reads and deletes stay open (invariant #14). + // Reads and deletes stay open (invariant #14); `path` additionally + // exempts the deletion-lifecycle POST route (see + // `OWNER_ACCESS_EXEMPT_PATHS`). await assertWorkspaceOwnerAccessForMethod({ method, ownerId: workspace.ownerId, + path, }) const requestApiToken: RequestApiToken = { diff --git a/apps/builder/src/orpc.ts b/apps/builder/src/orpc.ts index 204377c7d8..b1e0421070 100644 --- a/apps/builder/src/orpc.ts +++ b/apps/builder/src/orpc.ts @@ -157,4 +157,28 @@ const requireTokenScope = (scope: WorkspaceApiTokenScope) => export const workspaceTokenAuthAPIForScope = (scope: WorkspaceApiTokenScope) => publicAPI.use(workspaceTokenAuthMidddleware).use(requireTokenScope(scope)) +/** + * Guards `/v1/api-tokens` on top of `workspaceTokenAuthAPIForScope("workspace")`. + * A token that can mint tokens is a privilege-escalation vector, so a token + * carrying an explicit scope allow-list — even one listing `workspace` — is + * denied; only an "All scopes" (`scopes: null`) token reaches this stack, and + * it can therefore never mint a token broader than itself. `read_only` is + * still enforced by `workspaceTokenAuthMidddleware` (GET/HEAD plus the + * POST-for-read allow-list), so a read-only unrestricted token may list but + * not mint. This stack is used only by the `/v1/api-tokens` routes. + */ +const requireUnrestrictedToken = base.middleware(async ({ context, next }) => { + if (!context.apiToken || context.apiToken.scopes != null) { + throw new ORPCError("FORBIDDEN", { + message: + "Only an unrestricted (All scopes) token can manage workspace API tokens", + }) + } + return await next() +}) + +export const workspaceTokenAdminAPI = workspaceTokenAuthAPIForScope( + "workspace", +).use(requireUnrestrictedToken) + export const channelApiTokenAPI = publicAPI.use(channelApiTokenAuthMidddleware) diff --git a/apps/builder/src/routers/public.ts b/apps/builder/src/routers/public.ts index c3dc98be0b..38ca462fd0 100644 --- a/apps/builder/src/routers/public.ts +++ b/apps/builder/src/routers/public.ts @@ -1,3 +1,4 @@ +import { auditLogsPublicRouter } from "@/enterprise/features/audit-logs/api/public" import { inboxTeamsPublicRouter } from "@/enterprise/features/inbox-teams/api/public" import { adsPublicRouter } from "@/features/ads/api/public" import { aiAgentsPublicRouter } from "@/features/ai-agents/api/public" @@ -48,10 +49,13 @@ import { savedRepliesPublicRouter } from "@/features/saved-replies/api/public" import { sequencesPublicRouter } from "@/features/sequences/api/public" import { spreadsheetsPublicRouter } from "@/features/spreadsheets/api/public" import { tagsPublicRouter } from "@/features/tags/api/public" +import { templatesPublicRouter } from "@/features/templates/api/public" import { triggersPublicRouter } from "@/features/triggers/api/public" import { userPersistentMenusPublicRouter } from "@/features/user-persistent-menus/api/public" import { webhooksPublicRouter } from "@/features/webhooks/api/public" import { workspaceMembersPublicRouter } from "@/features/workspace-members/api/public" +import { workspacePublicRouter } from "@/features/workspaces/api/public" +import { apiTokensPublicRouter } from "@/features/workspaces/api/public/api-tokens" export const publicRouter = { ads: adsPublicRouter, @@ -60,10 +64,12 @@ export const publicRouter = { aiFunctions: aiFunctionsPublicRouter, aiMcpServers: aiMcpServersPublicRouter, analytics: analyticsPublicRouter, + apiTokens: apiTokensPublicRouter, appointmentCalendars: appointmentCalendarsPublicRouter, appointmentExternalCalendars: appointmentExternalCalendarsPublicRouter, appointmentReminders: appointmentRemindersPublicRouter, appointments: appointmentsPublicRouter, + auditLogs: auditLogsPublicRouter, botFields: botFieldsPublicRouter, broadcasts: broadcastsPublicRouter, channels: channelsPublicRouter, @@ -102,10 +108,12 @@ export const publicRouter = { spreadsheets: spreadsheetsPublicRouter, tags: tagsPublicRouter, templateMessages: templateMessagesPublicRouter, + templates: templatesPublicRouter, triggers: triggersPublicRouter, userPersistentMenus: userPersistentMenusPublicRouter, webchats: webchatsPublicRouter, webhooks: webhooksPublicRouter, + workspace: workspacePublicRouter, workspaceMembers: workspaceMembersPublicRouter, zaloChannels: zaloChannelsPublicRouter, } diff --git a/packages/business/__tests__/channel-token-refresh.service.test.ts b/packages/business/__tests__/channel-token-refresh.service.test.ts new file mode 100644 index 0000000000..225526c46f --- /dev/null +++ b/packages/business/__tests__/channel-token-refresh.service.test.ts @@ -0,0 +1,452 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +const zaloIntegrationService = { + findAllByWorkspaceIds: vi.fn( + async (_workspaceIds: string[]) => [] as never[], + ), + findById: vi.fn(), + updateAuth: vi.fn(async () => undefined), + markTokenRefreshError: vi.fn(async () => undefined), +} +vi.mock("../src/integration-zalo/service", () => ({ zaloIntegrationService })) + +const tiktokIntegrationService = { + findAllByWorkspaceIds: vi.fn( + async (_workspaceIds: string[]) => [] as never[], + ), + findById: vi.fn(), + updateAuth: vi.fn(async () => undefined), + markTokenRefreshError: vi.fn(async () => undefined), +} +vi.mock("../src/integration-tiktok/service", () => ({ + tiktokIntegrationService, +})) + +const instagramIntegrationService = { + findForTokenRefreshByWorkspaceIds: vi.fn( + async (_workspaceIds: string[]) => [] as never[], + ), + findFacebookForTokenRefreshByWorkspaceIds: vi.fn( + async (_workspaceIds: string[]) => [] as never[], + ), + findByIdForWorkspace: vi.fn(), + updateAuth: vi.fn(async () => undefined), + markTokenRefreshError: vi.fn(async () => undefined), +} +vi.mock("../src/integration-instagram/service", () => ({ + instagramIntegrationService, +})) + +const messengerIntegrationService = { + findForTokenRefreshByWorkspaceIds: vi.fn( + async (_workspaceIds: string[]) => [] as never[], + ), + findByIdForWorkspace: vi.fn(), + updateAuth: vi.fn(async () => undefined), + markTokenRefreshError: vi.fn(async () => undefined), +} +vi.mock("../src/integration-messenger/service", () => ({ + messengerIntegrationService, +})) + +const integrationWhatsappService = { + findForTokenRefreshByWorkspaceIds: vi.fn( + async (_workspaceIds: string[]) => [] as never[], + ), + findByIdForWorkspace: vi.fn(), + updateAuth: vi.fn(async () => undefined), + markTokenRefreshError: vi.fn(async () => undefined), +} +vi.mock("../src/integration-whatsapp/service", () => ({ + integrationWhatsappService, +})) + +const dispatchAuditRecord = vi.fn(async () => undefined) +vi.mock("../src/audit/dispatcher", () => ({ dispatchAuditRecord })) + +const distributedLock = { + runExclusive: vi.fn( + async ({ fn }: { fn: () => Promise }) => await fn(), + ), +} +vi.mock("@chatbotx.io/redis", () => ({ distributedLock })) + +const refreshZaloAccessToken = vi.fn(async () => ({ + access_token: "new-zalo-access", + refresh_token: "new-zalo-refresh", + expires_in: 3600, +})) +vi.mock("@chatbotx.io/integration-zalo", () => ({ + refreshAccessToken: refreshZaloAccessToken, + calculateExpiresAt: (expiresIn: number) => + new Date(Date.now() + expiresIn * 1000).toISOString(), +})) + +const refreshTiktokAccessToken = vi.fn(async () => ({ + access_token: "new-tiktok-access", + refresh_token: "new-tiktok-refresh", + expires_in: 3600, + refresh_expires_in: 86_400, +})) +vi.mock("@chatbotx.io/integration-tiktok/apis/auth", () => ({ + refreshAccessToken: refreshTiktokAccessToken, +})) +vi.mock("@chatbotx.io/integration-tiktok/lib/token-utils", () => ({ + buildTokenTimestamps: (expiresIn: number, refreshExpiresIn: number) => ({ + expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), + refreshTokenExpiresAt: new Date( + Date.now() + refreshExpiresIn * 1000, + ).toISOString(), + }), +})) + +const { channelTokenRefreshService } = await import( + "../src/workspace/channel-token-refresh" +) + +const refreshAuthCallback = vi.fn(async (auth: Record) => ({ + ...auth, + accessToken: "refreshed-provider-token", +})) + +beforeEach(() => { + vi.clearAllMocks() + distributedLock.runExclusive.mockImplementation( + async ({ fn }: { fn: () => Promise }) => await fn(), + ) + zaloIntegrationService.findAllByWorkspaceIds.mockResolvedValue([]) + tiktokIntegrationService.findAllByWorkspaceIds.mockResolvedValue([]) + instagramIntegrationService.findForTokenRefreshByWorkspaceIds.mockResolvedValue( + [], + ) + instagramIntegrationService.findFacebookForTokenRefreshByWorkspaceIds.mockResolvedValue( + [], + ) + messengerIntegrationService.findForTokenRefreshByWorkspaceIds.mockResolvedValue( + [], + ) + integrationWhatsappService.findForTokenRefreshByWorkspaceIds.mockResolvedValue( + [], + ) +}) + +describe("channelTokenRefreshService.refreshWorkspace — per-provider branches", () => { + test("zalo: refreshes an integration with a refresh token and audits it", async () => { + zaloIntegrationService.findAllByWorkspaceIds.mockResolvedValue([ + { + id: "zalo-1", + workspaceId: "ws-1", + auth: { tokens: { refreshToken: "rt-1" } }, + }, + ]) + zaloIntegrationService.findById.mockResolvedValue({ + id: "zalo-1", + workspaceId: "ws-1", + auth: { tokens: { refreshToken: "rt-1" } }, + }) + + const summary = await channelTokenRefreshService.refreshWorkspace({ + workspaceId: "ws-1", + }) + + expect(summary).toEqual({ refreshed: 1, failed: 0 }) + expect(zaloIntegrationService.updateAuth).toHaveBeenCalledWith( + "zalo-1", + expect.objectContaining({ + tokens: expect.objectContaining({ accessToken: "new-zalo-access" }), + }), + ) + expect(dispatchAuditRecord).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: "ws-1", action: "refresh" }), + ) + }) + + test("zalo: skips an integration with no stored refresh token", async () => { + zaloIntegrationService.findAllByWorkspaceIds.mockResolvedValue([ + { id: "zalo-2", workspaceId: "ws-1", auth: { tokens: {} } }, + ]) + zaloIntegrationService.findById.mockResolvedValue({ + id: "zalo-2", + workspaceId: "ws-1", + auth: { tokens: {} }, + }) + + const summary = await channelTokenRefreshService.refreshWorkspace({ + workspaceId: "ws-1", + }) + + expect(summary).toEqual({ refreshed: 0, failed: 0 }) + expect(zaloIntegrationService.updateAuth).not.toHaveBeenCalled() + expect(dispatchAuditRecord).not.toHaveBeenCalled() + }) + + test("zalo: a provider failure marks the integration failed instead of throwing", async () => { + zaloIntegrationService.findAllByWorkspaceIds.mockResolvedValue([ + { + id: "zalo-3", + workspaceId: "ws-1", + auth: { tokens: { refreshToken: "rt-3" } }, + }, + ]) + zaloIntegrationService.findById.mockResolvedValue({ + id: "zalo-3", + workspaceId: "ws-1", + auth: { tokens: { refreshToken: "rt-3" } }, + }) + refreshZaloAccessToken.mockRejectedValueOnce(new Error("provider down")) + + const summary = await channelTokenRefreshService.refreshWorkspace({ + workspaceId: "ws-1", + }) + + expect(summary).toEqual({ refreshed: 0, failed: 1 }) + expect(zaloIntegrationService.markTokenRefreshError).toHaveBeenCalledWith( + "zalo-3", + "provider down", + ) + }) + + test("tiktok: refreshes and persists both new tokens plus expiry timestamps", async () => { + tiktokIntegrationService.findAllByWorkspaceIds.mockResolvedValue([ + { + id: "tiktok-1", + workspaceId: "ws-1", + auth: { + clientId: "cid", + clientSecret: "secret", + tokens: { refreshToken: "rt-1" }, + }, + }, + ]) + tiktokIntegrationService.findById.mockResolvedValue({ + id: "tiktok-1", + workspaceId: "ws-1", + auth: { + clientId: "cid", + clientSecret: "secret", + tokens: { refreshToken: "rt-1" }, + }, + }) + + const summary = await channelTokenRefreshService.refreshWorkspace({ + workspaceId: "ws-1", + }) + + expect(summary).toEqual({ refreshed: 1, failed: 0 }) + expect(tiktokIntegrationService.updateAuth).toHaveBeenCalledWith( + "tiktok-1", + expect.objectContaining({ + tokens: expect.objectContaining({ + accessToken: "new-tiktok-access", + refreshToken: "new-tiktok-refresh", + }), + }), + ) + }) + + test("instagram/messenger/whatsapp: skip entirely without a refreshAuth callback", async () => { + instagramIntegrationService.findForTokenRefreshByWorkspaceIds.mockResolvedValue( + [{ id: "ig-1", workspaceId: "ws-1", auth: {} }], + ) + messengerIntegrationService.findForTokenRefreshByWorkspaceIds.mockResolvedValue( + [{ id: "msg-1", workspaceId: "ws-1", auth: {} }], + ) + + const summary = await channelTokenRefreshService.refreshWorkspace({ + workspaceId: "ws-1", + }) + + expect(summary).toEqual({ refreshed: 0, failed: 0 }) + // The callback-less providers must never even query for candidates — + // this is the short-circuit at the top of each `refreshXIntegrations`. + expect( + instagramIntegrationService.findForTokenRefreshByWorkspaceIds, + ).not.toHaveBeenCalled() + expect( + messengerIntegrationService.findForTokenRefreshByWorkspaceIds, + ).not.toHaveBeenCalled() + }) + + test("messenger: refreshes via the injected auth callback", async () => { + messengerIntegrationService.findForTokenRefreshByWorkspaceIds.mockResolvedValue( + [{ id: "msg-1", workspaceId: "ws-1", auth: { pageAccessToken: "old" } }], + ) + messengerIntegrationService.findByIdForWorkspace.mockResolvedValue({ + id: "msg-1", + workspaceId: "ws-1", + auth: { pageAccessToken: "old" }, + }) + + const summary = await channelTokenRefreshService.refreshWorkspace({ + workspaceId: "ws-1", + refreshMessengerAuth: refreshAuthCallback, + }) + + expect(summary).toEqual({ refreshed: 1, failed: 0 }) + expect(refreshAuthCallback).toHaveBeenCalledWith({ + pageAccessToken: "old", + }) + expect(messengerIntegrationService.updateAuth).toHaveBeenCalledWith({ + id: "msg-1", + workspaceId: "ws-1", + auth: { pageAccessToken: "old", accessToken: "refreshed-provider-token" }, + }) + }) + + test("messenger: a deleted integration (findByIdForWorkspace -> null) is skipped, not failed", async () => { + messengerIntegrationService.findForTokenRefreshByWorkspaceIds.mockResolvedValue( + [{ id: "msg-2", workspaceId: "ws-1", auth: {} }], + ) + messengerIntegrationService.findByIdForWorkspace.mockResolvedValue(null) + + const summary = await channelTokenRefreshService.refreshWorkspace({ + workspaceId: "ws-1", + refreshMessengerAuth: refreshAuthCallback, + }) + + expect(summary).toEqual({ refreshed: 0, failed: 0 }) + expect(refreshAuthCallback).not.toHaveBeenCalled() + expect( + messengerIntegrationService.markTokenRefreshError, + ).not.toHaveBeenCalled() + }) + + test("whatsapp: a manually-authenticated integration is skipped, never refreshed", async () => { + integrationWhatsappService.findForTokenRefreshByWorkspaceIds.mockResolvedValue( + [ + { + id: "wa-1", + workspaceId: "ws-1", + auth: { metadata: { isManual: true } }, + }, + ], + ) + integrationWhatsappService.findByIdForWorkspace.mockResolvedValue({ + id: "wa-1", + workspaceId: "ws-1", + auth: { metadata: { isManual: true } }, + }) + + const summary = await channelTokenRefreshService.refreshWorkspace({ + workspaceId: "ws-1", + refreshWhatsappAuth: refreshAuthCallback, + }) + + expect(summary).toEqual({ refreshed: 0, failed: 0 }) + expect(refreshAuthCallback).not.toHaveBeenCalled() + expect(integrationWhatsappService.updateAuth).not.toHaveBeenCalled() + }) + + test("a lock-acquisition rejection for one integration fails only that item (runInBatches isolation)", async () => { + zaloIntegrationService.findAllByWorkspaceIds.mockResolvedValue([ + { + id: "zalo-ok", + workspaceId: "ws-1", + auth: { tokens: { refreshToken: "rt-ok" } }, + }, + { + id: "zalo-lock-timeout", + workspaceId: "ws-1", + auth: { tokens: { refreshToken: "rt-timeout" } }, + }, + ]) + zaloIntegrationService.findById.mockImplementation( + async ({ id }: { id: string }) => ({ + id, + workspaceId: "ws-1", + auth: { tokens: { refreshToken: `rt-${id}` } }, + }), + ) + distributedLock.runExclusive.mockImplementation( + async ({ key, fn }: { key: string; fn: () => Promise }) => { + if (key.includes("zalo-lock-timeout")) { + throw new Error("lock acquisition timed out") + } + return await fn() + }, + ) + + const summary = await channelTokenRefreshService.refreshWorkspace({ + workspaceId: "ws-1", + }) + + // `runInBatches`' per-item `.catch(() => "failed")` must isolate the + // rejected lock acquisition from the sibling integration that + // succeeded — a single bad lock must never fail the whole batch. + expect(summary).toEqual({ refreshed: 1, failed: 1 }) + }) +}) + +describe("channelTokenRefreshService.refreshWorkspaces — cross-workspace batching", () => { + test("queries each provider once with every workspace id instead of once per workspace", async () => { + const summary = await channelTokenRefreshService.refreshWorkspaces({ + workspaceIds: ["ws-1", "ws-2", "ws-3"], + }) + + expect(summary).toEqual({ refreshed: 0, failed: 0 }) + expect(zaloIntegrationService.findAllByWorkspaceIds).toHaveBeenCalledTimes( + 1, + ) + expect(zaloIntegrationService.findAllByWorkspaceIds).toHaveBeenCalledWith([ + "ws-1", + "ws-2", + "ws-3", + ]) + expect( + tiktokIntegrationService.findAllByWorkspaceIds, + ).toHaveBeenCalledTimes(1) + expect(tiktokIntegrationService.findAllByWorkspaceIds).toHaveBeenCalledWith( + ["ws-1", "ws-2", "ws-3"], + ) + }) + + test("returns a zero summary and skips every provider query for an empty id list", async () => { + const summary = await channelTokenRefreshService.refreshWorkspaces({ + workspaceIds: [], + }) + + expect(summary).toEqual({ refreshed: 0, failed: 0 }) + expect(zaloIntegrationService.findAllByWorkspaceIds).not.toHaveBeenCalled() + }) + + test("sums refreshed/failed across every provider for the combined workspace set", async () => { + zaloIntegrationService.findAllByWorkspaceIds.mockResolvedValue([ + { + id: "zalo-a", + workspaceId: "ws-1", + auth: { tokens: { refreshToken: "rt-a" } }, + }, + ]) + zaloIntegrationService.findById.mockResolvedValue({ + id: "zalo-a", + workspaceId: "ws-1", + auth: { tokens: { refreshToken: "rt-a" } }, + }) + tiktokIntegrationService.findAllByWorkspaceIds.mockResolvedValue([ + { + id: "tiktok-b", + workspaceId: "ws-2", + auth: { + clientId: "cid", + clientSecret: "secret", + tokens: { refreshToken: "rt-b" }, + }, + }, + ]) + tiktokIntegrationService.findById.mockResolvedValue({ + id: "tiktok-b", + workspaceId: "ws-2", + auth: { + clientId: "cid", + clientSecret: "secret", + tokens: { refreshToken: "rt-b" }, + }, + }) + + const summary = await channelTokenRefreshService.refreshWorkspaces({ + workspaceIds: ["ws-1", "ws-2"], + }) + + expect(summary).toEqual({ refreshed: 2, failed: 0 }) + }) +}) diff --git a/packages/business/__tests__/invitation.service.test.ts b/packages/business/__tests__/invitation.service.test.ts new file mode 100644 index 0000000000..fea578853c --- /dev/null +++ b/packages/business/__tests__/invitation.service.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +const insertBuilder = { + values: vi.fn(() => insertBuilder), + returning: vi.fn(async () => [ + { id: "invitation-1", workspaceId: "ws-1", code: "code-1" }, + ]), +} +const db = { insert: vi.fn(() => insertBuilder) } +vi.mock("@chatbotx.io/database/client", () => ({ db })) + +vi.mock("@chatbotx.io/database/schema", () => ({ invitationModel: {} })) + +vi.mock("@chatbotx.io/utils", () => ({ + createId: vi.fn(() => "generated-id"), + SymbolicSnowflakeIDs: { generate: vi.fn(() => "generated-code") }, +})) + +vi.mock("../src/workspace-member/permissions", () => ({ + normalizeWorkspaceMemberPermissions: (permissions: unknown) => permissions, +})) + +const findById = vi.fn(async () => ({ id: "ws-1", ownerId: "owner-1" })) +vi.mock("../src/workspace/service", () => ({ + workspaceService: { findById: (...args: unknown[]) => findById(...args) }, +})) + +const hasReachedLimit = vi.fn(async () => false) +vi.mock("../src/quota-enforcement/service", () => ({ + quotaEnforcementService: { + hasReachedLimit: (...args: unknown[]) => hasReachedLimit(...args), + }, +})) + +const dispatchAuditRecord = vi.fn(async () => undefined) +vi.mock("../src/audit/dispatcher", () => ({ dispatchAuditRecord })) + +const loggerWarn = vi.fn() +vi.mock("../src/logger", () => ({ logger: { warn: loggerWarn } })) + +const { invitationService } = await import("../src/invitation/service") + +const basePermissions = { + superAdmin: false, + analytics: true, + flows: true, + contacts: true, + onlyAssignedContacts: false, + emailAndPhone: true, + broadcast: true, + ecommerce: true, +} + +beforeEach(() => { + vi.clearAllMocks() + insertBuilder.values.mockReturnValue(insertBuilder) + insertBuilder.returning.mockResolvedValue([ + { id: "invitation-1", workspaceId: "ws-1", code: "code-1" }, + ]) + findById.mockResolvedValue({ id: "ws-1", ownerId: "owner-1" }) + hasReachedLimit.mockResolvedValue(false) +}) + +describe("invitationService.create", () => { + test("throws and never inserts once the owner's team-member limit is reached", async () => { + hasReachedLimit.mockResolvedValue(true) + + await expect( + invitationService.create({ + workspaceId: "ws-1", + permissions: basePermissions, + invitedBy: "user-1", + }), + ).rejects.toThrow("Team member limit reached for this workspace plan") + + expect(db.insert).not.toHaveBeenCalled() + }) + + test("inserts the invitation and records an 'invite' audit when not inside a caller transaction", async () => { + const invitation = await invitationService.create({ + workspaceId: "ws-1", + permissions: { ...basePermissions, superAdmin: true }, + invitedBy: "user-1", + }) + + expect(invitation).toEqual({ + id: "invitation-1", + workspaceId: "ws-1", + code: "code-1", + }) + expect(dispatchAuditRecord).toHaveBeenCalledWith({ + action: "invite", + detail: "invited a new admin", + }) + }) + + test("skips the audit call entirely when composed inside a caller-owned transaction", async () => { + const tx = { insert: vi.fn(() => insertBuilder) } + + await invitationService.create({ + workspaceId: "ws-1", + permissions: basePermissions, + invitedBy: "user-1", + tx: tx as never, + }) + + expect(dispatchAuditRecord).not.toHaveBeenCalled() + }) + + test("a failing audit write is logged and swallowed — the invitation is still returned", async () => { + dispatchAuditRecord.mockRejectedValueOnce(new Error("audit backend down")) + + const invitation = await invitationService.create({ + workspaceId: "ws-1", + permissions: basePermissions, + invitedBy: "user-1", + }) + + expect(invitation).toEqual({ + id: "invitation-1", + workspaceId: "ws-1", + code: "code-1", + }) + expect(loggerWarn).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "ws-1", + invitationId: "invitation-1", + }), + "Failed to record audit log for workspace invitation", + ) + }) +}) diff --git a/packages/business/__tests__/template-cross-workspace-idor.test.ts b/packages/business/__tests__/template-cross-workspace-idor.test.ts new file mode 100644 index 0000000000..896af616d9 --- /dev/null +++ b/packages/business/__tests__/template-cross-workspace-idor.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type Row = Record +type IsNullCondition = { isNull: boolean } + +const isIsNullCondition = (value: unknown): value is IsNullCondition => + typeof value === "object" && value !== null && "isNull" in value + +const matchesWhere = (row: Row, where: Row): boolean => + Object.entries(where).every(([key, condition]) => { + if (isIsNullCondition(condition)) { + return condition.isNull ? row[key] == null : row[key] != null + } + return row[key] === condition + }) + +const templateRows: Row[] = [ + { + id: "template-a", + workspaceId: "workspace-a", + tenantId: "tenant-1", + deletedAt: null, + name: "Template A", + }, +] + +const findFirst = vi.fn( + async ({ where }: { where: Row }) => + templateRows.find((row) => matchesWhere(row, where)) ?? undefined, +) + +const db = { + query: { templateModel: { findFirst } }, +} +vi.mock("@chatbotx.io/database/client", () => ({ db, eq: vi.fn() })) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + templateSelectableResourceRepository: {}, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + templateModel: {}, + templateInstallationModel: {}, +})) + +vi.mock("@chatbotx.io/flow-config", () => ({ parseTemplateExport: vi.fn() })) + +vi.mock("@chatbotx.io/utils", () => ({ createId: vi.fn(() => "generated-id") })) + +vi.mock("@chatbotx.io/worker-config", () => ({ + DefaultJobAction: { installTemplate: "installTemplate" }, + defaultQueue: { add: vi.fn() }, +})) + +vi.mock("../src/workspace", () => ({ + workspaceService: { findOrFail: vi.fn(), findById: vi.fn() }, +})) + +vi.mock("../src/template/snapshot.service", () => ({ + buildTemplateSnapshot: vi.fn(), +})) + +const { templateService } = await import("../src/template/service") + +beforeEach(() => { + findFirst.mockClear() +}) + +describe("templateService.findByIdOrFail — cross-workspace isolation (IDOR)", () => { + test("resolves a template scoped to its own workspace", async () => { + const template = await templateService.findByIdOrFail({ + workspaceId: "workspace-a", + templateId: "template-a", + }) + + expect(template).toMatchObject({ id: "template-a" }) + }) + + test("a template id that exists under a different workspace resolves not-found, never the other workspace's row", async () => { + await expect( + templateService.findByIdOrFail({ + workspaceId: "workspace-b", + templateId: "template-a", + }), + ).rejects.toMatchObject({ code: "notFound" }) + + // The lookup must have been attempted scoped by both id AND workspaceId + // together — never id alone followed by an app-layer ownership check. + expect(findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: "template-a", + workspaceId: "workspace-b", + }), + }), + ) + }) +}) diff --git a/packages/business/__tests__/workspace-api-token-cross-workspace-idor.test.ts b/packages/business/__tests__/workspace-api-token-cross-workspace-idor.test.ts new file mode 100644 index 0000000000..c4317725eb --- /dev/null +++ b/packages/business/__tests__/workspace-api-token-cross-workspace-idor.test.ts @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type TokenRow = { + id: string + workspaceId: string + isDefault: boolean + name: string + permission: string + scopes: string[] | null +} + +const tokenRows: TokenRow[] = [ + { + id: "token-a", + workspaceId: "workspace-a", + isDefault: false, + name: "Token A", + permission: "full", + scopes: null, + }, +] + +const findByIdForWorkspace = vi.fn( + async ({ workspaceId, id }: { workspaceId: string; id: string }) => + tokenRows.find((row) => row.id === id && row.workspaceId === workspaceId) ?? + undefined, +) + +const updateByIdForWorkspace = vi.fn( + ({ workspaceId, id }: { workspaceId: string; id: string }) => + tokenRows.find( + (candidate) => + candidate.id === id && candidate.workspaceId === workspaceId, + ), +) + +const deleteByIdForWorkspace = vi.fn( + async ({ id, workspaceId }: { id: string; workspaceId: string }) => + tokenRows.some((row) => row.id === id && row.workspaceId === workspaceId), +) + +vi.mock("@chatbotx.io/database/client", () => ({ + db: { transaction: vi.fn() }, +})) + +vi.mock("@chatbotx.io/database/repositories", () => ({ + workspaceApiTokenRepository: { + findByIdForWorkspace, + updateByIdForWorkspace, + deleteByIdForWorkspace, + }, +})) + +vi.mock("@chatbotx.io/encryption", () => ({ + encryptUtils: { encryptText: vi.fn(), decryptText: vi.fn() }, +})) + +vi.mock("@chatbotx.io/redis", () => ({ + withCache: vi.fn(), + invalidateCacheByTags: vi.fn(async () => undefined), +})) + +vi.mock("../src/audit/dispatcher", () => ({ + dispatchAuditRecord: vi.fn(async () => undefined), +})) + +vi.mock("../src/workspace/service", () => ({ + workspaceService: { findById: vi.fn() }, +})) + +const { workspaceApiTokenService } = await import( + "../src/workspace-api-token/service" +) + +beforeEach(() => { + findByIdForWorkspace.mockClear() + updateByIdForWorkspace.mockClear() + deleteByIdForWorkspace.mockClear() +}) + +describe("workspaceApiTokenService — cross-workspace isolation (IDOR)", () => { + test("findTokenOrFail resolves a token scoped to its own workspace", async () => { + const token = await workspaceApiTokenService.findTokenOrFail({ + workspaceId: "workspace-a", + id: "token-a", + }) + + expect(token).toMatchObject({ id: "token-a" }) + }) + + test("findTokenOrFail throws not-found for a token id that exists under a different workspace", async () => { + await expect( + workspaceApiTokenService.findTokenOrFail({ + workspaceId: "workspace-b", + id: "token-a", + }), + ).rejects.toMatchObject({ code: "notFound" }) + }) + + test("updateToken never reaches the repository write for a cross-workspace token id", async () => { + await expect( + workspaceApiTokenService.updateToken({ + workspaceId: "workspace-b", + id: "token-a", + name: "Hijacked name", + }), + ).rejects.toMatchObject({ code: "notFound" }) + + expect(updateByIdForWorkspace).not.toHaveBeenCalled() + }) + + test("deleteToken reports no deletion for a cross-workspace token id", async () => { + const deleted = await workspaceApiTokenService.deleteToken({ + workspaceId: "workspace-b", + id: "token-a", + }) + + expect(deleted).toBe(false) + expect(deleteByIdForWorkspace).toHaveBeenCalledWith( + { id: "token-a", workspaceId: "workspace-b" }, + expect.anything(), + ) + }) +}) diff --git a/packages/business/__tests__/workspace-api-token.service.test.ts b/packages/business/__tests__/workspace-api-token.service.test.ts index 4a7f64e652..51303a0c1b 100644 --- a/packages/business/__tests__/workspace-api-token.service.test.ts +++ b/packages/business/__tests__/workspace-api-token.service.test.ts @@ -14,6 +14,9 @@ const listByWorkspaceId = vi.fn(async () => [] as unknown[]) const countByWorkspaceId = vi.fn(async (): Promise => 0) const lockWorkspaceTokens = vi.fn(async (): Promise => undefined) const deleteByIdForWorkspace = vi.fn(async (): Promise => false) +const findByIdForWorkspace = vi.fn(async (): Promise => null) +const updateByIdForWorkspace = vi.fn(async (): Promise => null) +const rotateTokenById = vi.fn(async (): Promise => null) const insert = vi.fn(async () => ({ id: "t-1", workspaceId: "ws-1", @@ -33,6 +36,9 @@ vi.mock("@chatbotx.io/database/repositories", () => ({ countByWorkspaceId, lockWorkspaceTokens, deleteByIdForWorkspace, + findByIdForWorkspace, + updateByIdForWorkspace, + rotateTokenById, insert, findDefaultByWorkspaceId, insertDefault, @@ -108,6 +114,9 @@ beforeEach(() => { countByWorkspaceId.mockResolvedValue(0) lockWorkspaceTokens.mockResolvedValue(undefined) deleteByIdForWorkspace.mockResolvedValue(false) + findByIdForWorkspace.mockResolvedValue(null) + updateByIdForWorkspace.mockResolvedValue(null) + rotateTokenById.mockResolvedValue(null) workspaceService.findById.mockResolvedValue({ id: "ws-1", name: "Acme", @@ -378,6 +387,123 @@ describe("workspaceApiTokenService.createToken", () => { }) }) +describe("workspaceApiTokenService.updateToken", () => { + test("updates the supplied fields and invalidates the workspace token cache", async () => { + findByIdForWorkspace.mockResolvedValue({ + id: "t-1", + workspaceId: "ws-1", + isDefault: false, + }) + const updatedToken = { + id: "t-1", + workspaceId: "ws-1", + name: "Renamed token", + permission: "full", + scopes: null, + } + updateByIdForWorkspace.mockResolvedValue(updatedToken) + + await expect( + workspaceApiTokenService.updateToken({ + workspaceId: "ws-1", + id: "t-1", + name: "Renamed token", + }), + ).resolves.toEqual(updatedToken) + + expect(updateByIdForWorkspace).toHaveBeenCalledWith( + { + workspaceId: "ws-1", + id: "t-1", + name: "Renamed token", + permission: undefined, + scopes: undefined, + }, + db, + ) + expect(invalidateCacheByTags).toHaveBeenCalledWith([ + workspaceApiTokenCacheTag("ws-1"), + ]) + }) + + test("rejects a default token before updating it", async () => { + findByIdForWorkspace.mockResolvedValue({ + id: "t-default", + workspaceId: "ws-1", + isDefault: true, + }) + + await expect( + workspaceApiTokenService.updateToken({ + workspaceId: "ws-1", + id: "t-default", + name: "Renamed token", + }), + ).rejects.toMatchObject({ code: "workspaceApiTokenImmutable" }) + + expect(updateByIdForWorkspace).not.toHaveBeenCalled() + }) +}) + +describe("workspaceApiTokenService.rotateToken", () => { + test("rotates credentials and invalidates the workspace token cache", async () => { + findByIdForWorkspace.mockResolvedValue({ + id: "t-1", + workspaceId: "ws-1", + isDefault: false, + }) + const rotatedToken = { + id: "t-1", + workspaceId: "ws-1", + name: "Rotated token", + permission: "full", + scopes: null, + } + rotateTokenById.mockResolvedValue(rotatedToken) + + await expect( + workspaceApiTokenService.rotateToken({ + workspaceId: "ws-1", + id: "t-1", + tokenHash: TOKEN_HASH, + tokenPrefix: "cbx_ws_rotated", + }), + ).resolves.toEqual(rotatedToken) + + expect(rotateTokenById).toHaveBeenCalledWith( + { + workspaceId: "ws-1", + id: "t-1", + tokenHash: TOKEN_HASH, + tokenPrefix: "cbx_ws_rotated", + }, + db, + ) + expect(invalidateCacheByTags).toHaveBeenCalledWith([ + workspaceApiTokenCacheTag("ws-1"), + ]) + }) + + test("rejects a default token before rotating it", async () => { + findByIdForWorkspace.mockResolvedValue({ + id: "t-default", + workspaceId: "ws-1", + isDefault: true, + }) + + await expect( + workspaceApiTokenService.rotateToken({ + workspaceId: "ws-1", + id: "t-default", + tokenHash: TOKEN_HASH, + tokenPrefix: "cbx_ws_rotated", + }), + ).rejects.toMatchObject({ code: "workspaceApiTokenImmutable" }) + + expect(rotateTokenById).not.toHaveBeenCalled() + }) +}) + describe("workspaceApiTokenService.deleteToken", () => { test("audits and invalidates the workspace's token cache tag when a row was actually deleted", async () => { deleteByIdForWorkspace.mockResolvedValue(true) diff --git a/packages/business/__tests__/workspace-member-cross-workspace-idor.test.ts b/packages/business/__tests__/workspace-member-cross-workspace-idor.test.ts new file mode 100644 index 0000000000..16f048a2c4 --- /dev/null +++ b/packages/business/__tests__/workspace-member-cross-workspace-idor.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" + +type Row = Record + +const matchesWhere = (row: Row, where: Row): boolean => + Object.entries(where).every(([key, condition]) => row[key] === condition) + +const memberRows: Row[] = [ + { + id: "member-a", + workspaceId: "workspace-a", + userId: "user-a", + permissions: { superAdmin: false }, + notificationTypes: [], + notificationChannels: [], + }, +] + +const findFirst = vi.fn( + async ({ where }: { where: Row }) => + memberRows.find((row) => matchesWhere(row, where)) ?? undefined, +) + +const updateBuilder = { + set: vi.fn(() => updateBuilder), + where: vi.fn(() => updateBuilder), + returning: vi.fn(async () => [] as Row[]), +} + +const db = { + query: { workspaceMemberModel: { findFirst } }, + update: vi.fn(() => updateBuilder), +} +vi.mock("@chatbotx.io/database/client", () => ({ + db, + and: (...conditions: unknown[]) => ({ and: conditions }), + eq: (column: unknown, value: unknown) => ({ column, value }), + relationsFilterToSQL: vi.fn(), +})) + +vi.mock("@chatbotx.io/database/partials", () => ({ + workspaceMemberRoles: { enum: { owner: "owner" } }, +})) + +vi.mock("@chatbotx.io/database/schema", () => ({ + workspaceMemberModel: { id: "id-column", workspaceId: "workspaceId-column" }, +})) + +vi.mock("@chatbotx.io/database/utils", () => ({ + getPaginationWithDefaults: vi.fn(), + likeContains: vi.fn(), +})) + +vi.mock("@chatbotx.io/redis", () => ({ withCache: vi.fn() })) + +vi.mock("../src/user/service", () => ({ + userService: { findNameAndEmail: vi.fn() }, +})) + +vi.mock("../src/workspace-usage/service", () => ({ + workspaceUsageService: { increment: vi.fn(), decrement: vi.fn() }, +})) + +const { workspaceMemberService } = await import( + "../src/workspace-member/service" +) + +beforeEach(() => { + findFirst.mockClear() +}) + +describe("workspaceMemberService.findByIdOrFail — cross-workspace isolation (IDOR)", () => { + test("resolves a member scoped to its own workspace", async () => { + const member = await workspaceMemberService.findByIdOrFail({ + id: "member-a", + workspaceId: "workspace-a", + }) + + expect(member).toMatchObject({ id: "member-a" }) + }) + + test("a member id that exists under a different workspace resolves not-found", async () => { + await expect( + workspaceMemberService.findByIdOrFail({ + id: "member-a", + workspaceId: "workspace-b", + }), + ).rejects.toMatchObject({ code: "notFound" }) + + expect(findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: "member-a", + workspaceId: "workspace-b", + }), + }), + ) + }) +}) + +describe("workspaceMemberService.updateMember — cross-workspace isolation (IDOR)", () => { + test("a member id that exists under a different workspace is never updated", async () => { + await expect( + workspaceMemberService.updateMember({ + id: "member-a", + workspaceId: "workspace-b", + data: { permissions: { superAdmin: true } }, + }), + ).rejects.toMatchObject({ code: "notFound" }) + + // `updateMember` must fail its existence check before ever issuing the + // write — a service that skipped the workspace-scoped read and went + // straight to `update(id, workspaceId)` could otherwise no-op silently + // instead of surfacing the cross-workspace attempt. + expect(db.update).not.toHaveBeenCalled() + }) +}) diff --git a/packages/business/package.json b/packages/business/package.json index 3987d58546..8fb92e6ede 100644 --- a/packages/business/package.json +++ b/packages/business/package.json @@ -52,6 +52,8 @@ "@chatbotx.io/imports": "workspace:*", "@chatbotx.io/integration-facebook-ads": "workspace:*", "@chatbotx.io/integration-google-calendar": "workspace:*", + "@chatbotx.io/integration-tiktok": "workspace:*", + "@chatbotx.io/integration-zalo": "workspace:*", "@chatbotx.io/javascript-sandbox": "workspace:*", "@chatbotx.io/logger": "workspace:*", "@chatbotx.io/partysocket-config": "workspace:*", diff --git a/packages/business/src/invitation/service.ts b/packages/business/src/invitation/service.ts index 3c24b07bd3..8a53f76a96 100644 --- a/packages/business/src/invitation/service.ts +++ b/packages/business/src/invitation/service.ts @@ -1,9 +1,67 @@ -import { db } from "@chatbotx.io/database/client" +import { type DatabaseClient, db } from "@chatbotx.io/database/client" +import type { WorkspaceMemberPermissions } from "@chatbotx.io/database/partials" +import { invitationModel } from "@chatbotx.io/database/schema" import type { InvitationModel } from "@chatbotx.io/database/types" +import { createId, SymbolicSnowflakeIDs } from "@chatbotx.io/utils" +import { addDays } from "date-fns" import { BaseService } from "../base.service" -import { notFoundException } from "../errors" +import { ChatbotXException, notFoundException } from "../errors" +import { logger } from "../logger" +import { quotaEnforcementService } from "../quota-enforcement/service" +import { workspaceService } from "../workspace/service" +import { normalizeWorkspaceMemberPermissions } from "../workspace-member/permissions" class InvitationService extends BaseService { + async create(props: { + workspaceId: string + permissions: WorkspaceMemberPermissions + invitedBy: string + tx?: DatabaseClient + }): Promise { + const { tx = db, workspaceId, invitedBy } = props + const permissions = normalizeWorkspaceMemberPermissions(props.permissions) + // Team-member usage is reconcile-counted after acceptance, so prevent + // issuing invitations once the workspace owner is at their limit. + const workspace = await workspaceService.findById({ id: workspaceId, tx }) + const atLimit = await quotaEnforcementService.hasReachedLimit({ + userId: workspace.ownerId, + metric: "teamMembers", + }) + if (atLimit) { + throw new ChatbotXException( + "Team member limit reached for this workspace plan", + ) + } + + const [invitation] = await tx + .insert(invitationModel) + .values({ + id: createId(), + code: SymbolicSnowflakeIDs.generate(), + permissions, + expiresAt: addDays(new Date(), 1), + workspaceId, + invitedBy, + }) + .returning() + + if (!props.tx) { + try { + await this.audit( + "invite", + `invited a new ${permissions.superAdmin ? "admin" : "member"}`, + ) + } catch (err) { + logger.warn( + { err, workspaceId, invitationId: invitation.id }, + "Failed to record audit log for workspace invitation", + ) + } + } + + return invitation + } + async findByCodeOrFail(code: string): Promise { const invitation = await db.query.invitationModel.findFirst({ where: { code }, diff --git a/packages/business/src/template/service.ts b/packages/business/src/template/service.ts index 17fafbfc4f..6ad802e02b 100644 --- a/packages/business/src/template/service.ts +++ b/packages/business/src/template/service.ts @@ -15,6 +15,7 @@ import type { } from "@chatbotx.io/database/types" import { parseTemplateExport } from "@chatbotx.io/flow-config" import { createId } from "@chatbotx.io/utils" +import { DefaultJobAction, defaultQueue } from "@chatbotx.io/worker-config" import { ChatbotXException, notFoundException } from "../errors" import { workspaceService } from "../workspace" import { generateShareToken } from "./share-token" @@ -163,7 +164,7 @@ class TemplateService { async createOrUpdate(input: { workspaceId: string tenantId: string - createdBy: string + createdBy: string | null name: string description?: string | null imageUrl?: string | null @@ -318,7 +319,7 @@ class TemplateService { */ async createInstallationRecord(input: { workspaceId: string - installedBy: string + installedBy: string | null template: TemplateModel }): Promise { const [installation] = await db @@ -360,6 +361,52 @@ class TemplateService { .where(eq(templateInstallationModel.id, input.installationId)) } + /** + * Full install-lifecycle sequence shared by the private + * `installTemplateAction` and the public `POST /v1/templates/installations` + * route: validate installability, create the `pending` tracking row, then + * enqueue the worker job — marking the row `failed` (and rethrowing) if + * the enqueue itself throws, so it is never left stuck at `pending`. + */ + async enqueueInstallation(input: { + shareToken: string + workspaceId: string + installedBy: string | null + }): Promise { + const { template } = await this.assertInstallable({ + shareToken: input.shareToken, + targetWorkspaceId: input.workspaceId, + }) + + const installation = await this.createInstallationRecord({ + workspaceId: input.workspaceId, + installedBy: input.installedBy, + template, + }) + + try { + await defaultQueue.add( + DefaultJobAction.installTemplate, + { + type: DefaultJobAction.installTemplate, + data: { + installationId: installation.id, + workspaceId: input.workspaceId, + }, + }, + { jobId: `install-template-${installation.id}` }, + ) + } catch (error) { + await this.markInstallationFailed({ + installationId: installation.id, + errorMessage: "Unable to queue template install", + }) + throw error + } + + return installation + } + /** * Fetches the installation row plus the payload it should install from. * `TemplateInstallation` has no payload column of its own — the payload diff --git a/packages/business/src/workspace-api-token/service.ts b/packages/business/src/workspace-api-token/service.ts index 3d1359004f..edf4564d50 100644 --- a/packages/business/src/workspace-api-token/service.ts +++ b/packages/business/src/workspace-api-token/service.ts @@ -12,7 +12,7 @@ import type { import { encryptUtils } from "@chatbotx.io/encryption" import { withCache } from "@chatbotx.io/redis" import { BaseService } from "../base.service" -import { ChatbotXException } from "../errors" +import { ChatbotXException, notFoundException } from "../errors" import { logger } from "../logger" import { workspaceService } from "../workspace/service" import { generateWorkspaceToken } from "./credentials" @@ -30,6 +30,10 @@ const WORKSPACE_API_TOKEN_CACHE_TTL_SECONDS = 300 export const workspaceApiTokenCacheTag = (workspaceId: string) => `workspace-api-tokens:${workspaceId}` +const scopeSummary = ( + scopes: WorkspaceApiTokenScope[] | null | undefined, +): string => (scopes?.length ? scopes.join(",") : "all") + class WorkspaceApiTokenService extends BaseService { async findWorkspaceByTokenHash(props: { tokenHash: TokenHash @@ -106,6 +110,124 @@ class WorkspaceApiTokenService extends BaseService { return await workspaceApiTokenRepository.listByWorkspaceId(workspaceId, tx) } + async findTokenOrFail(props: { + workspaceId: string + id: string + tx?: DatabaseClient + }): Promise { + const token = await workspaceApiTokenRepository.findByIdForWorkspace( + { workspaceId: props.workspaceId, id: props.id }, + props.tx, + ) + if (!token) { + throw notFoundException("Workspace API token not found") + } + + return token + } + + async updateToken(props: { + workspaceId: string + id: string + name?: string + permission?: WorkspaceApiTokenPermission + scopes?: WorkspaceApiTokenScope[] | null + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, name, permission, scopes, tx = db } = props + const token = await this.findTokenOrFail({ workspaceId, id, tx }) + if (token.isDefault) { + throw new ChatbotXException( + "The default workspace API token cannot be modified", + "workspaceApiTokenImmutable", + ) + } + + const updatedToken = + await workspaceApiTokenRepository.updateByIdForWorkspace( + { workspaceId, id, name, permission, scopes }, + tx, + ) + if (!updatedToken) { + throw notFoundException("Workspace API token not found") + } + + try { + await this.invalidateCacheTags(workspaceApiTokenCacheTag(workspaceId)) + } catch (err) { + logger.warn( + { err, workspaceId, id }, + "Failed to invalidate workspace API token cache; update still applied", + ) + } + + if (!props.tx) { + try { + await this.audit( + "update", + `updated workspace API token "${updatedToken.name}" (${updatedToken.permission}, scopes: ${scopeSummary(updatedToken.scopes)})`, + ) + } catch (err) { + logger.warn( + { err, workspaceId, tokenId: updatedToken.id }, + "Failed to record audit log for workspace API token update", + ) + } + } + + return updatedToken + } + + async rotateToken(props: { + workspaceId: string + id: string + tokenHash: TokenHash + tokenPrefix: string + tx?: DatabaseClient + }): Promise { + const { workspaceId, id, tokenHash, tokenPrefix, tx = db } = props + const token = await this.findTokenOrFail({ workspaceId, id, tx }) + if (token.isDefault) { + throw new ChatbotXException( + "The default workspace API token cannot be rotated", + "workspaceApiTokenImmutable", + ) + } + + const rotatedToken = await workspaceApiTokenRepository.rotateTokenById( + { workspaceId, id, tokenHash, tokenPrefix }, + tx, + ) + if (!rotatedToken) { + throw notFoundException("Workspace API token not found") + } + + try { + await this.invalidateCacheTags(workspaceApiTokenCacheTag(workspaceId)) + } catch (err) { + logger.warn( + { err, workspaceId, id }, + "Failed to invalidate workspace API token cache; rotation still applied", + ) + } + + if (!props.tx) { + try { + await this.audit( + "update", + `rotated workspace API token "${rotatedToken.name}" (#${id})`, + ) + } catch (err) { + logger.warn( + { err, workspaceId, tokenId: rotatedToken.id }, + "Failed to record audit log for workspace API token rotation", + ) + } + } + + return rotatedToken + } + async createToken(props: { workspaceId: string name: string @@ -169,11 +291,9 @@ class WorkspaceApiTokenService extends BaseService { // committed create into a user-visible error. if (!props.tx) { try { - const scopeSummary = - scopes && scopes.length > 0 ? scopes.join(",") : "all" await this.audit( "create", - `created workspace API token "${name}" (${permission}, scopes: ${scopeSummary})`, + `created workspace API token "${name}" (${permission}, scopes: ${scopeSummary(scopes)})`, ) } catch (err) { logger.warn( diff --git a/packages/business/src/workspace-member/permissions.ts b/packages/business/src/workspace-member/permissions.ts index aac534b933..9987186d5d 100644 --- a/packages/business/src/workspace-member/permissions.ts +++ b/packages/business/src/workspace-member/permissions.ts @@ -1,4 +1,5 @@ import type { WorkspaceMemberPermissions } from "@chatbotx.io/database/partials" +import { isCommunity } from "../keys" /** * Every `WorkspaceMemberPermissions` flag set to `true`. Used by the @@ -17,3 +18,18 @@ export const FULL_WORKSPACE_MEMBER_PERMISSIONS: WorkspaceMemberPermissions = broadcast: true, ecommerce: true, }) + +export const normalizeWorkspaceMemberPermissions = ( + permissions: WorkspaceMemberPermissions, +): WorkspaceMemberPermissions => { + if (isCommunity()) { + return { ...FULL_WORKSPACE_MEMBER_PERMISSIONS } + } + + return { + ...permissions, + onlyAssignedContacts: permissions.contacts + ? false + : permissions.onlyAssignedContacts, + } +} diff --git a/packages/business/src/workspace-member/service.ts b/packages/business/src/workspace-member/service.ts index c92412ec5c..7592b08d5a 100644 --- a/packages/business/src/workspace-member/service.ts +++ b/packages/business/src/workspace-member/service.ts @@ -5,7 +5,10 @@ import { eq, relationsFilterToSQL, } from "@chatbotx.io/database/client" -import { workspaceMemberRoles } from "@chatbotx.io/database/partials" +import { + type WorkspaceMemberPermissions, + workspaceMemberRoles, +} from "@chatbotx.io/database/partials" import { workspaceMemberModel } from "@chatbotx.io/database/schema" import type { UserModel, @@ -20,7 +23,28 @@ import { withCache } from "@chatbotx.io/redis" import { BaseService } from "../base.service" import { notFoundException } from "../errors" import { logger } from "../logger" +import { userService } from "../user/service" import { workspaceUsageService } from "../workspace-usage/service" +import { normalizeWorkspaceMemberPermissions } from "./permissions" + +/** + * Field-by-field comparison of the fixed-shape `WorkspaceMemberPermissions` + * object — deliberately not `node:util`'s `isDeepStrictEqual`: this module + * is reachable from the barrel traced into the Edge Runtime build (see + * `__tests__/edge-safe-import-graph.test.ts`), which forbids Node built-ins. + */ +const permissionsEqual = ( + a: WorkspaceMemberPermissions, + b: WorkspaceMemberPermissions, +): boolean => + a.superAdmin === b.superAdmin && + a.analytics === b.analytics && + a.flows === b.flows && + a.contacts === b.contacts && + a.onlyAssignedContacts === b.onlyAssignedContacts && + a.emailAndPhone === b.emailAndPhone && + a.broadcast === b.broadcast && + a.ecommerce === b.ecommerce type ListWorkspaceMembersInput = { workspaceId: string @@ -311,6 +335,19 @@ export class WorkspaceMemberService extends BaseService { return member } + normalizeUpdateData< + Data extends Partial, + >(data: Data): Data { + if (!data.permissions) { + return data + } + + return { + ...data, + permissions: normalizeWorkspaceMemberPermissions(data.permissions), + } as Data + } + async update(input: { tx?: DatabaseClient id: string @@ -318,10 +355,11 @@ export class WorkspaceMemberService extends BaseService { data: Partial }): Promise<{ id: string } | undefined> { const { tx = db, id, workspaceId, data } = input + const normalizedData = this.normalizeUpdateData(data) const updated = await tx .update(workspaceMemberModel) - .set(data) + .set(normalizedData) .where( and( eq(workspaceMemberModel.id, id), @@ -345,6 +383,50 @@ export class WorkspaceMemberService extends BaseService { return { id: row.id } } + /** + * Diff + audit wrapper around `update`, shared by the private + * `updateWorkspaceMemberAction` and the public `/v1/members/{memberId}` + * route so a permissions change is recorded as a `role_change` audit + * event from either caller — see AGENTS.md invariant #4 (public/private + * parity). Only a `permissions` change is audited; other fields + * (notification settings) update silently. + */ + async updateMember(input: { + tx?: DatabaseClient + id: string + workspaceId: string + data: Partial + }): Promise<{ id: string } | undefined> { + const { tx, id, workspaceId, data } = input + const normalizedData = this.normalizeUpdateData(data) + + const existing = await this.findByIdOrFail({ id, workspaceId, tx }) + + const updated = await this.update({ + tx, + id, + workspaceId, + data: normalizedData, + }) + if (!updated) { + return + } + + const permissionsChanged = + normalizedData.permissions !== undefined && + !permissionsEqual(existing.permissions, normalizedData.permissions) + + if (permissionsChanged && !tx) { + const targetUser = await userService.findNameAndEmail(existing.userId) + await this.audit( + "role_change", + `changed role of ${targetUser?.name ?? targetUser?.email ?? "a member"} to ${normalizedData.permissions?.superAdmin ? "admin" : "member"}`, + ) + } + + return updated + } + async listPaginated( input: ListWorkspaceMembersInput, ): Promise { diff --git a/packages/business/src/workspace-support-access/service.ts b/packages/business/src/workspace-support-access/service.ts index 7a09cc4de3..740d0dead5 100644 --- a/packages/business/src/workspace-support-access/service.ts +++ b/packages/business/src/workspace-support-access/service.ts @@ -53,7 +53,7 @@ export class WorkspaceSupportAccessService extends BaseService { private auditAndLog(props: { action: SupportAccessAuditAction detail: string - userId: string + userId?: string workspaceId: string }) { const { action, detail, userId, workspaceId } = props @@ -69,7 +69,7 @@ export class WorkspaceSupportAccessService extends BaseService { */ async enable(props: { workspaceId: string - actorUserId: string + actorUserId: string | null }): Promise { const { workspaceId, actorUserId } = props @@ -89,7 +89,7 @@ export class WorkspaceSupportAccessService extends BaseService { await this.auditAndLog({ action: "support_access_enabled", detail: `enabled platform support access for workspace ${workspaceId} until ${supportAccessUntil.toISOString()}`, - userId: actorUserId, + userId: actorUserId ?? undefined, workspaceId, }) } @@ -102,7 +102,7 @@ export class WorkspaceSupportAccessService extends BaseService { */ async disable(props: { workspaceId: string - actorUserId: string + actorUserId: string | null }): Promise { const { workspaceId, actorUserId } = props @@ -120,7 +120,7 @@ export class WorkspaceSupportAccessService extends BaseService { await this.auditAndLog({ action: "support_access_disabled", detail: `disabled platform support access for workspace ${workspaceId}`, - userId: actorUserId, + userId: actorUserId ?? undefined, workspaceId, }) } diff --git a/packages/business/src/workspace/channel-token-refresh.ts b/packages/business/src/workspace/channel-token-refresh.ts new file mode 100644 index 0000000000..d8e2c22266 --- /dev/null +++ b/packages/business/src/workspace/channel-token-refresh.ts @@ -0,0 +1,504 @@ +import type { TiktokAuthValue } from "@chatbotx.io/integration-tiktok" +import { refreshAccessToken as refreshTiktokAccessToken } from "@chatbotx.io/integration-tiktok/apis/auth" +import { buildTokenTimestamps } from "@chatbotx.io/integration-tiktok/lib/token-utils" +import { + calculateExpiresAt, + refreshAccessToken as refreshZaloAccessToken, + type ZaloAuthValue, +} from "@chatbotx.io/integration-zalo" +import { distributedLock } from "@chatbotx.io/redis" +import { dispatchAuditRecord } from "../audit/dispatcher" +import { instagramIntegrationService } from "../integration-instagram/service" +import { messengerIntegrationService } from "../integration-messenger/service" +import { tiktokIntegrationService } from "../integration-tiktok/service" +import { integrationWhatsappService } from "../integration-whatsapp/service" +import { zaloIntegrationService } from "../integration-zalo/service" + +const BATCH_SIZE = 50 +// Must outlive the channel APIs' HTTP timeouts (Zalo's OAuth client allows +// 30s): the Zalo refresh token is single-use, so if the lock expired mid-call +// concurrent refreshes could consume the same refresh token and clobber the +// rotated credentials. +const REFRESH_LOCK_TIMEOUT_SECONDS = 60 + +type RefreshResult = "failed" | "refreshed" | "skipped" +type RefreshSummary = { refreshed: number; failed: number } + +/** + * `@chatbotx.io/integration-instagram`, `-instagram-facebook`, `-messenger`, + * and `-whatsapp` each already depend on `@chatbotx.io/business` (their + * channel-connect wiring lives under `apps/builder`'s integration features, + * which call back into business services). This package therefore must not + * import any of those four SDKs directly — doing so creates a workspace + * dependency cycle (`pnpm install` warns "cyclic workspace dependencies"). + * `@chatbotx.io/integration-tiktok` and `@chatbotx.io/integration-zalo` have + * no such reverse dependency and are imported directly above. + * + * The caller — `apps/builder`, which has no cycle constraint — supplies the + * actual provider refresh call as a primitive. Same dependency-injection + * shape as `integration-whatsapp/coexist.ts`'s `SetCoexistTriggerSync`. + */ +export type ChannelRefreshAuthCallback = ( + auth: Record, +) => Promise> + +export type ChannelTokenRefreshCallbacks = { + refreshInstagramAuth?: ChannelRefreshAuthCallback + refreshInstagramFacebookAuth?: ChannelRefreshAuthCallback + refreshMessengerAuth?: ChannelRefreshAuthCallback + refreshWhatsappAuth?: ChannelRefreshAuthCallback +} + +const toSummary = (results: RefreshResult[]): RefreshSummary => ({ + refreshed: results.filter((result) => result === "refreshed").length, + failed: results.filter((result) => result === "failed").length, +}) + +const runInBatches = async ( + items: T[], + worker: (item: T) => Promise, +): Promise => { + const results: RefreshResult[] = [] + for (let index = 0; index < items.length; index += BATCH_SIZE) { + const batch = items.slice(index, index + BATCH_SIZE) + results.push( + ...(await Promise.all( + batch.map((item) => worker(item).catch((): RefreshResult => "failed")), + )), + ) + } + return results +} + +const refreshOneZalo = async ( + id: string, + workspaceId: string, +): Promise => + await distributedLock.runExclusive({ + key: `auth:refresh:zalo:${id}`, + timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, + fn: async () => { + try { + const integration = await zaloIntegrationService.findById({ + id, + workspaceId, + }) + const auth = integration.auth as ZaloAuthValue + if (!auth.tokens.refreshToken) { + return "skipped" + } + + const newTokens = await refreshZaloAccessToken( + auth, + auth.tokens.refreshToken, + ) + await zaloIntegrationService.updateAuth(id, { + ...auth, + tokens: { + ...auth.tokens, + accessToken: newTokens.access_token, + refreshToken: newTokens.refresh_token, + expiresAt: calculateExpiresAt(newTokens.expires_in), + }, + }) + await dispatchAuditRecord({ + workspaceId, + action: "refresh", + detail: "refreshed the Zalo channel permissions", + }) + return "refreshed" + } catch (error) { + await zaloIntegrationService.markTokenRefreshError( + id, + error instanceof Error ? error.message : String(error), + ) + return "failed" + } + }, + }) + +const refreshZaloIntegrations = async ( + workspaceIds: string[], +): Promise => { + const integrations = + await zaloIntegrationService.findAllByWorkspaceIds(workspaceIds) + return toSummary( + await runInBatches(integrations, (integration) => + refreshOneZalo(integration.id, integration.workspaceId), + ), + ) +} + +const refreshOneTiktok = async ( + id: string, + workspaceId: string, +): Promise => + await distributedLock.runExclusive({ + key: `auth:refresh:tiktok:${id}`, + timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, + fn: async () => { + try { + const integration = await tiktokIntegrationService.findById({ + id, + workspaceId, + }) + const auth = integration.auth as TiktokAuthValue + if (!auth.tokens.refreshToken) { + return "skipped" + } + + const newTokens = await refreshTiktokAccessToken( + { clientId: auth.clientId, clientSecret: auth.clientSecret }, + auth.tokens.refreshToken, + ) + await tiktokIntegrationService.updateAuth(id, { + ...auth, + tokens: { + ...auth.tokens, + accessToken: newTokens.access_token, + refreshToken: newTokens.refresh_token, + ...buildTokenTimestamps( + newTokens.expires_in, + newTokens.refresh_expires_in, + ), + }, + }) + await dispatchAuditRecord({ + workspaceId, + action: "refresh", + detail: "refreshed the TikTok channel token", + }) + return "refreshed" + } catch (error) { + await tiktokIntegrationService.markTokenRefreshError( + id, + error instanceof Error ? error.message : String(error), + ) + return "failed" + } + }, + }) + +const refreshTiktokIntegrations = async ( + workspaceIds: string[], +): Promise => { + const integrations = + await tiktokIntegrationService.findAllByWorkspaceIds(workspaceIds) + return toSummary( + await runInBatches(integrations, (integration) => + refreshOneTiktok(integration.id, integration.workspaceId), + ), + ) +} + +const refreshOneInstagram = async ( + id: string, + workspaceId: string, + refreshAuth: ChannelRefreshAuthCallback, +): Promise => + await distributedLock.runExclusive({ + key: `auth:refresh:instagram:${id}`, + timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, + fn: async () => { + try { + const integration = + await instagramIntegrationService.findByIdForWorkspace({ + id, + workspaceId, + }) + if (!integration) { + return "skipped" + } + + const newAuth = await refreshAuth( + integration.auth as Record, + ) + await instagramIntegrationService.updateAuth({ + id, + workspaceId, + auth: newAuth, + }) + await dispatchAuditRecord({ + workspaceId, + action: "refresh", + detail: "refreshed the Instagram channel token", + }) + return "refreshed" + } catch (error) { + await instagramIntegrationService.markTokenRefreshError( + id, + error instanceof Error ? error.message : String(error), + ) + return "failed" + } + }, + }) + +const refreshInstagramIntegrations = async ( + workspaceIds: string[], + refreshAuth: ChannelRefreshAuthCallback | undefined, +): Promise => { + if (!refreshAuth) { + return { refreshed: 0, failed: 0 } + } + const integrations = + await instagramIntegrationService.findForTokenRefreshByWorkspaceIds( + workspaceIds, + ) + return toSummary( + await runInBatches(integrations, (integration) => + refreshOneInstagram(integration.id, integration.workspaceId, refreshAuth), + ), + ) +} + +const refreshOneInstagramFacebook = async ( + id: string, + workspaceId: string, + refreshAuth: ChannelRefreshAuthCallback, +): Promise => + await distributedLock.runExclusive({ + key: `auth:refresh:instagramFacebook:${id}`, + timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, + fn: async () => { + try { + const integration = + await instagramIntegrationService.findByIdForWorkspace({ + id, + workspaceId, + }) + if (!integration) { + return "skipped" + } + + const newAuth = await refreshAuth( + integration.auth as Record, + ) + await instagramIntegrationService.updateAuth({ + id, + workspaceId, + auth: newAuth, + }) + await dispatchAuditRecord({ + workspaceId, + action: "refresh", + detail: "refreshed the Instagram channel token", + }) + return "refreshed" + } catch (error) { + await instagramIntegrationService.markTokenRefreshError( + id, + error instanceof Error ? error.message : String(error), + ) + return "failed" + } + }, + }) + +const refreshInstagramFacebookIntegrations = async ( + workspaceIds: string[], + refreshAuth: ChannelRefreshAuthCallback | undefined, +): Promise => { + if (!refreshAuth) { + return { refreshed: 0, failed: 0 } + } + const integrations = + await instagramIntegrationService.findFacebookForTokenRefreshByWorkspaceIds( + workspaceIds, + ) + return toSummary( + await runInBatches(integrations, (integration) => + refreshOneInstagramFacebook( + integration.id, + integration.workspaceId, + refreshAuth, + ), + ), + ) +} + +const refreshOneMessenger = async ( + id: string, + workspaceId: string, + refreshAuth: ChannelRefreshAuthCallback, +): Promise => + await distributedLock.runExclusive({ + key: `auth:refresh:messenger:${id}`, + timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, + fn: async () => { + try { + const integration = + await messengerIntegrationService.findByIdForWorkspace({ + id, + workspaceId, + }) + if (!integration) { + return "skipped" + } + + const newAuth = await refreshAuth( + integration.auth as Record, + ) + await messengerIntegrationService.updateAuth({ + id, + workspaceId, + auth: newAuth, + }) + await dispatchAuditRecord({ + workspaceId, + action: "refresh", + detail: "refreshed the Messenger channel token", + }) + return "refreshed" + } catch (error) { + await messengerIntegrationService.markTokenRefreshError( + id, + error instanceof Error ? error.message : String(error), + ) + return "failed" + } + }, + }) + +const refreshMessengerIntegrations = async ( + workspaceIds: string[], + refreshAuth: ChannelRefreshAuthCallback | undefined, +): Promise => { + if (!refreshAuth) { + return { refreshed: 0, failed: 0 } + } + const integrations = + await messengerIntegrationService.findForTokenRefreshByWorkspaceIds( + workspaceIds, + ) + return toSummary( + await runInBatches(integrations, (integration) => + refreshOneMessenger(integration.id, integration.workspaceId, refreshAuth), + ), + ) +} + +/** Just enough shape to apply the manual-token WhatsApp skip without the real `WhatsappAuthValue` type. */ +type WhatsappManualAuth = { metadata?: { isManual?: boolean } } + +const refreshOneWhatsapp = async ( + id: string, + workspaceId: string, + refreshAuth: ChannelRefreshAuthCallback, +): Promise => + await distributedLock.runExclusive({ + key: `auth:refresh:whatsapp:${id}`, + timeoutInSeconds: REFRESH_LOCK_TIMEOUT_SECONDS, + fn: async () => { + try { + const integration = + await integrationWhatsappService.findByIdForWorkspace({ + id, + workspaceId, + }) + if (!integration) { + return "skipped" + } + + if ((integration.auth as WhatsappManualAuth).metadata?.isManual) { + return "skipped" + } + + const newAuth = await refreshAuth( + integration.auth as Record, + ) + await integrationWhatsappService.updateAuth({ + id, + workspaceId, + auth: newAuth, + }) + await dispatchAuditRecord({ + workspaceId, + action: "refresh", + detail: "refreshed the WhatsApp channel token", + }) + return "refreshed" + } catch (error) { + await integrationWhatsappService.markTokenRefreshError( + id, + error instanceof Error ? error.message : String(error), + ) + return "failed" + } + }, + }) + +const refreshWhatsappIntegrations = async ( + workspaceIds: string[], + refreshAuth: ChannelRefreshAuthCallback | undefined, +): Promise => { + if (!refreshAuth) { + return { refreshed: 0, failed: 0 } + } + const integrations = + await integrationWhatsappService.findForTokenRefreshByWorkspaceIds( + workspaceIds, + ) + return toSummary( + await runInBatches(integrations, (integration) => + refreshOneWhatsapp(integration.id, integration.workspaceId, refreshAuth), + ), + ) +} + +class ChannelTokenRefreshService { + async refreshWorkspace( + props: { workspaceId: string } & ChannelTokenRefreshCallbacks, + ): Promise { + const { workspaceId, ...callbacks } = props + return await this.refreshWorkspaces({ + workspaceIds: [workspaceId], + ...callbacks, + }) + } + + /** + * Batches every provider's lookup across all `workspaceIds` in one query + * per provider (6 total), then refreshes the combined integration set in + * `BATCH_SIZE`-sized concurrent batches — instead of one lookup set per + * workspace. Callers refreshing many workspaces at once (e.g. a bulk + * "refresh all my workspaces" action) must call this once with the full + * id list rather than mapping `refreshWorkspace` over each id. + */ + async refreshWorkspaces( + props: { workspaceIds: string[] } & ChannelTokenRefreshCallbacks, + ): Promise { + const { + workspaceIds, + refreshInstagramAuth, + refreshInstagramFacebookAuth, + refreshMessengerAuth, + refreshWhatsappAuth, + } = props + if (workspaceIds.length === 0) { + return { refreshed: 0, failed: 0 } + } + + const summaries = await Promise.all([ + refreshZaloIntegrations(workspaceIds), + refreshTiktokIntegrations(workspaceIds), + refreshInstagramIntegrations(workspaceIds, refreshInstagramAuth), + refreshInstagramFacebookIntegrations( + workspaceIds, + refreshInstagramFacebookAuth, + ), + refreshMessengerIntegrations(workspaceIds, refreshMessengerAuth), + refreshWhatsappIntegrations(workspaceIds, refreshWhatsappAuth), + ]) + + return summaries.reduce( + (summary, next) => ({ + refreshed: summary.refreshed + next.refreshed, + failed: summary.failed + next.failed, + }), + { refreshed: 0, failed: 0 }, + ) + } +} + +export const channelTokenRefreshService = new ChannelTokenRefreshService() diff --git a/packages/business/src/workspace/index.ts b/packages/business/src/workspace/index.ts index 9376fea807..a5c06afc54 100644 --- a/packages/business/src/workspace/index.ts +++ b/packages/business/src/workspace/index.ts @@ -1 +1,2 @@ +export * from "./channel-token-refresh" export * from "./service" diff --git a/packages/database/src/partials/workspace-api-token.ts b/packages/database/src/partials/workspace-api-token.ts index 8a7cd0a5a3..41917d494b 100644 --- a/packages/database/src/partials/workspace-api-token.ts +++ b/packages/database/src/partials/workspace-api-token.ts @@ -24,6 +24,7 @@ export const workspaceApiTokenScopes = z.enum([ "appointments", "media", "ads", + "workspace", ]) export type WorkspaceApiTokenScope = z.infer diff --git a/packages/database/src/repositories/workspace-api-token/repository.ts b/packages/database/src/repositories/workspace-api-token/repository.ts index 2ef51bacba..5207f20f00 100644 --- a/packages/database/src/repositories/workspace-api-token/repository.ts +++ b/packages/database/src/repositories/workspace-api-token/repository.ts @@ -32,6 +32,22 @@ type SetEncryptedTokenInput = { encryptedToken: EncryptedData } +type UpdateWorkspaceApiTokenInput = { + id: string + workspaceId: string + name?: string + permission?: WorkspaceApiTokenPermission + // undefined = leave unchanged; null = unrestricted ("All scopes") + scopes?: WorkspaceApiTokenScope[] | null +} + +type RotateWorkspaceApiTokenInput = { + id: string + workspaceId: string + tokenHash: TokenHash + tokenPrefix: string +} + class WorkspaceApiTokenRepository { async findByTokenHash( tokenHash: TokenHash, @@ -81,6 +97,73 @@ class WorkspaceApiTokenRepository { ) } + async findByIdForWorkspace( + input: { id: string; workspaceId: string }, + tx: DatabaseClient = db, + ): Promise { + const row = await tx.query.workspaceApiTokenModel.findFirst({ + where: { id: input.id, workspaceId: input.workspaceId }, + }) + + return row ?? null + } + + async updateByIdForWorkspace( + input: UpdateWorkspaceApiTokenInput, + tx: DatabaseClient = db, + ): Promise { + const values: { + name?: string + permission?: WorkspaceApiTokenPermission + scopes?: WorkspaceApiTokenScope[] | null + } = {} + + if (input.name !== undefined) { + values.name = input.name + } + if (input.permission !== undefined) { + values.permission = input.permission + } + if (input.scopes !== undefined) { + values.scopes = input.scopes + } + + if (Object.keys(values).length === 0) { + return await this.findByIdForWorkspace(input, tx) + } + + const [row] = await tx + .update(workspaceApiTokenModel) + .set(values) + .where( + and( + eq(workspaceApiTokenModel.id, input.id), + eq(workspaceApiTokenModel.workspaceId, input.workspaceId), + ), + ) + .returning() + + return row ?? null + } + + async rotateTokenById( + input: RotateWorkspaceApiTokenInput, + tx: DatabaseClient = db, + ): Promise { + const [row] = await tx + .update(workspaceApiTokenModel) + .set({ tokenHash: input.tokenHash, tokenPrefix: input.tokenPrefix }) + .where( + and( + eq(workspaceApiTokenModel.id, input.id), + eq(workspaceApiTokenModel.workspaceId, input.workspaceId), + ), + ) + .returning() + + return row ?? null + } + async deleteByIdForWorkspace( input: { id: string; workspaceId: string }, tx: DatabaseClient = db, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a51b043f1a..bdbe2f0cef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2180,6 +2180,12 @@ importers: '@chatbotx.io/integration-google-calendar': specifier: workspace:* version: link:../../integrations/google-calendar + '@chatbotx.io/integration-tiktok': + specifier: workspace:* + version: link:../../integrations/tiktok + '@chatbotx.io/integration-zalo': + specifier: workspace:* + version: link:../../integrations/zalo '@chatbotx.io/javascript-sandbox': specifier: workspace:* version: link:../javascript-sandbox