@jskit-ai/connectors-catalog 0.1.63 → 0.1.65

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.
Files changed (117) hide show
  1. package/README.md +12 -17
  2. package/docs/ai.md +5 -17
  3. package/docs/amazon-redshift.md +6 -8
  4. package/docs/apollo-io.md +0 -8
  5. package/docs/ashby.md +0 -8
  6. package/docs/attention.md +0 -8
  7. package/docs/aws-athena.md +2 -2
  8. package/docs/aws-credentials.md +1 -1
  9. package/docs/aws-s3.md +1 -1
  10. package/docs/bigquery.md +2 -4
  11. package/docs/calendly.md +4 -14
  12. package/docs/chargebee.md +0 -9
  13. package/docs/clickhouse.md +1 -1
  14. package/docs/confidence-oauth.md +3 -9
  15. package/docs/contentful.md +4 -13
  16. package/docs/databricks.md +35 -3
  17. package/docs/dbt-semantic-layer.md +2 -8
  18. package/docs/figma.md +5 -5
  19. package/docs/firebase-cloud-messaging.md +3 -3
  20. package/docs/firecrawl.md +1 -1
  21. package/docs/fireflies.md +7 -10
  22. package/docs/gemini-enterprise.md +33 -3
  23. package/docs/gong.md +0 -9
  24. package/docs/google-ads.md +53 -11
  25. package/docs/google-analytics.md +2 -2
  26. package/docs/google-maps-platform.md +3 -9
  27. package/docs/granola.md +1 -1
  28. package/docs/hubspot.md +25 -0
  29. package/docs/klipy.md +0 -13
  30. package/docs/lexware.md +0 -8
  31. package/docs/lightspeed.md +32 -3
  32. package/docs/linear.md +24 -7
  33. package/docs/linkedin.md +16 -16
  34. package/docs/logo-dev.md +7 -25
  35. package/docs/mailgun.md +1 -14
  36. package/docs/mapbox.md +0 -8
  37. package/docs/microsoft-fabric.md +50 -13
  38. package/docs/microsoft-oauth.md +33 -0
  39. package/docs/microsoft-powerpoint.md +2 -13
  40. package/docs/microsoft-word.md +2 -13
  41. package/docs/miro.md +2 -2
  42. package/docs/oura.md +4 -3
  43. package/docs/polar.md +3 -5
  44. package/docs/posthog.md +2 -17
  45. package/docs/prestashop.md +5 -9
  46. package/docs/salesforce.md +41 -12
  47. package/docs/sentry.md +1 -12
  48. package/docs/sevdesk.md +2 -13
  49. package/docs/shopify.md +35 -2
  50. package/docs/slack.md +39 -14
  51. package/docs/snowflake.md +27 -2
  52. package/docs/telegram.md +0 -10
  53. package/docs/tiktok.md +19 -1
  54. package/docs/twilio.md +1 -1
  55. package/docs/twitch.md +34 -4
  56. package/docs/wave.md +36 -15
  57. package/docs/wix.md +4 -17
  58. package/docs/wiz.md +4 -4
  59. package/docs/wordpress-com.md +4 -13
  60. package/docs/workday.md +28 -9
  61. package/docs/x-twitter.md +0 -9
  62. package/docs/xero.md +39 -11
  63. package/docs/zoho-books.md +41 -10
  64. package/docs/zoho-crm.md +36 -12
  65. package/package.json +3 -3
  66. package/patterns/oauth-connection/PATTERN.md +27 -610
  67. package/patterns/paddle-catalogue/example/create-products.js +2 -1
  68. package/patterns/redshift-queries/PATTERN.md +1 -1
  69. package/src/server/attention.js +8 -11
  70. package/src/server/bigquery.js +7 -11
  71. package/src/server/brevo.js +7 -10
  72. package/src/server/calendly.js +7 -9
  73. package/src/server/chargebee.js +3 -6
  74. package/src/server/firecrawl.js +3 -3
  75. package/src/server/gatewayapi.js +4 -4
  76. package/src/server/github-api.js +1 -1
  77. package/src/server/gitlab-api.js +1 -1
  78. package/src/server/gmail.js +6 -11
  79. package/src/server/gong.js +4 -4
  80. package/src/server/google-ads-search.js +4 -5
  81. package/src/server/google-docs.js +10 -22
  82. package/src/server/google-drive.js +6 -6
  83. package/src/server/google-maps-platform.js +4 -8
  84. package/src/server/google-search-console.js +2 -2
  85. package/src/server/google-sheets.js +12 -18
  86. package/src/server/google-slides.js +10 -16
  87. package/src/server/google.js +17 -9
  88. package/src/server/jsonOperation.js +17 -18
  89. package/src/server/lexware.js +4 -9
  90. package/src/server/resend.js +8 -14
  91. package/src/server/semrush.js +70 -89
  92. package/src/server/wix.js +152 -222
  93. package/src/server/x-twitter.js +21 -18
  94. package/src/shared/ai.js +3 -1
  95. package/src/shared/definitions.js +2 -34
  96. package/src/shared/firecrawl.js +17 -0
  97. package/src/shared/gemini-enterprise.js +1 -1
  98. package/src/shared/google-ads.js +1 -1
  99. package/src/shared/google.js +2 -2
  100. package/src/shared/logo-dev.js +1 -1
  101. package/src/shared/mapbox.js +2 -2
  102. package/src/shared/mcp.js +2 -5
  103. package/src/shared/microsoft-fabric.js +1 -1
  104. package/src/shared/microsoft.js +12 -24
  105. package/src/shared/resend.js +20 -0
  106. package/src/shared/siteUrl.js +1 -1
  107. package/src/shared/tokens.js +4 -6
  108. package/src/shared/workday.js +1 -1
  109. package/test/firecrawl.test.js +1 -1
  110. package/test/github-api.test.js +2 -1
  111. package/test/gitlab-api.test.js +2 -1
  112. package/test/gong.test.js +2 -2
  113. package/test/google.test.js +20 -10
  114. package/test/setupGuides.test.js +18 -0
  115. package/test/tokens.test.js +1 -1
  116. package/test/wix.test.js +13 -0
  117. package/test/wiz.test.js +9 -3
