@vellumai/assistant 0.11.0-staging.2 → 0.11.0

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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Unit tests for `hasWebhookRoutingConfigured`'s resolution order (LUM-2882).
3
+ *
4
+ * The predicate has to agree with `handleWebhooksRegister` in
5
+ * `runtime/routes/webhook-routes.ts` tier for tier. Where they disagree, the
6
+ * status surfaces (channel readiness, the Telegram webhook health sweep) report
7
+ * a state the registration path contradicts, which hides a broken registration
8
+ * instead of surfacing it. The tier cases here mirror the ones in
9
+ * `runtime/routes/__tests__/webhook-routes.test.ts`.
10
+ */
11
+
12
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
13
+
14
+ let isPlatform = false;
15
+ let rawConfig: Record<string, unknown> = {};
16
+ let platformContextEnabled = false;
17
+
18
+ // Spread the real modules: these are broad barrels and replacing them wholesale
19
+ // breaks unrelated importers pulled in by the module under test.
20
+ const actualEnvRegistry = await import("../env-registry.js");
21
+ mock.module("../env-registry.js", () => ({
22
+ ...actualEnvRegistry,
23
+ getIsPlatform: () => isPlatform,
24
+ }));
25
+
26
+ const actualLoader = await import("../loader.js");
27
+ mock.module("../loader.js", () => ({
28
+ ...actualLoader,
29
+ loadRawConfig: () => rawConfig,
30
+ getConfig: () => rawConfig,
31
+ }));
32
+
33
+ const actualRegistration =
34
+ await import("../../inbound/platform-callback-registration.js");
35
+ mock.module("../../inbound/platform-callback-registration.js", () => ({
36
+ ...actualRegistration,
37
+ resolvePlatformCallbackRegistrationContext: async () => ({
38
+ isPlatform,
39
+ platformBaseUrl: "https://api.vellum.ai",
40
+ assistantId: platformContextEnabled ? "assistant-123" : "",
41
+ hasAssistantApiKey: platformContextEnabled,
42
+ authHeader: platformContextEnabled ? "Api-Key secret" : null,
43
+ enabled: platformContextEnabled,
44
+ }),
45
+ }));
46
+
47
+ const { hasIngressConfigured, hasWebhookRoutingConfigured } =
48
+ await import("../webhook-routing.js");
49
+
50
+ describe("hasWebhookRoutingConfigured resolution order", () => {
51
+ beforeEach(() => {
52
+ isPlatform = false;
53
+ rawConfig = {};
54
+ platformContextEnabled = false;
55
+ });
56
+
57
+ // ── Tier 1: platform pods ────────────────────────────────────────────────
58
+
59
+ test("platform pods use managed callbacks", async () => {
60
+ isPlatform = true;
61
+
62
+ expect(await hasWebhookRoutingConfigured(true)).toEqual({
63
+ configured: true,
64
+ usesManagedCallbacks: true,
65
+ });
66
+ });
67
+
68
+ test("platform pods report managed even with an ingress URL configured", async () => {
69
+ isPlatform = true;
70
+ rawConfig = { ingress: { publicBaseUrl: "https://tunnel.example.com" } };
71
+
72
+ // `handleWebhooksRegister` registers with the platform gateway before it
73
+ // ever reads the ingress config on a pod, so reporting the ingress URL
74
+ // here would name a URL no webhook is actually registered against.
75
+ expect(await hasWebhookRoutingConfigured(true)).toEqual({
76
+ configured: true,
77
+ usesManagedCallbacks: true,
78
+ });
79
+ });
80
+
81
+ // ── Tier 2: a configured ingress wins ────────────────────────────────────
82
+
83
+ test("ingress beats the platform-connected fallback", async () => {
84
+ platformContextEnabled = true;
85
+ rawConfig = { ingress: { publicBaseUrl: "https://tunnel.example.com" } };
86
+
87
+ // Any logged-in local assistant holds platform credentials for the LLM
88
+ // proxy, so credential presence must not reroute an explicitly configured
89
+ // self-hosted webhook through the platform.
90
+ expect(await hasWebhookRoutingConfigured(true)).toEqual({
91
+ configured: true,
92
+ usesManagedCallbacks: false,
93
+ });
94
+ });
95
+
96
+ test("the twilio option resolves ingress before the fallback too", async () => {
97
+ platformContextEnabled = true;
98
+ rawConfig = { ingress: { publicBaseUrl: "https://twilio.example.com" } };
99
+
100
+ expect(await hasWebhookRoutingConfigured(true, { twilio: true })).toEqual({
101
+ configured: true,
102
+ usesManagedCallbacks: false,
103
+ });
104
+ });
105
+
106
+ test("the twilio option falls back to managed callbacks with no ingress", async () => {
107
+ platformContextEnabled = true;
108
+
109
+ expect(await hasWebhookRoutingConfigured(true, { twilio: true })).toEqual({
110
+ configured: true,
111
+ usesManagedCallbacks: true,
112
+ });
113
+ });
114
+
115
+ // ── Tier 3: platform-connected fallback ──────────────────────────────────
116
+
117
+ test("a platform-connected assistant with no ingress uses managed callbacks", async () => {
118
+ platformContextEnabled = true;
119
+
120
+ // The LUM-2882 case: `webhooks register` hands back a platform callback
121
+ // URL here, so the status surfaces must not report missing ingress.
122
+ expect(await hasWebhookRoutingConfigured(true)).toEqual({
123
+ configured: true,
124
+ usesManagedCallbacks: true,
125
+ });
126
+ });
127
+
128
+ test("the fallback applies when the ingress URL is present but empty", async () => {
129
+ platformContextEnabled = true;
130
+ rawConfig = { ingress: { publicBaseUrl: "" } };
131
+
132
+ expect(await hasWebhookRoutingConfigured(true)).toEqual({
133
+ configured: true,
134
+ usesManagedCallbacks: true,
135
+ });
136
+ });
137
+
138
+ test("an explicit ingress.enabled false blocks the fallback", async () => {
139
+ platformContextEnabled = true;
140
+ rawConfig = { ingress: { enabled: false } };
141
+
142
+ // Opting out is a decision not to accept inbound webhooks at all, not an
143
+ // invitation to route them through the platform instead.
144
+ expect(await hasWebhookRoutingConfigured(true)).toEqual({
145
+ configured: false,
146
+ usesManagedCallbacks: false,
147
+ });
148
+ });
149
+
150
+ test("ingress.enabled false blocks the fallback even with a URL configured", async () => {
151
+ platformContextEnabled = true;
152
+ rawConfig = {
153
+ ingress: { enabled: false, publicBaseUrl: "https://tunnel.example.com" },
154
+ };
155
+
156
+ expect(await hasWebhookRoutingConfigured(true)).toEqual({
157
+ configured: false,
158
+ usesManagedCallbacks: false,
159
+ });
160
+ });
161
+
162
+ // ── Tier 4: nothing configured ───────────────────────────────────────────
163
+
164
+ test("no ingress and no platform connectivity is not configured", async () => {
165
+ expect(await hasWebhookRoutingConfigured(true)).toEqual({
166
+ configured: false,
167
+ usesManagedCallbacks: false,
168
+ });
169
+ });
170
+
171
+ // ── allowManagedCallbacks gating ─────────────────────────────────────────
172
+
173
+ test("allowManagedCallbacks false hides the platform-connected fallback", async () => {
174
+ platformContextEnabled = true;
175
+
176
+ // Channels that can only be served by a self-hosted ingress pass `false`
177
+ // and must never be told a managed route stands in for one.
178
+ expect(await hasWebhookRoutingConfigured(false)).toEqual({
179
+ configured: false,
180
+ usesManagedCallbacks: false,
181
+ });
182
+ });
183
+
184
+ test("allowManagedCallbacks false hides the platform-pod tier", async () => {
185
+ isPlatform = true;
186
+
187
+ expect(await hasWebhookRoutingConfigured(false)).toEqual({
188
+ configured: false,
189
+ usesManagedCallbacks: false,
190
+ });
191
+ });
192
+
193
+ test("allowManagedCallbacks false still honors a configured ingress", async () => {
194
+ rawConfig = { ingress: { publicBaseUrl: "https://tunnel.example.com" } };
195
+
196
+ expect(await hasWebhookRoutingConfigured(false)).toEqual({
197
+ configured: true,
198
+ usesManagedCallbacks: false,
199
+ });
200
+ });
201
+ });
202
+
203
+ describe("hasIngressConfigured", () => {
204
+ beforeEach(() => {
205
+ isPlatform = false;
206
+ rawConfig = {};
207
+ platformContextEnabled = false;
208
+ });
209
+
210
+ test("is unaffected by platform connectivity", () => {
211
+ platformContextEnabled = true;
212
+
213
+ expect(hasIngressConfigured()).toBe(false);
214
+ });
215
+
216
+ test("treats an unset enabled flag with a URL as enabled", () => {
217
+ rawConfig = { ingress: { publicBaseUrl: "https://tunnel.example.com" } };
218
+
219
+ expect(hasIngressConfigured()).toBe(true);
220
+ });
221
+
222
+ test("treats an explicit enabled false as not configured", () => {
223
+ rawConfig = {
224
+ ingress: { enabled: false, publicBaseUrl: "https://tunnel.example.com" },
225
+ };
226
+
227
+ expect(hasIngressConfigured()).toBe(false);
228
+ });
229
+ });
@@ -14,8 +14,10 @@ import {
14
14
  resolveTwilioPublicBaseUrl,
15
15
  } from "@vellumai/service-contracts/twilio-ingress";
