@opengeni/api-router 0.23.1 → 0.26.1

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 (107) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/browser-controller-authority.d.ts +43 -0
  4. package/dist/browser-state-authority.d.ts +27 -0
  5. package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
  6. package/dist/chunk-JIKNR5YL.js.map +1 -0
  7. package/dist/codemode.d.ts +23 -0
  8. package/dist/editable-artifact-live-hints.d.ts +11 -0
  9. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  10. package/dist/editable-artifact-office-import.d.ts +22 -0
  11. package/dist/editable-artifact-production.d.ts +29 -0
  12. package/dist/editable-artifact-websocket.d.ts +49 -0
  13. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  14. package/dist/github-browser-flow.d.ts +6 -0
  15. package/dist/http/cors.d.ts +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1796 -22
  18. package/dist/index.js.map +1 -1
  19. package/dist/integrations/api-integrations.d.ts +24 -0
  20. package/dist/integrations/atlassian.d.ts +176 -0
  21. package/dist/integrations/github-skill-source.d.ts +5 -0
  22. package/dist/integrations/google-drive.d.ts +85 -0
  23. package/dist/integrations/oauth-client.d.ts +22 -1
  24. package/dist/integrations/provider-oauth.d.ts +19 -0
  25. package/dist/integrations/slack-bot.d.ts +4 -0
  26. package/dist/integrations/slack-interactions.d.ts +8 -2
  27. package/dist/integrations/social-api.d.ts +2 -1
  28. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  29. package/dist/mcp/editable-artifacts.d.ts +13 -0
  30. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  31. package/dist/mcp/server.d.ts +14 -3
  32. package/dist/memory-slack-delivery.d.ts +9 -0
  33. package/dist/routes/api-integrations.d.ts +8 -0
  34. package/dist/routes/browser-identities.d.ts +5 -0
  35. package/dist/routes/browser-sessions.d.ts +6 -0
  36. package/dist/routes/company-profile.d.ts +3 -0
  37. package/dist/routes/computer-sessions.d.ts +6 -0
  38. package/dist/routes/editable-artifacts.d.ts +44 -0
  39. package/dist/routes/integration-features.d.ts +3 -0
  40. package/dist/routes/memory-slack-publications.d.ts +6 -0
  41. package/dist/routes/plugins.d.ts +8 -0
  42. package/dist/routes/sessions.d.ts +17 -2
  43. package/dist/routes/skills.d.ts +6 -0
  44. package/dist/routes/video-generation.d.ts +3 -0
  45. package/dist/sandbox/channel-a.d.ts +55 -2
  46. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  47. package/dist/sandbox/viewer.d.ts +4 -2
  48. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  49. package/package.json +19 -14
  50. package/src/app.ts +274 -37
  51. package/src/browser-controller-authority.ts +137 -0
  52. package/src/browser-state-authority.ts +236 -0
  53. package/src/codemode.ts +186 -0
  54. package/src/editable-artifact-live-hints.ts +64 -0
  55. package/src/editable-artifact-native-kernel.ts +659 -0
  56. package/src/editable-artifact-office-import.ts +230 -0
  57. package/src/editable-artifact-production.ts +419 -0
  58. package/src/editable-artifact-websocket.ts +311 -0
  59. package/src/editable-artifact-workspace-files.ts +186 -0
  60. package/src/github-browser-flow.ts +35 -6
  61. package/src/http/auth.ts +2 -0
  62. package/src/http/cors.ts +3 -0
  63. package/src/index.ts +119 -20
  64. package/src/integrations/api-integrations.ts +350 -0
  65. package/src/integrations/atlassian.ts +1621 -0
  66. package/src/integrations/github-skill-source.ts +142 -0
  67. package/src/integrations/google-drive.ts +1000 -64
  68. package/src/integrations/oauth-client.ts +123 -33
  69. package/src/integrations/provider-oauth.ts +777 -0
  70. package/src/integrations/slack-bot.ts +31 -2
  71. package/src/integrations/slack-interactions.ts +487 -27
  72. package/src/integrations/social-api.ts +11 -0
  73. package/src/mcp/documents.ts +32 -1
  74. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  75. package/src/mcp/editable-artifacts.ts +448 -0
  76. package/src/mcp/scheduled-task-view.ts +34 -0
  77. package/src/mcp/server.ts +923 -145
  78. package/src/memory-slack-delivery.ts +209 -0
  79. package/src/routes/api-integrations.ts +407 -0
  80. package/src/routes/browser-identities.ts +136 -0
  81. package/src/routes/browser-sessions.ts +2543 -0
  82. package/src/routes/company-profile.ts +255 -0
  83. package/src/routes/computer-sessions.ts +1247 -0
  84. package/src/routes/connections.ts +284 -99
  85. package/src/routes/documents.ts +22 -3
  86. package/src/routes/editable-artifacts.ts +1159 -0
  87. package/src/routes/files.ts +106 -4
  88. package/src/routes/github.ts +18 -2
  89. package/src/routes/install.ts +7 -1
  90. package/src/routes/integration-features.ts +258 -0
  91. package/src/routes/memory-slack-publications.ts +216 -0
  92. package/src/routes/packs.ts +437 -7
  93. package/src/routes/plugins.ts +751 -0
  94. package/src/routes/rigs.ts +77 -20
  95. package/src/routes/scheduled-tasks.ts +67 -40
  96. package/src/routes/sessions.ts +421 -245
  97. package/src/routes/skills.ts +174 -0
  98. package/src/routes/video-generation.ts +132 -0
  99. package/src/routes/workspaces.ts +46 -24
  100. package/src/sandbox/channel-a.ts +723 -116
  101. package/src/sandbox/metrics-ingestion.ts +121 -3
  102. package/src/sandbox/rematerialize.ts +35 -47
  103. package/src/sandbox/viewer.ts +29 -9
  104. package/src/temporal-schedule-cleanup.ts +135 -0
  105. package/dist/chunk-T4T2PGU4.js.map +0 -1
  106. package/dist/mcp/toolspace.d.ts +0 -71
  107. package/src/mcp/toolspace.ts +0 -1190
