diff --git a/packages/plugins/official/zapier-bridge/__tests__/index.test.ts b/packages/plugins/official/zapier-bridge/__tests__/index.test.ts new file mode 100644 index 0000000..b055234 --- /dev/null +++ b/packages/plugins/official/zapier-bridge/__tests__/index.test.ts @@ -0,0 +1,992 @@ +/// +/** + * Zapier Bridge — Unit Tests + * + * Covers: DB key helpers, ID generators, purgeOldEvents, fanOutEvent, + * plugin manifest/settings, app:init (5 endpoints), + * POST /subscribe, DELETE /subscribe/:hookId, GET /subscribe, + * POST /action (all 3 action types + auth + guards), + * GET /events (filter, limit), conversation:end hook, user:register hook. + */ +import plugin, { + buildHookKey, + buildEventKey, + generateHookId, + generateEventId, + purgeOldEvents, + fanOutEvent, + SUPPORTED_EVENTS, + SUPPORTED_ACTIONS, + MAX_STORED_EVENTS, + HookRecord, + EventRecord, + ZapierActionPayload, +} from "../src/index"; +import { + PluginContext, + PluginAPI, + PluginDatabaseAPI, + PluginEventBus, + EndpointDefinition, + EndpointRequest, + EndpointResponse, +} from "@agentbase/plugin-sdk"; + +// ── Mock factory ────────────────────────────────────────────────────────────── + +function createMockAPI(): PluginAPI & { _endpoints: EndpointDefinition[] } { + const store = new Map(); + const _endpoints: EndpointDefinition[] = []; + + const db: PluginDatabaseAPI = { + set: jest + .fn() + .mockImplementation(async (k: string, v: unknown) => store.set(k, v)), + get: jest + .fn() + .mockImplementation(async (k: string) => store.get(k) ?? null), + delete: jest.fn().mockImplementation(async (k: string) => { + const had = store.has(k); + store.delete(k); + return had; + }), + keys: jest + .fn() + .mockImplementation(async (prefix?: string) => + [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)), + ), + find: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }; + + const events: PluginEventBus = { + emit: jest.fn().mockResolvedValue(undefined), + on: jest.fn(), + off: jest.fn(), + }; + + const configStore = new Map([ + ["enableTriggers", true], + ["enableActions", true], + ["allowedActions", "send_message,emit_event,update_context"], + ["actionSecret", ""], + ]); + + return { + _endpoints, + getConfig: jest + .fn() + .mockImplementation((k: string) => configStore.get(k) ?? undefined), + setConfig: jest + .fn() + .mockImplementation(async (k: string, v: unknown) => + configStore.set(k, v), + ), + makeRequest: jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({}), + text: jest.fn().mockResolvedValue(""), + }), + log: jest.fn(), + db, + events, + registerEndpoint: jest + .fn() + .mockImplementation((def: EndpointDefinition) => _endpoints.push(def)), + registerCronJob: jest.fn(), + registerWebhook: jest.fn(), + registerAdminPage: jest.fn(), + } as unknown as PluginAPI & { _endpoints: EndpointDefinition[] }; +} + +type MockAPI = ReturnType; +type MockCtx = PluginContext & { api: MockAPI }; + +function makeCtx(overrides: Partial = {}): MockCtx { + const api = createMockAPI(); + return { + appId: "app-1", + userId: "user-1", + config: {}, + api, + ...overrides, + } as MockCtx; +} + +interface MockRes { + status: jest.Mock; + json: jest.Mock; + _status: number; + _body: unknown; +} + +function makeRes(): MockRes { + const r: MockRes = { + _status: 200, + _body: undefined, + status: jest.fn(), + json: jest.fn(), + }; + r.status.mockImplementation((code: number) => { + r._status = code; + return r; + }); + r.json.mockImplementation((body: unknown) => { + r._body = body; + }); + return r; +} + +function makeReq(overrides: Partial = {}): EndpointRequest { + return { + method: "GET", + path: "/", + params: {}, + query: {}, + body: {}, + headers: {}, + ...overrides, + }; +} + +async function runInit(ctx: MockCtx): Promise { + const hook = plugin.definition.hooks?.["app:init"]; + if (!hook) throw new Error("app:init hook not registered"); + await hook(ctx); +} + +function getEndpoint( + api: MockAPI, + method: string, + path: string, +): EndpointDefinition { + const ep = api._endpoints.find((e) => e.method === method && e.path === path); + if (!ep) throw new Error(`Endpoint ${method} ${path} not found`); + return ep; +} + +// ── DB Key Helpers ──────────────────────────────────────────────────────────── + +describe("DB key helpers", () => { + it("buildHookKey", () => { + expect(buildHookKey("h_abc")).toBe("hook:h_abc"); + }); + + it("buildEventKey", () => { + expect(buildEventKey("e_xyz")).toBe("event:e_xyz"); + }); +}); + +// ── ID generators ───────────────────────────────────────────────────────────── + +describe("ID generators", () => { + it("generateHookId starts with hook_", () => { + expect(generateHookId()).toMatch(/^hook_[a-f0-9]+$/); + }); + + it("generateHookId returns unique values", () => { + const ids = new Set(Array.from({ length: 20 }, generateHookId)); + expect(ids.size).toBe(20); + }); + + it("generateEventId starts with evt_", () => { + expect(generateEventId()).toMatch(/^evt_[a-f0-9]+$/); + }); + + it("generateEventId returns unique values", () => { + const ids = new Set(Array.from({ length: 20 }, generateEventId)); + expect(ids.size).toBe(20); + }); +}); + +// ── purgeOldEvents ──────────────────────────────────────────────────────────── + +describe("purgeOldEvents", () => { + it("does nothing when event count is at or below MAX_STORED_EVENTS", async () => { + const store = new Map(); + for (let i = 0; i < 50; i++) { + store.set(`event:e${i}`, { eventId: `e${i}`, timestamp: i }); + } + const dbDelete = jest.fn(); + await purgeOldEvents( + async (k) => store.get(k) ?? null, + dbDelete, + async (prefix) => + [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)), + ); + expect(dbDelete).not.toHaveBeenCalled(); + }); + + it("prunes oldest events when over MAX_STORED_EVENTS", async () => { + const store = new Map(); + const total = MAX_STORED_EVENTS + 5; + for (let i = 0; i < total; i++) { + store.set(`event:e${i}`, { eventId: `e${i}`, timestamp: i }); + } + const deleted: string[] = []; + await purgeOldEvents( + async (k) => store.get(k) ?? null, + async (k) => { + deleted.push(k); + return true; + }, + async (prefix) => + [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)), + ); + // Should delete the 5 oldest (lowest timestamps: e0..e4) + expect(deleted).toHaveLength(5); + const deletedIds = deleted.map((k) => k.replace("event:", "")); + expect(deletedIds).toEqual( + expect.arrayContaining(["e0", "e1", "e2", "e3", "e4"]), + ); + }); +}); + +// ── fanOutEvent ─────────────────────────────────────────────────────────────── + +describe("fanOutEvent", () => { + it("stores an EventRecord", async () => { + const ctx = makeCtx(); + await fanOutEvent(ctx, "conversation.end", { some: "data" }); + expect(ctx.api.db.set).toHaveBeenCalledWith( + expect.stringMatching(/^event:/), + expect.objectContaining({ type: "conversation.end" }), + ); + }); + + it("POSTs to all subscribers of the matching event", async () => { + const ctx = makeCtx(); + const hook1: HookRecord = { + hookId: "h1", + targetUrl: "https://a.com", + event: "conversation.end", + createdAt: 1, + }; + const hook2: HookRecord = { + hookId: "h2", + targetUrl: "https://b.com", + event: "user.register", + createdAt: 2, + }; + await ctx.api.db.set("hook:h1", hook1); + await ctx.api.db.set("hook:h2", hook2); + + await fanOutEvent(ctx, "conversation.end", {}); + await new Promise((r) => setTimeout(r, 20)); + + const makeReqCalls = (ctx.api.makeRequest as jest.Mock).mock.calls as [ + string, + RequestInit, + ][]; + const postedUrls = makeReqCalls.map(([url]) => url); + expect(postedUrls).toContain("https://a.com"); + expect(postedUrls).not.toContain("https://b.com"); + }); + + it("posts to 'custom' event subscribers for any event type", async () => { + const ctx = makeCtx(); + const hook: HookRecord = { + hookId: "hc", + targetUrl: "https://c.com", + event: "custom", + createdAt: 1, + }; + await ctx.api.db.set("hook:hc", hook); + await fanOutEvent(ctx, "conversation.end", {}); + await new Promise((r) => setTimeout(r, 20)); + const calls = (ctx.api.makeRequest as jest.Mock).mock.calls as [string][]; + expect(calls.some(([url]) => url === "https://c.com")).toBe(true); + }); + + it("logs on delivery failure but does not throw", async () => { + const ctx = makeCtx(); + const hook: HookRecord = { + hookId: "hfail", + targetUrl: "https://fail.com", + event: "user.register", + createdAt: 1, + }; + await ctx.api.db.set("hook:hfail", hook); + (ctx.api.makeRequest as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + }); + await fanOutEvent(ctx, "user.register", {}); + await new Promise((r) => setTimeout(r, 20)); + expect(ctx.api.log).toHaveBeenCalledWith( + expect.stringContaining("failed"), + "warn", + ); + }); + + it("logs on delivery exception but does not throw", async () => { + const ctx = makeCtx(); + const hook: HookRecord = { + hookId: "herr", + targetUrl: "https://err.com", + event: "user.register", + createdAt: 1, + }; + await ctx.api.db.set("hook:herr", hook); + (ctx.api.makeRequest as jest.Mock).mockRejectedValueOnce( + new Error("ECONNREFUSED"), + ); + await fanOutEvent(ctx, "user.register", {}); + await new Promise((r) => setTimeout(r, 20)); + expect(ctx.api.log).toHaveBeenCalledWith( + expect.stringContaining("ECONNREFUSED"), + "error", + ); + }); +}); + +// ── Plugin manifest / settings ──────────────────────────────────────────────── + +describe("plugin manifest / settings", () => { + it("name is zapier-bridge", () => { + expect(plugin.definition.name).toBe("zapier-bridge"); + }); + + it("version is 1.0.0", () => { + expect(plugin.definition.version).toBe("1.0.0"); + }); + + it("has required settings", () => { + const settings = plugin.definition.settings!; + expect(settings).toHaveProperty("enableTriggers"); + expect(settings).toHaveProperty("enableActions"); + expect(settings).toHaveProperty("allowedActions"); + expect(settings).toHaveProperty("actionSecret"); + }); + + it("actionSecret is encrypted", () => { + expect(plugin.definition.settings!["actionSecret"]?.["encrypted"]).toBe( + true, + ); + }); + + it("enableTriggers default is true", () => { + expect(plugin.definition.settings!["enableTriggers"]?.default).toBe(true); + }); +}); + +// ── app:init ────────────────────────────────────────────────────────────────── + +describe("app:init", () => { + it("registers exactly 5 endpoints", async () => { + const ctx = makeCtx(); + await runInit(ctx); + expect(ctx.api._endpoints).toHaveLength(5); + }); + + it("registers all expected endpoint paths", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const paths = ctx.api._endpoints.map((e) => `${e.method} ${e.path}`); + expect(paths).toContain("POST /subscribe"); + expect(paths).toContain("DELETE /subscribe/:hookId"); + expect(paths).toContain("GET /subscribe"); + expect(paths).toContain("POST /action"); + expect(paths).toContain("GET /events"); + }); +}); + +// ── POST /subscribe ─────────────────────────────────────────────────────────── + +describe("POST /subscribe", () => { + it("returns 403 when enableTriggers is false", async () => { + const ctx = makeCtx(); + await runInit(ctx); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => + k === "enableTriggers" ? false : undefined, + ); + const ep = getEndpoint(ctx.api, "POST", "/subscribe"); + const res = makeRes(); + await ep.handler!( + makeReq({ + body: { targetUrl: "https://z.com", event: "conversation.end" }, + }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(403); + }); + + it("returns 400 when targetUrl is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/subscribe"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { event: "conversation.end" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + expect((res._body as { error: string }).error).toContain("targetUrl"); + }); + + it("returns 400 when event is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/subscribe"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { targetUrl: "https://z.com" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + expect((res._body as { error: string }).error).toContain("event"); + }); + + it("returns 400 for unsupported event type", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/subscribe"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { targetUrl: "https://z.com", event: "unknown.event" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + expect((res._body as { error: string }).error).toContain( + "Unsupported event", + ); + }); + + it("saves hook and returns 201 with id on success", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/subscribe"); + const res = makeRes(); + await ep.handler!( + makeReq({ + body: { + targetUrl: "https://hooks.zapier.com/123", + event: "conversation.end", + }, + }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(201); + const body = res._body as { id: string; event: string; targetUrl: string }; + expect(body.event).toBe("conversation.end"); + expect(body.id).toMatch(/^hook_/); + expect(ctx.api.db.set).toHaveBeenCalledWith( + expect.stringMatching(/^hook:/), + expect.objectContaining({ targetUrl: "https://hooks.zapier.com/123" }), + ); + }); + + it("accepts 'custom' as a valid event type", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/subscribe"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { targetUrl: "https://z.com/hook", event: "custom" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(201); + }); + + it("accepts all SUPPORTED_EVENTS", async () => { + for (const event of SUPPORTED_EVENTS) { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/subscribe"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { targetUrl: "https://z.com", event } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(201); + } + }); +}); + +// ── DELETE /subscribe/:hookId ───────────────────────────────────────────────── + +describe("DELETE /subscribe/:hookId", () => { + it("returns 400 when hookId param is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "DELETE", "/subscribe/:hookId"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: {} }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + }); + + it("returns 404 when hook does not exist", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "DELETE", "/subscribe/:hookId"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: { hookId: "nonexistent" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(404); + }); + + it("deletes hook and returns deleted:true", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const hook: HookRecord = { + hookId: "hook_del1", + targetUrl: "https://a.com", + event: "user.register", + createdAt: 1, + }; + await ctx.api.db.set(buildHookKey("hook_del1"), hook); + + const ep = getEndpoint(ctx.api, "DELETE", "/subscribe/:hookId"); + const res = makeRes(); + await ep.handler!( + makeReq({ params: { hookId: "hook_del1" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(200); + expect((res._body as { deleted: boolean }).deleted).toBe(true); + expect(ctx.api.db.delete).toHaveBeenCalledWith(buildHookKey("hook_del1")); + }); +}); + +// ── GET /subscribe ──────────────────────────────────────────────────────────── + +describe("GET /subscribe", () => { + it("returns empty hooks list when none registered", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "GET", "/subscribe"); + const res = makeRes(); + await ep.handler!(makeReq(), res as unknown as EndpointResponse); + expect(res._status).toBe(200); + expect((res._body as { hooks: unknown[] }).hooks).toEqual([]); + }); + + it("returns all registered hooks", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const h1: HookRecord = { + hookId: "h1", + targetUrl: "https://a.com", + event: "conversation.end", + createdAt: 1, + }; + const h2: HookRecord = { + hookId: "h2", + targetUrl: "https://b.com", + event: "user.register", + createdAt: 2, + }; + await ctx.api.db.set(buildHookKey("h1"), h1); + await ctx.api.db.set(buildHookKey("h2"), h2); + + const ep = getEndpoint(ctx.api, "GET", "/subscribe"); + const res = makeRes(); + await ep.handler!(makeReq(), res as unknown as EndpointResponse); + expect((res._body as { hooks: HookRecord[] }).hooks).toHaveLength(2); + }); +}); + +// ── POST /action ────────────────────────────────────────────────────────────── + +describe("POST /action", () => { + it("returns 403 when enableActions is false", async () => { + const ctx = makeCtx(); + await runInit(ctx); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => + k === "enableActions" ? false : undefined, + ); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { action: "send_message", data: {} } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(403); + }); + + it("returns 401 when actionSecret is set and header is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + if (k === "actionSecret") return "super_secret"; + if (k === "enableActions") return true; + if (k === "allowedActions") return "send_message"; + return undefined; + }); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ + body: { action: "send_message", data: { message: "hi" } }, + headers: {}, + }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(401); + }); + + it("returns 401 when actionSecret is set and header is wrong", async () => { + const ctx = makeCtx(); + await runInit(ctx); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + if (k === "actionSecret") return "super_secret"; + if (k === "enableActions") return true; + if (k === "allowedActions") return "send_message"; + return undefined; + }); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ + body: { action: "send_message", data: { message: "hi" } }, + headers: { "x-zapier-secret": "wrong" }, + }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(401); + }); + + it("passes through when correct secret provided", async () => { + const ctx = makeCtx(); + await runInit(ctx); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + if (k === "actionSecret") return "super_secret"; + if (k === "enableActions") return true; + if (k === "allowedActions") return "send_message"; + return undefined; + }); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ + body: { action: "send_message", data: { message: "hello" } }, + headers: { "x-zapier-secret": "super_secret" }, + }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(200); + }); + + it("returns 400 when action field is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { data: {} } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + expect((res._body as { error: string }).error).toContain("action"); + }); + + it("returns 400 when data field is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { action: "send_message" } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + }); + + it("returns 403 when action is not in allowedActions", async () => { + const ctx = makeCtx(); + await runInit(ctx); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => { + if (k === "enableActions") return true; + if (k === "allowedActions") return "send_message"; + if (k === "actionSecret") return ""; + return undefined; + }); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { action: "emit_event", data: { eventName: "x" } } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(403); + expect((res._body as { error: string }).error).toContain("allowed actions"); + }); + + describe("send_message action", () => { + it("returns 400 when message is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { action: "send_message", data: { userId: "U1" } } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + expect((res._body as { error: string }).error).toContain("message"); + }); + + it("emits zapier:send_message and returns ok", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ + body: { + action: "send_message", + data: { message: "Hello AI", userId: "U1" }, + }, + }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(200); + expect(ctx.api.events.emit).toHaveBeenCalledWith( + "zapier:send_message", + expect.objectContaining({ message: "Hello AI", userId: "U1" }), + ); + }); + }); + + describe("emit_event action", () => { + it("returns 400 when eventName is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { action: "emit_event", data: { eventData: {} } } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + expect((res._body as { error: string }).error).toContain("eventName"); + }); + + it("emits zapier: and returns ok with eventName", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ + body: { + action: "emit_event", + data: { eventName: "myEvent", eventData: { x: 1 } }, + }, + }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(200); + expect((res._body as { eventName: string }).eventName).toBe("myEvent"); + expect(ctx.api.events.emit).toHaveBeenCalledWith( + "zapier:myEvent", + expect.anything(), + ); + }); + }); + + describe("update_context action", () => { + it("returns 400 when key is missing", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ body: { action: "update_context", data: { value: "v" } } }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(400); + }); + + it("emits zapier:update_context and returns ok with key", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "POST", "/action"); + const res = makeRes(); + await ep.handler!( + makeReq({ + body: { + action: "update_context", + data: { key: "theme", value: "dark" }, + }, + }), + res as unknown as EndpointResponse, + ); + expect(res._status).toBe(200); + expect((res._body as { key: string }).key).toBe("theme"); + expect(ctx.api.events.emit).toHaveBeenCalledWith( + "zapier:update_context", + expect.objectContaining({ key: "theme", value: "dark" }), + ); + }); + }); +}); + +// ── GET /events ─────────────────────────────────────────────────────────────── + +describe("GET /events", () => { + it("returns empty events array when none stored", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const ep = getEndpoint(ctx.api, "GET", "/events"); + const res = makeRes(); + await ep.handler!(makeReq(), res as unknown as EndpointResponse); + expect(res._status).toBe(200); + expect((res._body as { events: unknown[] }).events).toEqual([]); + }); + + it("returns stored events sorted newest first", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const e1: EventRecord = { + eventId: "e1", + type: "conversation.end", + payload: {}, + timestamp: 1000, + }; + const e2: EventRecord = { + eventId: "e2", + type: "user.register", + payload: {}, + timestamp: 2000, + }; + await ctx.api.db.set(buildEventKey("e1"), e1); + await ctx.api.db.set(buildEventKey("e2"), e2); + + const ep = getEndpoint(ctx.api, "GET", "/events"); + const res = makeRes(); + await ep.handler!(makeReq(), res as unknown as EndpointResponse); + const events = (res._body as { events: EventRecord[] }).events; + expect(events[0]!.eventId).toBe("e2"); // newest first + expect(events[1]!.eventId).toBe("e1"); + }); + + it("filters by type when query.type is provided", async () => { + const ctx = makeCtx(); + await runInit(ctx); + const e1: EventRecord = { + eventId: "e1", + type: "conversation.end", + payload: {}, + timestamp: 1000, + }; + const e2: EventRecord = { + eventId: "e2", + type: "user.register", + payload: {}, + timestamp: 2000, + }; + await ctx.api.db.set(buildEventKey("e1"), e1); + await ctx.api.db.set(buildEventKey("e2"), e2); + + const ep = getEndpoint(ctx.api, "GET", "/events"); + const res = makeRes(); + await ep.handler!( + makeReq({ query: { type: "user.register" } }), + res as unknown as EndpointResponse, + ); + const events = (res._body as { events: EventRecord[] }).events; + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe("user.register"); + }); + + it("respects limit query parameter", async () => { + const ctx = makeCtx(); + await runInit(ctx); + for (let i = 0; i < 10; i++) { + await ctx.api.db.set(buildEventKey(`e${i}`), { + eventId: `e${i}`, + type: "conversation.end", + payload: {}, + timestamp: i, + }); + } + const ep = getEndpoint(ctx.api, "GET", "/events"); + const res = makeRes(); + await ep.handler!( + makeReq({ query: { limit: "3" } }), + res as unknown as EndpointResponse, + ); + expect((res._body as { events: unknown[] }).events).toHaveLength(3); + }); + + it("caps limit at MAX_STORED_EVENTS", async () => { + const ctx = makeCtx(); + await runInit(ctx); + for (let i = 0; i < 10; i++) { + await ctx.api.db.set(buildEventKey(`e${i}`), { + eventId: `e${i}`, + type: "user.register", + payload: {}, + timestamp: i, + }); + } + const ep = getEndpoint(ctx.api, "GET", "/events"); + const res = makeRes(); + await ep.handler!( + makeReq({ query: { limit: String(MAX_STORED_EVENTS + 50) } }), + res as unknown as EndpointResponse, + ); + const events = (res._body as { events: unknown[] }).events; + expect(events.length).toBeLessThanOrEqual(MAX_STORED_EVENTS); + }); +}); + +// ── conversation:end hook ───────────────────────────────────────────────────── + +describe("conversation:end hook", () => { + it("does nothing when enableTriggers is false", async () => { + const ctx = makeCtx(); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => + k === "enableTriggers" ? false : undefined, + ); + const hook = plugin.definition.hooks?.["conversation:end"]; + if (!hook) throw new Error("conversation:end hook not registered"); + await hook(ctx); + expect(ctx.api.db.set).not.toHaveBeenCalled(); + }); + + it("calls fanOutEvent with conversation.end", async () => { + const ctx = makeCtx(); + (ctx.api.getConfig as jest.Mock).mockReturnValue(true); + const hook = plugin.definition.hooks?.["conversation:end"]; + if (!hook) throw new Error("conversation:end hook not registered"); + await hook(ctx); + expect(ctx.api.db.set).toHaveBeenCalledWith( + expect.stringMatching(/^event:/), + expect.objectContaining({ type: "conversation.end" }), + ); + }); +}); + +// ── user:register hook ──────────────────────────────────────────────────────── + +describe("user:register hook", () => { + it("does nothing when enableTriggers is false", async () => { + const ctx = makeCtx(); + (ctx.api.getConfig as jest.Mock).mockImplementation((k: string) => + k === "enableTriggers" ? false : undefined, + ); + const hook = plugin.definition.hooks?.["user:register"]; + if (!hook) throw new Error("user:register hook not registered"); + await hook(ctx); + expect(ctx.api.db.set).not.toHaveBeenCalled(); + }); + + it("calls fanOutEvent with user.register", async () => { + const ctx = makeCtx(); + (ctx.api.getConfig as jest.Mock).mockReturnValue(true); + const hook = plugin.definition.hooks?.["user:register"]; + if (!hook) throw new Error("user:register hook not registered"); + await hook(ctx); + expect(ctx.api.db.set).toHaveBeenCalledWith( + expect.stringMatching(/^event:/), + expect.objectContaining({ type: "user.register" }), + ); + }); +}); diff --git a/packages/plugins/official/zapier-bridge/__tests__/tsconfig.json b/packages/plugins/official/zapier-bridge/__tests__/tsconfig.json new file mode 100644 index 0000000..a05feed --- /dev/null +++ b/packages/plugins/official/zapier-bridge/__tests__/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "../..", + "baseUrl": "..", + "lib": ["ES2022", "DOM"], + "types": ["jest", "node"], + "paths": { + "@agentbase/plugin-sdk": ["../../../sdk/src/index.ts"] + } + }, + "include": ["../src/**/*", "./**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/plugins/official/zapier-bridge/manifest.json b/packages/plugins/official/zapier-bridge/manifest.json new file mode 100644 index 0000000..2ca509a --- /dev/null +++ b/packages/plugins/official/zapier-bridge/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "zapier-bridge", + "version": "1.0.0", + "description": "Expose Agentbase events as Zapier triggers and accept inbound Zapier actions", + "category": "integration", + "author": "Agentbase", + "license": "UNLICENSED", + "agentbase": { + "pluginSlug": "zapier-bridge", + "minPlatformVersion": "1.0.0" + }, + "permissions": ["network:external", "db:readwrite"] +} diff --git a/packages/plugins/official/zapier-bridge/package.json b/packages/plugins/official/zapier-bridge/package.json new file mode 100644 index 0000000..d0063e8 --- /dev/null +++ b/packages/plugins/official/zapier-bridge/package.json @@ -0,0 +1,41 @@ +{ + "name": "@agentbase/plugin-zapier-bridge", + "version": "1.0.0", + "description": "Expose Agentbase events as Zapier triggers and accept inbound Zapier actions.", + "private": true, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "license": "GPL-3.0-or-later", + "scripts": { + "build": "tsc", + "test": "jest --passWithNoTests", + "test:cov": "jest --coverage --passWithNoTests" + }, + "dependencies": { + "@agentbase/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^25.5.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.0", + "typescript": "^5.7.0" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testMatch": [ + "**/__tests__/**/*.test.ts" + ], + "globals": { + "ts-jest": { + "tsconfig": "./tsconfig.test.json" + } + }, + "coverageThreshold": { + "global": { + "lines": 80 + } + } + } +} diff --git a/packages/plugins/official/zapier-bridge/src/index.ts b/packages/plugins/official/zapier-bridge/src/index.ts new file mode 100644 index 0000000..1b0e511 --- /dev/null +++ b/packages/plugins/official/zapier-bridge/src/index.ts @@ -0,0 +1,488 @@ +/** + * Zapier Bridge + * + * Exposes Agentbase events as Zapier triggers using the REST Hooks standard + * (instant delivery) and accepts inbound Zapier actions. + * + * REST Hooks pattern: + * - Zapier POSTs to /subscribe when a user enables a Zap trigger. + * - Agentbase immediately POSTs to the registered targetUrl whenever the + * subscribed event fires. + * - Zapier DELETEs /subscribe/:hookId when the Zap is disabled. + * - /events provides a polling fallback for the Zap editor's sample data. + * + * Inbound actions (/action): + * - Zapier POSTs a JSON payload to /action. The `action` field selects the + * operation; the `data` field carries parameters. + * - Protected by an optional per-plugin `actionSecret` header. + * + * @package @agentbase/plugin-zapier-bridge + * @version 1.0.0 + */ +import { createPlugin, PluginContext } from "@agentbase/plugin-sdk"; +import { randomUUID } from "crypto"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Maximum number of events stored for the polling fallback endpoint. */ +export const MAX_STORED_EVENTS = 100; + +/** Event types that can be subscribed to as Zapier triggers. */ +export const SUPPORTED_EVENTS = [ + "conversation.end", + "user.register", + "custom", +] as const; + +export type SupportedEvent = (typeof SUPPORTED_EVENTS)[number]; + +/** Actions that can be triggered inbound from Zapier. */ +export const SUPPORTED_ACTIONS = [ + "send_message", + "emit_event", + "update_context", +] as const; + +export type SupportedAction = (typeof SUPPORTED_ACTIONS)[number]; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface HookRecord { + hookId: string; + targetUrl: string; + event: string; + createdAt: number; +} + +export interface EventRecord { + eventId: string; + type: string; + payload: unknown; + timestamp: number; +} + +export interface ZapierActionPayload { + action: string; + data: Record; + zapId?: string; +} + +// ── DB Key Helpers ──────────────────────────────────────────────────────────── + +export function buildHookKey(hookId: string): string { + return `hook:${hookId}`; +} + +export function buildEventKey(eventId: string): string { + return `event:${eventId}`; +} + +// ── ID Generation ───────────────────────────────────────────────────────────── + +export function generateHookId(): string { + return `hook_${randomUUID().replace(/-/g, "").slice(0, 16)}`; +} + +export function generateEventId(): string { + return `evt_${randomUUID().replace(/-/g, "").slice(0, 16)}`; +} + +// ── Event Storage ───────────────────────────────────────────────────────────── + +/** + * Prune stored events to keep only the most recent MAX_STORED_EVENTS entries. + * Called after every new event is stored. + */ +export async function purgeOldEvents( + dbGet: (key: string) => Promise, + dbDelete: (key: string) => Promise, + dbKeys: (prefix?: string) => Promise, +): Promise { + const keys = await dbKeys("event:"); + if (keys.length <= MAX_STORED_EVENTS) return; + + const records = ( + await Promise.all(keys.map(async (k) => ({ key: k, rec: await dbGet(k) }))) + ).filter((x): x is { key: string; rec: EventRecord } => x.rec !== null); + + records.sort( + (a, b) => + (a.rec as EventRecord).timestamp - (b.rec as EventRecord).timestamp, + ); + + const toDelete = records.slice(0, records.length - MAX_STORED_EVENTS); + await Promise.all(toDelete.map(({ key }) => dbDelete(key))); +} + +/** + * Store an event for the polling fallback endpoint and fan out to all + * matching REST Hook subscribers. + * + * The fan-out is fire-and-forget — delivery failures are logged but do not + * affect the caller. + */ +export async function fanOutEvent( + ctx: PluginContext, + eventType: string, + payload: unknown, +): Promise { + const { api } = ctx; + + // 1. Store event for polling fallback + const eventId = generateEventId(); + const record: EventRecord = { + eventId, + type: eventType, + payload, + timestamp: Date.now(), + }; + await api.db.set(buildEventKey(eventId), record); + await purgeOldEvents(api.db.get, api.db.delete, api.db.keys); + + // 2. Collect matching subscribers + const hookKeys = await api.db.keys("hook:"); + if (hookKeys.length === 0) return; + + const hooks = (await Promise.all(hookKeys.map((k) => api.db.get(k)))).filter( + (h): h is HookRecord => + h !== null && + (h as HookRecord).targetUrl !== undefined && + ((h as HookRecord).event === eventType || + (h as HookRecord).event === "custom"), + ); + + // 3. Fan-out: fire-and-forget to each subscriber + for (const hook of hooks) { + (async () => { + try { + const response = await api.makeRequest(hook.targetUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: record.eventId, + type: eventType, + data: payload, + timestamp: record.timestamp, + }), + }); + if (!response.ok) { + api.log( + `Zapier fan-out to ${hook.targetUrl} failed: HTTP ${response.status}`, + "warn", + ); + } + } catch (err) { + api.log( + `Zapier fan-out delivery error for hook ${hook.hookId}: ${(err as Error).message}`, + "error", + ); + } + })(); + } +} + +// ── Plugin Definition ───────────────────────────────────────────────────────── + +export default createPlugin({ + name: "zapier-bridge", + version: "1.0.0", + description: + "Expose Agentbase events as Zapier triggers and accept inbound Zapier actions, connecting your AI app to 7,000+ integrations.", + permissions: ["network:external", "db:readwrite"], + settings: { + enableTriggers: { + type: "boolean", + label: "Enable Zapier Triggers", + default: true, + }, + enableActions: { + type: "boolean", + label: "Enable Inbound Zapier Actions", + default: true, + }, + allowedActions: { + type: "string", + label: + "Allowed Inbound Actions (comma-separated: send_message, emit_event, update_context)", + default: "send_message,emit_event", + }, + actionSecret: { + type: "string", + label: "Action Endpoint Secret (optional — sent as X-Zapier-Secret)", + encrypted: true, + }, + }, + + hooks: { + "app:init": async (context) => { + const { api } = context; + + // ── POST /subscribe ───────────────────────────────────────────────────── + api.registerEndpoint({ + method: "POST", + path: "/subscribe", + auth: true, + description: "Register a Zapier REST Hook subscription", + handler: async (req, res) => { + const enableTriggers = + (api.getConfig("enableTriggers") as boolean) ?? true; + if (!enableTriggers) { + res.status(403).json({ error: "Triggers are disabled" }); + return; + } + + const { targetUrl, event } = req.body as { + targetUrl?: string; + event?: string; + }; + + if (!targetUrl || typeof targetUrl !== "string") { + res.status(400).json({ error: "targetUrl is required" }); + return; + } + if (!event || typeof event !== "string") { + res.status(400).json({ error: "event is required" }); + return; + } + if ( + !(SUPPORTED_EVENTS as readonly string[]).includes(event) && + event !== "custom" + ) { + res.status(400).json({ + error: `Unsupported event '${event}'. Supported: ${SUPPORTED_EVENTS.join(", ")}`, + }); + return; + } + + const hookId = generateHookId(); + const hook: HookRecord = { + hookId, + targetUrl, + event, + createdAt: Date.now(), + }; + await api.db.set(buildHookKey(hookId), hook); + + res.status(201).json({ id: hookId, event, targetUrl }); + }, + }); + + // ── DELETE /subscribe/:hookId ─────────────────────────────────────────── + api.registerEndpoint({ + method: "DELETE", + path: "/subscribe/:hookId", + auth: true, + description: "Unregister a Zapier REST Hook subscription", + handler: async (req, res) => { + const hookId = req.params?.["hookId"] as string | undefined; + if (!hookId) { + res.status(400).json({ error: "hookId is required" }); + return; + } + + const existing = await api.db.get(buildHookKey(hookId)); + if (!existing) { + res.status(404).json({ error: "Hook not found" }); + return; + } + + await api.db.delete(buildHookKey(hookId)); + res.status(200).json({ deleted: true, hookId }); + }, + }); + + // ── GET /subscribe ────────────────────────────────────────────────────── + api.registerEndpoint({ + method: "GET", + path: "/subscribe", + auth: true, + description: "List all active Zapier REST Hook subscriptions", + handler: async (_req, res) => { + const hookKeys = await api.db.keys("hook:"); + const hooks = ( + await Promise.all(hookKeys.map((k) => api.db.get(k))) + ).filter((h): h is HookRecord => h !== null); + + res.status(200).json({ hooks }); + }, + }); + + // ── POST /action ──────────────────────────────────────────────────────── + api.registerEndpoint({ + method: "POST", + path: "/action", + auth: false, + description: "Accept an inbound action from Zapier", + handler: async (req, res) => { + const enableActions = + (api.getConfig("enableActions") as boolean) ?? true; + if (!enableActions) { + res.status(403).json({ error: "Inbound actions are disabled" }); + return; + } + + // Verify optional action secret + const configuredSecret = + (api.getConfig("actionSecret") as string) ?? ""; + if (configuredSecret) { + const providedSecret = req.headers["x-zapier-secret"] as + | string + | undefined; + if (!providedSecret || providedSecret !== configuredSecret) { + res.status(401).json({ error: "Invalid or missing secret" }); + return; + } + } + + const payload = req.body as ZapierActionPayload; + if (!payload.action || typeof payload.action !== "string") { + res.status(400).json({ error: "action field is required" }); + return; + } + if (!payload.data || typeof payload.data !== "object") { + res.status(400).json({ error: "data field is required" }); + return; + } + + const allowedActions = ( + (api.getConfig("allowedActions") as string | undefined) ?? + "send_message,emit_event" + ) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (!allowedActions.includes(payload.action)) { + res.status(403).json({ + error: `Action '${payload.action}' is not in the allowed actions list`, + }); + return; + } + + switch (payload.action as SupportedAction) { + case "send_message": { + const { message, userId } = payload.data as { + message?: string; + userId?: string; + }; + if (!message || typeof message !== "string") { + res + .status(400) + .json({ error: "data.message is required for send_message" }); + return; + } + await api.events.emit("zapier:send_message", { + message, + userId, + zapId: payload.zapId, + }); + res.status(200).json({ ok: true, action: "send_message" }); + return; + } + + case "emit_event": { + const { eventName, eventData } = payload.data as { + eventName?: string; + eventData?: unknown; + }; + if (!eventName || typeof eventName !== "string") { + res + .status(400) + .json({ error: "data.eventName is required for emit_event" }); + return; + } + await api.events.emit(`zapier:${eventName}`, { + data: eventData, + zapId: payload.zapId, + }); + res + .status(200) + .json({ ok: true, action: "emit_event", eventName }); + return; + } + + case "update_context": { + const { key, value } = payload.data as { + key?: string; + value?: unknown; + }; + if (!key || typeof key !== "string") { + res + .status(400) + .json({ error: "data.key is required for update_context" }); + return; + } + await api.events.emit("zapier:update_context", { + key, + value, + zapId: payload.zapId, + }); + res.status(200).json({ ok: true, action: "update_context", key }); + return; + } + + default: + res + .status(400) + .json({ error: `Unknown action '${payload.action}'` }); + } + }, + }); + + // ── GET /events ───────────────────────────────────────────────────────── + api.registerEndpoint({ + method: "GET", + path: "/events", + auth: false, + description: + "Polling fallback — return up to 100 recent events for Zapier sample data", + handler: async (req, res) => { + const eventType = req.query?.["type"] as string | undefined; + const limitParam = req.query?.["limit"] as string | undefined; + const limit = Math.min( + parseInt(limitParam ?? "25", 10) || 25, + MAX_STORED_EVENTS, + ); + + const eventKeys = await api.db.keys("event:"); + const events = ( + await Promise.all(eventKeys.map((k) => api.db.get(k))) + ).filter((e): e is EventRecord => e !== null); + + const filtered = eventType + ? events.filter((e) => e.type === eventType) + : events; + filtered.sort((a, b) => b.timestamp - a.timestamp); + + res.status(200).json({ events: filtered.slice(0, limit) }); + }, + }); + }, + + // ── conversation:end ───────────────────────────────────────────────────── + "conversation:end": async (context) => { + const enableTriggers = + (context.api.getConfig("enableTriggers") as boolean) ?? true; + if (!enableTriggers) return; + await fanOutEvent(context, "conversation.end", { + appId: context.appId, + userId: context.userId, + conversationId: (context as unknown as Record)[ + "conversationId" + ], + timestamp: Date.now(), + }); + }, + + // ── user:register ──────────────────────────────────────────────────────── + "user:register": async (context) => { + const enableTriggers = + (context.api.getConfig("enableTriggers") as boolean) ?? true; + if (!enableTriggers) return; + await fanOutEvent(context, "user.register", { + appId: context.appId, + userId: context.userId, + timestamp: Date.now(), + }); + }, + }, +}); diff --git a/packages/plugins/official/zapier-bridge/tsconfig.json b/packages/plugins/official/zapier-bridge/tsconfig.json new file mode 100644 index 0000000..7f8ba5d --- /dev/null +++ b/packages/plugins/official/zapier-bridge/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "outDir": "./dist", + "rootDir": "../..", + "baseUrl": ".", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noEmit": true, + "types": ["node"], + "paths": { + "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/packages/plugins/official/zapier-bridge/tsconfig.test.json b/packages/plugins/official/zapier-bridge/tsconfig.test.json new file mode 100644 index 0000000..a3367a8 --- /dev/null +++ b/packages/plugins/official/zapier-bridge/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["jest", "node"], + "paths": { + "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"] + } + }, + "include": ["src/**/*", "__tests__/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca1795b..6360fc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -466,6 +466,28 @@ importers: specifier: ^5.7.0 version: 5.9.3 + packages/plugins/official/zapier-bridge: + dependencies: + '@agentbase/plugin-sdk': + specifier: workspace:* + version: link:../.. + devDependencies: + '@types/jest': + specifier: ^29.5.0 + version: 29.5.14 + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@25.5.2) + ts-jest: + specifier: ^29.2.0 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + packages/plugins/template: dependencies: '@agentbase/plugin-sdk':