@vellumai/assistant 0.11.8-staging.1 → 0.11.8

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 (48) hide show
  1. package/docs/guardian-request-flow.md +3 -3
  2. package/openapi.yaml +0 -24
  3. package/package.json +1 -1
  4. package/src/__tests__/access-request-seed-content-blocks.test.ts +2 -2
  5. package/src/__tests__/anthropic-provider.test.ts +2 -0
  6. package/src/__tests__/channel-availability-routes.test.ts +15 -0
  7. package/src/__tests__/channel-readiness-routes.test.ts +96 -3
  8. package/src/__tests__/channel-readiness-service.test.ts +19 -4
  9. package/src/__tests__/config-loader-backfill.test.ts +2 -2
  10. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  11. package/src/__tests__/email-invite-adapter.test.ts +53 -12
  12. package/src/__tests__/external-binding-chat-name.test.ts +60 -0
  13. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +28 -0
  14. package/src/__tests__/managed-profile-guard.test.ts +1 -1
  15. package/src/__tests__/model-intents.test.ts +2 -2
  16. package/src/__tests__/pricing.test.ts +13 -0
  17. package/src/__tests__/tool-approval-seed-content-blocks.test.ts +16 -3
  18. package/src/api/responses/home.ts +0 -6
  19. package/src/config/__tests__/default-profile-catalog.test.ts +1 -1
  20. package/src/config/call-site-defaults.ts +1 -1
  21. package/src/config/default-profile-catalog.ts +12 -12
  22. package/src/config/default-profile-names.ts +2 -2
  23. package/src/email/byo-email-credential.test.ts +39 -0
  24. package/src/email/byo-email-credential.ts +76 -0
  25. package/src/email/registered-inbox.test.ts +272 -0
  26. package/src/email/registered-inbox.ts +169 -0
  27. package/src/messaging/providers/slack/approval-source.test.ts +70 -0
  28. package/src/messaging/providers/slack/approval-source.ts +18 -5
  29. package/src/notifications/__tests__/guardian-feed-projection.test.ts +13 -14
  30. package/src/notifications/approval-card-data.ts +13 -5
  31. package/src/notifications/emit-signal.ts +5 -8
  32. package/src/notifications/guardian-feed-projection.ts +9 -74
  33. package/src/notifications/guardian-question-mode.ts +33 -1
  34. package/src/notifications/home-feed-side-effect.ts +0 -4
  35. package/src/persistence/external-conversation-store.ts +5 -1
  36. package/src/providers/anthropic/client.ts +1 -1
  37. package/src/providers/model-catalog.ts +54 -0
  38. package/src/providers/model-intents.ts +3 -3
  39. package/src/runtime/approval-source-link.ts +6 -0
  40. package/src/runtime/channel-invite-transports/email.ts +10 -9
  41. package/src/runtime/channel-readiness-service.ts +83 -23
  42. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +1 -1
  43. package/src/runtime/routes/channel-availability-routes.ts +18 -31
  44. package/src/runtime/routes/credential-routes.ts +3 -0
  45. package/src/runtime/routes/email-routes.ts +35 -47
  46. package/src/runtime/verification-outbound-actions.ts +8 -12
  47. package/src/tools/credentials/store.ts +3 -0
  48. package/src/util/pricing.ts +2 -1