@@ -0,0 +1,1621 @@
1
+ import type { Settings } from "@opengeni/config";
2
+ import type { AccessGrant, ScheduledTask, ScheduledTaskScheduleSpec } from "@opengeni/contracts";
3
+ import {
4
+ ATLASSIAN_CREDENTIAL_LABEL,
5
+ ATLASSIAN_CREDENTIAL_ROLE,
6
+ ATLASSIAN_PROVIDER_DOMAIN,
7
+ ATLASSIAN_REQUIRED_SCOPES,
8
+ AtlassianBrowseItem,
9
+ AtlassianBrowseResponse,
10
+ AtlassianConnectionLifecycle,
11
+ AtlassianConnectionMetadata,
12
+ AtlassianOAuthStartResponse,
13
+ SaveAtlassianSourcesRequest,
14
+ atlassianScopesAllowRead,
15
+ type AtlassianDisconnectRequest,
16
+ type AtlassianLifecycleActionRequest,
17
+ type AtlassianOAuthStartRequest,
18
+ type AtlassianSelectedSource,
19
+ } from "@opengeni/contracts/atlassian";
20
+ import {
21
+ bindConnectorDocumentDestination,
22
+ type ConnectorDocumentDestinationSelection,
23
+ } from "@opengeni/contracts/connector-destinations";
24
+ import {
25
+ captureScheduledTaskRestoreState,
26
+ createValidatedScheduledTask,
27
+ hasPermission,
28
+ manualScheduledTaskTriggerWorkflowId,
29
+ requireEnvironmentEncryption,
30
+ syncCreatedScheduledTask,
31
+ syncUpdatedScheduledTask,
32
+ type ApiRouteDeps,
33
+ } from "@opengeni/core";
34
+ import {
35
+ appendKnowledgeSourceAclVersion,
36
+ buildConnectionTokenResolver,
37
+ ConnectionDisconnectGenerationError,
38
+ ConnectionDisconnectIdempotencyError,
39
+ consumeIntegrationOAuthStateNonce,
40
+ createConnection,
41
+ deauthorizeKnowledgeSourceRetrieval,
42
+ disconnectConnectionIdempotently,
43
+ encryptEnvironmentValue,
44
+ getConnectionMetadata,
45
+ getKnowledgeSourceByExternalIdentityForSyncAuthority,
46
+ getKnowledgeSourceForSyncAuthority,
47
+ getWorkspaceGrant,
48
+ listKnowledgeSourceSyncTasksForConnection,
49
+ loadConnectionCredentialForBroker,
50
+ recordKnowledgeLifecycleEvent,
51
+ transitionConnectionState,
52
+ updateConnection,
53
+ updateScheduledTask,
54
+ upsertKnowledgeProvider,
55
+ upsertKnowledgeSource,
56
+ } from "@opengeni/db";
57
+ import { atlassianKnowledgeSourceIdentity } from "@opengeni/documents/atlassian";
58
+ import { createSignedState, readSignedState } from "@opengeni/github";
59
+ import { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
60
+ import { HTTPException } from "hono/http-exception";
61
+ import {
62
+ integrationBaseUrl,
63
+ oauthStateTtlMs,
64
+ requireIntegrationsStateSecret,
65
+ } from "./oauth-client";
66
+
67
+ const ATLASSIAN_AUTHORIZATION_URL = "https://auth.atlassian.com/authorize";
68
+ const ATLASSIAN_TOKEN_URL = "https://auth.atlassian.com/oauth/token";
69
+ const ATLASSIAN_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources";
70
+ const ATLASSIAN_PROFILE_URL = "https://api.atlassian.com/me";
71
+ const ATLASSIAN_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
72
+ const ATLASSIAN_REQUEST_TIMEOUT_MS = 15_000;
73
+ const ATLASSIAN_RETURN_PATH = (workspaceId: string) => `/workspaces/${workspaceId}/capabilities`;
74
+
75
+ type AtlassianOAuthState = {
76
+ accountId: string;
77
+ workspaceId: string;
78
+ subjectId: string;
79
+ returnPath: string;
80
+ connectionId?: string;
81
+ connectionVersion?: number;
82
+ nonce: string;
83
+ iat: number;
84
+ };
85
+
86
+ type AtlassianConnectionRecord = NonNullable<Awaited<ReturnType<typeof getConnectionMetadata>>>;
87
+ type AtlassianSite = {
88
+ cloudId: string;
89
+ name: string;
90
+ url: string;
91
+ products: Array<"jira" | "confluence">;
92
+ };
93
+
94
+ export async function startAtlassianOAuth(
95
+ deps: ApiRouteDeps,
96
+ input: {
97
+ accountId: string;
98
+ workspaceId: string;
99
+ subjectId: string;
100
+ requestUrl: string;
101
+ payload: AtlassianOAuthStartRequest;
102
+ },
103
+ ): Promise<AtlassianOAuthStartResponse> {
104
+ const oauth = requireAtlassianSettings(deps.settings);
105
+ const existing = input.payload.connectionId
106
+ ? await getConnectionMetadata(
107
+ deps.db,
108
+ input.workspaceId,
109
+ input.payload.connectionId,
110
+ input.subjectId,
111
+ )
112
+ : null;
113
+ if (input.payload.connectionId && !existing) {
114
+ throw new HTTPException(404, { message: "Atlassian connection not found" });
115
+ }
116
+ if (existing) requireAtlassianConnection(existing, input.subjectId);
117
+
118
+ const baseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
119
+ const redirectUri = `${baseUrl}/v1/integrations/atlassian/callback`;
120
+ const state = createSignedState(requireIntegrationsStateSecret(deps.settings), {
121
+ accountId: input.accountId,
122
+ workspaceId: input.workspaceId,
123
+ subjectId: input.subjectId,
124
+ returnPath: ATLASSIAN_RETURN_PATH(input.workspaceId),
125
+ ...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
126
+ });
127
+ const url = new URL(ATLASSIAN_AUTHORIZATION_URL);
128
+ url.searchParams.set("audience", ATLASSIAN_PROVIDER_DOMAIN);
129
+ url.searchParams.set("client_id", oauth.clientId);
130
+ url.searchParams.set("scope", ATLASSIAN_REQUIRED_SCOPES.join(" "));
131
+ url.searchParams.set("redirect_uri", redirectUri);
132
+ url.searchParams.set("state", state);
133
+ url.searchParams.set("response_type", "code");
134
+ url.searchParams.set("prompt", "consent");
135
+ return AtlassianOAuthStartResponse.parse({
136
+ authorizationUrl: url.toString(),
137
+ expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
138
+ });
139
+ }
140
+
141
+ export async function completeAtlassianOAuthCallback(
142
+ deps: ApiRouteDeps,
143
+ input: {
144
+ code?: string | undefined;
145
+ state?: string | undefined;
146
+ error?: string | undefined;
147
+ requestUrl: string;
148
+ },
149
+ ): Promise<{ redirectTo: string }> {
150
+ const baseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
151
+ const returnBaseUrl = deps.settings.webBaseUrl?.replace(/\/+$/, "") ?? baseUrl;
152
+ let state: AtlassianOAuthState | null = null;
153
+ try {
154
+ state = readAtlassianOAuthState(input.state, deps.settings);
155
+ await requireCallbackGrant(deps, state);
156
+ const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
157
+ accountId: state.accountId,
158
+ workspaceId: state.workspaceId,
159
+ subjectId: state.subjectId,
160
+ nonce: state.nonce,
161
+ expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
162
+ now: new Date(),
163
+ });
164
+ if (!consumed) throw new AtlassianCallbackError("state_reused");
165
+ if (input.error) throw new AtlassianCallbackError("provider_denied");
166
+ if (!input.code) throw new AtlassianCallbackError("missing_code");
167
+
168
+ const oauth = requireAtlassianSettings(deps.settings);
169
+ const redirectUri = `${baseUrl}/v1/integrations/atlassian/callback`;
170
+ const fetchImpl = deps.atlassianFetch ?? fetch;
171
+ const token = await exchangeAuthorizationCode(fetchImpl, {
172
+ code: input.code,
173
+ clientId: oauth.clientId,
174
+ clientSecret: oauth.clientSecret,
175
+ redirectUri,
176
+ });
177
+ if (!atlassianScopesAllowRead(token.scopes)) {
178
+ throw new AtlassianCallbackError("scope_not_granted");
179
+ }
180
+ const [profile, sites] = await Promise.all([
181
+ fetchAtlassianProfile(fetchImpl, token.accessToken),
182
+ fetchAccessibleSites(fetchImpl, token.accessToken),
183
+ ]);
184
+ if (sites.length === 0) throw new AtlassianCallbackError("no_accessible_sites");
185
+ await requireCallbackGrant(deps, state);
186
+
187
+ const existing = state.connectionId
188
+ ? await getConnectionMetadata(deps.db, state.workspaceId, state.connectionId, state.subjectId)
189
+ : null;
190
+ if (state.connectionId && !existing) throw new AtlassianCallbackError("connection_conflict");
191
+ if (existing) {
192
+ const previous = requireAtlassianConnection(existing, state.subjectId);
193
+ if (existing.version !== state.connectionVersion) {
194
+ throw new AtlassianCallbackError("connection_conflict");
195
+ }
196
+ if (previous.atlassianAccountId !== profile.accountId) {
197
+ throw new AtlassianCallbackError("account_mismatch");
198
+ }
199
+ }
200
+ const previousMetadata = existing ? AtlassianConnectionMetadata.parse(existing.metadata) : null;
201
+ let refreshToken = token.refreshToken;
202
+ if (!refreshToken && existing) {
203
+ const previousCredential = await loadConnectionCredentialForBroker(deps.db, deps.settings, {
204
+ workspaceId: state.workspaceId,
205
+ connectionId: existing.id,
206
+ providerDomain: ATLASSIAN_PROVIDER_DOMAIN,
207
+ kind: "oauth2",
208
+ subjectId: state.subjectId,
209
+ allowSubjectOwned: true,
210
+ });
211
+ refreshToken = optionalString(previousCredential?.credential.refresh_token) ?? undefined;
212
+ }
213
+ if (!refreshToken) throw new AtlassianCallbackError("refresh_token_missing");
214
+
215
+ const credentialEncrypted = encryptEnvironmentValue(
216
+ requireEnvironmentEncryption(deps.settings),
217
+ JSON.stringify({
218
+ access_token: token.accessToken,
219
+ refresh_token: refreshToken,
220
+ token_type: token.tokenType,
221
+ expires_at: token.expiresAt.toISOString(),
222
+ scope: token.scopes.join(" "),
223
+ token_endpoint: ATLASSIAN_TOKEN_URL,
224
+ client_id: oauth.clientId,
225
+ client_secret: oauth.clientSecret,
226
+ token_endpoint_auth_method: "client_secret_post",
227
+ token_request_encoding: "json",
228
+ }),
229
+ );
230
+ const metadata = AtlassianConnectionMetadata.parse({
231
+ credentialRole: ATLASSIAN_CREDENTIAL_ROLE,
232
+ credentialLabel: ATLASSIAN_CREDENTIAL_LABEL,
233
+ atlassianAccountId: profile.accountId,
234
+ displayName: profile.displayName,
235
+ email: profile.email,
236
+ sites,
237
+ verifiedAt: new Date().toISOString(),
238
+ accessMode: "readonly",
239
+ lifecycle: lifecycle("active"),
240
+ ...(previousMetadata?.documentDestination
241
+ ? { documentDestination: previousMetadata.documentDestination }
242
+ : {}),
243
+ selectedSources: previousMetadata?.selectedSources ?? [],
244
+ });
245
+ const connection = existing
246
+ ? await updateConnection(deps.db, {
247
+ workspaceId: state.workspaceId,
248
+ connectionId: existing.id,
249
+ visibleToSubjectId: state.subjectId,
250
+ expectedVersion: existing.version,
251
+ subjectId: state.subjectId,
252
+ providerDomain: ATLASSIAN_PROVIDER_DOMAIN,
253
+ kind: "oauth2",
254
+ status: "active",
255
+ credentialEncrypted,
256
+ grantedScopes: token.scopes,
257
+ expiresAt: token.expiresAt,
258
+ metadata,
259
+ updatedBySubjectId: state.subjectId,
260
+ })
261
+ : await createConnection(deps.db, {
262
+ accountId: state.accountId,
263
+ workspaceId: state.workspaceId,
264
+ subjectId: state.subjectId,
265
+ providerDomain: ATLASSIAN_PROVIDER_DOMAIN,
266
+ kind: "oauth2",
267
+ credentialEncrypted,
268
+ grantedScopes: token.scopes,
269
+ expiresAt: token.expiresAt,
270
+ metadata,
271
+ createdBySubjectId: state.subjectId,
272
+ });
273
+ if (!connection) throw new AtlassianCallbackError("connection_conflict");
274
+ return {
275
+ redirectTo: returnUrl(returnBaseUrl, state.returnPath, "connected", connection.id),
276
+ };
277
+ } catch (error) {
278
+ return {
279
+ redirectTo: returnUrl(
280
+ returnBaseUrl,
281
+ state?.returnPath ?? "/integrations",
282
+ "error",
283
+ errorReason(error),
284
+ ),
285
+ };
286
+ }
287
+ }
288
+
289
+ export async function browseAtlassianSources(
290
+ deps: ApiRouteDeps,
291
+ input: { workspaceId: string; subjectId: string; connectionId: string },
292
+ ) {
293
+ const connection = await getConnectionMetadata(
294
+ deps.db,
295
+ input.workspaceId,
296
+ input.connectionId,
297
+ input.subjectId,
298
+ );
299
+ if (!connection) throw new HTTPException(404, { message: "Atlassian connection not found" });
300
+ const metadata = requireUsableConnection(connection, input.subjectId);
301
+ const groups = await Promise.all(
302
+ metadata.sites.flatMap((site) =>
303
+ site.products.map(async (product) =>
304
+ product === "jira"
305
+ ? await browseJiraProjects(deps, input, site)
306
+ : await browseConfluenceSpaces(deps, input, site),
307
+ ),
308
+ ),
309
+ );
310
+ const current =
311
+ (await getConnectionMetadata(
312
+ deps.db,
313
+ input.workspaceId,
314
+ input.connectionId,
315
+ input.subjectId,
316
+ )) ?? connection;
317
+ return AtlassianBrowseResponse.parse({
318
+ connection: current,
319
+ items: groups
320
+ .flat()
321
+ .sort(
322
+ (left, right) =>
323
+ left.siteName.localeCompare(right.siteName) ||
324
+ left.kind.localeCompare(right.kind) ||
325
+ left.name.localeCompare(right.name),
326
+ ),
327
+ });
328
+ }
329
+
330
+ export async function searchAtlassianLive(
331
+ deps: ApiRouteDeps,
332
+ input: {
333
+ workspaceId: string;
334
+ subjectId: string;
335
+ connectionId: string;
336
+ query: string;
337
+ product?: "jira" | "confluence" | undefined;
338
+ limit: number;
339
+ },
340
+ ) {
341
+ const connection = await getConnectionMetadata(
342
+ deps.db,
343
+ input.workspaceId,
344
+ input.connectionId,
345
+ input.subjectId,
346
+ );
347
+ if (!connection) throw new HTTPException(404, { message: "Atlassian connection not found" });
348
+ const metadata = requireUsableConnection(connection, input.subjectId);
349
+ const query = input.query.trim();
350
+ if (!query || query.length > 500)
351
+ throw new HTTPException(400, { message: "invalid search query" });
352
+ const jiraSources = metadata.selectedSources.filter(
353
+ (source) => source.kind === "jira_project" && input.product !== "confluence",
354
+ );
355
+ const confluenceSources = metadata.selectedSources.filter(
356
+ (source) => source.kind === "confluence_space" && input.product !== "jira",
357
+ );
358
+ const results = await Promise.all([
359
+ ...groupSourcesByCloudId(jiraSources).map(async ([cloudId, sources]) => {
360
+ const url = new URL(
361
+ `https://api.atlassian.com/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3/search/jql`,
362
+ );
363
+ url.searchParams.set(
364
+ "jql",
365
+ `project in (${sources.map((source) => jqlString(source.key)).join(",")}) AND text ~ ${jqlString(query)} ORDER BY updated DESC`,
366
+ );
367
+ url.searchParams.set("maxResults", String(input.limit));
368
+ url.searchParams.set("fields", "summary,project,status,issuetype,updated");
369
+ const payload = objectRecord(
370
+ await atlassianApiRequest(deps, { ...input, url, label: "Jira live search" }),
371
+ );
372
+ return (Array.isArray(payload.issues) ? payload.issues : []).map((raw) => {
373
+ const row = objectRecord(raw);
374
+ const fields = objectRecord(row.fields);
375
+ const project = objectRecord(fields.project);
376
+ const key = requiredString(row.key, "issue.key");
377
+ const source = sources.find(
378
+ (candidate) => candidate.resourceId === optionalString(project.id),
379
+ );
380
+ return {
381
+ kind: "jira_issue" as const,
382
+ id: requiredString(row.id, "issue.id"),
383
+ key,
384
+ title: requiredString(fields.summary, "issue.summary"),
385
+ sourceId: source?.id ?? null,
386
+ sourceName: source?.name ?? optionalString(project.name),
387
+ status: optionalString(objectRecord(fields.status).name),
388
+ updatedAt: optionalString(fields.updated),
389
+ url: new URL(
390
+ `/browse/${encodeURIComponent(key)}`,
391
+ source?.siteUrl ?? sources[0]!.siteUrl,
392
+ ).toString(),
393
+ };
394
+ });
395
+ }),
396
+ ...groupSourcesByCloudId(confluenceSources).map(async ([cloudId, sources]) => {
397
+ const url = new URL(
398
+ `https://api.atlassian.com/ex/confluence/${encodeURIComponent(cloudId)}/wiki/rest/api/search`,
399
+ );
400
+ url.searchParams.set(
401
+ "cql",
402
+ `type=page AND space in (${sources.map((source) => cqlString(source.key)).join(",")}) AND text ~ ${cqlString(query)}`,
403
+ );
404
+ url.searchParams.set("limit", String(input.limit));
405
+ url.searchParams.set("expand", "content.space");
406
+ const payload = objectRecord(
407
+ await atlassianApiRequest(deps, { ...input, url, label: "Confluence live search" }),
408
+ );
409
+ const base = optionalString(objectRecord(payload._links).base) ?? sources[0]!.siteUrl;
410
+ return (Array.isArray(payload.results) ? payload.results : []).map((raw) => {
411
+ const row = objectRecord(raw);
412
+ const content = objectRecord(row.content);
413
+ const space = objectRecord(content.space);
414
+ const source = sources.find((candidate) => candidate.key === optionalString(space.key));
415
+ const webUi = optionalString(objectRecord(content._links).webui);
416
+ return {
417
+ kind: "confluence_page" as const,
418
+ id: requiredString(content.id, "page.id"),
419
+ key: null,
420
+ title: requiredString(content.title, "page.title"),
421
+ sourceId: source?.id ?? null,
422
+ sourceName: source?.name ?? optionalString(space.name),
423
+ status: null,
424
+ updatedAt: optionalString(objectRecord(row.lastModified).when),
425
+ url: webUi ? new URL(webUi, base).toString() : (source?.siteUrl ?? base),
426
+ };
427
+ });
428
+ }),
429
+ ]);
430
+ return results.flat().slice(0, input.limit);
431
+ }
432
+
433
+ export async function getAtlassianLiveItem(
434
+ deps: ApiRouteDeps,
435
+ input: {
436
+ workspaceId: string;
437
+ subjectId: string;
438
+ connectionId: string;
439
+ kind: "jira_issue" | "confluence_page";
440
+ id: string;
441
+ },
442
+ ) {
443
+ const connection = await getConnectionMetadata(
444
+ deps.db,
445
+ input.workspaceId,
446
+ input.connectionId,
447
+ input.subjectId,
448
+ );
449
+ if (!connection) throw new HTTPException(404, { message: "Atlassian connection not found" });
450
+ const metadata = requireUsableConnection(connection, input.subjectId);
451
+ if (input.kind === "jira_issue") {
452
+ for (const [cloudId, sources] of groupSourcesByCloudId(
453
+ metadata.selectedSources.filter((source) => source.kind === "jira_project"),
454
+ )) {
455
+ const url = new URL(
456
+ `https://api.atlassian.com/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3/issue/${encodeURIComponent(input.id)}`,
457
+ );
458
+ url.searchParams.set(
459
+ "fields",
460
+ "summary,description,project,status,issuetype,priority,assignee,reporter,labels,created,updated",
461
+ );
462
+ try {
463
+ const issue = objectRecord(
464
+ await atlassianApiRequest(deps, { ...input, url, label: "Jira live issue" }),
465
+ );
466
+ const fields = objectRecord(issue.fields);
467
+ const source = sources.find(
468
+ (candidate) => candidate.resourceId === optionalString(objectRecord(fields.project).id),
469
+ );
470
+ if (!source) continue;
471
+ const comments = await readJiraLiveComments(deps, input, cloudId);
472
+ return {
473
+ kind: "jira_issue" as const,
474
+ id: requiredString(issue.id, "issue.id"),
475
+ key: requiredString(issue.key, "issue.key"),
476
+ title: requiredString(fields.summary, "issue.summary"),
477
+ source: { id: source.id, name: source.name, siteName: source.siteName },
478
+ status: optionalString(objectRecord(fields.status).name),
479
+ issueType: optionalString(objectRecord(fields.issuetype).name),
480
+ priority: optionalString(objectRecord(fields.priority).name),
481
+ assignee: optionalString(objectRecord(fields.assignee).displayName),
482
+ reporter: optionalString(objectRecord(fields.reporter).displayName),
483
+ labels: Array.isArray(fields.labels)
484
+ ? fields.labels.filter((value): value is string => typeof value === "string")
485
+ : [],
486
+ createdAt: optionalString(fields.created),
487
+ updatedAt: optionalString(fields.updated),
488
+ description: adfText(fields.description),
489
+ comments,
490
+ url: new URL(
491
+ `/browse/${encodeURIComponent(requiredString(issue.key, "issue.key"))}`,
492
+ source.siteUrl,
493
+ ).toString(),
494
+ };
495
+ } catch (error) {
496
+ if (error instanceof HTTPException && error.status === 404) continue;
497
+ throw error;
498
+ }
499
+ }
500
+ throw new HTTPException(404, { message: "Jira issue is outside the selected projects" });
501
+ }
502
+
503
+ for (const [cloudId, sources] of groupSourcesByCloudId(
504
+ metadata.selectedSources.filter((source) => source.kind === "confluence_space"),
505
+ )) {
506
+ const url = new URL(
507
+ `https://api.atlassian.com/ex/confluence/${encodeURIComponent(cloudId)}/wiki/api/v2/pages/${encodeURIComponent(input.id)}`,
508
+ );
509
+ url.searchParams.set("body-format", "storage");
510
+ try {
511
+ const page = objectRecord(
512
+ await atlassianApiRequest(deps, { ...input, url, label: "Confluence live page" }),
513
+ );
514
+ const source = sources.find(
515
+ (candidate) => candidate.resourceId === optionalString(page.spaceId),
516
+ );
517
+ if (!source) continue;
518
+ const comments = await readConfluenceLiveComments(deps, input, cloudId);
519
+ const webUi = optionalString(objectRecord(page._links).webui);
520
+ return {
521
+ kind: "confluence_page" as const,
522
+ id: requiredString(page.id, "page.id"),
523
+ title: requiredString(page.title, "page.title"),
524
+ source: { id: source.id, name: source.name, siteName: source.siteName },
525
+ status: optionalString(page.status),
526
+ createdAt: optionalString(page.createdAt),
527
+ updatedAt: optionalString(objectRecord(page.version).createdAt),
528
+ content: optionalString(objectRecord(objectRecord(page.body).storage).value) ?? "",
529
+ comments,
530
+ url: webUi
531
+ ? new URL(webUi.startsWith("/wiki/") ? webUi : `/wiki${webUi}`, source.siteUrl).toString()
532
+ : source.siteUrl,
533
+ };
534
+ } catch (error) {
535
+ if (error instanceof HTTPException && error.status === 404) continue;
536
+ throw error;
537
+ }
538
+ }
539
+ throw new HTTPException(404, { message: "Confluence page is outside the selected spaces" });
540
+ }
541
+
542
+ export async function saveAtlassianSources(
543
+ deps: ApiRouteDeps,
544
+ input: {
545
+ accountId: string;
546
+ workspaceId: string;
547
+ subjectId: string;
548
+ connectionId: string;
549
+ grant: AccessGrant;
550
+ payload: unknown;
551
+ canManageOrganizationDestination: boolean;
552
+ canManageWorkspaceDestination: boolean;
553
+ canManagePersonalDestination: boolean;
554
+ },
555
+ ) {
556
+ const parsed = SaveAtlassianSourcesRequest.safeParse(input.payload);
557
+ if (!parsed.success) {
558
+ throw new HTTPException(400, { message: "invalid Atlassian source selection" });
559
+ }
560
+ const existing = await getConnectionMetadata(
561
+ deps.db,
562
+ input.workspaceId,
563
+ input.connectionId,
564
+ input.subjectId,
565
+ );
566
+ if (!existing) throw new HTTPException(404, { message: "Atlassian connection not found" });
567
+ const metadata = requireUsableConnection(existing, input.subjectId);
568
+ const destinationSelection: ConnectorDocumentDestinationSelection = parsed.data.destination;
569
+ if (
570
+ destinationSelection.authorityKind === "organization" &&
571
+ !input.canManageOrganizationDestination
572
+ ) {
573
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
574
+ }
575
+ if (destinationSelection.authorityKind === "workspace" && !input.canManageWorkspaceDestination) {
576
+ throw new HTTPException(403, { message: "missing permission: workspace:admin" });
577
+ }
578
+ if (destinationSelection.authorityKind === "personal" && !input.canManagePersonalDestination) {
579
+ throw new HTTPException(403, { message: "personal destination requires the exact actor" });
580
+ }
581
+ const destination = bindConnectorDocumentDestination(destinationSelection, {
582
+ accountId: input.accountId,
583
+ workspaceId: input.workspaceId,
584
+ initiatingSubjectId: input.subjectId,
585
+ });
586
+
587
+ const available = (await browseAtlassianSources(deps, input)).items;
588
+ const availableById = new Map(available.map((source) => [source.id, source]));
589
+ const verified = parsed.data.sources.map((source) => {
590
+ const current = availableById.get(source.id);
591
+ if (
592
+ !current ||
593
+ current.cloudId !== source.cloudId ||
594
+ current.resourceId !== source.resourceId ||
595
+ current.key !== source.key ||
596
+ current.name !== source.name ||
597
+ current.kind !== source.kind
598
+ ) {
599
+ throw new HTTPException(409, {
600
+ message: "An Atlassian source changed while it was selected; reload the list",
601
+ });
602
+ }
603
+ return current;
604
+ });
605
+ const previousSources = metadata.selectedSources;
606
+ const updated = await transitionConnectionState(deps.db, {
607
+ workspaceId: input.workspaceId,
608
+ connectionId: existing.id,
609
+ visibleToSubjectId: input.subjectId,
610
+ expectedVersion: existing.version,
611
+ metadata: AtlassianConnectionMetadata.parse({
612
+ ...metadata,
613
+ documentDestination: destination,
614
+ selectedSources: verified.map((source) => ({
615
+ ...source,
616
+ destination,
617
+ syncCadence: parsed.data.syncCadence,
618
+ syncEnabled: parsed.data.syncEnabled,
619
+ configGeneration:
620
+ (previousSources.find((previous) => previous.id === source.id)?.configGeneration ?? 0) +
621
+ 1,
622
+ readPolicy: parsed.data.readPolicy,
623
+ selectedAt: new Date().toISOString(),
624
+ })),
625
+ }),
626
+ updatedBySubjectId: input.subjectId,
627
+ });
628
+ if (!updated) throw new HTTPException(409, { message: "Atlassian connection changed" });
629
+ await materializeSchedules(deps, {
630
+ ...input,
631
+ connection: updated,
632
+ metadata: AtlassianConnectionMetadata.parse(updated.metadata),
633
+ previousSources,
634
+ });
635
+ return updated;
636
+ }
637
+
638
+ export async function transitionAtlassianLifecycle(
639
+ deps: ApiRouteDeps,
640
+ input: {
641
+ workspaceId: string;
642
+ subjectId: string;
643
+ connectionId: string;
644
+ payload: AtlassianLifecycleActionRequest;
645
+ },
646
+ ) {
647
+ const connection = await getConnectionMetadata(
648
+ deps.db,
649
+ input.workspaceId,
650
+ input.connectionId,
651
+ input.subjectId,
652
+ );
653
+ if (!connection) throw new HTTPException(404, { message: "Atlassian connection not found" });
654
+ const metadata = requireAtlassianConnection(connection, input.subjectId);
655
+ if (connection.version !== input.payload.expectedVersion) {
656
+ throw new HTTPException(409, { message: "Atlassian connection changed" });
657
+ }
658
+ const target = input.payload.action === "pause" ? "paused" : "active";
659
+ if (target === "active" && connection.status !== "active") {
660
+ throw new HTTPException(409, { message: "Reconnect Atlassian before resuming" });
661
+ }
662
+ if (target === "paused") {
663
+ await deauthorizeConnectionSources(deps, connection, input.subjectId, "connection_paused");
664
+ }
665
+ const updated = await transitionConnectionState(deps.db, {
666
+ workspaceId: input.workspaceId,
667
+ connectionId: input.connectionId,
668
+ visibleToSubjectId: input.subjectId,
669
+ expectedVersion: connection.version,
670
+ metadata: AtlassianConnectionMetadata.parse({ ...metadata, lifecycle: lifecycle(target) }),
671
+ updatedBySubjectId: input.subjectId,
672
+ });
673
+ if (!updated) throw new HTTPException(409, { message: "Atlassian connection changed" });
674
+ await setSchedulePause(deps, input.workspaceId, input.connectionId, target === "paused");
675
+ return updated;
676
+ }
677
+
678
+ export async function disconnectAtlassian(
679
+ deps: ApiRouteDeps,
680
+ input: {
681
+ workspaceId: string;
682
+ subjectId: string;
683
+ connection: AtlassianConnectionRecord;
684
+ payload: AtlassianDisconnectRequest;
685
+ },
686
+ ) {
687
+ const metadata = requireAtlassianConnection(input.connection, input.subjectId);
688
+ try {
689
+ await deauthorizeConnectionSources(
690
+ deps,
691
+ input.connection,
692
+ input.subjectId,
693
+ "connection_disconnected",
694
+ );
695
+ const disconnected = await disconnectConnectionIdempotently(deps.db, {
696
+ accountId: input.connection.accountId,
697
+ workspaceId: input.workspaceId,
698
+ subjectId: input.subjectId,
699
+ connectionId: input.connection.id,
700
+ expectedVersion: input.payload.expectedVersion,
701
+ idempotencyKey: input.payload.idempotencyKey,
702
+ metadata: AtlassianConnectionMetadata.parse({
703
+ ...metadata,
704
+ lifecycle: lifecycle("disconnected"),
705
+ }),
706
+ lastError: null,
707
+ updatedBySubjectId: input.subjectId,
708
+ });
709
+ await setSchedulePause(deps, input.workspaceId, input.connection.id, true);
710
+ return disconnected;
711
+ } catch (error) {
712
+ if (error instanceof ConnectionDisconnectIdempotencyError) {
713
+ throw new HTTPException(409, { message: "Atlassian disconnect key was already used" });
714
+ }
715
+ if (error instanceof ConnectionDisconnectGenerationError) {
716
+ throw new HTTPException(409, { message: "Atlassian connection changed" });
717
+ }
718
+ throw error;
719
+ }
720
+ }
721
+
722
+ export async function revokeAtlassianScheduleAuthorization(
723
+ deps: ApiRouteDeps,
724
+ input: { task: ScheduledTask; subjectId: string },
725
+ ): Promise<void> {
726
+ const { task } = input;
727
+ if (task.action.kind !== "knowledge_source_sync") return;
728
+ if (
729
+ task.action.initiatingSubjectId !== input.subjectId ||
730
+ task.action.connection.ownerSubjectId !== input.subjectId
731
+ ) {
732
+ throw new HTTPException(403, {
733
+ message: "knowledge source schedule requires the exact initiating subject",
734
+ });
735
+ }
736
+ const externalSourceId = optionalString(task.metadata.externalSourceId);
737
+ if (!externalSourceId) {
738
+ throw new HTTPException(409, { message: "Atlassian schedule identity is incomplete" });
739
+ }
740
+ const connection = await getConnectionMetadata(
741
+ deps.db,
742
+ task.workspaceId,
743
+ task.action.connection.connectionId,
744
+ input.subjectId,
745
+ );
746
+ if (!connection) throw new HTTPException(409, { message: "Atlassian connection is unavailable" });
747
+ const metadata = requireAtlassianConnection(connection, input.subjectId);
748
+ const selected = metadata.selectedSources.find((source) => source.id === externalSourceId);
749
+ let authorityVersion = connection.version;
750
+ if (selected?.syncEnabled) {
751
+ const updated = await transitionConnectionState(deps.db, {
752
+ workspaceId: task.workspaceId,
753
+ connectionId: connection.id,
754
+ visibleToSubjectId: input.subjectId,
755
+ expectedVersion: connection.version,
756
+ metadata: AtlassianConnectionMetadata.parse({
757
+ ...metadata,
758
+ selectedSources: metadata.selectedSources.map((source) =>
759
+ source.id === externalSourceId
760
+ ? { ...source, syncEnabled: false, configGeneration: source.configGeneration + 1 }
761
+ : source,
762
+ ),
763
+ }),
764
+ updatedBySubjectId: input.subjectId,
765
+ });
766
+ if (!updated) throw new HTTPException(409, { message: "Atlassian connection changed" });
767
+ authorityVersion = updated.version;
768
+ }
769
+ const resolved = await getKnowledgeSourceForSyncAuthority(deps.db, {
770
+ accountId: task.accountId,
771
+ workspaceId: task.workspaceId,
772
+ sourceId: task.action.sourceId,
773
+ initiatingSubjectId: input.subjectId,
774
+ });
775
+ if (resolved?.source.lifecycleState === "active") {
776
+ await recordKnowledgeLifecycleEvent(deps.db, {
777
+ accountId: task.accountId,
778
+ workspaceId: task.workspaceId,
779
+ targetKind: "source",
780
+ targetId: resolved.source.id,
781
+ eventType: "deleted",
782
+ expectedGeneration: resolved.source.lifecycleGeneration,
783
+ operationId: `atlassian-schedule-delete:${task.id}:${authorityVersion}`,
784
+ reasonCode: "schedule_deleted",
785
+ actor: {
786
+ kind: "human",
787
+ subjectId: input.subjectId,
788
+ initiatingHumanSubjectId: input.subjectId,
789
+ },
790
+ });
791
+ }
792
+ }
793
+
794
+ async function materializeSchedules(
795
+ deps: ApiRouteDeps,
796
+ input: {
797
+ accountId: string;
798
+ workspaceId: string;
799
+ subjectId: string;
800
+ connectionId: string;
801
+ grant: AccessGrant;
802
+ connection: AtlassianConnectionRecord;
803
+ metadata: ReturnType<typeof AtlassianConnectionMetadata.parse>;
804
+ previousSources: AtlassianSelectedSource[];
805
+ },
806
+ ): Promise<void> {
807
+ const enabledSources = input.metadata.selectedSources.filter((source) => source.syncEnabled);
808
+ const enabledIds = new Set(enabledSources.map((source) => source.id));
809
+ const previouslyEnabled = new Set(
810
+ input.previousSources.filter((source) => source.syncEnabled).map((source) => source.id),
811
+ );
812
+ const tasks = await listKnowledgeSourceSyncTasksForConnection(
813
+ deps.db,
814
+ input.workspaceId,
815
+ input.connectionId,
816
+ );
817
+ const actor = {
818
+ kind: "human" as const,
819
+ subjectId: input.subjectId,
820
+ initiatingHumanSubjectId: input.subjectId,
821
+ };
822
+
823
+ for (const selectedSource of enabledSources) {
824
+ const identity = atlassianKnowledgeSourceIdentity({
825
+ source: selectedSource,
826
+ accountId: input.accountId,
827
+ workspaceId: input.workspaceId,
828
+ connectionSubjectId: input.subjectId,
829
+ });
830
+ let source = null as Awaited<ReturnType<typeof upsertKnowledgeSource>> | null;
831
+ let existingTask: (typeof tasks)[number] | null = null;
832
+ for (const task of tasks) {
833
+ if (task.action.kind !== "knowledge_source_sync") continue;
834
+ const resolved = await getKnowledgeSourceForSyncAuthority(deps.db, {
835
+ accountId: input.accountId,
836
+ workspaceId: input.workspaceId,
837
+ sourceId: task.action.sourceId,
838
+ initiatingSubjectId: input.subjectId,
839
+ });
840
+ if (resolved?.source.externalSourceId !== identity.externalSourceId) continue;
841
+ source = resolved.source;
842
+ existingTask = task;
843
+ break;
844
+ }
845
+ if (!source) {
846
+ const provider = await upsertKnowledgeProvider(deps.db, {
847
+ accountId: input.accountId,
848
+ workspaceId: input.workspaceId,
849
+ scope: identity.scope,
850
+ providerKey: identity.providerKey,
851
+ externalTenantId: identity.externalTenantId,
852
+ operationId: `atlassian-provider:${input.connectionId}:${identity.externalTenantId}`,
853
+ actor,
854
+ });
855
+ source =
856
+ (await getKnowledgeSourceByExternalIdentityForSyncAuthority(deps.db, {
857
+ accountId: input.accountId,
858
+ workspaceId: input.workspaceId,
859
+ providerId: provider.id,
860
+ externalSourceId: identity.externalSourceId,
861
+ initiatingSubjectId: input.subjectId,
862
+ })) ??
863
+ (await upsertKnowledgeSource(deps.db, {
864
+ accountId: input.accountId,
865
+ workspaceId: input.workspaceId,
866
+ scope: identity.scope,
867
+ providerId: provider.id,
868
+ externalSourceId: identity.externalSourceId,
869
+ sourceKind: identity.sourceKind,
870
+ sourceUri: identity.sourceUri,
871
+ operationId: `atlassian-source:${input.connectionId}:${identity.externalSourceId}`,
872
+ actor,
873
+ }));
874
+ }
875
+ if (source.lifecycleState !== "active") {
876
+ if (previouslyEnabled.has(selectedSource.id)) continue;
877
+ const restored = await recordKnowledgeLifecycleEvent(deps.db, {
878
+ accountId: input.accountId,
879
+ workspaceId: input.workspaceId,
880
+ targetKind: "source",
881
+ targetId: source.id,
882
+ eventType: "restored",
883
+ expectedGeneration: source.lifecycleGeneration,
884
+ operationId: `atlassian-source-restore:${source.id}:${input.connection.version}`,
885
+ reasonCode: "source_explicitly_reenabled",
886
+ actor,
887
+ });
888
+ source = {
889
+ ...source,
890
+ lifecycleState: "active",
891
+ lifecycleGeneration: restored.lifecycleGeneration,
892
+ };
893
+ }
894
+ if (!source.currentAclGeneration) {
895
+ await appendKnowledgeSourceAclVersion(deps.db, {
896
+ accountId: input.accountId,
897
+ workspaceId: input.workspaceId,
898
+ sourceId: source.id,
899
+ audience: identity.scope,
900
+ expectedSourceLifecycleGeneration: source.lifecycleGeneration,
901
+ expectedAclGeneration: 0,
902
+ aclVersion: `atlassian-destination:${input.connection.version}`,
903
+ agentAccess: false,
904
+ operationId: `atlassian-source-acl:${source.id}`,
905
+ reasonCode: "source_selected",
906
+ actor,
907
+ });
908
+ }
909
+ const action = {
910
+ kind: "knowledge_source_sync" as const,
911
+ sourceId: source.id,
912
+ sourceGeneration: source.syncGeneration,
913
+ sourceLifecycleGeneration: source.lifecycleGeneration,
914
+ sourceConfigGeneration: selectedSource.configGeneration,
915
+ controlWorkspaceId: input.workspaceId,
916
+ providerCoordinationKey: `atlassian:${selectedSource.cloudId}:${selectedSource.kind}`,
917
+ destination: identity.scope,
918
+ initiatingSubjectId: input.subjectId,
919
+ allDescendants: true,
920
+ connection: {
921
+ connectionId: input.connectionId,
922
+ ownerSubjectId: input.subjectId,
923
+ connectionVersion: input.connection.version,
924
+ providerDomain: input.connection.providerDomain,
925
+ kind: input.connection.kind,
926
+ },
927
+ limits: {
928
+ maxItems: 1_000,
929
+ maxBytes: 250_000_000,
930
+ maxFileBytes: 5_000_000,
931
+ maxProviderRequests: 2_000,
932
+ maxElapsedSeconds: 300,
933
+ maxConcurrency: 4,
934
+ maxFailureDetails: 25,
935
+ },
936
+ };
937
+ let task;
938
+ if (existingTask) {
939
+ const previous = await captureScheduledTaskRestoreState(deps.db, existingTask);
940
+ task = await updateScheduledTask(deps.db, input.workspaceId, existingTask.id, {
941
+ name: `Sync ${selectedSource.kind === "jira_project" ? "Jira" : "Confluence"}: ${selectedSource.name}`,
942
+ schedule: atlassianSchedule(selectedSource.syncCadence),
943
+ overlapPolicy: "buffer_one",
944
+ action,
945
+ metadata: {
946
+ ...existingTask.metadata,
947
+ connectorKind: "atlassian",
948
+ connectionId: input.connectionId,
949
+ externalSourceId: selectedSource.id,
950
+ knowledgeSourceSync: { sourceEnabled: true, connectionPaused: false },
951
+ },
952
+ });
953
+ await syncUpdatedScheduledTask({
954
+ db: deps.db,
955
+ workflowClient: deps.workflowClient,
956
+ previous,
957
+ task,
958
+ });
959
+ } else {
960
+ task = await createValidatedScheduledTask({
961
+ settings: deps.settings,
962
+ db: deps.db,
963
+ objectStorage: deps.objectStorage,
964
+ grant: input.grant,
965
+ authorizationSurface: "http",
966
+ sessionAuthorization: deps.sessionAuthorization,
967
+ payload: {
968
+ name: `Sync ${selectedSource.kind === "jira_project" ? "Jira" : "Confluence"}: ${selectedSource.name}`,
969
+ status: "active",
970
+ schedule: atlassianSchedule(selectedSource.syncCadence),
971
+ overlapPolicy: "buffer_one",
972
+ action,
973
+ runMode: "new_session_per_run",
974
+ targetSessionId: null,
975
+ agentConfig: {
976
+ prompt: "Knowledge source synchronization",
977
+ resources: [],
978
+ tools: [],
979
+ metadata: {},
980
+ },
981
+ variableSetId: null,
982
+ environmentId: null,
983
+ rigId: null,
984
+ metadata: {
985
+ connectorKind: "atlassian",
986
+ connectionId: input.connectionId,
987
+ externalSourceId: selectedSource.id,
988
+ knowledgeSourceSync: { sourceEnabled: true, connectionPaused: false },
989
+ },
990
+ },
991
+ });
992
+ await syncCreatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, task });
993
+ }
994
+ const token = `source-save-${input.connection.version}`;
995
+ await deps.workflowClient.triggerScheduledTask({
996
+ task,
997
+ agentRunUsageIdempotencyKey: `knowledge-source-sync:initial:${task.id}:${token}`,
998
+ triggerWorkflowId: manualScheduledTaskTriggerWorkflowId(task.id, token),
999
+ initiator: { kind: "subject", subjectId: input.subjectId },
1000
+ triggerType: existingTask ? "repair" : "initial",
1001
+ });
1002
+ }
1003
+
1004
+ for (const task of tasks) {
1005
+ if (task.action.kind !== "knowledge_source_sync") continue;
1006
+ const resolved = await getKnowledgeSourceForSyncAuthority(deps.db, {
1007
+ accountId: input.accountId,
1008
+ workspaceId: input.workspaceId,
1009
+ sourceId: task.action.sourceId,
1010
+ initiatingSubjectId: input.subjectId,
1011
+ });
1012
+ if (!resolved || enabledIds.has(resolved.source.externalSourceId)) continue;
1013
+ if (resolved.source.lifecycleState === "active") {
1014
+ await recordKnowledgeLifecycleEvent(deps.db, {
1015
+ accountId: input.accountId,
1016
+ workspaceId: input.workspaceId,
1017
+ targetKind: "source",
1018
+ targetId: resolved.source.id,
1019
+ eventType: "deleted",
1020
+ expectedGeneration: resolved.source.lifecycleGeneration,
1021
+ operationId: `atlassian-source-deselect:${resolved.source.id}:${input.connection.version}`,
1022
+ reasonCode: "source_deselected",
1023
+ actor,
1024
+ });
1025
+ }
1026
+ const previous = await captureScheduledTaskRestoreState(deps.db, task);
1027
+ const disabled = await updateScheduledTask(deps.db, input.workspaceId, task.id, {
1028
+ metadata: {
1029
+ ...task.metadata,
1030
+ knowledgeSourceSync: { sourceEnabled: false, connectionPaused: false },
1031
+ },
1032
+ });
1033
+ await syncUpdatedScheduledTask({
1034
+ db: deps.db,
1035
+ workflowClient: deps.workflowClient,
1036
+ previous,
1037
+ task: disabled,
1038
+ });
1039
+ }
1040
+ }
1041
+
1042
+ async function browseJiraProjects(
1043
+ deps: ApiRouteDeps,
1044
+ input: { workspaceId: string; subjectId: string; connectionId: string },
1045
+ site: AtlassianSite,
1046
+ ): Promise<AtlassianBrowseItem[]> {
1047
+ const items: AtlassianBrowseItem[] = [];
1048
+ let startAt = 0;
1049
+ for (let page = 0; page < 20; page += 1) {
1050
+ const url = new URL(
1051
+ `https://api.atlassian.com/ex/jira/${encodeURIComponent(site.cloudId)}/rest/api/3/project/search`,
1052
+ );
1053
+ url.searchParams.set("startAt", String(startAt));
1054
+ url.searchParams.set("maxResults", "100");
1055
+ url.searchParams.set("orderBy", "name");
1056
+ const payload = objectRecord(
1057
+ await atlassianApiRequest(deps, { ...input, url, label: "Jira projects" }),
1058
+ );
1059
+ const values = Array.isArray(payload.values) ? payload.values : [];
1060
+ for (const raw of values) {
1061
+ const row = objectRecord(raw);
1062
+ const resourceId = optionalString(row.id);
1063
+ const key = optionalString(row.key);
1064
+ const name = optionalString(row.name);
1065
+ if (!resourceId || !key || !name) continue;
1066
+ items.push({
1067
+ id: `jira_project:${site.cloudId}:${resourceId}`,
1068
+ cloudId: site.cloudId,
1069
+ siteName: site.name,
1070
+ siteUrl: site.url,
1071
+ resourceId,
1072
+ key,
1073
+ name,
1074
+ kind: "jira_project",
1075
+ description: null,
1076
+ webUrl: new URL(
1077
+ `/jira/software/c/projects/${encodeURIComponent(key)}`,
1078
+ site.url,
1079
+ ).toString(),
1080
+ });
1081
+ }
1082
+ if (payload.isLast === true || values.length === 0) break;
1083
+ startAt += values.length;
1084
+ }
1085
+ return items;
1086
+ }
1087
+
1088
+ async function browseConfluenceSpaces(
1089
+ deps: ApiRouteDeps,
1090
+ input: { workspaceId: string; subjectId: string; connectionId: string },
1091
+ site: AtlassianSite,
1092
+ ): Promise<AtlassianBrowseItem[]> {
1093
+ const items: AtlassianBrowseItem[] = [];
1094
+ let url = new URL(
1095
+ `https://api.atlassian.com/ex/confluence/${encodeURIComponent(site.cloudId)}/wiki/api/v2/spaces?limit=100`,
1096
+ );
1097
+ for (let page = 0; page < 20; page += 1) {
1098
+ const payload = objectRecord(
1099
+ await atlassianApiRequest(deps, { ...input, url, label: "Confluence spaces" }),
1100
+ );
1101
+ const results = Array.isArray(payload.results) ? payload.results : [];
1102
+ for (const raw of results) {
1103
+ const row = objectRecord(raw);
1104
+ if (!isSelectableConfluenceSpaceType(row.type)) continue;
1105
+ const resourceId = optionalString(row.id);
1106
+ const key = optionalString(row.key);
1107
+ const name = optionalString(row.name);
1108
+ if (!resourceId || !key || !name) continue;
1109
+ items.push({
1110
+ id: `confluence_space:${site.cloudId}:${resourceId}`,
1111
+ cloudId: site.cloudId,
1112
+ siteName: site.name,
1113
+ siteUrl: site.url,
1114
+ resourceId,
1115
+ key,
1116
+ name,
1117
+ kind: "confluence_space",
1118
+ description: optionalString(objectRecord(row.description).plain?.toString()),
1119
+ webUrl: new URL(`/wiki/spaces/${encodeURIComponent(key)}`, site.url).toString(),
1120
+ });
1121
+ }
1122
+ const next = optionalString(objectRecord(payload._links).next);
1123
+ if (!next) break;
1124
+ url = confluenceNextUrl(site.cloudId, next);
1125
+ }
1126
+ return items;
1127
+ }
1128
+
1129
+ export function isSelectableConfluenceSpaceType(value: unknown): boolean {
1130
+ return optionalString(value) !== "personal";
1131
+ }
1132
+
1133
+ export function confluenceNextUrl(cloudId: string, next: string): URL {
1134
+ const apiBase = `https://api.atlassian.com/ex/confluence/${encodeURIComponent(cloudId)}`;
1135
+ const candidate = new URL(next, `${apiBase}/`);
1136
+ if (candidate.origin !== "https://api.atlassian.com") {
1137
+ throw new Error("Confluence pagination crossed the Atlassian API origin");
1138
+ }
1139
+ if (next.startsWith("/wiki/")) return new URL(`${apiBase}${next}`);
1140
+ if (!candidate.pathname.startsWith(`/ex/confluence/${encodeURIComponent(cloudId)}/`)) {
1141
+ throw new Error("Confluence pagination crossed the selected site boundary");
1142
+ }
1143
+ return candidate;
1144
+ }
1145
+
1146
+ async function atlassianApiRequest(
1147
+ deps: ApiRouteDeps,
1148
+ input: {
1149
+ workspaceId: string;
1150
+ subjectId: string;
1151
+ connectionId: string;
1152
+ url: URL;
1153
+ label: string;
1154
+ },
1155
+ ): Promise<unknown> {
1156
+ const current = await getConnectionMetadata(
1157
+ deps.db,
1158
+ input.workspaceId,
1159
+ input.connectionId,
1160
+ input.subjectId,
1161
+ );
1162
+ if (!current) throw new HTTPException(404, { message: "Atlassian connection not found" });
1163
+ requireUsableConnection(current, input.subjectId);
1164
+ const resolver = buildConnectionTokenResolver(deps.db, deps.settings, undefined, {
1165
+ ...(deps.atlassianFetch ? { refreshTransport: { fetchImpl: deps.atlassianFetch } } : {}),
1166
+ });
1167
+ const resolve = async (forceRefresh: boolean) =>
1168
+ await resolver({
1169
+ workspaceId: input.workspaceId,
1170
+ subjectId: input.subjectId,
1171
+ serverId: "atlassian-source-browser",
1172
+ toolName: input.label,
1173
+ connectionRef: {
1174
+ providerDomain: ATLASSIAN_PROVIDER_DOMAIN,
1175
+ connectionId: input.connectionId,
1176
+ kind: "oauth2",
1177
+ subjectScope: "subject",
1178
+ },
1179
+ destinationUrl: input.url.toString(),
1180
+ forceRefresh,
1181
+ });
1182
+ let credential = await resolve(false);
1183
+ if (credential.status !== "ok") {
1184
+ throw new HTTPException(401, { message: "Atlassian needs to be reconnected" });
1185
+ }
1186
+ const fetchImpl = deps.atlassianFetch ?? fetch;
1187
+ let response = await providerFetch(fetchImpl, input.url, {
1188
+ headers: { ...credential.headers, accept: "application/json" },
1189
+ });
1190
+ if (response.status === 401) {
1191
+ await response.body?.cancel().catch(() => undefined);
1192
+ credential = await resolve(true);
1193
+ if (credential.status !== "ok") {
1194
+ throw new HTTPException(401, { message: "Atlassian needs to be reconnected" });
1195
+ }
1196
+ response = await providerFetch(fetchImpl, input.url, {
1197
+ headers: { ...credential.headers, accept: "application/json" },
1198
+ });
1199
+ }
1200
+ if (!response.ok) {
1201
+ await response.body?.cancel().catch(() => undefined);
1202
+ const status = response.status === 403 ? 403 : response.status === 404 ? 404 : 502;
1203
+ throw new HTTPException(status, {
1204
+ message:
1205
+ response.status === 403
1206
+ ? "Atlassian denied access to this source"
1207
+ : response.status === 404
1208
+ ? "Atlassian content was not found"
1209
+ : "Atlassian is temporarily unavailable",
1210
+ });
1211
+ }
1212
+ return await readResponseJsonBounded<unknown>(
1213
+ response,
1214
+ ATLASSIAN_MAX_RESPONSE_BYTES,
1215
+ input.label,
1216
+ );
1217
+ }
1218
+
1219
+ async function exchangeAuthorizationCode(
1220
+ fetchImpl: FetchLike,
1221
+ input: { code: string; clientId: string; clientSecret: string; redirectUri: string },
1222
+ ) {
1223
+ const response = await providerFetch(fetchImpl, ATLASSIAN_TOKEN_URL, {
1224
+ method: "POST",
1225
+ headers: { accept: "application/json", "content-type": "application/json" },
1226
+ body: JSON.stringify({
1227
+ grant_type: "authorization_code",
1228
+ client_id: input.clientId,
1229
+ client_secret: input.clientSecret,
1230
+ code: input.code,
1231
+ redirect_uri: input.redirectUri,
1232
+ }),
1233
+ });
1234
+ if (!response.ok) throw new AtlassianCallbackError("token_exchange_failed");
1235
+ const payload = objectRecord(
1236
+ await readResponseJsonBounded<unknown>(
1237
+ response,
1238
+ ATLASSIAN_MAX_RESPONSE_BYTES,
1239
+ "Atlassian token response",
1240
+ ),
1241
+ );
1242
+ const accessToken = optionalString(payload.access_token);
1243
+ const refreshToken = optionalString(payload.refresh_token);
1244
+ const expiresIn = numberValue(payload.expires_in);
1245
+ if (!accessToken || !expiresIn) throw new AtlassianCallbackError("token_exchange_failed");
1246
+ return {
1247
+ accessToken,
1248
+ refreshToken: refreshToken ?? undefined,
1249
+ tokenType: optionalString(payload.token_type) ?? "Bearer",
1250
+ expiresAt: new Date(Date.now() + expiresIn * 1000),
1251
+ scopes: uniqueStrings((optionalString(payload.scope) ?? "").split(/\s+/)),
1252
+ };
1253
+ }
1254
+
1255
+ async function fetchAtlassianProfile(fetchImpl: FetchLike, accessToken: string) {
1256
+ const response = await providerFetch(fetchImpl, ATLASSIAN_PROFILE_URL, {
1257
+ headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
1258
+ });
1259
+ if (!response.ok) throw new AtlassianCallbackError("identity_verification_failed");
1260
+ const payload = objectRecord(
1261
+ await readResponseJsonBounded<unknown>(
1262
+ response,
1263
+ ATLASSIAN_MAX_RESPONSE_BYTES,
1264
+ "Atlassian profile response",
1265
+ ),
1266
+ );
1267
+ const accountId = optionalString(payload.account_id);
1268
+ const displayName = optionalString(payload.name) ?? optionalString(payload.nickname);
1269
+ if (!accountId || !displayName) throw new AtlassianCallbackError("identity_verification_failed");
1270
+ return { accountId, displayName, email: optionalString(payload.email) };
1271
+ }
1272
+
1273
+ async function fetchAccessibleSites(
1274
+ fetchImpl: FetchLike,
1275
+ accessToken: string,
1276
+ ): Promise<AtlassianSite[]> {
1277
+ const response = await providerFetch(fetchImpl, ATLASSIAN_RESOURCES_URL, {
1278
+ headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
1279
+ });
1280
+ if (!response.ok) throw new AtlassianCallbackError("site_verification_failed");
1281
+ const payload = await readResponseJsonBounded<unknown>(
1282
+ response,
1283
+ ATLASSIAN_MAX_RESPONSE_BYTES,
1284
+ "Atlassian accessible resources",
1285
+ );
1286
+ if (!Array.isArray(payload)) throw new AtlassianCallbackError("site_verification_failed");
1287
+ const merged = new Map<string, AtlassianSite>();
1288
+ for (const raw of payload) {
1289
+ const row = objectRecord(raw);
1290
+ const cloudId = optionalString(row.id);
1291
+ const name = optionalString(row.name);
1292
+ const url = optionalString(row.url);
1293
+ const scopes = Array.isArray(row.scopes)
1294
+ ? row.scopes.filter((scope): scope is string => typeof scope === "string")
1295
+ : [];
1296
+ if (!cloudId || !name || !url) continue;
1297
+ const products: Array<"jira" | "confluence"> = [];
1298
+ if (scopes.some((scope) => scope.includes(":jira") || scope.includes("jira-")))
1299
+ products.push("jira");
1300
+ if (scopes.some((scope) => scope.includes(":confluence") || scope.includes("confluence-"))) {
1301
+ products.push("confluence");
1302
+ }
1303
+ if (products.length === 0) continue;
1304
+ const key = `${cloudId}:${url}`;
1305
+ const previous = merged.get(key);
1306
+ merged.set(key, {
1307
+ cloudId,
1308
+ name,
1309
+ url: new URL(url).toString(),
1310
+ products: [...new Set([...(previous?.products ?? []), ...products])],
1311
+ });
1312
+ }
1313
+ return [...merged.values()].sort((left, right) => left.name.localeCompare(right.name));
1314
+ }
1315
+
1316
+ async function deauthorizeConnectionSources(
1317
+ deps: ApiRouteDeps,
1318
+ connection: AtlassianConnectionRecord,
1319
+ subjectId: string,
1320
+ reasonCode: string,
1321
+ ): Promise<void> {
1322
+ const tasks = await listKnowledgeSourceSyncTasksForConnection(
1323
+ deps.db,
1324
+ connection.workspaceId,
1325
+ connection.id,
1326
+ );
1327
+ for (const task of tasks) {
1328
+ if (task.action.kind !== "knowledge_source_sync") continue;
1329
+ const resolved = await getKnowledgeSourceForSyncAuthority(deps.db, {
1330
+ accountId: task.accountId,
1331
+ workspaceId: task.workspaceId,
1332
+ sourceId: task.action.sourceId,
1333
+ initiatingSubjectId: subjectId,
1334
+ });
1335
+ if (!resolved || resolved.source.lifecycleState !== "active") continue;
1336
+ await deauthorizeKnowledgeSourceRetrieval(deps.db, {
1337
+ accountId: task.accountId,
1338
+ workspaceId: task.workspaceId,
1339
+ sourceId: task.action.sourceId,
1340
+ audience: task.action.destination,
1341
+ operationId: `atlassian-deauthorize:${connection.id}:${connection.version + 1}:${task.action.sourceId}:${reasonCode}`,
1342
+ reasonCode,
1343
+ actor: { kind: "human", subjectId, initiatingHumanSubjectId: subjectId },
1344
+ });
1345
+ }
1346
+ }
1347
+
1348
+ async function setSchedulePause(
1349
+ deps: ApiRouteDeps,
1350
+ workspaceId: string,
1351
+ connectionId: string,
1352
+ paused: boolean,
1353
+ ): Promise<void> {
1354
+ const tasks = await listKnowledgeSourceSyncTasksForConnection(deps.db, workspaceId, connectionId);
1355
+ for (const task of tasks) {
1356
+ const previous = await captureScheduledTaskRestoreState(deps.db, task);
1357
+ const control = objectRecord(task.metadata.knowledgeSourceSync);
1358
+ const updated = await updateScheduledTask(deps.db, workspaceId, task.id, {
1359
+ metadata: {
1360
+ ...task.metadata,
1361
+ knowledgeSourceSync: { ...control, connectionPaused: paused },
1362
+ },
1363
+ });
1364
+ await syncUpdatedScheduledTask({
1365
+ db: deps.db,
1366
+ workflowClient: deps.workflowClient,
1367
+ previous,
1368
+ task: updated,
1369
+ });
1370
+ }
1371
+ }
1372
+
1373
+ function requireAtlassianConnection(connection: AtlassianConnectionRecord, subjectId: string) {
1374
+ const metadata = AtlassianConnectionMetadata.safeParse(connection.metadata);
1375
+ if (
1376
+ connection.subjectId !== subjectId ||
1377
+ connection.providerDomain !== ATLASSIAN_PROVIDER_DOMAIN ||
1378
+ connection.kind !== "oauth2" ||
1379
+ !metadata.success
1380
+ ) {
1381
+ throw new HTTPException(422, { message: "connection is not this user's Atlassian account" });
1382
+ }
1383
+ return metadata.data;
1384
+ }
1385
+
1386
+ function requireUsableConnection(connection: AtlassianConnectionRecord, subjectId: string) {
1387
+ const metadata = requireAtlassianConnection(connection, subjectId);
1388
+ if (
1389
+ connection.status !== "active" ||
1390
+ (metadata.lifecycle?.state && metadata.lifecycle.state !== "active")
1391
+ ) {
1392
+ throw new HTTPException(409, { message: "Atlassian must be reconnected or resumed" });
1393
+ }
1394
+ if (!atlassianScopesAllowRead(connection.grantedScopes)) {
1395
+ throw new HTTPException(401, { message: "Atlassian needs permission re-consent" });
1396
+ }
1397
+ return metadata;
1398
+ }
1399
+
1400
+ function readAtlassianOAuthState(raw: string | undefined, settings: Settings): AtlassianOAuthState {
1401
+ if (!raw) throw new HTTPException(400, { message: "missing Atlassian OAuth state" });
1402
+ const payload = readSignedState(raw, requireIntegrationsStateSecret(settings)) as Record<
1403
+ string,
1404
+ unknown
1405
+ > | null;
1406
+ if (!payload) throw new HTTPException(400, { message: "invalid Atlassian OAuth state" });
1407
+ const iat = numberValue(payload.iat);
1408
+ const now = Math.floor(Date.now() / 1000);
1409
+ if (iat === undefined || now < iat || now - iat > oauthStateTtlMs / 1_000) {
1410
+ throw new HTTPException(400, { message: "expired Atlassian OAuth state" });
1411
+ }
1412
+ const accountId = requiredString(payload.accountId, "state.accountId");
1413
+ const workspaceId = requiredString(payload.workspaceId, "state.workspaceId");
1414
+ const subjectId = requiredString(payload.subjectId, "state.subjectId");
1415
+ const returnPath = requiredString(payload.returnPath, "state.returnPath");
1416
+ if (returnPath !== ATLASSIAN_RETURN_PATH(workspaceId)) {
1417
+ throw new HTTPException(400, { message: "invalid Atlassian OAuth return path" });
1418
+ }
1419
+ const connectionId = optionalString(payload.connectionId) ?? undefined;
1420
+ const connectionVersion = numberValue(payload.connectionVersion);
1421
+ if (Boolean(connectionId) !== Boolean(connectionVersion)) {
1422
+ throw new HTTPException(400, { message: "invalid Atlassian reconnect state" });
1423
+ }
1424
+ return {
1425
+ accountId,
1426
+ workspaceId,
1427
+ subjectId,
1428
+ returnPath,
1429
+ ...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
1430
+ nonce: requiredString(payload.nonce, "state.nonce"),
1431
+ iat,
1432
+ };
1433
+ }
1434
+
1435
+ async function requireCallbackGrant(deps: ApiRouteDeps, state: AtlassianOAuthState): Promise<void> {
1436
+ const grant = await getWorkspaceGrant(deps.db, state.subjectId, state.workspaceId);
1437
+ if (
1438
+ !grant ||
1439
+ grant.accountId !== state.accountId ||
1440
+ !hasPermission(grant.permissions, "connections:write")
1441
+ ) {
1442
+ throw new HTTPException(403, { message: "Atlassian OAuth subject no longer has access" });
1443
+ }
1444
+ }
1445
+
1446
+ function requireAtlassianSettings(settings: Settings) {
1447
+ const clientId = settings.atlassianClientId?.trim();
1448
+ const clientSecret = settings.atlassianClientSecret?.trim();
1449
+ if (!clientId || !clientSecret) {
1450
+ throw new HTTPException(503, {
1451
+ message:
1452
+ "Atlassian requires OPENGENI_ATLASSIAN_CLIENT_ID and OPENGENI_ATLASSIAN_CLIENT_SECRET",
1453
+ });
1454
+ }
1455
+ return { clientId, clientSecret };
1456
+ }
1457
+
1458
+ function lifecycle(state: AtlassianConnectionLifecycle["state"]): AtlassianConnectionLifecycle {
1459
+ return AtlassianConnectionLifecycle.parse({
1460
+ state,
1461
+ recoverable: state !== "app_removed",
1462
+ observedAt: new Date().toISOString(),
1463
+ });
1464
+ }
1465
+
1466
+ function atlassianSchedule(cadence: "manual" | "hourly" | "daily"): ScheduledTaskScheduleSpec {
1467
+ if (cadence === "manual") return { type: "manual" };
1468
+ if (cadence === "hourly") return { type: "interval", everySeconds: 3_600 };
1469
+ return { type: "calendar", timeZone: "UTC", hour: 0, minute: 0 };
1470
+ }
1471
+
1472
+ async function providerFetch(fetchImpl: FetchLike, url: string | URL, init: RequestInit) {
1473
+ try {
1474
+ return await fetchImpl(url, {
1475
+ ...init,
1476
+ redirect: "error",
1477
+ signal: AbortSignal.timeout(ATLASSIAN_REQUEST_TIMEOUT_MS),
1478
+ });
1479
+ } catch {
1480
+ throw new HTTPException(502, { message: "Atlassian is temporarily unavailable" });
1481
+ }
1482
+ }
1483
+
1484
+ function returnUrl(base: string, path: string, status: "connected" | "error", value: string) {
1485
+ const url = new URL(path, base);
1486
+ url.searchParams.set("atlassian", status);
1487
+ url.searchParams.set(status === "connected" ? "connectionId" : "reason", value);
1488
+ return url.toString();
1489
+ }
1490
+
1491
+ function errorReason(error: unknown): string {
1492
+ if (error instanceof AtlassianCallbackError) return error.reason;
1493
+ if (error instanceof HTTPException) return `http_${error.status}`;
1494
+ return "connection_failed";
1495
+ }
1496
+
1497
+ class AtlassianCallbackError extends Error {
1498
+ constructor(readonly reason: string) {
1499
+ super(reason);
1500
+ this.name = "AtlassianCallbackError";
1501
+ }
1502
+ }
1503
+
1504
+ function objectRecord(value: unknown): Record<string, unknown> {
1505
+ return value && typeof value === "object" && !Array.isArray(value)
1506
+ ? (value as Record<string, unknown>)
1507
+ : {};
1508
+ }
1509
+
1510
+ function optionalString(value: unknown): string | null {
1511
+ return typeof value === "string" && value.length > 0 ? value : null;
1512
+ }
1513
+
1514
+ function requiredString(value: unknown, label: string): string {
1515
+ const result = optionalString(value);
1516
+ if (!result) throw new HTTPException(400, { message: `${label} is invalid` });
1517
+ return result;
1518
+ }
1519
+
1520
+ function numberValue(value: unknown): number | undefined {
1521
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1522
+ }
1523
+
1524
+ function uniqueStrings(values: string[]): string[] {
1525
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
1526
+ }
1527
+
1528
+ function groupSourcesByCloudId(
1529
+ sources: AtlassianSelectedSource[],
1530
+ ): Array<[string, AtlassianSelectedSource[]]> {
1531
+ const grouped = new Map<string, AtlassianSelectedSource[]>();
1532
+ for (const source of sources) {
1533
+ grouped.set(source.cloudId, [...(grouped.get(source.cloudId) ?? []), source]);
1534
+ }
1535
+ return [...grouped.entries()];
1536
+ }
1537
+
1538
+ function jqlString(value: string): string {
1539
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
1540
+ }
1541
+
1542
+ function cqlString(value: string): string {
1543
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
1544
+ }
1545
+
1546
+ async function readJiraLiveComments(
1547
+ deps: ApiRouteDeps,
1548
+ input: { workspaceId: string; subjectId: string; connectionId: string; id: string },
1549
+ cloudId: string,
1550
+ ) {
1551
+ const comments: Array<{ author: string | null; createdAt: string | null; body: string }> = [];
1552
+ let startAt = 0;
1553
+ for (let page = 0; page < 20; page += 1) {
1554
+ const url = new URL(
1555
+ `https://api.atlassian.com/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3/issue/${encodeURIComponent(input.id)}/comment`,
1556
+ );
1557
+ url.searchParams.set("startAt", String(startAt));
1558
+ url.searchParams.set("maxResults", "100");
1559
+ url.searchParams.set("orderBy", "created");
1560
+ const payload = objectRecord(
1561
+ await atlassianApiRequest(deps, { ...input, url, label: "Jira live comments" }),
1562
+ );
1563
+ const rows = Array.isArray(payload.comments) ? payload.comments : [];
1564
+ for (const raw of rows) {
1565
+ const row = objectRecord(raw);
1566
+ comments.push({
1567
+ author: optionalString(objectRecord(row.author).displayName),
1568
+ createdAt: optionalString(row.created),
1569
+ body: adfText(row.body),
1570
+ });
1571
+ }
1572
+ const total = numberValue(payload.total) ?? comments.length;
1573
+ if (comments.length >= total || rows.length === 0) break;
1574
+ startAt += rows.length;
1575
+ }
1576
+ return comments;
1577
+ }
1578
+
1579
+ async function readConfluenceLiveComments(
1580
+ deps: ApiRouteDeps,
1581
+ input: { workspaceId: string; subjectId: string; connectionId: string; id: string },
1582
+ cloudId: string,
1583
+ ) {
1584
+ const comments: Array<{ createdAt: string | null; content: string }> = [];
1585
+ let cursor: string | null = null;
1586
+ for (let page = 0; page < 20; page += 1) {
1587
+ const url = new URL(
1588
+ `https://api.atlassian.com/ex/confluence/${encodeURIComponent(cloudId)}/wiki/api/v2/pages/${encodeURIComponent(input.id)}/footer-comments`,
1589
+ );
1590
+ url.searchParams.set("body-format", "storage");
1591
+ url.searchParams.set("limit", "100");
1592
+ if (cursor) url.searchParams.set("cursor", cursor);
1593
+ const payload = objectRecord(
1594
+ await atlassianApiRequest(deps, { ...input, url, label: "Confluence live comments" }),
1595
+ );
1596
+ for (const raw of Array.isArray(payload.results) ? payload.results : []) {
1597
+ const row = objectRecord(raw);
1598
+ comments.push({
1599
+ createdAt: optionalString(objectRecord(row.version).createdAt),
1600
+ content: optionalString(objectRecord(objectRecord(row.body).storage).value) ?? "",
1601
+ });
1602
+ }
1603
+ const next = optionalString(objectRecord(payload._links).next);
1604
+ if (!next) break;
1605
+ const nextUrl = confluenceNextUrl(cloudId, next);
1606
+ cursor = nextUrl.searchParams.get("cursor");
1607
+ if (!cursor) break;
1608
+ }
1609
+ return comments;
1610
+ }
1611
+
1612
+ function adfText(value: unknown): string {
1613
+ const node = objectRecord(value);
1614
+ if (node.type === "text") return typeof node.text === "string" ? node.text : "";
1615
+ const children = Array.isArray(node.content) ? node.content.map(adfText).join("") : "";
1616
+ if (node.type === "paragraph" || node.type === "heading" || node.type === "listItem") {
1617
+ return `${children.trim()}\n`;
1618
+ }
1619
+ if (node.type === "hardBreak") return "\n";
1620
+ return children;
1621
+ }