Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .agents/rules/data-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ name: data-access
description: >-
Enforces the action/API handler → service → repository → DB chain in
ChatbotX. Read before adding or reviewing code in apps/builder,
apps/worker, integrations/*, packages/business, or packages/database that
reads or writes data.
apps/worker, integrations/*, packages/business, packages/connections, or
packages/database that reads or writes data.
globs:
- apps/builder/**
- apps/worker/**
- apps/mcp-server/**
- integrations/**
- packages/business/**
- packages/connections/**
- packages/database/**
---

Expand All @@ -34,6 +35,7 @@ The chain is: **action / API handler → service (`packages/business/`) → repo
|-------|---|------|
| `packages/database/src/repositories/*` | Yes | Raw where-builders, joins, pagination, shard routing. **Never** cache invalidation, event emission, or validation. |
| `packages/business/src/*` | Yes | Validation, orchestration across repositories, cache invalidation, events, audit, quota checks, optional `tx?: DatabaseClient` passthrough. **Never** imports from `apps/` or `integrations/`. |
| `packages/connections/src/*` | Yes | The **registry-aware** orchestration tier for the `Connection` domain, one level above `packages/business` — it composes `CONNECTION_REGISTRY` (each `integrations/<provider>`'s SDK adapter) with `packages/business`'s registry-free `connectionStateService`/`connectSessionService`, and owns the transactions that touch both a satellite table row and the `Connection` row in one commit (`upsertConnectionRow`, `disconnect`, `completeReconnect`). It exists as its own tier — not folded into `packages/business` — specifically because `packages/business` must stay registry-free (importable from `markOffline` hooks and webhook handlers without pulling in every provider's module graph); `packages/connections` is the one place allowed to depend on both. Only `connectionService` (from `@chatbotx.io/connections`) is the public surface app/worker code calls — never import `packages/connections/src/internal.ts`'s helpers directly. |
| `apps/builder/src/features/*/actions/` | **No** | Parse input → call a service method → map the result/error for the client. |
| `apps/builder/src/features/*/queries/` | **No** | See the `.query.ts` contract below. |
| `apps/builder/src/features/*/api/` | **No** | Resolve session context into plain params, call the same service method the private path uses. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,71 @@ exports[`public API spec — operation naming guard > operation list (operationI
"operationId": "channels.typing",
"path": "/v1/channels/api/typing",
},
{
"method": "GET",
"operationId": "connectionProviders.list",
"path": "/v1/connection-providers",
},
{
"method": "POST",
"operationId": "connections.create",
"path": "/v1/connections",
},
{
"method": "DELETE",
"operationId": "connections.disconnect",
"path": "/v1/connections/{id}",
},
{
"method": "GET",
"operationId": "connections.get",
"path": "/v1/connections/{id}",
},
{
"method": "GET",
"operationId": "connections.list",
"path": "/v1/connections",
},
{
"method": "POST",
"operationId": "connections.reconnect",
"path": "/v1/connections/{id}/reconnect",
},
{
"method": "POST",
"operationId": "connections.refresh",
"path": "/v1/connections/{id}/refresh",
},
{
"method": "PATCH",
"operationId": "connections.update",
"path": "/v1/connections/{id}",
},
{
"method": "POST",
"operationId": "connections.verify",
"path": "/v1/connections/{id}/verify",
},
{
"method": "DELETE",
"operationId": "connectSessions.cancel",
"path": "/v1/connect-sessions/{id}",
},
{
"method": "POST",
"operationId": "connectSessions.connectTargets",
"path": "/v1/connect-sessions/{id}/targets",
},
{
"method": "GET",
"operationId": "connectSessions.get",
"path": "/v1/connect-sessions/{id}",
},
{
"method": "POST",
"operationId": "connectSessions.submitInput",
"path": "/v1/connect-sessions/{id}/input",
},
{
"method": "POST",
"operationId": "contacts.addTags",
Expand Down
192 changes: 71 additions & 121 deletions apps/builder/__tests__/channel-connect-credential-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,62 +3,32 @@
import { beforeEach, describe, expect, test, vi } from "vitest"

// ---------------------------------------------------------------------------
// The OAuth completion legs (the three "select" actions plus the messenger
// reuse-check route) must resolve the platform credential owner from the
// SAME workspaceId the start leg (`/channels/create`) used — never from the
// request host. Those legs run post-relay on the broker or branded host
// interchangeably, so a host-derived completion leg could silently pick a
// different OAuth app than the one the start leg authorized against,
// breaking the token exchange.
//
// Messenger's `connectMessengerPage` (plan §2.4/§4.7) gets its `workspaceId`
// from the encrypted, httpOnly pending-auth cookie — never client input —
// so this test pins that it forwards the COOKIE's workspaceId into
// `resolvePlatformOwnerId`, and that a schema-invalid/missing cookie is
// rejected with a `sessionError` before the resolver is ever called.
// Instagram's two legs (phase 4) now go through the same
// `resolveConnectSession` helper, so they get the identical treatment: the
// wire payload carries only `igId`, never `workspaceId`.
// The OAuth completion legs (the three "select" actions) must resolve the
// platform credential from the SAME owner the start leg (`/channels/create`
// or `channels/create/messenger/route.ts`) resolved — never re-derive it
// from the request host. Under the unified `ConnectSession` model this is a
// structural invariant rather than a call-site convention: the session row
// stores `platformOwnerId` once, at creation time, and every completion leg
// (`resolveConnectSession`) reads it straight off that row — there is no
// `resolvePlatformOwnerId`/host-derived re-resolution step in the
// completion leg to drift. This test pins that `platformCredentialService
// .resolveForOwner` is called with the SESSION's stored owner, and that a
// missing/expired session short-circuits before any credential lookup.
// ---------------------------------------------------------------------------

const {
mockResolvePlatformOwnerId,
mockResolveForOwner,
mockReadPendingAuth,
mockWorkspaceFind,
mockIsMember,
} = vi.hoisted(() => ({
mockResolvePlatformOwnerId: vi.fn(async () => "resolved-owner-1"),
mockResolveForOwner: vi.fn(async () => undefined),
mockReadPendingAuth: vi.fn(
async (): Promise<{
userToken: string
workspaceId: string
referer: string
version: string
expiresAt: number
} | null> => ({
userToken: "user-token-1",
workspaceId: "ws-1",
referer: "/channels/create",
version: "v23.0",
expiresAt: Date.now() + 600_000,
}),
),
mockWorkspaceFind: vi.fn(async () => ({
id: "ws-1",
ownerId: "owner-1",
})),
mockIsMember: vi.fn(async () => true),
}))

// A passthrough action-client chain: `.inputSchema()`/`.action()` just
// return their handler so the test can call it directly with a hand-built
// `{ ctx, parsedInput }`, without instantiating the real safe-action /
// next-safe-action machinery. Mirrors the pattern in
// `instagram-facebook-settings-actions.test.ts`.
vi.mock("@/lib/platform-credential-owner", () => ({
resolvePlatformOwnerId: mockResolvePlatformOwnerId,
const { mockFindById, mockResolveForOwner, mockWorkspaceFind, mockIsMember } =
vi.hoisted(() => ({
mockFindById: vi.fn(),
mockResolveForOwner: vi.fn(async () => undefined),
mockWorkspaceFind: vi.fn(async () => ({
id: "ws-1",
ownerId: "owner-1",
})),
mockIsMember: vi.fn(async () => true),
}))

vi.mock("@chatbotx.io/business/connect-session", () => ({
connectSessionService: { findById: mockFindById },
}))

// Bypassed entirely — this test is about credential-owner resolution, not
Expand All @@ -84,18 +54,28 @@ vi.mock("@chatbotx.io/business", () => ({
findConnectedPageIds: vi.fn(async () => new Set<string>()),
connectPage: vi.fn(),
updateUserInfo: vi.fn(),
findByInboxId: vi.fn(),
},
instagramIntegrationService: {
findConnectedIgIds: vi.fn(async () => new Set<string>()),
connectAccount: vi.fn(),
updateUserInfo: vi.fn(),
findByInboxId: vi.fn(),
},
tagSyncService: { enqueueChannelScan: vi.fn() },
userQuotaService: { getAccessState: vi.fn(async () => ({ blocked: false })) },
connectChannelIntegration: vi.fn(),
buildContext: vi.fn(async () => ({})),
}))

// The real connect actions never reach `connectionService.connectTargets` in
// this test — `mockResolveForOwner` defaults to `undefined`, so
// `resolveConnectSession` throws `credentialMissingException` first — this
// stub only satisfies the module import.
vi.mock("@chatbotx.io/connections", () => ({
connectionService: { connectTargets: vi.fn() },
}))

// The REAL session/item-outcome mapping table — `resolveConnectSession`
// (called by `connectMessengerPage`) throws genuine exceptions from the
// (also real, below) `@chatbotx.io/business/errors`, so this file lets the
Expand Down Expand Up @@ -129,45 +109,14 @@ vi.mock("@chatbotx.io/database/client", () => ({
isDatabaseError: vi.fn(() => false),
}))

vi.mock("@chatbotx.io/database/schema", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@chatbotx.io/database/schema")>()
return {
...actual,
integrationInstagramModel: {},
integrationMessengerModel: {},
}
})

vi.mock("@chatbotx.io/integration-messenger", () => ({
integration: { runChannelHandler: vi.fn() },
getUserPages: vi.fn(async () => ({
pages: [
{
id: "p1",
name: "Page",
access_token: "page-token",
isConnectable: true,
},
],
bmLookupFailed: false,
})),
}))
vi.mock("@chatbotx.io/integration-messenger/apis/page", () => ({
exchangeLongLivedToken: vi.fn(),
subscribePageToAppWebhook: vi.fn(),
}))
vi.mock("@chatbotx.io/integration-instagram", () => ({
integration: { runChannelHandler: vi.fn() },
subscribePageToInstagramWebhook: vi.fn(),
}))
vi.mock("@chatbotx.io/integration-instagram-facebook", () => ({
integration: { runChannelHandler: vi.fn() },
subscribePageToInstagramWebhook: vi.fn(),
}))
vi.mock("@chatbotx.io/sdk", () => ({
AuthType: { oauth2: "oauth2" },
SdkException: class SdkException extends Error {},
}))
vi.mock("@chatbotx.io/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@chatbotx.io/utils")>()
Expand All @@ -188,13 +137,6 @@ vi.mock("@/features/integration-webchat/lib", () => ({
vi.mock("@/features/workspaces/actions/upload-logo", () => ({
updateWorkspaceLogo: vi.fn(),
}))
vi.mock("@/lib/facebook-pending-auth", () => ({
FB_MESSENGER_PENDING_AUTH_COOKIE: "fb_messenger_pending_auth",
FB_INSTAGRAM_FACEBOOK_PENDING_AUTH_COOKIE:
"fb_instagram_facebook_pending_auth",
FB_INSTAGRAM_PENDING_AUTH_COOKIE: "fb_instagram_pending_auth",
readPendingAuth: mockReadPendingAuth,
}))
vi.mock("@/lib/integration-user-info", () => ({
persistIntegrationUserInfo: vi.fn(),
}))
Expand All @@ -212,69 +154,77 @@ const { connectInstagramAccountViaFacebook } = await import(
"../src/features/integration-instagram/actions/connect-account-facebook"
)

const session = {
id: "session-1",
workspaceId: "ws-1",
platformOwnerId: "owner-1",
provider: "messenger",
status: "awaiting_selection",
targets: [{ id: "p1", name: "Page", selectable: true }],
}

describe("channel connect completion legs never re-derive the credential owner from the host", () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolvePlatformOwnerId.mockResolvedValue("resolved-owner-1")
// Credential missing short-circuits each action right after the
// resolver call — exactly the point this test needs to observe, without
// running the rest of the (heavily mocked) connect transaction.
mockResolveForOwner.mockResolvedValue(undefined)
mockReadPendingAuth.mockResolvedValue({
userToken: "user-token-1",
workspaceId: "ws-1",
referer: "/channels/create",
version: "v23.0",
expiresAt: Date.now() + 600_000,
})
mockFindById.mockResolvedValue(session)
mockWorkspaceFind.mockResolvedValue({ id: "ws-1", ownerId: "owner-1" })
mockIsMember.mockResolvedValue(true)
})

test("connectMessengerPage resolves the credential owner from the pending-auth cookie's workspaceId", async () => {
await connectMessengerPage({ userId: "user-1", pageId: "p1" }).catch(
() => undefined,
)

expect(mockResolvePlatformOwnerId).toHaveBeenCalledWith({
test("connectMessengerPage resolves the credential from the session's stored platformOwnerId", async () => {
await connectMessengerPage({
userId: "user-1",
workspaceId: "ws-1",
sessionId: "session-1",
pageId: "p1",
}).catch(() => undefined)

expect(mockResolveForOwner).toHaveBeenCalledWith({
ownerId: "owner-1",
type: "messenger",
})
})

test("connectInstagramAccount resolves the credential owner from the pending-auth cookie's workspaceId", async () => {
await connectInstagramAccount({ userId: "user-1", igId: "ig1" }).catch(
() => undefined,
)

expect(mockResolvePlatformOwnerId).toHaveBeenCalledWith({
test("connectInstagramAccount resolves the credential from the session's stored platformOwnerId", async () => {
await connectInstagramAccount({
userId: "user-1",
workspaceId: "ws-1",
sessionId: "session-1",
igId: "ig1",
}).catch(() => undefined)

expect(mockResolveForOwner).toHaveBeenCalledWith({
ownerId: "owner-1",
type: "instagram",
})
})

test("connectInstagramAccountViaFacebook resolves the credential owner from the pending-auth cookie's workspaceId", async () => {
test("connectInstagramAccountViaFacebook resolves the credential from the session's stored platformOwnerId", async () => {
await connectInstagramAccountViaFacebook({
userId: "user-1",
sessionId: "session-1",
igId: "ig1",
}).catch(() => undefined)

expect(mockResolvePlatformOwnerId).toHaveBeenCalledWith({
userId: "user-1",
workspaceId: "ws-1",
expect(mockResolveForOwner).toHaveBeenCalledWith({
ownerId: "owner-1",
type: "instagramFacebook",
})
})

test("connectMessengerPage never calls the resolver when the pending-auth cookie is missing/schema-invalid", async () => {
mockReadPendingAuth.mockResolvedValue(null)
test("connectMessengerPage never calls the credential resolver when the session is missing/expired", async () => {
mockFindById.mockResolvedValue(null)

const result = await connectMessengerPage({
userId: "user-1",
sessionId: "session-1",
pageId: "p1",
})

expect(result).toEqual({ kind: "sessionError", code: "sessionExpired" })
expect(mockResolvePlatformOwnerId).not.toHaveBeenCalled()
expect(mockResolveForOwner).not.toHaveBeenCalled()
expect(mockWorkspaceFind).not.toHaveBeenCalled()
})
})
8 changes: 0 additions & 8 deletions apps/builder/__tests__/channel-route-guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,14 +111,6 @@ vi.mock(
}),
)

vi.mock("@/features/integration-instagram/libs/oauth", () => ({
generateInstagramRedirectUri: vi.fn(async () => ""),
}))

vi.mock("@/features/integration-instagram/libs/oauth-facebook", () => ({
generateInstagramFacebookRedirectUri: vi.fn(async () => ""),
}))

vi.mock("@/features/integration-messenger/libs/oauth", () => ({
generateMessengerRedirectUri: vi.fn(async () => ""),
}))
Expand Down
Loading
Loading