@@ -164,14 +164,8 @@ export const FeedItemGuardianRequestSchema = z.object({
164
164
  sourceContextLabel: z.string().optional(),
165
165
  /** Permalink to the originating channel message, when derivable. */
166
166
  sourceUrl: z.string().optional(),
167
- /** Web URL of the Slack guardian-DM approval card ("Open in Slack"). */
168
- slackCardUrl: z.string().optional(),
169
- /** slack:// deep link for the same card, preferred on devices with the app. */
170
- slackCardAppUrl: z.string().optional(),
171
167
  /** Action that resolved the request, for terminal statuses. */
172
168
  decidedAction: z.string().optional(),
173
- /** Display label of the decider, when the decision came from a person. */
174
- decidedByLabel: z.string().optional(),
175
169
  /** ISO-8601 time the request reached its terminal status. */
176
170
  decidedAt: z.string().optional(),
177
171
  /**
@@ -440,7 +440,7 @@ describe("resolveDefaultProfileForProvider", () => {
440
440
  expect(entry?.provider_connection).toBeUndefined();
441
441
  expect(entry?.source).toBe("managed");
442
442
  }
443
- // Cost and Speed both opt fully out of reasoning.
443
+ // Budget and Fast both opt fully out of reasoning.
444
444
  expect(effective["cost-optimized"]?.effort).toBe("none");
445
445
  expect(effective["latency-optimized"]?.effort).toBe("none");
446
446
  expect(effective.balanced?.thinking?.enabled).toBe(true);
@@ -133,7 +133,7 @@ export const CALL_SITE_DEFAULTS: Record<LLMCallSite, CallSiteDefaultConfig> = {
133
133
  // default-profile-catalog.ts): managed installs get the pinned latency model,
134
134
  // BYOK installs resolve their own provider's latency model through the intent
135
135
  // table rather than a model id they may hold no credential for. The profile
136
- // is user-facing ("Speed"), so a user edit to it moves this call site too.
136
+ // is user-facing ("Fast"), so a user edit to it moves this call site too.
137
137
  voiceProgressNarration: {
138
138
  profile: "latency-optimized",
139
139
  effort: "low",
@@ -117,7 +117,7 @@ const VELLUM_PROFILE_IMPLS: ProfileImpls = {
117
117
  model: "accounts/fireworks/models/deepseek-v4-flash-0731",
118
118
  provider: "vellum",
119
119
  source: "managed",
120
- label: "Cost",
120
+ label: "Budget",
121
121
  // Tier intent only - never name the concrete model here. Clients
122
122
  // surface the live model beside the description, so a model name in
123
123
  // this copy would go stale the moment the pin moves.
@@ -150,7 +150,7 @@ const VELLUM_PROFILE_IMPLS: ProfileImpls = {
150
150
  model: "gpt-5.6-luna",
151
151
  provider: "vellum",
152
152
  source: "managed",
153
- label: "Speed",
153
+ label: "Fast",
154
154
  description: "Fastest responses, with reasoning turned off",
155
155
  fallbackProfile: FALLBACK_PROFILE_BY_KEY["latency-optimized"],
156
156
  maxTokens: 8192,
@@ -211,10 +211,10 @@ const BACKUP_PROFILE_IMPLS: Record<BackupProfileKey, DefaultProfileTemplate> = {
211
211
  model: "gemini-3.1-flash-lite",
212
212
  provider: "vellum",
213
213
  source: "managed",
214
- label: "Cost Backup",
215
- description: "Automatic backup for the Cost profile",
214
+ label: "Budget Backup",
215
+ description: "Automatic backup for the Budget profile",
216
216
  maxTokens: 8192,
217
- // Explicit reasoning opt-out, matching the primary Cost profile: OpenAI-
217
+ // Explicit reasoning opt-out, matching the primary Budget profile: OpenAI-
218
218
  // compat APIs default reasoning to "medium" when the field is omitted,
219
219
  // and effort-driven providers encode disabled thinking through this same
220
220
  // knob (see DISABLED_THINKING_USES_EFFORT_PROVIDERS in
@@ -229,8 +229,8 @@ const BACKUP_PROFILE_IMPLS: Record<BackupProfileKey, DefaultProfileTemplate> = {
229
229
  model: "claude-haiku-4-5-20251001",
230
230
  provider: "vellum",
231
231
  source: "managed",
232
- label: "Speed Backup",
233
- description: "Automatic backup for the Speed profile",
232
+ label: "Fast Backup",
233
+ description: "Automatic backup for the Fast profile",
234
234
  maxTokens: 8192,
235
235
  effort: "low",
236
236
  thinking: { enabled: false, streamThinking: false },
@@ -286,7 +286,7 @@ function managedProfileImpl(key: DefaultProfileKey): DefaultProfileTemplate {
286
286
  * `chatgpt-subscription` row via `resolveRoutingIdentity` with no pinned
287
287
  * connection. Models are pinned (never intents): the intent tables are
288
288
  * keyed by concrete dispatch providers, and the Codex endpoint serves only
289
- * `CODEX_SUBSCRIPTION_MODEL_IDS`. Cost and Speed are identical
289
+ * `CODEX_SUBSCRIPTION_MODEL_IDS`. Budget and Fast are identical
290
290
  * implementations here: the subscription serves no tier cheaper or faster
291
291
  * than luna, and both profiles advertise reasoning off.
292
292
  */
@@ -319,7 +319,7 @@ const CHATGPT_PROFILE_IMPLS: ProfileImpls = {
319
319
  model: "gpt-5.6-luna",
320
320
  provider: "chatgpt",
321
321
  source: "managed",
322
- label: "Cost",
322
+ label: "Budget",
323
323
  description: "Cheapest responses, for high-volume work",
324
324
  maxTokens: 8192,
325
325
  effort: "none",
@@ -330,7 +330,7 @@ const CHATGPT_PROFILE_IMPLS: ProfileImpls = {
330
330
  model: "gpt-5.6-luna",
331
331
  provider: "chatgpt",
332
332
  source: "managed",
333
- label: "Speed",
333
+ label: "Fast",
334
334
  description: "Fastest responses, with reasoning turned off",
335
335
  maxTokens: 8192,
336
336
  // Explicit reasoning opt-out, matching the other columns: this profile
@@ -381,7 +381,7 @@ const BYOK_PROFILE_IMPLS: Record<
381
381
  "cost-optimized": {
382
382
  intent: "cost-optimized",
383
383
  source: "user",
384
- label: "Cost",
384
+ label: "Budget",
385
385
  description: "Cheapest responses, for high-volume work",
386
386
  maxTokens: 8192,
387
387
  effort: "none",
@@ -391,7 +391,7 @@ const BYOK_PROFILE_IMPLS: Record<
391
391
  "latency-optimized": {
392
392
  intent: "latency-optimized",
393
393
  source: "user",
394
- label: "Speed",
394
+ label: "Fast",
395
395
  description: "Fastest responses, with reasoning turned off",
396
396
  maxTokens: 8192,
397
397
  effort: "none",
@@ -11,8 +11,8 @@
11
11
  *
12
12
  * A key is an on-disk contract: `llm.callSites.*.profile`, `activeProfile`,
13
13
  * mix arms and schedule pins all reference it, so a key is fixed regardless of
14
- * the label its profile carries. `cost-optimized` is labelled "Cost" and
15
- * `latency-optimized` is labelled "Speed" (see `default-profile-catalog.ts`).
14
+ * the label its profile carries. `cost-optimized` is labelled "Budget" and
15
+ * `latency-optimized` is labelled "Fast" (see `default-profile-catalog.ts`).
16
16
  *
17
17
  * `latency-optimized` also backs the live-voice front model, which is why it
18
18
  * exists as a profile rather than a raw model pin on the call site: a pin
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A stored or deleted BYO email provider credential must drop the readiness
3
+ * service's cached email snapshot: the BYO check lives in the TTL-cached
4
+ * remote bucket, so without the invalidation the channel badge serves the
5
+ * pre-write verdict for the rest of the TTL.
6
+ */
7
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
8
+
9
+ let invalidatedChannels: string[];
10
+
11
+ mock.module("../daemon/handlers/config-channels.js", () => ({
12
+ getReadinessService: () => ({
13
+ invalidateChannel: (channel: string) => {
14
+ invalidatedChannels.push(channel);
15
+ },
16
+ }),
17
+ }));
18
+
19
+ import { invalidateEmailReadinessForByoCredential } from "./byo-email-credential.js";
20
+
21
+ describe("invalidateEmailReadinessForByoCredential", () => {
22
+ beforeEach(() => {
23
+ invalidatedChannels = [];
24
+ });
25
+
26
+ test("invalidates the email channel for each BYO provider", async () => {
27
+ await invalidateEmailReadinessForByoCredential("resend");
28
+ await invalidateEmailReadinessForByoCredential("mailgun");
29
+
30
+ expect(invalidatedChannels).toEqual(["email", "email"]);
31
+ });
32
+
33
+ test("leaves other services' credentials alone", async () => {
34
+ await invalidateEmailReadinessForByoCredential("telegram");
35
+ await invalidateEmailReadinessForByoCredential("slack_channel");
36
+
37
+ expect(invalidatedChannels).toEqual([]);
38
+ });
39
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * "Your own" (BYO) email providers: the assistant sends and receives email
3
+ * through the user's own provider account instead of a platform-managed
4
+ * inbox. Configuration is the provider's stored `api_key` credential;
5
+ * inbound arrives through the gateway's per-provider webhook routes
6
+ * (`gateway/src/http/routes/resend-webhook.ts`, `mailgun-webhook.ts`).
7
+ *
8
+ * The service ids here are the credential-store service names those webhook
9
+ * routes and the web client's BYO setup read (`EMAIL_BYO_PROVIDERS` in
10
+ * `clients/web/src/lib/provider-catalogs.ts` mirrors this list). Adding a
11
+ * provider means a new gateway webhook route and a web catalog entry, so
12
+ * extend all three together.
13
+ */
14
+
15
+ import { credentialKey } from "../security/credential-key.js";
16
+ import { getSecureKeyAsync } from "../security/secure-keys.js";
17
+ import { getLogger } from "../util/logger.js";
18
+
19
+ const log = getLogger("byo-email-credential");
20
+
21
+ export const BYO_EMAIL_CREDENTIAL_SERVICES = ["resend", "mailgun"] as const;
22
+
23
+ export type ByoEmailCredentialService =
24
+ (typeof BYO_EMAIL_CREDENTIAL_SERVICES)[number];
25
+
26
+ export function isByoEmailCredentialService(
27
+ service: string,
28
+ ): service is ByoEmailCredentialService {
29
+ return BYO_EMAIL_CREDENTIAL_SERVICES.some((s) => s === service);
30
+ }
31
+
32
+ /**
33
+ * The first BYO email provider whose API key is stored, or `undefined` when
34
+ * none is configured. Credential presence is the configuration claim, the
35
+ * same standard the other channels' readiness checks apply to their tokens.
36
+ */
37
+ export async function resolveConfiguredByoEmailService(): Promise<
38
+ ByoEmailCredentialService | undefined
39
+ > {
40
+ for (const service of BYO_EMAIL_CREDENTIAL_SERVICES) {
41
+ if (await getSecureKeyAsync(credentialKey(service, "api_key"))) {
42
+ return service;
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ /**
49
+ * A stored or deleted BYO email provider key changes the email channel's
50
+ * readiness verdict, and that check lives in the readiness service's
51
+ * TTL-cached remote bucket; drop the cached snapshot so the next readiness
52
+ * read re-evaluates instead of serving the pre-write answer for the rest of
53
+ * the TTL. Called from the credential write and delete paths. Best-effort:
54
+ * the credential change already succeeded, and a missed invalidation
55
+ * self-heals when the TTL lapses.
56
+ */
57
+ export async function invalidateEmailReadinessForByoCredential(
58
+ service: string,
59
+ ): Promise<void> {
60
+ if (!isByoEmailCredentialService(service)) {
61
+ return;
62
+ }
63
+ try {
64
+ // Lazily imported: the readiness service sits in the daemon handler
65
+ // graph, which credential writers (CLI, plugin API) should not load
66
+ // unless a BYO email key actually changed.
67
+ const { getReadinessService } =
68
+ await import("../daemon/handlers/config-channels.js");
69
+ getReadinessService().invalidateChannel("email");
70
+ } catch (err) {
71
+ log.warn(
72
+ { err, service },
73
+ "Credential change succeeded, but email readiness invalidation failed",
74
+ );
75
+ }
76
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Tests for the shared registered-inbox reader: the single answer to "does
3
+ * this assistant have a managed inbox?", read from the platform
4
+ * email-addresses API (the only writer of managed registrations).
5
+ */
6
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Mocks, set up before importing the module under test
10
+ // ---------------------------------------------------------------------------
11
+
12
+ let mockPlatformAssistantId: string | null;
13
+ let mockResponse: { ok: boolean; status: number; body: unknown };
14
+ let mockFetchThrows: boolean;
15
+ let mockCreateThrows: boolean;
16
+ let fetchCallCount: number;
17
+
18
+ mock.module("../platform/client.js", () => ({
19
+ VellumPlatformClient: {
20
+ create: async () => {
21
+ if (mockCreateThrows) {
22
+ throw new Error("credential store unavailable");
23
+ }
24
+ return clientStub();
25
+ },
26
+ },
27
+ }));
28
+
29
+ const clientStub = () => ({
30
+ platformAssistantId: mockPlatformAssistantId,
31
+ fetch: async (_path: string) => {
32
+ fetchCallCount += 1;
33
+ if (mockFetchThrows) {
34
+ throw new Error("platform unreachable");
35
+ }
36
+ return {
37
+ ok: mockResponse.ok,
38
+ status: mockResponse.status,
39
+ json: async () => mockResponse.body,
40
+ };
41
+ },
42
+ });
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Import after mocks
46
+ // ---------------------------------------------------------------------------
47
+
48
+ import { VellumPlatformClient } from "../platform/client.js";
49
+ import {
50
+ invalidateRegisteredInboxCache,
51
+ listEmailAddresses,
52
+ resolveRegisteredInbox,
53
+ } from "./registered-inbox.js";
54
+
55
+ async function testClient(): Promise<VellumPlatformClient> {
56
+ const client = await VellumPlatformClient.create();
57
+ if (!client) {
58
+ throw new Error("mocked create returned null");
59
+ }
60
+ return client;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Tests
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe("resolveRegisteredInbox", () => {
68
+ beforeEach(() => {
69
+ mockPlatformAssistantId = "assistant-test-id";
70
+ mockResponse = { ok: true, status: 200, body: { count: 0, results: [] } };
71
+ mockFetchThrows = false;
72
+ mockCreateThrows = false;
73
+ fetchCallCount = 0;
74
+ invalidateRegisteredInboxCache();
75
+ });
76
+
77
+ test("unavailable when client creation itself throws", async () => {
78
+ mockCreateThrows = true;
79
+
80
+ const state = await resolveRegisteredInbox();
81
+
82
+ expect(state).toEqual({
83
+ status: "unavailable",
84
+ detail: "credential store unavailable",
85
+ });
86
+ });
87
+
88
+ test("registered when the platform lists an address", async () => {
89
+ mockResponse = {
90
+ ok: true,
91
+ status: 200,
92
+ body: {
93
+ count: 1,
94
+ results: [{ id: "addr-1", address: "assistant@example.com" }],
95
+ },
96
+ };
97
+
98
+ const state = await resolveRegisteredInbox();
99
+
100
+ expect(state).toEqual({
101
+ status: "registered",
102
+ address: "assistant@example.com",
103
+ });
104
+ });
105
+
106
+ test("none when the platform lists no addresses", async () => {
107
+ const state = await resolveRegisteredInbox();
108
+
109
+ expect(state).toEqual({ status: "none" });
110
+ });
111
+
112
+ test("no_platform when platform credentials are missing", async () => {
113
+ mockPlatformAssistantId = null;
114
+
115
+ const state = await resolveRegisteredInbox();
116
+
117
+ expect(state).toEqual({ status: "no_platform" });
118
+ });
119
+
120
+ test("unavailable on a non-ok platform response", async () => {
121
+ mockResponse = { ok: false, status: 503, body: { detail: "boom" } };
122
+
123
+ const state = await resolveRegisteredInbox();
124
+
125
+ expect(state).toEqual({ status: "unavailable", detail: "HTTP 503" });
126
+ });
127
+
128
+ test("unavailable when the platform fetch throws", async () => {
129
+ mockFetchThrows = true;
130
+
131
+ const state = await resolveRegisteredInbox();
132
+
133
+ expect(state).toEqual({
134
+ status: "unavailable",
135
+ detail: "platform unreachable",
136
+ });
137
+ });
138
+
139
+ test("unavailable on an unexpected response shape", async () => {
140
+ mockResponse = { ok: true, status: 200, body: { results: "not-a-list" } };
141
+
142
+ const state = await resolveRegisteredInbox();
143
+
144
+ expect(state).toEqual({
145
+ status: "unavailable",
146
+ detail: "unexpected response shape",
147
+ });
148
+ });
149
+
150
+ test("serves the cached answer within the TTL", async () => {
151
+ mockResponse = {
152
+ ok: true,
153
+ status: 200,
154
+ body: { count: 1, results: [{ id: "addr-1", address: "hi@bot" }] },
155
+ };
156
+ await resolveRegisteredInbox();
157
+
158
+ // A changed platform answer is not observed until the cache is dropped.
159
+ mockResponse = { ok: true, status: 200, body: { count: 0, results: [] } };
160
+ const state = await resolveRegisteredInbox();
161
+
162
+ expect(state).toEqual({ status: "registered", address: "hi@bot" });
163
+ expect(fetchCallCount).toBe(1);
164
+ });
165
+
166
+ test("fresh bypasses the cache and updates it", async () => {
167
+ await resolveRegisteredInbox();
168
+ mockResponse = {
169
+ ok: true,
170
+ status: 200,
171
+ body: { count: 1, results: [{ id: "addr-1", address: "hi@bot" }] },
172
+ };
173
+
174
+ const fresh = await resolveRegisteredInbox({ fresh: true });
175
+ const cached = await resolveRegisteredInbox();
176
+
177
+ expect(fresh).toEqual({ status: "registered", address: "hi@bot" });
178
+ expect(cached).toEqual({ status: "registered", address: "hi@bot" });
179
+ expect(fetchCallCount).toBe(2);
180
+ });
181
+
182
+ test("invalidate drops the cached answer", async () => {
183
+ await resolveRegisteredInbox();
184
+ invalidateRegisteredInboxCache();
185
+ await resolveRegisteredInbox();
186
+
187
+ expect(fetchCallCount).toBe(2);
188
+ });
189
+
190
+ test("unavailable is not cached, so the next call retries", async () => {
191
+ mockFetchThrows = true;
192
+ await resolveRegisteredInbox();
193
+
194
+ mockFetchThrows = false;
195
+ mockResponse = {
196
+ ok: true,
197
+ status: 200,
198
+ body: { count: 1, results: [{ id: "addr-1", address: "hi@bot" }] },
199
+ };
200
+ const state = await resolveRegisteredInbox();
201
+
202
+ expect(state).toEqual({ status: "registered", address: "hi@bot" });
203
+ });
204
+ });
205
+
206
+ describe("listEmailAddresses", () => {
207
+ beforeEach(() => {
208
+ mockPlatformAssistantId = "assistant-test-id";
209
+ mockResponse = { ok: true, status: 200, body: { count: 0, results: [] } };
210
+ mockFetchThrows = false;
211
+ mockCreateThrows = false;
212
+ fetchCallCount = 0;
213
+ });
214
+
215
+ test("a 200 without results is a failed listing, not an empty one", async () => {
216
+ mockResponse = { ok: true, status: 200, body: {} };
217
+
218
+ const list = await listEmailAddresses(await testClient());
219
+
220
+ expect(list).toEqual({ ok: false, detail: "unexpected response shape" });
221
+ });
222
+
223
+ test("a positive count with no rows is a failed listing", async () => {
224
+ mockResponse = { ok: true, status: 200, body: { count: 2, results: [] } };
225
+
226
+ const list = await listEmailAddresses(await testClient());
227
+
228
+ expect(list).toEqual({
229
+ ok: false,
230
+ detail: "listing reported 2 addresses but returned none",
231
+ });
232
+ });
233
+
234
+ test("returns the platform's rows with ids", async () => {
235
+ mockResponse = {
236
+ ok: true,
237
+ status: 200,
238
+ body: { results: [{ id: "addr-1", address: "user@example.com" }] },
239
+ };
240
+
241
+ const list = await listEmailAddresses(await testClient());
242
+
243
+ expect(list).toEqual({
244
+ ok: true,
245
+ addresses: [{ id: "addr-1", address: "user@example.com" }],
246
+ });
247
+ });
248
+
249
+ test("carries the platform's status on a non-ok response", async () => {
250
+ mockResponse = { ok: false, status: 503, body: { detail: "boom" } };
251
+
252
+ const list = await listEmailAddresses(await testClient());
253
+
254
+ expect(list).toEqual({ ok: false, status: 503, detail: "HTTP 503" });
255
+ });
256
+
257
+ test("fails without a status when the fetch throws", async () => {
258
+ mockFetchThrows = true;
259
+
260
+ const list = await listEmailAddresses(await testClient());
261
+
262
+ expect(list).toEqual({ ok: false, detail: "platform unreachable" });
263
+ });
264
+
265
+ test("fails on an unexpected response shape", async () => {
266
+ mockResponse = { ok: true, status: 200, body: { results: [{ id: 7 }] } };
267
+
268
+ const list = await listEmailAddresses(await testClient());
269
+
270
+ expect(list).toEqual({ ok: false, detail: "unexpected response shape" });
271
+ });
272
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The one place that answers "does this assistant have a managed inbox?".
3
+ *
4
+ * Managed email registration lives on the Vellum platform: the
5
+ * `/v1/assistants/:id/email-addresses/` API is the only writer and the only
6
+ * source of truth. Nothing about a registration lands in workspace config, so
7
+ * any reader that wants the inbox address has to ask the platform. Every
8
+ * consumer of the listing (the email readiness probe, the email invite
9
+ * adapter, the channel-availability route, the email management routes, and
10
+ * verification email delivery) reads through this module so they cannot
11
+ * drift onto different answers.
12
+ *
13
+ * `unavailable` is distinct from `none` for the same reason the readiness
14
+ * service separates "failed" from "indeterminate": an unreachable platform is
15
+ * not evidence that no inbox exists, and reporting it as one turns every
16
+ * platform blip into a "set up email" prompt.
17
+ */
18
+
19
+ import { z } from "zod";
20
+
21
+ import { VellumPlatformClient } from "../platform/client.js";
22
+
23
+ export type RegisteredInboxState =
24
+ /** The platform holds a registered inbox address for this assistant. */
25
+ | { status: "registered"; address: string }
26
+ /** The platform answered: no inbox is registered. */
27
+ | { status: "none" }
28
+ /** No platform credentials, so no managed inbox can exist. */
29
+ | { status: "no_platform" }
30
+ /** The platform could not be asked; nothing is known either way. */
31
+ | { status: "unavailable"; detail: string };
32
+
33
+ /** One row of the platform's email-address listing. */
34
+ export interface RegisteredEmailAddress {
35
+ id: string;
36
+ address: string;
37
+ }
38
+
39
+ export type EmailAddressListResult =
40
+ | { ok: true; addresses: RegisteredEmailAddress[] }
41
+ /** The platform could not be asked or answered unusably. */
42
+ | { ok: false; status?: number; detail: string };
43
+
44
+ /**
45
+ * Validated rather than cast: the response crosses a runtime boundary, and a
46
+ * shape drift should read as a failed listing (nothing established), not as a
47
+ * confidently wrong answer. Unknown keys are ignored, since the platform adds
48
+ * fields over time.
49
+ */
50
+ const EmailAddressListSchema = z.object({
51
+ count: z.number().optional(),
52
+ // Required: the platform's paginated listing always carries `results`, so
53
+ // a 200 without it is shape drift and must read as a failed listing, never
54
+ // as "no inbox".
55
+ results: z.array(z.object({ id: z.string(), address: z.string() })),
56
+ });
57
+
58
+ /**
59
+ * Fetch the platform's email-address listing for the caller's own client.
60
+ *
61
+ * The seam every consumer of the listing shares: the readiness resolver
62
+ * below, the email register/unregister/status/send routes, and verification
63
+ * email delivery. Takes the caller's client rather than creating one so a
64
+ * route that already authenticated (and already knows how to report a
65
+ * missing platform connection) keeps its own error semantics.
66
+ */
67
+ export async function listEmailAddresses(
68
+ client: VellumPlatformClient,
69
+ ): Promise<EmailAddressListResult> {
70
+ let response: Response;
71
+ try {
72
+ response = await client.fetch(
73
+ `/v1/assistants/${client.platformAssistantId}/email-addresses/`,
74
+ );
75
+ } catch (err) {
76
+ const detail = err instanceof Error ? err.message : String(err);
77
+ return { ok: false, detail };
78
+ }
79
+
80
+ if (!response.ok) {
81
+ return {
82
+ ok: false,
83
+ status: response.status,
84
+ detail: `HTTP ${response.status}`,
85
+ };
86
+ }
87
+
88
+ const parsed = EmailAddressListSchema.safeParse(
89
+ await response.json().catch(() => null),
90
+ );
91
+ if (!parsed.success) {
92
+ return { ok: false, detail: "unexpected response shape" };
93
+ }
94
+
95
+ const { count, results: addresses } = parsed.data;
96
+ if (addresses.length === 0 && (count ?? 0) > 0) {
97
+ // A positive count with no rows is drift, not an empty listing: treating
98
+ // it as "no inbox" would recreate the false Not connected verdict.
99
+ return {
100
+ ok: false,
101
+ detail: `listing reported ${count} addresses but returned none`,
102
+ };
103
+ }
104
+
105
+ return { ok: true, addresses };
106
+ }
107
+
108
+ /**
109
+ * Matches the readiness service's remote-check TTL: the cache exists so the
110
+ * invite-adapter enrichment on the readiness poll (every 15s per client) does
111
+ * not turn into a platform request per poll, and registration changes made
112
+ * through the daemon invalidate it explicitly.
113
+ */
114
+ const CACHE_TTL_MS = 5 * 60 * 1000;
115
+
116
+ let cache: { state: RegisteredInboxState; fetchedAt: number } | null = null;
117
+
118
+ /** Drop the cached answer; call after registering or releasing an inbox. */
119
+ export function invalidateRegisteredInboxCache(): void {
120
+ cache = null;
121
+ }
122
+
123
+ /**
124
+ * Resolve the managed inbox state, serving a cached answer within
125
+ * {@link CACHE_TTL_MS} unless `fresh` demands a live read. `unavailable` is
126
+ * never cached: a platform blip should heal on the next call, not pin the
127
+ * unknown state for the TTL.
128
+ */
129
+ export async function resolveRegisteredInbox(
130
+ options: { fresh?: boolean } = {},
131
+ ): Promise<RegisteredInboxState> {
132
+ if (!options.fresh && cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) {
133
+ return cache.state;
134
+ }
135
+
136
+ const state = await fetchRegisteredInbox();
137
+ if (state.status === "unavailable") {
138
+ return state;
139
+ }
140
+ cache = { state, fetchedAt: Date.now() };
141
+ return state;
142
+ }
143
+
144
+ async function fetchRegisteredInbox(): Promise<RegisteredInboxState> {
145
+ // Creation resolves the managed-proxy context and the credential store,
146
+ // either of which can reject; a backend blip there is "could not ask",
147
+ // not an error the caller should surface as a failed request.
148
+ let client: VellumPlatformClient | null;
149
+ try {
150
+ client = await VellumPlatformClient.create();
151
+ } catch (err) {
152
+ const detail = err instanceof Error ? err.message : String(err);
153
+ return { status: "unavailable", detail };
154
+ }
155
+ if (!client?.platformAssistantId) {
156
+ return { status: "no_platform" };
157
+ }
158
+
159
+ const list = await listEmailAddresses(client);
160
+ if (!list.ok) {
161
+ return { status: "unavailable", detail: list.detail };
162
+ }
163
+
164
+ const address = list.addresses[0]?.address;
165
+ if (typeof address === "string" && address.length > 0) {
166
+ return { status: "registered", address };
167
+ }
168
+ return { status: "none" };
169
+ }