@axtary/adapters 0.0.1 → 0.2.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.
- package/LICENSE +202 -0
- package/README.md +56 -6
- package/dist/index.d.ts +310 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1564 -200
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
package/dist/index.js
CHANGED
|
@@ -1,86 +1,27 @@
|
|
|
1
|
-
import { verifyActionPass, } from "@axtary/actionpass";
|
|
2
|
-
import {
|
|
1
|
+
import { actionPassDpopTarget, InMemoryDpopReplayStore, verifyActionPass, analyzePostgresSelect, POSTGRES_NATIVE_CONNECTOR_GOVERNANCE, DRIVE_NATIVE_CONNECTOR_GOVERNANCE, GITHUB_NATIVE_CONNECTOR_GOVERNANCE, JIRA_NATIVE_CONNECTOR_GOVERNANCE, LINEAR_NATIVE_CONNECTOR_GOVERNANCE, } from "@axtary/actionpass";
|
|
2
|
+
import { SignJWT } from "jose";
|
|
3
|
+
import { Client } from "pg";
|
|
4
|
+
import { createHash, createHmac, createPrivateKey } from "node:crypto";
|
|
3
5
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
4
6
|
import { basename, extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
7
|
export const GITHUB_REST_API_BASE_URL = "https://api.github.com";
|
|
6
8
|
export const GITHUB_REST_USER_AGENT = "axtary-local-proxy";
|
|
7
9
|
export const SLACK_WEB_API_BASE_URL = "https://slack.com/api";
|
|
8
10
|
export const LINEAR_GRAPHQL_API_URL = "https://api.linear.app/graphql";
|
|
11
|
+
export const JIRA_CLOUD_API_BASE_URL = "https://api.atlassian.com";
|
|
9
12
|
export const AWS_STS_API_URL = "https://sts.amazonaws.com/";
|
|
10
13
|
export const GCP_CLOUD_RESOURCE_MANAGER_API_BASE_URL = "https://cloudresourcemanager.googleapis.com";
|
|
11
14
|
export const GCP_STORAGE_API_BASE_URL = "https://storage.googleapis.com/storage/v1";
|
|
15
|
+
export const GOOGLE_DRIVE_API_BASE_URL = "https://www.googleapis.com/drive/v3";
|
|
12
16
|
export const DEFAULT_PROVIDER_RETRIES = 2;
|
|
17
|
+
export function createNativeConnectorHandlers(descriptor, config, options = {}) {
|
|
18
|
+
const handlers = descriptor.createHandlers(config);
|
|
19
|
+
return options.verification
|
|
20
|
+
? wrapHandlersWithActionPassVerification(handlers, options.verification)
|
|
21
|
+
: handlers;
|
|
22
|
+
}
|
|
13
23
|
export const CONNECTOR_CAPABILITY_REGISTRY = [
|
|
14
|
-
|
|
15
|
-
id: "github-pr-create",
|
|
16
|
-
provider: "github",
|
|
17
|
-
connector: "github-rest",
|
|
18
|
-
label: "Create pull request",
|
|
19
|
-
tool: "github.pull_requests.create",
|
|
20
|
-
operation: "write",
|
|
21
|
-
status: "supported",
|
|
22
|
-
supportedModes: ["fake", "rest"],
|
|
23
|
-
requiresActionPass: true,
|
|
24
|
-
defaultPolicy: "step_up",
|
|
25
|
-
payloadFields: ["title", "baseBranch", "filesChanged", "testsPassed", "touchesProduction"],
|
|
26
|
-
approvalTriggers: [
|
|
27
|
-
"protected path",
|
|
28
|
-
"production impact",
|
|
29
|
-
"base branch mismatch",
|
|
30
|
-
"missing tests",
|
|
31
|
-
"large file set",
|
|
32
|
-
],
|
|
33
|
-
credentialBoundary: "GitHub token stays in the proxy/adapter; the agent receives an ActionPass-bound result.",
|
|
34
|
-
smokeCheck: "GET /user",
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
id: "github-content-read",
|
|
38
|
-
provider: "github",
|
|
39
|
-
connector: "github-rest",
|
|
40
|
-
label: "Read repository content",
|
|
41
|
-
tool: "github.contents.read",
|
|
42
|
-
operation: "read",
|
|
43
|
-
status: "supported",
|
|
44
|
-
supportedModes: ["fake", "rest"],
|
|
45
|
-
requiresActionPass: true,
|
|
46
|
-
defaultPolicy: "deny_until_scoped",
|
|
47
|
-
payloadFields: ["path", "ref"],
|
|
48
|
-
approvalTriggers: ["denied secret or production path"],
|
|
49
|
-
credentialBoundary: "Repository content is fetched by the adapter after policy checks deny protected paths.",
|
|
50
|
-
smokeCheck: "GET /user",
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
id: "github-branch-create",
|
|
54
|
-
provider: "github",
|
|
55
|
-
connector: "github-rest",
|
|
56
|
-
label: "Create branch",
|
|
57
|
-
tool: "github.branches.create",
|
|
58
|
-
operation: "write",
|
|
59
|
-
status: "supported",
|
|
60
|
-
supportedModes: ["fake", "rest"],
|
|
61
|
-
requiresActionPass: true,
|
|
62
|
-
defaultPolicy: "allow",
|
|
63
|
-
payloadFields: ["branch", "baseBranch", "sha", "allowExisting"],
|
|
64
|
-
approvalTriggers: ["base branch mismatch"],
|
|
65
|
-
credentialBoundary: "Branch creation runs through the proxy and is bound to the same ActionPass verification path.",
|
|
66
|
-
smokeCheck: "GET /user",
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
id: "github-content-write",
|
|
70
|
-
provider: "github",
|
|
71
|
-
connector: "github-rest",
|
|
72
|
-
label: "Write repository content",
|
|
73
|
-
tool: "github.contents.write",
|
|
74
|
-
operation: "write",
|
|
75
|
-
status: "supported",
|
|
76
|
-
supportedModes: ["fake", "rest"],
|
|
77
|
-
requiresActionPass: true,
|
|
78
|
-
defaultPolicy: "step_up",
|
|
79
|
-
payloadFields: ["path", "branch", "message", "sha", "content"],
|
|
80
|
-
approvalTriggers: ["protected path"],
|
|
81
|
-
credentialBoundary: "File content is never written until policy and ActionPass verification both pass.",
|
|
82
|
-
smokeCheck: "GET /user",
|
|
83
|
-
},
|
|
24
|
+
...GITHUB_NATIVE_CONNECTOR_GOVERNANCE.tools.map((tool) => tool.capability),
|
|
84
25
|
{
|
|
85
26
|
id: "slack-message-create",
|
|
86
27
|
provider: "slack",
|
|
@@ -97,102 +38,10 @@ export const CONNECTOR_CAPABILITY_REGISTRY = [
|
|
|
97
38
|
credentialBoundary: "Slack bot token stays adapter-side; exact outbound text is the approval payload.",
|
|
98
39
|
smokeCheck: "auth.test",
|
|
99
40
|
},
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
label: "Read issue",
|
|
105
|
-
tool: "linear.issues.read",
|
|
106
|
-
operation: "read",
|
|
107
|
-
status: "supported",
|
|
108
|
-
supportedModes: ["fake", "graphql"],
|
|
109
|
-
requiresActionPass: true,
|
|
110
|
-
defaultPolicy: "allow",
|
|
111
|
-
payloadFields: ["issueKey", "projectKey"],
|
|
112
|
-
approvalTriggers: [],
|
|
113
|
-
credentialBoundary: "Linear API key stays adapter-side; policy pins issue access by project key.",
|
|
114
|
-
smokeCheck: "viewer",
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
id: "linear-comment-create",
|
|
118
|
-
provider: "linear",
|
|
119
|
-
connector: "linear-graphql",
|
|
120
|
-
label: "Create issue comment",
|
|
121
|
-
tool: "linear.comments.create",
|
|
122
|
-
operation: "write",
|
|
123
|
-
status: "supported",
|
|
124
|
-
supportedModes: ["fake", "graphql"],
|
|
125
|
-
requiresActionPass: true,
|
|
126
|
-
defaultPolicy: "allow",
|
|
127
|
-
payloadFields: ["issueKey", "projectKey", "body"],
|
|
128
|
-
approvalTriggers: [],
|
|
129
|
-
credentialBoundary: "Linear comments execute only through proxy policy and ActionPass verification.",
|
|
130
|
-
smokeCheck: "viewer",
|
|
131
|
-
},
|
|
132
|
-
{
|
|
133
|
-
id: "linear-issue-update",
|
|
134
|
-
provider: "linear",
|
|
135
|
-
connector: "linear-graphql",
|
|
136
|
-
label: "Update issue",
|
|
137
|
-
tool: "linear.issues.update",
|
|
138
|
-
operation: "write",
|
|
139
|
-
status: "supported",
|
|
140
|
-
supportedModes: ["fake", "graphql"],
|
|
141
|
-
requiresActionPass: true,
|
|
142
|
-
defaultPolicy: "step_up",
|
|
143
|
-
payloadFields: ["issueKey", "projectKey", "status", "title", "description", "priority"],
|
|
144
|
-
approvalTriggers: ["protected status"],
|
|
145
|
-
credentialBoundary: "Issue mutations stay behind adapter credentials and project/status policy checks.",
|
|
146
|
-
smokeCheck: "viewer",
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
id: "jira-issue-read",
|
|
150
|
-
provider: "jira",
|
|
151
|
-
connector: "jira-fake",
|
|
152
|
-
label: "Read issue",
|
|
153
|
-
tool: "jira.issues.read",
|
|
154
|
-
operation: "read",
|
|
155
|
-
status: "supported",
|
|
156
|
-
supportedModes: ["fake"],
|
|
157
|
-
requiresActionPass: true,
|
|
158
|
-
defaultPolicy: "allow",
|
|
159
|
-
payloadFields: ["issueKey", "projectKey"],
|
|
160
|
-
approvalTriggers: [],
|
|
161
|
-
credentialBoundary: "Jira is currently fake/local in this repo; future credentials must remain adapter-side.",
|
|
162
|
-
smokeCheck: null,
|
|
163
|
-
},
|
|
164
|
-
{
|
|
165
|
-
id: "jira-comment-create",
|
|
166
|
-
provider: "jira",
|
|
167
|
-
connector: "jira-fake",
|
|
168
|
-
label: "Create issue comment",
|
|
169
|
-
tool: "jira.comments.create",
|
|
170
|
-
operation: "write",
|
|
171
|
-
status: "supported",
|
|
172
|
-
supportedModes: ["fake"],
|
|
173
|
-
requiresActionPass: true,
|
|
174
|
-
defaultPolicy: "allow",
|
|
175
|
-
payloadFields: ["issueKey", "projectKey", "body"],
|
|
176
|
-
approvalTriggers: [],
|
|
177
|
-
credentialBoundary: "Jira is currently fake/local in this repo; future credentials must remain adapter-side.",
|
|
178
|
-
smokeCheck: null,
|
|
179
|
-
},
|
|
180
|
-
{
|
|
181
|
-
id: "jira-issue-update",
|
|
182
|
-
provider: "jira",
|
|
183
|
-
connector: "jira-fake",
|
|
184
|
-
label: "Update issue",
|
|
185
|
-
tool: "jira.issues.update",
|
|
186
|
-
operation: "write",
|
|
187
|
-
status: "supported",
|
|
188
|
-
supportedModes: ["fake"],
|
|
189
|
-
requiresActionPass: true,
|
|
190
|
-
defaultPolicy: "step_up",
|
|
191
|
-
payloadFields: ["issueKey", "projectKey", "status"],
|
|
192
|
-
approvalTriggers: ["protected status"],
|
|
193
|
-
credentialBoundary: "Jira is currently fake/local in this repo; future credentials must remain adapter-side.",
|
|
194
|
-
smokeCheck: null,
|
|
195
|
-
},
|
|
41
|
+
...LINEAR_NATIVE_CONNECTOR_GOVERNANCE.tools.map((tool) => tool.capability),
|
|
42
|
+
...JIRA_NATIVE_CONNECTOR_GOVERNANCE.tools.map((tool) => tool.capability),
|
|
43
|
+
...POSTGRES_NATIVE_CONNECTOR_GOVERNANCE.tools.map((tool) => tool.capability),
|
|
44
|
+
...DRIVE_NATIVE_CONNECTOR_GOVERNANCE.tools.map((tool) => tool.capability),
|
|
196
45
|
{
|
|
197
46
|
id: "aws-identity-get",
|
|
198
47
|
provider: "aws",
|
|
@@ -348,6 +197,298 @@ export function getConnectorCapability(tool) {
|
|
|
348
197
|
export function getConnectorCapabilitiesByProvider(provider) {
|
|
349
198
|
return CONNECTOR_CAPABILITY_REGISTRY.filter((capability) => capability.provider === provider);
|
|
350
199
|
}
|
|
200
|
+
export async function buildConnectorReadinessReport(input = {}) {
|
|
201
|
+
const env = input.env ?? process.env;
|
|
202
|
+
const providers = [
|
|
203
|
+
providerReadiness({
|
|
204
|
+
provider: "github",
|
|
205
|
+
mode: input.adapters?.github?.mode ?? "fake",
|
|
206
|
+
requiredEnv: input.adapters?.github?.mode === "rest"
|
|
207
|
+
? [input.adapters.github.tokenEnv ?? "GITHUB_TOKEN"]
|
|
208
|
+
: input.adapters?.github?.mode === "app"
|
|
209
|
+
? githubAppRequiredEnv({
|
|
210
|
+
appId: input.adapters.github.appId,
|
|
211
|
+
appIdEnv: input.adapters.github.appIdEnv,
|
|
212
|
+
installationId: input.adapters.github.installationId,
|
|
213
|
+
installationIdEnv: input.adapters.github.installationIdEnv,
|
|
214
|
+
privateKeyEnv: input.adapters.github.privateKeyEnv ?? "AXTARY_GITHUB_APP_PRIVATE_KEY",
|
|
215
|
+
})
|
|
216
|
+
: [],
|
|
217
|
+
env,
|
|
218
|
+
smokeCheck: input.adapters?.github?.mode === "app"
|
|
219
|
+
? "POST /app/installations/{id}/access_tokens"
|
|
220
|
+
: "GET /user + X-Accepted-GitHub-Permissions",
|
|
221
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
222
|
+
localSummary: "Fake GitHub adapter is active for deterministic local execution.",
|
|
223
|
+
readySummary: input.adapters?.github?.mode === "app"
|
|
224
|
+
? "GitHub App mode mints short-lived installation tokens from an env-backed private key."
|
|
225
|
+
: "GitHub REST mode has an env-backed token configured.",
|
|
226
|
+
}),
|
|
227
|
+
providerReadiness({
|
|
228
|
+
provider: "slack",
|
|
229
|
+
mode: input.adapters?.slack?.mode ?? "fake",
|
|
230
|
+
requiredEnv: input.adapters?.slack?.mode === "web"
|
|
231
|
+
? [input.adapters.slack.tokenEnv ?? "SLACK_BOT_TOKEN"]
|
|
232
|
+
: [],
|
|
233
|
+
env,
|
|
234
|
+
smokeCheck: "auth.test",
|
|
235
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
236
|
+
localSummary: "Fake Slack adapter is active for deterministic local execution.",
|
|
237
|
+
readySummary: "Slack Web API mode has an env-backed bot token configured.",
|
|
238
|
+
}),
|
|
239
|
+
providerReadiness({
|
|
240
|
+
provider: "linear",
|
|
241
|
+
mode: input.adapters?.linear?.mode ?? "fake",
|
|
242
|
+
requiredEnv: input.adapters?.linear?.mode === "graphql"
|
|
243
|
+
? [input.adapters.linear.tokenEnv ?? "LINEAR_API_KEY"]
|
|
244
|
+
: [],
|
|
245
|
+
env,
|
|
246
|
+
smokeCheck: "viewer",
|
|
247
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
248
|
+
localSummary: "Fake Linear adapter is active for deterministic local execution.",
|
|
249
|
+
readySummary: "Linear GraphQL mode has an env-backed API key configured.",
|
|
250
|
+
}),
|
|
251
|
+
providerReadiness({
|
|
252
|
+
provider: "jira",
|
|
253
|
+
mode: input.adapters?.jira?.mode ?? "fake",
|
|
254
|
+
requiredEnv: input.adapters?.jira?.mode === "rest"
|
|
255
|
+
? input.adapters.jira.auth === "oauth"
|
|
256
|
+
? [
|
|
257
|
+
input.adapters.jira.tokenEnv ?? "JIRA_OAUTH_TOKEN",
|
|
258
|
+
...(input.adapters.jira.cloudId
|
|
259
|
+
? []
|
|
260
|
+
: [
|
|
261
|
+
input.adapters.jira.cloudIdEnv ??
|
|
262
|
+
"AXTARY_JIRA_CLOUD_ID",
|
|
263
|
+
]),
|
|
264
|
+
]
|
|
265
|
+
: [
|
|
266
|
+
input.adapters.jira.emailEnv ?? "JIRA_EMAIL",
|
|
267
|
+
input.adapters.jira.tokenEnv ?? "JIRA_API_TOKEN",
|
|
268
|
+
]
|
|
269
|
+
: [],
|
|
270
|
+
env,
|
|
271
|
+
configuredResources: [
|
|
272
|
+
...(input.adapters?.jira?.mode === "rest" &&
|
|
273
|
+
input.adapters?.jira?.cloudId
|
|
274
|
+
? [`cloud:${input.adapters.jira.cloudId}`]
|
|
275
|
+
: []),
|
|
276
|
+
...(input.adapters?.jira?.mode === "rest" &&
|
|
277
|
+
input.adapters?.jira?.siteUrl
|
|
278
|
+
? [`site:${input.adapters.jira.siteUrl}`]
|
|
279
|
+
: []),
|
|
280
|
+
],
|
|
281
|
+
smokeCheck: "GET /myself",
|
|
282
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
283
|
+
localSummary: "Fake Jira adapter is active for deterministic local execution.",
|
|
284
|
+
readySummary: input.adapters?.jira?.auth === "oauth"
|
|
285
|
+
? "Jira REST mode has an OAuth credential and cloud site configured."
|
|
286
|
+
: "Jira REST mode has an email and API token configured.",
|
|
287
|
+
}),
|
|
288
|
+
providerReadiness({
|
|
289
|
+
provider: "postgres",
|
|
290
|
+
mode: input.adapters?.postgres?.mode ?? "off",
|
|
291
|
+
requiredEnv: input.adapters?.postgres?.mode === "postgres"
|
|
292
|
+
? [input.adapters.postgres.dsnEnv ?? "AXTARY_POSTGRES_DSN"]
|
|
293
|
+
: [],
|
|
294
|
+
env,
|
|
295
|
+
configuredResources: input.adapters?.postgres?.allowedTables?.map((table) => `table:${table}`) ?? [],
|
|
296
|
+
smokeCheck: "current role/database, read-only state, and RLS metadata",
|
|
297
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
298
|
+
localSummary: "Postgres adapter is disabled until a DSN and explicit table scope are configured.",
|
|
299
|
+
readySummary: "Postgres native mode has an env-backed DSN; smoke still verifies least privilege, read-only transactions, and RLS.",
|
|
300
|
+
}),
|
|
301
|
+
providerReadiness({
|
|
302
|
+
provider: "drive",
|
|
303
|
+
mode: input.adapters?.drive?.mode ?? "off",
|
|
304
|
+
requiredEnv: input.adapters?.drive?.mode === "rest"
|
|
305
|
+
? [input.adapters.drive.tokenEnv ?? "AXTARY_DRIVE_ACCESS_TOKEN"]
|
|
306
|
+
: [],
|
|
307
|
+
env,
|
|
308
|
+
configuredResources: input.adapters?.drive?.selectedFileId
|
|
309
|
+
? [`file:${input.adapters.drive.selectedFileId}`]
|
|
310
|
+
: [],
|
|
311
|
+
smokeCheck: "selected-file metadata and provider isAppAuthorized state",
|
|
312
|
+
smokeCommand: "axtary doctor connectors --config axtary.yml",
|
|
313
|
+
localSummary: "Google Drive is disabled until REST mode and a local OAuth connection are configured.",
|
|
314
|
+
readySummary: "Google Drive REST mode has an env-backed token; local broker credentials can also satisfy runtime execution.",
|
|
315
|
+
}),
|
|
316
|
+
providerReadiness({
|
|
317
|
+
provider: "aws",
|
|
318
|
+
mode: input.adapters?.aws?.mode ?? "off",
|
|
319
|
+
requiredEnv: input.adapters?.aws?.mode === "rest"
|
|
320
|
+
? [
|
|
321
|
+
input.adapters.aws.accessKeyIdEnv ?? "AWS_ACCESS_KEY_ID",
|
|
322
|
+
input.adapters.aws.secretAccessKeyEnv ?? "AWS_SECRET_ACCESS_KEY",
|
|
323
|
+
]
|
|
324
|
+
: [],
|
|
325
|
+
optionalEnv: input.adapters?.aws?.mode === "rest"
|
|
326
|
+
? [input.adapters.aws.sessionTokenEnv ?? "AWS_SESSION_TOKEN"]
|
|
327
|
+
: [],
|
|
328
|
+
env,
|
|
329
|
+
smokeCheck: "STS GetCallerIdentity",
|
|
330
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
331
|
+
localSummary: "AWS adapter is disabled in the active local config.",
|
|
332
|
+
readySummary: "AWS REST mode has access key and secret env vars configured.",
|
|
333
|
+
}),
|
|
334
|
+
providerReadiness({
|
|
335
|
+
provider: "gcp",
|
|
336
|
+
mode: input.adapters?.gcp?.mode ?? "off",
|
|
337
|
+
requiredEnv: input.adapters?.gcp?.mode === "rest"
|
|
338
|
+
? [input.adapters.gcp.accessTokenEnv ?? "GCP_ACCESS_TOKEN"]
|
|
339
|
+
: [],
|
|
340
|
+
env,
|
|
341
|
+
smokeCheck: "Cloud Resource Manager project get",
|
|
342
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
343
|
+
configuredResources: input.adapters?.gcp?.projectId
|
|
344
|
+
? [`project:${input.adapters.gcp.projectId}`]
|
|
345
|
+
: [],
|
|
346
|
+
localSummary: "GCP adapter is disabled in the active local config.",
|
|
347
|
+
readySummary: "GCP REST mode has an env-backed access token configured.",
|
|
348
|
+
}),
|
|
349
|
+
await docsReadiness(input),
|
|
350
|
+
providerReadiness({
|
|
351
|
+
provider: "mcp",
|
|
352
|
+
mode: "local-wrapper",
|
|
353
|
+
requiredEnv: [],
|
|
354
|
+
env,
|
|
355
|
+
smokeCheck: null,
|
|
356
|
+
smokeCommand: null,
|
|
357
|
+
localSummary: "MCP wrapper is local and verifies server identity, schema version, and tool definition hashes.",
|
|
358
|
+
readySummary: "MCP wrapper is local and verifies server identity, schema version, and tool definition hashes.",
|
|
359
|
+
}),
|
|
360
|
+
];
|
|
361
|
+
const totals = providers.reduce((acc, provider) => {
|
|
362
|
+
if (provider.status === "ready")
|
|
363
|
+
acc.ready += 1;
|
|
364
|
+
if (provider.status === "local_only")
|
|
365
|
+
acc.localOnly += 1;
|
|
366
|
+
if (provider.status === "missing_env")
|
|
367
|
+
acc.missingEnv += 1;
|
|
368
|
+
if (provider.status === "off")
|
|
369
|
+
acc.off += 1;
|
|
370
|
+
if (provider.status === "planned")
|
|
371
|
+
acc.planned += 1;
|
|
372
|
+
acc.supportedCapabilities += provider.supportedCapabilities;
|
|
373
|
+
acc.plannedCapabilities += provider.plannedCapabilities;
|
|
374
|
+
return acc;
|
|
375
|
+
}, {
|
|
376
|
+
ready: 0,
|
|
377
|
+
localOnly: 0,
|
|
378
|
+
missingEnv: 0,
|
|
379
|
+
off: 0,
|
|
380
|
+
planned: 0,
|
|
381
|
+
supportedCapabilities: 0,
|
|
382
|
+
plannedCapabilities: 0,
|
|
383
|
+
});
|
|
384
|
+
return {
|
|
385
|
+
schemaVersion: "axtary.connector_readiness.v0",
|
|
386
|
+
source: "package_registry",
|
|
387
|
+
generatedAt: input.generatedAt ?? new Date().toISOString(),
|
|
388
|
+
providers,
|
|
389
|
+
totals,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function providerReadiness(input) {
|
|
393
|
+
const capabilities = getConnectorCapabilitiesByProvider(input.provider);
|
|
394
|
+
const supportedCapabilities = capabilities.filter((capability) => capability.status === "supported").length;
|
|
395
|
+
const plannedCapabilities = capabilities.length - supportedCapabilities;
|
|
396
|
+
const presentEnv = [...input.requiredEnv, ...(input.optionalEnv ?? [])].filter((name) => Boolean(input.env[name]));
|
|
397
|
+
const missingEnv = input.requiredEnv.filter((name) => !input.env[name]);
|
|
398
|
+
const status = providerStatus(input.mode, missingEnv);
|
|
399
|
+
return {
|
|
400
|
+
provider: input.provider,
|
|
401
|
+
mode: input.mode,
|
|
402
|
+
status,
|
|
403
|
+
supportedCapabilities,
|
|
404
|
+
plannedCapabilities,
|
|
405
|
+
requiredEnv: input.requiredEnv,
|
|
406
|
+
optionalEnv: input.optionalEnv ?? [],
|
|
407
|
+
presentEnv,
|
|
408
|
+
missingEnv,
|
|
409
|
+
configuredResources: input.configuredResources ?? [],
|
|
410
|
+
smokeCheck: input.smokeCheck,
|
|
411
|
+
smokeCommand: input.smokeCommand,
|
|
412
|
+
summary: status === "missing_env"
|
|
413
|
+
? `Missing required env: ${missingEnv.join(", ")}.`
|
|
414
|
+
: status === "ready"
|
|
415
|
+
? input.readySummary
|
|
416
|
+
: input.localSummary,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
async function docsReadiness(input) {
|
|
420
|
+
const docs = input.adapters?.docs;
|
|
421
|
+
const mode = docs?.mode ?? "local";
|
|
422
|
+
const roots = docs?.roots ?? [];
|
|
423
|
+
const baseDir = input.docsBaseDir ?? /* turbopackIgnore: true */ process.cwd();
|
|
424
|
+
if (mode === "off") {
|
|
425
|
+
return providerReadiness({
|
|
426
|
+
provider: "docs",
|
|
427
|
+
mode: "off",
|
|
428
|
+
requiredEnv: [],
|
|
429
|
+
env: input.env ?? process.env,
|
|
430
|
+
smokeCheck: "configured roots exist",
|
|
431
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
432
|
+
localSummary: "Local docs connector is disabled in the active config.",
|
|
433
|
+
readySummary: "Local docs connector is disabled in the active config.",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
const missingRoots = [];
|
|
437
|
+
for (const root of roots) {
|
|
438
|
+
const rootPath = isAbsolute(root) ? root : resolve(baseDir, root);
|
|
439
|
+
try {
|
|
440
|
+
const stats = await stat(rootPath);
|
|
441
|
+
if (!stats.isDirectory())
|
|
442
|
+
missingRoots.push(root);
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
missingRoots.push(root);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (missingRoots.length > 0) {
|
|
449
|
+
const capabilities = getConnectorCapabilitiesByProvider("docs");
|
|
450
|
+
return {
|
|
451
|
+
provider: "docs",
|
|
452
|
+
mode: "local",
|
|
453
|
+
status: "missing_env",
|
|
454
|
+
supportedCapabilities: capabilities.filter((capability) => capability.status === "supported").length,
|
|
455
|
+
plannedCapabilities: capabilities.filter((capability) => capability.status === "planned").length,
|
|
456
|
+
requiredEnv: [],
|
|
457
|
+
optionalEnv: [],
|
|
458
|
+
presentEnv: [],
|
|
459
|
+
missingEnv: missingRoots.map((root) => `root:${root}`),
|
|
460
|
+
configuredResources: roots.map((root) => `root:${root}`),
|
|
461
|
+
smokeCheck: "configured roots exist",
|
|
462
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
463
|
+
summary: `Missing configured docs roots: ${missingRoots.join(", ")}.`,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
return providerReadiness({
|
|
467
|
+
provider: "docs",
|
|
468
|
+
mode: "local",
|
|
469
|
+
requiredEnv: [],
|
|
470
|
+
env: input.env ?? process.env,
|
|
471
|
+
configuredResources: [
|
|
472
|
+
`workspace:${docs?.workspace ?? "local"}`,
|
|
473
|
+
...roots.map((root) => `root:${root}`),
|
|
474
|
+
],
|
|
475
|
+
smokeCheck: "configured roots exist",
|
|
476
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
477
|
+
localSummary: "Local docs connector roots exist and are bounded by docs policy.",
|
|
478
|
+
readySummary: "Local docs connector roots exist and are bounded by docs policy.",
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
function providerStatus(mode, missingEnv) {
|
|
482
|
+
if (mode === "planned")
|
|
483
|
+
return "planned";
|
|
484
|
+
if (mode === "off")
|
|
485
|
+
return "off";
|
|
486
|
+
if (missingEnv.length > 0)
|
|
487
|
+
return "missing_env";
|
|
488
|
+
if (mode === "fake" || mode === "local" || mode === "local-wrapper")
|
|
489
|
+
return "local_only";
|
|
490
|
+
return "ready";
|
|
491
|
+
}
|
|
351
492
|
export function createFakeAdapterState() {
|
|
352
493
|
return {
|
|
353
494
|
github: {
|
|
@@ -405,16 +546,19 @@ export function createFakeHandlers(state = createFakeAdapterState(), options = {
|
|
|
405
546
|
? wrapHandlersWithActionPassVerification(handlers, options.verification)
|
|
406
547
|
: handlers;
|
|
407
548
|
}
|
|
408
|
-
|
|
409
|
-
|
|
549
|
+
function createGitHubRestHandlersRaw(config) {
|
|
550
|
+
return {
|
|
410
551
|
"github.contents.read": createGitHubRestContentReadHandler(config),
|
|
411
552
|
"github.pull_requests.create": createGitHubRestPullRequestHandler(config),
|
|
412
553
|
"github.branches.create": createGitHubRestBranchCreateHandler(config),
|
|
413
554
|
"github.contents.write": createGitHubRestContentWriteHandler(config),
|
|
555
|
+
"github.pull_request_review_comments.create": createGitHubRestReviewCommentHandler(config),
|
|
556
|
+
"github.check_runs.create": createGitHubRestCheckRunHandler(config),
|
|
557
|
+
"github.issue_comments.create": createGitHubRestIssueCommentHandler(config),
|
|
414
558
|
};
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
559
|
+
}
|
|
560
|
+
export function createGitHubRestHandlers(config, options = {}) {
|
|
561
|
+
return createNativeConnectorHandlers(GITHUB_NATIVE_CONNECTOR, config, options);
|
|
418
562
|
}
|
|
419
563
|
export function createSlackWebHandlers(config, options = {}) {
|
|
420
564
|
const handlers = {
|
|
@@ -424,15 +568,282 @@ export function createSlackWebHandlers(config, options = {}) {
|
|
|
424
568
|
? wrapHandlersWithActionPassVerification(handlers, options.verification)
|
|
425
569
|
: handlers;
|
|
426
570
|
}
|
|
427
|
-
|
|
428
|
-
|
|
571
|
+
function createLinearGraphqlHandlersRaw(config) {
|
|
572
|
+
return {
|
|
429
573
|
"linear.issues.read": createLinearIssueReadHandler(config),
|
|
430
574
|
"linear.comments.create": createLinearCommentCreateHandler(config),
|
|
431
575
|
"linear.issues.update": createLinearIssueUpdateHandler(config),
|
|
432
576
|
};
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
577
|
+
}
|
|
578
|
+
export const LINEAR_NATIVE_CONNECTOR = {
|
|
579
|
+
...LINEAR_NATIVE_CONNECTOR_GOVERNANCE,
|
|
580
|
+
createHandlers: createLinearGraphqlHandlersRaw,
|
|
581
|
+
smoke: smokeLinearGraphqlCredentials,
|
|
582
|
+
doctor: async (config, context) => {
|
|
583
|
+
const credential = await context.resolveCredential?.("linear");
|
|
584
|
+
const token = credential?.token ?? context.env[config.tokenEnv];
|
|
585
|
+
return {
|
|
586
|
+
provider: "linear",
|
|
587
|
+
mode: config.mode,
|
|
588
|
+
activeMode: "graphql",
|
|
589
|
+
capability: config.mode === "graphql" ? "real-write" : "fake",
|
|
590
|
+
requiredEnv: config.mode === "graphql" && !token ? [config.tokenEnv] : [],
|
|
591
|
+
requiredScopes: config.mode === "graphql"
|
|
592
|
+
? [...LINEAR_NATIVE_CONNECTOR_GOVERNANCE.requiredScopes]
|
|
593
|
+
: [],
|
|
594
|
+
smokeCheck: config.mode === "graphql"
|
|
595
|
+
? LINEAR_NATIVE_CONNECTOR_GOVERNANCE.smokeCheck
|
|
596
|
+
: null,
|
|
597
|
+
runSmoke: config.mode === "graphql"
|
|
598
|
+
? () => smokeLinearGraphqlCredentials({
|
|
599
|
+
token: token ?? "",
|
|
600
|
+
apiUrl: config.apiUrl,
|
|
601
|
+
fetch: context.fetch,
|
|
602
|
+
maxRetries: 0,
|
|
603
|
+
})
|
|
604
|
+
: undefined,
|
|
605
|
+
};
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
export const GITHUB_NATIVE_CONNECTOR = {
|
|
609
|
+
...GITHUB_NATIVE_CONNECTOR_GOVERNANCE,
|
|
610
|
+
createHandlers: createGitHubRestHandlersRaw,
|
|
611
|
+
smoke: smokeGitHubRestCredentials,
|
|
612
|
+
doctor: async (config, context) => {
|
|
613
|
+
const credential = await context.resolveCredential?.("github");
|
|
614
|
+
const restToken = credential?.token ?? context.env[config.tokenEnv];
|
|
615
|
+
const appRequiredEnv = config.mode === "app"
|
|
616
|
+
? githubAppRequiredEnv({
|
|
617
|
+
appId: config.appId,
|
|
618
|
+
appIdEnv: config.appIdEnv,
|
|
619
|
+
installationId: config.installationId,
|
|
620
|
+
installationIdEnv: config.installationIdEnv,
|
|
621
|
+
privateKeyEnv: config.privateKeyEnv,
|
|
622
|
+
})
|
|
623
|
+
: [];
|
|
624
|
+
const missingEnv = config.mode === "rest"
|
|
625
|
+
? restToken
|
|
626
|
+
? []
|
|
627
|
+
: [config.tokenEnv]
|
|
628
|
+
: config.mode === "app"
|
|
629
|
+
? appRequiredEnv.filter((name) => !context.env[name])
|
|
630
|
+
: [];
|
|
631
|
+
return {
|
|
632
|
+
provider: "github",
|
|
633
|
+
mode: config.mode,
|
|
634
|
+
activeMode: config.mode === "app" ? "app" : "rest",
|
|
635
|
+
capability: config.mode === "app" || config.mode === "rest"
|
|
636
|
+
? "real-write"
|
|
637
|
+
: "fake",
|
|
638
|
+
requiredEnv: missingEnv,
|
|
639
|
+
requiredScopes: config.mode === "app" || config.mode === "rest"
|
|
640
|
+
? [...GITHUB_NATIVE_CONNECTOR_GOVERNANCE.requiredScopes]
|
|
641
|
+
: [],
|
|
642
|
+
smokeCheck: config.mode === "app" || config.mode === "rest"
|
|
643
|
+
? GITHUB_NATIVE_CONNECTOR_GOVERNANCE.smokeCheck
|
|
644
|
+
: null,
|
|
645
|
+
runSmoke: config.mode === "rest"
|
|
646
|
+
? () => smokeGitHubRestCredentials({
|
|
647
|
+
token: restToken ?? "",
|
|
648
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
649
|
+
userAgent: config.userAgent,
|
|
650
|
+
fetch: context.fetch,
|
|
651
|
+
maxRetries: 0,
|
|
652
|
+
})
|
|
653
|
+
: config.mode === "app" && missingEnv.length === 0
|
|
654
|
+
? () => {
|
|
655
|
+
const resolved = resolveGithubAppCredentials({
|
|
656
|
+
appId: config.appId,
|
|
657
|
+
appIdEnv: config.appIdEnv,
|
|
658
|
+
installationId: config.installationId,
|
|
659
|
+
installationIdEnv: config.installationIdEnv,
|
|
660
|
+
privateKeyEnv: config.privateKeyEnv,
|
|
661
|
+
}, context.env);
|
|
662
|
+
return smokeGithubAppCredentials({
|
|
663
|
+
...resolved,
|
|
664
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
665
|
+
userAgent: config.userAgent,
|
|
666
|
+
fetch: context.fetch,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
: undefined,
|
|
670
|
+
};
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
export function createLinearGraphqlHandlers(config, options = {}) {
|
|
674
|
+
return createNativeConnectorHandlers(LINEAR_NATIVE_CONNECTOR, config, options);
|
|
675
|
+
}
|
|
676
|
+
function createJiraRestHandlersRaw(config) {
|
|
677
|
+
return {
|
|
678
|
+
"jira.issues.read": createJiraIssueReadHandler(config),
|
|
679
|
+
"jira.comments.create": createJiraCommentCreateHandler(config),
|
|
680
|
+
"jira.issues.update": createJiraIssueUpdateHandler(config),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
export const JIRA_NATIVE_CONNECTOR = {
|
|
684
|
+
...JIRA_NATIVE_CONNECTOR_GOVERNANCE,
|
|
685
|
+
createHandlers: createJiraRestHandlersRaw,
|
|
686
|
+
smoke: smokeJiraRestCredentials,
|
|
687
|
+
doctor: async (config, context) => {
|
|
688
|
+
const credential = await context.resolveCredential?.("jira");
|
|
689
|
+
const cloudId = credential?.cloudId ??
|
|
690
|
+
config.cloudId ??
|
|
691
|
+
context.env[config.cloudIdEnv];
|
|
692
|
+
const siteUrl = credential?.siteUrl ?? config.siteUrl;
|
|
693
|
+
const token = credential?.token ?? context.env[config.tokenEnv];
|
|
694
|
+
const email = credential?.email ?? context.env[config.emailEnv];
|
|
695
|
+
const missingEnv = config.mode !== "rest"
|
|
696
|
+
? []
|
|
697
|
+
: config.auth === "oauth"
|
|
698
|
+
? [
|
|
699
|
+
...(!token ? [config.tokenEnv] : []),
|
|
700
|
+
...(!cloudId ? [config.cloudIdEnv] : []),
|
|
701
|
+
]
|
|
702
|
+
: [
|
|
703
|
+
...(!email ? [config.emailEnv] : []),
|
|
704
|
+
...(!token ? [config.tokenEnv] : []),
|
|
705
|
+
];
|
|
706
|
+
const auth = config.auth === "oauth"
|
|
707
|
+
? { type: "bearer", token: token ?? "" }
|
|
708
|
+
: { type: "basic", email: email ?? "", apiToken: token ?? "" };
|
|
709
|
+
return {
|
|
710
|
+
provider: "jira",
|
|
711
|
+
mode: config.mode,
|
|
712
|
+
activeMode: "rest",
|
|
713
|
+
capability: config.mode === "rest" ? "real-write" : "fake",
|
|
714
|
+
requiredEnv: missingEnv,
|
|
715
|
+
requiredScopes: config.mode === "rest"
|
|
716
|
+
? [...JIRA_NATIVE_CONNECTOR_GOVERNANCE.requiredScopes]
|
|
717
|
+
: [],
|
|
718
|
+
smokeCheck: config.mode === "rest"
|
|
719
|
+
? JIRA_NATIVE_CONNECTOR_GOVERNANCE.smokeCheck
|
|
720
|
+
: null,
|
|
721
|
+
runSmoke: config.mode === "rest"
|
|
722
|
+
? () => smokeJiraRestCredentials({
|
|
723
|
+
auth,
|
|
724
|
+
apiBaseUrl: auth.type === "bearer"
|
|
725
|
+
? `https://api.atlassian.com/ex/jira/${encodeURIComponent(cloudId ?? "")}/rest/api/3`
|
|
726
|
+
: `${siteUrl.replace(/\/$/, "")}/rest/api/3`,
|
|
727
|
+
siteUrl,
|
|
728
|
+
fetch: context.fetch,
|
|
729
|
+
maxRetries: 0,
|
|
730
|
+
})
|
|
731
|
+
: undefined,
|
|
732
|
+
};
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
export function createJiraRestHandlers(config, options = {}) {
|
|
736
|
+
return createNativeConnectorHandlers(JIRA_NATIVE_CONNECTOR, config, options);
|
|
737
|
+
}
|
|
738
|
+
function createPostgresNativeHandlersRaw(config) {
|
|
739
|
+
return {
|
|
740
|
+
"postgres.query.select": createPostgresSelectHandler(config),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
function createDriveRestHandlersRaw(config) {
|
|
744
|
+
return {
|
|
745
|
+
"drive.files.read": createDriveFileReadHandler(config),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
export const POSTGRES_NATIVE_CONNECTOR = {
|
|
749
|
+
...POSTGRES_NATIVE_CONNECTOR_GOVERNANCE,
|
|
750
|
+
createHandlers: createPostgresNativeHandlersRaw,
|
|
751
|
+
smoke: smokePostgresCredentials,
|
|
752
|
+
doctor: async (config, context) => {
|
|
753
|
+
const connectionString = context.env[config.dsnEnv];
|
|
754
|
+
return {
|
|
755
|
+
provider: "postgres",
|
|
756
|
+
mode: config.mode,
|
|
757
|
+
activeMode: "postgres",
|
|
758
|
+
capability: config.mode === "postgres" ? "read-only" : "off",
|
|
759
|
+
requiredEnv: config.mode === "postgres" && !connectionString ? [config.dsnEnv] : [],
|
|
760
|
+
requiredScopes: config.mode === "postgres"
|
|
761
|
+
? [...POSTGRES_NATIVE_CONNECTOR_GOVERNANCE.requiredScopes]
|
|
762
|
+
: [],
|
|
763
|
+
smokeCheck: config.mode === "postgres"
|
|
764
|
+
? POSTGRES_NATIVE_CONNECTOR_GOVERNANCE.smokeCheck
|
|
765
|
+
: null,
|
|
766
|
+
runSmoke: config.mode === "postgres" && connectionString
|
|
767
|
+
? () => smokePostgresCredentials({
|
|
768
|
+
connectionString,
|
|
769
|
+
allowedTables: config.allowedTables,
|
|
770
|
+
allowedFunctions: config.allowedFunctions,
|
|
771
|
+
requireRls: config.requireRls,
|
|
772
|
+
maxRows: config.maxRows,
|
|
773
|
+
statementTimeoutMs: config.statementTimeoutMs,
|
|
774
|
+
lockTimeoutMs: config.lockTimeoutMs,
|
|
775
|
+
idleTransactionTimeoutMs: config.idleTransactionTimeoutMs,
|
|
776
|
+
})
|
|
777
|
+
: undefined,
|
|
778
|
+
};
|
|
779
|
+
},
|
|
780
|
+
};
|
|
781
|
+
export const DRIVE_NATIVE_CONNECTOR = {
|
|
782
|
+
...DRIVE_NATIVE_CONNECTOR_GOVERNANCE,
|
|
783
|
+
createHandlers: createDriveRestHandlersRaw,
|
|
784
|
+
smoke: smokeDriveRestCredentials,
|
|
785
|
+
doctor: async (config, context) => {
|
|
786
|
+
const credential = await context.resolveCredential?.("drive");
|
|
787
|
+
const accessToken = credential?.token ?? context.env[config.tokenEnv];
|
|
788
|
+
const selectedFileId = credential?.selectedFileId ?? config.selectedFileId;
|
|
789
|
+
const missing = [];
|
|
790
|
+
if (config.mode === "rest" && !accessToken) {
|
|
791
|
+
missing.push(`axtary connect drive or ${config.tokenEnv}`);
|
|
792
|
+
}
|
|
793
|
+
if (config.mode === "rest" && !selectedFileId) {
|
|
794
|
+
missing.push("Picker-selected file id");
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
provider: "drive",
|
|
798
|
+
mode: config.mode,
|
|
799
|
+
activeMode: "rest",
|
|
800
|
+
capability: config.mode === "rest" ? "read-only" : "off",
|
|
801
|
+
requiredEnv: missing,
|
|
802
|
+
requiredScopes: config.mode === "rest"
|
|
803
|
+
? [...DRIVE_NATIVE_CONNECTOR_GOVERNANCE.requiredScopes]
|
|
804
|
+
: [],
|
|
805
|
+
smokeCheck: config.mode === "rest"
|
|
806
|
+
? DRIVE_NATIVE_CONNECTOR_GOVERNANCE.smokeCheck
|
|
807
|
+
: null,
|
|
808
|
+
runSmoke: config.mode === "rest" && accessToken && selectedFileId
|
|
809
|
+
? () => smokeDriveRestCredentials({
|
|
810
|
+
accessToken,
|
|
811
|
+
selectedFileId,
|
|
812
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
813
|
+
allowedMimeTypes: config.allowedMimeTypes,
|
|
814
|
+
maxReadBytes: config.maxReadBytes,
|
|
815
|
+
fetch: context.fetch,
|
|
816
|
+
maxRetries: 0,
|
|
817
|
+
})
|
|
818
|
+
: undefined,
|
|
819
|
+
};
|
|
820
|
+
},
|
|
821
|
+
};
|
|
822
|
+
export function createPostgresNativeHandlers(config, options = {}) {
|
|
823
|
+
return createNativeConnectorHandlers(POSTGRES_NATIVE_CONNECTOR, config, options);
|
|
824
|
+
}
|
|
825
|
+
export function createDriveRestHandlers(config, options = {}) {
|
|
826
|
+
return createNativeConnectorHandlers(DRIVE_NATIVE_CONNECTOR, config, options);
|
|
827
|
+
}
|
|
828
|
+
export const NATIVE_CONNECTOR_DESCRIPTORS = [
|
|
829
|
+
GITHUB_NATIVE_CONNECTOR,
|
|
830
|
+
LINEAR_NATIVE_CONNECTOR,
|
|
831
|
+
JIRA_NATIVE_CONNECTOR,
|
|
832
|
+
POSTGRES_NATIVE_CONNECTOR,
|
|
833
|
+
DRIVE_NATIVE_CONNECTOR,
|
|
834
|
+
];
|
|
835
|
+
export async function buildNativeConnectorDoctorPlans(input) {
|
|
836
|
+
const plans = [
|
|
837
|
+
LINEAR_NATIVE_CONNECTOR.doctor(input.configs.linear, input.context),
|
|
838
|
+
JIRA_NATIVE_CONNECTOR.doctor(input.configs.jira, input.context),
|
|
839
|
+
];
|
|
840
|
+
if (input.configs.postgres) {
|
|
841
|
+
plans.push(POSTGRES_NATIVE_CONNECTOR.doctor(input.configs.postgres, input.context));
|
|
842
|
+
}
|
|
843
|
+
if (input.configs.drive) {
|
|
844
|
+
plans.push(DRIVE_NATIVE_CONNECTOR.doctor(input.configs.drive, input.context));
|
|
845
|
+
}
|
|
846
|
+
return Promise.all(plans);
|
|
436
847
|
}
|
|
437
848
|
export function createAwsRestHandlers(config, options = {}) {
|
|
438
849
|
const handlers = {
|
|
@@ -479,9 +890,94 @@ export async function smokeLocalDocsRoots(config) {
|
|
|
479
890
|
},
|
|
480
891
|
};
|
|
481
892
|
}
|
|
893
|
+
/**
|
|
894
|
+
* Env var names that must be set for app mode given the config. A literal
|
|
895
|
+
* satisfies its slot, so its env name drops out of the requirement. Used for
|
|
896
|
+
* readiness/doctor reporting (which env names are missing) — never values.
|
|
897
|
+
*/
|
|
898
|
+
export function githubAppRequiredEnv(config) {
|
|
899
|
+
const names = [config.privateKeyEnv];
|
|
900
|
+
if (!config.appId && config.appIdEnv)
|
|
901
|
+
names.push(config.appIdEnv);
|
|
902
|
+
if (!config.installationId && config.installationIdEnv)
|
|
903
|
+
names.push(config.installationIdEnv);
|
|
904
|
+
return names;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Resolve effective App ID, Installation ID, and private key from config + env
|
|
908
|
+
* (literal wins over env name). Throws a descriptive, value-free error naming
|
|
909
|
+
* the missing literal or env var.
|
|
910
|
+
*/
|
|
911
|
+
export function resolveGithubAppCredentials(config, env) {
|
|
912
|
+
const appId = config.appId ?? (config.appIdEnv ? env[config.appIdEnv] : undefined);
|
|
913
|
+
const installationId = config.installationId ?? (config.installationIdEnv ? env[config.installationIdEnv] : undefined);
|
|
914
|
+
const privateKey = env[config.privateKeyEnv];
|
|
915
|
+
if (!privateKey) {
|
|
916
|
+
throw new Error(`missing_github_app_private_key_env:${config.privateKeyEnv}`);
|
|
917
|
+
}
|
|
918
|
+
if (!appId) {
|
|
919
|
+
throw new Error(config.appIdEnv ? `missing_github_app_id_env:${config.appIdEnv}` : "missing_github_app_id");
|
|
920
|
+
}
|
|
921
|
+
if (!installationId) {
|
|
922
|
+
throw new Error(config.installationIdEnv
|
|
923
|
+
? `missing_github_installation_id_env:${config.installationIdEnv}`
|
|
924
|
+
: "missing_github_installation_id");
|
|
925
|
+
}
|
|
926
|
+
return { appId, installationId, privateKey };
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Mint a short-lived GitHub App installation token. Builds an RS256 app JWT
|
|
930
|
+
* (iss=appId, ~9 min lifetime, iat backdated 60s for clock skew) and exchanges
|
|
931
|
+
* it at POST /app/installations/{id}/access_tokens. The returned token is
|
|
932
|
+
* fine-grained to the App's installed permissions and expires in ~1h — the
|
|
933
|
+
* credential the proxy actually hands to provider calls. The PEM private key is
|
|
934
|
+
* never logged; on a malformed key we throw before any network call.
|
|
935
|
+
*/
|
|
936
|
+
export async function mintGithubInstallationToken(input) {
|
|
937
|
+
const fetchImpl = input.fetch ?? fetch;
|
|
938
|
+
const apiBaseUrl = (input.apiBaseUrl ?? GITHUB_REST_API_BASE_URL).replace(/\/$/, "");
|
|
939
|
+
const userAgent = input.userAgent ?? GITHUB_REST_USER_AGENT;
|
|
940
|
+
let signingKey;
|
|
941
|
+
try {
|
|
942
|
+
// createPrivateKey accepts both PKCS#1 ("BEGIN RSA PRIVATE KEY", GitHub's
|
|
943
|
+
// download format) and PKCS#8 ("BEGIN PRIVATE KEY"); jose.importPKCS8 would
|
|
944
|
+
// reject the former. jose's SignJWT.sign accepts a Node KeyObject.
|
|
945
|
+
signingKey = createPrivateKey(input.privateKey);
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
throw new ProviderRequestError("github_app_private_key_invalid", 0, "auth_error");
|
|
949
|
+
}
|
|
950
|
+
const now = Math.floor(Date.now() / 1000);
|
|
951
|
+
const appJwt = await new SignJWT({})
|
|
952
|
+
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
|
|
953
|
+
.setIssuer(input.appId)
|
|
954
|
+
.setIssuedAt(now - 60)
|
|
955
|
+
.setExpirationTime(now + 540)
|
|
956
|
+
.sign(signingKey);
|
|
957
|
+
const response = await fetchImpl(`${apiBaseUrl}/app/installations/${encodeURIComponent(input.installationId)}/access_tokens`, {
|
|
958
|
+
method: "POST",
|
|
959
|
+
headers: {
|
|
960
|
+
accept: "application/vnd.github+json",
|
|
961
|
+
authorization: `Bearer ${appJwt}`,
|
|
962
|
+
"user-agent": userAgent,
|
|
963
|
+
"x-github-api-version": "2022-11-28",
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
if (!response.ok) {
|
|
967
|
+
// 401 => bad private key / app id; 404 => bad installation id or app not
|
|
968
|
+
// installed on the repo. providerError keeps the body bounded and category-tagged.
|
|
969
|
+
throw await providerError("github_app", response);
|
|
970
|
+
}
|
|
971
|
+
const parsed = (await response.json());
|
|
972
|
+
if (!parsed.token) {
|
|
973
|
+
throw new ProviderRequestError("github_app_token_missing", response.status, "provider_error");
|
|
974
|
+
}
|
|
975
|
+
return { token: parsed.token, expiresAt: parsed.expires_at ?? "" };
|
|
976
|
+
}
|
|
482
977
|
export async function smokeGitHubRestCredentials(config) {
|
|
483
978
|
const client = createGitHubRestClient(config);
|
|
484
|
-
const
|
|
979
|
+
const response = await client.requestWithMetadata("/user", { method: "GET" });
|
|
980
|
+
const user = response.data;
|
|
485
981
|
return {
|
|
486
982
|
provider: "github",
|
|
487
983
|
ok: true,
|
|
@@ -489,6 +985,25 @@ export async function smokeGitHubRestCredentials(config) {
|
|
|
489
985
|
details: {
|
|
490
986
|
login: user.login,
|
|
491
987
|
id: user.id,
|
|
988
|
+
acceptedPermissions: response.metadata.acceptedPermissions,
|
|
989
|
+
rateLimit: response.metadata.rateLimit,
|
|
990
|
+
},
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
export async function smokeGithubAppCredentials(input) {
|
|
994
|
+
// Minting proves the App id, private key, and installation id all work
|
|
995
|
+
// together. The token is discarded — no repo state is touched.
|
|
996
|
+
const minted = await mintGithubInstallationToken(input);
|
|
997
|
+
return {
|
|
998
|
+
provider: "github",
|
|
999
|
+
ok: true,
|
|
1000
|
+
summary: minted.expiresAt
|
|
1001
|
+
? `installation token minted (expires ${minted.expiresAt})`
|
|
1002
|
+
: "installation token minted",
|
|
1003
|
+
details: {
|
|
1004
|
+
appId: input.appId,
|
|
1005
|
+
installationId: input.installationId,
|
|
1006
|
+
expiresAt: minted.expiresAt || null,
|
|
492
1007
|
},
|
|
493
1008
|
};
|
|
494
1009
|
}
|
|
@@ -529,6 +1044,83 @@ export async function smokeLinearGraphqlCredentials(config) {
|
|
|
529
1044
|
},
|
|
530
1045
|
};
|
|
531
1046
|
}
|
|
1047
|
+
export async function smokeJiraRestCredentials(config) {
|
|
1048
|
+
const client = createJiraRestClient(config);
|
|
1049
|
+
const myself = await client.request("/myself", {
|
|
1050
|
+
method: "GET",
|
|
1051
|
+
});
|
|
1052
|
+
return {
|
|
1053
|
+
provider: "jira",
|
|
1054
|
+
ok: true,
|
|
1055
|
+
summary: `user: ${myself.displayName}`,
|
|
1056
|
+
details: {
|
|
1057
|
+
accountId: myself.accountId,
|
|
1058
|
+
displayName: myself.displayName,
|
|
1059
|
+
emailAddress: myself.emailAddress ?? null,
|
|
1060
|
+
siteUrl: config.siteUrl ?? null,
|
|
1061
|
+
},
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
export async function smokePostgresCredentials(config) {
|
|
1065
|
+
if (config.allowedTables.length === 0) {
|
|
1066
|
+
throw new Error("postgres_allowed_tables_required");
|
|
1067
|
+
}
|
|
1068
|
+
const client = postgresClient(config);
|
|
1069
|
+
await client.connect();
|
|
1070
|
+
try {
|
|
1071
|
+
await beginPostgresReadOnly(client, config);
|
|
1072
|
+
const safety = await inspectPostgresSession(client, config.allowedTables);
|
|
1073
|
+
enforcePostgresSafety(safety, config);
|
|
1074
|
+
await client.query("ROLLBACK");
|
|
1075
|
+
return {
|
|
1076
|
+
provider: "postgres",
|
|
1077
|
+
ok: true,
|
|
1078
|
+
summary: `database: ${safety.database}, role: ${safety.role}, read-only: ${safety.transactionReadOnly}`,
|
|
1079
|
+
details: {
|
|
1080
|
+
database: safety.database,
|
|
1081
|
+
role: safety.role,
|
|
1082
|
+
transactionReadOnly: safety.transactionReadOnly,
|
|
1083
|
+
roleLeastPrivilege: safety.roleLeastPrivilege,
|
|
1084
|
+
tables: safety.tables.map((table) => ({
|
|
1085
|
+
name: table.name,
|
|
1086
|
+
selectGranted: table.selectGranted,
|
|
1087
|
+
writeGranted: table.writeGranted,
|
|
1088
|
+
rlsEnabled: table.rlsEnabled,
|
|
1089
|
+
rlsForced: table.rlsForced,
|
|
1090
|
+
roleOwnsTable: table.roleOwnsTable,
|
|
1091
|
+
})),
|
|
1092
|
+
},
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
catch (error) {
|
|
1096
|
+
await rollbackQuietly(client);
|
|
1097
|
+
throw new Error(postgresErrorReason("postgres_smoke_failed", error));
|
|
1098
|
+
}
|
|
1099
|
+
finally {
|
|
1100
|
+
await client.end();
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
export async function smokeDriveRestCredentials(config) {
|
|
1104
|
+
if (!config.selectedFileId) {
|
|
1105
|
+
throw new Error("drive_selected_file_required");
|
|
1106
|
+
}
|
|
1107
|
+
const metadata = await getDriveFileMetadata(config, config.selectedFileId);
|
|
1108
|
+
if (metadata.isAppAuthorized !== true) {
|
|
1109
|
+
throw new Error("drive_file_not_authorized");
|
|
1110
|
+
}
|
|
1111
|
+
return {
|
|
1112
|
+
provider: "drive",
|
|
1113
|
+
ok: true,
|
|
1114
|
+
summary: `selected file: ${metadata.name}`,
|
|
1115
|
+
details: {
|
|
1116
|
+
fileId: metadata.id,
|
|
1117
|
+
title: metadata.name,
|
|
1118
|
+
mimeType: metadata.mimeType,
|
|
1119
|
+
appAuthorized: metadata.isAppAuthorized,
|
|
1120
|
+
scope: "https://www.googleapis.com/auth/drive.file",
|
|
1121
|
+
},
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
532
1124
|
export async function smokeAwsRestCredentials(config) {
|
|
533
1125
|
const client = createAwsClient(config);
|
|
534
1126
|
const identity = await getAwsCallerIdentity(client);
|
|
@@ -575,24 +1167,38 @@ export async function smokeGcpRestCredentials(config) {
|
|
|
575
1167
|
};
|
|
576
1168
|
}
|
|
577
1169
|
export function wrapHandlersWithActionPassVerification(handlers, config) {
|
|
1170
|
+
const verificationConfig = {
|
|
1171
|
+
...config,
|
|
1172
|
+
dpopReplayStore: config.dpopReplayStore ?? new InMemoryDpopReplayStore(),
|
|
1173
|
+
};
|
|
578
1174
|
return Object.fromEntries(Object.entries(handlers).map(([tool, handler]) => [
|
|
579
1175
|
tool,
|
|
580
|
-
createActionPassVerifiedHandler(handler,
|
|
1176
|
+
createActionPassVerifiedHandler(handler, verificationConfig),
|
|
581
1177
|
]));
|
|
582
1178
|
}
|
|
583
1179
|
export function createActionPassVerifiedHandler(handler, config) {
|
|
1180
|
+
const dpopReplayStore = config.dpopReplayStore ?? new InMemoryDpopReplayStore();
|
|
584
1181
|
return async (request) => {
|
|
585
1182
|
if (!request.actionPass) {
|
|
586
1183
|
throw new Error("adapter_actionpass_required");
|
|
587
1184
|
}
|
|
1185
|
+
const target = actionPassDpopTarget(request.action);
|
|
588
1186
|
const verified = await verifyActionPass({
|
|
589
1187
|
token: request.actionPass.token,
|
|
590
1188
|
action: request.action,
|
|
591
1189
|
verificationKey: config.verificationKey,
|
|
1190
|
+
verificationKeys: config.verificationKeys,
|
|
592
1191
|
issuer: config.issuer,
|
|
593
1192
|
algorithms: config.algorithms,
|
|
594
1193
|
clockTolerance: config.clockTolerance,
|
|
595
1194
|
currentDate: config.currentDate?.(),
|
|
1195
|
+
dpopProof: request.dpopProof ?? undefined,
|
|
1196
|
+
dpopMethod: target.method,
|
|
1197
|
+
dpopUri: target.uri,
|
|
1198
|
+
dpopNonce: config.dpopNonce,
|
|
1199
|
+
dpopReplayStore,
|
|
1200
|
+
isRevoked: config.isRevoked,
|
|
1201
|
+
getStatus: config.getStatus,
|
|
596
1202
|
});
|
|
597
1203
|
if (!verified.valid) {
|
|
598
1204
|
throw new Error(`adapter_actionpass_invalid:${verified.reason}`);
|
|
@@ -923,6 +1529,120 @@ export function createGitHubRestContentWriteHandler(config) {
|
|
|
923
1529
|
};
|
|
924
1530
|
};
|
|
925
1531
|
}
|
|
1532
|
+
export function createGitHubRestReviewCommentHandler(config) {
|
|
1533
|
+
const client = createGitHubRestClient(config);
|
|
1534
|
+
return async ({ action, actionPass }) => {
|
|
1535
|
+
const repo = parseGitHubRepoResource(action.capability.resource);
|
|
1536
|
+
const payload = action.capability.payload;
|
|
1537
|
+
const pullNumber = requirePositiveInteger(payload.pullNumber, "github_rest_missing_pull_number");
|
|
1538
|
+
const line = requirePositiveInteger(payload.line, "github_rest_missing_review_line");
|
|
1539
|
+
const side = requireEnumString(payload.side, ["LEFT", "RIGHT"], "github_rest_invalid_review_side");
|
|
1540
|
+
const startLine = parseOptionalPositiveInteger(payload.startLine, "github_rest_invalid_review_start_line");
|
|
1541
|
+
const startSide = payload.startSide === undefined
|
|
1542
|
+
? undefined
|
|
1543
|
+
: requireEnumString(payload.startSide, ["LEFT", "RIGHT"], "github_rest_invalid_review_start_side");
|
|
1544
|
+
const response = await client.requestWithMetadata(`/repos/${repo.owner}/${repo.name}/pulls/${pullNumber}/comments`, {
|
|
1545
|
+
method: "POST",
|
|
1546
|
+
body: {
|
|
1547
|
+
body: requireString(payload.body, "github_rest_missing_review_body"),
|
|
1548
|
+
commit_id: requireString(payload.commitId, "github_rest_missing_commit_id"),
|
|
1549
|
+
path: requireString(payload.path, "github_rest_missing_review_path"),
|
|
1550
|
+
line,
|
|
1551
|
+
side,
|
|
1552
|
+
start_line: startLine,
|
|
1553
|
+
start_side: startSide,
|
|
1554
|
+
},
|
|
1555
|
+
});
|
|
1556
|
+
return {
|
|
1557
|
+
provider: "github",
|
|
1558
|
+
kind: "pull_request_review_comment",
|
|
1559
|
+
id: String(response.data.id),
|
|
1560
|
+
url: response.data.html_url,
|
|
1561
|
+
pullNumber,
|
|
1562
|
+
path: response.data.path,
|
|
1563
|
+
line: response.data.line,
|
|
1564
|
+
side: response.data.side,
|
|
1565
|
+
acceptedPermissions: response.metadata.acceptedPermissions,
|
|
1566
|
+
rateLimit: response.metadata.rateLimit,
|
|
1567
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1568
|
+
};
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
export function createGitHubRestCheckRunHandler(config) {
|
|
1572
|
+
const client = createGitHubRestClient(config);
|
|
1573
|
+
return async ({ action, actionPass }) => {
|
|
1574
|
+
const repo = parseGitHubRepoResource(action.capability.resource);
|
|
1575
|
+
const payload = action.capability.payload;
|
|
1576
|
+
const output = getObject(payload.output);
|
|
1577
|
+
const response = await client.requestWithMetadata(`/repos/${repo.owner}/${repo.name}/check-runs`, {
|
|
1578
|
+
method: "POST",
|
|
1579
|
+
body: {
|
|
1580
|
+
name: requireString(payload.name, "github_rest_missing_check_name"),
|
|
1581
|
+
head_sha: requireString(payload.headSha, "github_rest_missing_check_head_sha"),
|
|
1582
|
+
status: payload.status === undefined
|
|
1583
|
+
? undefined
|
|
1584
|
+
: requireEnumString(payload.status, ["queued", "in_progress", "completed", "pending"], "github_rest_invalid_check_status"),
|
|
1585
|
+
conclusion: payload.conclusion === undefined
|
|
1586
|
+
? undefined
|
|
1587
|
+
: requireEnumString(payload.conclusion, [
|
|
1588
|
+
"action_required",
|
|
1589
|
+
"cancelled",
|
|
1590
|
+
"failure",
|
|
1591
|
+
"neutral",
|
|
1592
|
+
"success",
|
|
1593
|
+
"skipped",
|
|
1594
|
+
"stale",
|
|
1595
|
+
"timed_out",
|
|
1596
|
+
], "github_rest_invalid_check_conclusion"),
|
|
1597
|
+
details_url: getString(payload.detailsUrl) ?? undefined,
|
|
1598
|
+
external_id: getString(payload.externalId) ?? undefined,
|
|
1599
|
+
output: output
|
|
1600
|
+
? removeUndefined({
|
|
1601
|
+
title: requireString(output.title, "github_rest_missing_check_output_title"),
|
|
1602
|
+
summary: requireString(output.summary, "github_rest_missing_check_output_summary"),
|
|
1603
|
+
text: getString(output.text) ?? undefined,
|
|
1604
|
+
})
|
|
1605
|
+
: undefined,
|
|
1606
|
+
},
|
|
1607
|
+
});
|
|
1608
|
+
return {
|
|
1609
|
+
provider: "github",
|
|
1610
|
+
kind: "check_run",
|
|
1611
|
+
id: String(response.data.id),
|
|
1612
|
+
url: response.data.html_url,
|
|
1613
|
+
status: response.data.status,
|
|
1614
|
+
conclusion: response.data.conclusion,
|
|
1615
|
+
headSha: response.data.head_sha,
|
|
1616
|
+
acceptedPermissions: response.metadata.acceptedPermissions,
|
|
1617
|
+
rateLimit: response.metadata.rateLimit,
|
|
1618
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1619
|
+
};
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
export function createGitHubRestIssueCommentHandler(config) {
|
|
1623
|
+
const client = createGitHubRestClient(config);
|
|
1624
|
+
return async ({ action, actionPass }) => {
|
|
1625
|
+
const repo = parseGitHubRepoResource(action.capability.resource);
|
|
1626
|
+
const payload = action.capability.payload;
|
|
1627
|
+
const issueNumber = requirePositiveInteger(payload.issueNumber, "github_rest_missing_issue_number");
|
|
1628
|
+
const response = await client.requestWithMetadata(`/repos/${repo.owner}/${repo.name}/issues/${issueNumber}/comments`, {
|
|
1629
|
+
method: "POST",
|
|
1630
|
+
body: {
|
|
1631
|
+
body: requireString(payload.body, "github_rest_missing_issue_comment_body"),
|
|
1632
|
+
},
|
|
1633
|
+
});
|
|
1634
|
+
return {
|
|
1635
|
+
provider: "github",
|
|
1636
|
+
kind: "issue_comment",
|
|
1637
|
+
id: String(response.data.id),
|
|
1638
|
+
url: response.data.html_url,
|
|
1639
|
+
issueNumber,
|
|
1640
|
+
acceptedPermissions: response.metadata.acceptedPermissions,
|
|
1641
|
+
rateLimit: response.metadata.rateLimit,
|
|
1642
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1643
|
+
};
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
926
1646
|
export function createSlackWebPostMessageHandler(config) {
|
|
927
1647
|
const client = createSlackWebClient(config);
|
|
928
1648
|
return async ({ action, actionPass }) => {
|
|
@@ -1062,6 +1782,213 @@ export function createLinearIssueUpdateHandler(config) {
|
|
|
1062
1782
|
};
|
|
1063
1783
|
};
|
|
1064
1784
|
}
|
|
1785
|
+
export function createJiraIssueReadHandler(config) {
|
|
1786
|
+
const client = createJiraRestClient(config);
|
|
1787
|
+
return async ({ action, actionPass }) => {
|
|
1788
|
+
const issueKey = issueIdentifier(action.capability.payload);
|
|
1789
|
+
const issue = await getJiraIssue(client, issueKey);
|
|
1790
|
+
return jiraIssueResult(issue, config, actionPass?.claims.jti ?? null);
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
export function createJiraCommentCreateHandler(config) {
|
|
1794
|
+
const client = createJiraRestClient(config);
|
|
1795
|
+
return async ({ action, actionPass }) => {
|
|
1796
|
+
const issueKey = issueIdentifier(action.capability.payload);
|
|
1797
|
+
const body = requireString(action.capability.payload.body, "jira_rest_missing_comment_body");
|
|
1798
|
+
const comment = await client.request(`/issue/${encodeURIComponent(issueKey)}/comment`, {
|
|
1799
|
+
method: "POST",
|
|
1800
|
+
body: {
|
|
1801
|
+
body: jiraAdfDocument(body),
|
|
1802
|
+
},
|
|
1803
|
+
});
|
|
1804
|
+
return {
|
|
1805
|
+
provider: "jira",
|
|
1806
|
+
kind: "issue_comment",
|
|
1807
|
+
id: comment.id,
|
|
1808
|
+
issueKey,
|
|
1809
|
+
url: jiraIssueUrl(config, issueKey),
|
|
1810
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1811
|
+
};
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
export function createJiraIssueUpdateHandler(config) {
|
|
1815
|
+
const client = createJiraRestClient(config);
|
|
1816
|
+
return async ({ action, actionPass }) => {
|
|
1817
|
+
const payload = action.capability.payload;
|
|
1818
|
+
const issueKey = issueIdentifier(payload);
|
|
1819
|
+
const status = getString(payload.status);
|
|
1820
|
+
const fields = removeUndefined({
|
|
1821
|
+
title: getString(payload.title) ?? undefined,
|
|
1822
|
+
summary: getString(payload.title) ?? undefined,
|
|
1823
|
+
description: getString(payload.description)
|
|
1824
|
+
? jiraAdfDocument(getString(payload.description))
|
|
1825
|
+
: undefined,
|
|
1826
|
+
priority: getString(payload.priorityId)
|
|
1827
|
+
? { id: getString(payload.priorityId) }
|
|
1828
|
+
: undefined,
|
|
1829
|
+
});
|
|
1830
|
+
// "title" is Axtary's normalized field; Jira calls it "summary".
|
|
1831
|
+
delete fields.title;
|
|
1832
|
+
if (Object.keys(fields).length === 0 && !status) {
|
|
1833
|
+
throw new Error("jira_rest_missing_update_fields");
|
|
1834
|
+
}
|
|
1835
|
+
if (Object.keys(fields).length > 0) {
|
|
1836
|
+
await client.request(`/issue/${encodeURIComponent(issueKey)}`, {
|
|
1837
|
+
method: "PUT",
|
|
1838
|
+
body: { fields },
|
|
1839
|
+
expectEmpty: true,
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
let transitionId = null;
|
|
1843
|
+
if (status) {
|
|
1844
|
+
const transitions = await client.request(`/issue/${encodeURIComponent(issueKey)}/transitions?expand=transitions.fields`, { method: "GET" });
|
|
1845
|
+
const transition = transitions.transitions.find((candidate) => candidate.name.localeCompare(status, undefined, {
|
|
1846
|
+
sensitivity: "base",
|
|
1847
|
+
}) === 0 ||
|
|
1848
|
+
candidate.to?.name.localeCompare(status, undefined, {
|
|
1849
|
+
sensitivity: "base",
|
|
1850
|
+
}) === 0);
|
|
1851
|
+
if (!transition) {
|
|
1852
|
+
const available = transitions.transitions
|
|
1853
|
+
.map((candidate) => candidate.to?.name ?? candidate.name)
|
|
1854
|
+
.join(",");
|
|
1855
|
+
throw new Error(`jira_rest_transition_not_found:${status}:available=${available}`);
|
|
1856
|
+
}
|
|
1857
|
+
transitionId = transition.id;
|
|
1858
|
+
await client.request(`/issue/${encodeURIComponent(issueKey)}/transitions`, {
|
|
1859
|
+
method: "POST",
|
|
1860
|
+
body: { transition: { id: transition.id } },
|
|
1861
|
+
expectEmpty: true,
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
const issue = await getJiraIssue(client, issueKey);
|
|
1865
|
+
return {
|
|
1866
|
+
...jiraIssueResult(issue, config, actionPass?.claims.jti ?? null),
|
|
1867
|
+
kind: "issue_update",
|
|
1868
|
+
transitionId,
|
|
1869
|
+
};
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
export function createPostgresSelectHandler(config) {
|
|
1873
|
+
return async ({ action, actionPass }) => {
|
|
1874
|
+
const payload = action.capability.payload;
|
|
1875
|
+
const statement = requireString(payload.statement, "postgres_statement_required");
|
|
1876
|
+
const parameters = Array.isArray(payload.parameters)
|
|
1877
|
+
? payload.parameters
|
|
1878
|
+
: null;
|
|
1879
|
+
const maxRows = requirePositiveInteger(payload.maxRows, "postgres_max_rows_required");
|
|
1880
|
+
if (!parameters)
|
|
1881
|
+
throw new Error("postgres_parameters_required");
|
|
1882
|
+
const analyzed = analyzePostgresSelect(statement);
|
|
1883
|
+
if (!analyzed.ok)
|
|
1884
|
+
throw new Error(analyzed.reason);
|
|
1885
|
+
const analysis = analyzed.analysis;
|
|
1886
|
+
enforcePostgresQueryScope(analysis, parameters.length, maxRows, config);
|
|
1887
|
+
const expectedDatabase = parsePostgresDatabaseResource(action.capability.resource);
|
|
1888
|
+
const client = postgresClient(config);
|
|
1889
|
+
await client.connect();
|
|
1890
|
+
try {
|
|
1891
|
+
await beginPostgresReadOnly(client, config);
|
|
1892
|
+
const safety = await inspectPostgresSession(client, analysis.tables);
|
|
1893
|
+
enforcePostgresSafety(safety, config);
|
|
1894
|
+
if (safety.database !== expectedDatabase) {
|
|
1895
|
+
throw new Error("postgres_database_resource_mismatch");
|
|
1896
|
+
}
|
|
1897
|
+
const limitParameter = analysis.parameterCount + 1;
|
|
1898
|
+
const boundedStatement = `SELECT * FROM (${analysis.canonicalStatement}) AS axtary_scoped_query LIMIT $${limitParameter}`;
|
|
1899
|
+
const result = await client.query(boundedStatement, [
|
|
1900
|
+
...parameters,
|
|
1901
|
+
maxRows + 1,
|
|
1902
|
+
]);
|
|
1903
|
+
const truncated = result.rows.length > maxRows;
|
|
1904
|
+
const rows = result.rows.slice(0, maxRows).map(jsonSafePostgresRow);
|
|
1905
|
+
const fields = result.fields.map((field) => field.name);
|
|
1906
|
+
await client.query("COMMIT");
|
|
1907
|
+
return {
|
|
1908
|
+
provider: "postgres",
|
|
1909
|
+
kind: "query_select",
|
|
1910
|
+
database: safety.database,
|
|
1911
|
+
role: safety.role,
|
|
1912
|
+
transactionReadOnly: safety.transactionReadOnly,
|
|
1913
|
+
roleLeastPrivilege: safety.roleLeastPrivilege,
|
|
1914
|
+
statementHash: analysis.statementHash,
|
|
1915
|
+
predicateHash: analysis.predicateHash,
|
|
1916
|
+
tables: analysis.tables,
|
|
1917
|
+
rowCount: rows.length,
|
|
1918
|
+
fields,
|
|
1919
|
+
truncated,
|
|
1920
|
+
rls: safety.tables.map((table) => ({
|
|
1921
|
+
table: table.name,
|
|
1922
|
+
selectGranted: table.selectGranted,
|
|
1923
|
+
writeGranted: table.writeGranted,
|
|
1924
|
+
enabled: table.rlsEnabled,
|
|
1925
|
+
forced: table.rlsForced,
|
|
1926
|
+
roleOwnsTable: table.roleOwnsTable,
|
|
1927
|
+
})),
|
|
1928
|
+
rows,
|
|
1929
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
catch (error) {
|
|
1933
|
+
await rollbackQuietly(client);
|
|
1934
|
+
throw new Error(postgresErrorReason("postgres_query_failed", error));
|
|
1935
|
+
}
|
|
1936
|
+
finally {
|
|
1937
|
+
await client.end();
|
|
1938
|
+
}
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
export function createDriveFileReadHandler(config) {
|
|
1942
|
+
return async ({ action, actionPass }) => {
|
|
1943
|
+
const fileId = requireString(action.capability.payload.fileId, "drive_file_id_required");
|
|
1944
|
+
const maxBytes = requirePositiveInteger(action.capability.payload.maxBytes, "drive_max_bytes_required");
|
|
1945
|
+
if (action.capability.resource !== `drive:file:${fileId}`) {
|
|
1946
|
+
throw new Error("drive_file_resource_mismatch");
|
|
1947
|
+
}
|
|
1948
|
+
if (maxBytes > config.maxReadBytes) {
|
|
1949
|
+
throw new Error("drive_read_byte_limit_exceeded");
|
|
1950
|
+
}
|
|
1951
|
+
const metadata = await getDriveFileMetadata(config, fileId);
|
|
1952
|
+
if (metadata.isAppAuthorized !== true) {
|
|
1953
|
+
throw new Error("drive_file_not_authorized");
|
|
1954
|
+
}
|
|
1955
|
+
if (!config.allowedMimeTypes.includes(metadata.mimeType)) {
|
|
1956
|
+
throw new Error(`drive_mime_type_not_allowed:${metadata.mimeType}`);
|
|
1957
|
+
}
|
|
1958
|
+
if (metadata.size !== null &&
|
|
1959
|
+
metadata.size > maxBytes) {
|
|
1960
|
+
throw new Error("drive_content_too_large");
|
|
1961
|
+
}
|
|
1962
|
+
const contentUrl = driveContentUrl(config, metadata);
|
|
1963
|
+
const response = await driveFetch(config, contentUrl, {
|
|
1964
|
+
headers: {
|
|
1965
|
+
authorization: `Bearer ${config.accessToken}`,
|
|
1966
|
+
accept: "text/plain, text/*;q=0.9, application/json;q=0.8",
|
|
1967
|
+
},
|
|
1968
|
+
});
|
|
1969
|
+
if (!response.ok) {
|
|
1970
|
+
throw await driveResponseError(response);
|
|
1971
|
+
}
|
|
1972
|
+
const content = await readDriveTextBounded(response, maxBytes);
|
|
1973
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
1974
|
+
return {
|
|
1975
|
+
provider: "drive",
|
|
1976
|
+
kind: "file_read",
|
|
1977
|
+
id: metadata.id,
|
|
1978
|
+
fileId: metadata.id,
|
|
1979
|
+
title: metadata.name,
|
|
1980
|
+
mimeType: metadata.mimeType,
|
|
1981
|
+
modifiedTime: metadata.modifiedTime,
|
|
1982
|
+
url: metadata.webViewLink,
|
|
1983
|
+
scope: "https://www.googleapis.com/auth/drive.file",
|
|
1984
|
+
appAuthorized: metadata.isAppAuthorized,
|
|
1985
|
+
bytes,
|
|
1986
|
+
truncated: false,
|
|
1987
|
+
content,
|
|
1988
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1989
|
+
};
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1065
1992
|
export function createAwsIdentityGetHandler(config) {
|
|
1066
1993
|
const client = createAwsClient(config);
|
|
1067
1994
|
return async ({ actionPass }) => {
|
|
@@ -1411,33 +2338,183 @@ function createGcpRestClient(config) {
|
|
|
1411
2338
|
},
|
|
1412
2339
|
};
|
|
1413
2340
|
}
|
|
2341
|
+
async function getDriveFileMetadata(config, fileId) {
|
|
2342
|
+
const apiBaseUrl = (config.apiBaseUrl ?? GOOGLE_DRIVE_API_BASE_URL).replace(/\/$/, "");
|
|
2343
|
+
const fields = [
|
|
2344
|
+
"id",
|
|
2345
|
+
"name",
|
|
2346
|
+
"mimeType",
|
|
2347
|
+
"modifiedTime",
|
|
2348
|
+
"size",
|
|
2349
|
+
"webViewLink",
|
|
2350
|
+
"isAppAuthorized",
|
|
2351
|
+
].join(",");
|
|
2352
|
+
const url = `${apiBaseUrl}/files/${encodeURIComponent(fileId)}` +
|
|
2353
|
+
`?supportsAllDrives=true&fields=${encodeURIComponent(fields)}`;
|
|
2354
|
+
const response = await driveFetch(config, url, {
|
|
2355
|
+
headers: {
|
|
2356
|
+
authorization: `Bearer ${config.accessToken}`,
|
|
2357
|
+
accept: "application/json",
|
|
2358
|
+
},
|
|
2359
|
+
});
|
|
2360
|
+
if (!response.ok) {
|
|
2361
|
+
throw await driveResponseError(response);
|
|
2362
|
+
}
|
|
2363
|
+
const value = (await response.json());
|
|
2364
|
+
if (typeof value.id !== "string" ||
|
|
2365
|
+
typeof value.name !== "string" ||
|
|
2366
|
+
typeof value.mimeType !== "string") {
|
|
2367
|
+
throw new Error("drive_metadata_invalid");
|
|
2368
|
+
}
|
|
2369
|
+
const size = typeof value.size === "string" && /^\d+$/.test(value.size)
|
|
2370
|
+
? Number.parseInt(value.size, 10)
|
|
2371
|
+
: typeof value.size === "number" && Number.isFinite(value.size)
|
|
2372
|
+
? value.size
|
|
2373
|
+
: null;
|
|
2374
|
+
return {
|
|
2375
|
+
id: value.id,
|
|
2376
|
+
name: value.name,
|
|
2377
|
+
mimeType: value.mimeType,
|
|
2378
|
+
modifiedTime: typeof value.modifiedTime === "string" ? value.modifiedTime : null,
|
|
2379
|
+
size,
|
|
2380
|
+
webViewLink: typeof value.webViewLink === "string" ? value.webViewLink : null,
|
|
2381
|
+
isAppAuthorized: value.isAppAuthorized === true,
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
function driveContentUrl(config, metadata) {
|
|
2385
|
+
const apiBaseUrl = (config.apiBaseUrl ?? GOOGLE_DRIVE_API_BASE_URL).replace(/\/$/, "");
|
|
2386
|
+
const filePath = `${apiBaseUrl}/files/${encodeURIComponent(metadata.id)}`;
|
|
2387
|
+
return metadata.mimeType === "application/vnd.google-apps.document"
|
|
2388
|
+
? `${filePath}/export?mimeType=${encodeURIComponent("text/plain")}`
|
|
2389
|
+
: `${filePath}?alt=media&supportsAllDrives=true`;
|
|
2390
|
+
}
|
|
2391
|
+
async function driveFetch(config, url, init) {
|
|
2392
|
+
const fetchImpl = config.fetch ?? fetch;
|
|
2393
|
+
const maxRetries = config.maxRetries ?? DEFAULT_PROVIDER_RETRIES;
|
|
2394
|
+
return requestWithRetry(maxRetries, async () => {
|
|
2395
|
+
const response = await fetchImpl(url, init);
|
|
2396
|
+
if (!response.ok) {
|
|
2397
|
+
throw await driveResponseError(response);
|
|
2398
|
+
}
|
|
2399
|
+
return response;
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
async function driveResponseError(response) {
|
|
2403
|
+
const body = (await response.text()).slice(0, 1_000);
|
|
2404
|
+
const retryAfter = providerRetryAfter(response, body);
|
|
2405
|
+
if (/appNotAuthorizedToFile/i.test(body)) {
|
|
2406
|
+
return new ProviderRequestError("drive_file_not_authorized", response.status, "auth_error");
|
|
2407
|
+
}
|
|
2408
|
+
if (response.status === 404) {
|
|
2409
|
+
return new ProviderRequestError("drive_file_not_found_or_inaccessible", response.status, "provider_error");
|
|
2410
|
+
}
|
|
2411
|
+
if (response.status === 401) {
|
|
2412
|
+
return new ProviderRequestError("drive_oauth_token_invalid", response.status, "auth_error");
|
|
2413
|
+
}
|
|
2414
|
+
if (response.status === 429 || /rateLimitExceeded/i.test(body)) {
|
|
2415
|
+
return new ProviderRequestError("drive_rate_limited", response.status, "rate_limited", retryAfter);
|
|
2416
|
+
}
|
|
2417
|
+
if (response.status === 403) {
|
|
2418
|
+
return new ProviderRequestError("drive_access_denied", response.status, "auth_error");
|
|
2419
|
+
}
|
|
2420
|
+
if (response.status >= 500) {
|
|
2421
|
+
return new ProviderRequestError(`drive_provider_unavailable:${response.status}`, response.status, "server_error", retryAfter);
|
|
2422
|
+
}
|
|
2423
|
+
return new ProviderRequestError(`drive_request_failed:${response.status}`, response.status, "provider_error");
|
|
2424
|
+
}
|
|
2425
|
+
async function readDriveTextBounded(response, maxBytes) {
|
|
2426
|
+
if (!response.body) {
|
|
2427
|
+
const value = await response.arrayBuffer();
|
|
2428
|
+
if (value.byteLength > maxBytes) {
|
|
2429
|
+
throw new Error("drive_content_too_large");
|
|
2430
|
+
}
|
|
2431
|
+
return new TextDecoder().decode(value);
|
|
2432
|
+
}
|
|
2433
|
+
const reader = response.body.getReader();
|
|
2434
|
+
const chunks = [];
|
|
2435
|
+
let total = 0;
|
|
2436
|
+
while (true) {
|
|
2437
|
+
const { done, value } = await reader.read();
|
|
2438
|
+
if (done)
|
|
2439
|
+
break;
|
|
2440
|
+
total += value.byteLength;
|
|
2441
|
+
if (total > maxBytes) {
|
|
2442
|
+
await reader.cancel();
|
|
2443
|
+
throw new Error("drive_content_too_large");
|
|
2444
|
+
}
|
|
2445
|
+
chunks.push(value);
|
|
2446
|
+
}
|
|
2447
|
+
const joined = new Uint8Array(total);
|
|
2448
|
+
let offset = 0;
|
|
2449
|
+
for (const chunk of chunks) {
|
|
2450
|
+
joined.set(chunk, offset);
|
|
2451
|
+
offset += chunk.byteLength;
|
|
2452
|
+
}
|
|
2453
|
+
return new TextDecoder().decode(joined);
|
|
2454
|
+
}
|
|
1414
2455
|
function createGitHubRestClient(config) {
|
|
1415
2456
|
const fetchImpl = config.fetch ?? fetch;
|
|
1416
2457
|
const apiBaseUrl = (config.apiBaseUrl ?? GITHUB_REST_API_BASE_URL).replace(/\/$/, "");
|
|
1417
2458
|
const userAgent = config.userAgent ?? GITHUB_REST_USER_AGENT;
|
|
1418
2459
|
const maxRetries = config.maxRetries ?? DEFAULT_PROVIDER_RETRIES;
|
|
2460
|
+
async function requestWithMetadata(path, options) {
|
|
2461
|
+
return requestWithRetry(maxRetries, async () => {
|
|
2462
|
+
const response = await fetchImpl(`${apiBaseUrl}${path}`, {
|
|
2463
|
+
method: options.method,
|
|
2464
|
+
headers: {
|
|
2465
|
+
accept: "application/vnd.github+json",
|
|
2466
|
+
authorization: `Bearer ${config.token}`,
|
|
2467
|
+
"content-type": "application/json",
|
|
2468
|
+
"user-agent": userAgent,
|
|
2469
|
+
"x-github-api-version": "2022-11-28",
|
|
2470
|
+
},
|
|
2471
|
+
body: options.body
|
|
2472
|
+
? JSON.stringify(removeUndefined(options.body))
|
|
2473
|
+
: undefined,
|
|
2474
|
+
});
|
|
2475
|
+
if (!response.ok) {
|
|
2476
|
+
throw await providerError("github_rest", response);
|
|
2477
|
+
}
|
|
2478
|
+
return {
|
|
2479
|
+
data: (await response.json()),
|
|
2480
|
+
metadata: githubResponseMetadata(response.headers),
|
|
2481
|
+
};
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
1419
2484
|
return {
|
|
1420
2485
|
async request(path, options) {
|
|
1421
|
-
return
|
|
1422
|
-
const response = await fetchImpl(`${apiBaseUrl}${path}`, {
|
|
1423
|
-
method: options.method,
|
|
1424
|
-
headers: {
|
|
1425
|
-
accept: "application/vnd.github+json",
|
|
1426
|
-
authorization: `Bearer ${config.token}`,
|
|
1427
|
-
"content-type": "application/json",
|
|
1428
|
-
"user-agent": userAgent,
|
|
1429
|
-
"x-github-api-version": "2022-11-28",
|
|
1430
|
-
},
|
|
1431
|
-
body: options.body ? JSON.stringify(removeUndefined(options.body)) : undefined,
|
|
1432
|
-
});
|
|
1433
|
-
if (!response.ok) {
|
|
1434
|
-
throw await providerError("github_rest", response);
|
|
1435
|
-
}
|
|
1436
|
-
return response.json();
|
|
1437
|
-
});
|
|
2486
|
+
return (await requestWithMetadata(path, options)).data;
|
|
1438
2487
|
},
|
|
2488
|
+
requestWithMetadata,
|
|
1439
2489
|
};
|
|
1440
2490
|
}
|
|
2491
|
+
function githubResponseMetadata(headers) {
|
|
2492
|
+
const reset = parseOptionalIntegerHeader(headers.get("x-ratelimit-reset"));
|
|
2493
|
+
return {
|
|
2494
|
+
acceptedPermissions: parseAcceptedGitHubPermissions(headers.get("x-accepted-github-permissions")),
|
|
2495
|
+
rateLimit: {
|
|
2496
|
+
limit: parseOptionalIntegerHeader(headers.get("x-ratelimit-limit")),
|
|
2497
|
+
remaining: parseOptionalIntegerHeader(headers.get("x-ratelimit-remaining")),
|
|
2498
|
+
resetAt: reset === null ? null : new Date(reset * 1000).toISOString(),
|
|
2499
|
+
resource: headers.get("x-ratelimit-resource"),
|
|
2500
|
+
},
|
|
2501
|
+
};
|
|
2502
|
+
}
|
|
2503
|
+
function parseAcceptedGitHubPermissions(value) {
|
|
2504
|
+
if (!value)
|
|
2505
|
+
return [];
|
|
2506
|
+
return value
|
|
2507
|
+
.split(";")
|
|
2508
|
+
.map((permission) => permission.trim())
|
|
2509
|
+
.filter(Boolean)
|
|
2510
|
+
.sort();
|
|
2511
|
+
}
|
|
2512
|
+
function parseOptionalIntegerHeader(value) {
|
|
2513
|
+
if (!value)
|
|
2514
|
+
return null;
|
|
2515
|
+
const parsed = Number.parseInt(value, 10);
|
|
2516
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
2517
|
+
}
|
|
1441
2518
|
function createSlackWebClient(config) {
|
|
1442
2519
|
const fetchImpl = config.fetch ?? fetch;
|
|
1443
2520
|
const apiBaseUrl = (config.apiBaseUrl ?? SLACK_WEB_API_BASE_URL).replace(/\/$/, "");
|
|
@@ -1500,6 +2577,73 @@ function createLinearGraphqlClient(config) {
|
|
|
1500
2577
|
},
|
|
1501
2578
|
};
|
|
1502
2579
|
}
|
|
2580
|
+
function createJiraRestClient(config) {
|
|
2581
|
+
const fetchImpl = config.fetch ?? fetch;
|
|
2582
|
+
const apiBaseUrl = config.apiBaseUrl.replace(/\/$/, "");
|
|
2583
|
+
const maxRetries = config.maxRetries ?? DEFAULT_PROVIDER_RETRIES;
|
|
2584
|
+
const authorization = config.auth.type === "bearer"
|
|
2585
|
+
? `Bearer ${config.auth.token}`
|
|
2586
|
+
: `Basic ${Buffer.from(`${config.auth.email}:${config.auth.apiToken}`, "utf8").toString("base64")}`;
|
|
2587
|
+
return {
|
|
2588
|
+
async request(path, options) {
|
|
2589
|
+
return requestWithRetry(maxRetries, async () => {
|
|
2590
|
+
const response = await fetchImpl(`${apiBaseUrl}${path}`, {
|
|
2591
|
+
method: options.method,
|
|
2592
|
+
headers: {
|
|
2593
|
+
authorization,
|
|
2594
|
+
accept: "application/json",
|
|
2595
|
+
...(options.body ? { "content-type": "application/json" } : {}),
|
|
2596
|
+
},
|
|
2597
|
+
body: options.body
|
|
2598
|
+
? JSON.stringify(removeUndefined(options.body))
|
|
2599
|
+
: undefined,
|
|
2600
|
+
});
|
|
2601
|
+
if (!response.ok) {
|
|
2602
|
+
throw await providerError("jira_rest", response);
|
|
2603
|
+
}
|
|
2604
|
+
if (options.expectEmpty || response.status === 204) {
|
|
2605
|
+
return undefined;
|
|
2606
|
+
}
|
|
2607
|
+
return (await response.json());
|
|
2608
|
+
});
|
|
2609
|
+
},
|
|
2610
|
+
};
|
|
2611
|
+
}
|
|
2612
|
+
async function getJiraIssue(client, issueKey) {
|
|
2613
|
+
return client.request(`/issue/${encodeURIComponent(issueKey)}?fields=summary,status,project,priority,description`, { method: "GET" });
|
|
2614
|
+
}
|
|
2615
|
+
function jiraIssueResult(issue, config, actionPassId) {
|
|
2616
|
+
return {
|
|
2617
|
+
provider: "jira",
|
|
2618
|
+
kind: "issue",
|
|
2619
|
+
id: issue.id,
|
|
2620
|
+
issueKey: issue.key,
|
|
2621
|
+
projectKey: issue.fields.project?.key ?? issue.key.split("-", 1)[0],
|
|
2622
|
+
title: issue.fields.summary,
|
|
2623
|
+
status: issue.fields.status?.name ?? null,
|
|
2624
|
+
statusId: issue.fields.status?.id ?? null,
|
|
2625
|
+
priority: issue.fields.priority?.name ?? null,
|
|
2626
|
+
priorityId: issue.fields.priority?.id ?? null,
|
|
2627
|
+
url: jiraIssueUrl(config, issue.key),
|
|
2628
|
+
actionPassId,
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
function jiraIssueUrl(config, issueKey) {
|
|
2632
|
+
if (!config.siteUrl)
|
|
2633
|
+
return null;
|
|
2634
|
+
return `${config.siteUrl.replace(/\/$/, "")}/browse/${encodeURIComponent(issueKey)}`;
|
|
2635
|
+
}
|
|
2636
|
+
function jiraAdfDocument(text) {
|
|
2637
|
+
const paragraphs = text.split(/\r?\n/).map((line) => ({
|
|
2638
|
+
type: "paragraph",
|
|
2639
|
+
content: line.length > 0 ? [{ type: "text", text: line }] : [],
|
|
2640
|
+
}));
|
|
2641
|
+
return {
|
|
2642
|
+
type: "doc",
|
|
2643
|
+
version: 1,
|
|
2644
|
+
content: paragraphs.length > 0 ? paragraphs : [{ type: "paragraph" }],
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
1503
2647
|
class ProviderRequestError extends Error {
|
|
1504
2648
|
status;
|
|
1505
2649
|
category;
|
|
@@ -1514,9 +2658,13 @@ class ProviderRequestError extends Error {
|
|
|
1514
2658
|
}
|
|
1515
2659
|
async function providerError(prefix, response) {
|
|
1516
2660
|
const message = (await response.text()).slice(0, 500);
|
|
1517
|
-
const retryAfter =
|
|
2661
|
+
const retryAfter = providerRetryAfter(response, message);
|
|
2662
|
+
const githubRateLimited = response.status === 403 &&
|
|
2663
|
+
(response.headers.get("retry-after") !== null ||
|
|
2664
|
+
response.headers.get("x-ratelimit-remaining") === "0" ||
|
|
2665
|
+
/secondary rate limit|rate limit exceeded/i.test(message));
|
|
1518
2666
|
const category = response.status === 401 || response.status === 403
|
|
1519
|
-
?
|
|
2667
|
+
? githubRateLimited
|
|
1520
2668
|
? "rate_limited"
|
|
1521
2669
|
: "auth_error"
|
|
1522
2670
|
: response.status === 429
|
|
@@ -1526,6 +2674,23 @@ async function providerError(prefix, response) {
|
|
|
1526
2674
|
: "provider_error";
|
|
1527
2675
|
return new ProviderRequestError(`${prefix}_${category}_${response.status}:${message}`, response.status, category, retryAfter);
|
|
1528
2676
|
}
|
|
2677
|
+
function providerRetryAfter(response, message) {
|
|
2678
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
2679
|
+
if (retryAfter !== null)
|
|
2680
|
+
return retryAfter;
|
|
2681
|
+
if (response.headers.get("x-ratelimit-remaining") === "0") {
|
|
2682
|
+
const reset = parseOptionalIntegerHeader(response.headers.get("x-ratelimit-reset"));
|
|
2683
|
+
if (reset !== null) {
|
|
2684
|
+
return Math.max(0, reset * 1000 - Date.now());
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
if (response.status === 429 ||
|
|
2688
|
+
(response.status === 403 &&
|
|
2689
|
+
/secondary rate limit|rate limit exceeded/i.test(message))) {
|
|
2690
|
+
return 60_000;
|
|
2691
|
+
}
|
|
2692
|
+
return null;
|
|
2693
|
+
}
|
|
1529
2694
|
async function requestWithRetry(maxRetries, run) {
|
|
1530
2695
|
let attempt = 0;
|
|
1531
2696
|
while (true) {
|
|
@@ -1617,7 +2782,8 @@ function signAwsRequest(input) {
|
|
|
1617
2782
|
: {}),
|
|
1618
2783
|
});
|
|
1619
2784
|
const canonicalHeaders = Object.entries(headers)
|
|
1620
|
-
|
|
2785
|
+
// SigV4 requires code-point order; must match the signedHeaders sort below.
|
|
2786
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
1621
2787
|
.map(([key, value]) => `${key}:${value.trim()}\n`)
|
|
1622
2788
|
.join("");
|
|
1623
2789
|
const signedHeaders = Object.keys(headers).sort().join(";");
|
|
@@ -1726,6 +2892,204 @@ function requireString(value, reason) {
|
|
|
1726
2892
|
}
|
|
1727
2893
|
return text;
|
|
1728
2894
|
}
|
|
2895
|
+
function requirePositiveInteger(value, reason) {
|
|
2896
|
+
if (typeof value !== "number" ||
|
|
2897
|
+
!Number.isInteger(value) ||
|
|
2898
|
+
value <= 0) {
|
|
2899
|
+
throw new Error(reason);
|
|
2900
|
+
}
|
|
2901
|
+
return value;
|
|
2902
|
+
}
|
|
2903
|
+
function parseOptionalPositiveInteger(value, reason) {
|
|
2904
|
+
return value === undefined ? undefined : requirePositiveInteger(value, reason);
|
|
2905
|
+
}
|
|
2906
|
+
function requireEnumString(value, allowed, reason) {
|
|
2907
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
2908
|
+
throw new Error(reason);
|
|
2909
|
+
}
|
|
2910
|
+
return value;
|
|
2911
|
+
}
|
|
2912
|
+
function postgresClient(config) {
|
|
2913
|
+
return config.clientFactory
|
|
2914
|
+
? config.clientFactory(config.connectionString)
|
|
2915
|
+
: new Client({ connectionString: config.connectionString });
|
|
2916
|
+
}
|
|
2917
|
+
async function beginPostgresReadOnly(client, config) {
|
|
2918
|
+
const statementTimeout = positiveConfigInteger(config.statementTimeoutMs, 5_000);
|
|
2919
|
+
const lockTimeout = positiveConfigInteger(config.lockTimeoutMs, 1_000);
|
|
2920
|
+
const idleTimeout = positiveConfigInteger(config.idleTransactionTimeoutMs, 5_000);
|
|
2921
|
+
await client.query("BEGIN READ ONLY");
|
|
2922
|
+
await client.query(`SET LOCAL statement_timeout = '${statementTimeout}ms'`);
|
|
2923
|
+
await client.query(`SET LOCAL lock_timeout = '${lockTimeout}ms'`);
|
|
2924
|
+
await client.query(`SET LOCAL idle_in_transaction_session_timeout = '${idleTimeout}ms'`);
|
|
2925
|
+
}
|
|
2926
|
+
async function inspectPostgresSession(client, tables) {
|
|
2927
|
+
const identity = await client.query(`SELECT
|
|
2928
|
+
current_database() AS database,
|
|
2929
|
+
current_user AS role,
|
|
2930
|
+
current_setting('transaction_read_only') AS transaction_read_only,
|
|
2931
|
+
r.rolsuper,
|
|
2932
|
+
r.rolcreatedb,
|
|
2933
|
+
r.rolcreaterole,
|
|
2934
|
+
r.rolreplication,
|
|
2935
|
+
r.rolbypassrls
|
|
2936
|
+
FROM pg_roles r
|
|
2937
|
+
WHERE r.rolname = current_user`);
|
|
2938
|
+
const row = identity.rows[0];
|
|
2939
|
+
if (!row)
|
|
2940
|
+
throw new Error("postgres_role_metadata_missing");
|
|
2941
|
+
const tableResult = await client.query(`SELECT
|
|
2942
|
+
n.nspname || '.' || c.relname AS table_name,
|
|
2943
|
+
c.relrowsecurity,
|
|
2944
|
+
c.relforcerowsecurity,
|
|
2945
|
+
has_table_privilege(current_user, c.oid, 'SELECT') AS select_granted,
|
|
2946
|
+
(
|
|
2947
|
+
has_table_privilege(current_user, c.oid, 'INSERT') OR
|
|
2948
|
+
has_table_privilege(current_user, c.oid, 'UPDATE') OR
|
|
2949
|
+
has_table_privilege(current_user, c.oid, 'DELETE') OR
|
|
2950
|
+
has_table_privilege(current_user, c.oid, 'TRUNCATE') OR
|
|
2951
|
+
has_table_privilege(current_user, c.oid, 'REFERENCES') OR
|
|
2952
|
+
has_table_privilege(current_user, c.oid, 'TRIGGER')
|
|
2953
|
+
) AS write_granted,
|
|
2954
|
+
pg_get_userbyid(c.relowner) = current_user AS role_owns_table
|
|
2955
|
+
FROM pg_class c
|
|
2956
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
2957
|
+
WHERE n.nspname || '.' || c.relname = ANY($1::text[])
|
|
2958
|
+
ORDER BY table_name`, [tables]);
|
|
2959
|
+
const tableRows = tableResult.rows;
|
|
2960
|
+
const found = new Map(tableRows.map((table) => [table.table_name, table]));
|
|
2961
|
+
const tableSafety = tables.map((table) => {
|
|
2962
|
+
const metadata = found.get(table);
|
|
2963
|
+
if (!metadata)
|
|
2964
|
+
throw new Error(`postgres_table_metadata_missing:${table}`);
|
|
2965
|
+
return {
|
|
2966
|
+
name: table,
|
|
2967
|
+
selectGranted: metadata.select_granted,
|
|
2968
|
+
writeGranted: metadata.write_granted,
|
|
2969
|
+
rlsEnabled: metadata.relrowsecurity,
|
|
2970
|
+
rlsForced: metadata.relforcerowsecurity,
|
|
2971
|
+
roleOwnsTable: metadata.role_owns_table,
|
|
2972
|
+
};
|
|
2973
|
+
});
|
|
2974
|
+
return {
|
|
2975
|
+
database: row.database,
|
|
2976
|
+
role: row.role,
|
|
2977
|
+
transactionReadOnly: row.transaction_read_only === "on",
|
|
2978
|
+
roleLeastPrivilege: !row.rolsuper &&
|
|
2979
|
+
!row.rolcreatedb &&
|
|
2980
|
+
!row.rolcreaterole &&
|
|
2981
|
+
!row.rolreplication &&
|
|
2982
|
+
!row.rolbypassrls,
|
|
2983
|
+
tables: tableSafety,
|
|
2984
|
+
};
|
|
2985
|
+
}
|
|
2986
|
+
function enforcePostgresSafety(safety, config) {
|
|
2987
|
+
if (!safety.transactionReadOnly) {
|
|
2988
|
+
throw new Error("postgres_transaction_not_read_only");
|
|
2989
|
+
}
|
|
2990
|
+
if (!safety.roleLeastPrivilege) {
|
|
2991
|
+
throw new Error("postgres_role_not_least_privilege");
|
|
2992
|
+
}
|
|
2993
|
+
if (config.requireRls ?? true) {
|
|
2994
|
+
for (const table of safety.tables) {
|
|
2995
|
+
if (!table.selectGranted) {
|
|
2996
|
+
throw new Error(`postgres_select_not_granted:${table.name}`);
|
|
2997
|
+
}
|
|
2998
|
+
if (table.writeGranted) {
|
|
2999
|
+
throw new Error(`postgres_write_privilege_present:${table.name}`);
|
|
3000
|
+
}
|
|
3001
|
+
if (!table.rlsEnabled) {
|
|
3002
|
+
throw new Error(`postgres_rls_not_enabled:${table.name}`);
|
|
3003
|
+
}
|
|
3004
|
+
if (table.roleOwnsTable && !table.rlsForced) {
|
|
3005
|
+
throw new Error(`postgres_table_owner_bypasses_rls:${table.name}`);
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
function enforcePostgresQueryScope(analysis, parameterLength, maxRows, config) {
|
|
3011
|
+
if (analysis.parameterCount !== parameterLength) {
|
|
3012
|
+
throw new Error("postgres_parameter_count_mismatch");
|
|
3013
|
+
}
|
|
3014
|
+
if (analysis.tables.some((table) => !table.includes("."))) {
|
|
3015
|
+
throw new Error("postgres_table_must_be_schema_qualified");
|
|
3016
|
+
}
|
|
3017
|
+
if (analysis.tables.some((table) => !config.allowedTables.includes(table))) {
|
|
3018
|
+
throw new Error("postgres_table_not_allowed");
|
|
3019
|
+
}
|
|
3020
|
+
const allowedFunctions = config.allowedFunctions ?? [];
|
|
3021
|
+
if (analysis.functions.some((functionName) => !allowedFunctions.includes(functionName))) {
|
|
3022
|
+
throw new Error("postgres_function_not_allowed");
|
|
3023
|
+
}
|
|
3024
|
+
if (maxRows > (config.maxRows ?? 100)) {
|
|
3025
|
+
throw new Error("postgres_row_limit_exceeded");
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
function parsePostgresDatabaseResource(resource) {
|
|
3029
|
+
const match = /^postgres:database\/([^/]+)$/.exec(resource);
|
|
3030
|
+
if (!match)
|
|
3031
|
+
throw new Error("postgres_database_resource_invalid");
|
|
3032
|
+
return match[1];
|
|
3033
|
+
}
|
|
3034
|
+
function jsonSafePostgresRow(row) {
|
|
3035
|
+
return Object.fromEntries(Object.entries(row).map(([key, value]) => [key, jsonSafePostgresValue(value)]));
|
|
3036
|
+
}
|
|
3037
|
+
function jsonSafePostgresValue(value) {
|
|
3038
|
+
if (value === null ||
|
|
3039
|
+
typeof value === "string" ||
|
|
3040
|
+
typeof value === "boolean") {
|
|
3041
|
+
return value;
|
|
3042
|
+
}
|
|
3043
|
+
if (typeof value === "number") {
|
|
3044
|
+
return Number.isFinite(value) ? value : String(value);
|
|
3045
|
+
}
|
|
3046
|
+
if (typeof value === "bigint")
|
|
3047
|
+
return value.toString();
|
|
3048
|
+
if (value instanceof Date)
|
|
3049
|
+
return value.toISOString();
|
|
3050
|
+
if (Buffer.isBuffer(value))
|
|
3051
|
+
return value.toString("base64");
|
|
3052
|
+
if (Array.isArray(value))
|
|
3053
|
+
return value.map(jsonSafePostgresValue);
|
|
3054
|
+
if (value && typeof value === "object") {
|
|
3055
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [
|
|
3056
|
+
key,
|
|
3057
|
+
jsonSafePostgresValue(child),
|
|
3058
|
+
]));
|
|
3059
|
+
}
|
|
3060
|
+
return String(value);
|
|
3061
|
+
}
|
|
3062
|
+
function positiveConfigInteger(value, fallback) {
|
|
3063
|
+
return Number.isInteger(value) && (value ?? 0) > 0 ? value : fallback;
|
|
3064
|
+
}
|
|
3065
|
+
async function rollbackQuietly(client) {
|
|
3066
|
+
try {
|
|
3067
|
+
await client.query("ROLLBACK");
|
|
3068
|
+
}
|
|
3069
|
+
catch {
|
|
3070
|
+
// Preserve the original failure; rollback diagnostics may contain SQL text.
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
function postgresErrorReason(prefix, error) {
|
|
3074
|
+
if (error instanceof Error && /^postgres_[a-z0-9_.:-]+$/.test(error.message)) {
|
|
3075
|
+
return error.message;
|
|
3076
|
+
}
|
|
3077
|
+
if (error &&
|
|
3078
|
+
typeof error === "object" &&
|
|
3079
|
+
"code" in error &&
|
|
3080
|
+
typeof error.code === "string" &&
|
|
3081
|
+
/^[A-Z0-9]{5}$/.test(error.code)) {
|
|
3082
|
+
return `${prefix}:${error.code}`;
|
|
3083
|
+
}
|
|
3084
|
+
return prefix;
|
|
3085
|
+
}
|
|
3086
|
+
function getObject(value) {
|
|
3087
|
+
return value !== null &&
|
|
3088
|
+
typeof value === "object" &&
|
|
3089
|
+
!Array.isArray(value)
|
|
3090
|
+
? value
|
|
3091
|
+
: null;
|
|
3092
|
+
}
|
|
1729
3093
|
function issueIdentifier(payload) {
|
|
1730
3094
|
return (getString(payload.issueId) ??
|
|
1731
3095
|
getString(payload.issueKey) ??
|