@axiom-lattice/gateway 2.1.53 → 2.1.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +8 -8
  2. package/CHANGELOG.md +19 -0
  3. package/dist/index.js +1043 -526
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +977 -449
  6. package/dist/index.mjs.map +1 -1
  7. package/jest.config.js +5 -0
  8. package/package.json +6 -5
  9. package/src/__tests__/__mocks__/e2b.ts +1 -0
  10. package/src/__tests__/channel-installations.test.ts +199 -0
  11. package/src/__tests__/sandbox-provider-registration.test.ts +74 -0
  12. package/src/__tests__/workspace.test.ts +119 -8
  13. package/src/channels/__tests__/routes.test.ts +71 -0
  14. package/src/channels/lark/README.md +187 -0
  15. package/src/channels/lark/__tests__/aggregator.test.ts +23 -0
  16. package/src/channels/lark/__tests__/controller.test.ts +270 -0
  17. package/src/channels/lark/__tests__/mapping-service.test.ts +118 -0
  18. package/src/channels/lark/__tests__/parser.test.ts +72 -0
  19. package/src/channels/lark/__tests__/sender.test.ts +37 -0
  20. package/src/channels/lark/__tests__/verification.test.ts +157 -0
  21. package/src/channels/lark/aggregator.ts +16 -0
  22. package/src/channels/lark/config.ts +44 -0
  23. package/src/channels/lark/controller.ts +189 -0
  24. package/src/channels/lark/mapping-service.ts +138 -0
  25. package/src/channels/lark/parser.ts +68 -0
  26. package/src/channels/lark/routes.ts +121 -0
  27. package/src/channels/lark/runner.ts +37 -0
  28. package/src/channels/lark/sender.ts +58 -0
  29. package/src/channels/lark/types.ts +33 -0
  30. package/src/channels/lark/verification.ts +67 -0
  31. package/src/channels/routes.ts +25 -0
  32. package/src/controllers/channel-installations.ts +354 -0
  33. package/src/controllers/sandbox.ts +30 -80
  34. package/src/controllers/skills.ts +71 -321
  35. package/src/controllers/threads.ts +8 -6
  36. package/src/controllers/workspace.ts +64 -179
  37. package/src/index.ts +28 -5
  38. package/src/routes/channel-installations.ts +33 -0
  39. package/src/routes/index.ts +6 -0
  40. package/src/schemas/index.ts +2 -2
  41. package/src/services/sandbox_service.ts +21 -21
  42. package/src/services/skill_service.ts +97 -0
