@vellumai/assistant 0.4.48 → 0.4.49

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 (252) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/README.md +2 -23
  3. package/docs/architecture/integrations.md +45 -41
  4. package/docs/architecture/keychain-broker.md +3 -3
  5. package/docs/runbook-trusted-contacts.md +3 -8
  6. package/hook-templates/debug-prompt-logger/hook.json +1 -1
  7. package/hook-templates/debug-prompt-logger/run.sh +1 -3
  8. package/package.json +1 -1
  9. package/src/__tests__/actor-token-service.test.ts +0 -1
  10. package/src/__tests__/anthropic-provider.test.ts +156 -0
  11. package/src/__tests__/approval-cascade.test.ts +810 -0
  12. package/src/__tests__/approval-primitive.test.ts +0 -1
  13. package/src/__tests__/approval-routes-http.test.ts +2 -0
  14. package/src/__tests__/assistant-attachments.test.ts +12 -34
  15. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +76 -0
  16. package/src/__tests__/assistant-feature-flags-integration.test.ts +0 -1
  17. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
  18. package/src/__tests__/channel-guardian.test.ts +0 -2
  19. package/src/__tests__/channel-readiness-routes.test.ts +15 -6
  20. package/src/__tests__/channel-readiness-service.test.ts +10 -9
  21. package/src/__tests__/checker.test.ts +9 -29
  22. package/src/__tests__/computer-use-skill-manifest-regression.test.ts +1 -1
  23. package/src/__tests__/computer-use-tools.test.ts +2 -19
  24. package/src/__tests__/config-watcher.test.ts +0 -1
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +0 -1
  26. package/src/__tests__/context-image-dimensions.test.ts +332 -0
  27. package/src/__tests__/context-token-estimator.test.ts +196 -13
  28. package/src/__tests__/conversation-attention-store.test.ts +0 -1
  29. package/src/__tests__/conversation-attention-telegram.test.ts +0 -1
  30. package/src/__tests__/conversation-routes-guardian-reply.test.ts +144 -0
  31. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  32. package/src/__tests__/credential-metadata-store.test.ts +64 -73
  33. package/src/__tests__/credential-security-invariants.test.ts +13 -7
  34. package/src/__tests__/credential-vault-unit.test.ts +280 -49
  35. package/src/__tests__/credential-vault.test.ts +138 -16
  36. package/src/__tests__/credentials-cli.test.ts +71 -0
  37. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +0 -1
  38. package/src/__tests__/ephemeral-permissions.test.ts +3 -3
  39. package/src/__tests__/gateway-only-guard.test.ts +0 -1
  40. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +0 -1
  41. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +0 -1
  42. package/src/__tests__/guardian-routing-invariants.test.ts +0 -1
  43. package/src/__tests__/guardian-verification-voice-binding.test.ts +0 -1
  44. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +0 -39
  45. package/src/__tests__/heartbeat-service.test.ts +0 -1
  46. package/src/__tests__/host-cu-proxy.test.ts +629 -0
  47. package/src/__tests__/host-shell-tool.test.ts +27 -15
  48. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  49. package/src/__tests__/ingress-url-consistency.test.ts +14 -21
  50. package/src/__tests__/integration-status.test.ts +32 -51
  51. package/src/__tests__/intent-routing.test.ts +0 -1
  52. package/src/__tests__/invite-routes-http.test.ts +10 -9
  53. package/src/__tests__/keychain-broker-client.test.ts +11 -43
  54. package/src/__tests__/notification-routing-intent.test.ts +0 -1
  55. package/src/__tests__/oauth-cli.test.ts +373 -14
  56. package/src/__tests__/oauth-provider-profiles.test.ts +9 -9
  57. package/src/__tests__/oauth-scope-policy.test.ts +4 -6
  58. package/src/__tests__/oauth-store.test.ts +756 -0
  59. package/src/__tests__/onboarding-starter-tasks.test.ts +0 -1
  60. package/src/__tests__/provider-error-scenarios.test.ts +0 -1
  61. package/src/__tests__/provider-streaming.benchmark.test.ts +0 -1
  62. package/src/__tests__/public-ingress-urls.test.ts +15 -21
  63. package/src/__tests__/recording-handler.test.ts +3 -4
  64. package/src/__tests__/registry.test.ts +2 -2
  65. package/src/__tests__/runtime-events-sse.test.ts +55 -7
  66. package/src/__tests__/schedule-store.test.ts +0 -1
  67. package/src/__tests__/scheduler-recurrence.test.ts +0 -1
  68. package/src/__tests__/scoped-approval-grants.test.ts +0 -1
  69. package/src/__tests__/scoped-grant-security-matrix.test.ts +0 -1
  70. package/src/__tests__/secret-ingress-handler.test.ts +0 -1
  71. package/src/__tests__/send-endpoint-busy.test.ts +21 -6
  72. package/src/__tests__/sequence-store.test.ts +0 -1
  73. package/src/__tests__/session-init.benchmark.test.ts +4 -5
  74. package/src/__tests__/skill-include-graph.test.ts +66 -0
  75. package/src/__tests__/skill-load-feature-flag.test.ts +0 -1
  76. package/src/__tests__/skill-load-tool.test.ts +149 -1
  77. package/src/__tests__/skill-projection-feature-flag.test.ts +0 -1
  78. package/src/__tests__/skills-uninstall.test.ts +1 -1
  79. package/src/__tests__/skills.test.ts +3 -3
  80. package/src/__tests__/slack-channel-config.test.ts +67 -3
  81. package/src/__tests__/slack-share-routes.test.ts +17 -19
  82. package/src/__tests__/system-prompt.test.ts +0 -1
  83. package/src/__tests__/telegram-invite-adapter.test.ts +18 -22
  84. package/src/__tests__/terminal-tools.test.ts +4 -3
  85. package/src/__tests__/test-support/computer-use-skill-harness.ts +3 -2
  86. package/src/__tests__/tool-approval-handler.test.ts +0 -1
  87. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +0 -1
  88. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  89. package/src/__tests__/tool-executor-shell-integration.test.ts +0 -1
  90. package/src/__tests__/tool-executor.test.ts +0 -1
  91. package/src/__tests__/tool-grant-request-escalation.test.ts +0 -1
  92. package/src/__tests__/trust-store-pattern-matches.test.ts +29 -0
  93. package/src/__tests__/trust-store.test.ts +1 -22
  94. package/src/__tests__/trusted-contact-approval-notifier.test.ts +0 -1
  95. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +0 -1
  96. package/src/__tests__/twilio-routes.test.ts +0 -16
  97. package/src/__tests__/verification-control-plane-policy.test.ts +0 -1
  98. package/src/__tests__/voice-scoped-grant-consumer.test.ts +0 -1
  99. package/src/agent/ax-tree-compaction.test.ts +235 -0
  100. package/src/agent/loop.ts +76 -130
  101. package/src/calls/call-domain.ts +1 -6
  102. package/src/calls/relay-server.ts +9 -13
  103. package/src/calls/twilio-config.ts +2 -7
  104. package/src/calls/twilio-routes.ts +1 -2
  105. package/src/calls/voice-ingress-preflight.ts +1 -1
  106. package/src/cli/commands/browser-relay.ts +18 -12
  107. package/src/cli/commands/completions.ts +0 -3
  108. package/src/cli/commands/credentials.ts +101 -15
  109. package/src/cli/commands/oauth/apps.ts +255 -0
  110. package/src/cli/commands/oauth/connections.ts +299 -0
  111. package/src/cli/commands/oauth/index.ts +52 -0
  112. package/src/cli/commands/oauth/providers.ts +242 -0
  113. package/src/cli/commands/skills.ts +4 -338
  114. package/src/cli/program.ts +1 -5
  115. package/src/cli/reference.ts +1 -3
  116. package/src/config/assistant-feature-flags.ts +0 -3
  117. package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
  118. package/src/config/bundled-skills/computer-use/SKILL.md +3 -6
  119. package/src/config/bundled-skills/computer-use/TOOLS.json +22 -4
  120. package/src/config/bundled-skills/google-calendar/calendar-client.ts +21 -16
  121. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -4
  122. package/src/config/bundled-skills/settings/SKILL.md +1 -1
  123. package/src/config/bundled-skills/settings/TOOLS.json +2 -8
  124. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +5 -33
  125. package/src/config/env-registry.ts +14 -83
  126. package/src/config/env.ts +11 -50
  127. package/src/config/feature-flag-registry.json +16 -16
  128. package/src/config/loader.ts +0 -6
  129. package/src/config/schema.ts +3 -1
  130. package/src/config/skills.ts +21 -2
  131. package/src/context/image-dimensions.ts +229 -0
  132. package/src/context/token-estimator.ts +75 -12
  133. package/src/context/window-manager.ts +49 -10
  134. package/src/daemon/assistant-attachments.ts +1 -13
  135. package/src/daemon/handlers/config-ingress.ts +8 -33
  136. package/src/daemon/handlers/config-slack-channel.ts +49 -46
  137. package/src/daemon/handlers/config-telegram.ts +32 -16
  138. package/src/daemon/handlers/sessions.ts +10 -24
  139. package/src/daemon/handlers/shared.ts +0 -130
  140. package/src/daemon/host-cu-proxy.ts +401 -0
  141. package/src/daemon/lifecycle.ts +36 -68
  142. package/src/daemon/message-protocol.ts +3 -0
  143. package/src/daemon/message-types/computer-use.ts +2 -119
  144. package/src/daemon/message-types/host-cu.ts +19 -0
  145. package/src/daemon/message-types/messages.ts +3 -0
  146. package/src/daemon/server.ts +14 -21
  147. package/src/daemon/session-agent-loop-handlers.ts +2 -0
  148. package/src/daemon/session-attachments.ts +1 -2
  149. package/src/daemon/session-slash.ts +1 -1
  150. package/src/daemon/session-surfaces.ts +40 -28
  151. package/src/daemon/session-tool-setup.ts +2 -9
  152. package/src/daemon/session.ts +138 -15
  153. package/src/daemon/tool-side-effects.ts +2 -8
  154. package/src/daemon/watch-handler.ts +2 -2
  155. package/src/events/tool-metrics-listener.ts +2 -2
  156. package/src/hooks/manager.ts +1 -4
  157. package/src/inbound/public-ingress-urls.ts +7 -7
  158. package/src/logfire.ts +16 -5
  159. package/src/memory/conversation-key-store.ts +21 -0
  160. package/src/memory/db-init.ts +4 -0
  161. package/src/memory/migrations/149-oauth-tables.ts +60 -0
  162. package/src/memory/migrations/index.ts +1 -0
  163. package/src/memory/schema/index.ts +1 -0
  164. package/src/memory/schema/oauth.ts +65 -0
  165. package/src/messaging/provider.ts +4 -4
  166. package/src/messaging/providers/gmail/client.ts +82 -2
  167. package/src/messaging/providers/gmail/people-client.ts +10 -10
  168. package/src/messaging/providers/telegram-bot/adapter.ts +17 -17
  169. package/src/messaging/providers/whatsapp/adapter.ts +11 -8
  170. package/src/messaging/registry.ts +2 -32
  171. package/src/notifications/copy-composer.ts +0 -5
  172. package/src/notifications/signal.ts +4 -5
  173. package/src/oauth/byo-connection.test.ts +126 -25
  174. package/src/oauth/byo-connection.ts +22 -6
  175. package/src/oauth/connect-orchestrator.ts +113 -57
  176. package/src/oauth/connect-types.ts +17 -23
  177. package/src/oauth/connection-resolver.ts +35 -11
  178. package/src/oauth/connection.ts +1 -1
  179. package/src/oauth/manual-token-connection.ts +104 -0
  180. package/src/oauth/oauth-store.ts +496 -0
  181. package/src/oauth/platform-connection.test.ts +29 -0
  182. package/src/oauth/platform-connection.ts +6 -5
  183. package/src/oauth/provider-behaviors.ts +124 -0
  184. package/src/oauth/scope-policy.ts +9 -2
  185. package/src/oauth/seed-providers.ts +161 -0
  186. package/src/oauth/token-persistence.ts +74 -78
  187. package/src/permissions/checker.ts +3 -3
  188. package/src/permissions/defaults.ts +0 -1
  189. package/src/permissions/prompter.ts +10 -1
  190. package/src/permissions/trust-store.ts +13 -0
  191. package/src/prompts/__tests__/build-cli-reference-section.test.ts +3 -1
  192. package/src/prompts/system-prompt.ts +28 -40
  193. package/src/providers/anthropic/client.ts +133 -24
  194. package/src/providers/retry.ts +1 -27
  195. package/src/runtime/auth/route-policy.ts +0 -3
  196. package/src/runtime/channel-reply-delivery.ts +0 -40
  197. package/src/runtime/gateway-client.ts +0 -7
  198. package/src/runtime/http-server.ts +8 -6
  199. package/src/runtime/http-types.ts +2 -2
  200. package/src/runtime/middleware/twilio-validation.ts +1 -11
  201. package/src/runtime/pending-interactions.ts +14 -12
  202. package/src/runtime/routes/channel-delivery-routes.ts +0 -1
  203. package/src/runtime/routes/conversation-routes.ts +73 -19
  204. package/src/runtime/routes/events-routes.ts +21 -11
  205. package/src/runtime/routes/host-cu-routes.ts +97 -0
  206. package/src/runtime/routes/inbound-stages/background-dispatch.ts +12 -111
  207. package/src/runtime/routes/integrations/slack/share.ts +6 -7
  208. package/src/runtime/routes/log-export-routes.ts +126 -8
  209. package/src/runtime/routes/settings-routes.ts +55 -48
  210. package/src/runtime/routes/surface-action-routes.ts +1 -1
  211. package/src/runtime/routes/watch-routes.ts +128 -0
  212. package/src/schedule/integration-status.ts +10 -9
  213. package/src/security/credential-key.ts +0 -156
  214. package/src/security/keychain-broker-client.ts +5 -6
  215. package/src/security/oauth2.ts +1 -1
  216. package/src/security/token-manager.ts +119 -46
  217. package/src/skills/catalog-install.ts +358 -0
  218. package/src/skills/include-graph.ts +32 -0
  219. package/src/telegram/bot-username.ts +2 -3
  220. package/src/tools/browser/network-recorder.ts +1 -1
  221. package/src/tools/browser/network-recording-types.ts +1 -1
  222. package/src/tools/computer-use/definitions.ts +46 -11
  223. package/src/tools/computer-use/registry.ts +4 -5
  224. package/src/tools/credentials/broker.ts +1 -2
  225. package/src/tools/credentials/metadata-store.ts +17 -121
  226. package/src/tools/credentials/vault.ts +94 -167
  227. package/src/tools/registry.ts +2 -7
  228. package/src/tools/skills/load.ts +62 -3
  229. package/src/tools/watch/watch-state.ts +0 -12
  230. package/src/util/logger.ts +7 -41
  231. package/src/util/platform.ts +9 -28
  232. package/src/watcher/providers/google-calendar.ts +2 -1
  233. package/src/__tests__/computer-use-session-compaction.test.ts +0 -143
  234. package/src/__tests__/computer-use-session-lifecycle.test.ts +0 -322
  235. package/src/__tests__/computer-use-session-working-dir.test.ts +0 -166
  236. package/src/__tests__/computer-use-skill-baseline.test.ts +0 -78
  237. package/src/__tests__/computer-use-skill-endstate.test.ts +0 -105
  238. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +0 -249
  239. package/src/__tests__/ride-shotgun-handler.test.ts +0 -452
  240. package/src/cli/commands/dev.ts +0 -129
  241. package/src/cli/commands/map.ts +0 -391
  242. package/src/cli/commands/oauth.ts +0 -77
  243. package/src/config/bundled-skills/computer-use/tools/computer-use-request-control.ts +0 -16
  244. package/src/daemon/computer-use-session.ts +0 -1026
  245. package/src/daemon/ride-shotgun-handler.ts +0 -569
  246. package/src/oauth/provider-base-urls.ts +0 -21
  247. package/src/oauth/provider-profiles.ts +0 -192
  248. package/src/prompts/computer-use-prompt.ts +0 -98
  249. package/src/runtime/routes/computer-use-routes.ts +0 -641
  250. package/src/runtime/telegram-streaming-delivery.test.ts +0 -729
  251. package/src/runtime/telegram-streaming-delivery.ts +0 -393
  252. package/src/tools/computer-use/request-computer-control.ts +0 -56
