@axtary/adapters 0.0.1 → 0.2.0

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