@@ -0,0 +1,157 @@
1
+ import type { FastifyRequest } from "fastify";
2
+ import crypto from "crypto";
3
+ import {
4
+ createLarkRequestVerifier,
5
+ decryptLarkPayload,
6
+ parseLarkRequestBody,
7
+ } from "../verification";
8
+
9
+ describe("createLarkRequestVerifier", () => {
10
+ it("accepts requests with the configured verification token", () => {
11
+ const verify = createLarkRequestVerifier({
12
+ enabled: true,
13
+ appId: "app-id",
14
+ appSecret: "app-secret",
15
+ verificationToken: "token-1",
16
+ tenantId: "tenant-a",
17
+ assistantId: "assistant-a",
18
+ mappingMode: "hybrid",
19
+ });
20
+
21
+ const request = {
22
+ headers: {},
23
+ body: { token: "token-1" },
24
+ } as FastifyRequest;
25
+
26
+ expect(verify(request)).toBe(true);
27
+ });
28
+
29
+ it("rejects requests with the wrong verification token", () => {
30
+ const verify = createLarkRequestVerifier({
31
+ enabled: true,
32
+ appId: "app-id",
33
+ appSecret: "app-secret",
34
+ verificationToken: "token-1",
35
+ tenantId: "tenant-a",
36
+ assistantId: "assistant-a",
37
+ mappingMode: "hybrid",
38
+ });
39
+
40
+ const request = {
41
+ headers: {},
42
+ body: { token: "token-2" },
43
+ } as FastifyRequest;
44
+
45
+ expect(verify(request)).toBe(false);
46
+ });
47
+
48
+ it("accepts requests carrying lark signature headers", () => {
49
+ const verify = createLarkRequestVerifier({
50
+ enabled: true,
51
+ appId: "app-id",
52
+ appSecret: "app-secret",
53
+ tenantId: "tenant-a",
54
+ assistantId: "assistant-a",
55
+ mappingMode: "hybrid",
56
+ });
57
+
58
+ const request = {
59
+ headers: {
60
+ "x-lark-request-timestamp": "1712730010",
61
+ "x-lark-signature": "signature",
62
+ },
63
+ body: {},
64
+ } as FastifyRequest;
65
+
66
+ expect(verify(request)).toBe(true);
67
+ });
68
+
69
+ it("accepts v2 payloads that carry the token in header.token", () => {
70
+ const verify = createLarkRequestVerifier({
71
+ enabled: true,
72
+ appId: "app-id",
73
+ appSecret: "app-secret",
74
+ verificationToken: "token-1",
75
+ tenantId: "tenant-a",
76
+ assistantId: "assistant-a",
77
+ mappingMode: "hybrid",
78
+ });
79
+
80
+ const request = {
81
+ headers: {},
82
+ body: {
83
+ schema: "2.0",
84
+ header: {
85
+ token: "token-1",
86
+ },
87
+ },
88
+ } as FastifyRequest;
89
+
90
+ expect(verify(request)).toBe(true);
91
+ });
92
+
93
+ it("decrypts an encrypted lark payload", () => {
94
+ const encryptKey = "encrypt-key-1";
95
+ const payload = JSON.stringify({
96
+ type: "url_verification",
97
+ challenge: "challenge-1",
98
+ token: "token-1",
99
+ });
100
+ const encrypted = encryptForTest(encryptKey, payload);
101
+
102
+ expect(decryptLarkPayload(encryptKey, encrypted)).toEqual({
103
+ type: "url_verification",
104
+ challenge: "challenge-1",
105
+ token: "token-1",
106
+ });
107
+ });
108
+
109
+ it("parses encrypted payloads before token verification", () => {
110
+ const encryptKey = "encrypt-key-1";
111
+ const verify = createLarkRequestVerifier({
112
+ enabled: true,
113
+ appId: "app-id",
114
+ appSecret: "app-secret",
115
+ verificationToken: "token-1",
116
+ encryptKey,
117
+ tenantId: "tenant-a",
118
+ assistantId: "assistant-a",
119
+ mappingMode: "hybrid",
120
+ });
121
+
122
+ const encrypted = encryptForTest(
123
+ encryptKey,
124
+ JSON.stringify({
125
+ type: "url_verification",
126
+ challenge: "challenge-1",
127
+ token: "token-1",
128
+ }),
129
+ );
130
+
131
+ const request = {
132
+ headers: {},
133
+ body: {
134
+ encrypt: encrypted,
135
+ },
136
+ } as FastifyRequest;
137
+
138
+ expect(verify(request)).toBe(true);
139
+ expect(parseLarkRequestBody(request.body, encryptKey)).toEqual({
140
+ type: "url_verification",
141
+ challenge: "challenge-1",
142
+ token: "token-1",
143
+ });
144
+ });
145
+ });
146
+
147
+ function encryptForTest(encryptKey: string, plaintext: string): string {
148
+ const key = crypto.createHash("sha256").update(encryptKey).digest();
149
+ const iv = Buffer.alloc(16, 1);
150
+ const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
151
+ const ciphertext = Buffer.concat([
152
+ cipher.update(Buffer.from(plaintext, "utf8")),
153
+ cipher.final(),
154
+ ]);
155
+
156
+ return Buffer.concat([iv, ciphertext]).toString("base64");
157
+ }
@@ -0,0 +1,16 @@
1
+ import type { MessageChunk } from "@axiom-lattice/protocols";
2
+ import { MessageChunkTypes } from "@axiom-lattice/protocols";
3
+
4
+ export function aggregateLarkReply(
5
+ messageId: string,
6
+ chunks: MessageChunk[],
7
+ ): string {
8
+ return chunks
9
+ .filter(
10
+ (chunk) =>
11
+ chunk.type === MessageChunkTypes.AI && chunk.data.id === messageId,
12
+ )
13
+ .map((chunk) => chunk.data.content || "")
14
+ .join("")
15
+ .trim();
16
+ }
@@ -0,0 +1,44 @@
1
+ import type { LarkIngressConfig } from "./types";
2
+
3
+ export function loadLarkIngressConfig(): LarkIngressConfig {
4
+ return {
5
+ enabled: process.env.LARK_ENABLED !== "false",
6
+ appId: process.env.LARK_APP_ID || "",
7
+ appSecret: process.env.LARK_APP_SECRET || "",
8
+ verificationToken: process.env.LARK_VERIFICATION_TOKEN,
9
+ encryptKey: process.env.LARK_ENCRYPT_KEY,
10
+ tenantId: process.env.LARK_TENANT_ID || "default",
11
+ assistantId: process.env.LARK_ASSISTANT_ID || "default_agent",
12
+ workspaceId: process.env.LARK_WORKSPACE_ID,
13
+ projectId: process.env.LARK_PROJECT_ID,
14
+ mappingMode:
15
+ (process.env.LARK_MAPPING_MODE as "user" | "group" | "hybrid") ||
16
+ "hybrid",
17
+ };
18
+ }
19
+
20
+ export function isLarkIngressEnabled(config: LarkIngressConfig): boolean {
21
+ return config.enabled;
22
+ }
23
+
24
+ export function assertLarkIngressConfig(config: LarkIngressConfig): void {
25
+ if (!config.enabled) {
26
+ return;
27
+ }
28
+
29
+ if (!config.appId) {
30
+ throw new Error("LARK_APP_ID is required when Lark ingress is enabled");
31
+ }
32
+
33
+ if (!config.appSecret) {
34
+ throw new Error("LARK_APP_SECRET is required when Lark ingress is enabled");
35
+ }
36
+
37
+ if (!config.tenantId) {
38
+ throw new Error("LARK_TENANT_ID is required when Lark ingress is enabled");
39
+ }
40
+
41
+ if (!config.assistantId) {
42
+ throw new Error("LARK_ASSISTANT_ID is required when Lark ingress is enabled");
43
+ }
44
+ }
@@ -0,0 +1,189 @@
1
+ import type { FastifyReply, FastifyRequest } from "fastify";
2
+ import { parseLarkMessageEvent } from "./parser";
3
+ import type {
4
+ LarkIngressConfig,
5
+ LarkUrlVerificationPayload,
6
+ ParsedLarkMessageEvent,
7
+ } from "./types";
8
+
9
+ export interface LarkEventHandlerDependencies {
10
+ getInstallationConfig: (
11
+ installationId: string,
12
+ ) => Promise<LarkIngressConfig | null>;
13
+ parseRequestBody: (
14
+ body: unknown,
15
+ encryptKey?: string,
16
+ ) => LarkUrlVerificationPayload;
17
+ verifyParsedBody: (
18
+ body: LarkUrlVerificationPayload,
19
+ config: LarkIngressConfig,
20
+ ) => boolean;
21
+ parseEvent: (payload: unknown) => ParsedLarkMessageEvent | null;
22
+ claimInboundReceipt: (input: {
23
+ channel: string;
24
+ channelAppId: string;
25
+ externalMessageId: string;
26
+ tenantId: string;
27
+ }) => Promise<{ accepted: boolean; status: "processing" | "completed" }>;
28
+ markInboundReceiptCompleted: (input: {
29
+ channel: string;
30
+ channelAppId: string;
31
+ externalMessageId: string;
32
+ tenantId: string;
33
+ threadId: string;
34
+ }) => Promise<void>;
35
+ markInboundReceiptFailed: (input: {
36
+ channel: string;
37
+ channelAppId: string;
38
+ externalMessageId: string;
39
+ tenantId: string;
40
+ }) => Promise<void>;
41
+ resolveThread: (input: {
42
+ channel: string;
43
+ channelAppId: string;
44
+ tenantId: string;
45
+ assistantId: string;
46
+ mappingMode: "user" | "group" | "hybrid";
47
+ openId: string;
48
+ chatId: string;
49
+ chatType: "direct" | "group";
50
+ messageId: string;
51
+ workspaceId?: string;
52
+ projectId?: string;
53
+ }) => Promise<{ threadId: string }>;
54
+ runAgentAndCollectText: (input: {
55
+ tenantId: string;
56
+ assistantId: string;
57
+ threadId: string;
58
+ text: string;
59
+ workspaceId?: string;
60
+ projectId?: string;
61
+ }) => Promise<string>;
62
+ sendTextReply: (input: {
63
+ chatId: string;
64
+ text: string;
65
+ config: LarkIngressConfig;
66
+ }) => Promise<void>;
67
+ }
68
+
69
+ export function createLarkEventHandler(
70
+ dependencies: LarkEventHandlerDependencies,
71
+ ) {
72
+ return async function handleLarkEvent(
73
+ request: FastifyRequest,
74
+ reply: FastifyReply,
75
+ ): Promise<void> {
76
+ const installationId = (request.params as { installationId?: string })
77
+ ?.installationId;
78
+
79
+ if (!installationId) {
80
+ reply.status(400).send({ success: false, message: "Missing installationId" });
81
+ return;
82
+ }
83
+
84
+ const config = await dependencies.getInstallationConfig(installationId);
85
+ if (!config) {
86
+ reply.status(404).send({ success: false, message: "Lark installation not found" });
87
+ return;
88
+ }
89
+
90
+ const body = dependencies.parseRequestBody(
91
+ request.body,
92
+ config.encryptKey,
93
+ ) as LarkUrlVerificationPayload;
94
+
95
+ if (!dependencies.verifyParsedBody(body, config)) {
96
+ reply.status(401).send({ success: false, message: "Invalid Lark request" });
97
+ return;
98
+ }
99
+
100
+ if (body.type === "url_verification" && body.challenge) {
101
+ reply.status(200).send({ challenge: body.challenge });
102
+ return;
103
+ }
104
+
105
+ const parsed = dependencies.parseEvent(request.body);
106
+ if (!parsed) {
107
+ reply.status(200).send({ success: true, ignored: true });
108
+ return;
109
+ }
110
+
111
+ const receipt = await dependencies.claimInboundReceipt({
112
+ channel: "lark",
113
+ channelAppId: config.appId,
114
+ externalMessageId: parsed.messageId,
115
+ tenantId: config.tenantId,
116
+ });
117
+
118
+ if (!receipt.accepted) {
119
+ reply.status(200).send(
120
+ receipt.status === "processing"
121
+ ? { success: true, processing: true }
122
+ : { success: true, duplicate: true },
123
+ );
124
+ return;
125
+ }
126
+
127
+ try {
128
+ const { threadId } = await dependencies.resolveThread({
129
+ channel: "lark",
130
+ channelAppId: config.appId,
131
+ tenantId: config.tenantId,
132
+ assistantId: config.assistantId,
133
+ mappingMode: config.mappingMode,
134
+ openId: parsed.openId,
135
+ chatId: parsed.chatId,
136
+ chatType: parsed.chatType,
137
+ messageId: parsed.messageId,
138
+ workspaceId: config.workspaceId,
139
+ projectId: config.projectId,
140
+ });
141
+
142
+ const text = await dependencies.runAgentAndCollectText({
143
+ tenantId: config.tenantId,
144
+ assistantId: config.assistantId,
145
+ threadId,
146
+ text: parsed.text,
147
+ workspaceId: config.workspaceId,
148
+ projectId: config.projectId,
149
+ });
150
+
151
+ await dependencies.sendTextReply({
152
+ chatId: parsed.chatId,
153
+ text,
154
+ config,
155
+ });
156
+
157
+ await dependencies.markInboundReceiptCompleted({
158
+ channel: "lark",
159
+ channelAppId: config.appId,
160
+ externalMessageId: parsed.messageId,
161
+ tenantId: config.tenantId,
162
+ threadId,
163
+ });
164
+
165
+ reply.status(200).send({ success: true, threadId });
166
+ } catch (error) {
167
+ await dependencies.markInboundReceiptFailed({
168
+ channel: "lark",
169
+ channelAppId: config.appId,
170
+ externalMessageId: parsed.messageId,
171
+ tenantId: config.tenantId,
172
+ });
173
+ throw error;
174
+ }
175
+ };
176
+ }
177
+
178
+ export const handleLarkEvent = createLarkEventHandler({
179
+ getInstallationConfig: async () => null,
180
+ parseRequestBody: (body) => (body || {}) as LarkUrlVerificationPayload,
181
+ verifyParsedBody: () => true,
182
+ parseEvent: parseLarkMessageEvent,
183
+ claimInboundReceipt: async () => ({ accepted: true, status: "processing" }),
184
+ markInboundReceiptCompleted: async () => undefined,
185
+ markInboundReceiptFailed: async () => undefined,
186
+ resolveThread: async () => ({ threadId: "" }),
187
+ runAgentAndCollectText: async () => "",
188
+ sendTextReply: async () => undefined,
189
+ });
@@ -0,0 +1,138 @@
1
+ import type { ThreadStore } from "@axiom-lattice/protocols";
2
+ import type { ChannelIdentityMappingStore } from "@axiom-lattice/pg-stores";
3
+ import { randomUUID } from "crypto";
4
+
5
+ interface GetOrCreateThreadInput {
6
+ channel: string;
7
+ channelAppId: string;
8
+ tenantId: string;
9
+ assistantId: string;
10
+ mappingMode: "user" | "group" | "hybrid";
11
+ openId: string;
12
+ chatId: string;
13
+ chatType: "direct" | "group";
14
+ messageId: string;
15
+ workspaceId?: string;
16
+ projectId?: string;
17
+ }
18
+
19
+ interface ChannelThreadMappingServiceDeps {
20
+ mappingStore: Pick<
21
+ ChannelIdentityMappingStore,
22
+ "getMappingBySubject" | "createMapping"
23
+ >;
24
+ threadStore: Pick<ThreadStore, "createThread" | "deleteThread">;
25
+ uuid?: () => string;
26
+ }
27
+
28
+ export function createChannelThreadMappingService(
29
+ deps: ChannelThreadMappingServiceDeps,
30
+ ) {
31
+ return {
32
+ async getOrCreateThread(input: GetOrCreateThreadInput): Promise<{ threadId: string }> {
33
+ const externalSubjectKey = buildExternalSubjectKey(input);
34
+ const existing = await deps.mappingStore.getMappingBySubject({
35
+ channel: input.channel,
36
+ channelAppId: input.channelAppId,
37
+ tenantId: input.tenantId,
38
+ assistantId: input.assistantId,
39
+ externalSubjectKey,
40
+ });
41
+
42
+ if (existing) {
43
+ return { threadId: existing.threadId };
44
+ }
45
+
46
+ const threadId = (deps.uuid || randomUUID)();
47
+
48
+ await deps.threadStore.createThread(input.tenantId, input.assistantId, threadId, {
49
+ metadata: {
50
+ tenantId: input.tenantId,
51
+ workspaceId: input.workspaceId,
52
+ projectId: input.projectId,
53
+ channel: input.channel,
54
+ larkChatId: input.chatId,
55
+ larkOpenId: input.openId,
56
+ },
57
+ });
58
+
59
+ try {
60
+ await deps.mappingStore.createMapping({
61
+ channel: input.channel,
62
+ channelAppId: input.channelAppId,
63
+ tenantId: input.tenantId,
64
+ assistantId: input.assistantId,
65
+ mappingMode: input.mappingMode,
66
+ externalSubjectType:
67
+ resolveSubjectType(input.mappingMode, input.chatType) === "user"
68
+ ? "user"
69
+ : "chat",
70
+ externalSubjectKey,
71
+ larkOpenId: input.openId,
72
+ larkChatId: input.chatId,
73
+ larkMessageId: input.messageId,
74
+ threadId,
75
+ });
76
+ } catch (error) {
77
+ if (!isUniqueViolation(error)) {
78
+ throw error;
79
+ }
80
+
81
+ const canonical = await deps.mappingStore.getMappingBySubject({
82
+ channel: input.channel,
83
+ channelAppId: input.channelAppId,
84
+ tenantId: input.tenantId,
85
+ assistantId: input.assistantId,
86
+ externalSubjectKey,
87
+ });
88
+
89
+ if (!canonical) {
90
+ throw error;
91
+ }
92
+
93
+ await deps.threadStore.deleteThread(input.tenantId, threadId);
94
+
95
+ return { threadId: canonical.threadId };
96
+ }
97
+
98
+ return { threadId };
99
+ },
100
+ };
101
+ }
102
+
103
+ function isUniqueViolation(error: unknown): boolean {
104
+ return (
105
+ typeof error === "object" &&
106
+ error !== null &&
107
+ "code" in error &&
108
+ (error as { code?: string }).code === "23505"
109
+ );
110
+ }
111
+
112
+ function buildExternalSubjectKey(input: GetOrCreateThreadInput): string {
113
+ const subjectType = resolveSubjectType(input.mappingMode, input.chatType);
114
+ const subjectValue = subjectType === "user" ? input.openId : input.chatId;
115
+
116
+ return [
117
+ input.channel,
118
+ input.channelAppId,
119
+ `tenant:${input.tenantId}`,
120
+ `assistant:${input.assistantId}`,
121
+ `${subjectType}:${subjectValue}`,
122
+ ].join(":");
123
+ }
124
+
125
+ function resolveSubjectType(
126
+ mappingMode: "user" | "group" | "hybrid",
127
+ chatType: "direct" | "group",
128
+ ): "user" | "chat" {
129
+ if (mappingMode === "user") {
130
+ return "user";
131
+ }
132
+
133
+ if (mappingMode === "group") {
134
+ return "chat";
135
+ }
136
+
137
+ return chatType === "direct" ? "user" : "chat";
138
+ }
@@ -0,0 +1,68 @@
1
+ import type { ParsedLarkMessageEvent } from "./types";
2
+
3
+ interface RawLarkPayload {
4
+ header?: {
5
+ event_type?: string;
6
+ };
7
+ event?: {
8
+ sender?: {
9
+ sender_id?: {
10
+ open_id?: string;
11
+ };
12
+ };
13
+ message?: {
14
+ message_id?: string;
15
+ chat_id?: string;
16
+ chat_type?: string;
17
+ message_type?: string;
18
+ content?: string;
19
+ };
20
+ };
21
+ }
22
+
23
+ export function parseLarkMessageEvent(
24
+ payload: unknown,
25
+ ): ParsedLarkMessageEvent | null {
26
+ const raw = payload as RawLarkPayload;
27
+
28
+ if (raw.header?.event_type !== "im.message.receive_v1") {
29
+ return null;
30
+ }
31
+
32
+ const message = raw.event?.message;
33
+ const openId = raw.event?.sender?.sender_id?.open_id;
34
+
35
+ if (!message || !openId || message.message_type !== "text") {
36
+ return null;
37
+ }
38
+
39
+ const parsedContent = parseLarkTextContent(message.content);
40
+ if (!message.message_id || !message.chat_id || !parsedContent) {
41
+ return null;
42
+ }
43
+
44
+ return {
45
+ messageId: message.message_id,
46
+ openId,
47
+ chatId: message.chat_id,
48
+ chatType: normalizeChatType(message.chat_type),
49
+ text: parsedContent,
50
+ };
51
+ }
52
+
53
+ function parseLarkTextContent(content: string | undefined): string | null {
54
+ if (!content) {
55
+ return null;
56
+ }
57
+
58
+ try {
59
+ const parsed = JSON.parse(content) as { text?: string };
60
+ return typeof parsed.text === "string" ? parsed.text : null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function normalizeChatType(chatType: string | undefined): "direct" | "group" {
67
+ return chatType === "p2p" ? "direct" : "group";
68
+ }