@agent-native/core 0.105.0 → 0.106.0

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 (139) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +33 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +4 -0
  5. package/corpus/core/src/client/MultiTabAssistantChat.tsx +5 -1
  6. package/corpus/core/src/client/composer/PromptComposer.tsx +8 -0
  7. package/corpus/core/src/client/composer/TiptapComposer.tsx +38 -1
  8. package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +107 -32
  9. package/corpus/core/src/client/resources/mcp-integration-catalog.ts +33 -0
  10. package/corpus/core/src/client/resources/use-mcp-servers.ts +1 -0
  11. package/corpus/core/src/client/use-chat-models.ts +10 -2
  12. package/corpus/core/src/connections/catalog.ts +107 -6
  13. package/corpus/core/src/localization/default-messages.ts +2 -0
  14. package/corpus/core/src/mcp/actions/call-mcp-tool.ts +18 -0
  15. package/corpus/core/src/mcp/actions/list-mcp-tools.ts +18 -0
  16. package/corpus/core/src/mcp-client/app-api.ts +136 -0
  17. package/corpus/core/src/mcp-client/index.ts +21 -0
  18. package/corpus/core/src/mcp-client/oauth-client.ts +462 -0
  19. package/corpus/core/src/mcp-client/oauth-routes.ts +307 -0
  20. package/corpus/core/src/mcp-client/remote-store.ts +93 -118
  21. package/corpus/core/src/mcp-client/remote-url.ts +97 -0
  22. package/corpus/core/src/mcp-client/routes.ts +11 -0
  23. package/corpus/core/src/oauth-tokens/store.ts +10 -4
  24. package/corpus/core/src/provider-api/index.ts +690 -55
  25. package/corpus/core/src/secrets/register-framework-secrets.ts +30 -0
  26. package/corpus/core/src/server/action-discovery.ts +2 -0
  27. package/corpus/core/src/server/core-routes-plugin.ts +9 -1
  28. package/corpus/core/src/server/workspace-provider-oauth.ts +448 -61
  29. package/corpus/core/src/templates/default/AGENTS.md +9 -7
  30. package/corpus/core/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +46 -0
  31. package/corpus/templates/analytics/app/lib/data-source-status.ts +2 -0
  32. package/corpus/templates/analytics/app/pages/DataSources.tsx +61 -0
  33. package/corpus/templates/analytics/server/handlers/jira.ts +7 -0
  34. package/corpus/templates/analytics/server/lib/jira.ts +14 -42
  35. package/corpus/templates/analytics/server/routes/api/test-connection.post.ts +11 -0
  36. package/corpus/templates/dispatch/app/routes/integrations.tsx +57 -4
  37. package/dist/client/AssistantChat.d.ts +2 -0
  38. package/dist/client/AssistantChat.d.ts.map +1 -1
  39. package/dist/client/AssistantChat.js +2 -2
  40. package/dist/client/AssistantChat.js.map +1 -1
  41. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  42. package/dist/client/MultiTabAssistantChat.js +5 -2
  43. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  44. package/dist/client/composer/PromptComposer.d.ts +2 -0
  45. package/dist/client/composer/PromptComposer.d.ts.map +1 -1
  46. package/dist/client/composer/PromptComposer.js +5 -2
  47. package/dist/client/composer/PromptComposer.js.map +1 -1
  48. package/dist/client/composer/TiptapComposer.d.ts +4 -1
  49. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  50. package/dist/client/composer/TiptapComposer.js +13 -5
  51. package/dist/client/composer/TiptapComposer.js.map +1 -1
  52. package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
  53. package/dist/client/resources/McpIntegrationDialog.js +58 -14
  54. package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
  55. package/dist/client/resources/mcp-integration-catalog.d.ts +9 -0
  56. package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
  57. package/dist/client/resources/mcp-integration-catalog.js +13 -0
  58. package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
  59. package/dist/client/resources/use-mcp-servers.d.ts +1 -0
  60. package/dist/client/resources/use-mcp-servers.d.ts.map +1 -1
  61. package/dist/client/resources/use-mcp-servers.js.map +1 -1
  62. package/dist/client/use-chat-models.d.ts +1 -0
  63. package/dist/client/use-chat-models.d.ts.map +1 -1
  64. package/dist/client/use-chat-models.js +8 -2
  65. package/dist/client/use-chat-models.js.map +1 -1
  66. package/dist/collab/awareness.d.ts +2 -2
  67. package/dist/collab/awareness.d.ts.map +1 -1
  68. package/dist/connections/catalog.d.ts +78 -7
  69. package/dist/connections/catalog.d.ts.map +1 -1
  70. package/dist/connections/catalog.js +97 -6
  71. package/dist/connections/catalog.js.map +1 -1
  72. package/dist/localization/default-messages.d.ts +2 -0
  73. package/dist/localization/default-messages.d.ts.map +1 -1
  74. package/dist/localization/default-messages.js +2 -0
  75. package/dist/localization/default-messages.js.map +1 -1
  76. package/dist/mcp/actions/call-mcp-tool.d.ts +7 -0
  77. package/dist/mcp/actions/call-mcp-tool.d.ts.map +1 -0
  78. package/dist/mcp/actions/call-mcp-tool.js +15 -0
  79. package/dist/mcp/actions/call-mcp-tool.js.map +1 -0
  80. package/dist/mcp/actions/list-mcp-tools.d.ts +5 -0
  81. package/dist/mcp/actions/list-mcp-tools.d.ts.map +1 -0
  82. package/dist/mcp/actions/list-mcp-tools.js +16 -0
  83. package/dist/mcp/actions/list-mcp-tools.js.map +1 -0
  84. package/dist/mcp-client/app-api.d.ts +34 -0
  85. package/dist/mcp-client/app-api.d.ts.map +1 -0
  86. package/dist/mcp-client/app-api.js +94 -0
  87. package/dist/mcp-client/app-api.js.map +1 -0
  88. package/dist/mcp-client/index.d.ts +3 -1
  89. package/dist/mcp-client/index.d.ts.map +1 -1
  90. package/dist/mcp-client/index.js +3 -1
  91. package/dist/mcp-client/index.js.map +1 -1
  92. package/dist/mcp-client/oauth-client.d.ts +113 -0
  93. package/dist/mcp-client/oauth-client.d.ts.map +1 -0
  94. package/dist/mcp-client/oauth-client.js +316 -0
  95. package/dist/mcp-client/oauth-client.js.map +1 -0
  96. package/dist/mcp-client/oauth-routes.d.ts +25 -0
  97. package/dist/mcp-client/oauth-routes.d.ts.map +1 -0
  98. package/dist/mcp-client/oauth-routes.js +218 -0
  99. package/dist/mcp-client/oauth-routes.js.map +1 -0
  100. package/dist/mcp-client/remote-store.d.ts +21 -6
  101. package/dist/mcp-client/remote-store.d.ts.map +1 -1
  102. package/dist/mcp-client/remote-store.js +72 -124
  103. package/dist/mcp-client/remote-store.js.map +1 -1
  104. package/dist/mcp-client/remote-url.d.ts +8 -0
  105. package/dist/mcp-client/remote-url.d.ts.map +1 -0
  106. package/dist/mcp-client/remote-url.js +103 -0
  107. package/dist/mcp-client/remote-url.js.map +1 -0
  108. package/dist/mcp-client/routes.d.ts +1 -0
  109. package/dist/mcp-client/routes.d.ts.map +1 -1
  110. package/dist/mcp-client/routes.js +9 -0
  111. package/dist/mcp-client/routes.js.map +1 -1
  112. package/dist/oauth-tokens/store.d.ts +2 -2
  113. package/dist/oauth-tokens/store.d.ts.map +1 -1
  114. package/dist/oauth-tokens/store.js +10 -6
  115. package/dist/oauth-tokens/store.js.map +1 -1
  116. package/dist/provider-api/index.d.ts +14 -0
  117. package/dist/provider-api/index.d.ts.map +1 -1
  118. package/dist/provider-api/index.js +499 -61
  119. package/dist/provider-api/index.js.map +1 -1
  120. package/dist/resources/handlers.d.ts +1 -1
  121. package/dist/secrets/register-framework-secrets.d.ts.map +1 -1
  122. package/dist/secrets/register-framework-secrets.js +28 -0
  123. package/dist/secrets/register-framework-secrets.js.map +1 -1
  124. package/dist/server/action-discovery.d.ts.map +1 -1
  125. package/dist/server/action-discovery.js +2 -0
  126. package/dist/server/action-discovery.js.map +1 -1
  127. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  128. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  129. package/dist/server/core-routes-plugin.js +9 -1
  130. package/dist/server/core-routes-plugin.js.map +1 -1
  131. package/dist/server/workspace-provider-oauth.d.ts +10 -3
  132. package/dist/server/workspace-provider-oauth.d.ts.map +1 -1
  133. package/dist/server/workspace-provider-oauth.js +325 -43
  134. package/dist/server/workspace-provider-oauth.js.map +1 -1
  135. package/dist/templates/default/AGENTS.md +9 -7
  136. package/dist/templates/workspace-core/.agents/skills/external-agents/SKILL.md +46 -0
  137. package/package.json +2 -2
  138. package/src/templates/default/AGENTS.md +9 -7
  139. package/src/templates/workspace-core/.agents/skills/external-agents/SKILL.md +46 -0
