@vellumai/assistant 0.5.11 → 0.5.13
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/Dockerfile +42 -9
- package/docs/architecture/integrations.md +34 -32
- package/node_modules/@vellumai/ces-contracts/src/__tests__/grants.test.ts +7 -7
- package/node_modules/@vellumai/ces-contracts/src/handles.ts +5 -4
- package/node_modules/@vellumai/ces-contracts/src/index.ts +7 -0
- package/node_modules/@vellumai/ces-contracts/src/rpc.ts +5 -0
- package/node_modules/@vellumai/credential-storage/src/index.ts +1 -1
- package/openapi.yaml +87 -9
- package/package.json +1 -1
- package/src/__tests__/catalog-cache.test.ts +164 -0
- package/src/__tests__/catalog-search.test.ts +61 -0
- package/src/__tests__/cli-command-risk-guard.test.ts +181 -6
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +396 -0
- package/src/__tests__/conversation-error.test.ts +3 -2
- package/src/__tests__/credential-security-invariants.test.ts +9 -15
- package/src/__tests__/credential-vault-unit.test.ts +32 -34
- package/src/__tests__/credential-vault.test.ts +25 -33
- package/src/__tests__/credentials-cli.test.ts +3 -3
- package/src/__tests__/daemon-credential-client.test.ts +2 -2
- package/src/__tests__/first-greeting.test.ts +7 -0
- package/src/__tests__/host-bash-proxy.test.ts +79 -0
- package/src/__tests__/host-cu-proxy.test.ts +90 -0
- package/src/__tests__/host-file-proxy.test.ts +89 -0
- package/src/__tests__/integration-status.test.ts +5 -5
- package/src/__tests__/list-messages-attachments.test.ts +171 -0
- package/src/__tests__/mcp-abort-signal.test.ts +205 -0
- package/src/__tests__/messaging-send-tool.test.ts +5 -5
- package/src/__tests__/navigate-settings-tab.test.ts +6 -2
- package/src/__tests__/notification-telegram-adapter.test.ts +125 -0
- package/src/__tests__/oauth-cli.test.ts +126 -119
- package/src/__tests__/oauth-provider-profiles.test.ts +55 -20
- package/src/__tests__/oauth-scope-policy.test.ts +4 -6
- package/src/__tests__/onboarding-template-contract.test.ts +2 -2
- package/src/__tests__/platform.test.ts +3 -168
- package/src/__tests__/secret-routes-managed-proxy.test.ts +78 -0
- package/src/__tests__/secure-keys-managed-failover.test.ts +73 -0
- package/src/__tests__/skill-feature-flags.test.ts +8 -0
- package/src/__tests__/skill-secret-handling-guard.test.ts +212 -0
- package/src/__tests__/skills-uninstall.test.ts +2 -2
- package/src/__tests__/slack-messaging-token-resolution.test.ts +22 -24
- package/src/__tests__/slack-share-routes.test.ts +5 -5
- package/src/__tests__/system-prompt.test.ts +39 -0
- package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -1
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +5 -4
- package/src/cli/AGENTS.md +47 -7
- package/src/cli/commands/browser-relay.ts +2 -17
- package/src/cli/commands/contacts.ts +6 -4
- package/src/cli/commands/conversations.ts +13 -1
- package/src/cli/commands/credential-execution.ts +16 -1
- package/src/cli/commands/credentials.ts +2 -8
- package/src/cli/commands/oauth/__tests__/connect.test.ts +29 -108
- package/src/cli/commands/oauth/__tests__/disconnect.test.ts +13 -87
- package/src/cli/commands/oauth/__tests__/mode.test.ts +22 -69
- package/src/cli/commands/oauth/__tests__/ping.test.ts +20 -79
- package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +574 -0
- package/src/cli/commands/oauth/__tests__/providers-update.test.ts +416 -0
- package/src/cli/commands/oauth/__tests__/status.test.ts +12 -40
- package/src/cli/commands/oauth/__tests__/token.test.ts +3 -50
- package/src/cli/commands/oauth/apps.ts +63 -44
- package/src/cli/commands/oauth/connect.ts +187 -155
- package/src/cli/commands/oauth/disconnect.ts +27 -75
- package/src/cli/commands/oauth/index.ts +36 -46
- package/src/cli/commands/oauth/mode.ts +22 -34
- package/src/cli/commands/oauth/ping.ts +19 -45
- package/src/cli/commands/oauth/providers.ts +569 -62
- package/src/cli/commands/oauth/request.ts +36 -48
- package/src/cli/commands/oauth/shared.ts +1 -19
- package/src/cli/commands/oauth/status.ts +14 -25
- package/src/cli/commands/oauth/token.ts +25 -34
- package/src/cli/commands/platform/__tests__/connect.test.ts +224 -0
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +237 -0
- package/src/cli/commands/platform/__tests__/status.test.ts +246 -0
- package/src/cli/commands/platform/connect.ts +104 -0
- package/src/cli/commands/platform/disconnect.ts +118 -0
- package/src/cli/commands/{platform.ts → platform/index.ts} +108 -38
- package/src/cli/commands/sequence.ts +5 -4
- package/src/cli/commands/shotgun.ts +16 -0
- package/src/cli/commands/skills.ts +173 -41
- package/src/cli/commands/usage.ts +5 -11
- package/src/cli/lib/daemon-credential-client.ts +22 -38
- package/src/cli/program.ts +1 -1
- package/src/config/assistant-feature-flags.ts +3 -7
- package/src/config/bundled-skills/contacts/tools/google-contacts.ts +1 -1
- package/src/config/bundled-skills/conversations/SKILL.md +20 -0
- package/src/config/bundled-skills/conversations/TOOLS.json +23 -0
- package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +66 -0
- package/src/config/bundled-skills/gmail/SKILL.md +13 -13
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +3 -3
- package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +2 -2
- package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-label.ts +2 -2
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +1 -1
- package/src/config/bundled-skills/google-calendar/SKILL.md +10 -4
- package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
- package/src/config/bundled-skills/messaging/SKILL.md +7 -7
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -2
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -6
- package/src/config/bundled-skills/settings/TOOLS.json +5 -3
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +4 -2
- package/src/config/bundled-tool-registry.ts +5 -0
- package/src/config/feature-flag-registry.json +2 -2
- package/src/credential-execution/client.ts +15 -3
- package/src/daemon/conversation-agent-loop.ts +2 -0
- package/src/daemon/conversation-error.ts +36 -6
- package/src/daemon/conversation-messaging.ts +9 -0
- package/src/daemon/conversation-runtime-assembly.ts +33 -0
- package/src/daemon/conversation-surfaces.ts +120 -14
- package/src/daemon/conversation.ts +5 -0
- package/src/daemon/first-greeting.ts +6 -1
- package/src/daemon/handlers/skills.ts +148 -3
- package/src/daemon/host-bash-proxy.ts +16 -0
- package/src/daemon/host-cu-proxy.ts +16 -0
- package/src/daemon/host-file-proxy.ts +16 -0
- package/src/daemon/lifecycle.ts +56 -5
- package/src/daemon/message-types/conversations.ts +1 -0
- package/src/daemon/message-types/guardian-actions.ts +2 -0
- package/src/daemon/message-types/host-bash.ts +6 -1
- package/src/daemon/message-types/host-cu.ts +6 -1
- package/src/daemon/message-types/host-file.ts +6 -1
- package/src/daemon/message-types/integrations.ts +0 -1
- package/src/daemon/server.ts +29 -2
- package/src/hooks/cli.ts +74 -0
- package/src/inbound/platform-callback-registration.ts +7 -12
- package/src/index.ts +0 -12
- package/src/mcp/client.ts +6 -1
- package/src/mcp/manager.ts +2 -1
- package/src/memory/conversation-crud.ts +92 -3
- package/src/memory/conversation-key-store.ts +26 -0
- package/src/memory/conversation-queries.ts +6 -6
- package/src/memory/db-init.ts +16 -0
- package/src/memory/journal-memory.ts +8 -2
- package/src/memory/migrations/196-messages-conversation-created-at-index.ts +9 -0
- package/src/memory/migrations/196-strip-integration-prefix-from-provider-keys.ts +186 -0
- package/src/memory/migrations/197-oauth-providers-behavior-columns.ts +29 -0
- package/src/memory/migrations/198-drop-setup-skill-id-column.ts +11 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/schema/oauth.ts +11 -0
- package/src/messaging/provider.ts +13 -12
- package/src/messaging/providers/gmail/adapter.ts +44 -35
- package/src/messaging/providers/slack/adapter.ts +63 -33
- package/src/messaging/providers/telegram-bot/adapter.ts +6 -8
- package/src/messaging/providers/whatsapp/adapter.ts +6 -8
- package/src/notifications/adapters/telegram.ts +78 -2
- package/src/oauth/__tests__/identity-verifier.test.ts +464 -0
- package/src/oauth/byo-connection.test.ts +22 -24
- package/src/oauth/connect-orchestrator.ts +37 -76
- package/src/oauth/connect-types.ts +7 -65
- package/src/oauth/connection-resolver.test.ts +13 -13
- package/src/oauth/connection-resolver.ts +3 -4
- package/src/oauth/identity-verifier.ts +177 -0
- package/src/oauth/oauth-store.ts +228 -3
- package/src/oauth/platform-connection.test.ts +56 -6
- package/src/oauth/platform-connection.ts +8 -1
- package/src/oauth/seed-providers.ts +247 -34
- package/src/permissions/checker.ts +127 -1
- package/src/prompts/journal-context.ts +4 -1
- package/src/prompts/system-prompt.ts +54 -9
- package/src/prompts/templates/BOOTSTRAP.md +16 -5
- package/src/providers/anthropic/client.ts +2 -33
- package/src/runtime/guardian-action-service.ts +7 -2
- package/src/runtime/http-server.ts +12 -18
- package/src/runtime/http-types.ts +8 -1
- package/src/runtime/migrations/rebind-secrets-screen.ts +2 -2
- package/src/runtime/routes/conversation-management-routes.ts +31 -0
- package/src/runtime/routes/conversation-routes.ts +79 -4
- package/src/runtime/routes/guardian-action-routes.ts +15 -2
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -8
- package/src/runtime/routes/integrations/slack/share.ts +1 -1
- package/src/runtime/routes/oauth-apps.ts +2 -1
- package/src/runtime/routes/secret-routes.ts +45 -15
- package/src/runtime/routes/settings-routes.ts +12 -19
- package/src/runtime/routes/skills-routes.ts +45 -4
- package/src/schedule/integration-status.ts +2 -2
- package/src/security/ces-rpc-credential-backend.ts +19 -16
- package/src/security/oauth-completion-page.ts +153 -0
- package/src/security/oauth2.ts +3 -17
- package/src/security/secure-keys.ts +207 -7
- package/src/security/token-manager.ts +3 -6
- package/src/signals/bash.ts +6 -1
- package/src/skills/catalog-cache.ts +44 -0
- package/src/skills/catalog-search.ts +18 -0
- package/src/tools/browser/browser-manager.ts +2 -2
- package/src/tools/credentials/post-connect-hooks.ts +1 -1
- package/src/tools/credentials/vault.ts +34 -45
- package/src/tools/host-terminal/host-shell.ts +16 -3
- package/src/tools/mcp/mcp-tool-factory.ts +2 -1
- package/src/tools/skills/sandbox-runner.ts +16 -3
- package/src/tools/terminal/shell.ts +16 -3
- package/src/util/logger.ts +11 -1
- package/src/util/platform.ts +1 -91
- package/src/util/sentry-log-stream.ts +51 -0
- package/src/watcher/providers/github.ts +2 -2
- package/src/watcher/providers/gmail.ts +1 -1
- package/src/watcher/providers/google-calendar.ts +1 -1
- package/src/watcher/providers/linear.ts +2 -2
- package/src/workspace/migrations/011-backfill-installation-id.ts +5 -3
- package/src/workspace/migrations/020-rename-oauth-skill-dirs.ts +119 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/cli/commands/oauth/connections.ts +0 -255
- package/src/oauth/provider-behaviors.ts +0 -634
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { verifyIdentity } from "../identity-verifier.js";
|
|
4
|
+
import type { OAuthProviderRow } from "../oauth-store.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Mock fetch
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const originalFetch = globalThis.fetch;
|
|
11
|
+
let mockFetch: ReturnType<typeof mock<any>>;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
mockFetch = mock(() => Promise.resolve(new Response("{}", { status: 200 })));
|
|
15
|
+
globalThis.fetch = mockFetch as unknown as typeof fetch;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
globalThis.fetch = originalFetch;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Helper: build a minimal OAuthProviderRow with identity fields
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function makeProviderRow(
|
|
27
|
+
overrides: Partial<OAuthProviderRow> & { providerKey: string },
|
|
28
|
+
): OAuthProviderRow {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
const { providerKey, ...rest } = overrides;
|
|
31
|
+
return {
|
|
32
|
+
providerKey,
|
|
33
|
+
authUrl: "https://example.com/auth",
|
|
34
|
+
tokenUrl: "https://example.com/token",
|
|
35
|
+
tokenEndpointAuthMethod: null,
|
|
36
|
+
userinfoUrl: null,
|
|
37
|
+
baseUrl: null,
|
|
38
|
+
defaultScopes: "[]",
|
|
39
|
+
scopePolicy: "{}",
|
|
40
|
+
extraParams: null,
|
|
41
|
+
callbackTransport: null,
|
|
42
|
+
pingUrl: null,
|
|
43
|
+
pingMethod: null,
|
|
44
|
+
pingHeaders: null,
|
|
45
|
+
pingBody: null,
|
|
46
|
+
managedServiceConfigKey: null,
|
|
47
|
+
displayName: null,
|
|
48
|
+
description: null,
|
|
49
|
+
dashboardUrl: null,
|
|
50
|
+
clientIdPlaceholder: null,
|
|
51
|
+
requiresClientSecret: 1,
|
|
52
|
+
loopbackPort: null,
|
|
53
|
+
injectionTemplates: null,
|
|
54
|
+
appType: null,
|
|
55
|
+
setupNotes: null,
|
|
56
|
+
identityUrl: null,
|
|
57
|
+
identityMethod: null,
|
|
58
|
+
identityHeaders: null,
|
|
59
|
+
identityBody: null,
|
|
60
|
+
identityResponsePaths: null,
|
|
61
|
+
identityFormat: null,
|
|
62
|
+
identityOkField: null,
|
|
63
|
+
createdAt: now,
|
|
64
|
+
updatedAt: now,
|
|
65
|
+
...rest,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
70
|
+
return new Response(JSON.stringify(body), {
|
|
71
|
+
status,
|
|
72
|
+
headers: { "Content-Type": "application/json" },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Tests
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
describe("verifyIdentity", () => {
|
|
81
|
+
// -----------------------------------------------------------------------
|
|
82
|
+
// Missing identity URL
|
|
83
|
+
// -----------------------------------------------------------------------
|
|
84
|
+
test("returns undefined when identityUrl is null", async () => {
|
|
85
|
+
const row = makeProviderRow({ providerKey: "custom" });
|
|
86
|
+
const result = await verifyIdentity(row, "token-abc");
|
|
87
|
+
expect(result).toBeUndefined();
|
|
88
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// -----------------------------------------------------------------------
|
|
92
|
+
// Google: simple GET, extract email
|
|
93
|
+
// -----------------------------------------------------------------------
|
|
94
|
+
describe("Google pattern", () => {
|
|
95
|
+
const googleRow = makeProviderRow({
|
|
96
|
+
providerKey: "google",
|
|
97
|
+
identityUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
|
|
98
|
+
identityResponsePaths: JSON.stringify(["email"]),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("extracts email from response", async () => {
|
|
102
|
+
mockFetch.mockResolvedValueOnce(
|
|
103
|
+
jsonResponse({ email: "user@gmail.com", name: "Test User" }),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const result = await verifyIdentity(googleRow, "google-token");
|
|
107
|
+
|
|
108
|
+
expect(result).toBe("user@gmail.com");
|
|
109
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
110
|
+
|
|
111
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
112
|
+
expect(url).toBe("https://www.googleapis.com/oauth2/v2/userinfo");
|
|
113
|
+
expect((init as RequestInit).method).toBe("GET");
|
|
114
|
+
const headers = (init as RequestInit).headers as Record<string, string>;
|
|
115
|
+
expect(headers["Authorization"]).toBe("Bearer google-token");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("returns undefined when email is missing", async () => {
|
|
119
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({ name: "Test User" }));
|
|
120
|
+
const result = await verifyIdentity(googleRow, "google-token");
|
|
121
|
+
expect(result).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// -----------------------------------------------------------------------
|
|
126
|
+
// Slack: GET with ok check + format template
|
|
127
|
+
// -----------------------------------------------------------------------
|
|
128
|
+
describe("Slack pattern", () => {
|
|
129
|
+
const slackRow = makeProviderRow({
|
|
130
|
+
providerKey: "slack",
|
|
131
|
+
identityUrl: "https://slack.com/api/auth.test",
|
|
132
|
+
identityOkField: "ok",
|
|
133
|
+
identityResponsePaths: JSON.stringify(["user", "team"]),
|
|
134
|
+
identityFormat: "@${user} (${team})",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("returns formatted string when all fields present", async () => {
|
|
138
|
+
mockFetch.mockResolvedValueOnce(
|
|
139
|
+
jsonResponse({ ok: true, user: "alice", team: "acme-corp" }),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const result = await verifyIdentity(slackRow, "slack-token");
|
|
143
|
+
expect(result).toBe("@alice (acme-corp)");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("returns @user when team is missing (fallback)", async () => {
|
|
147
|
+
mockFetch.mockResolvedValueOnce(
|
|
148
|
+
jsonResponse({ ok: true, user: "alice" }),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const result = await verifyIdentity(slackRow, "slack-token");
|
|
152
|
+
expect(result).toBe("@alice");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("returns undefined when ok is false", async () => {
|
|
156
|
+
mockFetch.mockResolvedValueOnce(
|
|
157
|
+
jsonResponse({ ok: false, user: "alice", team: "acme-corp" }),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const result = await verifyIdentity(slackRow, "slack-token");
|
|
161
|
+
expect(result).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("returns undefined when ok field is missing", async () => {
|
|
165
|
+
mockFetch.mockResolvedValueOnce(
|
|
166
|
+
jsonResponse({ user: "alice", team: "acme-corp" }),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const result = await verifyIdentity(slackRow, "slack-token");
|
|
170
|
+
expect(result).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// -----------------------------------------------------------------------
|
|
175
|
+
// HubSpot: URL-interpolated token, no Authorization header
|
|
176
|
+
// -----------------------------------------------------------------------
|
|
177
|
+
describe("HubSpot pattern", () => {
|
|
178
|
+
const hubspotRow = makeProviderRow({
|
|
179
|
+
providerKey: "hubspot",
|
|
180
|
+
identityUrl:
|
|
181
|
+
"https://api.hubapi.com/oauth/v1/access-tokens/${accessToken}",
|
|
182
|
+
identityResponsePaths: JSON.stringify(["user", "hub_domain"]),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("interpolates token in URL and skips Authorization header", async () => {
|
|
186
|
+
mockFetch.mockResolvedValueOnce(
|
|
187
|
+
jsonResponse({
|
|
188
|
+
user: "admin@hubspot.com",
|
|
189
|
+
hub_domain: "mycompany.hubspot.com",
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const result = await verifyIdentity(hubspotRow, "hs-token-123");
|
|
194
|
+
|
|
195
|
+
expect(result).toBe("admin@hubspot.com");
|
|
196
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
197
|
+
|
|
198
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
199
|
+
expect(url).toBe(
|
|
200
|
+
"https://api.hubapi.com/oauth/v1/access-tokens/hs-token-123",
|
|
201
|
+
);
|
|
202
|
+
const headers = (init as RequestInit).headers as Record<string, string>;
|
|
203
|
+
// Should NOT have Authorization header since token is in URL
|
|
204
|
+
expect(headers["Authorization"]).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("falls back to hub_domain when user is missing", async () => {
|
|
208
|
+
mockFetch.mockResolvedValueOnce(
|
|
209
|
+
jsonResponse({ hub_domain: "mycompany.hubspot.com" }),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const result = await verifyIdentity(hubspotRow, "hs-token-123");
|
|
213
|
+
expect(result).toBe("mycompany.hubspot.com");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// -----------------------------------------------------------------------
|
|
218
|
+
// Linear: POST with JSON body, GraphQL response
|
|
219
|
+
// -----------------------------------------------------------------------
|
|
220
|
+
describe("Linear pattern", () => {
|
|
221
|
+
const linearRow = makeProviderRow({
|
|
222
|
+
providerKey: "linear",
|
|
223
|
+
identityUrl: "https://api.linear.app/graphql",
|
|
224
|
+
identityMethod: "POST",
|
|
225
|
+
identityHeaders: JSON.stringify({ "Content-Type": "application/json" }),
|
|
226
|
+
identityBody: JSON.stringify({
|
|
227
|
+
query: "{ viewer { email name } }",
|
|
228
|
+
}),
|
|
229
|
+
identityResponsePaths: JSON.stringify([
|
|
230
|
+
"data.viewer.email",
|
|
231
|
+
"data.viewer.name",
|
|
232
|
+
]),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("extracts email from nested GraphQL response", async () => {
|
|
236
|
+
mockFetch.mockResolvedValueOnce(
|
|
237
|
+
jsonResponse({
|
|
238
|
+
data: { viewer: { email: "dev@linear.app", name: "Dev User" } },
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const result = await verifyIdentity(linearRow, "linear-token");
|
|
243
|
+
|
|
244
|
+
expect(result).toBe("dev@linear.app");
|
|
245
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
246
|
+
|
|
247
|
+
const [url, init] = mockFetch.mock.calls[0];
|
|
248
|
+
expect(url).toBe("https://api.linear.app/graphql");
|
|
249
|
+
expect((init as RequestInit).method).toBe("POST");
|
|
250
|
+
const headers = (init as RequestInit).headers as Record<string, string>;
|
|
251
|
+
expect(headers["Authorization"]).toBe("Bearer linear-token");
|
|
252
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
253
|
+
expect((init as RequestInit).body).toBe(
|
|
254
|
+
JSON.stringify({ query: "{ viewer { email name } }" }),
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("falls back to name when email is missing", async () => {
|
|
259
|
+
mockFetch.mockResolvedValueOnce(
|
|
260
|
+
jsonResponse({
|
|
261
|
+
data: { viewer: { name: "Dev User" } },
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const result = await verifyIdentity(linearRow, "linear-token");
|
|
266
|
+
expect(result).toBe("Dev User");
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// -----------------------------------------------------------------------
|
|
271
|
+
// Todoist: POST with form body
|
|
272
|
+
// -----------------------------------------------------------------------
|
|
273
|
+
describe("Todoist pattern", () => {
|
|
274
|
+
const todoistRow = makeProviderRow({
|
|
275
|
+
providerKey: "todoist",
|
|
276
|
+
identityUrl: "https://api.todoist.com/sync/v9/sync",
|
|
277
|
+
identityMethod: "POST",
|
|
278
|
+
identityHeaders: JSON.stringify({
|
|
279
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
280
|
+
}),
|
|
281
|
+
identityBody: JSON.stringify("sync_token=*&resource_types=[%22user%22]"),
|
|
282
|
+
identityResponsePaths: JSON.stringify(["user.full_name", "user.email"]),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("extracts full_name from nested response", async () => {
|
|
286
|
+
mockFetch.mockResolvedValueOnce(
|
|
287
|
+
jsonResponse({
|
|
288
|
+
user: { full_name: "Jane Doe", email: "jane@example.com" },
|
|
289
|
+
}),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const result = await verifyIdentity(todoistRow, "todoist-token");
|
|
293
|
+
|
|
294
|
+
expect(result).toBe("Jane Doe");
|
|
295
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
296
|
+
|
|
297
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
298
|
+
expect((init as RequestInit).method).toBe("POST");
|
|
299
|
+
// Body should be the form-encoded string
|
|
300
|
+
expect((init as RequestInit).body).toBe(
|
|
301
|
+
"sync_token=*&resource_types=[%22user%22]",
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("falls back to email when full_name is missing", async () => {
|
|
306
|
+
mockFetch.mockResolvedValueOnce(
|
|
307
|
+
jsonResponse({ user: { email: "jane@example.com" } }),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const result = await verifyIdentity(todoistRow, "todoist-token");
|
|
311
|
+
expect(result).toBe("jane@example.com");
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// -----------------------------------------------------------------------
|
|
316
|
+
// Twitter: format template with nested path
|
|
317
|
+
// -----------------------------------------------------------------------
|
|
318
|
+
describe("Twitter pattern", () => {
|
|
319
|
+
const twitterRow = makeProviderRow({
|
|
320
|
+
providerKey: "twitter",
|
|
321
|
+
identityUrl: "https://api.x.com/2/users/me",
|
|
322
|
+
identityResponsePaths: JSON.stringify(["data.username"]),
|
|
323
|
+
identityFormat: "@${data.username}",
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("returns formatted @username", async () => {
|
|
327
|
+
mockFetch.mockResolvedValueOnce(
|
|
328
|
+
jsonResponse({ data: { username: "elonmusk" } }),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const result = await verifyIdentity(twitterRow, "twitter-token");
|
|
332
|
+
expect(result).toBe("@elonmusk");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// -----------------------------------------------------------------------
|
|
337
|
+
// GitHub: format template with simple path
|
|
338
|
+
// -----------------------------------------------------------------------
|
|
339
|
+
describe("GitHub pattern", () => {
|
|
340
|
+
const githubRow = makeProviderRow({
|
|
341
|
+
providerKey: "github",
|
|
342
|
+
identityUrl: "https://api.github.com/user",
|
|
343
|
+
identityResponsePaths: JSON.stringify(["login"]),
|
|
344
|
+
identityFormat: "@${login}",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("returns formatted @login", async () => {
|
|
348
|
+
mockFetch.mockResolvedValueOnce(jsonResponse({ login: "octocat" }));
|
|
349
|
+
|
|
350
|
+
const result = await verifyIdentity(githubRow, "gh-token");
|
|
351
|
+
expect(result).toBe("@octocat");
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// -----------------------------------------------------------------------
|
|
356
|
+
// Notion: custom headers, multiple fallback paths
|
|
357
|
+
// -----------------------------------------------------------------------
|
|
358
|
+
describe("Notion pattern", () => {
|
|
359
|
+
const notionRow = makeProviderRow({
|
|
360
|
+
providerKey: "notion",
|
|
361
|
+
identityUrl: "https://api.notion.com/v1/users/me",
|
|
362
|
+
identityHeaders: JSON.stringify({ "Notion-Version": "2022-06-28" }),
|
|
363
|
+
identityResponsePaths: JSON.stringify(["name", "person.email"]),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("returns name when present", async () => {
|
|
367
|
+
mockFetch.mockResolvedValueOnce(
|
|
368
|
+
jsonResponse({ name: "Test Bot", person: { email: "user@notion.so" } }),
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const result = await verifyIdentity(notionRow, "notion-token");
|
|
372
|
+
expect(result).toBe("Test Bot");
|
|
373
|
+
|
|
374
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
375
|
+
const headers = (init as RequestInit).headers as Record<string, string>;
|
|
376
|
+
expect(headers["Notion-Version"]).toBe("2022-06-28");
|
|
377
|
+
expect(headers["Authorization"]).toBe("Bearer notion-token");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("falls back to person.email when name is missing", async () => {
|
|
381
|
+
mockFetch.mockResolvedValueOnce(
|
|
382
|
+
jsonResponse({ person: { email: "user@notion.so" } }),
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const result = await verifyIdentity(notionRow, "notion-token");
|
|
386
|
+
expect(result).toBe("user@notion.so");
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// -----------------------------------------------------------------------
|
|
391
|
+
// Error handling
|
|
392
|
+
// -----------------------------------------------------------------------
|
|
393
|
+
describe("error handling", () => {
|
|
394
|
+
const googleRow = makeProviderRow({
|
|
395
|
+
providerKey: "google",
|
|
396
|
+
identityUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
|
|
397
|
+
identityResponsePaths: JSON.stringify(["email"]),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("returns undefined on fetch failure", async () => {
|
|
401
|
+
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
|
402
|
+
|
|
403
|
+
const result = await verifyIdentity(googleRow, "token");
|
|
404
|
+
expect(result).toBeUndefined();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("returns undefined on non-OK response", async () => {
|
|
408
|
+
mockFetch.mockResolvedValueOnce(
|
|
409
|
+
new Response("Unauthorized", { status: 401 }),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const result = await verifyIdentity(googleRow, "token");
|
|
413
|
+
expect(result).toBeUndefined();
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("returns undefined on invalid JSON response", async () => {
|
|
417
|
+
mockFetch.mockResolvedValueOnce(
|
|
418
|
+
new Response("not json", {
|
|
419
|
+
status: 200,
|
|
420
|
+
headers: { "Content-Type": "application/json" },
|
|
421
|
+
}),
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const result = await verifyIdentity(googleRow, "token");
|
|
425
|
+
expect(result).toBeUndefined();
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
// -----------------------------------------------------------------------
|
|
430
|
+
// Dropbox: POST with no explicit body
|
|
431
|
+
// -----------------------------------------------------------------------
|
|
432
|
+
describe("Dropbox pattern", () => {
|
|
433
|
+
const dropboxRow = makeProviderRow({
|
|
434
|
+
providerKey: "dropbox",
|
|
435
|
+
identityUrl: "https://api.dropboxapi.com/2/users/get_current_account",
|
|
436
|
+
identityMethod: "POST",
|
|
437
|
+
identityResponsePaths: JSON.stringify(["name.display_name", "email"]),
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("extracts nested display_name", async () => {
|
|
441
|
+
mockFetch.mockResolvedValueOnce(
|
|
442
|
+
jsonResponse({
|
|
443
|
+
name: { display_name: "Jane Doe" },
|
|
444
|
+
email: "jane@dropbox.com",
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const result = await verifyIdentity(dropboxRow, "dbx-token");
|
|
449
|
+
expect(result).toBe("Jane Doe");
|
|
450
|
+
|
|
451
|
+
const [, init] = mockFetch.mock.calls[0];
|
|
452
|
+
expect((init as RequestInit).method).toBe("POST");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("falls back to email when name is missing", async () => {
|
|
456
|
+
mockFetch.mockResolvedValueOnce(
|
|
457
|
+
jsonResponse({ email: "jane@dropbox.com" }),
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
const result = await verifyIdentity(dropboxRow, "dbx-token");
|
|
461
|
+
expect(result).toBe("jane@dropbox.com");
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
});
|
|
@@ -195,7 +195,7 @@ async function setupCredential(
|
|
|
195
195
|
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
196
196
|
// Only well-known providers (gmail) have a baseUrl; custom services don't
|
|
197
197
|
baseUrl:
|
|
198
|
-
service === "
|
|
198
|
+
service === "google"
|
|
199
199
|
? "https://gmail.googleapis.com/gmail/v1/users/me"
|
|
200
200
|
: undefined,
|
|
201
201
|
});
|
|
@@ -230,7 +230,7 @@ async function setupCredential(
|
|
|
230
230
|
upsertCredentialMetadata(service, "access_token", {});
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
function createConnection(service = "
|
|
233
|
+
function createConnection(service = "google"): BYOOAuthConnection {
|
|
234
234
|
return new BYOOAuthConnection({
|
|
235
235
|
id: `conn-${service}`,
|
|
236
236
|
providerKey: service,
|
|
@@ -246,7 +246,7 @@ function createConnection(service = "integration:google"): BYOOAuthConnection {
|
|
|
246
246
|
describe("BYOOAuthConnection", () => {
|
|
247
247
|
describe("request()", () => {
|
|
248
248
|
test("makes authenticated request with Bearer token", async () => {
|
|
249
|
-
await setupCredential("
|
|
249
|
+
await setupCredential("google");
|
|
250
250
|
const conn = createConnection();
|
|
251
251
|
|
|
252
252
|
const result = await conn.request({
|
|
@@ -270,7 +270,7 @@ describe("BYOOAuthConnection", () => {
|
|
|
270
270
|
});
|
|
271
271
|
|
|
272
272
|
test("appends query parameters", async () => {
|
|
273
|
-
await setupCredential("
|
|
273
|
+
await setupCredential("google");
|
|
274
274
|
const conn = createConnection();
|
|
275
275
|
|
|
276
276
|
await conn.request({
|
|
@@ -286,7 +286,7 @@ describe("BYOOAuthConnection", () => {
|
|
|
286
286
|
});
|
|
287
287
|
|
|
288
288
|
test("uses per-request baseUrl override", async () => {
|
|
289
|
-
await setupCredential("
|
|
289
|
+
await setupCredential("google");
|
|
290
290
|
const conn = createConnection();
|
|
291
291
|
|
|
292
292
|
await conn.request({
|
|
@@ -300,7 +300,7 @@ describe("BYOOAuthConnection", () => {
|
|
|
300
300
|
});
|
|
301
301
|
|
|
302
302
|
test("sends JSON body for POST requests", async () => {
|
|
303
|
-
await setupCredential("
|
|
303
|
+
await setupCredential("google");
|
|
304
304
|
const conn = createConnection();
|
|
305
305
|
|
|
306
306
|
await conn.request({
|
|
@@ -320,7 +320,7 @@ describe("BYOOAuthConnection", () => {
|
|
|
320
320
|
});
|
|
321
321
|
|
|
322
322
|
test("retries once on 401 response", async () => {
|
|
323
|
-
await setupCredential("
|
|
323
|
+
await setupCredential("google");
|
|
324
324
|
const conn = createConnection();
|
|
325
325
|
|
|
326
326
|
// First call returns 401, second returns 200
|
|
@@ -348,7 +348,7 @@ describe("BYOOAuthConnection", () => {
|
|
|
348
348
|
});
|
|
349
349
|
|
|
350
350
|
test("handles empty response body", async () => {
|
|
351
|
-
await setupCredential("
|
|
351
|
+
await setupCredential("google");
|
|
352
352
|
const conn = createConnection();
|
|
353
353
|
|
|
354
354
|
globalThis.fetch = mock(() =>
|
|
@@ -365,7 +365,7 @@ describe("BYOOAuthConnection", () => {
|
|
|
365
365
|
});
|
|
366
366
|
|
|
367
367
|
test("handles non-JSON response body", async () => {
|
|
368
|
-
await setupCredential("
|
|
368
|
+
await setupCredential("google");
|
|
369
369
|
const conn = createConnection();
|
|
370
370
|
|
|
371
371
|
globalThis.fetch = mock(() =>
|
|
@@ -382,7 +382,7 @@ describe("BYOOAuthConnection", () => {
|
|
|
382
382
|
});
|
|
383
383
|
|
|
384
384
|
test("returns response headers", async () => {
|
|
385
|
-
await setupCredential("
|
|
385
|
+
await setupCredential("google");
|
|
386
386
|
const conn = createConnection();
|
|
387
387
|
|
|
388
388
|
globalThis.fetch = mock(() =>
|
|
@@ -406,7 +406,7 @@ describe("BYOOAuthConnection", () => {
|
|
|
406
406
|
});
|
|
407
407
|
|
|
408
408
|
test("includes custom request headers", async () => {
|
|
409
|
-
await setupCredential("
|
|
409
|
+
await setupCredential("google");
|
|
410
410
|
const conn = createConnection();
|
|
411
411
|
|
|
412
412
|
await conn.request({
|
|
@@ -425,7 +425,7 @@ describe("BYOOAuthConnection", () => {
|
|
|
425
425
|
describe("proactive token refresh", () => {
|
|
426
426
|
test("refreshes token when near expiry (within 5-minute buffer)", async () => {
|
|
427
427
|
// Set token to expire in 2 minutes (within 5-min buffer)
|
|
428
|
-
await setupCredential("
|
|
428
|
+
await setupCredential("google", {
|
|
429
429
|
expiresAt: Date.now() + 2 * 60 * 1000,
|
|
430
430
|
});
|
|
431
431
|
const conn = createConnection();
|
|
@@ -449,7 +449,7 @@ describe("BYOOAuthConnection", () => {
|
|
|
449
449
|
|
|
450
450
|
describe("withToken()", () => {
|
|
451
451
|
test("provides valid token to callback", async () => {
|
|
452
|
-
await setupCredential("
|
|
452
|
+
await setupCredential("google");
|
|
453
453
|
const conn = createConnection();
|
|
454
454
|
|
|
455
455
|
const result = await conn.withToken(async (token) => {
|
|
@@ -460,7 +460,7 @@ describe("BYOOAuthConnection", () => {
|
|
|
460
460
|
});
|
|
461
461
|
|
|
462
462
|
test("retries callback on 401 error", async () => {
|
|
463
|
-
await setupCredential("
|
|
463
|
+
await setupCredential("google");
|
|
464
464
|
const conn = createConnection();
|
|
465
465
|
|
|
466
466
|
let callCount = 0;
|
|
@@ -493,25 +493,23 @@ describe("BYOOAuthConnection", () => {
|
|
|
493
493
|
|
|
494
494
|
describe("resolveOAuthConnection", () => {
|
|
495
495
|
test("returns a BYOOAuthConnection for valid credential", async () => {
|
|
496
|
-
await setupCredential("
|
|
497
|
-
const conn = await resolveOAuthConnection("
|
|
496
|
+
await setupCredential("google");
|
|
497
|
+
const conn = await resolveOAuthConnection("google");
|
|
498
498
|
|
|
499
499
|
expect(conn).toBeInstanceOf(BYOOAuthConnection);
|
|
500
|
-
expect(conn.providerKey).toBe("
|
|
500
|
+
expect(conn.providerKey).toBe("google");
|
|
501
501
|
});
|
|
502
502
|
|
|
503
503
|
test("throws when no credential metadata exists", async () => {
|
|
504
|
-
await expect(resolveOAuthConnection("
|
|
505
|
-
/No active OAuth connection found for "
|
|
504
|
+
await expect(resolveOAuthConnection("unknown")).rejects.toThrow(
|
|
505
|
+
/No active OAuth connection found for "unknown"/,
|
|
506
506
|
);
|
|
507
507
|
});
|
|
508
508
|
|
|
509
509
|
test("throws when no base URL configured", async () => {
|
|
510
|
-
await setupCredential("
|
|
511
|
-
await expect(
|
|
512
|
-
|
|
513
|
-
).rejects.toThrow(
|
|
514
|
-
/No base URL configured for "integration:custom-service"/,
|
|
510
|
+
await setupCredential("custom-service");
|
|
511
|
+
await expect(resolveOAuthConnection("custom-service")).rejects.toThrow(
|
|
512
|
+
/No base URL configured for "custom-service"/,
|
|
515
513
|
);
|
|
516
514
|
});
|
|
517
515
|
});
|