@axtary/adapters 0.1.0 → 0.2.0

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