@@ -1,31 +1,25 @@
1
- import { createSchema } from "json-rest-schema";
2
- import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
3
1
  import { googleSlidesDefinition } from "../shared/google.js";
4
- import { googleRead, googleProvider, googleFileVerification, documentId } from "./google.js";
2
+ import { googleOperation, googleBatchRequests, googleRead, googleProvider, googleFileVerification, documentId } from "./google.js";
5
3
  const origin = "https://slides.googleapis.com";
6
4
  const readScopes = ["presentations.readonly", "presentations", "drive.readonly", "drive.file", "drive"];
7
- const writable = ["presentations", "drive.file", "drive"].map(scope => `https://www.googleapis.com/auth/${scope}`);
5
+ const writable = ["presentations", "drive.file", "drive"];
8
6
  const validPresentation = value => typeof value?.presentationId === "string" && typeof value.title === "string";
9
7
  const get = googleRead(readScopes, { presentationId: documentId }, ({ presentationId }) => ({
10
8
  url: `${origin}/v1/presentations/${encodeURIComponent(presentationId)}`
11
9
  }), validPresentation);
12
10
  const pageId = { ...documentId, validator: value => /^[a-zA-Z0-9_][a-zA-Z0-9_:-]*$/.test(value) || "Enter a slide object ID, not its URL." };
