@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/dist/index.js CHANGED
@@ -1,86 +1,27 @@
1
- import { verifyActionPass, } from "@axtary/actionpass";
2
- import { createHash, createHmac } from "node:crypto";
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
- id: "linear-issue-read",
102
- provider: "linear",
103
- connector: "linear-graphql",
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
- const env = input.env ?? process.env;
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: "GET /user",
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: "GitHub REST mode has an env-backed token configured.",
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
- smokeCheck: null,
396
- smokeCommand: null,
397
- localSummary: "Jira is local/fake in this package; no real Jira credential path exists yet.",
398
- readySummary: "Jira is local/fake in this package; no real Jira credential path exists yet.",
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: input.env ?? process.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: input.env ?? process.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
- export function createGitHubRestHandlers(config, options = {}) {
634
- const handlers = {
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
- return options.verification
641
- ? wrapHandlersWithActionPassVerification(handlers, options.verification)
642
- : handlers;
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
- export function createLinearGraphqlHandlers(config, options = {}) {
653
- const handlers = {
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
- return options.verification
659
- ? wrapHandlersWithActionPassVerification(handlers, options.verification)
660
- : handlers;
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 user = await client.request("/user", { method: "GET" });
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, config),
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 requestWithRetry(maxRetries, async () => {
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 = parseRetryAfter(response.headers.get("retry-after"));
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
- ? response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0"
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
- .sort(([left], [right]) => left.localeCompare(right))
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) ??