@@ -0,0 +1,756 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
5
+
6
+ const testDir = mkdtempSync(join(tmpdir(), "oauth-store-test-"));
7
+
8
+ mock.module("../util/platform.js", () => ({
9
+ getDataDir: () => testDir,
10
+ isMacOS: () => process.platform === "darwin",
11
+ isLinux: () => process.platform === "linux",
12
+ isWindows: () => process.platform === "win32",
13
+ getPidPath: () => join(testDir, "test.pid"),
14
+ getDbPath: () => ":memory:",
15
+ getLogPath: () => join(testDir, "test.log"),
16
+ ensureDataDir: () => {},
17
+ }));
18
+
19
+ mock.module("../util/logger.js", () => ({
20
+ getLogger: () =>
21
+ new Proxy({} as Record<string, unknown>, {
22
+ get: () => () => {},
23
+ }),
24
+ }));
25
+
26
+ const mockDeleteSecureKeyAsync = mock(
27
+ (): Promise<"deleted" | "not-found" | "error"> =>
28
+ Promise.resolve("deleted" as const),
29
+ );
30
+ const mockSetSecureKeyAsync = mock(() => Promise.resolve(true));
31
+ /** Simulated secure key store for getSecureKey lookups. */
32
+ const secureKeyValues = new Map<string, string>();
33
+ mock.module("../security/secure-keys.js", () => ({
34
+ deleteSecureKeyAsync: mockDeleteSecureKeyAsync,
35
+ setSecureKeyAsync: mockSetSecureKeyAsync,
36
+ getSecureKey: (account: string) => secureKeyValues.get(account),
37
+ }));
38
+
39
+ import { initializeDb, resetDb, resetTestTables } from "../memory/db.js";
40
+ import {
41
+ createConnection,
42
+ deleteApp,
43
+ deleteConnection,
44
+ disconnectOAuthProvider,
45
+ getApp,
46
+ getAppByProviderAndClientId,
47
+ getConnection,
48
+ getConnectionByProvider,
49
+ getProvider,
50
+ isProviderConnected,
51
+ listConnections,
52
+ registerProvider,
53
+ seedProviders,
54
+ updateConnection,
55
+ upsertApp,
56
+ } from "../oauth/oauth-store.js";
57
+
58
+ initializeDb();
59
+
60
+ /** Seed a minimal provider row for FK satisfaction. */
61
+ function seedTestProvider(providerKey = "github"): void {
62
+ seedProviders([
63
+ {
64
+ providerKey,
65
+ authUrl: `https://${providerKey}.example.com/authorize`,
66
+ tokenUrl: `https://${providerKey}.example.com/token`,
67
+ defaultScopes: ["read"],
68
+ scopePolicy: {},
69
+ },
70
+ ]);
71
+ }
72
+
73
+ /** Create an app linked to the given provider. Returns the app row. */
74
+ async function createTestApp(providerKey = "github", clientId = "client-1") {
75
+ seedTestProvider(providerKey);
76
+ return await upsertApp(providerKey, clientId);
77
+ }
78
+
79
+ beforeEach(() => {
80
+ resetDb();
81
+ initializeDb();
82
+ // Explicitly clear all OAuth tables to prevent cross-test state pollution.
83
+ // Delete in FK-dependency order: connections → apps → providers.
84
+ resetTestTables("oauth_connections", "oauth_apps", "oauth_providers");
85
+ mockDeleteSecureKeyAsync.mockClear();
86
+ mockSetSecureKeyAsync.mockClear();
87
+ secureKeyValues.clear();
88
+ });
89
+
90
+ afterAll(() => {
91
+ resetDb();
92
+ try {
93
+ rmSync(testDir, { recursive: true });
94
+ } catch {
95
+ // best-effort cleanup
96
+ }
97
+ });
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Provider operations
101
+ // ---------------------------------------------------------------------------
102
+
103
+ describe("provider operations", () => {
104
+ describe("seedProviders", () => {
105
+ test("creates rows for new providers", () => {
106
+ seedProviders([
107
+ {
108
+ providerKey: "github",
109
+ authUrl: "https://github.com/login/oauth/authorize",
110
+ tokenUrl: "https://github.com/login/oauth/access_token",
111
+ defaultScopes: ["repo", "user"],
112
+ scopePolicy: { required: ["repo"] },
113
+ },
114
+ {
115
+ providerKey: "google",
116
+ authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
117
+ tokenUrl: "https://oauth2.googleapis.com/token",
118
+ defaultScopes: ["openid", "email"],
119
+ scopePolicy: {},
120
+ extraParams: { access_type: "offline" },
121
+ },
122
+ ]);
123
+
124
+ const gh = getProvider("github");
125
+ expect(gh).toBeDefined();
126
+ expect(gh!.providerKey).toBe("github");
127
+ expect(gh!.authUrl).toBe("https://github.com/login/oauth/authorize");
128
+ expect(gh!.tokenUrl).toBe("https://github.com/login/oauth/access_token");
129
+ expect(JSON.parse(gh!.defaultScopes)).toEqual(["repo", "user"]);
130
+ expect(JSON.parse(gh!.scopePolicy)).toEqual({ required: ["repo"] });
131
+
132
+ const goog = getProvider("google");
133
+ expect(goog).toBeDefined();
134
+ expect(goog!.providerKey).toBe("google");
135
+ expect(JSON.parse(goog!.extraParams!)).toEqual({
136
+ access_type: "offline",
137
+ });
138
+ });
139
+
140
+ test("updates existing provider rows with corrected seed data", () => {
141
+ seedProviders([
142
+ {
143
+ providerKey: "github",
144
+ authUrl: "https://github.com/login/oauth/authorize",
145
+ tokenUrl: "https://github.com/login/oauth/access_token",
146
+ defaultScopes: ["repo"],
147
+ scopePolicy: {},
148
+ baseUrl: "https://api.github.com",
149
+ },
150
+ ]);
151
+
152
+ const original = getProvider("github");
153
+ expect(original).toBeDefined();
154
+ expect(original!.baseUrl).toBe("https://api.github.com");
155
+ const originalCreatedAt = original!.createdAt;
156
+
157
+ // Re-seed with corrected values (simulates a code fix deployed on upgrade)
158
+ seedProviders([
159
+ {
160
+ providerKey: "github",
161
+ authUrl: "https://github.com/login/oauth/authorize-v2",
162
+ tokenUrl: "https://github.com/login/oauth/access_token-v2",
163
+ defaultScopes: ["repo", "user"],
164
+ scopePolicy: { required: ["repo"] },
165
+ baseUrl: "https://api.github.com/v2",
166
+ },
167
+ ]);
168
+
169
+ const row = getProvider("github");
170
+ expect(row).toBeDefined();
171
+ // Seed data should overwrite the existing row
172
+ expect(row!.authUrl).toBe("https://github.com/login/oauth/authorize-v2");
173
+ expect(row!.tokenUrl).toBe(
174
+ "https://github.com/login/oauth/access_token-v2",
175
+ );
176
+ expect(row!.baseUrl).toBe("https://api.github.com/v2");
177
+ expect(JSON.parse(row!.defaultScopes)).toEqual(["repo", "user"]);
178
+ expect(JSON.parse(row!.scopePolicy)).toEqual({ required: ["repo"] });
179
+ // createdAt should be preserved from the original insert
180
+ expect(row!.createdAt).toBe(originalCreatedAt);
181
+ });
182
+ });
183
+
184
+ describe("getProvider", () => {
185
+ test("returns the correct row", () => {
186
+ seedProviders([
187
+ {
188
+ providerKey: "github",
189
+ authUrl: "https://github.com/authorize",
190
+ tokenUrl: "https://github.com/token",
191
+ defaultScopes: ["repo"],
192
+ scopePolicy: {},
193
+ callbackTransport: "loopback",
194
+ loopbackPort: 8765,
195
+ },
196
+ ]);
197
+
198
+ const row = getProvider("github");
199
+ expect(row).toBeDefined();
200
+ expect(row!.providerKey).toBe("github");
201
+ expect(row!.callbackTransport).toBe("loopback");
202
+ expect(row!.loopbackPort).toBe(8765);
203
+ });
204
+
205
+ test("returns undefined for unknown keys", () => {
206
+ expect(getProvider("nonexistent")).toBeUndefined();
207
+ });
208
+ });
209
+
210
+ describe("registerProvider", () => {
211
+ test("creates a new row", () => {
212
+ const row = registerProvider({
213
+ providerKey: "linear",
214
+ authUrl: "https://linear.app/oauth/authorize",
215
+ tokenUrl: "https://api.linear.app/oauth/token",
216
+ defaultScopes: ["read"],
217
+ scopePolicy: {},
218
+ });
219
+
220
+ expect(row.providerKey).toBe("linear");
221
+ expect(row.authUrl).toBe("https://linear.app/oauth/authorize");
222
+
223
+ const fetched = getProvider("linear");
224
+ expect(fetched).toBeDefined();
225
+ expect(fetched!.providerKey).toBe("linear");
226
+ });
227
+
228
+ test("throws for duplicate provider_key", () => {
229
+ registerProvider({
230
+ providerKey: "linear",
231
+ authUrl: "https://linear.app/oauth/authorize",
232
+ tokenUrl: "https://api.linear.app/oauth/token",
233
+ defaultScopes: ["read"],
234
+ scopePolicy: {},
235
+ });
236
+
237
+ expect(() =>
238
+ registerProvider({
239
+ providerKey: "linear",
240
+ authUrl: "https://linear.app/oauth/authorize",
241
+ tokenUrl: "https://api.linear.app/oauth/token",
242
+ defaultScopes: ["read"],
243
+ scopePolicy: {},
244
+ }),
245
+ ).toThrow(/already exists.*linear/);
246
+ });
247
+ });
248
+ });
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // App operations
252
+ // ---------------------------------------------------------------------------
253
+
254
+ describe("app operations", () => {
255
+ describe("upsertApp", () => {
256
+ test("creates a new app and returns it with a UUID", async () => {
257
+ seedTestProvider("github");
258
+ const app = await upsertApp("github", "client-abc");
259
+
260
+ expect(app.id).toBeTruthy();
261
+ // UUID v4 format check
262
+ expect(app.id).toMatch(
263
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
264
+ );
265
+ expect(app.providerKey).toBe("github");
266
+ expect(app.clientId).toBe("client-abc");
267
+ expect(app.createdAt).toBeGreaterThan(0);
268
+ expect(app.updatedAt).toBeGreaterThan(0);
269
+ });
270
+
271
+ test("returns the existing app when called again with same (providerKey, clientId)", async () => {
272
+ seedTestProvider("github");
273
+ const first = await upsertApp("github", "client-abc");
274
+ const second = await upsertApp("github", "client-abc");
275
+
276
+ expect(second.id).toBe(first.id);
277
+ expect(second.createdAt).toBe(first.createdAt);
278
+ });
279
+
280
+ test("stores clientSecret in secure storage on new app creation", async () => {
281
+ seedTestProvider("github");
282
+ const app = await upsertApp("github", "client-abc", "my-secret");
283
+
284
+ expect(mockSetSecureKeyAsync).toHaveBeenCalledTimes(1);
285
+ expect(mockSetSecureKeyAsync).toHaveBeenCalledWith(
286
+ `oauth_app/${app.id}/client_secret`,
287
+ "my-secret",
288
+ );
289
+ });
290
+
291
+ test("stores clientSecret in secure storage when upserting an existing app", async () => {
292
+ seedTestProvider("github");
293
+ const first = await upsertApp("github", "client-abc");
294
+ mockSetSecureKeyAsync.mockClear();
295
+
296
+ await upsertApp("github", "client-abc", "updated-secret");
297
+
298
+ expect(mockSetSecureKeyAsync).toHaveBeenCalledTimes(1);
299
+ expect(mockSetSecureKeyAsync).toHaveBeenCalledWith(
300
+ `oauth_app/${first.id}/client_secret`,
301
+ "updated-secret",
302
+ );
303
+ });
304
+
305
+ test("throws when setSecureKeyAsync returns false", async () => {
306
+ seedTestProvider("github");
307
+ mockSetSecureKeyAsync.mockResolvedValueOnce(false);
308
+
309
+ await expect(
310
+ upsertApp("github", "client-abc", "bad-secret"),
311
+ ).rejects.toThrow("Failed to store client_secret in secure storage");
312
+ });
313
+ });
314
+
315
+ describe("getApp", () => {
316
+ test("returns the correct row by id", async () => {
317
+ const app = await createTestApp("github", "client-1");
318
+ const fetched = getApp(app.id);
319
+
320
+ expect(fetched).toBeDefined();
321
+ expect(fetched!.id).toBe(app.id);
322
+ expect(fetched!.providerKey).toBe("github");
323
+ expect(fetched!.clientId).toBe("client-1");
324
+ });
325
+
326
+ test("returns undefined for unknown id", () => {
327
+ expect(getApp("nonexistent-id")).toBeUndefined();
328
+ });
329
+ });
330
+
331
+ describe("getAppByProviderAndClientId", () => {
332
+ test("returns the correct row", async () => {
333
+ const app = await createTestApp("github", "client-1");
334
+ const fetched = getAppByProviderAndClientId("github", "client-1");
335
+
336
+ expect(fetched).toBeDefined();
337
+ expect(fetched!.id).toBe(app.id);
338
+ });
339
+
340
+ test("returns undefined for unknown combination", () => {
341
+ expect(
342
+ getAppByProviderAndClientId("github", "nonexistent"),
343
+ ).toBeUndefined();
344
+ });
345
+ });
346
+
347
+ describe("deleteApp", () => {
348
+ test("removes the row and returns true", async () => {
349
+ const app = await createTestApp("github", "client-1");
350
+ const deleted = await deleteApp(app.id);
351
+
352
+ expect(deleted).toBe(true);
353
+ expect(getApp(app.id)).toBeUndefined();
354
+ });
355
+
356
+ test("cleans up client_secret from secure storage", async () => {
357
+ const app = await createTestApp("github", "client-1");
358
+ mockDeleteSecureKeyAsync.mockClear();
359
+
360
+ await deleteApp(app.id);
361
+
362
+ expect(mockDeleteSecureKeyAsync).toHaveBeenCalledWith(
363
+ `oauth_app/${app.id}/client_secret`,
364
+ );
365
+ });
366
+
367
+ test("returns false for nonexistent id", async () => {
368
+ expect(await deleteApp("nonexistent-id")).toBe(false);
369
+ });
370
+
371
+ test("throws when deleteSecureKeyAsync returns error", async () => {
372
+ const app = await createTestApp("github", "client-1");
373
+ mockDeleteSecureKeyAsync.mockResolvedValueOnce("error");
374
+
375
+ await expect(deleteApp(app.id)).rejects.toThrow(
376
+ /failed to remove client_secret from secure storage/i,
377
+ );
378
+
379
+ // DB row should already be deleted (delete happens before secure key cleanup)
380
+ expect(getApp(app.id)).toBeUndefined();
381
+ });
382
+ });
383
+ });
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Connection operations
387
+ // ---------------------------------------------------------------------------
388
+
389
+ describe("connection operations", () => {
390
+ describe("createConnection", () => {
391
+ test("creates a row with status='active'", async () => {
392
+ const app = await createTestApp("github", "client-1");
393
+ const conn = createConnection({
394
+ oauthAppId: app.id,
395
+ providerKey: "github",
396
+ grantedScopes: ["repo", "user"],
397
+ hasRefreshToken: true,
398
+ accountInfo: "user@example.com",
399
+ label: "Primary GitHub",
400
+ metadata: { login: "octocat" },
401
+ });
402
+
403
+ expect(conn.id).toBeTruthy();
404
+ expect(conn.oauthAppId).toBe(app.id);
405
+ expect(conn.providerKey).toBe("github");
406
+ expect(conn.status).toBe("active");
407
+ expect(JSON.parse(conn.grantedScopes)).toEqual(["repo", "user"]);
408
+ expect(conn.hasRefreshToken).toBe(1);
409
+ expect(conn.accountInfo).toBe("user@example.com");
410
+ expect(conn.label).toBe("Primary GitHub");
411
+ expect(JSON.parse(conn.metadata!)).toEqual({ login: "octocat" });
412
+ expect(conn.createdAt).toBeGreaterThan(0);
413
+ });
414
+ });
415
+
416
+ describe("getConnection", () => {
417
+ test("returns the correct row", async () => {
418
+ const app = await createTestApp("github", "client-1");
419
+ const conn = createConnection({
420
+ oauthAppId: app.id,
421
+ providerKey: "github",
422
+ grantedScopes: ["repo"],
423
+ hasRefreshToken: false,
424
+ });
425
+
426
+ const fetched = getConnection(conn.id);
427
+ expect(fetched).toBeDefined();
428
+ expect(fetched!.id).toBe(conn.id);
429
+ expect(fetched!.providerKey).toBe("github");
430
+ });
431
+
432
+ test("returns undefined for unknown id", () => {
433
+ expect(getConnection("nonexistent-id")).toBeUndefined();
434
+ });
435
+ });
436
+
437
+ describe("getConnectionByProvider", () => {
438
+ test("returns the most recent active connection", async () => {
439
+ const app = await createTestApp("github", "client-1");
440
+
441
+ // Create two connections with explicit timestamps so ordering is deterministic
442
+ createConnection({
443
+ oauthAppId: app.id,
444
+ providerKey: "github",
445
+ grantedScopes: ["repo"],
446
+ hasRefreshToken: false,
447
+ createdAt: 1000,
448
+ });
449
+
450
+ const conn2 = createConnection({
451
+ oauthAppId: app.id,
452
+ providerKey: "github",
453
+ grantedScopes: ["repo", "user"],
454
+ hasRefreshToken: true,
455
+ createdAt: 2000,
456
+ });
457
+
458
+ const result = getConnectionByProvider("github");
459
+ expect(result).toBeDefined();
460
+ expect(result!.id).toBe(conn2.id);
461
+ });
462
+
463
+ test("skips connections with status='revoked'", async () => {
464
+ const app = await createTestApp("github", "client-1");
465
+
466
+ const conn1 = createConnection({
467
+ oauthAppId: app.id,
468
+ providerKey: "github",
469
+ grantedScopes: ["repo"],
470
+ hasRefreshToken: false,
471
+ });
472
+
473
+ const conn2 = createConnection({
474
+ oauthAppId: app.id,
475
+ providerKey: "github",
476
+ grantedScopes: ["repo", "user"],
477
+ hasRefreshToken: true,
478
+ });
479
+
480
+ // Revoke the most recent connection
481
+ updateConnection(conn2.id, { status: "revoked" });
482
+
483
+ const result = getConnectionByProvider("github");
484
+ expect(result).toBeDefined();
485
+ expect(result!.id).toBe(conn1.id);
486
+ });
487
+
488
+ test("skips connections with status='expired'", async () => {
489
+ const app = await createTestApp("github", "client-1");
490
+
491
+ const conn = createConnection({
492
+ oauthAppId: app.id,
493
+ providerKey: "github",
494
+ grantedScopes: ["repo"],
495
+ hasRefreshToken: false,
496
+ });
497
+
498
+ updateConnection(conn.id, { status: "expired" });
499
+
500
+ const result = getConnectionByProvider("github");
501
+ expect(result).toBeUndefined();
502
+ });
503
+
504
+ test("returns undefined when no active connections exist", () => {
505
+ expect(getConnectionByProvider("github")).toBeUndefined();
506
+ });
507
+ });
508
+
509
+ describe("isProviderConnected", () => {
510
+ test("returns true when active connection has an access token in secure storage", async () => {
511
+ const app = await createTestApp("github", "client-1");
512
+ const conn = createConnection({
513
+ oauthAppId: app.id,
514
+ providerKey: "github",
515
+ grantedScopes: ["repo"],
516
+ hasRefreshToken: false,
517
+ });
518
+
519
+ secureKeyValues.set(`oauth_connection/${conn.id}/access_token`, "tok");
520
+
521
+ expect(isProviderConnected("github")).toBe(true);
522
+ });
523
+
524
+ test("returns false when active connection exists but access token is missing", async () => {
525
+ const app = await createTestApp("github", "client-1");
526
+ createConnection({
527
+ oauthAppId: app.id,
528
+ providerKey: "github",
529
+ grantedScopes: ["repo"],
530
+ hasRefreshToken: false,
531
+ });
532
+
533
+ // No secure key set — simulates failed token write
534
+ expect(isProviderConnected("github")).toBe(false);
535
+ });
536
+
537
+ test("returns false when no connection exists", () => {
538
+ expect(isProviderConnected("github")).toBe(false);
539
+ });
540
+
541
+ test("returns false when connection is revoked even with token in store", async () => {
542
+ const app = await createTestApp("github", "client-1");
543
+ const conn = createConnection({
544
+ oauthAppId: app.id,
545
+ providerKey: "github",
546
+ grantedScopes: ["repo"],
547
+ hasRefreshToken: false,
548
+ });
549
+
550
+ updateConnection(conn.id, { status: "revoked" });
551
+ secureKeyValues.set(`oauth_connection/${conn.id}/access_token`, "tok");
552
+
553
+ expect(isProviderConnected("github")).toBe(false);
554
+ });
555
+ });
556
+
557
+ describe("updateConnection", () => {
558
+ test("modifies specific fields", async () => {
559
+ const app = await createTestApp("github", "client-1");
560
+ const conn = createConnection({
561
+ oauthAppId: app.id,
562
+ providerKey: "github",
563
+ grantedScopes: ["repo"],
564
+ hasRefreshToken: false,
565
+ });
566
+
567
+ const updated = updateConnection(conn.id, {
568
+ status: "revoked",
569
+ label: "Revoked account",
570
+ grantedScopes: ["repo", "user", "gist"],
571
+ hasRefreshToken: true,
572
+ metadata: { reason: "user-requested" },
573
+ });
574
+
575
+ expect(updated).toBe(true);
576
+
577
+ const fetched = getConnection(conn.id);
578
+ expect(fetched).toBeDefined();
579
+ expect(fetched!.status).toBe("revoked");
580
+ expect(fetched!.label).toBe("Revoked account");
581
+ expect(JSON.parse(fetched!.grantedScopes)).toEqual([
582
+ "repo",
583
+ "user",
584
+ "gist",
585
+ ]);
586
+ expect(fetched!.hasRefreshToken).toBe(1);
587
+ expect(JSON.parse(fetched!.metadata!)).toEqual({
588
+ reason: "user-requested",
589
+ });
590
+ expect(fetched!.updatedAt).toBeGreaterThanOrEqual(conn.createdAt);
591
+ });
592
+
593
+ test("updates oauthAppId to a different app", async () => {
594
+ const app1 = await createTestApp("github", "client-1");
595
+ const app2 = await upsertApp("github", "client-2");
596
+
597
+ const conn = createConnection({
598
+ oauthAppId: app1.id,
599
+ providerKey: "github",
600
+ grantedScopes: ["repo"],
601
+ hasRefreshToken: false,
602
+ });
603
+
604
+ expect(getConnection(conn.id)!.oauthAppId).toBe(app1.id);
605
+
606
+ const updated = updateConnection(conn.id, { oauthAppId: app2.id });
607
+ expect(updated).toBe(true);
608
+
609
+ const fetched = getConnection(conn.id);
610
+ expect(fetched).toBeDefined();
611
+ expect(fetched!.oauthAppId).toBe(app2.id);
612
+ });
613
+
614
+ test("returns false for nonexistent id", () => {
615
+ expect(updateConnection("nonexistent-id", { status: "revoked" })).toBe(
616
+ false,
617
+ );
618
+ });
619
+ });
620
+
621
+ describe("listConnections", () => {
622
+ test("returns all connections when no filter is given", async () => {
623
+ const ghApp = await createTestApp("github", "client-1");
624
+ seedTestProvider("google");
625
+ const googApp = await upsertApp("google", "client-2");
626
+
627
+ createConnection({
628
+ oauthAppId: ghApp.id,
629
+ providerKey: "github",
630
+ grantedScopes: ["repo"],
631
+ hasRefreshToken: false,
632
+ });
633
+ createConnection({
634
+ oauthAppId: googApp.id,
635
+ providerKey: "google",
636
+ grantedScopes: ["email"],
637
+ hasRefreshToken: true,
638
+ });
639
+
640
+ const all = listConnections();
641
+ expect(all).toHaveLength(2);
642
+ });
643
+
644
+ test("filters by provider key", async () => {
645
+ const ghApp = await createTestApp("github", "client-1");
646
+ seedTestProvider("google");
647
+ const googApp = await upsertApp("google", "client-2");
648
+
649
+ createConnection({
650
+ oauthAppId: ghApp.id,
651
+ providerKey: "github",
652
+ grantedScopes: ["repo"],
653
+ hasRefreshToken: false,
654
+ });
655
+ createConnection({
656
+ oauthAppId: googApp.id,
657
+ providerKey: "google",
658
+ grantedScopes: ["email"],
659
+ hasRefreshToken: true,
660
+ });
661
+
662
+ const ghConns = listConnections("github");
663
+ expect(ghConns).toHaveLength(1);
664
+ expect(ghConns[0].providerKey).toBe("github");
665
+
666
+ const googConns = listConnections("google");
667
+ expect(googConns).toHaveLength(1);
668
+ expect(googConns[0].providerKey).toBe("google");
669
+ });
670
+
671
+ test("returns empty array when no connections exist", () => {
672
+ expect(listConnections()).toEqual([]);
673
+ });
674
+ });
675
+
676
+ describe("deleteConnection", () => {
677
+ test("removes the row and returns true", async () => {
678
+ const app = await createTestApp("github", "client-1");
679
+ const conn = createConnection({
680
+ oauthAppId: app.id,
681
+ providerKey: "github",
682
+ grantedScopes: ["repo"],
683
+ hasRefreshToken: false,
684
+ });
685
+
686
+ const deleted = deleteConnection(conn.id);
687
+ expect(deleted).toBe(true);
688
+ expect(getConnection(conn.id)).toBeUndefined();
689
+ });
690
+
691
+ test("returns false for nonexistent id", () => {
692
+ expect(deleteConnection("nonexistent-id")).toBe(false);
693
+ });
694
+ });
695
+ });
696
+
697
+ // ---------------------------------------------------------------------------
698
+ // disconnectOAuthProvider
699
+ // ---------------------------------------------------------------------------
700
+
701
+ describe("disconnectOAuthProvider", () => {
702
+ test("returns 'not-found' when no connection exists for the provider", async () => {
703
+ const result = await disconnectOAuthProvider("github");
704
+ expect(result).toBe("not-found");
705
+ expect(mockDeleteSecureKeyAsync).not.toHaveBeenCalled();
706
+ });
707
+
708
+ test("returns 'disconnected' and deletes connection row and secure keys when connection exists", async () => {
709
+ const app = await createTestApp("github", "client-1");
710
+ const conn = createConnection({
711
+ oauthAppId: app.id,
712
+ providerKey: "github",
713
+ grantedScopes: ["repo"],
714
+ hasRefreshToken: true,
715
+ });
716
+
717
+ const result = await disconnectOAuthProvider("github");
718
+ expect(result).toBe("disconnected");
719
+
720
+ // Verify secure keys were deleted
721
+ expect(mockDeleteSecureKeyAsync).toHaveBeenCalledTimes(2);
722
+ expect(mockDeleteSecureKeyAsync).toHaveBeenCalledWith(
723
+ `oauth_connection/${conn.id}/access_token`,
724
+ );
725
+ expect(mockDeleteSecureKeyAsync).toHaveBeenCalledWith(
726
+ `oauth_connection/${conn.id}/refresh_token`,
727
+ );
728
+
729
+ // Verify connection row was deleted
730
+ expect(getConnection(conn.id)).toBeUndefined();
731
+ });
732
+ });
733
+
734
+ // ---------------------------------------------------------------------------
735
+ // FK constraint enforcement
736
+ // ---------------------------------------------------------------------------
737
+
738
+ describe("FK constraints", () => {
739
+ test("creating an app with a nonexistent provider_key fails", async () => {
740
+ await expect(
741
+ upsertApp("nonexistent-provider", "client-1"),
742
+ ).rejects.toThrow();
743
+ });
744
+
745
+ test("creating a connection with a nonexistent oauth_app_id fails", () => {
746
+ seedTestProvider("github");
747
+ expect(() =>
748
+ createConnection({
749
+ oauthAppId: "nonexistent-app-id",
750
+ providerKey: "github",
751
+ grantedScopes: ["repo"],
752
+ hasRefreshToken: false,
753
+ }),
754
+ ).toThrow();
755
+ });
756
+ });