@agent-native/dispatch 0.2.16 → 0.2.17

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.
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch-integrations.d.ts","sourceRoot":"","sources":["../../../src/server/lib/dispatch-integrations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EAChB,MAAM,2BAA2B,CAAC;AAUnC,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,eAAe,GACxB,MAAM,GAAG,IAAI,CAmBf;AA8BD,wBAAsB,oBAAoB,CACxC,QAAQ,EAAE,eAAe,GACxB,OAAO,CAAC,MAAM,CAAC,CA8BjB;AAED,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,eAAe,EACzB,QAAQ,EAAE,eAAe,GACxB,OAAO,CAAC;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CAAC,CAuBxE"}
1
+ {"version":3,"file":"dispatch-integrations.d.ts","sourceRoot":"","sources":["../../../src/server/lib/dispatch-integrations.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,eAAe,EAChB,MAAM,2BAA2B,CAAC;AAsBnC,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,eAAe,GACxB,MAAM,GAAG,IAAI,CAmBf;AA0GD,wBAAsB,oBAAoB,CACxC,QAAQ,EAAE,eAAe,GACxB,OAAO,CAAC,MAAM,CAAC,CAuCjB;AAED,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,eAAe,EACzB,QAAQ,EAAE,eAAe,GACxB,OAAO,CAAC;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CAAC,CAuBxE"}
@@ -1,5 +1,8 @@
1
+ import { resolveOrgIdForEmail } from "@agent-native/core/org";
1
2
  import crypto from "node:crypto";
2
3
  import { consumeLinkToken, resolveLinkedOwner } from "./dispatch-store.js";
4
+ const slackProfileCache = new Map();
5
+ const SLACK_PROFILE_CACHE_TTL = 10 * 60 * 1000;
3
6
  function contextString(value) {
4
7
  if (typeof value === "string" && value.trim())
5
8
  return value.trim();
@@ -49,6 +52,65 @@ function configuredDefaultOwnerForIncoming(incoming) {
49
52
  return null;
50
53
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : null;
51
54
  }
55
+ async function resolveSlackSenderProfile(incoming) {
56
+ if (incoming.platform !== "slack")
57
+ return { email: null, name: null };
58
+ const token = process.env.SLACK_BOT_TOKEN;
59
+ const userId = contextString(incoming.senderId);
60
+ const teamId = contextString(incoming.platformContext.teamId);
61
+ if (!token || !userId)
62
+ return { email: null, name: null };
63
+ // Slack user IDs are scoped per workspace, so without a teamId we can't
64
+ // safely cache: two installs of the bot in different workspaces could
65
+ // share user-id strings and collide on a single "default" key. Skip the
66
+ // cache (and lookup on every request) when teamId is missing.
67
+ const cacheKey = teamId ? `${teamId}:${userId}` : null;
68
+ if (cacheKey) {
69
+ const cached = slackProfileCache.get(cacheKey);
70
+ if (cached && cached.expiresAt > Date.now())
71
+ return cached.profile;
72
+ }
73
+ try {
74
+ const params = new URLSearchParams({ user: userId });
75
+ const res = await fetch(`https://slack.com/api/users.info?${params}`, {
76
+ headers: { Authorization: `Bearer ${token}` },
77
+ });
78
+ const data = (await res.json());
79
+ const profile = data.ok
80
+ ? {
81
+ email: data.user?.profile?.email?.trim().toLowerCase() || null,
82
+ name: data.user?.profile?.real_name?.trim() ||
83
+ data.user?.profile?.display_name?.trim() ||
84
+ data.user?.real_name?.trim() ||
85
+ data.user?.name?.trim() ||
86
+ null,
87
+ }
88
+ : { email: null, name: null };
89
+ if (cacheKey) {
90
+ slackProfileCache.set(cacheKey, {
91
+ profile,
92
+ expiresAt: Date.now() + SLACK_PROFILE_CACHE_TTL,
93
+ });
94
+ }
95
+ return profile;
96
+ }
97
+ catch {
98
+ return { email: null, name: null };
99
+ }
100
+ }
101
+ async function resolveSlackOwnerFromVerifiedEmail(incoming) {
102
+ const profile = await resolveSlackSenderProfile(incoming);
103
+ if (!profile.email)
104
+ return null;
105
+ incoming.senderEmail = profile.email;
106
+ incoming.platformContext.senderEmail = profile.email;
107
+ if (profile.name) {
108
+ incoming.senderName = profile.name;
109
+ incoming.platformContext.senderName = profile.name;
110
+ }
111
+ const orgId = await resolveOrgIdForEmail(profile.email);
112
+ return orgId ? profile.email : null;
113
+ }
52
114
  export async function resolveDispatchOwner(incoming) {
53
115
  try {
54
116
  const externalUserId = identityKeyForIncoming(incoming);
@@ -66,6 +128,15 @@ export async function resolveDispatchOwner(incoming) {
66
128
  incoming.senderId.includes("@")) {
67
129
  return incoming.senderId;
68
130
  }
131
+ // Slack gives us a user id in the event payload. Resolve it to a verified
132
+ // workspace email and use that user's own org context when they are an
133
+ // Agent-Native member, so artifacts created via @agent-native are visible
134
+ // when they open the target app.
135
+ if (incoming.platform === "slack") {
136
+ const slackOwner = await resolveSlackOwnerFromVerifiedEmail(incoming);
137
+ if (slackOwner)
138
+ return slackOwner;
139
+ }
69
140
  const defaultOwner = configuredDefaultOwnerForIncoming(incoming);
70
141
  if (defaultOwner)
71
142
  return defaultOwner;
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch-integrations.js","sourceRoot":"","sources":["../../../src/server/lib/dispatch-integrations.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n IncomingMessage,\n PlatformAdapter,\n} from \"@agent-native/core/server\";\nimport crypto from \"node:crypto\";\nimport { consumeLinkToken, resolveLinkedOwner } from \"./dispatch-store.js\";\n\nfunction contextString(value: unknown): string | null {\n if (typeof value === \"string\" && value.trim()) return value.trim();\n if (typeof value === \"number\" && Number.isFinite(value)) return String(value);\n return null;\n}\n\nexport function identityKeyForIncoming(\n incoming: IncomingMessage,\n): string | null {\n const senderId = contextString(incoming.senderId);\n if (!senderId) return null;\n\n if (incoming.platform === \"slack\") {\n const teamId = contextString(incoming.platformContext.teamId);\n return teamId ? `${teamId}:${senderId}` : senderId;\n }\n\n if (incoming.platform === \"whatsapp\") {\n const phoneNumberId = contextString(incoming.platformContext.phoneNumberId);\n return phoneNumberId ? `${phoneNumberId}:${senderId}` : senderId;\n }\n\n if (incoming.platform === \"email\") {\n return senderId.toLowerCase();\n }\n\n return senderId;\n}\n\nfunction fallbackOwnerForIncoming(incoming: IncomingMessage): string {\n const tenant =\n contextString(incoming.platformContext.teamId) ||\n contextString(incoming.platformContext.phoneNumberId) ||\n contextString(incoming.platformContext.chatId) ||\n contextString(incoming.platformContext.from) ||\n incoming.externalThreadId;\n const raw = `${incoming.platform}:${tenant}:${incoming.senderId || \"\"}`;\n const hash = crypto\n .createHash(\"sha256\")\n .update(raw)\n .digest(\"hex\")\n .slice(0, 16);\n return `dispatch+${hash}@integration.local`;\n}\n\nfunction configuredDefaultOwnerForIncoming(\n incoming: IncomingMessage,\n): string | null {\n // This is intentionally Slack-only: a deployment-wide default owner grants\n // that Slack workspace access to the owner's connected agents and org\n // credentials, so other platforms should opt in with explicit identity links.\n if (incoming.platform !== \"slack\") return null;\n const email = process.env.DISPATCH_DEFAULT_OWNER_EMAIL?.trim();\n if (!email) return null;\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email) ? email : null;\n}\n\nexport async function resolveDispatchOwner(\n incoming: IncomingMessage,\n): Promise<string> {\n try {\n const externalUserId = identityKeyForIncoming(incoming);\n\n // Webhooks do not have the browser request's org context, so allow a safe\n // cross-org fallback when the linked platform identity maps to one owner.\n const owner = await resolveLinkedOwner(incoming.platform, externalUserId, {\n allowAnyOrgFallback: true,\n });\n if (owner) return owner;\n\n // For email, the sender's email address is already a natural identity.\n // If the senderId looks like an email address, use it directly as the owner.\n if (\n incoming.platform === \"email\" &&\n incoming.senderId &&\n incoming.senderId.includes(\"@\")\n ) {\n return incoming.senderId;\n }\n\n const defaultOwner = configuredDefaultOwnerForIncoming(incoming);\n if (defaultOwner) return defaultOwner;\n\n return fallbackOwnerForIncoming(incoming);\n } catch {\n const defaultOwner = configuredDefaultOwnerForIncoming(incoming);\n if (defaultOwner) return defaultOwner;\n return fallbackOwnerForIncoming(incoming);\n }\n}\n\nexport async function beforeDispatchProcess(\n incoming: IncomingMessage,\n _adapter: PlatformAdapter,\n): Promise<{ handled: true; responseText?: string } | { handled: false }> {\n const trimmed = incoming.text.trim();\n const match = trimmed.match(/^\\/link\\s+([a-zA-Z0-9_-]+)$/);\n if (!match) return { handled: false };\n\n try {\n const owner = await consumeLinkToken({\n platform: incoming.platform,\n token: match[1],\n externalUserId: identityKeyForIncoming(incoming),\n externalUserName: incoming.senderName || null,\n });\n return {\n handled: true,\n responseText: `Linked successfully. Future ${incoming.platform} messages will use ${owner}'s personal dispatch context.`,\n };\n } catch (error) {\n return {\n handled: true,\n responseText:\n error instanceof Error ? error.message : \"Failed to link this account.\",\n };\n }\n}\n"]}
1
+ {"version":3,"file":"dispatch-integrations.js","sourceRoot":"","sources":["../../../src/server/lib/dispatch-integrations.ts"],"names":[],"mappings":"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","sourcesContent":["import type {\n IncomingMessage,\n PlatformAdapter,\n} from \"@agent-native/core/server\";\nimport { resolveOrgIdForEmail } from \"@agent-native/core/org\";\nimport crypto from \"node:crypto\";\nimport { consumeLinkToken, resolveLinkedOwner } from \"./dispatch-store.js\";\n\ntype SlackSenderProfile = {\n email: string | null;\n name: string | null;\n};\n\nconst slackProfileCache = new Map<\n string,\n { profile: SlackSenderProfile; expiresAt: number }\n>();\nconst SLACK_PROFILE_CACHE_TTL = 10 * 60 * 1000;\n\nfunction contextString(value: unknown): string | null {\n if (typeof value === \"string\" && value.trim()) return value.trim();\n if (typeof value === \"number\" && Number.isFinite(value)) return String(value);\n return null;\n}\n\nexport function identityKeyForIncoming(\n incoming: IncomingMessage,\n): string | null {\n const senderId = contextString(incoming.senderId);\n if (!senderId) return null;\n\n if (incoming.platform === \"slack\") {\n const teamId = contextString(incoming.platformContext.teamId);\n return teamId ? `${teamId}:${senderId}` : senderId;\n }\n\n if (incoming.platform === \"whatsapp\") {\n const phoneNumberId = contextString(incoming.platformContext.phoneNumberId);\n return phoneNumberId ? `${phoneNumberId}:${senderId}` : senderId;\n }\n\n if (incoming.platform === \"email\") {\n return senderId.toLowerCase();\n }\n\n return senderId;\n}\n\nfunction fallbackOwnerForIncoming(incoming: IncomingMessage): string {\n const tenant =\n contextString(incoming.platformContext.teamId) ||\n contextString(incoming.platformContext.phoneNumberId) ||\n contextString(incoming.platformContext.chatId) ||\n contextString(incoming.platformContext.from) ||\n incoming.externalThreadId;\n const raw = `${incoming.platform}:${tenant}:${incoming.senderId || \"\"}`;\n const hash = crypto\n .createHash(\"sha256\")\n .update(raw)\n .digest(\"hex\")\n .slice(0, 16);\n return `dispatch+${hash}@integration.local`;\n}\n\nfunction configuredDefaultOwnerForIncoming(\n incoming: IncomingMessage,\n): string | null {\n // This is intentionally Slack-only: a deployment-wide default owner grants\n // that Slack workspace access to the owner's connected agents and org\n // credentials, so other platforms should opt in with explicit identity links.\n if (incoming.platform !== \"slack\") return null;\n const email = process.env.DISPATCH_DEFAULT_OWNER_EMAIL?.trim();\n if (!email) return null;\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email) ? email : null;\n}\n\nasync function resolveSlackSenderProfile(\n incoming: IncomingMessage,\n): Promise<SlackSenderProfile> {\n if (incoming.platform !== \"slack\") return { email: null, name: null };\n const token = process.env.SLACK_BOT_TOKEN;\n const userId = contextString(incoming.senderId);\n const teamId = contextString(incoming.platformContext.teamId);\n if (!token || !userId) return { email: null, name: null };\n\n // Slack user IDs are scoped per workspace, so without a teamId we can't\n // safely cache: two installs of the bot in different workspaces could\n // share user-id strings and collide on a single \"default\" key. Skip the\n // cache (and lookup on every request) when teamId is missing.\n const cacheKey = teamId ? `${teamId}:${userId}` : null;\n if (cacheKey) {\n const cached = slackProfileCache.get(cacheKey);\n if (cached && cached.expiresAt > Date.now()) return cached.profile;\n }\n\n try {\n const params = new URLSearchParams({ user: userId });\n const res = await fetch(`https://slack.com/api/users.info?${params}`, {\n headers: { Authorization: `Bearer ${token}` },\n });\n const data = (await res.json()) as {\n ok?: boolean;\n user?: {\n real_name?: string;\n name?: string;\n profile?: {\n email?: string;\n real_name?: string;\n display_name?: string;\n };\n };\n };\n const profile = data.ok\n ? {\n email: data.user?.profile?.email?.trim().toLowerCase() || null,\n name:\n data.user?.profile?.real_name?.trim() ||\n data.user?.profile?.display_name?.trim() ||\n data.user?.real_name?.trim() ||\n data.user?.name?.trim() ||\n null,\n }\n : { email: null, name: null };\n if (cacheKey) {\n slackProfileCache.set(cacheKey, {\n profile,\n expiresAt: Date.now() + SLACK_PROFILE_CACHE_TTL,\n });\n }\n return profile;\n } catch {\n return { email: null, name: null };\n }\n}\n\nasync function resolveSlackOwnerFromVerifiedEmail(\n incoming: IncomingMessage,\n): Promise<string | null> {\n const profile = await resolveSlackSenderProfile(incoming);\n if (!profile.email) return null;\n\n incoming.senderEmail = profile.email;\n incoming.platformContext.senderEmail = profile.email;\n if (profile.name) {\n incoming.senderName = profile.name;\n incoming.platformContext.senderName = profile.name;\n }\n\n const orgId = await resolveOrgIdForEmail(profile.email);\n return orgId ? profile.email : null;\n}\n\nexport async function resolveDispatchOwner(\n incoming: IncomingMessage,\n): Promise<string> {\n try {\n const externalUserId = identityKeyForIncoming(incoming);\n\n // Webhooks do not have the browser request's org context, so allow a safe\n // cross-org fallback when the linked platform identity maps to one owner.\n const owner = await resolveLinkedOwner(incoming.platform, externalUserId, {\n allowAnyOrgFallback: true,\n });\n if (owner) return owner;\n\n // For email, the sender's email address is already a natural identity.\n // If the senderId looks like an email address, use it directly as the owner.\n if (\n incoming.platform === \"email\" &&\n incoming.senderId &&\n incoming.senderId.includes(\"@\")\n ) {\n return incoming.senderId;\n }\n\n // Slack gives us a user id in the event payload. Resolve it to a verified\n // workspace email and use that user's own org context when they are an\n // Agent-Native member, so artifacts created via @agent-native are visible\n // when they open the target app.\n if (incoming.platform === \"slack\") {\n const slackOwner = await resolveSlackOwnerFromVerifiedEmail(incoming);\n if (slackOwner) return slackOwner;\n }\n\n const defaultOwner = configuredDefaultOwnerForIncoming(incoming);\n if (defaultOwner) return defaultOwner;\n\n return fallbackOwnerForIncoming(incoming);\n } catch {\n const defaultOwner = configuredDefaultOwnerForIncoming(incoming);\n if (defaultOwner) return defaultOwner;\n return fallbackOwnerForIncoming(incoming);\n }\n}\n\nexport async function beforeDispatchProcess(\n incoming: IncomingMessage,\n _adapter: PlatformAdapter,\n): Promise<{ handled: true; responseText?: string } | { handled: false }> {\n const trimmed = incoming.text.trim();\n const match = trimmed.match(/^\\/link\\s+([a-zA-Z0-9_-]+)$/);\n if (!match) return { handled: false };\n\n try {\n const owner = await consumeLinkToken({\n platform: incoming.platform,\n token: match[1],\n externalUserId: identityKeyForIncoming(incoming),\n externalUserName: incoming.senderName || null,\n });\n return {\n handled: true,\n responseText: `Linked successfully. Future ${incoming.platform} messages will use ${owner}'s personal dispatch context.`,\n };\n } catch (error) {\n return {\n handled: true,\n responseText:\n error instanceof Error ? error.message : \"Failed to link this account.\",\n };\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/dispatch",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "type": "module",
5
5
  "description": "Dispatch — workspace control plane for agent-native apps. Vault, integrations, destinations, scheduled jobs, and cross-app delegation, shipped as a single drop-in package.",
6
6
  "license": "MIT",
@@ -96,7 +96,7 @@
96
96
  "typescript": "^6.0.3",
97
97
  "vite": "8.0.3",
98
98
  "vitest": "^4.1.5",
99
- "@agent-native/core": "0.12.19"
99
+ "@agent-native/core": "0.12.21"
100
100
  },
101
101
  "scripts": {
102
102
  "build": "tsc && tsc-alias --resolve-full-paths",
@@ -0,0 +1,116 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ consumeLinkToken: vi.fn(),
5
+ resolveLinkedOwner: vi.fn(),
6
+ resolveOrgIdForEmail: vi.fn(),
7
+ }));
8
+
9
+ vi.mock("./dispatch-store.js", () => ({
10
+ consumeLinkToken: mocks.consumeLinkToken,
11
+ resolveLinkedOwner: mocks.resolveLinkedOwner,
12
+ }));
13
+
14
+ vi.mock("@agent-native/core/org", () => ({
15
+ resolveOrgIdForEmail: mocks.resolveOrgIdForEmail,
16
+ }));
17
+
18
+ import {
19
+ identityKeyForIncoming,
20
+ resolveDispatchOwner,
21
+ } from "./dispatch-integrations.js";
22
+ import type { IncomingMessage } from "@agent-native/core/server";
23
+
24
+ const originalFetch = globalThis.fetch;
25
+
26
+ function slackIncoming(
27
+ overrides: Partial<IncomingMessage> = {},
28
+ ): IncomingMessage {
29
+ return {
30
+ platform: "slack",
31
+ externalThreadId: "C1:123.456",
32
+ text: "make a deck",
33
+ senderId: "U123",
34
+ senderName: "U123",
35
+ platformContext: { teamId: "T123", channelId: "C1" },
36
+ timestamp: 1,
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ beforeEach(() => {
42
+ mocks.resolveLinkedOwner.mockResolvedValue(null);
43
+ mocks.consumeLinkToken.mockResolvedValue("owner@example.test");
44
+ mocks.resolveOrgIdForEmail.mockResolvedValue(null);
45
+ vi.stubGlobal(
46
+ "fetch",
47
+ vi.fn(async () => new Response(JSON.stringify({ ok: false }))),
48
+ );
49
+ });
50
+
51
+ afterEach(() => {
52
+ vi.unstubAllEnvs();
53
+ vi.unstubAllGlobals();
54
+ globalThis.fetch = originalFetch;
55
+ vi.clearAllMocks();
56
+ });
57
+
58
+ describe("identityKeyForIncoming", () => {
59
+ it("scopes Slack identities by team", () => {
60
+ expect(identityKeyForIncoming(slackIncoming())).toBe("T123:U123");
61
+ });
62
+ });
63
+
64
+ describe("resolveDispatchOwner", () => {
65
+ it("uses a linked identity before Slack email lookup", async () => {
66
+ mocks.resolveLinkedOwner.mockResolvedValueOnce("linked@example.test");
67
+ vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-token");
68
+
69
+ await expect(resolveDispatchOwner(slackIncoming())).resolves.toBe(
70
+ "linked@example.test",
71
+ );
72
+ expect(globalThis.fetch).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it("uses the verified Slack email for org members", async () => {
76
+ vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-token");
77
+ mocks.resolveOrgIdForEmail.mockResolvedValueOnce("org_123");
78
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce(
79
+ new Response(
80
+ JSON.stringify({
81
+ ok: true,
82
+ user: {
83
+ real_name: "Slack User",
84
+ profile: { email: "USER@EXAMPLE.TEST", display_name: "User" },
85
+ },
86
+ }),
87
+ ),
88
+ );
89
+
90
+ const incoming = slackIncoming();
91
+
92
+ await expect(resolveDispatchOwner(incoming)).resolves.toBe(
93
+ "user@example.test",
94
+ );
95
+ expect(incoming.senderEmail).toBe("user@example.test");
96
+ expect(incoming.senderName).toBe("User");
97
+ expect(incoming.platformContext.senderEmail).toBe("user@example.test");
98
+ });
99
+
100
+ it("falls back to the configured Slack owner when the sender is not an org member", async () => {
101
+ vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-token");
102
+ vi.stubEnv("DISPATCH_DEFAULT_OWNER_EMAIL", "default@example.test");
103
+ vi.mocked(globalThis.fetch).mockResolvedValueOnce(
104
+ new Response(
105
+ JSON.stringify({
106
+ ok: true,
107
+ user: { profile: { email: "guest@example.test" } },
108
+ }),
109
+ ),
110
+ );
111
+
112
+ await expect(resolveDispatchOwner(slackIncoming())).resolves.toBe(
113
+ "default@example.test",
114
+ );
115
+ });
116
+ });
@@ -2,9 +2,21 @@ import type {
2
2
  IncomingMessage,
3
3
  PlatformAdapter,
4
4
  } from "@agent-native/core/server";
5
+ import { resolveOrgIdForEmail } from "@agent-native/core/org";
5
6
  import crypto from "node:crypto";
6
7
  import { consumeLinkToken, resolveLinkedOwner } from "./dispatch-store.js";
7
8
 
9
+ type SlackSenderProfile = {
10
+ email: string | null;
11
+ name: string | null;
12
+ };
13
+
14
+ const slackProfileCache = new Map<
15
+ string,
16
+ { profile: SlackSenderProfile; expiresAt: number }
17
+ >();
18
+ const SLACK_PROFILE_CACHE_TTL = 10 * 60 * 1000;
19
+
8
20
  function contextString(value: unknown): string | null {
9
21
  if (typeof value === "string" && value.trim()) return value.trim();
10
22
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
@@ -62,6 +74,82 @@ function configuredDefaultOwnerForIncoming(
62
74
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : null;
63
75
  }
64
76
 
77
+ async function resolveSlackSenderProfile(
78
+ incoming: IncomingMessage,
79
+ ): Promise<SlackSenderProfile> {
80
+ if (incoming.platform !== "slack") return { email: null, name: null };
81
+ const token = process.env.SLACK_BOT_TOKEN;
82
+ const userId = contextString(incoming.senderId);
83
+ const teamId = contextString(incoming.platformContext.teamId);
84
+ if (!token || !userId) return { email: null, name: null };
85
+
86
+ // Slack user IDs are scoped per workspace, so without a teamId we can't
87
+ // safely cache: two installs of the bot in different workspaces could
88
+ // share user-id strings and collide on a single "default" key. Skip the
89
+ // cache (and lookup on every request) when teamId is missing.
90
+ const cacheKey = teamId ? `${teamId}:${userId}` : null;
91
+ if (cacheKey) {
92
+ const cached = slackProfileCache.get(cacheKey);
93
+ if (cached && cached.expiresAt > Date.now()) return cached.profile;
94
+ }
95
+
96
+ try {
97
+ const params = new URLSearchParams({ user: userId });
98
+ const res = await fetch(`https://slack.com/api/users.info?${params}`, {
99
+ headers: { Authorization: `Bearer ${token}` },
100
+ });
101
+ const data = (await res.json()) as {
102
+ ok?: boolean;
103
+ user?: {
104
+ real_name?: string;
105
+ name?: string;
106
+ profile?: {
107
+ email?: string;
108
+ real_name?: string;
109
+ display_name?: string;
110
+ };
111
+ };
112
+ };
113
+ const profile = data.ok
114
+ ? {
115
+ email: data.user?.profile?.email?.trim().toLowerCase() || null,
116
+ name:
117
+ data.user?.profile?.real_name?.trim() ||
118
+ data.user?.profile?.display_name?.trim() ||
119
+ data.user?.real_name?.trim() ||
120
+ data.user?.name?.trim() ||
121
+ null,
122
+ }
123
+ : { email: null, name: null };
124
+ if (cacheKey) {
125
+ slackProfileCache.set(cacheKey, {
126
+ profile,
127
+ expiresAt: Date.now() + SLACK_PROFILE_CACHE_TTL,
128
+ });
129
+ }
130
+ return profile;
131
+ } catch {
132
+ return { email: null, name: null };
133
+ }
134
+ }
135
+
136
+ async function resolveSlackOwnerFromVerifiedEmail(
137
+ incoming: IncomingMessage,
138
+ ): Promise<string | null> {
139
+ const profile = await resolveSlackSenderProfile(incoming);
140
+ if (!profile.email) return null;
141
+
142
+ incoming.senderEmail = profile.email;
143
+ incoming.platformContext.senderEmail = profile.email;
144
+ if (profile.name) {
145
+ incoming.senderName = profile.name;
146
+ incoming.platformContext.senderName = profile.name;
147
+ }
148
+
149
+ const orgId = await resolveOrgIdForEmail(profile.email);
150
+ return orgId ? profile.email : null;
151
+ }
152
+
65
153
  export async function resolveDispatchOwner(
66
154
  incoming: IncomingMessage,
67
155
  ): Promise<string> {
@@ -85,6 +173,15 @@ export async function resolveDispatchOwner(
85
173
  return incoming.senderId;
86
174
  }
87
175
 
176
+ // Slack gives us a user id in the event payload. Resolve it to a verified
177
+ // workspace email and use that user's own org context when they are an
178
+ // Agent-Native member, so artifacts created via @agent-native are visible
179
+ // when they open the target app.
180
+ if (incoming.platform === "slack") {
181
+ const slackOwner = await resolveSlackOwnerFromVerifiedEmail(incoming);
182
+ if (slackOwner) return slackOwner;
183
+ }
184
+
88
185
  const defaultOwner = configuredDefaultOwnerForIncoming(incoming);
89
186
  if (defaultOwner) return defaultOwner;
90
187