@@ -16,6 +16,7 @@ import {
16
16
  type WorkspaceConnectionProvider,
17
17
  } from "../connections/catalog.js";
18
18
  import { saveOAuthTokens, setOAuthDisplayName } from "../oauth-tokens/store.js";
19
+ import { getOrgContext } from "../org/context.js";
19
20
  import { decryptSecretValue, encryptSecretValue } from "../secrets/crypto.js";
20
21
  import {
21
22
  listWorkspaceConnections,
@@ -33,11 +34,22 @@ import {
33
34
  } from "./google-oauth.js";
34
35
  import { runWithRequestContext } from "./request-context.js";
35
36
 
36
- export type GenericWorkspaceOAuthProvider = "figma" | "google_drive" | "notion";
37
+ export type GenericWorkspaceOAuthProvider =
38
+ | "figma"
39
+ | "google_drive"
40
+ | "github"
41
+ | "hubspot"
42
+ | "jira"
43
+ | "sentry"
44
+ | "notion";
37
45
 
38
46
  const SUPPORTED_PROVIDERS = new Set<GenericWorkspaceOAuthProvider>([
39
47
  "figma",
40
48
  "google_drive",
49
+ "github",
50
+ "hubspot",
51
+ "jira",
52
+ "sentry",
41
53
  "notion",
42
54
  ]);
43
55
  const FLOW_TTL_SECONDS = 10 * 60;
@@ -83,6 +95,21 @@ export async function handleWorkspaceProviderOAuthStart(
83
95
  if (getMethod(event) !== "GET") return methodNotAllowed(event);
84
96
  const session = await getSession(event).catch(() => null);
85
97
  if (!session?.email) return unauthorized(event);
98
+ const orgContext = await requireWorkspaceProviderOAuthAdmin(event);
99
+ if (!orgContext) {
100
+ return {
101
+ error:
102
+ "Only organization owners and admins can connect shared OAuth accounts.",
103
+ };
104
+ }
105
+ const orgId = orgContext.orgId;
106
+ if (!orgId) {
107
+ setResponseStatus(event, 403);
108
+ return {
109
+ error:
110
+ "Only organization owners and admins can connect shared OAuth accounts.",
111
+ };
112
+ }
86
113
  const provider = requiredProvider(providerId);
87
114
  const query = getQuery(event);
88
115
  const appId = normalizeAppId(
@@ -100,12 +127,10 @@ export async function handleWorkspaceProviderOAuthStart(
100
127
  return { error: "Invalid OAuth redirect URI." };
101
128
  }
102
129
  return runWithRequestContext(
103
- { userEmail: session.email, orgId: session.orgId },
130
+ { userEmail: session.email, orgId },
104
131
  async () => {
105
- const [clientId, clientSecret] = await Promise.all([
106
- resolveSecret(clientCredentialKey(providerId, "id")),
107
- resolveSecret(clientCredentialKey(providerId, "secret")),
108
- ]);
132
+ const [clientId, clientSecret] =
133
+ await resolveProviderClientCredentials(providerId);
109
134
  if (!clientId || !clientSecret) {
110
135
  setResponseStatus(event, 503);
111
136
  return {
@@ -125,7 +150,7 @@ export async function handleWorkspaceProviderOAuthStart(
125
150
  const state = encodeOAuthState({
126
151
  redirectUri,
127
152
  owner: session.email,
128
- orgId: session.orgId,
153
+ orgId,
129
154
  app: appId,
130
155
  returnUrl,
131
156
  flowId,
@@ -136,7 +161,7 @@ export async function handleWorkspaceProviderOAuthStart(
136
161
  verifier,
137
162
  redirectUri,
138
163
  owner: session.email,
139
- ...(session.orgId ? { orgId: session.orgId } : {}),
164
+ orgId,
140
165
  appId,
141
166
  expiresAt: Date.now() + FLOW_TTL_SECONDS * 1_000,
142
167
  };
@@ -171,6 +196,21 @@ export async function handleWorkspaceProviderOAuthCallback(
171
196
  if (getMethod(event) !== "GET") return methodNotAllowed(event);
172
197
  const session = await getSession(event).catch(() => null);
173
198
  if (!session?.email) return unauthorized(event);
199
+ const orgContext = await requireWorkspaceProviderOAuthAdmin(event);
200
+ if (!orgContext) {
201
+ return {
202
+ error:
203
+ "Only organization owners and admins can connect shared OAuth accounts.",
204
+ };
205
+ }
206
+ const orgId = orgContext.orgId;
207
+ if (!orgId) {
208
+ setResponseStatus(event, 403);
209
+ return {
210
+ error:
211
+ "Only organization owners and admins can connect shared OAuth accounts.",
212
+ };
213
+ }
174
214
  const query = getQuery(event);
175
215
  const code = text(query.code);
176
216
  const stateParam = text(query.state);
@@ -193,7 +233,7 @@ export async function handleWorkspaceProviderOAuthCallback(
193
233
  state,
194
234
  provider: providerId,
195
235
  sessionEmail: session.email,
196
- sessionOrgId: session.orgId,
236
+ sessionOrgId: orgId,
197
237
  })
198
238
  ) {
199
239
  setResponseStatus(event, 400);
@@ -201,12 +241,10 @@ export async function handleWorkspaceProviderOAuthCallback(
201
241
  }
202
242
  const provider = requiredProvider(providerId);
203
243
  return runWithRequestContext(
204
- { userEmail: session.email, orgId: session.orgId },
244
+ { userEmail: session.email, orgId },
205
245
  async () => {
206
- const [clientId, clientSecret] = await Promise.all([
207
- resolveSecret(clientCredentialKey(providerId, "id")),
208
- resolveSecret(clientCredentialKey(providerId, "secret")),
209
- ]);
246
+ const [clientId, clientSecret] =
247
+ await resolveProviderClientCredentials(providerId);
210
248
  if (!clientId || !clientSecret) {
211
249
  setResponseStatus(event, 503);
212
250
  return {
@@ -222,51 +260,71 @@ export async function handleWorkspaceProviderOAuthCallback(
222
260
  verifier: flow.verifier,
223
261
  redirectUri: flow.redirectUri,
224
262
  });
225
- const identity = await resolveWorkspaceProviderIdentity(
263
+ const identities = await resolveWorkspaceProviderIdentities(
226
264
  providerId,
227
265
  tokens,
228
266
  );
229
- await saveOAuthTokens(
230
- provider.oauth!.provider,
231
- identity.accountId,
232
- tokens,
233
- session.email,
234
- );
235
- await setOAuthDisplayName(
236
- provider.oauth!.provider,
237
- identity.accountId,
238
- identity.label,
239
- );
240
- const existing = (
241
- await listWorkspaceConnections({
242
- provider: providerId,
243
- includeDisabled: true,
244
- })
245
- ).find((connection) => connection.accountId === identity.accountId);
246
- const scopes = mergeWorkspaceOAuthValues(
247
- existing?.scopes ?? [],
248
- tokenScopes(tokens, provider.oauth!.scopes),
249
- );
250
- const connection = await upsertWorkspaceConnection({
251
- ...(existing ? { id: existing.id } : {}),
252
- provider: providerId,
253
- label: `${provider.label}: ${identity.label}`,
254
- accountId: identity.accountId,
255
- accountLabel: identity.label,
256
- status: "connected",
257
- scopes,
258
- allowedApps: existing?.allowedApps ?? [],
259
- config: { credentialMode: "oauth" },
260
- lastCheckedAt: new Date(),
261
- lastError: null,
262
- });
263
- await upsertWorkspaceConnectionGrant({
264
- connectionId: connection.id,
265
- appId: flow.appId,
267
+ if (!identities.length) {
268
+ throw new Error(
269
+ `${provider.label} OAuth did not return an accessible account.`,
270
+ );
271
+ }
272
+ const existingConnections = await listWorkspaceConnections({
266
273
  provider: providerId,
267
- scopes,
268
- config: { credentialMode: "oauth" },
274
+ includeDisabled: true,
269
275
  });
276
+ for (const identity of identities) {
277
+ const accountId = scopedOAuthAccountId(
278
+ providerId,
279
+ session.email,
280
+ identity.accountId,
281
+ );
282
+ await saveOAuthTokens(
283
+ provider.oauth!.provider,
284
+ accountId,
285
+ tokens,
286
+ session.email,
287
+ );
288
+ await setOAuthDisplayName(
289
+ provider.oauth!.provider,
290
+ accountId,
291
+ identity.label,
292
+ );
293
+ const existing = existingConnections.find(
294
+ (connection) => connection.accountId === accountId,
295
+ );
296
+ const scopes = mergeWorkspaceOAuthValues(
297
+ existing?.scopes ?? [],
298
+ identity.scopes ?? tokenScopes(tokens, provider.oauth!.scopes),
299
+ );
300
+ const connectionConfig = {
301
+ credentialMode: "oauth",
302
+ ...(providerId === "hubspot" || providerId === "jira"
303
+ ? { externalAccountId: identity.accountId }
304
+ : {}),
305
+ ...(identity.config ?? {}),
306
+ };
307
+ const connection = await upsertWorkspaceConnection({
308
+ ...(existing ? { id: existing.id } : {}),
309
+ provider: providerId,
310
+ label: `${provider.label}: ${identity.label}`,
311
+ accountId,
312
+ accountLabel: identity.label,
313
+ status: "connected",
314
+ scopes,
315
+ allowedApps: existing?.allowedApps ?? [],
316
+ config: connectionConfig,
317
+ lastCheckedAt: new Date(),
318
+ lastError: null,
319
+ });
320
+ await upsertWorkspaceConnectionGrant({
321
+ connectionId: connection.id,
322
+ appId: flow.appId,
323
+ provider: providerId,
324
+ scopes,
325
+ config: connectionConfig,
326
+ });
327
+ }
270
328
  const returnPath =
271
329
  state.returnUrl ??
272
330
  `/settings/connections?connected=${encodeURIComponent(providerId)}`;
@@ -289,10 +347,17 @@ export function buildWorkspaceProviderAuthorizationUrl(input: {
289
347
  url.searchParams.set("redirect_uri", input.redirectUri);
290
348
  url.searchParams.set("response_type", "code");
291
349
  url.searchParams.set("state", input.state);
292
- if (input.provider.id === "figma") {
350
+ if (input.provider.id === "figma" || input.provider.id === "sentry") {
293
351
  url.searchParams.set("code_challenge", input.challenge);
294
352
  url.searchParams.set("code_challenge_method", "S256");
295
353
  }
354
+ if (input.provider.id === "github") {
355
+ url.searchParams.set("allow_signup", "true");
356
+ }
357
+ if (input.provider.id === "jira") {
358
+ url.searchParams.set("audience", "api.atlassian.com");
359
+ url.searchParams.set("prompt", "consent");
360
+ }
296
361
  if (input.provider.id === "google_drive") {
297
362
  url.searchParams.set("access_type", "offline");
298
363
  url.searchParams.set("include_granted_scopes", "true");
@@ -317,11 +382,109 @@ export async function exchangeWorkspaceProviderOAuthCode(input: {
317
382
  const tokenUrl = input.provider.oauth?.tokenUrl;
318
383
  if (!tokenUrl) throw new Error("Provider does not support OAuth.");
319
384
  const authorization = `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString("base64")}`;
385
+ if (input.providerId === "github") {
386
+ const { response, body } = await fetchBoundedProviderJson(
387
+ tokenUrl,
388
+ {
389
+ method: "POST",
390
+ headers: {
391
+ Accept: "application/json",
392
+ "Content-Type": "application/json",
393
+ },
394
+ body: JSON.stringify({
395
+ client_id: input.clientId,
396
+ client_secret: input.clientSecret,
397
+ code: input.code,
398
+ redirect_uri: input.redirectUri,
399
+ }),
400
+ },
401
+ input.provider.label,
402
+ );
403
+ const accessToken = text(body.access_token);
404
+ if (!response.ok || !accessToken) {
405
+ throw new Error(
406
+ `${input.provider.label} OAuth token exchange failed (${response.status}).`,
407
+ );
408
+ }
409
+ return {
410
+ ...body,
411
+ ...(Number.isFinite(Number(body.expires_in)) &&
412
+ Number(body.expires_in) > 0
413
+ ? { expiry_date: Date.now() + Number(body.expires_in) * 1_000 }
414
+ : {}),
415
+ };
416
+ }
417
+ if (input.providerId === "hubspot") {
418
+ const { response, body } = await fetchBoundedProviderJson(
419
+ tokenUrl,
420
+ {
421
+ method: "POST",
422
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
423
+ body: new URLSearchParams({
424
+ grant_type: "authorization_code",
425
+ client_id: input.clientId,
426
+ client_secret: input.clientSecret,
427
+ code: input.code,
428
+ redirect_uri: input.redirectUri,
429
+ }),
430
+ },
431
+ input.provider.label,
432
+ );
433
+ const accessToken = text(body.access_token);
434
+ if (!response.ok || !accessToken) {
435
+ throw new Error(
436
+ `${input.provider.label} OAuth token exchange failed (${response.status}).`,
437
+ );
438
+ }
439
+ return {
440
+ ...body,
441
+ ...(Number.isFinite(Number(body.expires_in)) &&
442
+ Number(body.expires_in) > 0
443
+ ? { expiry_date: Date.now() + Number(body.expires_in) * 1_000 }
444
+ : {}),
445
+ };
446
+ }
447
+ if (input.providerId === "jira") {
448
+ const { response, body } = await fetchBoundedProviderJsonValue(
449
+ tokenUrl,
450
+ {
451
+ method: "POST",
452
+ headers: { "Content-Type": "application/json" },
453
+ body: JSON.stringify({
454
+ grant_type: "authorization_code",
455
+ client_id: input.clientId,
456
+ client_secret: input.clientSecret,
457
+ code: input.code,
458
+ redirect_uri: input.redirectUri,
459
+ }),
460
+ },
461
+ input.provider.label,
462
+ );
463
+ const tokenBody = record(body) ?? {};
464
+ const accessToken = text(tokenBody.access_token);
465
+ if (!response.ok || !accessToken) {
466
+ throw new Error(
467
+ `${input.provider.label} OAuth token exchange failed (${response.status}).`,
468
+ );
469
+ }
470
+ const expiresIn = Number(tokenBody.expires_in);
471
+ return {
472
+ ...tokenBody,
473
+ ...(Number.isFinite(expiresIn) && expiresIn > 0
474
+ ? { expiry_date: Date.now() + expiresIn * 1_000 }
475
+ : {}),
476
+ };
477
+ }
320
478
  const fields = {
321
479
  grant_type: "authorization_code",
322
480
  code: input.code,
323
481
  redirect_uri: input.redirectUri,
324
- ...(input.providerId === "figma" ? { code_verifier: input.verifier } : {}),
482
+ ...(input.providerId === "figma" || input.providerId === "sentry"
483
+ ? { code_verifier: input.verifier }
484
+ : {}),
485
+ ...(input.providerId === "sentry"
486
+ ? { client_id: input.clientId, client_secret: input.clientSecret }
487
+ : {}),
325
488
  ...(input.providerId === "google_drive"
326
489
  ? { client_id: input.clientId, client_secret: input.clientSecret }
327
490
  : {}),
@@ -368,7 +531,91 @@ export async function exchangeWorkspaceProviderOAuthCode(input: {
368
531
  export async function resolveWorkspaceProviderIdentity(
369
532
  providerId: GenericWorkspaceOAuthProvider,
370
533
  tokens: Record<string, unknown>,
371
- ): Promise<{ accountId: string; label: string }> {
534
+ ): Promise<WorkspaceProviderIdentity> {
535
+ const [identity] = await resolveWorkspaceProviderIdentities(
536
+ providerId,
537
+ tokens,
538
+ );
539
+ if (!identity) {
540
+ throw new Error(
541
+ `${providerId} OAuth response did not identify an account.`,
542
+ );
543
+ }
544
+ return identity;
545
+ }
546
+
547
+ export async function resolveWorkspaceProviderIdentities(
548
+ providerId: GenericWorkspaceOAuthProvider,
549
+ tokens: Record<string, unknown>,
550
+ ): Promise<WorkspaceProviderIdentity[]> {
551
+ if (providerId === "jira") {
552
+ const accessToken = text(tokens.access_token);
553
+ if (!accessToken) {
554
+ throw new Error("Jira OAuth response did not include an access token.");
555
+ }
556
+ const { response, body } = await fetchBoundedProviderJsonValue(
557
+ "https://api.atlassian.com/oauth/token/accessible-resources",
558
+ {
559
+ headers: {
560
+ Accept: "application/json",
561
+ Authorization: `Bearer ${accessToken}`,
562
+ },
563
+ },
564
+ "Jira",
565
+ );
566
+ if (!response.ok || !Array.isArray(body)) {
567
+ throw new Error("Jira OAuth response did not identify accessible sites.");
568
+ }
569
+ return body.flatMap((resource) => {
570
+ const entry = record(resource);
571
+ const cloudId = text(entry?.id);
572
+ const siteUrl = text(entry?.url);
573
+ if (!cloudId || !siteUrl) return [];
574
+ let parsedUrl: URL;
575
+ try {
576
+ parsedUrl = new URL(siteUrl);
577
+ } catch {
578
+ return [];
579
+ }
580
+ if (
581
+ parsedUrl.protocol !== "https:" ||
582
+ !parsedUrl.hostname.endsWith(".atlassian.net")
583
+ ) {
584
+ return [];
585
+ }
586
+ const scopes = Array.isArray(entry?.scopes)
587
+ ? entry.scopes.filter(
588
+ (scope): scope is string => typeof scope === "string",
589
+ )
590
+ : undefined;
591
+ return [
592
+ {
593
+ accountId: cloudId,
594
+ label: text(entry?.name) ?? parsedUrl.hostname,
595
+ ...(scopes?.length ? { scopes } : {}),
596
+ config: {
597
+ atlassianCloudId: cloudId,
598
+ atlassianApiBaseUrl: `https://api.atlassian.com/ex/jira/${encodeURIComponent(cloudId)}`,
599
+ siteUrl: parsedUrl.origin,
600
+ },
601
+ },
602
+ ];
603
+ });
604
+ }
605
+ return [await resolveWorkspaceProviderIdentitySingle(providerId, tokens)];
606
+ }
607
+
608
+ type WorkspaceProviderIdentity = {
609
+ accountId: string;
610
+ label: string;
611
+ scopes?: string[];
612
+ config?: Record<string, unknown>;
613
+ };
614
+
615
+ async function resolveWorkspaceProviderIdentitySingle(
616
+ providerId: GenericWorkspaceOAuthProvider,
617
+ tokens: Record<string, unknown>,
618
+ ): Promise<WorkspaceProviderIdentity> {
372
619
  if (providerId === "notion") {
373
620
  const owner = record(tokens.owner);
374
621
  const user = record(owner?.user);
@@ -390,6 +637,71 @@ export async function resolveWorkspaceProviderIdentity(
390
637
  "Notion workspace",
391
638
  };
392
639
  }
640
+ if (providerId === "github") {
641
+ const accessToken = text(tokens.access_token)!;
642
+ const { response, body: user } = await fetchBoundedProviderJson(
643
+ "https://api.github.com/user",
644
+ {
645
+ headers: {
646
+ Accept: "application/vnd.github+json",
647
+ Authorization: `Bearer ${accessToken}`,
648
+ "X-GitHub-Api-Version": "2022-11-28",
649
+ },
650
+ },
651
+ "GitHub",
652
+ );
653
+ const accountId = text(user.login) ?? text(user.id);
654
+ if (!response.ok || !accountId) {
655
+ throw new Error(
656
+ "GitHub OAuth response did not identify the connected user.",
657
+ );
658
+ }
659
+ return {
660
+ accountId,
661
+ label: text(user.name) ?? text(user.login) ?? "GitHub account",
662
+ };
663
+ }
664
+ if (providerId === "hubspot") {
665
+ const accessToken = text(tokens.access_token)!;
666
+ const { response, body } = await fetchBoundedProviderJson(
667
+ `https://api.hubapi.com/oauth/v1/access-tokens/${encodeURIComponent(accessToken)}`,
668
+ { headers: { Accept: "application/json" } },
669
+ "HubSpot",
670
+ );
671
+ const accountId = scalarText(body.hub_id) ?? scalarText(body.user_id);
672
+ if (!response.ok || !accountId) {
673
+ throw new Error(
674
+ "HubSpot OAuth response did not identify the connected portal.",
675
+ );
676
+ }
677
+ return {
678
+ accountId,
679
+ label: text(body.hub_domain) ?? `HubSpot portal ${accountId}`,
680
+ };
681
+ }
682
+ if (providerId === "sentry") {
683
+ const accessToken = text(tokens.access_token)!;
684
+ const { response, body: user } = await fetchBoundedProviderJson(
685
+ "https://sentry.io/api/0/users/me/",
686
+ {
687
+ headers: {
688
+ Accept: "application/json",
689
+ Authorization: `Bearer ${accessToken}`,
690
+ },
691
+ },
692
+ "Sentry",
693
+ );
694
+ const accountId = text(user.id);
695
+ if (!response.ok || !accountId) {
696
+ throw new Error(
697
+ "Sentry OAuth response did not identify the connected account.",
698
+ );
699
+ }
700
+ return {
701
+ accountId,
702
+ label: text(user?.email) ?? text(user?.name) ?? "Sentry account",
703
+ };
704
+ }
393
705
  if (providerId === "google_drive") {
394
706
  const accessToken = text(tokens.access_token)!;
395
707
  const { response, body } = await fetchBoundedProviderJson(
@@ -475,11 +787,45 @@ export function isWorkspaceProviderOAuthFlowValid(input: {
475
787
  );
476
788
  }
477
789
 
790
+ export function canConnectWorkspaceProviderOAuth(
791
+ orgId: string | null | undefined,
792
+ role: string | null | undefined,
793
+ ): boolean {
794
+ return Boolean(orgId) && (role === "owner" || role === "admin");
795
+ }
796
+
797
+ export function scopedOAuthAccountId(
798
+ provider: GenericWorkspaceOAuthProvider,
799
+ owner: string,
800
+ accountId: string,
801
+ ): string {
802
+ if (provider !== "hubspot" && provider !== "jira") return accountId;
803
+ const ownerKey = crypto
804
+ .createHash("sha256")
805
+ .update(`${provider}:${owner.trim().toLowerCase()}`)
806
+ .digest("hex")
807
+ .slice(0, 24);
808
+ return `${accountId}::${ownerKey}`;
809
+ }
810
+
478
811
  async function fetchBoundedProviderJson(
479
812
  url: string,
480
813
  init: RequestInit,
481
814
  providerLabel: string,
482
815
  ): Promise<{ response: Response; body: Record<string, unknown> }> {
816
+ const { response, body } = await fetchBoundedProviderJsonValue(
817
+ url,
818
+ init,
819
+ providerLabel,
820
+ );
821
+ return { response, body: record(body) ?? {} };
822
+ }
823
+
824
+ async function fetchBoundedProviderJsonValue(
825
+ url: string,
826
+ init: RequestInit,
827
+ providerLabel: string,
828
+ ): Promise<{ response: Response; body: unknown }> {
483
829
  let response: Response;
484
830
  try {
485
831
  response = await fetch(url, {
@@ -520,7 +866,7 @@ async function fetchBoundedProviderJson(
520
866
  try {
521
867
  const json = Buffer.concat(chunks).toString("utf8");
522
868
  const body = JSON.parse(json) as unknown;
523
- return { response, body: record(body) ?? {} };
869
+ return { response, body };
524
870
  } catch {
525
871
  return { response, body: {} };
526
872
  }
@@ -541,13 +887,37 @@ export function mergeWorkspaceOAuthValues(
541
887
  return [...new Set([...existing, ...incoming])];
542
888
  }
543
889
 
544
- function clientCredentialKey(
890
+ function clientCredentialKeys(
545
891
  provider: GenericWorkspaceOAuthProvider,
546
892
  field: "id" | "secret",
547
- ): string {
893
+ ): string[] {
548
894
  const prefix =
549
895
  provider === "google_drive" ? "GOOGLE" : provider.toUpperCase();
550
- return `${prefix}_CLIENT_${field === "id" ? "ID" : "SECRET"}`;
896
+ const suffix = field === "id" ? "ID" : "SECRET";
897
+ if (provider === "github") {
898
+ const integrationPrefix = `GITHUB_INTEGRATION_CLIENT_${suffix}`;
899
+ return [integrationPrefix, `GITHUB_CLIENT_${suffix}`];
900
+ }
901
+ if (provider === "hubspot") {
902
+ const integrationPrefix = `HUBSPOT_INTEGRATION_CLIENT_${suffix}`;
903
+ return [integrationPrefix, `HUBSPOT_CLIENT_${suffix}`];
904
+ }
905
+ return [`${prefix}_CLIENT_${suffix}`];
906
+ }
907
+
908
+ async function resolveProviderClientCredentials(
909
+ provider: GenericWorkspaceOAuthProvider,
910
+ ): Promise<[string | null, string | null]> {
911
+ const [clientId, clientSecret] = await Promise.all(
912
+ (["id", "secret"] as const).map(async (field) => {
913
+ for (const key of clientCredentialKeys(provider, field)) {
914
+ const value = await resolveSecret(key);
915
+ if (value) return value;
916
+ }
917
+ return null;
918
+ }),
919
+ );
920
+ return [clientId, clientSecret];
551
921
  }
552
922
 
553
923
  function flowCookieName(provider: GenericWorkspaceOAuthProvider): string {
@@ -566,6 +936,11 @@ function text(value: unknown): string | undefined {
566
936
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
567
937
  }
568
938
 
939
+ function scalarText(value: unknown): string | undefined {
940
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
941
+ return text(value);
942
+ }
943
+
569
944
  function record(value: unknown): Record<string, unknown> | null {
570
945
  return value && typeof value === "object" && !Array.isArray(value)
571
946
  ? (value as Record<string, unknown>)
@@ -581,3 +956,15 @@ function unauthorized(event: H3Event) {
581
956
  setResponseStatus(event, 401);
582
957
  return { error: "Authentication required" };
583
958
  }
959
+
960
+ async function requireWorkspaceProviderOAuthAdmin(event: H3Event) {
961
+ const context = await getOrgContext(event).catch(() => null);
962
+ if (
963
+ !context ||
964
+ !canConnectWorkspaceProviderOAuth(context.orgId, context.role)
965
+ ) {
966
+ setResponseStatus(event, 403);
967
+ return null;
968
+ }
969
+ return context;
970
+ }
@@ -133,13 +133,15 @@ capability is missing, add or extend a `defineAction` so both the agent and UI
133
133
  share the same operation. Do not create `/api/*` routes that only call,
134
134
  repackage, or proxy an action.
135
135
 
136
- | Action | Args | Purpose |
137
- | ------------- | --------------------------------- | ------------------------------- |
138
- | `view-screen` | | See current UI state |
139
- | `navigate` | `--view <name>` or `--path <url>` | Navigate the UI |
140
- | `hello` | `[--name <name>]` | Example script |
141
- | `db-schema` | | Show all tables, columns, types |
142
- | `db-query` | `--sql "SELECT ..."` | Run a SELECT query |
136
+ | Action | Args | Purpose |
137
+ | ---------------- | ------------------------------------------------------ | --------------------------------------------------------- |
138
+ | `view-screen` | | See current UI state |
139
+ | `navigate` | `--view <name>` or `--path <url>` | Navigate the UI |
140
+ | `hello` | `[--name <name>]` | Example script |
141
+ | `db-schema` | | Show all tables, columns, types |
142
+ | `db-query` | `--sql "SELECT ..."` | Run a SELECT query |
143
+ | `list-mcp-tools` | `--serverId <id>` | List connected MCP tools visible to the authenticated app |
144
+ | `call-mcp-tool` | `--serverId <id> --toolName <name> --arguments <json>` | Call a connected MCP tool through the scoped app API |
143
145
 
144
146
  **For data changes, pick the right surface:**
145
147