@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.
- package/docs/guardian-request-flow.md +3 -3
- package/openapi.yaml +0 -24
- package/package.json +1 -1
- package/src/__tests__/access-request-seed-content-blocks.test.ts +2 -2
- package/src/__tests__/anthropic-provider.test.ts +2 -0
- package/src/__tests__/channel-availability-routes.test.ts +15 -0
- package/src/__tests__/channel-readiness-routes.test.ts +96 -3
- package/src/__tests__/channel-readiness-service.test.ts +19 -4
- package/src/__tests__/config-loader-backfill.test.ts +2 -2
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/email-invite-adapter.test.ts +53 -12
- package/src/__tests__/external-binding-chat-name.test.ts +60 -0
- package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +28 -0
- package/src/__tests__/managed-profile-guard.test.ts +1 -1
- package/src/__tests__/model-intents.test.ts +2 -2
- package/src/__tests__/pricing.test.ts +13 -0
- package/src/__tests__/tool-approval-seed-content-blocks.test.ts +16 -3
- package/src/api/responses/home.ts +0 -6
- package/src/config/__tests__/default-profile-catalog.test.ts +1 -1
- package/src/config/call-site-defaults.ts +1 -1
- package/src/config/default-profile-catalog.ts +12 -12
- package/src/config/default-profile-names.ts +2 -2
- package/src/email/byo-email-credential.test.ts +39 -0
- package/src/email/byo-email-credential.ts +76 -0
- package/src/email/registered-inbox.test.ts +272 -0
- package/src/email/registered-inbox.ts +169 -0
- package/src/messaging/providers/slack/approval-source.test.ts +70 -0
- package/src/messaging/providers/slack/approval-source.ts +18 -5
- package/src/notifications/__tests__/guardian-feed-projection.test.ts +13 -14
- package/src/notifications/approval-card-data.ts +13 -5
- package/src/notifications/emit-signal.ts +5 -8
- package/src/notifications/guardian-feed-projection.ts +9 -74
- package/src/notifications/guardian-question-mode.ts +33 -1
- package/src/notifications/home-feed-side-effect.ts +0 -4
- package/src/persistence/external-conversation-store.ts +5 -1
- package/src/providers/anthropic/client.ts +1 -1
- package/src/providers/model-catalog.ts +54 -0
- package/src/providers/model-intents.ts +3 -3
- package/src/runtime/approval-source-link.ts +6 -0
- package/src/runtime/channel-invite-transports/email.ts +10 -9
- package/src/runtime/channel-readiness-service.ts +83 -23
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +1 -1
- package/src/runtime/routes/channel-availability-routes.ts +18 -31
- package/src/runtime/routes/credential-routes.ts +3 -0
- package/src/runtime/routes/email-routes.ts +35 -47
- package/src/runtime/verification-outbound-actions.ts +8 -12
- package/src/tools/credentials/store.ts +3 -0
- 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
|
-
//
|
|
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 ("
|
|
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: "
|
|
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: "
|
|
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: "
|
|
215
|
-
description: "Automatic backup for the
|
|
214
|
+
label: "Budget Backup",
|
|
215
|
+
description: "Automatic backup for the Budget profile",
|
|
216
216
|
maxTokens: 8192,
|
|
217
|
-
// Explicit reasoning opt-out, matching the primary
|
|
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: "
|
|
233
|
-
description: "Automatic backup for the
|
|
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`.
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 "
|
|
15
|
-
* `latency-optimized` is labelled "
|
|
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
|
+
}
|