@axtary/adapters 0.1.0 → 0.3.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/README.md +36 -6
- package/dist/index.d.ts +246 -25
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1359 -214
- package/dist/index.js.map +1 -1
- package/package.json +6 -3
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: "demo_only",
|
|
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: "demo_only",
|
|
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: "demo_only",
|
|
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",
|
|
@@ -349,19 +198,37 @@ export function getConnectorCapabilitiesByProvider(provider) {
|
|
|
349
198
|
return CONNECTOR_CAPABILITY_REGISTRY.filter((capability) => capability.provider === provider);
|
|
350
199
|
}
|
|
351
200
|
export async function buildConnectorReadinessReport(input = {}) {
|
|
352
|
-
|
|
201
|
+
// Scanner hygiene: the library never reads the ambient process environment — callers
|
|
202
|
+
// must pass the env map they want readiness assessed against, and the
|
|
203
|
+
// report fails closed without one.
|
|
204
|
+
const env = input.env;
|
|
205
|
+
if (!env) {
|
|
206
|
+
throw new Error("connector_readiness_env_required");
|
|
207
|
+
}
|
|
353
208
|
const providers = [
|
|
354
209
|
providerReadiness({
|
|
355
210
|
provider: "github",
|
|
356
211
|
mode: input.adapters?.github?.mode ?? "fake",
|
|
357
212
|
requiredEnv: input.adapters?.github?.mode === "rest"
|
|
358
213
|
? [input.adapters.github.tokenEnv ?? "GITHUB_TOKEN"]
|
|
359
|
-
:
|
|
214
|
+
: input.adapters?.github?.mode === "app"
|
|
215
|
+
? githubAppRequiredEnv({
|
|
216
|
+
appId: input.adapters.github.appId,
|
|
217
|
+
appIdEnv: input.adapters.github.appIdEnv,
|
|
218
|
+
installationId: input.adapters.github.installationId,
|
|
219
|
+
installationIdEnv: input.adapters.github.installationIdEnv,
|
|
220
|
+
privateKeyEnv: input.adapters.github.privateKeyEnv ?? "AXTARY_GITHUB_APP_PRIVATE_KEY",
|
|
221
|
+
})
|
|
222
|
+
: [],
|
|
360
223
|
env,
|
|
361
|
-
smokeCheck:
|
|
224
|
+
smokeCheck: input.adapters?.github?.mode === "app"
|
|
225
|
+
? "POST /app/installations/{id}/access_tokens"
|
|
226
|
+
: "GET /user + X-Accepted-GitHub-Permissions",
|
|
362
227
|
smokeCommand: "axtary smoke --config axtary.yml",
|
|
363
228
|
localSummary: "Fake GitHub adapter is active for deterministic local execution.",
|
|
364
|
-
readySummary:
|
|
229
|
+
readySummary: input.adapters?.github?.mode === "app"
|
|
230
|
+
? "GitHub App mode mints short-lived installation tokens from an env-backed private key."
|
|
231
|
+
: "GitHub REST mode has an env-backed token configured.",
|
|
365
232
|
}),
|
|
366
233
|
providerReadiness({
|
|
367
234
|
provider: "slack",
|
|
@@ -389,13 +256,68 @@ export async function buildConnectorReadinessReport(input = {}) {
|
|
|
389
256
|
}),
|
|
390
257
|
providerReadiness({
|
|
391
258
|
provider: "jira",
|
|
392
|
-
mode: "fake",
|
|
393
|
-
requiredEnv:
|
|
259
|
+
mode: input.adapters?.jira?.mode ?? "fake",
|
|
260
|
+
requiredEnv: input.adapters?.jira?.mode === "rest"
|
|
261
|
+
? input.adapters.jira.auth === "oauth"
|
|
262
|
+
? [
|
|
263
|
+
input.adapters.jira.tokenEnv ?? "JIRA_OAUTH_TOKEN",
|
|
264
|
+
...(input.adapters.jira.cloudId
|
|
265
|
+
? []
|
|
266
|
+
: [
|
|
267
|
+
input.adapters.jira.cloudIdEnv ??
|
|
268
|
+
"AXTARY_JIRA_CLOUD_ID",
|
|
269
|
+
]),
|
|
270
|
+
]
|
|
271
|
+
: [
|
|
272
|
+
input.adapters.jira.emailEnv ?? "JIRA_EMAIL",
|
|
273
|
+
input.adapters.jira.tokenEnv ?? "JIRA_API_TOKEN",
|
|
274
|
+
]
|
|
275
|
+
: [],
|
|
394
276
|
env,
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
277
|
+
configuredResources: [
|
|
278
|
+
...(input.adapters?.jira?.mode === "rest" &&
|
|
279
|
+
input.adapters?.jira?.cloudId
|
|
280
|
+
? [`cloud:${input.adapters.jira.cloudId}`]
|
|
281
|
+
: []),
|
|
282
|
+
...(input.adapters?.jira?.mode === "rest" &&
|
|
283
|
+
input.adapters?.jira?.siteUrl
|
|
284
|
+
? [`site:${input.adapters.jira.siteUrl}`]
|
|
285
|
+
: []),
|
|
286
|
+
],
|
|
287
|
+
smokeCheck: "GET /myself",
|
|
288
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
289
|
+
localSummary: "Fake Jira adapter is active for deterministic local execution.",
|
|
290
|
+
readySummary: input.adapters?.jira?.auth === "oauth"
|
|
291
|
+
? "Jira REST mode has an OAuth credential and cloud site configured."
|
|
292
|
+
: "Jira REST mode has an email and API token configured.",
|
|
293
|
+
}),
|
|
294
|
+
providerReadiness({
|
|
295
|
+
provider: "postgres",
|
|
296
|
+
mode: input.adapters?.postgres?.mode ?? "off",
|
|
297
|
+
requiredEnv: input.adapters?.postgres?.mode === "postgres"
|
|
298
|
+
? [input.adapters.postgres.dsnEnv ?? "AXTARY_POSTGRES_DSN"]
|
|
299
|
+
: [],
|
|
300
|
+
env,
|
|
301
|
+
configuredResources: input.adapters?.postgres?.allowedTables?.map((table) => `table:${table}`) ?? [],
|
|
302
|
+
smokeCheck: "current role/database, read-only state, and RLS metadata",
|
|
303
|
+
smokeCommand: "axtary smoke --config axtary.yml",
|
|
304
|
+
localSummary: "Postgres adapter is disabled until a DSN and explicit table scope are configured.",
|
|
305
|
+
readySummary: "Postgres native mode has an env-backed DSN; smoke still verifies least privilege, read-only transactions, and RLS.",
|
|
306
|
+
}),
|
|
307
|
+
providerReadiness({
|
|
308
|
+
provider: "drive",
|
|
309
|
+
mode: input.adapters?.drive?.mode ?? "off",
|
|
310
|
+
requiredEnv: input.adapters?.drive?.mode === "rest"
|
|
311
|
+
? [input.adapters.drive.tokenEnv ?? "AXTARY_DRIVE_ACCESS_TOKEN"]
|
|
312
|
+
: [],
|
|
313
|
+
env,
|
|
314
|
+
configuredResources: input.adapters?.drive?.selectedFileId
|
|
315
|
+
? [`file:${input.adapters.drive.selectedFileId}`]
|
|
316
|
+
: [],
|
|
317
|
+
smokeCheck: "selected-file metadata and provider isAppAuthorized state",
|
|
318
|
+
smokeCommand: "axtary doctor connectors --config axtary.yml",
|
|
319
|
+
localSummary: "Google Drive is disabled until REST mode and a local OAuth connection are configured.",
|
|
320
|
+
readySummary: "Google Drive REST mode has an env-backed token; local broker credentials can also satisfy runtime execution.",
|
|
399
321
|
}),
|
|
400
322
|
providerReadiness({
|
|
401
323
|
provider: "aws",
|
|
@@ -430,7 +352,7 @@ export async function buildConnectorReadinessReport(input = {}) {
|
|
|
430
352
|
localSummary: "GCP adapter is disabled in the active local config.",
|
|
431
353
|
readySummary: "GCP REST mode has an env-backed access token configured.",
|
|
432
354
|
}),
|
|
433
|
-
await docsReadiness(input),
|
|
355
|
+
await docsReadiness(input, env),
|
|
434
356
|
providerReadiness({
|
|
435
357
|
provider: "mcp",
|
|
436
358
|
mode: "local-wrapper",
|
|
@@ -500,7 +422,7 @@ function providerReadiness(input) {
|
|
|
500
422
|
: input.localSummary,
|
|
501
423
|
};
|
|
502
424
|
}
|
|
503
|
-
async function docsReadiness(input) {
|
|
425
|
+
async function docsReadiness(input, env) {
|
|
504
426
|
const docs = input.adapters?.docs;
|
|
505
427
|
const mode = docs?.mode ?? "local";
|
|
506
428
|
const roots = docs?.roots ?? [];
|
|
@@ -510,7 +432,7 @@ async function docsReadiness(input) {
|
|
|
510
432
|
provider: "docs",
|
|
511
433
|
mode: "off",
|
|
512
434
|
requiredEnv: [],
|
|
513
|
-
env
|
|
435
|
+
env,
|
|
514
436
|
smokeCheck: "configured roots exist",
|
|
515
437
|
smokeCommand: "axtary smoke --config axtary.yml",
|
|
516
438
|
localSummary: "Local docs connector is disabled in the active config.",
|
|
@@ -551,7 +473,7 @@ async function docsReadiness(input) {
|
|
|
551
473
|
provider: "docs",
|
|
552
474
|
mode: "local",
|
|
553
475
|
requiredEnv: [],
|
|
554
|
-
env
|
|
476
|
+
env,
|
|
555
477
|
configuredResources: [
|
|
556
478
|
`workspace:${docs?.workspace ?? "local"}`,
|
|
557
479
|
...roots.map((root) => `root:${root}`),
|
|
@@ -630,16 +552,19 @@ export function createFakeHandlers(state = createFakeAdapterState(), options = {
|
|
|
630
552
|
? wrapHandlersWithActionPassVerification(handlers, options.verification)
|
|
631
553
|
: handlers;
|
|
632
554
|
}
|
|
633
|
-
|
|
634
|
-
|
|
555
|
+
function createGitHubRestHandlersRaw(config) {
|
|
556
|
+
return {
|
|
635
557
|
"github.contents.read": createGitHubRestContentReadHandler(config),
|
|
636
558
|
"github.pull_requests.create": createGitHubRestPullRequestHandler(config),
|
|
637
559
|
"github.branches.create": createGitHubRestBranchCreateHandler(config),
|
|
638
560
|
"github.contents.write": createGitHubRestContentWriteHandler(config),
|
|
561
|
+
"github.pull_request_review_comments.create": createGitHubRestReviewCommentHandler(config),
|
|
562
|
+
"github.check_runs.create": createGitHubRestCheckRunHandler(config),
|
|
563
|
+
"github.issue_comments.create": createGitHubRestIssueCommentHandler(config),
|
|
639
564
|
};
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
565
|
+
}
|
|
566
|
+
export function createGitHubRestHandlers(config, options = {}) {
|
|
567
|
+
return createNativeConnectorHandlers(GITHUB_NATIVE_CONNECTOR, config, options);
|
|
643
568
|
}
|
|
644
569
|
export function createSlackWebHandlers(config, options = {}) {
|
|
645
570
|
const handlers = {
|
|
@@ -649,15 +574,282 @@ export function createSlackWebHandlers(config, options = {}) {
|
|
|
649
574
|
? wrapHandlersWithActionPassVerification(handlers, options.verification)
|
|
650
575
|
: handlers;
|
|
651
576
|
}
|
|
652
|
-
|
|
653
|
-
|
|
577
|
+
function createLinearGraphqlHandlersRaw(config) {
|
|
578
|
+
return {
|
|
654
579
|
"linear.issues.read": createLinearIssueReadHandler(config),
|
|
655
580
|
"linear.comments.create": createLinearCommentCreateHandler(config),
|
|
656
581
|
"linear.issues.update": createLinearIssueUpdateHandler(config),
|
|
657
582
|
};
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
583
|
+
}
|
|
584
|
+
export const LINEAR_NATIVE_CONNECTOR = {
|
|
585
|
+
...LINEAR_NATIVE_CONNECTOR_GOVERNANCE,
|
|
586
|
+
createHandlers: createLinearGraphqlHandlersRaw,
|
|
587
|
+
smoke: smokeLinearGraphqlCredentials,
|
|
588
|
+
doctor: async (config, context) => {
|
|
589
|
+
const credential = await context.resolveCredential?.("linear");
|
|
590
|
+
const token = credential?.token ?? context.env[config.tokenEnv];
|
|
591
|
+
return {
|
|
592
|
+
provider: "linear",
|
|
593
|
+
mode: config.mode,
|
|
594
|
+
activeMode: "graphql",
|
|
595
|
+
capability: config.mode === "graphql" ? "real-write" : "fake",
|
|
596
|
+
requiredEnv: config.mode === "graphql" && !token ? [config.tokenEnv] : [],
|
|
597
|
+
requiredScopes: config.mode === "graphql"
|
|
598
|
+
? [...LINEAR_NATIVE_CONNECTOR_GOVERNANCE.requiredScopes]
|
|
599
|
+
: [],
|
|
600
|
+
smokeCheck: config.mode === "graphql"
|
|
601
|
+
? LINEAR_NATIVE_CONNECTOR_GOVERNANCE.smokeCheck
|
|
602
|
+
: null,
|
|
603
|
+
runSmoke: config.mode === "graphql"
|
|
604
|
+
? () => smokeLinearGraphqlCredentials({
|
|
605
|
+
token: token ?? "",
|
|
606
|
+
apiUrl: config.apiUrl,
|
|
607
|
+
fetch: context.fetch,
|
|
608
|
+
maxRetries: 0,
|
|
609
|
+
})
|
|
610
|
+
: undefined,
|
|
611
|
+
};
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
export const GITHUB_NATIVE_CONNECTOR = {
|
|
615
|
+
...GITHUB_NATIVE_CONNECTOR_GOVERNANCE,
|
|
616
|
+
createHandlers: createGitHubRestHandlersRaw,
|
|
617
|
+
smoke: smokeGitHubRestCredentials,
|
|
618
|
+
doctor: async (config, context) => {
|
|
619
|
+
const credential = await context.resolveCredential?.("github");
|
|
620
|
+
const restToken = credential?.token ?? context.env[config.tokenEnv];
|
|
621
|
+
const appRequiredEnv = config.mode === "app"
|
|
622
|
+
? githubAppRequiredEnv({
|
|
623
|
+
appId: config.appId,
|
|
624
|
+
appIdEnv: config.appIdEnv,
|
|
625
|
+
installationId: config.installationId,
|
|
626
|
+
installationIdEnv: config.installationIdEnv,
|
|
627
|
+
privateKeyEnv: config.privateKeyEnv,
|
|
628
|
+
})
|
|
629
|
+
: [];
|
|
630
|
+
const missingEnv = config.mode === "rest"
|
|
631
|
+
? restToken
|
|
632
|
+
? []
|
|
633
|
+
: [config.tokenEnv]
|
|
634
|
+
: config.mode === "app"
|
|
635
|
+
? appRequiredEnv.filter((name) => !context.env[name])
|
|
636
|
+
: [];
|
|
637
|
+
return {
|
|
638
|
+
provider: "github",
|
|
639
|
+
mode: config.mode,
|
|
640
|
+
activeMode: config.mode === "app" ? "app" : "rest",
|
|
641
|
+
capability: config.mode === "app" || config.mode === "rest"
|
|
642
|
+
? "real-write"
|
|
643
|
+
: "fake",
|
|
644
|
+
requiredEnv: missingEnv,
|
|
645
|
+
requiredScopes: config.mode === "app" || config.mode === "rest"
|
|
646
|
+
? [...GITHUB_NATIVE_CONNECTOR_GOVERNANCE.requiredScopes]
|
|
647
|
+
: [],
|
|
648
|
+
smokeCheck: config.mode === "app" || config.mode === "rest"
|
|
649
|
+
? GITHUB_NATIVE_CONNECTOR_GOVERNANCE.smokeCheck
|
|
650
|
+
: null,
|
|
651
|
+
runSmoke: config.mode === "rest"
|
|
652
|
+
? () => smokeGitHubRestCredentials({
|
|
653
|
+
token: restToken ?? "",
|
|
654
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
655
|
+
userAgent: config.userAgent,
|
|
656
|
+
fetch: context.fetch,
|
|
657
|
+
maxRetries: 0,
|
|
658
|
+
})
|
|
659
|
+
: config.mode === "app" && missingEnv.length === 0
|
|
660
|
+
? () => {
|
|
661
|
+
const resolved = resolveGithubAppCredentials({
|
|
662
|
+
appId: config.appId,
|
|
663
|
+
appIdEnv: config.appIdEnv,
|
|
664
|
+
installationId: config.installationId,
|
|
665
|
+
installationIdEnv: config.installationIdEnv,
|
|
666
|
+
privateKeyEnv: config.privateKeyEnv,
|
|
667
|
+
}, context.env);
|
|
668
|
+
return smokeGithubAppCredentials({
|
|
669
|
+
...resolved,
|
|
670
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
671
|
+
userAgent: config.userAgent,
|
|
672
|
+
fetch: context.fetch,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
: undefined,
|
|
676
|
+
};
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
export function createLinearGraphqlHandlers(config, options = {}) {
|
|
680
|
+
return createNativeConnectorHandlers(LINEAR_NATIVE_CONNECTOR, config, options);
|
|
681
|
+
}
|
|
682
|
+
function createJiraRestHandlersRaw(config) {
|
|
683
|
+
return {
|
|
684
|
+
"jira.issues.read": createJiraIssueReadHandler(config),
|
|
685
|
+
"jira.comments.create": createJiraCommentCreateHandler(config),
|
|
686
|
+
"jira.issues.update": createJiraIssueUpdateHandler(config),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
export const JIRA_NATIVE_CONNECTOR = {
|
|
690
|
+
...JIRA_NATIVE_CONNECTOR_GOVERNANCE,
|
|
691
|
+
createHandlers: createJiraRestHandlersRaw,
|
|
692
|
+
smoke: smokeJiraRestCredentials,
|
|
693
|
+
doctor: async (config, context) => {
|
|
694
|
+
const credential = await context.resolveCredential?.("jira");
|
|
695
|
+
const cloudId = credential?.cloudId ??
|
|
696
|
+
config.cloudId ??
|
|
697
|
+
context.env[config.cloudIdEnv];
|
|
698
|
+
const siteUrl = credential?.siteUrl ?? config.siteUrl;
|
|
699
|
+
const token = credential?.token ?? context.env[config.tokenEnv];
|
|
700
|
+
const email = credential?.email ?? context.env[config.emailEnv];
|
|
701
|
+
const missingEnv = config.mode !== "rest"
|
|
702
|
+
? []
|
|
703
|
+
: config.auth === "oauth"
|
|
704
|
+
? [
|
|
705
|
+
...(!token ? [config.tokenEnv] : []),
|
|
706
|
+
...(!cloudId ? [config.cloudIdEnv] : []),
|
|
707
|
+
]
|
|
708
|
+
: [
|
|
709
|
+
...(!email ? [config.emailEnv] : []),
|
|
710
|
+
...(!token ? [config.tokenEnv] : []),
|
|
711
|
+
];
|
|
712
|
+
const auth = config.auth === "oauth"
|
|
713
|
+
? { type: "bearer", token: token ?? "" }
|
|
714
|
+
: { type: "basic", email: email ?? "", apiToken: token ?? "" };
|
|
715
|
+
return {
|
|
716
|
+
provider: "jira",
|
|
717
|
+
mode: config.mode,
|
|
718
|
+
activeMode: "rest",
|
|
719
|
+
capability: config.mode === "rest" ? "real-write" : "fake",
|
|
720
|
+
requiredEnv: missingEnv,
|
|
721
|
+
requiredScopes: config.mode === "rest"
|
|
722
|
+
? [...JIRA_NATIVE_CONNECTOR_GOVERNANCE.requiredScopes]
|
|
723
|
+
: [],
|
|
724
|
+
smokeCheck: config.mode === "rest"
|
|
725
|
+
? JIRA_NATIVE_CONNECTOR_GOVERNANCE.smokeCheck
|
|
726
|
+
: null,
|
|
727
|
+
runSmoke: config.mode === "rest"
|
|
728
|
+
? () => smokeJiraRestCredentials({
|
|
729
|
+
auth,
|
|
730
|
+
apiBaseUrl: auth.type === "bearer"
|
|
731
|
+
? `https://api.atlassian.com/ex/jira/${encodeURIComponent(cloudId ?? "")}/rest/api/3`
|
|
732
|
+
: `${siteUrl.replace(/\/$/, "")}/rest/api/3`,
|
|
733
|
+
siteUrl,
|
|
734
|
+
fetch: context.fetch,
|
|
735
|
+
maxRetries: 0,
|
|
736
|
+
})
|
|
737
|
+
: undefined,
|
|
738
|
+
};
|
|
739
|
+
},
|
|
740
|
+
};
|
|
741
|
+
export function createJiraRestHandlers(config, options = {}) {
|
|
742
|
+
return createNativeConnectorHandlers(JIRA_NATIVE_CONNECTOR, config, options);
|
|
743
|
+
}
|
|
744
|
+
function createPostgresNativeHandlersRaw(config) {
|
|
745
|
+
return {
|
|
746
|
+
"postgres.query.select": createPostgresSelectHandler(config),
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
function createDriveRestHandlersRaw(config) {
|
|
750
|
+
return {
|
|
751
|
+
"drive.files.read": createDriveFileReadHandler(config),
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
export const POSTGRES_NATIVE_CONNECTOR = {
|
|
755
|
+
...POSTGRES_NATIVE_CONNECTOR_GOVERNANCE,
|
|
756
|
+
createHandlers: createPostgresNativeHandlersRaw,
|
|
757
|
+
smoke: smokePostgresCredentials,
|
|
758
|
+
doctor: async (config, context) => {
|
|
759
|
+
const connectionString = context.env[config.dsnEnv];
|
|
760
|
+
return {
|
|
761
|
+
provider: "postgres",
|
|
762
|
+
mode: config.mode,
|
|
763
|
+
activeMode: "postgres",
|
|
764
|
+
capability: config.mode === "postgres" ? "read-only" : "off",
|
|
765
|
+
requiredEnv: config.mode === "postgres" && !connectionString ? [config.dsnEnv] : [],
|
|
766
|
+
requiredScopes: config.mode === "postgres"
|
|
767
|
+
? [...POSTGRES_NATIVE_CONNECTOR_GOVERNANCE.requiredScopes]
|
|
768
|
+
: [],
|
|
769
|
+
smokeCheck: config.mode === "postgres"
|
|
770
|
+
? POSTGRES_NATIVE_CONNECTOR_GOVERNANCE.smokeCheck
|
|
771
|
+
: null,
|
|
772
|
+
runSmoke: config.mode === "postgres" && connectionString
|
|
773
|
+
? () => smokePostgresCredentials({
|
|
774
|
+
connectionString,
|
|
775
|
+
allowedTables: config.allowedTables,
|
|
776
|
+
allowedFunctions: config.allowedFunctions,
|
|
777
|
+
requireRls: config.requireRls,
|
|
778
|
+
maxRows: config.maxRows,
|
|
779
|
+
statementTimeoutMs: config.statementTimeoutMs,
|
|
780
|
+
lockTimeoutMs: config.lockTimeoutMs,
|
|
781
|
+
idleTransactionTimeoutMs: config.idleTransactionTimeoutMs,
|
|
782
|
+
})
|
|
783
|
+
: undefined,
|
|
784
|
+
};
|
|
785
|
+
},
|
|
786
|
+
};
|
|
787
|
+
export const DRIVE_NATIVE_CONNECTOR = {
|
|
788
|
+
...DRIVE_NATIVE_CONNECTOR_GOVERNANCE,
|
|
789
|
+
createHandlers: createDriveRestHandlersRaw,
|
|
790
|
+
smoke: smokeDriveRestCredentials,
|
|
791
|
+
doctor: async (config, context) => {
|
|
792
|
+
const credential = await context.resolveCredential?.("drive");
|
|
793
|
+
const accessToken = credential?.token ?? context.env[config.tokenEnv];
|
|
794
|
+
const selectedFileId = credential?.selectedFileId ?? config.selectedFileId;
|
|
795
|
+
const missing = [];
|
|
796
|
+
if (config.mode === "rest" && !accessToken) {
|
|
797
|
+
missing.push(`axtary connect drive or ${config.tokenEnv}`);
|
|
798
|
+
}
|
|
799
|
+
if (config.mode === "rest" && !selectedFileId) {
|
|
800
|
+
missing.push("Picker-selected file id");
|
|
801
|
+
}
|
|
802
|
+
return {
|
|
803
|
+
provider: "drive",
|
|
804
|
+
mode: config.mode,
|
|
805
|
+
activeMode: "rest",
|
|
806
|
+
capability: config.mode === "rest" ? "read-only" : "off",
|
|
807
|
+
requiredEnv: missing,
|
|
808
|
+
requiredScopes: config.mode === "rest"
|
|
809
|
+
? [...DRIVE_NATIVE_CONNECTOR_GOVERNANCE.requiredScopes]
|
|
810
|
+
: [],
|
|
811
|
+
smokeCheck: config.mode === "rest"
|
|
812
|
+
? DRIVE_NATIVE_CONNECTOR_GOVERNANCE.smokeCheck
|
|
813
|
+
: null,
|
|
814
|
+
runSmoke: config.mode === "rest" && accessToken && selectedFileId
|
|
815
|
+
? () => smokeDriveRestCredentials({
|
|
816
|
+
accessToken,
|
|
817
|
+
selectedFileId,
|
|
818
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
819
|
+
allowedMimeTypes: config.allowedMimeTypes,
|
|
820
|
+
maxReadBytes: config.maxReadBytes,
|
|
821
|
+
fetch: context.fetch,
|
|
822
|
+
maxRetries: 0,
|
|
823
|
+
})
|
|
824
|
+
: undefined,
|
|
825
|
+
};
|
|
826
|
+
},
|
|
827
|
+
};
|
|
828
|
+
export function createPostgresNativeHandlers(config, options = {}) {
|
|
829
|
+
return createNativeConnectorHandlers(POSTGRES_NATIVE_CONNECTOR, config, options);
|
|
830
|
+
}
|
|
831
|
+
export function createDriveRestHandlers(config, options = {}) {
|
|
832
|
+
return createNativeConnectorHandlers(DRIVE_NATIVE_CONNECTOR, config, options);
|
|
833
|
+
}
|
|
834
|
+
export const NATIVE_CONNECTOR_DESCRIPTORS = [
|
|
835
|
+
GITHUB_NATIVE_CONNECTOR,
|
|
836
|
+
LINEAR_NATIVE_CONNECTOR,
|
|
837
|
+
JIRA_NATIVE_CONNECTOR,
|
|
838
|
+
POSTGRES_NATIVE_CONNECTOR,
|
|
839
|
+
DRIVE_NATIVE_CONNECTOR,
|
|
840
|
+
];
|
|
841
|
+
export async function buildNativeConnectorDoctorPlans(input) {
|
|
842
|
+
const plans = [
|
|
843
|
+
LINEAR_NATIVE_CONNECTOR.doctor(input.configs.linear, input.context),
|
|
844
|
+
JIRA_NATIVE_CONNECTOR.doctor(input.configs.jira, input.context),
|
|
845
|
+
];
|
|
846
|
+
if (input.configs.postgres) {
|
|
847
|
+
plans.push(POSTGRES_NATIVE_CONNECTOR.doctor(input.configs.postgres, input.context));
|
|
848
|
+
}
|
|
849
|
+
if (input.configs.drive) {
|
|
850
|
+
plans.push(DRIVE_NATIVE_CONNECTOR.doctor(input.configs.drive, input.context));
|
|
851
|
+
}
|
|
852
|
+
return Promise.all(plans);
|
|
661
853
|
}
|
|
662
854
|
export function createAwsRestHandlers(config, options = {}) {
|
|
663
855
|
const handlers = {
|
|
@@ -704,9 +896,94 @@ export async function smokeLocalDocsRoots(config) {
|
|
|
704
896
|
},
|
|
705
897
|
};
|
|
706
898
|
}
|
|
899
|
+
/**
|
|
900
|
+
* Env var names that must be set for app mode given the config. A literal
|
|
901
|
+
* satisfies its slot, so its env name drops out of the requirement. Used for
|
|
902
|
+
* readiness/doctor reporting (which env names are missing) — never values.
|
|
903
|
+
*/
|
|
904
|
+
export function githubAppRequiredEnv(config) {
|
|
905
|
+
const names = [config.privateKeyEnv];
|
|
906
|
+
if (!config.appId && config.appIdEnv)
|
|
907
|
+
names.push(config.appIdEnv);
|
|
908
|
+
if (!config.installationId && config.installationIdEnv)
|
|
909
|
+
names.push(config.installationIdEnv);
|
|
910
|
+
return names;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Resolve effective App ID, Installation ID, and private key from config + env
|
|
914
|
+
* (literal wins over env name). Throws a descriptive, value-free error naming
|
|
915
|
+
* the missing literal or env var.
|
|
916
|
+
*/
|
|
917
|
+
export function resolveGithubAppCredentials(config, env) {
|
|
918
|
+
const appId = config.appId ?? (config.appIdEnv ? env[config.appIdEnv] : undefined);
|
|
919
|
+
const installationId = config.installationId ?? (config.installationIdEnv ? env[config.installationIdEnv] : undefined);
|
|
920
|
+
const privateKey = env[config.privateKeyEnv];
|
|
921
|
+
if (!privateKey) {
|
|
922
|
+
throw new Error(`missing_github_app_private_key_env:${config.privateKeyEnv}`);
|
|
923
|
+
}
|
|
924
|
+
if (!appId) {
|
|
925
|
+
throw new Error(config.appIdEnv ? `missing_github_app_id_env:${config.appIdEnv}` : "missing_github_app_id");
|
|
926
|
+
}
|
|
927
|
+
if (!installationId) {
|
|
928
|
+
throw new Error(config.installationIdEnv
|
|
929
|
+
? `missing_github_installation_id_env:${config.installationIdEnv}`
|
|
930
|
+
: "missing_github_installation_id");
|
|
931
|
+
}
|
|
932
|
+
return { appId, installationId, privateKey };
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Mint a short-lived GitHub App installation token. Builds an RS256 app JWT
|
|
936
|
+
* (iss=appId, ~9 min lifetime, iat backdated 60s for clock skew) and exchanges
|
|
937
|
+
* it at POST /app/installations/{id}/access_tokens. The returned token is
|
|
938
|
+
* fine-grained to the App's installed permissions and expires in ~1h — the
|
|
939
|
+
* credential the proxy actually hands to provider calls. The PEM private key is
|
|
940
|
+
* never logged; on a malformed key we throw before any network call.
|
|
941
|
+
*/
|
|
942
|
+
export async function mintGithubInstallationToken(input) {
|
|
943
|
+
const fetchImpl = input.fetch ?? fetch;
|
|
944
|
+
const apiBaseUrl = (input.apiBaseUrl ?? GITHUB_REST_API_BASE_URL).replace(/\/$/, "");
|
|
945
|
+
const userAgent = input.userAgent ?? GITHUB_REST_USER_AGENT;
|
|
946
|
+
let signingKey;
|
|
947
|
+
try {
|
|
948
|
+
// createPrivateKey accepts both PKCS#1 ("BEGIN RSA PRIVATE KEY", GitHub's
|
|
949
|
+
// download format) and PKCS#8 ("BEGIN PRIVATE KEY"); jose.importPKCS8 would
|
|
950
|
+
// reject the former. jose's SignJWT.sign accepts a Node KeyObject.
|
|
951
|
+
signingKey = createPrivateKey(input.privateKey);
|
|
952
|
+
}
|
|
953
|
+
catch {
|
|
954
|
+
throw new ProviderRequestError("github_app_private_key_invalid", 0, "auth_error");
|
|
955
|
+
}
|
|
956
|
+
const now = Math.floor(Date.now() / 1000);
|
|
957
|
+
const appJwt = await new SignJWT({})
|
|
958
|
+
.setProtectedHeader({ alg: "RS256", typ: "JWT" })
|
|
959
|
+
.setIssuer(input.appId)
|
|
960
|
+
.setIssuedAt(now - 60)
|
|
961
|
+
.setExpirationTime(now + 540)
|
|
962
|
+
.sign(signingKey);
|
|
963
|
+
const response = await fetchImpl(`${apiBaseUrl}/app/installations/${encodeURIComponent(input.installationId)}/access_tokens`, {
|
|
964
|
+
method: "POST",
|
|
965
|
+
headers: {
|
|
966
|
+
accept: "application/vnd.github+json",
|
|
967
|
+
authorization: `Bearer ${appJwt}`,
|
|
968
|
+
"user-agent": userAgent,
|
|
969
|
+
"x-github-api-version": "2022-11-28",
|
|
970
|
+
},
|
|
971
|
+
});
|
|
972
|
+
if (!response.ok) {
|
|
973
|
+
// 401 => bad private key / app id; 404 => bad installation id or app not
|
|
974
|
+
// installed on the repo. providerError keeps the body bounded and category-tagged.
|
|
975
|
+
throw await providerError("github_app", response);
|
|
976
|
+
}
|
|
977
|
+
const parsed = (await response.json());
|
|
978
|
+
if (!parsed.token) {
|
|
979
|
+
throw new ProviderRequestError("github_app_token_missing", response.status, "provider_error");
|
|
980
|
+
}
|
|
981
|
+
return { token: parsed.token, expiresAt: parsed.expires_at ?? "" };
|
|
982
|
+
}
|
|
707
983
|
export async function smokeGitHubRestCredentials(config) {
|
|
708
984
|
const client = createGitHubRestClient(config);
|
|
709
|
-
const
|
|
985
|
+
const response = await client.requestWithMetadata("/user", { method: "GET" });
|
|
986
|
+
const user = response.data;
|
|
710
987
|
return {
|
|
711
988
|
provider: "github",
|
|
712
989
|
ok: true,
|
|
@@ -714,6 +991,25 @@ export async function smokeGitHubRestCredentials(config) {
|
|
|
714
991
|
details: {
|
|
715
992
|
login: user.login,
|
|
716
993
|
id: user.id,
|
|
994
|
+
acceptedPermissions: response.metadata.acceptedPermissions,
|
|
995
|
+
rateLimit: response.metadata.rateLimit,
|
|
996
|
+
},
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
export async function smokeGithubAppCredentials(input) {
|
|
1000
|
+
// Minting proves the App id, private key, and installation id all work
|
|
1001
|
+
// together. The token is discarded — no repo state is touched.
|
|
1002
|
+
const minted = await mintGithubInstallationToken(input);
|
|
1003
|
+
return {
|
|
1004
|
+
provider: "github",
|
|
1005
|
+
ok: true,
|
|
1006
|
+
summary: minted.expiresAt
|
|
1007
|
+
? `installation token minted (expires ${minted.expiresAt})`
|
|
1008
|
+
: "installation token minted",
|
|
1009
|
+
details: {
|
|
1010
|
+
appId: input.appId,
|
|
1011
|
+
installationId: input.installationId,
|
|
1012
|
+
expiresAt: minted.expiresAt || null,
|
|
717
1013
|
},
|
|
718
1014
|
};
|
|
719
1015
|
}
|
|
@@ -754,6 +1050,83 @@ export async function smokeLinearGraphqlCredentials(config) {
|
|
|
754
1050
|
},
|
|
755
1051
|
};
|
|
756
1052
|
}
|
|
1053
|
+
export async function smokeJiraRestCredentials(config) {
|
|
1054
|
+
const client = createJiraRestClient(config);
|
|
1055
|
+
const myself = await client.request("/myself", {
|
|
1056
|
+
method: "GET",
|
|
1057
|
+
});
|
|
1058
|
+
return {
|
|
1059
|
+
provider: "jira",
|
|
1060
|
+
ok: true,
|
|
1061
|
+
summary: `user: ${myself.displayName}`,
|
|
1062
|
+
details: {
|
|
1063
|
+
accountId: myself.accountId,
|
|
1064
|
+
displayName: myself.displayName,
|
|
1065
|
+
emailAddress: myself.emailAddress ?? null,
|
|
1066
|
+
siteUrl: config.siteUrl ?? null,
|
|
1067
|
+
},
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
export async function smokePostgresCredentials(config) {
|
|
1071
|
+
if (config.allowedTables.length === 0) {
|
|
1072
|
+
throw new Error("postgres_allowed_tables_required");
|
|
1073
|
+
}
|
|
1074
|
+
const client = postgresClient(config);
|
|
1075
|
+
await client.connect();
|
|
1076
|
+
try {
|
|
1077
|
+
await beginPostgresReadOnly(client, config);
|
|
1078
|
+
const safety = await inspectPostgresSession(client, config.allowedTables);
|
|
1079
|
+
enforcePostgresSafety(safety, config);
|
|
1080
|
+
await client.query("ROLLBACK");
|
|
1081
|
+
return {
|
|
1082
|
+
provider: "postgres",
|
|
1083
|
+
ok: true,
|
|
1084
|
+
summary: `database: ${safety.database}, role: ${safety.role}, read-only: ${safety.transactionReadOnly}`,
|
|
1085
|
+
details: {
|
|
1086
|
+
database: safety.database,
|
|
1087
|
+
role: safety.role,
|
|
1088
|
+
transactionReadOnly: safety.transactionReadOnly,
|
|
1089
|
+
roleLeastPrivilege: safety.roleLeastPrivilege,
|
|
1090
|
+
tables: safety.tables.map((table) => ({
|
|
1091
|
+
name: table.name,
|
|
1092
|
+
selectGranted: table.selectGranted,
|
|
1093
|
+
writeGranted: table.writeGranted,
|
|
1094
|
+
rlsEnabled: table.rlsEnabled,
|
|
1095
|
+
rlsForced: table.rlsForced,
|
|
1096
|
+
roleOwnsTable: table.roleOwnsTable,
|
|
1097
|
+
})),
|
|
1098
|
+
},
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
catch (error) {
|
|
1102
|
+
await rollbackQuietly(client);
|
|
1103
|
+
throw new Error(postgresErrorReason("postgres_smoke_failed", error));
|
|
1104
|
+
}
|
|
1105
|
+
finally {
|
|
1106
|
+
await client.end();
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
export async function smokeDriveRestCredentials(config) {
|
|
1110
|
+
if (!config.selectedFileId) {
|
|
1111
|
+
throw new Error("drive_selected_file_required");
|
|
1112
|
+
}
|
|
1113
|
+
const metadata = await getDriveFileMetadata(config, config.selectedFileId);
|
|
1114
|
+
if (metadata.isAppAuthorized !== true) {
|
|
1115
|
+
throw new Error("drive_file_not_authorized");
|
|
1116
|
+
}
|
|
1117
|
+
return {
|
|
1118
|
+
provider: "drive",
|
|
1119
|
+
ok: true,
|
|
1120
|
+
summary: `selected file: ${metadata.name}`,
|
|
1121
|
+
details: {
|
|
1122
|
+
fileId: metadata.id,
|
|
1123
|
+
title: metadata.name,
|
|
1124
|
+
mimeType: metadata.mimeType,
|
|
1125
|
+
appAuthorized: metadata.isAppAuthorized,
|
|
1126
|
+
scope: "https://www.googleapis.com/auth/drive.file",
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
757
1130
|
export async function smokeAwsRestCredentials(config) {
|
|
758
1131
|
const client = createAwsClient(config);
|
|
759
1132
|
const identity = await getAwsCallerIdentity(client);
|
|
@@ -800,24 +1173,38 @@ export async function smokeGcpRestCredentials(config) {
|
|
|
800
1173
|
};
|
|
801
1174
|
}
|
|
802
1175
|
export function wrapHandlersWithActionPassVerification(handlers, config) {
|
|
1176
|
+
const verificationConfig = {
|
|
1177
|
+
...config,
|
|
1178
|
+
dpopReplayStore: config.dpopReplayStore ?? new InMemoryDpopReplayStore(),
|
|
1179
|
+
};
|
|
803
1180
|
return Object.fromEntries(Object.entries(handlers).map(([tool, handler]) => [
|
|
804
1181
|
tool,
|
|
805
|
-
createActionPassVerifiedHandler(handler,
|
|
1182
|
+
createActionPassVerifiedHandler(handler, verificationConfig),
|
|
806
1183
|
]));
|
|
807
1184
|
}
|
|
808
1185
|
export function createActionPassVerifiedHandler(handler, config) {
|
|
1186
|
+
const dpopReplayStore = config.dpopReplayStore ?? new InMemoryDpopReplayStore();
|
|
809
1187
|
return async (request) => {
|
|
810
1188
|
if (!request.actionPass) {
|
|
811
1189
|
throw new Error("adapter_actionpass_required");
|
|
812
1190
|
}
|
|
1191
|
+
const target = actionPassDpopTarget(request.action);
|
|
813
1192
|
const verified = await verifyActionPass({
|
|
814
1193
|
token: request.actionPass.token,
|
|
815
1194
|
action: request.action,
|
|
816
1195
|
verificationKey: config.verificationKey,
|
|
1196
|
+
verificationKeys: config.verificationKeys,
|
|
817
1197
|
issuer: config.issuer,
|
|
818
1198
|
algorithms: config.algorithms,
|
|
819
1199
|
clockTolerance: config.clockTolerance,
|
|
820
1200
|
currentDate: config.currentDate?.(),
|
|
1201
|
+
dpopProof: request.dpopProof ?? undefined,
|
|
1202
|
+
dpopMethod: target.method,
|
|
1203
|
+
dpopUri: target.uri,
|
|
1204
|
+
dpopNonce: config.dpopNonce,
|
|
1205
|
+
dpopReplayStore,
|
|
1206
|
+
isRevoked: config.isRevoked,
|
|
1207
|
+
getStatus: config.getStatus,
|
|
821
1208
|
});
|
|
822
1209
|
if (!verified.valid) {
|
|
823
1210
|
throw new Error(`adapter_actionpass_invalid:${verified.reason}`);
|
|
@@ -1148,6 +1535,120 @@ export function createGitHubRestContentWriteHandler(config) {
|
|
|
1148
1535
|
};
|
|
1149
1536
|
};
|
|
1150
1537
|
}
|
|
1538
|
+
export function createGitHubRestReviewCommentHandler(config) {
|
|
1539
|
+
const client = createGitHubRestClient(config);
|
|
1540
|
+
return async ({ action, actionPass }) => {
|
|
1541
|
+
const repo = parseGitHubRepoResource(action.capability.resource);
|
|
1542
|
+
const payload = action.capability.payload;
|
|
1543
|
+
const pullNumber = requirePositiveInteger(payload.pullNumber, "github_rest_missing_pull_number");
|
|
1544
|
+
const line = requirePositiveInteger(payload.line, "github_rest_missing_review_line");
|
|
1545
|
+
const side = requireEnumString(payload.side, ["LEFT", "RIGHT"], "github_rest_invalid_review_side");
|
|
1546
|
+
const startLine = parseOptionalPositiveInteger(payload.startLine, "github_rest_invalid_review_start_line");
|
|
1547
|
+
const startSide = payload.startSide === undefined
|
|
1548
|
+
? undefined
|
|
1549
|
+
: requireEnumString(payload.startSide, ["LEFT", "RIGHT"], "github_rest_invalid_review_start_side");
|
|
1550
|
+
const response = await client.requestWithMetadata(`/repos/${repo.owner}/${repo.name}/pulls/${pullNumber}/comments`, {
|
|
1551
|
+
method: "POST",
|
|
1552
|
+
body: {
|
|
1553
|
+
body: requireString(payload.body, "github_rest_missing_review_body"),
|
|
1554
|
+
commit_id: requireString(payload.commitId, "github_rest_missing_commit_id"),
|
|
1555
|
+
path: requireString(payload.path, "github_rest_missing_review_path"),
|
|
1556
|
+
line,
|
|
1557
|
+
side,
|
|
1558
|
+
start_line: startLine,
|
|
1559
|
+
start_side: startSide,
|
|
1560
|
+
},
|
|
1561
|
+
});
|
|
1562
|
+
return {
|
|
1563
|
+
provider: "github",
|
|
1564
|
+
kind: "pull_request_review_comment",
|
|
1565
|
+
id: String(response.data.id),
|
|
1566
|
+
url: response.data.html_url,
|
|
1567
|
+
pullNumber,
|
|
1568
|
+
path: response.data.path,
|
|
1569
|
+
line: response.data.line,
|
|
1570
|
+
side: response.data.side,
|
|
1571
|
+
acceptedPermissions: response.metadata.acceptedPermissions,
|
|
1572
|
+
rateLimit: response.metadata.rateLimit,
|
|
1573
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1574
|
+
};
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
export function createGitHubRestCheckRunHandler(config) {
|
|
1578
|
+
const client = createGitHubRestClient(config);
|
|
1579
|
+
return async ({ action, actionPass }) => {
|
|
1580
|
+
const repo = parseGitHubRepoResource(action.capability.resource);
|
|
1581
|
+
const payload = action.capability.payload;
|
|
1582
|
+
const output = getObject(payload.output);
|
|
1583
|
+
const response = await client.requestWithMetadata(`/repos/${repo.owner}/${repo.name}/check-runs`, {
|
|
1584
|
+
method: "POST",
|
|
1585
|
+
body: {
|
|
1586
|
+
name: requireString(payload.name, "github_rest_missing_check_name"),
|
|
1587
|
+
head_sha: requireString(payload.headSha, "github_rest_missing_check_head_sha"),
|
|
1588
|
+
status: payload.status === undefined
|
|
1589
|
+
? undefined
|
|
1590
|
+
: requireEnumString(payload.status, ["queued", "in_progress", "completed", "pending"], "github_rest_invalid_check_status"),
|
|
1591
|
+
conclusion: payload.conclusion === undefined
|
|
1592
|
+
? undefined
|
|
1593
|
+
: requireEnumString(payload.conclusion, [
|
|
1594
|
+
"action_required",
|
|
1595
|
+
"cancelled",
|
|
1596
|
+
"failure",
|
|
1597
|
+
"neutral",
|
|
1598
|
+
"success",
|
|
1599
|
+
"skipped",
|
|
1600
|
+
"stale",
|
|
1601
|
+
"timed_out",
|
|
1602
|
+
], "github_rest_invalid_check_conclusion"),
|
|
1603
|
+
details_url: getString(payload.detailsUrl) ?? undefined,
|
|
1604
|
+
external_id: getString(payload.externalId) ?? undefined,
|
|
1605
|
+
output: output
|
|
1606
|
+
? removeUndefined({
|
|
1607
|
+
title: requireString(output.title, "github_rest_missing_check_output_title"),
|
|
1608
|
+
summary: requireString(output.summary, "github_rest_missing_check_output_summary"),
|
|
1609
|
+
text: getString(output.text) ?? undefined,
|
|
1610
|
+
})
|
|
1611
|
+
: undefined,
|
|
1612
|
+
},
|
|
1613
|
+
});
|
|
1614
|
+
return {
|
|
1615
|
+
provider: "github",
|
|
1616
|
+
kind: "check_run",
|
|
1617
|
+
id: String(response.data.id),
|
|
1618
|
+
url: response.data.html_url,
|
|
1619
|
+
status: response.data.status,
|
|
1620
|
+
conclusion: response.data.conclusion,
|
|
1621
|
+
headSha: response.data.head_sha,
|
|
1622
|
+
acceptedPermissions: response.metadata.acceptedPermissions,
|
|
1623
|
+
rateLimit: response.metadata.rateLimit,
|
|
1624
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1625
|
+
};
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
export function createGitHubRestIssueCommentHandler(config) {
|
|
1629
|
+
const client = createGitHubRestClient(config);
|
|
1630
|
+
return async ({ action, actionPass }) => {
|
|
1631
|
+
const repo = parseGitHubRepoResource(action.capability.resource);
|
|
1632
|
+
const payload = action.capability.payload;
|
|
1633
|
+
const issueNumber = requirePositiveInteger(payload.issueNumber, "github_rest_missing_issue_number");
|
|
1634
|
+
const response = await client.requestWithMetadata(`/repos/${repo.owner}/${repo.name}/issues/${issueNumber}/comments`, {
|
|
1635
|
+
method: "POST",
|
|
1636
|
+
body: {
|
|
1637
|
+
body: requireString(payload.body, "github_rest_missing_issue_comment_body"),
|
|
1638
|
+
},
|
|
1639
|
+
});
|
|
1640
|
+
return {
|
|
1641
|
+
provider: "github",
|
|
1642
|
+
kind: "issue_comment",
|
|
1643
|
+
id: String(response.data.id),
|
|
1644
|
+
url: response.data.html_url,
|
|
1645
|
+
issueNumber,
|
|
1646
|
+
acceptedPermissions: response.metadata.acceptedPermissions,
|
|
1647
|
+
rateLimit: response.metadata.rateLimit,
|
|
1648
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1649
|
+
};
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1151
1652
|
export function createSlackWebPostMessageHandler(config) {
|
|
1152
1653
|
const client = createSlackWebClient(config);
|
|
1153
1654
|
return async ({ action, actionPass }) => {
|
|
@@ -1287,6 +1788,213 @@ export function createLinearIssueUpdateHandler(config) {
|
|
|
1287
1788
|
};
|
|
1288
1789
|
};
|
|
1289
1790
|
}
|
|
1791
|
+
export function createJiraIssueReadHandler(config) {
|
|
1792
|
+
const client = createJiraRestClient(config);
|
|
1793
|
+
return async ({ action, actionPass }) => {
|
|
1794
|
+
const issueKey = issueIdentifier(action.capability.payload);
|
|
1795
|
+
const issue = await getJiraIssue(client, issueKey);
|
|
1796
|
+
return jiraIssueResult(issue, config, actionPass?.claims.jti ?? null);
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
export function createJiraCommentCreateHandler(config) {
|
|
1800
|
+
const client = createJiraRestClient(config);
|
|
1801
|
+
return async ({ action, actionPass }) => {
|
|
1802
|
+
const issueKey = issueIdentifier(action.capability.payload);
|
|
1803
|
+
const body = requireString(action.capability.payload.body, "jira_rest_missing_comment_body");
|
|
1804
|
+
const comment = await client.request(`/issue/${encodeURIComponent(issueKey)}/comment`, {
|
|
1805
|
+
method: "POST",
|
|
1806
|
+
body: {
|
|
1807
|
+
body: jiraAdfDocument(body),
|
|
1808
|
+
},
|
|
1809
|
+
});
|
|
1810
|
+
return {
|
|
1811
|
+
provider: "jira",
|
|
1812
|
+
kind: "issue_comment",
|
|
1813
|
+
id: comment.id,
|
|
1814
|
+
issueKey,
|
|
1815
|
+
url: jiraIssueUrl(config, issueKey),
|
|
1816
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1817
|
+
};
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
export function createJiraIssueUpdateHandler(config) {
|
|
1821
|
+
const client = createJiraRestClient(config);
|
|
1822
|
+
return async ({ action, actionPass }) => {
|
|
1823
|
+
const payload = action.capability.payload;
|
|
1824
|
+
const issueKey = issueIdentifier(payload);
|
|
1825
|
+
const status = getString(payload.status);
|
|
1826
|
+
const fields = removeUndefined({
|
|
1827
|
+
title: getString(payload.title) ?? undefined,
|
|
1828
|
+
summary: getString(payload.title) ?? undefined,
|
|
1829
|
+
description: getString(payload.description)
|
|
1830
|
+
? jiraAdfDocument(getString(payload.description))
|
|
1831
|
+
: undefined,
|
|
1832
|
+
priority: getString(payload.priorityId)
|
|
1833
|
+
? { id: getString(payload.priorityId) }
|
|
1834
|
+
: undefined,
|
|
1835
|
+
});
|
|
1836
|
+
// "title" is Axtary's normalized field; Jira calls it "summary".
|
|
1837
|
+
delete fields.title;
|
|
1838
|
+
if (Object.keys(fields).length === 0 && !status) {
|
|
1839
|
+
throw new Error("jira_rest_missing_update_fields");
|
|
1840
|
+
}
|
|
1841
|
+
if (Object.keys(fields).length > 0) {
|
|
1842
|
+
await client.request(`/issue/${encodeURIComponent(issueKey)}`, {
|
|
1843
|
+
method: "PUT",
|
|
1844
|
+
body: { fields },
|
|
1845
|
+
expectEmpty: true,
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
let transitionId = null;
|
|
1849
|
+
if (status) {
|
|
1850
|
+
const transitions = await client.request(`/issue/${encodeURIComponent(issueKey)}/transitions?expand=transitions.fields`, { method: "GET" });
|
|
1851
|
+
const transition = transitions.transitions.find((candidate) => candidate.name.localeCompare(status, undefined, {
|
|
1852
|
+
sensitivity: "base",
|
|
1853
|
+
}) === 0 ||
|
|
1854
|
+
candidate.to?.name.localeCompare(status, undefined, {
|
|
1855
|
+
sensitivity: "base",
|
|
1856
|
+
}) === 0);
|
|
1857
|
+
if (!transition) {
|
|
1858
|
+
const available = transitions.transitions
|
|
1859
|
+
.map((candidate) => candidate.to?.name ?? candidate.name)
|
|
1860
|
+
.join(",");
|
|
1861
|
+
throw new Error(`jira_rest_transition_not_found:${status}:available=${available}`);
|
|
1862
|
+
}
|
|
1863
|
+
transitionId = transition.id;
|
|
1864
|
+
await client.request(`/issue/${encodeURIComponent(issueKey)}/transitions`, {
|
|
1865
|
+
method: "POST",
|
|
1866
|
+
body: { transition: { id: transition.id } },
|
|
1867
|
+
expectEmpty: true,
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
const issue = await getJiraIssue(client, issueKey);
|
|
1871
|
+
return {
|
|
1872
|
+
...jiraIssueResult(issue, config, actionPass?.claims.jti ?? null),
|
|
1873
|
+
kind: "issue_update",
|
|
1874
|
+
transitionId,
|
|
1875
|
+
};
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
export function createPostgresSelectHandler(config) {
|
|
1879
|
+
return async ({ action, actionPass }) => {
|
|
1880
|
+
const payload = action.capability.payload;
|
|
1881
|
+
const statement = requireString(payload.statement, "postgres_statement_required");
|
|
1882
|
+
const parameters = Array.isArray(payload.parameters)
|
|
1883
|
+
? payload.parameters
|
|
1884
|
+
: null;
|
|
1885
|
+
const maxRows = requirePositiveInteger(payload.maxRows, "postgres_max_rows_required");
|
|
1886
|
+
if (!parameters)
|
|
1887
|
+
throw new Error("postgres_parameters_required");
|
|
1888
|
+
const analyzed = analyzePostgresSelect(statement);
|
|
1889
|
+
if (!analyzed.ok)
|
|
1890
|
+
throw new Error(analyzed.reason);
|
|
1891
|
+
const analysis = analyzed.analysis;
|
|
1892
|
+
enforcePostgresQueryScope(analysis, parameters.length, maxRows, config);
|
|
1893
|
+
const expectedDatabase = parsePostgresDatabaseResource(action.capability.resource);
|
|
1894
|
+
const client = postgresClient(config);
|
|
1895
|
+
await client.connect();
|
|
1896
|
+
try {
|
|
1897
|
+
await beginPostgresReadOnly(client, config);
|
|
1898
|
+
const safety = await inspectPostgresSession(client, analysis.tables);
|
|
1899
|
+
enforcePostgresSafety(safety, config);
|
|
1900
|
+
if (safety.database !== expectedDatabase) {
|
|
1901
|
+
throw new Error("postgres_database_resource_mismatch");
|
|
1902
|
+
}
|
|
1903
|
+
const limitParameter = analysis.parameterCount + 1;
|
|
1904
|
+
const boundedStatement = `SELECT * FROM (${analysis.canonicalStatement}) AS axtary_scoped_query LIMIT $${limitParameter}`;
|
|
1905
|
+
const result = await client.query(boundedStatement, [
|
|
1906
|
+
...parameters,
|
|
1907
|
+
maxRows + 1,
|
|
1908
|
+
]);
|
|
1909
|
+
const truncated = result.rows.length > maxRows;
|
|
1910
|
+
const rows = result.rows.slice(0, maxRows).map(jsonSafePostgresRow);
|
|
1911
|
+
const fields = result.fields.map((field) => field.name);
|
|
1912
|
+
await client.query("COMMIT");
|
|
1913
|
+
return {
|
|
1914
|
+
provider: "postgres",
|
|
1915
|
+
kind: "query_select",
|
|
1916
|
+
database: safety.database,
|
|
1917
|
+
role: safety.role,
|
|
1918
|
+
transactionReadOnly: safety.transactionReadOnly,
|
|
1919
|
+
roleLeastPrivilege: safety.roleLeastPrivilege,
|
|
1920
|
+
statementHash: analysis.statementHash,
|
|
1921
|
+
predicateHash: analysis.predicateHash,
|
|
1922
|
+
tables: analysis.tables,
|
|
1923
|
+
rowCount: rows.length,
|
|
1924
|
+
fields,
|
|
1925
|
+
truncated,
|
|
1926
|
+
rls: safety.tables.map((table) => ({
|
|
1927
|
+
table: table.name,
|
|
1928
|
+
selectGranted: table.selectGranted,
|
|
1929
|
+
writeGranted: table.writeGranted,
|
|
1930
|
+
enabled: table.rlsEnabled,
|
|
1931
|
+
forced: table.rlsForced,
|
|
1932
|
+
roleOwnsTable: table.roleOwnsTable,
|
|
1933
|
+
})),
|
|
1934
|
+
rows,
|
|
1935
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
catch (error) {
|
|
1939
|
+
await rollbackQuietly(client);
|
|
1940
|
+
throw new Error(postgresErrorReason("postgres_query_failed", error));
|
|
1941
|
+
}
|
|
1942
|
+
finally {
|
|
1943
|
+
await client.end();
|
|
1944
|
+
}
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
export function createDriveFileReadHandler(config) {
|
|
1948
|
+
return async ({ action, actionPass }) => {
|
|
1949
|
+
const fileId = requireString(action.capability.payload.fileId, "drive_file_id_required");
|
|
1950
|
+
const maxBytes = requirePositiveInteger(action.capability.payload.maxBytes, "drive_max_bytes_required");
|
|
1951
|
+
if (action.capability.resource !== `drive:file:${fileId}`) {
|
|
1952
|
+
throw new Error("drive_file_resource_mismatch");
|
|
1953
|
+
}
|
|
1954
|
+
if (maxBytes > config.maxReadBytes) {
|
|
1955
|
+
throw new Error("drive_read_byte_limit_exceeded");
|
|
1956
|
+
}
|
|
1957
|
+
const metadata = await getDriveFileMetadata(config, fileId);
|
|
1958
|
+
if (metadata.isAppAuthorized !== true) {
|
|
1959
|
+
throw new Error("drive_file_not_authorized");
|
|
1960
|
+
}
|
|
1961
|
+
if (!config.allowedMimeTypes.includes(metadata.mimeType)) {
|
|
1962
|
+
throw new Error(`drive_mime_type_not_allowed:${metadata.mimeType}`);
|
|
1963
|
+
}
|
|
1964
|
+
if (metadata.size !== null &&
|
|
1965
|
+
metadata.size > maxBytes) {
|
|
1966
|
+
throw new Error("drive_content_too_large");
|
|
1967
|
+
}
|
|
1968
|
+
const contentUrl = driveContentUrl(config, metadata);
|
|
1969
|
+
const response = await driveFetch(config, contentUrl, {
|
|
1970
|
+
headers: {
|
|
1971
|
+
authorization: `Bearer ${config.accessToken}`,
|
|
1972
|
+
accept: "text/plain, text/*;q=0.9, application/json;q=0.8",
|
|
1973
|
+
},
|
|
1974
|
+
});
|
|
1975
|
+
if (!response.ok) {
|
|
1976
|
+
throw await driveResponseError(response);
|
|
1977
|
+
}
|
|
1978
|
+
const content = await readDriveTextBounded(response, maxBytes);
|
|
1979
|
+
const bytes = Buffer.byteLength(content, "utf8");
|
|
1980
|
+
return {
|
|
1981
|
+
provider: "drive",
|
|
1982
|
+
kind: "file_read",
|
|
1983
|
+
id: metadata.id,
|
|
1984
|
+
fileId: metadata.id,
|
|
1985
|
+
title: metadata.name,
|
|
1986
|
+
mimeType: metadata.mimeType,
|
|
1987
|
+
modifiedTime: metadata.modifiedTime,
|
|
1988
|
+
url: metadata.webViewLink,
|
|
1989
|
+
scope: "https://www.googleapis.com/auth/drive.file",
|
|
1990
|
+
appAuthorized: metadata.isAppAuthorized,
|
|
1991
|
+
bytes,
|
|
1992
|
+
truncated: false,
|
|
1993
|
+
content,
|
|
1994
|
+
actionPassId: actionPass?.claims.jti ?? null,
|
|
1995
|
+
};
|
|
1996
|
+
};
|
|
1997
|
+
}
|
|
1290
1998
|
export function createAwsIdentityGetHandler(config) {
|
|
1291
1999
|
const client = createAwsClient(config);
|
|
1292
2000
|
return async ({ actionPass }) => {
|
|
@@ -1636,33 +2344,183 @@ function createGcpRestClient(config) {
|
|
|
1636
2344
|
},
|
|
1637
2345
|
};
|
|
1638
2346
|
}
|
|
2347
|
+
async function getDriveFileMetadata(config, fileId) {
|
|
2348
|
+
const apiBaseUrl = (config.apiBaseUrl ?? GOOGLE_DRIVE_API_BASE_URL).replace(/\/$/, "");
|
|
2349
|
+
const fields = [
|
|
2350
|
+
"id",
|
|
2351
|
+
"name",
|
|
2352
|
+
"mimeType",
|
|
2353
|
+
"modifiedTime",
|
|
2354
|
+
"size",
|
|
2355
|
+
"webViewLink",
|
|
2356
|
+
"isAppAuthorized",
|
|
2357
|
+
].join(",");
|
|
2358
|
+
const url = `${apiBaseUrl}/files/${encodeURIComponent(fileId)}` +
|
|
2359
|
+
`?supportsAllDrives=true&fields=${encodeURIComponent(fields)}`;
|
|
2360
|
+
const response = await driveFetch(config, url, {
|
|
2361
|
+
headers: {
|
|
2362
|
+
authorization: `Bearer ${config.accessToken}`,
|
|
2363
|
+
accept: "application/json",
|
|
2364
|
+
},
|
|
2365
|
+
});
|
|
2366
|
+
if (!response.ok) {
|
|
2367
|
+
throw await driveResponseError(response);
|
|
2368
|
+
}
|
|
2369
|
+
const value = (await response.json());
|
|
2370
|
+
if (typeof value.id !== "string" ||
|
|
2371
|
+
typeof value.name !== "string" ||
|
|
2372
|
+
typeof value.mimeType !== "string") {
|
|
2373
|
+
throw new Error("drive_metadata_invalid");
|
|
2374
|
+
}
|
|
2375
|
+
const size = typeof value.size === "string" && /^\d+$/.test(value.size)
|
|
2376
|
+
? Number.parseInt(value.size, 10)
|
|
2377
|
+
: typeof value.size === "number" && Number.isFinite(value.size)
|
|
2378
|
+
? value.size
|
|
2379
|
+
: null;
|
|
2380
|
+
return {
|
|
2381
|
+
id: value.id,
|
|
2382
|
+
name: value.name,
|
|
2383
|
+
mimeType: value.mimeType,
|
|
2384
|
+
modifiedTime: typeof value.modifiedTime === "string" ? value.modifiedTime : null,
|
|
2385
|
+
size,
|
|
2386
|
+
webViewLink: typeof value.webViewLink === "string" ? value.webViewLink : null,
|
|
2387
|
+
isAppAuthorized: value.isAppAuthorized === true,
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
function driveContentUrl(config, metadata) {
|
|
2391
|
+
const apiBaseUrl = (config.apiBaseUrl ?? GOOGLE_DRIVE_API_BASE_URL).replace(/\/$/, "");
|
|
2392
|
+
const filePath = `${apiBaseUrl}/files/${encodeURIComponent(metadata.id)}`;
|
|
2393
|
+
return metadata.mimeType === "application/vnd.google-apps.document"
|
|
2394
|
+
? `${filePath}/export?mimeType=${encodeURIComponent("text/plain")}`
|
|
2395
|
+
: `${filePath}?alt=media&supportsAllDrives=true`;
|
|
2396
|
+
}
|
|
2397
|
+
async function driveFetch(config, url, init) {
|
|
2398
|
+
const fetchImpl = config.fetch ?? fetch;
|
|
2399
|
+
const maxRetries = config.maxRetries ?? DEFAULT_PROVIDER_RETRIES;
|
|
2400
|
+
return requestWithRetry(maxRetries, async () => {
|
|
2401
|
+
const response = await fetchImpl(url, init);
|
|
2402
|
+
if (!response.ok) {
|
|
2403
|
+
throw await driveResponseError(response);
|
|
2404
|
+
}
|
|
2405
|
+
return response;
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
async function driveResponseError(response) {
|
|
2409
|
+
const body = (await response.text()).slice(0, 1_000);
|
|
2410
|
+
const retryAfter = providerRetryAfter(response, body);
|
|
2411
|
+
if (/appNotAuthorizedToFile/i.test(body)) {
|
|
2412
|
+
return new ProviderRequestError("drive_file_not_authorized", response.status, "auth_error");
|
|
2413
|
+
}
|
|
2414
|
+
if (response.status === 404) {
|
|
2415
|
+
return new ProviderRequestError("drive_file_not_found_or_inaccessible", response.status, "provider_error");
|
|
2416
|
+
}
|
|
2417
|
+
if (response.status === 401) {
|
|
2418
|
+
return new ProviderRequestError("drive_oauth_token_invalid", response.status, "auth_error");
|
|
2419
|
+
}
|
|
2420
|
+
if (response.status === 429 || /rateLimitExceeded/i.test(body)) {
|
|
2421
|
+
return new ProviderRequestError("drive_rate_limited", response.status, "rate_limited", retryAfter);
|
|
2422
|
+
}
|
|
2423
|
+
if (response.status === 403) {
|
|
2424
|
+
return new ProviderRequestError("drive_access_denied", response.status, "auth_error");
|
|
2425
|
+
}
|
|
2426
|
+
if (response.status >= 500) {
|
|
2427
|
+
return new ProviderRequestError(`drive_provider_unavailable:${response.status}`, response.status, "server_error", retryAfter);
|
|
2428
|
+
}
|
|
2429
|
+
return new ProviderRequestError(`drive_request_failed:${response.status}`, response.status, "provider_error");
|
|
2430
|
+
}
|
|
2431
|
+
async function readDriveTextBounded(response, maxBytes) {
|
|
2432
|
+
if (!response.body) {
|
|
2433
|
+
const value = await response.arrayBuffer();
|
|
2434
|
+
if (value.byteLength > maxBytes) {
|
|
2435
|
+
throw new Error("drive_content_too_large");
|
|
2436
|
+
}
|
|
2437
|
+
return new TextDecoder().decode(value);
|
|
2438
|
+
}
|
|
2439
|
+
const reader = response.body.getReader();
|
|
2440
|
+
const chunks = [];
|
|
2441
|
+
let total = 0;
|
|
2442
|
+
while (true) {
|
|
2443
|
+
const { done, value } = await reader.read();
|
|
2444
|
+
if (done)
|
|
2445
|
+
break;
|
|
2446
|
+
total += value.byteLength;
|
|
2447
|
+
if (total > maxBytes) {
|
|
2448
|
+
await reader.cancel();
|
|
2449
|
+
throw new Error("drive_content_too_large");
|
|
2450
|
+
}
|
|
2451
|
+
chunks.push(value);
|
|
2452
|
+
}
|
|
2453
|
+
const joined = new Uint8Array(total);
|
|
2454
|
+
let offset = 0;
|
|
2455
|
+
for (const chunk of chunks) {
|
|
2456
|
+
joined.set(chunk, offset);
|
|
2457
|
+
offset += chunk.byteLength;
|
|
2458
|
+
}
|
|
2459
|
+
return new TextDecoder().decode(joined);
|
|
2460
|
+
}
|
|
1639
2461
|
function createGitHubRestClient(config) {
|
|
1640
2462
|
const fetchImpl = config.fetch ?? fetch;
|
|
1641
2463
|
const apiBaseUrl = (config.apiBaseUrl ?? GITHUB_REST_API_BASE_URL).replace(/\/$/, "");
|
|
1642
2464
|
const userAgent = config.userAgent ?? GITHUB_REST_USER_AGENT;
|
|
1643
2465
|
const maxRetries = config.maxRetries ?? DEFAULT_PROVIDER_RETRIES;
|
|
2466
|
+
async function requestWithMetadata(path, options) {
|
|
2467
|
+
return requestWithRetry(maxRetries, async () => {
|
|
2468
|
+
const response = await fetchImpl(`${apiBaseUrl}${path}`, {
|
|
2469
|
+
method: options.method,
|
|
2470
|
+
headers: {
|
|
2471
|
+
accept: "application/vnd.github+json",
|
|
2472
|
+
authorization: `Bearer ${config.token}`,
|
|
2473
|
+
"content-type": "application/json",
|
|
2474
|
+
"user-agent": userAgent,
|
|
2475
|
+
"x-github-api-version": "2022-11-28",
|
|
2476
|
+
},
|
|
2477
|
+
body: options.body
|
|
2478
|
+
? JSON.stringify(removeUndefined(options.body))
|
|
2479
|
+
: undefined,
|
|
2480
|
+
});
|
|
2481
|
+
if (!response.ok) {
|
|
2482
|
+
throw await providerError("github_rest", response);
|
|
2483
|
+
}
|
|
2484
|
+
return {
|
|
2485
|
+
data: (await response.json()),
|
|
2486
|
+
metadata: githubResponseMetadata(response.headers),
|
|
2487
|
+
};
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
1644
2490
|
return {
|
|
1645
2491
|
async request(path, options) {
|
|
1646
|
-
return
|
|
1647
|
-
const response = await fetchImpl(`${apiBaseUrl}${path}`, {
|
|
1648
|
-
method: options.method,
|
|
1649
|
-
headers: {
|
|
1650
|
-
accept: "application/vnd.github+json",
|
|
1651
|
-
authorization: `Bearer ${config.token}`,
|
|
1652
|
-
"content-type": "application/json",
|
|
1653
|
-
"user-agent": userAgent,
|
|
1654
|
-
"x-github-api-version": "2022-11-28",
|
|
1655
|
-
},
|
|
1656
|
-
body: options.body ? JSON.stringify(removeUndefined(options.body)) : undefined,
|
|
1657
|
-
});
|
|
1658
|
-
if (!response.ok) {
|
|
1659
|
-
throw await providerError("github_rest", response);
|
|
1660
|
-
}
|
|
1661
|
-
return response.json();
|
|
1662
|
-
});
|
|
2492
|
+
return (await requestWithMetadata(path, options)).data;
|
|
1663
2493
|
},
|
|
2494
|
+
requestWithMetadata,
|
|
1664
2495
|
};
|
|
1665
2496
|
}
|
|
2497
|
+
function githubResponseMetadata(headers) {
|
|
2498
|
+
const reset = parseOptionalIntegerHeader(headers.get("x-ratelimit-reset"));
|
|
2499
|
+
return {
|
|
2500
|
+
acceptedPermissions: parseAcceptedGitHubPermissions(headers.get("x-accepted-github-permissions")),
|
|
2501
|
+
rateLimit: {
|
|
2502
|
+
limit: parseOptionalIntegerHeader(headers.get("x-ratelimit-limit")),
|
|
2503
|
+
remaining: parseOptionalIntegerHeader(headers.get("x-ratelimit-remaining")),
|
|
2504
|
+
resetAt: reset === null ? null : new Date(reset * 1000).toISOString(),
|
|
2505
|
+
resource: headers.get("x-ratelimit-resource"),
|
|
2506
|
+
},
|
|
2507
|
+
};
|
|
2508
|
+
}
|
|
2509
|
+
function parseAcceptedGitHubPermissions(value) {
|
|
2510
|
+
if (!value)
|
|
2511
|
+
return [];
|
|
2512
|
+
return value
|
|
2513
|
+
.split(";")
|
|
2514
|
+
.map((permission) => permission.trim())
|
|
2515
|
+
.filter(Boolean)
|
|
2516
|
+
.sort();
|
|
2517
|
+
}
|
|
2518
|
+
function parseOptionalIntegerHeader(value) {
|
|
2519
|
+
if (!value)
|
|
2520
|
+
return null;
|
|
2521
|
+
const parsed = Number.parseInt(value, 10);
|
|
2522
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
2523
|
+
}
|
|
1666
2524
|
function createSlackWebClient(config) {
|
|
1667
2525
|
const fetchImpl = config.fetch ?? fetch;
|
|
1668
2526
|
const apiBaseUrl = (config.apiBaseUrl ?? SLACK_WEB_API_BASE_URL).replace(/\/$/, "");
|
|
@@ -1725,6 +2583,73 @@ function createLinearGraphqlClient(config) {
|
|
|
1725
2583
|
},
|
|
1726
2584
|
};
|
|
1727
2585
|
}
|
|
2586
|
+
function createJiraRestClient(config) {
|
|
2587
|
+
const fetchImpl = config.fetch ?? fetch;
|
|
2588
|
+
const apiBaseUrl = config.apiBaseUrl.replace(/\/$/, "");
|
|
2589
|
+
const maxRetries = config.maxRetries ?? DEFAULT_PROVIDER_RETRIES;
|
|
2590
|
+
const authorization = config.auth.type === "bearer"
|
|
2591
|
+
? `Bearer ${config.auth.token}`
|
|
2592
|
+
: `Basic ${Buffer.from(`${config.auth.email}:${config.auth.apiToken}`, "utf8").toString("base64")}`;
|
|
2593
|
+
return {
|
|
2594
|
+
async request(path, options) {
|
|
2595
|
+
return requestWithRetry(maxRetries, async () => {
|
|
2596
|
+
const response = await fetchImpl(`${apiBaseUrl}${path}`, {
|
|
2597
|
+
method: options.method,
|
|
2598
|
+
headers: {
|
|
2599
|
+
authorization,
|
|
2600
|
+
accept: "application/json",
|
|
2601
|
+
...(options.body ? { "content-type": "application/json" } : {}),
|
|
2602
|
+
},
|
|
2603
|
+
body: options.body
|
|
2604
|
+
? JSON.stringify(removeUndefined(options.body))
|
|
2605
|
+
: undefined,
|
|
2606
|
+
});
|
|
2607
|
+
if (!response.ok) {
|
|
2608
|
+
throw await providerError("jira_rest", response);
|
|
2609
|
+
}
|
|
2610
|
+
if (options.expectEmpty || response.status === 204) {
|
|
2611
|
+
return undefined;
|
|
2612
|
+
}
|
|
2613
|
+
return (await response.json());
|
|
2614
|
+
});
|
|
2615
|
+
},
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
async function getJiraIssue(client, issueKey) {
|
|
2619
|
+
return client.request(`/issue/${encodeURIComponent(issueKey)}?fields=summary,status,project,priority,description`, { method: "GET" });
|
|
2620
|
+
}
|
|
2621
|
+
function jiraIssueResult(issue, config, actionPassId) {
|
|
2622
|
+
return {
|
|
2623
|
+
provider: "jira",
|
|
2624
|
+
kind: "issue",
|
|
2625
|
+
id: issue.id,
|
|
2626
|
+
issueKey: issue.key,
|
|
2627
|
+
projectKey: issue.fields.project?.key ?? issue.key.split("-", 1)[0],
|
|
2628
|
+
title: issue.fields.summary,
|
|
2629
|
+
status: issue.fields.status?.name ?? null,
|
|
2630
|
+
statusId: issue.fields.status?.id ?? null,
|
|
2631
|
+
priority: issue.fields.priority?.name ?? null,
|
|
2632
|
+
priorityId: issue.fields.priority?.id ?? null,
|
|
2633
|
+
url: jiraIssueUrl(config, issue.key),
|
|
2634
|
+
actionPassId,
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
function jiraIssueUrl(config, issueKey) {
|
|
2638
|
+
if (!config.siteUrl)
|
|
2639
|
+
return null;
|
|
2640
|
+
return `${config.siteUrl.replace(/\/$/, "")}/browse/${encodeURIComponent(issueKey)}`;
|
|
2641
|
+
}
|
|
2642
|
+
function jiraAdfDocument(text) {
|
|
2643
|
+
const paragraphs = text.split(/\r?\n/).map((line) => ({
|
|
2644
|
+
type: "paragraph",
|
|
2645
|
+
content: line.length > 0 ? [{ type: "text", text: line }] : [],
|
|
2646
|
+
}));
|
|
2647
|
+
return {
|
|
2648
|
+
type: "doc",
|
|
2649
|
+
version: 1,
|
|
2650
|
+
content: paragraphs.length > 0 ? paragraphs : [{ type: "paragraph" }],
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
1728
2653
|
class ProviderRequestError extends Error {
|
|
1729
2654
|
status;
|
|
1730
2655
|
category;
|
|
@@ -1739,9 +2664,13 @@ class ProviderRequestError extends Error {
|
|
|
1739
2664
|
}
|
|
1740
2665
|
async function providerError(prefix, response) {
|
|
1741
2666
|
const message = (await response.text()).slice(0, 500);
|
|
1742
|
-
const retryAfter =
|
|
2667
|
+
const retryAfter = providerRetryAfter(response, message);
|
|
2668
|
+
const githubRateLimited = response.status === 403 &&
|
|
2669
|
+
(response.headers.get("retry-after") !== null ||
|
|
2670
|
+
response.headers.get("x-ratelimit-remaining") === "0" ||
|
|
2671
|
+
/secondary rate limit|rate limit exceeded/i.test(message));
|
|
1743
2672
|
const category = response.status === 401 || response.status === 403
|
|
1744
|
-
?
|
|
2673
|
+
? githubRateLimited
|
|
1745
2674
|
? "rate_limited"
|
|
1746
2675
|
: "auth_error"
|
|
1747
2676
|
: response.status === 429
|
|
@@ -1751,6 +2680,23 @@ async function providerError(prefix, response) {
|
|
|
1751
2680
|
: "provider_error";
|
|
1752
2681
|
return new ProviderRequestError(`${prefix}_${category}_${response.status}:${message}`, response.status, category, retryAfter);
|
|
1753
2682
|
}
|
|
2683
|
+
function providerRetryAfter(response, message) {
|
|
2684
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
2685
|
+
if (retryAfter !== null)
|
|
2686
|
+
return retryAfter;
|
|
2687
|
+
if (response.headers.get("x-ratelimit-remaining") === "0") {
|
|
2688
|
+
const reset = parseOptionalIntegerHeader(response.headers.get("x-ratelimit-reset"));
|
|
2689
|
+
if (reset !== null) {
|
|
2690
|
+
return Math.max(0, reset * 1000 - Date.now());
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
if (response.status === 429 ||
|
|
2694
|
+
(response.status === 403 &&
|
|
2695
|
+
/secondary rate limit|rate limit exceeded/i.test(message))) {
|
|
2696
|
+
return 60_000;
|
|
2697
|
+
}
|
|
2698
|
+
return null;
|
|
2699
|
+
}
|
|
1754
2700
|
async function requestWithRetry(maxRetries, run) {
|
|
1755
2701
|
let attempt = 0;
|
|
1756
2702
|
while (true) {
|
|
@@ -1842,7 +2788,8 @@ function signAwsRequest(input) {
|
|
|
1842
2788
|
: {}),
|
|
1843
2789
|
});
|
|
1844
2790
|
const canonicalHeaders = Object.entries(headers)
|
|
1845
|
-
|
|
2791
|
+
// SigV4 requires code-point order; must match the signedHeaders sort below.
|
|
2792
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
1846
2793
|
.map(([key, value]) => `${key}:${value.trim()}\n`)
|
|
1847
2794
|
.join("");
|
|
1848
2795
|
const signedHeaders = Object.keys(headers).sort().join(";");
|
|
@@ -1951,6 +2898,204 @@ function requireString(value, reason) {
|
|
|
1951
2898
|
}
|
|
1952
2899
|
return text;
|
|
1953
2900
|
}
|
|
2901
|
+
function requirePositiveInteger(value, reason) {
|
|
2902
|
+
if (typeof value !== "number" ||
|
|
2903
|
+
!Number.isInteger(value) ||
|
|
2904
|
+
value <= 0) {
|
|
2905
|
+
throw new Error(reason);
|
|
2906
|
+
}
|
|
2907
|
+
return value;
|
|
2908
|
+
}
|
|
2909
|
+
function parseOptionalPositiveInteger(value, reason) {
|
|
2910
|
+
return value === undefined ? undefined : requirePositiveInteger(value, reason);
|
|
2911
|
+
}
|
|
2912
|
+
function requireEnumString(value, allowed, reason) {
|
|
2913
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
2914
|
+
throw new Error(reason);
|
|
2915
|
+
}
|
|
2916
|
+
return value;
|
|
2917
|
+
}
|
|
2918
|
+
function postgresClient(config) {
|
|
2919
|
+
return config.clientFactory
|
|
2920
|
+
? config.clientFactory(config.connectionString)
|
|
2921
|
+
: new Client({ connectionString: config.connectionString });
|
|
2922
|
+
}
|
|
2923
|
+
async function beginPostgresReadOnly(client, config) {
|
|
2924
|
+
const statementTimeout = positiveConfigInteger(config.statementTimeoutMs, 5_000);
|
|
2925
|
+
const lockTimeout = positiveConfigInteger(config.lockTimeoutMs, 1_000);
|
|
2926
|
+
const idleTimeout = positiveConfigInteger(config.idleTransactionTimeoutMs, 5_000);
|
|
2927
|
+
await client.query("BEGIN READ ONLY");
|
|
2928
|
+
await client.query(`SET LOCAL statement_timeout = '${statementTimeout}ms'`);
|
|
2929
|
+
await client.query(`SET LOCAL lock_timeout = '${lockTimeout}ms'`);
|
|
2930
|
+
await client.query(`SET LOCAL idle_in_transaction_session_timeout = '${idleTimeout}ms'`);
|
|
2931
|
+
}
|
|
2932
|
+
async function inspectPostgresSession(client, tables) {
|
|
2933
|
+
const identity = await client.query(`SELECT
|
|
2934
|
+
current_database() AS database,
|
|
2935
|
+
current_user AS role,
|
|
2936
|
+
current_setting('transaction_read_only') AS transaction_read_only,
|
|
2937
|
+
r.rolsuper,
|
|
2938
|
+
r.rolcreatedb,
|
|
2939
|
+
r.rolcreaterole,
|
|
2940
|
+
r.rolreplication,
|
|
2941
|
+
r.rolbypassrls
|
|
2942
|
+
FROM pg_roles r
|
|
2943
|
+
WHERE r.rolname = current_user`);
|
|
2944
|
+
const row = identity.rows[0];
|
|
2945
|
+
if (!row)
|
|
2946
|
+
throw new Error("postgres_role_metadata_missing");
|
|
2947
|
+
const tableResult = await client.query(`SELECT
|
|
2948
|
+
n.nspname || '.' || c.relname AS table_name,
|
|
2949
|
+
c.relrowsecurity,
|
|
2950
|
+
c.relforcerowsecurity,
|
|
2951
|
+
has_table_privilege(current_user, c.oid, 'SELECT') AS select_granted,
|
|
2952
|
+
(
|
|
2953
|
+
has_table_privilege(current_user, c.oid, 'INSERT') OR
|
|
2954
|
+
has_table_privilege(current_user, c.oid, 'UPDATE') OR
|
|
2955
|
+
has_table_privilege(current_user, c.oid, 'DELETE') OR
|
|
2956
|
+
has_table_privilege(current_user, c.oid, 'TRUNCATE') OR
|
|
2957
|
+
has_table_privilege(current_user, c.oid, 'REFERENCES') OR
|
|
2958
|
+
has_table_privilege(current_user, c.oid, 'TRIGGER')
|
|
2959
|
+
) AS write_granted,
|
|
2960
|
+
pg_get_userbyid(c.relowner) = current_user AS role_owns_table
|
|
2961
|
+
FROM pg_class c
|
|
2962
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
2963
|
+
WHERE n.nspname || '.' || c.relname = ANY($1::text[])
|
|
2964
|
+
ORDER BY table_name`, [tables]);
|
|
2965
|
+
const tableRows = tableResult.rows;
|
|
2966
|
+
const found = new Map(tableRows.map((table) => [table.table_name, table]));
|
|
2967
|
+
const tableSafety = tables.map((table) => {
|
|
2968
|
+
const metadata = found.get(table);
|
|
2969
|
+
if (!metadata)
|
|
2970
|
+
throw new Error(`postgres_table_metadata_missing:${table}`);
|
|
2971
|
+
return {
|
|
2972
|
+
name: table,
|
|
2973
|
+
selectGranted: metadata.select_granted,
|
|
2974
|
+
writeGranted: metadata.write_granted,
|
|
2975
|
+
rlsEnabled: metadata.relrowsecurity,
|
|
2976
|
+
rlsForced: metadata.relforcerowsecurity,
|
|
2977
|
+
roleOwnsTable: metadata.role_owns_table,
|
|
2978
|
+
};
|
|
2979
|
+
});
|
|
2980
|
+
return {
|
|
2981
|
+
database: row.database,
|
|
2982
|
+
role: row.role,
|
|
2983
|
+
transactionReadOnly: row.transaction_read_only === "on",
|
|
2984
|
+
roleLeastPrivilege: !row.rolsuper &&
|
|
2985
|
+
!row.rolcreatedb &&
|
|
2986
|
+
!row.rolcreaterole &&
|
|
2987
|
+
!row.rolreplication &&
|
|
2988
|
+
!row.rolbypassrls,
|
|
2989
|
+
tables: tableSafety,
|
|
2990
|
+
};
|
|
2991
|
+
}
|
|
2992
|
+
function enforcePostgresSafety(safety, config) {
|
|
2993
|
+
if (!safety.transactionReadOnly) {
|
|
2994
|
+
throw new Error("postgres_transaction_not_read_only");
|
|
2995
|
+
}
|
|
2996
|
+
if (!safety.roleLeastPrivilege) {
|
|
2997
|
+
throw new Error("postgres_role_not_least_privilege");
|
|
2998
|
+
}
|
|
2999
|
+
if (config.requireRls ?? true) {
|
|
3000
|
+
for (const table of safety.tables) {
|
|
3001
|
+
if (!table.selectGranted) {
|
|
3002
|
+
throw new Error(`postgres_select_not_granted:${table.name}`);
|
|
3003
|
+
}
|
|
3004
|
+
if (table.writeGranted) {
|
|
3005
|
+
throw new Error(`postgres_write_privilege_present:${table.name}`);
|
|
3006
|
+
}
|
|
3007
|
+
if (!table.rlsEnabled) {
|
|
3008
|
+
throw new Error(`postgres_rls_not_enabled:${table.name}`);
|
|
3009
|
+
}
|
|
3010
|
+
if (table.roleOwnsTable && !table.rlsForced) {
|
|
3011
|
+
throw new Error(`postgres_table_owner_bypasses_rls:${table.name}`);
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
function enforcePostgresQueryScope(analysis, parameterLength, maxRows, config) {
|
|
3017
|
+
if (analysis.parameterCount !== parameterLength) {
|
|
3018
|
+
throw new Error("postgres_parameter_count_mismatch");
|
|
3019
|
+
}
|
|
3020
|
+
if (analysis.tables.some((table) => !table.includes("."))) {
|
|
3021
|
+
throw new Error("postgres_table_must_be_schema_qualified");
|
|
3022
|
+
}
|
|
3023
|
+
if (analysis.tables.some((table) => !config.allowedTables.includes(table))) {
|
|
3024
|
+
throw new Error("postgres_table_not_allowed");
|
|
3025
|
+
}
|
|
3026
|
+
const allowedFunctions = config.allowedFunctions ?? [];
|
|
3027
|
+
if (analysis.functions.some((functionName) => !allowedFunctions.includes(functionName))) {
|
|
3028
|
+
throw new Error("postgres_function_not_allowed");
|
|
3029
|
+
}
|
|
3030
|
+
if (maxRows > (config.maxRows ?? 100)) {
|
|
3031
|
+
throw new Error("postgres_row_limit_exceeded");
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
function parsePostgresDatabaseResource(resource) {
|
|
3035
|
+
const match = /^postgres:database\/([^/]+)$/.exec(resource);
|
|
3036
|
+
if (!match)
|
|
3037
|
+
throw new Error("postgres_database_resource_invalid");
|
|
3038
|
+
return match[1];
|
|
3039
|
+
}
|
|
3040
|
+
function jsonSafePostgresRow(row) {
|
|
3041
|
+
return Object.fromEntries(Object.entries(row).map(([key, value]) => [key, jsonSafePostgresValue(value)]));
|
|
3042
|
+
}
|
|
3043
|
+
function jsonSafePostgresValue(value) {
|
|
3044
|
+
if (value === null ||
|
|
3045
|
+
typeof value === "string" ||
|
|
3046
|
+
typeof value === "boolean") {
|
|
3047
|
+
return value;
|
|
3048
|
+
}
|
|
3049
|
+
if (typeof value === "number") {
|
|
3050
|
+
return Number.isFinite(value) ? value : String(value);
|
|
3051
|
+
}
|
|
3052
|
+
if (typeof value === "bigint")
|
|
3053
|
+
return value.toString();
|
|
3054
|
+
if (value instanceof Date)
|
|
3055
|
+
return value.toISOString();
|
|
3056
|
+
if (Buffer.isBuffer(value))
|
|
3057
|
+
return value.toString("base64");
|
|
3058
|
+
if (Array.isArray(value))
|
|
3059
|
+
return value.map(jsonSafePostgresValue);
|
|
3060
|
+
if (value && typeof value === "object") {
|
|
3061
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [
|
|
3062
|
+
key,
|
|
3063
|
+
jsonSafePostgresValue(child),
|
|
3064
|
+
]));
|
|
3065
|
+
}
|
|
3066
|
+
return String(value);
|
|
3067
|
+
}
|
|
3068
|
+
function positiveConfigInteger(value, fallback) {
|
|
3069
|
+
return Number.isInteger(value) && (value ?? 0) > 0 ? value : fallback;
|
|
3070
|
+
}
|
|
3071
|
+
async function rollbackQuietly(client) {
|
|
3072
|
+
try {
|
|
3073
|
+
await client.query("ROLLBACK");
|
|
3074
|
+
}
|
|
3075
|
+
catch {
|
|
3076
|
+
// Preserve the original failure; rollback diagnostics may contain SQL text.
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
function postgresErrorReason(prefix, error) {
|
|
3080
|
+
if (error instanceof Error && /^postgres_[a-z0-9_.:-]+$/.test(error.message)) {
|
|
3081
|
+
return error.message;
|
|
3082
|
+
}
|
|
3083
|
+
if (error &&
|
|
3084
|
+
typeof error === "object" &&
|
|
3085
|
+
"code" in error &&
|
|
3086
|
+
typeof error.code === "string" &&
|
|
3087
|
+
/^[A-Z0-9]{5}$/.test(error.code)) {
|
|
3088
|
+
return `${prefix}:${error.code}`;
|
|
3089
|
+
}
|
|
3090
|
+
return prefix;
|
|
3091
|
+
}
|
|
3092
|
+
function getObject(value) {
|
|
3093
|
+
return value !== null &&
|
|
3094
|
+
typeof value === "object" &&
|
|
3095
|
+
!Array.isArray(value)
|
|
3096
|
+
? value
|
|
3097
|
+
: null;
|
|
3098
|
+
}
|
|
1954
3099
|
function issueIdentifier(payload) {
|
|
1955
3100
|
return (getString(payload.issueId) ??
|
|
1956
3101
|
getString(payload.issueKey) ??
|