13
- const create = createSchema({ title: { type: "string", required: true, minLength: 1, maxLength: 1024 } });
14
- const batch = createSchema({ presentationId: documentId, requests: { type: "array", required: true }, requiredRevisionId: { type: "string", minLength: 1, maxLength: 1024 } });
15
11
  const base = googleProvider(googleSlidesDefinition, origin, "connection.verify", {
16
12
  "connection.verify": googleFileVerification(get, "presentationId"),
17
13
  "presentations.get": get,
18
- "presentations.create": { scopes: writable, validateResult: validPresentation, request(input) {
19
- return { method: "POST", url: `${origin}/v1/presentations`, body: validateSchemaPayload({ schema: create, mode: "replace" }, input, { statusCode: 422 }) };
20
- } },
21
- "presentations.batchUpdate": { scopes: writable,
22
- validateResult: value => typeof value?.presentationId === "string" && (value.replies === undefined || Array.isArray(value.replies)),
23
- request(input) {
24
- const { presentationId, requests, requiredRevisionId } = validateSchemaPayload({ schema: batch, mode: "replace" }, input, { statusCode: 422 });
25
- if (!requests.length || requests.length > 100 || Buffer.byteLength(JSON.stringify(requests)) > 1048576 || requests.some(request => !request || typeof request !== "object" || Array.isArray(request) || Object.keys(request).length !== 1 || !/^[a-zA-Z][a-zA-Z0-9]+$/.test(Object.keys(request)[0]) || !Object.values(request)[0] || typeof Object.values(request)[0] !== "object" || Array.isArray(Object.values(request)[0]))) throw Object.assign(new Error("Supply 1–100 native Slides request objects, one operation per object, within 1 MiB."), { statusCode: 422 });
14
+ "presentations.create": googleOperation(writable, { title: { type: "string", required: true, minLength: 1, maxLength: 1024 } },
15
+ body => ({ method: "POST", url: `${origin}/v1/presentations`, body }), validPresentation),
16
+ "presentations.batchUpdate": googleOperation(writable,
17
+ { presentationId: documentId, requests: { type: "array", required: true }, requiredRevisionId: { type: "string", minLength: 1, maxLength: 1024 } },
18
+ ({ presentationId, requests, requiredRevisionId }) => {
19
+ googleBatchRequests(requests, "Slides");
26
20
  return { method: "POST", url: `${origin}/v1/presentations/${encodeURIComponent(presentationId)}:batchUpdate`, body: { requests, ...(requiredRevisionId ? { writeControl: { requiredRevisionId } } : {}) } };
27
- }
28
- },
21
+ },
22
+ value => typeof value?.presentationId === "string" && (value.replies === undefined || Array.isArray(value.replies))),
29
23
  "pages.get": googleRead(readScopes, { presentationId: documentId, pageObjectId: pageId }, ({ presentationId, pageObjectId }) => ({ url: `${origin}/v1/presentations/${encodeURIComponent(presentationId)}/pages/${encodeURIComponent(pageObjectId)}` }), value => typeof value?.objectId === "string"),
30
24
  "pages.getThumbnail": googleRead(readScopes, { presentationId: documentId, pageObjectId: pageId, size: { type: "string", enum: ["SMALL", "MEDIUM", "LARGE"] } }, ({ presentationId, pageObjectId, size }) => ({ url: `${origin}/v1/presentations/${encodeURIComponent(presentationId)}/pages/${encodeURIComponent(pageObjectId)}/thumbnail`, query: { "thumbnailProperties.mimeType": "PNG", "thumbnailProperties.thumbnailSize": size } }), value => {
31
25
  if (typeof value?.contentUrl !== "string" || !Number.isFinite(value.width) || !Number.isFinite(value.height) || value.width <= 0 || value.height <= 0) return false;
@@ -1,5 +1,7 @@
1
1
  import { createSchema } from "json-rest-schema";
2
2
  import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
3
+ import { ConnectorError } from "@jskit-ai/connectors-core/server";
4
+ import { validatedOperation } from "./jsonOperation.js";
3
5
 
4
6
  const oauth = Object.freeze({
5
7
  issuer: "https://accounts.google.com",
@@ -10,15 +12,21 @@ const googleScope = (name) => name.startsWith("https:") ? name : `https://www.go
10
12
  const documentId = { type: "string", required: true, minLength: 1, maxLength: 1024, validator: (value) => /^[a-zA-Z0-9_-]+$/u.test(value) || "Enter the document ID from its URL." };
11
13
  const pageToken = { type: "string", minLength: 1, maxLength: 4096 };
12
14
 
15
+ // Request builders report rejected input as the caller's mistake, not a provider outage.
16
+ const inputInvalid = (message, statusCode = 422) => new ConnectorError("connector_input_invalid", message, { statusCode });
17
+ const nativeRequest = request => request && typeof request === "object" && !Array.isArray(request) && Object.keys(request).length === 1 &&
18
+ /^[a-zA-Z][a-zA-Z0-9]+$/.test(Object.keys(request)[0]) && Boolean(Object.values(request)[0]) &&
19
+ typeof Object.values(request)[0] === "object" && !Array.isArray(Object.values(request)[0]);
20
+ // Docs, Slides and Sheets batchUpdate share one limit: 1-100 single-operation request objects within 1 MiB.
21
+ function googleBatchRequests(requests, product) {
22
+ if (!requests.length || requests.length > 100 || Buffer.byteLength(JSON.stringify(requests)) > 1048576 || !requests.every(nativeRequest)) {
23
+ throw inputInvalid(`Supply 1–100 native ${product} request objects, one operation per object, within 1 MiB.`);
24
+ }
25
+ return requests;
26
+ }
27
+
13
28
  function googleOperation(scopes, fields, request, validateResult) {
14
- const schema = createSchema(fields);
15
- return {
16
- scopes: scopes.map(scope => `https://www.googleapis.com/auth/${scope}`),
17
- validateResult,
18
- request(input) {
19
- return request(validateSchemaPayload({ schema, mode: "replace" }, input, { statusCode: 422 }));
20
- }
21
- };
29
+ return validatedOperation(fields, request, validateResult, scopes.map(googleScope));
22
30
  }
23
31
 
24
32
  function googleRead(scopes, fields, destination, validateResult) {
@@ -58,4 +66,4 @@ function googleFileVerification(get, field) {
58
66
  validateResult: value => get.validateResult(value) || Array.isArray(value?.files)
59
67
  };
60
68
  }
61
- export { googleOperation, googleRead, googleProvider, googleFileVerification, documentId, pageToken };
69
+ export { googleScope, googleOperation, googleBatchRequests, inputInvalid, googleRead, googleProvider, googleFileVerification, documentId, pageToken };
@@ -3,32 +3,31 @@ import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
3
3
 
4
4
  // Providers with custom paths or bodies share input validation, while retaining
5
5
  // ownership of their request construction and response checks.
6
- function validatedOperation(fields, build, validateResult) {
6
+ function validatedOperation(fields, build, validateResult, scopes = []) {
7
7
  const schema = createSchema(fields);
8
8
  return {
9
- scopes: [],
10
- request(input) {
11
- return build(validateSchemaPayload({ schema, mode: "replace" }, input, { statusCode: 422 }));
9
+ scopes,
10
+ request(input, settings = {}, providerData) {
11
+ return build(validateSchemaPayload({ schema, mode: "replace" }, input, { statusCode: 422 }), settings, providerData);
12
12
  },
13
13
  validateResult
14
14
  };
15
15
  }
16
16
 
17
+ // Copies validated values into a URL's query string.
18
+ function withQuery(url, values) {
19
+ const destination = new URL(url);
20
+ for (const [name, value] of Object.entries(values)) destination.searchParams.set(name, String(value));
21
+ return destination.href;
22
+ }
23
+
17
24
  // Provider endpoints with either query parameters or a JSON request body.
18
25
  function jsonOperation(url, fields, validateResult, method = "GET") {
19
- const schema = createSchema(fields);
20
- return {
21
- scopes: [],
22
- request(input, settings = {}) {
23
- const values = validateSchemaPayload({ schema, mode: "replace" }, input, { statusCode: 422 });
24
- const endpoint = typeof url === "function" ? url(settings) : url;
25
- if (method === "POST") return { method, url: endpoint, body: values };
26
- const destination = new URL(endpoint);
27
- for (const [name, value] of Object.entries(values)) destination.searchParams.set(name, String(value));
28
- return { method, url: destination.href };
29
- },
30
- validateResult
31
- };
26
+ return validatedOperation(fields, (values, settings) => {
27
+ const endpoint = typeof url === "function" ? url(settings) : url;
28
+ if (method === "POST") return { method, url: endpoint, body: values };
29
+ return { method, url: withQuery(endpoint, values) };
30
+ }, validateResult);
32
31
  }
33
32
 
34
- export { jsonOperation, validatedOperation };
33
+ export { jsonOperation, validatedOperation, withQuery };
@@ -1,7 +1,6 @@
1
1
  import { createSchema } from "json-rest-schema";
2
- import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
3
2
  import { lexwareDefinition } from "../shared/tokens.js";
4
- import { jsonOperation } from "./jsonOperation.js";
3
+ import { jsonOperation, validatedOperation } from "./jsonOperation.js";
5
4
 
6
5
  const id = { type: "string", required: true, validator: value => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value) || "Use a Lexware resource UUID." };
7
6
  const text = { type: "string", minLength: 1, maxLength: 1000 };
@@ -10,10 +9,6 @@ const paging = { page: { type: "integer", min: 0, max: 100000, defaultTo: 0 }, s
10
9
  const pageResult = result => Array.isArray(result?.content) && typeof result.first === "boolean" && typeof result.last === "boolean" &&
11
10
  ["totalPages", "totalElements", "numberOfElements", "number"].every(field => Number.isInteger(result[field]) && result[field] >= 0) && Number.isInteger(result.size) && result.size > 0;
12
11
  const resourceResult = result => typeof result?.id === "string";
13
- function operation(fields, build, validateResult = resourceResult) {
14
- const schema = createSchema(fields);
15
- return { scopes: [], request(input) { return build(validateSchemaPayload({ schema, mode: "replace" }, input, { statusCode: 422 })); }, validateResult };
16
- }
17
12
  const line = createSchema({
18
13
  name: { ...text, required: true }, description: { ...text, maxLength: 5000 },
19
14
  quantity: { type: "number", required: true, min: 0.000001, max: 1000000 },
@@ -31,7 +26,7 @@ const lexwareProvider = Object.freeze({
31
26
  ...paging, customer: { type: "boolean" }, vendor: { type: "boolean" }
32
27
  }, pageResult),
33
28
  ...Object.fromEntries(["contacts", "articles", "invoices", "vouchers"].map(resource => [
34
- `${resource}.get`, operation({ id }, ({ id }) => ({ method: "GET", url: `https://api.lexware.io/v1/${resource}/${id}` }))
29
+ `${resource}.get`, validatedOperation({ id }, ({ id }) => ({ method: "GET", url: `https://api.lexware.io/v1/${resource}/${id}` }), resourceResult)
35
30
  ])),
36
31
  "articles.list": jsonOperation("https://api.lexware.io/v1/articles", {
37
32
  ...paging, articleNumber: text, gtin: text, type: { type: "string", enum: ["PRODUCT", "SERVICE"] }
@@ -42,7 +37,7 @@ const lexwareProvider = Object.freeze({
42
37
  voucherStatus: { type: "string", required: true, enum: ["any", "draft", "open", "overdue", "paid", "paidoff", "voided", "transferred", "sepadebit", "unchecked"] },
43
38
  contactId: { ...id, required: false }, archived: { type: "boolean" }
44
39
  }, pageResult),
45
- "invoices.createDraft": operation({
40
+ "invoices.createDraft": validatedOperation({
46
41
  contactId: id, voucherDate: date, shippingDate: date,
47
42
  shippingType: { type: "string", required: true, enum: ["service", "delivery"] },
48
43
  lineItems: { type: "array", required: true, validator: value => value.length > 0 && value.length <= 100 || "Use 1–100 invoice lines.",
@@ -53,7 +48,7 @@ const lexwareProvider = Object.freeze({
53
48
  body: { ...other, voucherDate, address: { contactId },
54
49
  lineItems: lineItems.map(({ netAmount, taxRatePercentage, ...item }) => ({ ...item, type: "custom", unitPrice: { currency: "EUR", netAmount, taxRatePercentage } })),
55
50
  totalPrice: { currency: "EUR" }, taxConditions: { taxType: "net" }, shippingConditions: { shippingDate, shippingType } }
56
- }))
51
+ }), resourceResult)
57
52
  }
58
53
  });
59
54
  export { lexwareProvider };
@@ -1,6 +1,7 @@
1
1
  import { createSchema } from "json-rest-schema";
2
2
  import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
3
- import { resendDefinition } from "../shared/definitions.js";
3
+ import { resendDefinition } from "../shared/resend.js";
4
+ import { validatedOperation, withQuery } from "./jsonOperation.js";
4
5
 
5
6
  const pagination = createSchema({
6
7
  limit: { type: "integer", min: 1, max: 100, defaultTo: 20 },
@@ -32,17 +33,12 @@ const membershipResult = (result) => result?.object === "contact_segment" && typ
32
33
  const listResult = (result) => result?.object === "list" && Array.isArray(result.data);
33
34
  // Fixed provider routes; the application authorizes each named operation before HTTP.
34
35
  function marketingOperation(fields, method, destination, validateResult = hasId, draft = false) {
35
- const schema = createSchema(fields);
36
- return { scopes: [], request(input) {
37
- const values = validateSchemaPayload({ schema, mode: "replace" }, input, { statusCode: 422 });
36
+ return validatedOperation(fields, values => {
38
37
  const { id, segmentId, ...body } = values;
39
- const url = new URL(`https://api.resend.com/${destination(values)}`);
40
- if (method === "GET") {
41
- for (const [key, value] of Object.entries(body)) url.searchParams.set(key, String(value));
42
- return { method, url: url.href };
43
- }
44
- return { method, url: url.href, ...(method === "DELETE" ? {} : { body: draft ? { ...body, send: false } : body }) };
45
- }, validateResult };
38
+ const url = `https://api.resend.com/${destination(values)}`;
39
+ if (method === "GET") return { method, url: withQuery(url, body) };
40
+ return { method, url: new URL(url).href, ...(method === "DELETE" ? {} : { body: draft ? { ...body, send: false } : body }) };
41
+ }, validateResult);
46
42
  }
47
43
 
48
44
  const resendProvider = Object.freeze({
@@ -82,9 +78,7 @@ const resendProvider = Object.freeze({
82
78
  scopes: [],
83
79
  request(input) {
84
80
  const query = validateSchemaPayload({ schema: pagination, mode: "replace" }, input, { statusCode: 422 });
85
- const url = new URL("https://api.resend.com/domains");
86
- for (const [name, value] of Object.entries(query)) url.searchParams.set(name, String(value));
87
- return { method: "GET", url: url.href };
81
+ return { method: "GET", url: withQuery("https://api.resend.com/domains", query) };
88
82
  },
89
83
  validateResult: (result) => result?.object === "list" && Array.isArray(result.data) && typeof result.has_more === "boolean"
90
84
  }
@@ -60,6 +60,8 @@ function semrushKeywordReport(target, kind) {
60
60
  };
61
61
  }
62
62
 
63
+ const trackingObject = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value) && value.error === undefined);
64
+
63
65
  function semrushTrackingReport(type, positions = false) {
64
66
  const operation = jsonOperation("https://api.semrush.com/", {
65
67
  campaignId: { type: "string", required: true, pattern: /^\d+(?:_\d+)?$/u, maxLength: 80 },
@@ -72,7 +74,14 @@ function semrushTrackingReport(type, positions = false) {
72
74
  display_filter: { type: "string", minLength: 1, maxLength: 4000 }
73
75
  } : {})
74
76
  });
75
- return { scopes: [], request(input, settings) {
77
+ return { scopes: [], validateResult(value, request) {
78
+ const url = new URL(request.url);
79
+ return trackingObject(value) && value.data !== null && typeof value.data === "object" && /^(0|[1-9]\d*)$/u.test(String(value.total)) &&
80
+ (type === "tracking_campaign_dates" ? Object.values(value.data).every((entry) => /^\d{8}$/u.test(String(entry?.Dt))) :
81
+ String(value.limit) === url.searchParams.get("display_limit") && String(value.offset) === url.searchParams.get("display_offset") &&
82
+ Object.values(value.data).length <= Number(url.searchParams.get("display_limit")) &&
83
+ Object.values(value.data).every((entry) => typeof entry?.Ph === "string" && typeof entry.Pi === "string" && entry.Dt !== null && typeof entry.Dt === "object"));
84
+ }, request(input, settings) {
76
85
  if (!settings.v3ApiKeyRef) throw new ConnectorError("connector_binding_missing", "Configure the Semrush V3 key for position tracking.");
77
86
  const result = operation.request(input);
78
87
  const url = new URL(result.url);
@@ -118,6 +127,32 @@ function validProject(project) {
118
127
  Array.isArray(project.tools) && project.tools.every((tool) => typeof tool?.name === "string" && tool.name.length > 0);
119
128
  }
120
129
 
130
+ // Every V4 answer carries a successful envelope; each operation then checks it answered its own request.
131
+ function v4Result(check, status = 200) {
132
+ return (value, request) => value?.meta?.success === true && value.meta.status_code === status &&
133
+ typeof value.meta.request_id === "string" && value.meta.request_id.length > 0 && value.error === undefined &&
134
+ Boolean(check(value, new URL(request.url), request));
135
+ }
136
+ const count = (value) => Number.isSafeInteger(value) && value >= 0;
137
+ // Backlink reports keep the requested scope and, when Semrush supplies them, the requested page bounds.
138
+ function backlinkResult(report) {
139
+ return v4Result((value, url) => {
140
+ const { meta, data } = value;
141
+ if (meta.scope !== undefined && meta.scope !== url.searchParams.get("scope")) return false;
142
+ if (report === "overview") return ["backlinks_count", "domains_count", "urls_count", "score"].every((field) => count(data?.[field]));
143
+ // Some report envelopes omit pagination metadata; validate it when supplied.
144
+ return Array.isArray(data) && data.length <= Number(url.searchParams.get("limit")) && data.every((row) => {
145
+ if (!row || typeof row !== "object" || Array.isArray(row)) return false;
146
+ if (report === "links") return typeof row.source_url === "string" && typeof row.target_url === "string" &&
147
+ typeof row.anchor === "string" && typeof row.is_nofollow === "boolean";
148
+ if (report === "ref-domains") return typeof row.domain === "string" && count(row.backlinks_count) && count(row.domain_score);
149
+ return typeof row.anchor === "string" && count(row.backlinks_count) && count(row.domains_count);
150
+ }) && ["limit", "offset"].every((field) => meta[field] === undefined || String(meta[field]) === url.searchParams.get(field)) &&
151
+ (meta.total === undefined || count(meta.total));
152
+ });
153
+ }
154
+ const requestedProject = (url) => Number(url.pathname.split("/").at(-1));
155
+
121
156
  const semrushProvider = Object.freeze({
122
157
  ...semrushDefinition, apiOrigins: ["https://api.semrush.com"],
123
158
  apiKey: { headers: (key) => {
@@ -128,7 +163,11 @@ const semrushProvider = Object.freeze({
128
163
  } },
129
164
  checkOperation: "projects.list",
130
165
  operations: {
131
- "tracking.campaigns": { scopes: [], request(input, settings) {
166
+ "tracking.campaigns": { scopes: [], validateResult: (value, request) => trackingObject(value) &&
167
+ String(value.project_id) === new URL(request.url).pathname.split("/")[4] && Array.isArray(value.campaigns) &&
168
+ value.campaigns.every((campaign) => typeof campaign?.id === "string" && /^\d+(?:_\d+)?$/u.test(campaign.id) &&
169
+ typeof campaign.url === "string" && typeof campaign.device === "string" && typeof campaign.isGathering === "boolean"),
170
+ request(input, settings) {
132
171
  const { projectId } = validateSchemaPayload({ schema: projectSchema, mode: "replace" }, input, { statusCode: 422 });
133
172
  if (!settings.v3ApiKeyRef) throw new ConnectorError("connector_binding_missing", "Configure the Semrush V3 key for campaign discovery.");
134
173
  return { method: "GET", url: `https://api.semrush.com/management/v1/projects/${projectId}/tracking/campaigns` };
@@ -159,10 +198,10 @@ const semrushProvider = Object.freeze({
159
198
  return { ...result, url: url.href };
160
199
  }
161
200
  },
162
- "backlinks.overview": jsonOperation("https://api.semrush.com/apis/v4/backlinks/v1/overview", backlinkTarget),
163
- "backlinks.list": jsonOperation("https://api.semrush.com/apis/v4/backlinks/v1/links", backlinkPage),
164
- "backlinks.referringDomains": jsonOperation("https://api.semrush.com/apis/v4/backlinks/v1/ref-domains", backlinkPage),
165
- "backlinks.anchors": jsonOperation("https://api.semrush.com/apis/v4/backlinks/v1/anchors", backlinkPage),
201
+ "backlinks.overview": jsonOperation("https://api.semrush.com/apis/v4/backlinks/v1/overview", backlinkTarget, backlinkResult("overview")),
202
+ "backlinks.list": jsonOperation("https://api.semrush.com/apis/v4/backlinks/v1/links", backlinkPage, backlinkResult("links")),
203
+ "backlinks.referringDomains": jsonOperation("https://api.semrush.com/apis/v4/backlinks/v1/ref-domains", backlinkPage, backlinkResult("ref-domains")),
204
+ "backlinks.anchors": jsonOperation("https://api.semrush.com/apis/v4/backlinks/v1/anchors", backlinkPage, backlinkResult("anchors")),
166
205
  "keywords.metrics": {
167
206
  scopes: [],
168
207
  request(input) {
@@ -179,39 +218,57 @@ const semrushProvider = Object.freeze({
179
218
  }
180
219
  url.searchParams.set("format", "json");
181
220
  return { ...result, url: url.href };
182
- }
221
+ },
222
+ validateResult: v4Result(({ meta, data }, url) => meta.keyword === url.searchParams.get("keyword") && meta.country === url.searchParams.get("country") &&
223
+ typeof meta.month === "string" && /^\d{4}-(0[1-9]|1[0-2])$/u.test(meta.month) &&
224
+ (!url.searchParams.has("month") || meta.month === url.searchParams.get("month")) &&
225
+ data !== null && typeof data === "object" && !Array.isArray(data) &&
226
+ ["cpc", "number_of_results", "search_volume"].every((key) => typeof data[key] === "string" && /^\d+$/u.test(data[key])) &&
227
+ ["competitive_density", "keyword_difficulty"].every((key) => Number.isFinite(data[key])) &&
228
+ ["intents", "serp_features"].every((key) => Array.isArray(data[key]) && data[key].every((value) => typeof value === "string")) &&
229
+ Array.isArray(data.trends) && data.trends.every(Number.isFinite))
183
230
  },
184
231
  "projects.create": jsonOperation(projectsUrl, {
185
232
  domain: { type: "string", required: true, minLength: 1, maxLength: 253, pattern: /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/iu },
186
233
  project_name: projectName
187
- }, undefined, "POST"),
234
+ }, v4Result(({ data }, url, request) => validProject(data) && data.project_name === request.body.project_name &&
235
+ data.domain.toLowerCase() === request.body.domain.toLowerCase(), 201), "POST"),
188
236
  "projects.update": {
189
237
  scopes: [],
190
238
  request(input) {
191
239
  const { projectId, project_name } = validateSchemaPayload({ schema: renameSchema, mode: "replace" }, input, { statusCode: 422 });
192
240
  return { method: "PATCH", url: `${projectsUrl}/${projectId}`, body: { project_name } };
193
- }
241
+ },
242
+ validateResult: v4Result(({ data }, url, request) => validProject(data) && data.project_id === requestedProject(url) &&
243
+ data.project_name === request.body.project_name)
194
244
  },
195
245
  "projects.list": jsonOperation(projectsUrl, {
196
246
  scope: { type: "string", enum: ["OWN", "ALL", "SHARED", "CORPORATE"], defaultTo: "OWN" },
197
247
  limit: { type: "integer", min: 1, max: 1000, defaultTo: 100 },
198
248
  offset: { type: "integer", min: 0, max: Number.MAX_SAFE_INTEGER, defaultTo: 0 }
199
- }),
249
+ }, v4Result(({ meta, data }, url) => {
250
+ const limit = Number(url.searchParams.get("limit"));
251
+ return meta.scope === url.searchParams.get("scope") && meta.limit === limit && meta.offset === Number(url.searchParams.get("offset")) &&
252
+ count(meta.total_count) && Array.isArray(data) && data.length <= limit && data.length <= meta.total_count && data.every(validProject);
253
+ })),
200
254
  "projects.delete": {
201
255
  scopes: [],
202
256
  request(input) {
203
257
  const { projectId } = validateSchemaPayload({ schema: projectSchema, mode: "replace" }, input, { statusCode: 422 });
204
258
  return { method: "DELETE", url: `${projectsUrl}/${projectId}` };
205
- }
259
+ },
260
+ validateResult: v4Result(({ data }, url) => Number.isSafeInteger(data?.project_id) && data.project_id === requestedProject(url))
206
261
  },
207
262
  "projects.get": {
208
263
  scopes: [],
209
264
  request(input) {
210
265
  const { projectId } = validateSchemaPayload({ schema: projectSchema, mode: "replace" }, input, { statusCode: 422 });
211
266
  return { method: "GET", url: `${projectsUrl}/${projectId}` };
212
- }
267
+ },
268
+ validateResult: v4Result(({ data }, url) => validProject(data) && data.project_id === requestedProject(url))
213
269
  }
214
270
  },
271
+ // V3 reports need their own key and CSV/JSON decoding; V4 maps Semrush's error envelope. Operations validate results.
215
272
  async exchange(address, options, { request, fetchImpl, settings, resolveReference }) {
216
273
  const path = new URL(address).pathname;
217
274
  if (path === "/" || path.startsWith("/management/v1/projects/") || path.startsWith("/reports/v1/projects/")) {
@@ -238,28 +295,9 @@ const semrushProvider = Object.freeze({
238
295
  }
239
296
  if (text.length > 2_000_000) throw new ConnectorError("connector_response_invalid", "Semrush returned an oversized V3 report.", { statusCode: 502 });
240
297
  if (path !== "/") {
241
- let result;
242
- try { result = JSON.parse(text); } catch {
298
+ try { return JSON.parse(text); } catch {
243
299
  throw new ConnectorError("connector_response_invalid", "Semrush returned invalid tracking JSON.", { statusCode: 502 });
244
300
  }
245
- let valid = result && typeof result === "object" && !Array.isArray(result) && result.error === undefined;
246
- if (path.endsWith("/campaigns")) {
247
- valid &&= String(result.project_id) === path.split("/")[4] && Array.isArray(result.campaigns) &&
248
- result.campaigns.every((campaign) => typeof campaign?.id === "string" && /^\d+(?:_\d+)?$/u.test(campaign.id) &&
249
- typeof campaign.url === "string" && typeof campaign.device === "string" && typeof campaign.isGathering === "boolean");
250
- } else {
251
- valid &&= result.data !== null && typeof result.data === "object" &&
252
- /^(0|[1-9]\d*)$/u.test(String(result.total));
253
- if (url.searchParams.get("type") === "tracking_campaign_dates") {
254
- valid &&= Object.values(result.data).every((entry) => /^\d{8}$/u.test(String(entry?.Dt)));
255
- } else {
256
- valid &&= String(result.limit) === url.searchParams.get("display_limit") && String(result.offset) === url.searchParams.get("display_offset") &&
257
- Object.values(result.data).length <= Number(url.searchParams.get("display_limit")) &&
258
- Object.values(result.data).every((entry) => typeof entry?.Ph === "string" && typeof entry.Pi === "string" && entry.Dt !== null && typeof entry.Dt === "object");
259
- }
260
- }
261
- if (!valid) throw new ConnectorError("connector_response_invalid", "Semrush returned unexpected tracking data.", { statusCode: 502 });
262
- return result;
263
301
  }
264
302
  const columns = url.searchParams.get("type") === "domain_rank" ? domainColumns : keywordColumns;
265
303
  const rowLimit = columns === domainColumns ? 1 : Number(url.searchParams.get("display_limit")) - Number(url.searchParams.get("display_offset"));
@@ -296,63 +334,6 @@ const semrushProvider = Object.freeze({
296
334
  }
297
335
  throw new ConnectorError("connector_provider_failed", "Semrush could not complete this request.", { statusCode: 502 });
298
336
  }
299
- const url = new URL(address);
300
- const meta = result?.meta;
301
- const creating = options.method === "POST" && url.pathname === "/apis/v4/projects/v1/projects";
302
- let valid = meta?.success === true && meta.status_code === (creating ? 201 : 200) &&
303
- typeof meta.request_id === "string" && meta.request_id.length > 0 && result.error === undefined;
304
- if (url.pathname === "/apis/v4/keywords/v1/metrics") {
305
- const data = result?.data;
306
- valid &&= meta.keyword === url.searchParams.get("keyword") && meta.country === url.searchParams.get("country") &&
307
- typeof meta.month === "string" && /^\d{4}-(0[1-9]|1[0-2])$/u.test(meta.month) &&
308
- (!url.searchParams.has("month") || meta.month === url.searchParams.get("month")) &&
309
- data !== null && typeof data === "object" && !Array.isArray(data) &&
310
- ["cpc", "number_of_results", "search_volume"].every((key) => typeof data[key] === "string" && /^\d+$/u.test(data[key])) &&
311
- ["competitive_density", "keyword_difficulty"].every((key) => Number.isFinite(data[key])) &&
312
- ["intents", "serp_features"].every((key) => Array.isArray(data[key]) && data[key].every((value) => typeof value === "string")) &&
313
- Array.isArray(data.trends) && data.trends.every(Number.isFinite);
314
- } else if (url.pathname.startsWith("/apis/v4/backlinks/v1/")) {
315
- const report = url.pathname.split("/").at(-1);
316
- const count = (value) => Number.isSafeInteger(value) && value >= 0;
317
- if (report === "overview") {
318
- valid &&= ["backlinks_count", "domains_count", "urls_count", "score"].every((field) => count(result.data?.[field]));
319
- } else {
320
- valid &&= Array.isArray(result.data) && result.data.length <= Number(url.searchParams.get("limit")) &&
321
- result.data.every((row) => {
322
- if (!row || typeof row !== "object" || Array.isArray(row)) return false;
323
- if (report === "links") return typeof row.source_url === "string" && typeof row.target_url === "string" &&
324
- typeof row.anchor === "string" && typeof row.is_nofollow === "boolean";
325
- if (report === "ref-domains") return typeof row.domain === "string" && count(row.backlinks_count) && count(row.domain_score);
326
- return typeof row.anchor === "string" && count(row.backlinks_count) && count(row.domains_count);
327
- });
328
- // Some report envelopes omit pagination metadata; validate it when supplied.
329
- for (const field of ["limit", "offset"]) {
330
- if (meta?.[field] !== undefined) valid &&= String(meta[field]) === url.searchParams.get(field);
331
- }
332
- if (meta?.total !== undefined) valid &&= count(meta.total);
333
- }
334
- if (meta?.scope !== undefined) valid &&= meta.scope === url.searchParams.get("scope");
335
- } else if (creating) {
336
- const submitted = options.body;
337
- valid &&= validProject(result.data) && result.data.project_name === submitted.project_name &&
338
- result.data.domain.toLowerCase() === submitted.domain.toLowerCase();
339
- } else if (url.pathname === "/apis/v4/projects/v1/projects") {
340
- const limit = Number(url.searchParams.get("limit"));
341
- valid &&= meta.scope === url.searchParams.get("scope") && meta.limit === limit &&
342
- meta.offset === Number(url.searchParams.get("offset")) &&
343
- Number.isSafeInteger(meta.total_count) && meta.total_count >= 0 &&
344
- Array.isArray(result.data) && result.data.length <= limit && result.data.length <= meta.total_count &&
345
- result.data.every(validProject);
346
- } else if (options.method === "DELETE") {
347
- valid &&= Number.isSafeInteger(result.data?.project_id) &&
348
- result.data.project_id === Number(url.pathname.split("/").at(-1));
349
- } else {
350
- valid &&= validProject(result.data) && result.data.project_id === Number(url.pathname.split("/").at(-1));
351
- if (options.method === "PATCH") valid &&= result.data.project_name === options.body.project_name;
352
- }
353
- if (!valid) {
354
- throw new ConnectorError("connector_response_invalid", "Semrush returned unexpected report or project data.", { statusCode: 502 });
355
- }
356
337
  return result;
357
338
  }
358
339
  });