@vellumai/assistant 0.5.15 → 0.5.16

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 (175) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/docs/architecture/integrations.md +15 -14
  3. package/knip.json +3 -1
  4. package/openapi.yaml +11 -43
  5. package/package.json +1 -1
  6. package/src/__tests__/assistant-feature-flags-integration.test.ts +3 -375
  7. package/src/__tests__/ces-rpc-credential-backend.test.ts +4 -1
  8. package/src/__tests__/checker.test.ts +59 -0
  9. package/src/__tests__/cli-command-risk-guard.test.ts +98 -10
  10. package/src/__tests__/cli-memory.test.ts +372 -0
  11. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +12 -2
  12. package/src/__tests__/config-schema.test.ts +0 -2
  13. package/src/__tests__/config-watcher-feature-flags.test.ts +211 -0
  14. package/src/__tests__/conversation-runtime-assembly.test.ts +7 -4
  15. package/src/__tests__/conversation-slash-commands.test.ts +2 -6
  16. package/src/__tests__/conversation-usage.test.ts +1 -0
  17. package/src/__tests__/credential-security-e2e.test.ts +4 -1
  18. package/src/__tests__/docker-signing-key-bootstrap.test.ts +7 -73
  19. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +6 -7
  20. package/src/__tests__/guardian-routing-invariants.test.ts +151 -0
  21. package/src/__tests__/heartbeat-service.test.ts +1 -3
  22. package/src/__tests__/intent-routing.test.ts +6 -18
  23. package/src/__tests__/log-export-workspace.test.ts +2 -28
  24. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -37
  25. package/src/__tests__/managed-store.test.ts +2 -10
  26. package/src/__tests__/messaging-send-tool.test.ts +6 -6
  27. package/src/__tests__/migration-cross-version-compatibility.test.ts +1 -29
  28. package/src/__tests__/migration-export-http.test.ts +3 -34
  29. package/src/__tests__/migration-import-commit-http.test.ts +1 -29
  30. package/src/__tests__/migration-import-preflight-http.test.ts +3 -34
  31. package/src/__tests__/no-domain-routing-in-prompt-guard.test.ts +2 -1
  32. package/src/__tests__/oauth-apps-routes.test.ts +120 -10
  33. package/src/__tests__/oauth-connect-orchestrator.test.ts +709 -0
  34. package/src/__tests__/oauth-provider-serializer.test.ts +2 -1
  35. package/src/__tests__/oauth-provider-visibility.test.ts +149 -0
  36. package/src/__tests__/oauth-providers-routes.test.ts +5 -2
  37. package/src/__tests__/oauth-store.test.ts +0 -5
  38. package/src/__tests__/outlook-messaging-provider.test.ts +576 -0
  39. package/src/__tests__/path-policy.test.ts +2 -17
  40. package/src/__tests__/permission-types.test.ts +0 -1
  41. package/src/__tests__/platform-callback-registration.test.ts +3 -7
  42. package/src/__tests__/provider-commit-message-generator.test.ts +0 -1
  43. package/src/__tests__/provider-error-scenarios.test.ts +0 -2
  44. package/src/__tests__/qdrant-manager.test.ts +68 -21
  45. package/src/__tests__/require-fresh-approval.test.ts +0 -1
  46. package/src/__tests__/sandbox-diagnostics.test.ts +20 -29
  47. package/src/__tests__/scaffold-managed-skill-tool.test.ts +2 -10
  48. package/src/__tests__/secret-allowlist.test.ts +20 -35
  49. package/src/__tests__/shell-credential-ref.test.ts +0 -5
  50. package/src/__tests__/skill-load-feature-flag.test.ts +2 -43
  51. package/src/__tests__/skill-load-inline-command.test.ts +3 -65
  52. package/src/__tests__/skill-load-inline-includes.test.ts +3 -65
  53. package/src/__tests__/skill-load-tool.test.ts +3 -67
  54. package/src/__tests__/skill-memory.test.ts +362 -119
  55. package/src/__tests__/skills.test.ts +22 -49
  56. package/src/__tests__/slack-channel-config.test.ts +2 -21
  57. package/src/__tests__/starter-bundle.test.ts +2 -8
  58. package/src/__tests__/stt-hints.test.ts +7 -2
  59. package/src/__tests__/system-prompt.test.ts +25 -45
  60. package/src/__tests__/task-compiler.test.ts +0 -21
  61. package/src/__tests__/task-management-tools.test.ts +0 -21
  62. package/src/__tests__/task-memory-cleanup.test.ts +0 -21
  63. package/src/__tests__/task-runner.test.ts +0 -21
  64. package/src/__tests__/task-scheduler.test.ts +0 -21
  65. package/src/__tests__/terminal-tools.test.ts +1 -17
  66. package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +0 -79
  67. package/src/__tests__/tool-approval-handler.test.ts +1 -20
  68. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -11
  69. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +1 -25
  70. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  71. package/src/__tests__/tool-executor.test.ts +0 -1
  72. package/src/__tests__/tool-grant-request-escalation.test.ts +1 -20
  73. package/src/__tests__/tool-preview-lifecycle.test.ts +0 -20
  74. package/src/__tests__/trust-store.test.ts +9 -41
  75. package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -30
  76. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1 -21
  77. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +0 -22
  78. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -22
  79. package/src/__tests__/trusted-contact-verification.test.ts +0 -22
  80. package/src/__tests__/turn-boundary-resolution.test.ts +0 -28
  81. package/src/__tests__/twilio-provider.test.ts +0 -16
  82. package/src/__tests__/twilio-routes-twiml.test.ts +7 -12
  83. package/src/__tests__/twilio-routes.test.ts +0 -24
  84. package/src/__tests__/update-bulletin.test.ts +17 -89
  85. package/src/__tests__/usage-cache-backfill-migration.test.ts +0 -20
  86. package/src/__tests__/usage-routes.test.ts +0 -21
  87. package/src/__tests__/user-reference.test.ts +1 -5
  88. package/src/__tests__/vbundle-pax-and-symlink.test.ts +4 -34
  89. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +2 -53
  90. package/src/__tests__/voice-invite-redemption.test.ts +0 -21
  91. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -24
  92. package/src/__tests__/voice-session-bridge.test.ts +0 -21
  93. package/src/__tests__/workspace-migration-009-backfill-conversation-disk-view.test.ts +2 -23
  94. package/src/__tests__/workspace-migration-012-rename-conversation-disk-view-dirs.test.ts +2 -2
  95. package/src/__tests__/workspace-migration-013-repair-conversation-disk-view.test.ts +2 -23
  96. package/src/__tests__/workspace-migration-down-functions.test.ts +0 -6
  97. package/src/acp/client-handler.ts +1 -2
  98. package/src/cli/__tests__/notifications.test.ts +0 -22
  99. package/src/cli/cli-memory.ts +176 -0
  100. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -1
  101. package/src/cli/commands/oauth/connect.ts +15 -0
  102. package/src/cli/commands/oauth/providers.ts +49 -42
  103. package/src/cli/commands/platform/__tests__/connect.test.ts +2 -48
  104. package/src/cli/commands/platform/__tests__/disconnect.test.ts +2 -48
  105. package/src/cli/commands/platform/__tests__/status.test.ts +0 -50
  106. package/src/config/bundled-skills/computer-use/TOOLS.json +7 -7
  107. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  108. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  109. package/src/config/feature-flag-registry.json +16 -0
  110. package/src/config/loader.ts +4 -0
  111. package/src/config/schemas/security.ts +0 -6
  112. package/src/config/schemas/services.ts +8 -0
  113. package/src/context/window-manager.ts +28 -9
  114. package/src/credential-execution/approval-bridge.ts +0 -1
  115. package/src/daemon/config-watcher.ts +51 -0
  116. package/src/daemon/conversation-agent-loop.ts +3 -2
  117. package/src/daemon/conversation-process.ts +1 -0
  118. package/src/daemon/conversation-usage.ts +1 -0
  119. package/src/daemon/handlers/skills.ts +9 -1
  120. package/src/daemon/lifecycle.ts +13 -4
  121. package/src/daemon/message-types/conversations.ts +1 -0
  122. package/src/daemon/providers-setup.ts +2 -0
  123. package/src/daemon/server.ts +26 -22
  124. package/src/events/domain-events.ts +1 -2
  125. package/src/memory/db-init.ts +9 -0
  126. package/src/memory/job-handlers/batch-extraction.ts +16 -4
  127. package/src/memory/job-handlers/embedding.test.ts +3 -27
  128. package/src/memory/job-handlers/journal-carry-forward.test.ts +1 -29
  129. package/src/memory/llm-usage-store.ts +35 -2
  130. package/src/memory/migrations/201-oauth-providers-feature-flag.ts +11 -0
  131. package/src/memory/migrations/202-drop-callback-transport-column.ts +13 -0
  132. package/src/memory/migrations/index.ts +2 -0
  133. package/src/memory/qdrant-manager.ts +26 -5
  134. package/src/memory/query-expansion.ts +1 -1
  135. package/src/memory/retriever.test.ts +22 -20
  136. package/src/memory/retriever.ts +10 -2
  137. package/src/memory/schema/oauth.ts +1 -1
  138. package/src/memory/search/mmr.ts +8 -5
  139. package/src/memory/slack-thread-store.ts +17 -0
  140. package/src/messaging/providers/outlook/adapter.ts +193 -0
  141. package/src/messaging/providers/outlook/client.ts +311 -0
  142. package/src/messaging/providers/outlook/types.ts +83 -0
  143. package/src/notifications/adapters/slack.ts +1 -1
  144. package/src/oauth/__tests__/identity-verifier.test.ts +1 -1
  145. package/src/oauth/connect-orchestrator.ts +10 -3
  146. package/src/oauth/oauth-store.ts +10 -11
  147. package/src/oauth/provider-serializer.ts +3 -0
  148. package/src/oauth/provider-visibility.ts +16 -0
  149. package/src/oauth/seed-providers.ts +49 -17
  150. package/src/permissions/checker.ts +39 -7
  151. package/src/permissions/types.ts +2 -4
  152. package/src/prompts/journal-context.ts +9 -11
  153. package/src/prompts/system-prompt.ts +3 -64
  154. package/src/prompts/templates/UPDATES.md +6 -0
  155. package/src/runtime/auth/__tests__/credential-service.test.ts +1 -27
  156. package/src/runtime/auth/__tests__/token-service.test.ts +1 -25
  157. package/src/runtime/auth/route-policy.ts +0 -4
  158. package/src/runtime/guardian-reply-router.ts +6 -2
  159. package/src/runtime/routes/conversation-query-routes.ts +2 -58
  160. package/src/runtime/routes/inbound-stages/background-dispatch.ts +43 -2
  161. package/src/runtime/routes/memory-item-routes.test.ts +0 -17
  162. package/src/runtime/routes/memory-item-routes.ts +103 -12
  163. package/src/runtime/routes/oauth-apps.ts +18 -1
  164. package/src/runtime/routes/oauth-providers.ts +13 -1
  165. package/src/runtime/routes/settings-routes.ts +1 -0
  166. package/src/runtime/routes/usage-routes.ts +19 -2
  167. package/src/runtime/routes/work-items-routes.test.ts +0 -21
  168. package/src/runtime/routes/workspace-routes.test.ts +3 -27
  169. package/src/security/secret-allowlist.ts +4 -4
  170. package/src/skills/skill-memory.ts +62 -23
  171. package/src/tools/memory/handlers.test.ts +1 -29
  172. package/src/tools/permission-checker.ts +0 -18
  173. package/src/tools/skills/skill-script-runner.ts +1 -1
  174. package/src/util/device-id.ts +3 -65
  175. package/src/workspace/git-service.ts +27 -6
