diff --git a/packages/@boringos/core/src/boringos.ts b/packages/@boringos/core/src/boringos.ts index 2e2576b..4a94504 100644 --- a/packages/@boringos/core/src/boringos.ts +++ b/packages/@boringos/core/src/boringos.ts @@ -35,6 +35,7 @@ import type { } from "./types.js"; import { runWorkflow } from "./run-workflow.js"; import { createToolRoutes } from "./tool-routes.js"; +import { createCrmShimRoutes } from "./crm-shim-routes.js"; import { createModuleAdminRoutes } from "./module-admin-routes.js"; import { createModulePackageRoutes } from "./module-package-routes.js"; import { createModuleUiRoutes } from "./module-ui-routes.js"; @@ -884,6 +885,19 @@ export class BoringOS { }); app.route("/api/tools", toolsApp); + // Compatibility shim for legacy CRM REST callers + // (`/api/crm/*`). Translates v1 paths to v2 tool dispatches + // so CRM bundle slots that haven't been migrated to the + // tool API still work. See `crm-shim-routes.ts` for the + // mapping table; intentionally narrow surface so missing + // routes return 404 with `no_legacy_route`. + const crmShimApp = createCrmShimRoutes({ + db: dbConn.db, + registry: toolRegistry, + jwtSecret, + }); + app.route("/api/crm", crmShimApp); + const moduleAdminApp = createModuleAdminRoutes({ db: dbConn.db, toolRegistry: toolRegistry, diff --git a/packages/@boringos/core/src/crm-shim-routes.ts b/packages/@boringos/core/src/crm-shim-routes.ts new file mode 100644 index 0000000..ab85972 --- /dev/null +++ b/packages/@boringos/core/src/crm-shim-routes.ts @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Compatibility shim for the v1 CRM REST surface. +// +// Why this exists: the CRM module's web bundle calls +// `/api/crm/(/...)` from a few legacy slots that bypass the +// bundle's own `lib/api.ts` translator (PipelineSettings.tsx is the +// canonical example). Those slots got upgraded to the v2 tool +// surface — `/api/tools/crm..` — but the slots still +// call the old paths, so they 404 on a fresh `boringos` install +// and render a blank screen. +// +// We could patch every slot, but the CRM lives in a separate repo +// (`hebbs-crm`) and ships as a `.hebbsmod` bundle; coordinated +// releases are slow and any future legacy-pathed slot would +// re-break. A framework-side shim that translates v1 paths back +// onto v2 tool dispatches is small, sits in front of the +// dispatcher, and decouples the CRM bundle's release cadence from +// the framework's. +// +// Design: +// - `translate(method, path, body)` — pure mapping; ported verbatim +// from `hebbs-crm/packages/web/src/lib/api.ts` so the v1 ↔ v2 +// contract stays in lockstep. The mapping table comment block +// below is the source of truth on both sides. +// - `createCrmShimRoutes({ ... })` returns a Hono app that the +// framework mounts under `/api/crm`. It does the same dual-mode +// auth as `tool-routes.ts` (callback JWT or shell session +// bearer), translates, dispatches, unwraps the v2 envelope, and +// returns the v1-shaped JSON the legacy hooks expect. +// +// Future shims: the file's exports are intentionally clean +// (one `translate()` + one `createCrmShimRoutes(deps)`) so a +// future contributor can add `/api//*` shims for other +// modules by registering an additional translator. We deliberately +// don't generalise into a registry yet — KISS until a second +// caller exists. + +import { Hono } from "hono"; +import { sql } from "drizzle-orm"; +import type { Db } from "@boringos/db"; +import { + verifyCallbackToken, + dispatch, +} from "@boringos/agent"; +import type { ToolRegistry } from "@boringos/agent"; +import type { ToolInvocationSource } from "@boringos/module-sdk"; + +// ─── Types ────────────────────────────────────────────────────── + +export interface Translation { + toolName: string; + input: Record; +} + +export interface CrmShimDeps { + db: Db; + registry: ToolRegistry; + jwtSecret: string; +} + +interface ResolvedAuth { + tenantId: string; + agentId?: string; + runId?: string; + wakeOwnerUserId?: string; + invokedBy: ToolInvocationSource; +} + +type ShimEnv = { Variables: { auth: ResolvedAuth } }; + +// ─── Translate (kept in lockstep with hebbs-crm/packages/web/src/lib/api.ts) ── +// +// GET / -> crm..list +// GET // -> crm..get { id } +// POST / -> crm..create { ...body } +// PUT // -> crm..update { id, ...body } +// DELETE // -> crm..delete { id } +// +// Plus a few special cases for nested resources: +// GET /pipelines//forecast -> crm.pipelines.forecast { id } +// POST /pipelines//stages -> crm.pipelines.create_stage { pipelineId: id, ...body } +// PUT /pipelines//stages/ -> crm.pipelines.update_stage { pipelineId: id, id: sid, ...body } +// DELETE /pipelines//stages/ -> crm.pipelines.delete_stage { pipelineId: id, id: sid } +// GET /activities/timeline/ -> crm.activities.timeline { contactId: cid } +// GET /inbox -> crm.inbox.list +// GET /inbox//thread -> crm.inbox.get_thread { id } +// POST /inbox//reply -> crm.inbox.reply { id, ...body } +// POST /inbox//archive-gmail -> crm.inbox.archive { id } +// POST /inbox/sync -> crm.inbox.sync { ...body } +// POST /inbox/backfill-threads -> crm.inbox.backfill_threads { ...body } +// POST /inbox/backfill-bodies -> crm.inbox.backfill_bodies { ...body } +// GET /actions -> crm.actions.list +// GET /actions/count -> crm.actions.count_pending +// POST /actions//dismiss -> crm.actions.dismiss { id } +// POST /actions//complete -> crm.actions.complete { id } +// POST /actions//execute -> crm.actions.execute { id, ...body } +// GET /actions//comments -> crm.actions.list_comments { id } +// POST /actions//comments -> crm.actions.post_comment { id, ...body } +// GET/PUT /profile -> crm.profile.get / crm.profile.update + +function parseQuery(qs: string): Record { + const out: Record = {}; + if (!qs) return out; + for (const [k, v] of new URLSearchParams(qs).entries()) out[k] = v; + return out; +} + +export function translate(method: string, fullPath: string, body?: unknown): Translation { + const [pathOnly, queryString = ""] = fullPath.split("?"); + const query = parseQuery(queryString); + const segments = pathOnly.split("/").filter(Boolean); + const [group, ...rest] = segments; + const m = method.toUpperCase(); + const bodyObj = (body && typeof body === "object" ? body : {}) as Record; + + // Special-case routers go first. + switch (group) { + case "pipelines": { + if (rest.length === 2 && rest[1] === "forecast" && m === "GET") { + return { toolName: "crm.pipelines.forecast", input: { id: rest[0], ...query } }; + } + if (rest.length === 2 && rest[1] === "stages" && m === "POST") { + return { toolName: "crm.pipelines.create_stage", input: { pipelineId: rest[0], ...bodyObj } }; + } + if (rest.length === 3 && rest[1] === "stages") { + const pipelineId = rest[0]; + const id = rest[2]; + if (m === "PUT") return { toolName: "crm.pipelines.update_stage", input: { pipelineId, id, ...bodyObj } }; + if (m === "DELETE") return { toolName: "crm.pipelines.delete_stage", input: { pipelineId, id } }; + } + break; + } + case "activities": { + if (rest.length === 2 && rest[0] === "timeline" && m === "GET") { + return { toolName: "crm.activities.timeline", input: { contactId: rest[1], ...query } }; + } + break; + } + case "inbox": { + if (rest.length === 0 && m === "GET") return { toolName: "crm.inbox.list", input: query }; + if (rest.length === 1 && rest[0] === "sync" && m === "POST") + return { toolName: "crm.inbox.sync", input: bodyObj }; + if (rest.length === 1 && rest[0] === "backfill-threads" && m === "POST") + return { toolName: "crm.inbox.backfill_threads", input: bodyObj }; + if (rest.length === 1 && rest[0] === "backfill-bodies" && m === "POST") + return { toolName: "crm.inbox.backfill_bodies", input: bodyObj }; + if (rest.length === 2) { + const id = rest[0]; + if (rest[1] === "thread" && m === "GET") return { toolName: "crm.inbox.get_thread", input: { id } }; + if (rest[1] === "reply" && m === "POST") return { toolName: "crm.inbox.reply", input: { id, ...bodyObj } }; + if (rest[1] === "archive-gmail" && m === "POST") return { toolName: "crm.inbox.archive", input: { id } }; + } + break; + } + case "actions": { + if (rest.length === 0 && m === "GET") return { toolName: "crm.actions.list", input: query }; + if (rest.length === 1 && rest[0] === "count" && m === "GET") + return { toolName: "crm.actions.count_pending", input: {} }; + if (rest.length === 2) { + const id = rest[0]; + if (rest[1] === "dismiss" && m === "POST") return { toolName: "crm.actions.dismiss", input: { id } }; + if (rest[1] === "complete" && m === "POST") return { toolName: "crm.actions.complete", input: { id } }; + if (rest[1] === "execute" && m === "POST") + return { toolName: "crm.actions.execute", input: { id, ...bodyObj } }; + if (rest[1] === "comments") { + if (m === "GET") return { toolName: "crm.actions.list_comments", input: { id } }; + if (m === "POST") return { toolName: "crm.actions.post_comment", input: { id, ...bodyObj } }; + } + } + break; + } + case "profile": { + if (rest.length === 0) { + if (m === "GET") return { toolName: "crm.profile.get", input: {} }; + if (m === "PUT") return { toolName: "crm.profile.update", input: bodyObj }; + } + break; + } + } + + // Generic CRUD fallback. + if (group && rest.length === 0) { + if (m === "GET") return { toolName: `crm.${group}.list`, input: query }; + if (m === "POST") return { toolName: `crm.${group}.create`, input: bodyObj }; + } + if (group && rest.length === 1) { + const id = rest[0]; + if (m === "GET") return { toolName: `crm.${group}.get`, input: { id } }; + if (m === "PUT") return { toolName: `crm.${group}.update`, input: { id, ...bodyObj } }; + if (m === "DELETE") return { toolName: `crm.${group}.delete`, input: { id } }; + } + + throw new NoLegacyRouteError(m, fullPath); +} + +export class NoLegacyRouteError extends Error { + readonly method: string; + readonly path: string; + constructor(method: string, path: string) { + super(`No v2 tool mapping for ${method} ${path}`); + this.method = method; + this.path = path; + this.name = "NoLegacyRouteError"; + } +} + +// ─── Hono routes ───────────────────────────────────────────────── + +export function createCrmShimRoutes(deps: CrmShimDeps): Hono { + const app = new Hono(); + + // Same dual-mode auth as `createToolRoutes` — the shim is a + // legacy face of the same surface, callers must already be + // shell-authenticated for the real v2 dispatcher anyway. + app.use("/*", async (c, next) => { + const authHeader = c.req.header("Authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return c.json( + { ok: false, error: { code: "permission_denied", message: "Missing Authorization header", retryable: false } }, + 401, + ); + } + const token = authHeader.slice(7); + + const claims = verifyCallbackToken(token, deps.jwtSecret); + if (claims) { + c.set("auth", { + tenantId: claims.tenant_id, + agentId: claims.agent_id, + runId: claims.sub, + wakeOwnerUserId: claims.wake_owner_user_id, + invokedBy: "agent", + }); + return next(); + } + + const result = await deps.db.execute(sql` + SELECT s.user_id, ut.tenant_id + FROM auth_sessions s + JOIN user_tenants ut ON ut.user_id = s.user_id + WHERE s.token = ${token} AND s.expires_at > NOW() + LIMIT 1 + `); + const rows = result as unknown as Array<{ user_id: string; tenant_id: string }>; + if (rows[0]?.tenant_id) { + c.set("auth", { + tenantId: rows[0].tenant_id, + wakeOwnerUserId: rows[0].user_id, + invokedBy: "admin", + }); + return next(); + } + + return c.json( + { ok: false, error: { code: "permission_denied", message: "Invalid or expired token", retryable: false } }, + 401, + ); + }); + + app.all("/*", async (c) => { + const auth = c.get("auth"); + const url = new URL(c.req.url); + const path = url.pathname.replace(/^\/api\/crm/, ""); + const queryString = url.searchParams.toString(); + const fullPath = queryString ? `${path}?${queryString}` : path; + + let body: unknown = undefined; + const method = c.req.method.toUpperCase(); + if (method !== "GET" && method !== "DELETE" && method !== "HEAD") { + try { + const text = await c.req.text(); + body = text ? JSON.parse(text) : undefined; + } catch { + return c.json( + { ok: false, error: { code: "invalid_input", message: "Body must be valid JSON.", retryable: false } }, + 400, + ); + } + } + + let translation: Translation; + try { + translation = translate(method, fullPath, body); + } catch (err) { + if (err instanceof NoLegacyRouteError) { + return c.json( + { + ok: false, + error: { + code: "no_legacy_route", + message: err.message, + retryable: false, + hint: "Use POST /api/tools/ instead.", + }, + }, + 404, + ); + } + throw err; + } + + const idempotencyKey = c.req.header("Idempotency-Key") ?? undefined; + + try { + const dispatched = await dispatch( + { registry: deps.registry, db: deps.db }, + translation.toolName, + translation.input, + { + tenantId: auth.tenantId, + agentId: auth.agentId, + runId: auth.runId, + wakeOwnerUserId: auth.wakeOwnerUserId, + invokedBy: auth.invokedBy, + }, + { idempotencyKey }, + ); + + // Unwrap the v2 envelope back into the v1 shape the legacy + // hooks expect: success -> the inner `result` payload (e.g. + // `{ data: [...] }`); failure -> the standard error envelope + // with the dispatcher's HTTP status. + const status = dispatched.status as 200 | 400 | 403 | 404 | 500; + if (dispatched.result.ok) { + return c.json(dispatched.result.result ?? {}, status); + } + return c.json( + { ok: false, error: dispatched.result.error }, + status, + ); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`[crm-shim] dispatch threw for ${translation.toolName}:`, err); + return c.json( + { ok: false, error: { code: "internal", message: err instanceof Error ? err.message : String(err), retryable: false } }, + 500, + ); + } + }); + + return app; +} diff --git a/packages/@boringos/core/src/index.ts b/packages/@boringos/core/src/index.ts index b78d551..e1ff60b 100644 --- a/packages/@boringos/core/src/index.ts +++ b/packages/@boringos/core/src/index.ts @@ -45,6 +45,13 @@ export { type AutomatedClassification, type AutomatedKind, } from "./automated-mail.js"; +export { + translate as translateCrmLegacyPath, + createCrmShimRoutes, + NoLegacyRouteError, + type Translation as CrmLegacyTranslation, + type CrmShimDeps, +} from "./crm-shim-routes.js"; export { buildIngestMetadata } from "./inbox-gmail-forward-sync.js"; export { createRealtimeBus } from "./realtime.js"; export type { RealtimeBus, RealtimeEvent, EventType } from "./realtime.js"; diff --git a/tests/crm-shim-routes.test.ts b/tests/crm-shim-routes.test.ts new file mode 100644 index 0000000..2f1ecb7 --- /dev/null +++ b/tests/crm-shim-routes.test.ts @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Regression coverage for the CRM legacy-REST → v2-tool shim +// (issue #20). The frontend bits that this shim unblocks (the CRM +// PipelineSettings panel, the inbox slot fetches) all collapse to +// a single concern: "the path translation table is in lockstep +// with hebbs-crm's own translator". +// +// Strategy: unit-test `translate()` end-to-end. The Hono mount and +// dispatch glue is exercised by the existing tool-routes tests; we +// only want to lock down the translation contract here so any +// future drift between this file and `hebbs-crm/.../lib/api.ts` +// fails loudly. + +import { describe, it, expect } from "vitest"; + +import { + translateCrmLegacyPath as translate, + NoLegacyRouteError, +} from "@boringos/core"; + +describe("CRM shim — translate()", () => { + describe("generic CRUD", () => { + it("GET /pipelines → crm.pipelines.list (no input)", () => { + const t = translate("GET", "/pipelines"); + expect(t.toolName).toBe("crm.pipelines.list"); + expect(t.input).toEqual({}); + }); + + it("GET /pipelines?archived=true → carries query as input", () => { + const t = translate("GET", "/pipelines?archived=true&q=acme"); + expect(t.toolName).toBe("crm.pipelines.list"); + expect(t.input).toEqual({ archived: "true", q: "acme" }); + }); + + it("GET /pipelines/ → crm.pipelines.get { id }", () => { + const t = translate("GET", "/pipelines/p-123"); + expect(t.toolName).toBe("crm.pipelines.get"); + expect(t.input).toEqual({ id: "p-123" }); + }); + + it("POST /contacts → crm.contacts.create { ...body }", () => { + const t = translate("POST", "/contacts", { name: "Ada" }); + expect(t.toolName).toBe("crm.contacts.create"); + expect(t.input).toEqual({ name: "Ada" }); + }); + + it("PUT /contacts/ → crm.contacts.update merges id + body", () => { + const t = translate("PUT", "/contacts/c-1", { name: "Ada Lovelace" }); + expect(t.toolName).toBe("crm.contacts.update"); + expect(t.input).toEqual({ id: "c-1", name: "Ada Lovelace" }); + }); + + it("DELETE /contacts/ → crm.contacts.delete { id }", () => { + const t = translate("DELETE", "/contacts/c-1"); + expect(t.toolName).toBe("crm.contacts.delete"); + expect(t.input).toEqual({ id: "c-1" }); + }); + }); + + describe("pipelines special cases", () => { + it("GET /pipelines//forecast → crm.pipelines.forecast", () => { + const t = translate("GET", "/pipelines/p-1/forecast"); + expect(t.toolName).toBe("crm.pipelines.forecast"); + expect(t.input).toEqual({ id: "p-1" }); + }); + + it("POST /pipelines//stages → crm.pipelines.create_stage", () => { + const t = translate("POST", "/pipelines/p-1/stages", { name: "Qualified" }); + expect(t.toolName).toBe("crm.pipelines.create_stage"); + expect(t.input).toEqual({ pipelineId: "p-1", name: "Qualified" }); + }); + + it("PUT /pipelines//stages/ → crm.pipelines.update_stage", () => { + const t = translate("PUT", "/pipelines/p-1/stages/s-1", { name: "Won" }); + expect(t.toolName).toBe("crm.pipelines.update_stage"); + expect(t.input).toEqual({ pipelineId: "p-1", id: "s-1", name: "Won" }); + }); + + it("DELETE /pipelines//stages/ → crm.pipelines.delete_stage", () => { + const t = translate("DELETE", "/pipelines/p-1/stages/s-1"); + expect(t.toolName).toBe("crm.pipelines.delete_stage"); + expect(t.input).toEqual({ pipelineId: "p-1", id: "s-1" }); + }); + }); + + describe("inbox + activities + actions + profile", () => { + it("GET /activities/timeline/ carries query", () => { + const t = translate("GET", "/activities/timeline/c-1?limit=20"); + expect(t.toolName).toBe("crm.activities.timeline"); + expect(t.input).toEqual({ contactId: "c-1", limit: "20" }); + }); + + it("GET /inbox → crm.inbox.list", () => { + const t = translate("GET", "/inbox"); + expect(t.toolName).toBe("crm.inbox.list"); + }); + + it("POST /inbox//reply → crm.inbox.reply", () => { + const t = translate("POST", "/inbox/i-1/reply", { body: "Sure!" }); + expect(t.toolName).toBe("crm.inbox.reply"); + expect(t.input).toEqual({ id: "i-1", body: "Sure!" }); + }); + + it("POST /inbox//archive-gmail → crm.inbox.archive { id }", () => { + const t = translate("POST", "/inbox/i-1/archive-gmail"); + expect(t.toolName).toBe("crm.inbox.archive"); + expect(t.input).toEqual({ id: "i-1" }); + }); + + it("GET /actions/count → crm.actions.count_pending", () => { + const t = translate("GET", "/actions/count"); + expect(t.toolName).toBe("crm.actions.count_pending"); + expect(t.input).toEqual({}); + }); + + it("POST /actions//execute → crm.actions.execute merges id + body", () => { + const t = translate("POST", "/actions/a-1/execute", { confirm: true }); + expect(t.toolName).toBe("crm.actions.execute"); + expect(t.input).toEqual({ id: "a-1", confirm: true }); + }); + + it("GET /profile → crm.profile.get; PUT /profile → crm.profile.update", () => { + expect(translate("GET", "/profile").toolName).toBe("crm.profile.get"); + const t = translate("PUT", "/profile", { tz: "Asia/Kolkata" }); + expect(t.toolName).toBe("crm.profile.update"); + expect(t.input).toEqual({ tz: "Asia/Kolkata" }); + }); + }); + + describe("error cases", () => { + it("throws NoLegacyRouteError for unknown paths", () => { + expect(() => translate("GET", "/unknown/sub/path")).toThrow(NoLegacyRouteError); + }); + + it("throws NoLegacyRouteError for verbs without a matching pattern", () => { + // PATCH on // isn't in the v1 surface — translator + // must refuse to invent a tool name rather than guess. + expect(() => translate("PATCH", "/contacts/c-1", { name: "Ada" })).toThrow(NoLegacyRouteError); + }); + + it("includes the method + path in the error", () => { + try { + translate("PATCH", "/contacts/c-1"); + } catch (e) { + expect(e).toBeInstanceOf(NoLegacyRouteError); + const err = e as NoLegacyRouteError; + expect(err.method).toBe("PATCH"); + expect(err.path).toBe("/contacts/c-1"); + } + }); + }); +});