16
16
 
17
+ import { resolvePlatformCallbackRegistrationContext } from "../inbound/platform-callback-registration.js";
18
+ import { isPublicIngressDisabled } from "../inbound/public-ingress-urls.js";
17
19
  import { getIsPlatform } from "./env-registry.js";
18
- import { loadRawConfig } from "./loader.js";
20
+ import { getConfig, loadRawConfig } from "./loader.js";
19
21
 
20
22
  /**
21
23
  * True when a public ingress base URL is set and ingress is enabled.
@@ -43,25 +45,64 @@ export function hasIngressConfigured(
43
45
  }
44
46
 
45
47
  /**
46
- * True when inbound webhooks have somewhere to land: either a self-hosted
47
- * public ingress URL, or — when `allowManagedCallbacks` is set and this is a
48
- * platform deployment the platform's managed callback routes.
48
+ * True when the user has explicitly switched public ingress off.
49
+ *
50
+ * Reads the validated config rather than the raw file so the check matches
51
+ * what `getPublicBaseUrl` enforces. A config that fails to load is treated as
52
+ * "not explicitly disabled": absence of a decision is not an opt-out.
49
53
  */
50
- export function hasWebhookRoutingConfigured(
54
+ function isIngressExplicitlyDisabled(): boolean {
55
+ try {
56
+ return isPublicIngressDisabled(getConfig());
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * True when inbound webhooks have somewhere to land.
64
+ *
65
+ * Mirrors the resolution order `handleWebhooksRegister` uses in
66
+ * `runtime/routes/webhook-routes.ts`, because the two must agree: a probe that
67
+ * reports "no ingress" while `webhooks register` hands back a working callback
68
+ * URL hides a broken registration instead of surfacing it.
69
+ *
70
+ * 1. **Platform pods** (`IS_PLATFORM`) always use managed callbacks.
71
+ * 2. **A configured public ingress wins** for everyone else.
72
+ * 3. **Platform-connected assistants with no ingress** fall back to managed
73
+ * callbacks. Connectivity is decided by credentials (platform base URL +
74
+ * assistant ID + assistant API key), not by `IS_PLATFORM`, which is only
75
+ * ever true on a platform pod.
76
+ * 4. Otherwise nothing is configured.
77
+ *
78
+ * Ingress deliberately precedes the platform fallback: any logged-in local
79
+ * assistant holds platform credentials for the LLM proxy, so treating
80
+ * credential presence as "managed" would misreport an explicitly configured
81
+ * self-hosted webhook as platform-routed. An explicit `ingress.enabled: false`
82
+ * is a decision not to accept inbound webhooks at all and blocks the fallback.
83
+ *
84
+ * `allowManagedCallbacks` gates both platform tiers: channels that can only be
85
+ * served by a self-hosted ingress pass `false` and never see them.
86
+ */
87
+ export async function hasWebhookRoutingConfigured(
51
88
  allowManagedCallbacks = false,
52
89
  options: { twilio?: boolean } = {},
53
- ): {
90
+ ): Promise<{
54
91
  configured: boolean;
55
92
  usesManagedCallbacks: boolean;
56
- } {
57
- const ingressConfigured = hasIngressConfigured(options);
58
- if (ingressConfigured) {
93
+ }> {
94
+ if (allowManagedCallbacks && getIsPlatform()) {
95
+ return { configured: true, usesManagedCallbacks: true };
96
+ }
97
+
98
+ if (hasIngressConfigured(options)) {
59
99
  return { configured: true, usesManagedCallbacks: false };
60
100
  }
61
101
 
62
- const usesManagedCallbacks = allowManagedCallbacks && getIsPlatform();
63
- return {
64
- configured: usesManagedCallbacks,
65
- usesManagedCallbacks,
66
- };
102
+ if (!allowManagedCallbacks || isIngressExplicitlyDisabled()) {
103
+ return { configured: false, usesManagedCallbacks: false };
104
+ }
105
+
106
+ const { enabled } = await resolvePlatformCallbackRegistrationContext();
107
+ return { configured: enabled, usesManagedCallbacks: enabled };
67
108
  }
@@ -1,12 +1,12 @@
1
1
  import { z } from "zod";
2
2
 
3
- import { getIsPlatform } from "../../config/env-registry.js";
4
3
  import {
5
4
  invalidateConfigCache,
6
5
  loadRawConfig,
7
6
  saveRawConfig,
8
7
  setNestedValue,
9
8
  } from "../../config/loader.js";
9
+ import { hasWebhookRoutingConfigured } from "../../config/webhook-routing.js";
10
10
  import { registerCallbackRoute } from "../../inbound/platform-callback-registration.js";
11
11
  import {
12
12
  ensureManualTokenConnection,
@@ -249,12 +249,17 @@ export async function setTelegramConfig(
249
249
  hasWebhookSecret,
250
250
  };
251
251
 
252
- // When containerized with a platform, register the Telegram callback
253
- // route so the platform knows how to forward Telegram webhooks.
254
- // This must happen independently of effectiveUrl in containerized
255
- // deployments without ingress.publicBaseUrl, platform callbacks are the
256
- // only way to receive Telegram webhooks.
257
- if (getIsPlatform()) {
252
+ // Register the Telegram callback route so the platform knows how to
253
+ // forward Telegram webhooks. This applies whenever webhooks are delivered
254
+ // via managed callbacks: platform pods, and platform-connected local
255
+ // assistants with no public ingress. For both, platform callbacks are the
256
+ // only way to receive Telegram webhooks. `hasWebhookRoutingConfigured`
257
+ // encodes the resolution order (a configured ingress wins; an explicit
258
+ // `ingress.enabled: false` blocks the platform fallback) and is the same
259
+ // derivation the Telegram status checks read, so registration and reported
260
+ // status stay in agreement.
261
+ const { usesManagedCallbacks } = await hasWebhookRoutingConfigured(true);
262
+ if (usesManagedCallbacks) {
258
263
  registerCallbackRoute("webhooks/telegram", "telegram").catch((err) => {
259
264
  log.warn({ err }, "Failed to register Telegram platform callback route");
260
265
  });
@@ -21,6 +21,7 @@ import { getIsPlatform } from "../config/env-registry.js";
21
21
  import { credentialKey } from "../security/credential-key.js";
22
22
  import { getSecureKeyAsync } from "../security/secure-keys.js";
23
23
  import { getLogger } from "../util/logger.js";
24
+ import { PublicIngressDisabledError } from "./public-ingress-urls.js";
24
25
 
25
26
  const log = getLogger("platform-callback-registration");
26
27
 
@@ -151,18 +152,35 @@ export async function registerCallbackRoute(
151
152
  }
152
153
 
153
154
  /**
154
- * Resolve a callback URL, registering with the platform when platform-managed.
155
+ * Resolve a callback URL, registering with the platform when appropriate.
155
156
  *
156
- * When platform callbacks are enabled, registers the route and returns the
157
- * platform's stable callback URL (optionally with query parameters appended).
158
- * Otherwise evaluates the lazy direct URL supplier and returns that value.
157
+ * Resolution order, matching `handleWebhooksRegister` in
158
+ * `runtime/routes/webhook-routes.ts` and `hasWebhookRoutingConfigured` in
159
+ * `config/webhook-routing.ts`:
160
+ *
161
+ * 1. **Platform pods** (`IS_PLATFORM`) always register with the platform
162
+ * gateway: they have no ingress of their own to advertise.
163
+ * 2. **A configured public ingress wins** for everyone else, so the direct
164
+ * supplier is tried first and its value returned when it resolves.
165
+ * 3. **Platform-connected assistants with no ingress** register with the
166
+ * platform gateway rather than surfacing the direct builder's error.
167
+ * Connectivity is decided by credentials (platform base URL + assistant
168
+ * ID + assistant API key), not by `IS_PLATFORM`, which is only ever true
169
+ * on a platform pod.
170
+ *
171
+ * An explicit `ingress.enabled: false` is a decision not to accept inbound
172
+ * webhooks at all, so `PublicIngressDisabledError` propagates instead of being
173
+ * routed around. Ingress precedes the platform fallback because any logged-in
174
+ * local assistant holds platform credentials for the LLM proxy: treating
175
+ * credential presence as "managed" would silently reroute an explicitly
176
+ * configured self-hosted callback through the platform.
159
177
  *
160
178
  * The `directUrl` parameter is a **lazy supplier** (a function returning a
161
179
  * string) rather than an eagerly-evaluated string. This is critical because
162
180
  * the direct URL builders (e.g. `getTwilioVoiceWebhookUrl`) call
163
181
  * `getPublicBaseUrl()` which throws when no public ingress URL is configured.
164
- * In platform-managed environments that rely solely on platform callbacks, the
165
- * direct URL is never needed — deferring evaluation avoids the throw.
182
+ * On a platform pod the direct URL is never needed, and deferring evaluation
183
+ * avoids the throw.
166
184
  *
167
185
  * @param directUrl - Lazy supplier for the direct callback URL.
168
186
  * @param callbackPath - The path to register (e.g. "webhooks/twilio/voice").
@@ -179,7 +197,22 @@ export async function resolveCallbackUrl(
179
197
  sourceIdentifier?: string,
180
198
  ): Promise<string> {
181
199
  if (!getIsPlatform()) {
182
- return directUrl();
200
+ let ingressError: unknown;
201
+ try {
202
+ return directUrl();
203
+ } catch (err) {
204
+ if (err instanceof PublicIngressDisabledError) {
205
+ throw err;
206
+ }
207
+ ingressError = err;
208
+ }
209
+
210
+ // No ingress configured. Fall back to the platform gateway when this
211
+ // assistant is connected to the platform.
212
+ const context = await resolvePlatformCallbackRegistrationContext();
213
+ if (!context.enabled) {
214
+ throw ingressError;
215
+ }
183
216
  }
184
217
 
185
218
  try {
@@ -191,9 +224,10 @@ export async function resolveCallbackUrl(
191
224
  }
192
225
  return url;
193
226
  } catch (err) {
194
- // In platform-managed mode there is no local-ingress fallback and
195
- // ngrok is not applicable. Surface a clear error so callers (and the
196
- // user) understand this is a platform-side issue, not a tunnel problem.
227
+ // Registration is only attempted once the local ingress has been ruled
228
+ // out, so there is nothing left to fall back to. Surface a clear error so
229
+ // callers (and the user) understand this is a platform-side issue, not a
230
+ // tunnel problem.
197
231
  const detail = err instanceof Error ? err.message : String(err);
198
232
  throw new Error(
199
233
  `Managed callback route registration failed: ${detail}. ` +
@@ -43,11 +43,41 @@ export interface IngressConfig {
43
43
  };
44
44
  }
45
45
 
46
- function assertPublicIngressEnabled(config: IngressConfig): void {
47
- if (config.ingress?.enabled === false) {
48
- throw new Error(
46
+ /**
47
+ * True when the user has explicitly switched public ingress off.
48
+ *
49
+ * An explicit opt-out is a decision not to accept inbound webhooks at all, so
50
+ * it must not be silently routed around via platform callbacks. An *absent*
51
+ * ingress config is merely "not set up yet" and is eligible for the platform
52
+ * fallback, so only a literal `false` counts here.
53
+ *
54
+ * Every consumer that offers a platform-callback fallback has to consult this
55
+ * before falling back, which is why it lives here rather than in each caller.
56
+ */
57
+ export function isPublicIngressDisabled(config: IngressConfig): boolean {
58
+ return config.ingress?.enabled === false;
59
+ }
60
+
61
+ /**
62
+ * Thrown when a URL builder is asked for a URL while ingress is opted out.
63
+ *
64
+ * Distinct from the "no URL configured" error so callers with a
65
+ * platform-callback fallback can tell the two apart: "not set up yet" is
66
+ * eligible for the fallback, an explicit opt-out is not. Matching on the
67
+ * message text would break the moment the copy is reworded.
68
+ */
69
+ export class PublicIngressDisabledError extends Error {
70
+ constructor() {
71
+ super(
49
72
  "Public ingress is disabled. Ask the assistant to enable it, or update it from the Settings page.",
50
73
  );
74
+ this.name = "PublicIngressDisabledError";
75
+ }
76
+ }
77
+
78
+ function assertPublicIngressEnabled(config: IngressConfig): void {
79
+ if (isPublicIngressDisabled(config)) {
80
+ throw new PublicIngressDisabledError();
51
81
  }
52
82
  }
53
83
 
@@ -66,11 +96,15 @@ export function getPublicBaseUrl(config: IngressConfig): string {
66
96
 
67
97
  const ingressValue = config.ingress?.publicBaseUrl;
68
98
  const normalizedIngressValue = normalizePublicBaseUrl(ingressValue);
69
- if (normalizedIngressValue) return normalizedIngressValue;
99
+ if (normalizedIngressValue) {
100
+ return normalizedIngressValue;
101
+ }
70
102
 
71
103
  const ingressEnvValue = getIngressPublicBaseUrl();
72
104
  const normalizedIngressEnvValue = normalizePublicBaseUrl(ingressEnvValue);
73
- if (normalizedIngressEnvValue) return normalizedIngressEnvValue;
105
+ if (normalizedIngressEnvValue) {
106
+ return normalizedIngressEnvValue;
107
+ }
74
108
 
75
109
  throw new Error(
76
110
  "No public base URL configured. Set ingress.publicBaseUrl in config.",
@@ -90,10 +124,7 @@ export function getTwilioVoiceWebhookUrl(
90
124
  config: IngressConfig,
91
125
  callSessionId?: string,
92
126
  ): string {
93
- return buildTwilioVoiceWebhookUrl(
94
- getPublicBaseUrl(config),
95
- callSessionId,
96
- );
127
+ return buildTwilioVoiceWebhookUrl(getPublicBaseUrl(config), callSessionId);
97
128
  }
98
129
 
99
130
  /**
@@ -3,9 +3,9 @@ import { beforeEach, describe, expect, test } from "bun:test";
3
3
  import { setConfig } from "../../../__tests__/helpers/set-config.js";
4
4
  import type { ExternalConversationBinding } from "../../../persistence/external-conversation-store.js";
5
5
 
6
- // Seed the Slack workspace identity that the deep-link builders read. An
7
- // empty `teamUrl` (schema default) makes the URL builders return undefined,
8
- // standing in for the "no slack config" case.
6
+ // Seed the Slack workspace identity that the deep-link builders read. Empty
7
+ // `teamId`/`teamUrl` (the schema defaults) stand in for installs that never
8
+ // learned their workspace identity.
9
9
  function seedSlack(
10
10
  overrides: { teamId?: string; teamUrl?: string } = {},
11
11
  ): void {
@@ -55,11 +55,40 @@ describe("buildSlackBindingMetadata sourceLink", () => {
55
55
  });
56
56
  });
57
57
 
58
- test("omits sourceLink entirely without slack config", () => {
59
- // An empty teamUrl is the schema default — the URL builders return
60
- // undefined, so no source link is produced.
61
- seedSlack({ teamUrl: "" });
58
+ test("falls back to workspace-agnostic slack.com links without a teamUrl", () => {
59
+ // An empty teamUrl is the schema default — e.g. installs whose Slack
60
+ // connection came through the gateway and never ran the local bot-token
61
+ // setup. Links must still be produced via the slack.com permalink form.
62
+ seedSlack({ teamId: "", teamUrl: "" });
62
63
  const metadata = buildSlackBindingMetadata(makeBinding({}));
63
- expect(metadata.sourceLink).toBeUndefined();
64
+ expect(metadata.sourceLink).toEqual({
65
+ webUrl: "https://slack.com/archives/C0CHANNEL",
66
+ });
67
+ expect(metadata.slackChannel?.link).toEqual({
68
+ webUrl: "https://slack.com/archives/C0CHANNEL",
69
+ });
70
+ });
71
+
72
+ test("thread link falls back to the slack.com permalink without a teamUrl", () => {
73
+ seedSlack({ teamId: "", teamUrl: "" });
74
+ const metadata = buildSlackBindingMetadata(
75
+ makeBinding({ externalThreadId: "1720000000.000100" }),
76
+ );
77
+ expect(metadata.slackThread?.link).toEqual({
78
+ webUrl: "https://slack.com/archives/C0CHANNEL/p1720000000000100",
79
+ });
80
+ expect(metadata.sourceLink).toEqual(metadata.slackThread?.link);
81
+ });
82
+
83
+ test("keeps the slack:// app link when only the teamUrl is missing", () => {
84
+ seedSlack({ teamUrl: "" });
85
+ const metadata = buildSlackBindingMetadata(
86
+ makeBinding({ externalThreadId: "1720000000.000100" }),
87
+ );
88
+ expect(metadata.slackThread?.link).toEqual({
89
+ appUrl:
90
+ "slack://channel?team=T0EXAMPLE&id=C0CHANNEL&message=1720000000.000100",
91
+ webUrl: "https://slack.com/archives/C0CHANNEL/p1720000000000100",
92
+ });
64
93
  });
65
94
  });
@@ -16,8 +16,8 @@ import {
16
16
  * (`ChannelBindingMetadata`) — the single source of truth that also drives
17
17
  * `openapi.yaml` and the web client's generated types — so this builder cannot
18
18
  * drift from the wire contract. Slack is the only channel that can currently
19
- * produce message-level deep links, because the link inputs (workspace team
20
- * id/url + a stable per-message timestamp) only exist for Slack.
19
+ * produce message-level deep links, because the link inputs (a channel id and
20
+ * a stable per-message timestamp) only exist for Slack.
21
21
  */
22
22
  export function buildSlackBindingMetadata(
23
23
  binding: ExternalConversationBinding,
@@ -26,15 +26,18 @@ export function buildSlackBindingMetadata(
26
26
  binding.externalChatName?.trim() || binding.externalChatId;
27
27
  const slackConfig = getConfig().slack;
28
28
 
29
- const threadLink =
30
- slackConfig && binding.externalThreadId
31
- ? buildSlackMessageDeepLinks({
32
- teamId: slackConfig.teamId,
33
- teamUrl: slackConfig.teamUrl,
34
- channelId: binding.externalChatId,
35
- messageTs: binding.externalThreadId,
36
- })
37
- : undefined;
29
+ // The deep-link builders fall back to workspace-agnostic slack.com URLs
30
+ // when the workspace identity (teamId/teamUrl) is not configured — e.g.
31
+ // installs whose Slack connection came through the gateway and never ran
32
+ // the local bot-token setup — so links are always produced.
33
+ const threadLink = binding.externalThreadId
34
+ ? buildSlackMessageDeepLinks({
35
+ teamId: slackConfig?.teamId,
36
+ teamUrl: slackConfig?.teamUrl,
37
+ channelId: binding.externalChatId,
38
+ messageTs: binding.externalThreadId,
39
+ })
40
+ : undefined;
38
41
  const slackThread = binding.externalThreadId
39
42
  ? {
40
43
  channelId: binding.externalChatId,
@@ -43,17 +46,14 @@ export function buildSlackBindingMetadata(
43
46
  }
44
47
  : undefined;
45
48
 
46
- const channelWebUrl = slackConfig
47
- ? buildSlackWebChannelUrl({
48
- teamUrl: slackConfig.teamUrl,
49
- channelId: binding.externalChatId,
50
- })
51
- : undefined;
49
+ const channelWebUrl = buildSlackWebChannelUrl({
50
+ teamUrl: slackConfig?.teamUrl,
51
+ channelId: binding.externalChatId,
52
+ });
52
53
 
53
54
  // Channel-neutral source link: prefer the bound thread, fall back to the
54
55
  // channel, so clients land on the most specific source available.
55
- const sourceLink =
56
- threadLink ?? (channelWebUrl ? { webUrl: channelWebUrl } : undefined);
56
+ const sourceLink = threadLink ?? { webUrl: channelWebUrl };
57
57
 
58
58
  return {
59
59
  externalChatName,
@@ -61,8 +61,8 @@ export function buildSlackBindingMetadata(
61
61
  slackChannel: {
62
62
  channelId: binding.externalChatId,
63
63
  name: externalChatName,
64
- ...(channelWebUrl ? { link: { webUrl: channelWebUrl } } : {}),
64
+ link: { webUrl: channelWebUrl },
65
65
  },
66
- ...(sourceLink ? { sourceLink } : {}),
66
+ sourceLink,
67
67
  };
68
68
  }