@@ -0,0 +1,709 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks — must be set up BEFORE importing the module under test
5
+ // ---------------------------------------------------------------------------
6
+
7
+ // Track calls to prepareOAuth2Flow and startOAuth2Flow
8
+ let lastPrepareArgs: { config: unknown; options: unknown } | null = null;
9
+ let lastStartArgs: {
10
+ config: unknown;
11
+ callbacks: unknown;
12
+ options: unknown;
13
+ } | null = null;
14
+
15
+ let mockPrepareResult: {
16
+ authUrl: string;
17
+ state: string;
18
+ completion: Promise<{
19
+ tokens: { accessToken: string; refreshToken?: string };
20
+ grantedScopes: string[];
21
+ rawTokenResponse: Record<string, unknown>;
22
+ }>;
23
+ } = {
24
+ authUrl: "https://provider.example.com/authorize?prepared",
25
+ state: "mock-state-123",
26
+ completion: new Promise(() => {}), // never resolves by default
27
+ };
28
+
29
+ let mockStartResult: {
30
+ tokens: { accessToken: string; refreshToken?: string };
31
+ grantedScopes: string[];
32
+ rawTokenResponse: Record<string, unknown>;
33
+ } = {
34
+ tokens: { accessToken: "mock-access-token" },
35
+ grantedScopes: ["read", "write"],
36
+ rawTokenResponse: { access_token: "mock-access-token" },
37
+ };
38
+
39
+ mock.module("../security/oauth2.js", () => ({
40
+ prepareOAuth2Flow: async (config: unknown, options: unknown) => {
41
+ lastPrepareArgs = { config, options };
42
+ return mockPrepareResult;
43
+ },
44
+ startOAuth2Flow: async (
45
+ config: unknown,
46
+ callbacks: unknown,
47
+ options: unknown,
48
+ ) => {
49
+ lastStartArgs = { config, callbacks, options };
50
+ return mockStartResult;
51
+ },
52
+ }));
53
+
54
+ // Mock logger
55
+ mock.module("../util/logger.js", () => ({
56
+ getLogger: () =>
57
+ new Proxy({} as Record<string, unknown>, {
58
+ get: () => () => {},
59
+ }),
60
+ }));
61
+
62
+ // Mock identity verifier — returns a stable account identifier
63
+ let mockIdentityResult: string | undefined = "user@example.com";
64
+
65
+ mock.module("../oauth/identity-verifier.js", () => ({
66
+ verifyIdentity: async () => mockIdentityResult,
67
+ }));
68
+
69
+ // Mock token persistence — just returns the accountInfo
70
+ mock.module("../oauth/token-persistence.js", () => ({
71
+ storeOAuth2Tokens: async (params: { parsedAccountIdentifier?: string }) => ({
72
+ accountInfo: params.parsedAccountIdentifier ?? undefined,
73
+ }),
74
+ }));
75
+
76
+ // Mock scope policy — always approves
77
+ mock.module("../oauth/scope-policy.js", () => ({
78
+ resolveScopes: (
79
+ _profile: unknown,
80
+ requestedScopes: string[] | undefined,
81
+ ) => ({
82
+ ok: true,
83
+ scopes: requestedScopes ?? ["openid", "email"],
84
+ }),
85
+ }));
86
+
87
+ // Provider store mock — configurable per test
88
+ type ProviderRow = {
89
+ providerKey: string;
90
+ authUrl: string;
91
+ tokenUrl: string;
92
+ tokenEndpointAuthMethod: string | null;
93
+ userinfoUrl: string | null;
94
+ baseUrl: string | null;
95
+ defaultScopes: string;
96
+ scopePolicy: string;
97
+ extraParams: string | null;
98
+ pingUrl: string | null;
99
+ pingMethod: string | null;
100
+ pingHeaders: string | null;
101
+ pingBody: string | null;
102
+ managedServiceConfigKey: string | null;
103
+ displayName: string | null;
104
+ description: string | null;
105
+ dashboardUrl: string | null;
106
+ clientIdPlaceholder: string | null;
107
+ requiresClientSecret: number;
108
+ loopbackPort: number | null;
109
+ injectionTemplates: string | null;
110
+ appType: string | null;
111
+ setupNotes: string | null;
112
+ identityUrl: string | null;
113
+ identityMethod: string | null;
114
+ identityHeaders: string | null;
115
+ identityBody: string | null;
116
+ identityResponsePaths: string | null;
117
+ identityFormat: string | null;
118
+ identityOkField: string | null;
119
+ featureFlag: string | null;
120
+ createdAt: number;
121
+ updatedAt: number;
122
+ };
123
+
124
+ let mockProviderStore: Record<string, ProviderRow> = {};
125
+
126
+ mock.module("../oauth/oauth-store.js", () => ({
127
+ getProvider: (key: string) => mockProviderStore[key],
128
+ }));
129
+
130
+ // Config / ingress mocks — for gateway transport validation
131
+ let mockPublicBaseUrl = "";
132
+
133
+ mock.module("../config/loader.js", () => ({
134
+ loadConfig: () => ({
135
+ ingress: { publicBaseUrl: mockPublicBaseUrl },
136
+ }),
137
+ }));
138
+
139
+ mock.module("../inbound/public-ingress-urls.js", () => ({
140
+ getPublicBaseUrl: (config?: { ingress?: { publicBaseUrl?: string } }) => {
141
+ const url = config?.ingress?.publicBaseUrl ?? mockPublicBaseUrl;
142
+ if (!url) {
143
+ throw new Error("No public base URL configured.");
144
+ }
145
+ return url;
146
+ },
147
+ }));
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Import module under test AFTER mocks are in place
151
+ // ---------------------------------------------------------------------------
152
+
153
+ import { orchestrateOAuthConnect } from "../oauth/connect-orchestrator.js";
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Helpers
157
+ // ---------------------------------------------------------------------------
158
+
159
+ function makeProviderRow(
160
+ overrides: Partial<ProviderRow> & { providerKey: string },
161
+ ): ProviderRow {
162
+ const now = Date.now();
163
+ return {
164
+ authUrl: "https://provider.example.com/authorize",
165
+ tokenUrl: "https://provider.example.com/token",
166
+ tokenEndpointAuthMethod: null,
167
+ userinfoUrl: null,
168
+ baseUrl: null,
169
+ defaultScopes: '["openid","email"]',
170
+ scopePolicy:
171
+ '{"allowAdditionalScopes":false,"allowedOptionalScopes":[],"forbiddenScopes":[]}',
172
+ extraParams: null,
173
+ pingUrl: null,
174
+ pingMethod: null,
175
+ pingHeaders: null,
176
+ pingBody: null,
177
+ managedServiceConfigKey: null,
178
+ displayName: null,
179
+ description: null,
180
+ dashboardUrl: null,
181
+ clientIdPlaceholder: null,
182
+ requiresClientSecret: 1,
183
+ loopbackPort: null,
184
+ injectionTemplates: null,
185
+ appType: null,
186
+ setupNotes: null,
187
+ identityUrl: null,
188
+ identityMethod: null,
189
+ identityHeaders: null,
190
+ identityBody: null,
191
+ identityResponsePaths: null,
192
+ identityFormat: null,
193
+ identityOkField: null,
194
+ featureFlag: null,
195
+ createdAt: now,
196
+ updatedAt: now,
197
+ ...overrides,
198
+ };
199
+ }
200
+
201
+ // Shared provider definitions used across tests
202
+ const GOOGLE_PROVIDER = makeProviderRow({
203
+ providerKey: "google",
204
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
205
+ tokenUrl: "https://oauth2.googleapis.com/token",
206
+ loopbackPort: 17332,
207
+ displayName: "Google",
208
+ });
209
+
210
+ const OUTLOOK_PROVIDER = makeProviderRow({
211
+ providerKey: "outlook",
212
+ authUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
213
+ tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
214
+ loopbackPort: 17334,
215
+ displayName: "Outlook",
216
+ });
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Reset state between tests
220
+ // ---------------------------------------------------------------------------
221
+
222
+ beforeEach(() => {
223
+ lastPrepareArgs = null;
224
+ lastStartArgs = null;
225
+ mockPublicBaseUrl = "";
226
+ mockIdentityResult = "user@example.com";
227
+ mockProviderStore = {};
228
+
229
+ mockPrepareResult = {
230
+ authUrl: "https://provider.example.com/authorize?prepared",
231
+ state: "mock-state-123",
232
+ completion: new Promise(() => {}),
233
+ };
234
+
235
+ mockStartResult = {
236
+ tokens: { accessToken: "mock-access-token" },
237
+ grantedScopes: ["read", "write"],
238
+ rawTokenResponse: { access_token: "mock-access-token" },
239
+ };
240
+ });
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Tests
244
+ // ---------------------------------------------------------------------------
245
+
246
+ describe("orchestrateOAuthConnect — transport selection", () => {
247
+ // -------------------------------------------------------------------------
248
+ // Deferred (non-interactive) path
249
+ // -------------------------------------------------------------------------
250
+
251
+ describe("deferred (non-interactive) flow", () => {
252
+ test('callbackTransport: "loopback" → passes loopback options to prepareOAuth2Flow', async () => {
253
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
254
+
255
+ const result = await orchestrateOAuthConnect({
256
+ service: "google",
257
+ clientId: "client-id",
258
+ isInteractive: false,
259
+ callbackTransport: "loopback",
260
+ });
261
+
262
+ expect(result.success).toBe(true);
263
+ if (!result.success) return;
264
+ expect(result.deferred).toBe(true);
265
+
266
+ // Verify prepareOAuth2Flow received loopback options
267
+ expect(lastPrepareArgs).not.toBeNull();
268
+ expect(lastPrepareArgs!.options).toEqual({
269
+ callbackTransport: "loopback",
270
+ loopbackPort: 17332,
271
+ });
272
+ });
273
+
274
+ test('callbackTransport: "gateway" → passes gateway options to prepareOAuth2Flow', async () => {
275
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
276
+ mockPublicBaseUrl = "https://gw.example.com";
277
+
278
+ const result = await orchestrateOAuthConnect({
279
+ service: "google",
280
+ clientId: "client-id",
281
+ isInteractive: false,
282
+ callbackTransport: "gateway",
283
+ });
284
+
285
+ expect(result.success).toBe(true);
286
+ if (!result.success) return;
287
+ expect(result.deferred).toBe(true);
288
+
289
+ expect(lastPrepareArgs).not.toBeNull();
290
+ expect(lastPrepareArgs!.options).toEqual({
291
+ callbackTransport: "gateway",
292
+ });
293
+ });
294
+
295
+ test("callbackTransport omitted → defaults to loopback", async () => {
296
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
297
+
298
+ const result = await orchestrateOAuthConnect({
299
+ service: "google",
300
+ clientId: "client-id",
301
+ isInteractive: false,
302
+ // callbackTransport intentionally omitted
303
+ });
304
+
305
+ expect(result.success).toBe(true);
306
+ if (!result.success) return;
307
+ expect(result.deferred).toBe(true);
308
+
309
+ expect(lastPrepareArgs).not.toBeNull();
310
+ expect(lastPrepareArgs!.options).toEqual({
311
+ callbackTransport: "loopback",
312
+ loopbackPort: 17332,
313
+ });
314
+ });
315
+
316
+ test("gateway without ingress configured → returns error", async () => {
317
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
318
+ mockPublicBaseUrl = ""; // no ingress
319
+
320
+ const result = await orchestrateOAuthConnect({
321
+ service: "google",
322
+ clientId: "client-id",
323
+ isInteractive: false,
324
+ callbackTransport: "gateway",
325
+ });
326
+
327
+ expect(result.success).toBe(false);
328
+ if (result.success) return;
329
+ expect(result.error).toContain("public ingress URL");
330
+ });
331
+
332
+ test("loopback without ingress configured → succeeds", async () => {
333
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
334
+ mockPublicBaseUrl = ""; // no ingress
335
+
336
+ const result = await orchestrateOAuthConnect({
337
+ service: "google",
338
+ clientId: "client-id",
339
+ isInteractive: false,
340
+ callbackTransport: "loopback",
341
+ });
342
+
343
+ expect(result.success).toBe(true);
344
+ if (!result.success) return;
345
+ expect(result.deferred).toBe(true);
346
+ });
347
+ });
348
+
349
+ // -------------------------------------------------------------------------
350
+ // Interactive path
351
+ // -------------------------------------------------------------------------
352
+
353
+ describe("interactive flow", () => {
354
+ test('callbackTransport: "loopback" → passes loopback options to startOAuth2Flow', async () => {
355
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
356
+
357
+ const result = await orchestrateOAuthConnect({
358
+ service: "google",
359
+ clientId: "client-id",
360
+ isInteractive: true,
361
+ callbackTransport: "loopback",
362
+ openUrl: () => {},
363
+ });
364
+
365
+ expect(result.success).toBe(true);
366
+ if (!result.success) return;
367
+ expect(result.deferred).toBe(false);
368
+
369
+ expect(lastStartArgs).not.toBeNull();
370
+ expect(lastStartArgs!.options).toEqual({
371
+ callbackTransport: "loopback",
372
+ loopbackPort: 17332,
373
+ });
374
+ });
375
+
376
+ test('callbackTransport: "gateway" → passes gateway options to startOAuth2Flow', async () => {
377
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
378
+
379
+ const result = await orchestrateOAuthConnect({
380
+ service: "google",
381
+ clientId: "client-id",
382
+ isInteractive: true,
383
+ callbackTransport: "gateway",
384
+ openUrl: () => {},
385
+ });
386
+
387
+ expect(result.success).toBe(true);
388
+ if (!result.success) return;
389
+ expect(result.deferred).toBe(false);
390
+
391
+ expect(lastStartArgs).not.toBeNull();
392
+ expect(lastStartArgs!.options).toEqual({
393
+ callbackTransport: "gateway",
394
+ });
395
+ });
396
+
397
+ test("callbackTransport omitted → defaults to loopback", async () => {
398
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
399
+
400
+ const result = await orchestrateOAuthConnect({
401
+ service: "google",
402
+ clientId: "client-id",
403
+ isInteractive: true,
404
+ openUrl: () => {},
405
+ // callbackTransport intentionally omitted
406
+ });
407
+
408
+ expect(result.success).toBe(true);
409
+ if (!result.success) return;
410
+ expect(result.deferred).toBe(false);
411
+
412
+ expect(lastStartArgs).not.toBeNull();
413
+ expect(lastStartArgs!.options).toEqual({
414
+ callbackTransport: "loopback",
415
+ loopbackPort: 17332,
416
+ });
417
+ });
418
+ });
419
+
420
+ // -------------------------------------------------------------------------
421
+ // Provider x transport matrix — verifies the rule is universal
422
+ // -------------------------------------------------------------------------
423
+
424
+ describe("provider x transport matrix", () => {
425
+ test("Google + loopback → loopback transport with Google loopbackPort", async () => {
426
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
427
+
428
+ await orchestrateOAuthConnect({
429
+ service: "google",
430
+ clientId: "client-id",
431
+ isInteractive: false,
432
+ callbackTransport: "loopback",
433
+ });
434
+
435
+ expect(lastPrepareArgs!.options).toEqual({
436
+ callbackTransport: "loopback",
437
+ loopbackPort: 17332,
438
+ });
439
+ });
440
+
441
+ test("Google + gateway → gateway transport", async () => {
442
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
443
+ mockPublicBaseUrl = "https://gw.example.com";
444
+
445
+ await orchestrateOAuthConnect({
446
+ service: "google",
447
+ clientId: "client-id",
448
+ isInteractive: false,
449
+ callbackTransport: "gateway",
450
+ });
451
+
452
+ expect(lastPrepareArgs!.options).toEqual({
453
+ callbackTransport: "gateway",
454
+ });
455
+ });
456
+
457
+ test("Outlook + loopback → loopback transport with Outlook loopbackPort", async () => {
458
+ mockProviderStore["outlook"] = OUTLOOK_PROVIDER;
459
+
460
+ await orchestrateOAuthConnect({
461
+ service: "outlook",
462
+ clientId: "client-id",
463
+ isInteractive: false,
464
+ callbackTransport: "loopback",
465
+ });
466
+
467
+ expect(lastPrepareArgs!.options).toEqual({
468
+ callbackTransport: "loopback",
469
+ loopbackPort: 17334,
470
+ });
471
+ });
472
+
473
+ test("Outlook + gateway → gateway transport", async () => {
474
+ mockProviderStore["outlook"] = OUTLOOK_PROVIDER;
475
+ mockPublicBaseUrl = "https://gw.example.com";
476
+
477
+ await orchestrateOAuthConnect({
478
+ service: "outlook",
479
+ clientId: "client-id",
480
+ isInteractive: false,
481
+ callbackTransport: "gateway",
482
+ });
483
+
484
+ expect(lastPrepareArgs!.options).toEqual({
485
+ callbackTransport: "gateway",
486
+ });
487
+ });
488
+
489
+ test("Google + loopback (interactive) → loopback transport", async () => {
490
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
491
+
492
+ await orchestrateOAuthConnect({
493
+ service: "google",
494
+ clientId: "client-id",
495
+ isInteractive: true,
496
+ callbackTransport: "loopback",
497
+ openUrl: () => {},
498
+ });
499
+
500
+ expect(lastStartArgs!.options).toEqual({
501
+ callbackTransport: "loopback",
502
+ loopbackPort: 17332,
503
+ });
504
+ });
505
+
506
+ test("Google + gateway (interactive) → gateway transport", async () => {
507
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
508
+
509
+ await orchestrateOAuthConnect({
510
+ service: "google",
511
+ clientId: "client-id",
512
+ isInteractive: true,
513
+ callbackTransport: "gateway",
514
+ openUrl: () => {},
515
+ });
516
+
517
+ expect(lastStartArgs!.options).toEqual({
518
+ callbackTransport: "gateway",
519
+ });
520
+ });
521
+
522
+ test("Outlook + loopback (interactive) → loopback transport with Outlook loopbackPort", async () => {
523
+ mockProviderStore["outlook"] = OUTLOOK_PROVIDER;
524
+
525
+ await orchestrateOAuthConnect({
526
+ service: "outlook",
527
+ clientId: "client-id",
528
+ isInteractive: true,
529
+ callbackTransport: "loopback",
530
+ openUrl: () => {},
531
+ });
532
+
533
+ expect(lastStartArgs!.options).toEqual({
534
+ callbackTransport: "loopback",
535
+ loopbackPort: 17334,
536
+ });
537
+ });
538
+
539
+ test("Outlook + gateway (interactive) → gateway transport", async () => {
540
+ mockProviderStore["outlook"] = OUTLOOK_PROVIDER;
541
+
542
+ await orchestrateOAuthConnect({
543
+ service: "outlook",
544
+ clientId: "client-id",
545
+ isInteractive: true,
546
+ callbackTransport: "gateway",
547
+ openUrl: () => {},
548
+ });
549
+
550
+ expect(lastStartArgs!.options).toEqual({
551
+ callbackTransport: "gateway",
552
+ });
553
+ });
554
+ });
555
+
556
+ // -------------------------------------------------------------------------
557
+ // Backward compatibility
558
+ // -------------------------------------------------------------------------
559
+
560
+ describe("backward compatibility", () => {
561
+ test("missing callbackTransport defaults to loopback (deferred, Google)", async () => {
562
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
563
+
564
+ const result = await orchestrateOAuthConnect({
565
+ service: "google",
566
+ clientId: "client-id",
567
+ isInteractive: false,
568
+ });
569
+
570
+ expect(result.success).toBe(true);
571
+ expect(lastPrepareArgs!.options).toEqual({
572
+ callbackTransport: "loopback",
573
+ loopbackPort: 17332,
574
+ });
575
+ });
576
+
577
+ test("missing callbackTransport defaults to loopback (deferred, Outlook)", async () => {
578
+ mockProviderStore["outlook"] = OUTLOOK_PROVIDER;
579
+
580
+ const result = await orchestrateOAuthConnect({
581
+ service: "outlook",
582
+ clientId: "client-id",
583
+ isInteractive: false,
584
+ });
585
+
586
+ expect(result.success).toBe(true);
587
+ expect(lastPrepareArgs!.options).toEqual({
588
+ callbackTransport: "loopback",
589
+ loopbackPort: 17334,
590
+ });
591
+ });
592
+
593
+ test("missing callbackTransport defaults to loopback (interactive, Google)", async () => {
594
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
595
+
596
+ const result = await orchestrateOAuthConnect({
597
+ service: "google",
598
+ clientId: "client-id",
599
+ isInteractive: true,
600
+ openUrl: () => {},
601
+ });
602
+
603
+ expect(result.success).toBe(true);
604
+ expect(lastStartArgs!.options).toEqual({
605
+ callbackTransport: "loopback",
606
+ loopbackPort: 17332,
607
+ });
608
+ });
609
+
610
+ test("missing callbackTransport defaults to loopback (interactive, Outlook)", async () => {
611
+ mockProviderStore["outlook"] = OUTLOOK_PROVIDER;
612
+
613
+ const result = await orchestrateOAuthConnect({
614
+ service: "outlook",
615
+ clientId: "client-id",
616
+ isInteractive: true,
617
+ openUrl: () => {},
618
+ });
619
+
620
+ expect(result.success).toBe(true);
621
+ expect(lastStartArgs!.options).toEqual({
622
+ callbackTransport: "loopback",
623
+ loopbackPort: 17334,
624
+ });
625
+ });
626
+ });
627
+
628
+ // -------------------------------------------------------------------------
629
+ // Error cases
630
+ // -------------------------------------------------------------------------
631
+
632
+ describe("error cases", () => {
633
+ test("gateway without ingress (deferred, Google) → returns error", async () => {
634
+ mockProviderStore["google"] = GOOGLE_PROVIDER;
635
+ mockPublicBaseUrl = "";
636
+
637
+ const result = await orchestrateOAuthConnect({
638
+ service: "google",
639
+ clientId: "client-id",
640
+ isInteractive: false,
641
+ callbackTransport: "gateway",
642
+ });
643
+
644
+ expect(result.success).toBe(false);
645
+ if (result.success) return;
646
+ expect(result.error).toContain("public ingress URL");
647
+ // prepareOAuth2Flow should NOT have been called
648
+ expect(lastPrepareArgs).toBeNull();
649
+ });
650
+
651
+ test("gateway without ingress (deferred, Outlook) → returns error", async () => {
652
+ mockProviderStore["outlook"] = OUTLOOK_PROVIDER;
653
+ mockPublicBaseUrl = "";
654
+
655
+ const result = await orchestrateOAuthConnect({
656
+ service: "outlook",
657
+ clientId: "client-id",
658
+ isInteractive: false,
659
+ callbackTransport: "gateway",
660
+ });
661
+
662
+ expect(result.success).toBe(false);
663
+ if (result.success) return;
664
+ expect(result.error).toContain("public ingress URL");
665
+ expect(lastPrepareArgs).toBeNull();
666
+ });
667
+
668
+ test("unknown provider → returns error", async () => {
669
+ // No provider registered in the mock store
670
+
671
+ const result = await orchestrateOAuthConnect({
672
+ service: "unknown-provider",
673
+ clientId: "client-id",
674
+ isInteractive: false,
675
+ callbackTransport: "loopback",
676
+ });
677
+
678
+ expect(result.success).toBe(false);
679
+ if (result.success) return;
680
+ expect(result.error).toContain("No OAuth provider registered");
681
+ expect(result.error).toContain("unknown-provider");
682
+ });
683
+ });
684
+
685
+ // -------------------------------------------------------------------------
686
+ // Provider without loopbackPort
687
+ // -------------------------------------------------------------------------
688
+
689
+ describe("provider without loopbackPort", () => {
690
+ test("loopback transport passes undefined loopbackPort when provider has none", async () => {
691
+ mockProviderStore["custom"] = makeProviderRow({
692
+ providerKey: "custom",
693
+ loopbackPort: null, // no fixed port
694
+ });
695
+
696
+ await orchestrateOAuthConnect({
697
+ service: "custom",
698
+ clientId: "client-id",
699
+ isInteractive: false,
700
+ callbackTransport: "loopback",
701
+ });
702
+
703
+ expect(lastPrepareArgs!.options).toEqual({
704
+ callbackTransport: "loopback",
705
+ loopbackPort: undefined,
706
+ });
707
+ });
708
+ });
709
+ });