@jskit-ai/connectors-catalog 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/docs/ai.md +3 -3
- package/docs/airtable.md +1 -1
- package/docs/algolia.md +2 -2
- package/docs/amazon-redshift.md +2 -2
- package/docs/apollo-io.md +2 -2
- package/docs/asana.md +2 -2
- package/docs/ashby.md +1 -1
- package/docs/attention.md +2 -2
- package/docs/aws-athena.md +1 -1
- package/docs/aws-credentials.md +3 -3
- package/docs/aws-s3.md +1 -1
- package/docs/bigquery.md +2 -2
- package/docs/brevo.md +2 -2
- package/docs/calendly.md +3 -3
- package/docs/canva.md +2 -2
- package/docs/chargebee.md +2 -2
- package/docs/clay.md +2 -2
- package/docs/clickhouse.md +1 -1
- package/docs/confidence-flags.md +1 -1
- package/docs/contentful.md +1 -1
- package/docs/dbt-semantic-layer.md +2 -2
- package/docs/elevenlabs.md +2 -2
- package/docs/firebase-cloud-messaging.md +3 -3
- package/docs/firecrawl.md +4 -4
- package/docs/fireflies.md +1 -1
- package/docs/gatewayapi.md +1 -1
- package/docs/gemini-enterprise.md +1 -1
- package/docs/github-api.md +1 -1
- package/docs/gitlab-api.md +1 -1
- package/docs/gmail.md +1 -1
- package/docs/gong.md +1 -1
- package/docs/google-ads.md +3 -3
- package/docs/google-analytics.md +2 -2
- package/docs/google-docs.md +1 -1
- package/docs/google-drive.md +1 -1
- package/docs/google-maps-platform.md +2 -2
- package/docs/google-search-console.md +1 -1
- package/docs/google-sheets.md +1 -1
- package/docs/google-slides.md +2 -2
- package/docs/granola.md +2 -2
- package/docs/hex.md +2 -2
- package/docs/heygen.md +2 -2
- package/docs/inngest.md +1 -1
- package/docs/klipy.md +1 -1
- package/docs/lightspeed.md +2 -2
- package/docs/linear.md +1 -1
- package/docs/linkedin.md +2 -2
- package/docs/mailgun.md +1 -1
- package/docs/microsoft-excel.md +2 -2
- package/docs/microsoft-fabric.md +1 -1
- package/docs/microsoft-outlook.md +1 -1
- package/docs/microsoft-powerpoint.md +1 -1
- package/docs/microsoft-teams.md +1 -1
- package/docs/microsoft-word.md +1 -1
- package/docs/notion.md +1 -1
- package/docs/oura.md +3 -3
- package/docs/paddle.md +3 -3
- package/docs/pipedrive.md +2 -2
- package/docs/prestashop.md +1 -1
- package/docs/replicate.md +1 -1
- package/docs/resend.md +1 -1
- package/docs/salesforce.md +1 -1
- package/docs/sanity.md +3 -3
- package/docs/semrush.md +2 -2
- package/docs/sentry.md +1 -1
- package/docs/slack.md +1 -1
- package/docs/snowflake.md +5 -5
- package/docs/storyblok.md +1 -1
- package/docs/stripe.md +2 -2
- package/docs/tally.md +1 -1
- package/docs/telegram.md +2 -2
- package/docs/tiktok.md +3 -3
- package/docs/twilio.md +3 -3
- package/docs/twitch.md +2 -2
- package/docs/wave.md +3 -3
- package/docs/wix.md +13 -13
- package/docs/wordpress-com.md +1 -1
- package/docs/wordpress-self-hosted.md +1 -1
- package/docs/workday.md +2 -2
- package/docs/x-twitter.md +3 -3
- package/docs/xero.md +3 -3
- package/docs/zoho-books.md +2 -2
- package/docs/zoho-crm.md +2 -2
- package/package.json +4 -3
- package/patterns/ai-connections/PATTERN.md +16 -1
- package/patterns/ai-connections/example/ai-model.js +12 -3
- package/patterns/ai-connections/example/perplexity-answer.js +15 -4
- package/patterns/api-key-connection/PATTERN.md +9 -1
- package/patterns/api-key-connection/example/connections.js +5 -2
- package/patterns/aws-storage-queries/PATTERN.md +13 -3
- package/patterns/aws-storage-queries/example/s3-transfer.js +14 -3
- package/patterns/event-delivery/PATTERN.md +1 -1
- package/patterns/firebase-messaging/PATTERN.md +10 -0
- package/patterns/firebase-messaging/example/server/notifications.js +8 -0
- package/patterns/google-ads-search/PATTERN.md +18 -7
- package/patterns/google-ads-search/example/ads-setup.js +13 -5
- package/patterns/oauth-connection/PATTERN.md +1 -1
- package/patterns/paddle-catalogue/PATTERN.md +11 -2
- package/patterns/paddle-catalogue/example/create-products.js +5 -2
- package/patterns/public-image/PATTERN.md +10 -1
- package/patterns/public-image/example/logo-url.js +4 -1
- package/patterns/redshift-queries/PATTERN.md +1 -1
- package/patterns/source-scanning/PATTERN.md +13 -2
- package/patterns/source-scanning/example/source-scanner.js +9 -0
- package/src/server/ai.js +19 -5
- package/src/server/apify.js +2 -2
- package/src/server/asana.js +6 -6
- package/src/server/ashby.js +2 -1
- package/src/server/attention.js +12 -11
- package/src/server/awsTransport.js +6 -0
- package/src/server/bigquery.js +5 -4
- package/src/server/brevo.js +2 -1
- package/src/server/clay.js +2 -1
- package/src/server/clickhouse.js +2 -1
- package/src/server/confidence.js +6 -1
- package/src/server/connectorValues.js +33 -0
- package/src/server/databricks.js +7 -7
- package/src/server/dbt-semantic-layer.js +5 -5
- package/src/server/elevenlabs.js +2 -2
- package/src/server/firebase-cloud-messaging.js +2 -2
- package/src/server/gemini-enterprise.js +5 -5
- package/src/server/google-ads-search.js +68 -48
- package/src/server/google-ads.js +15 -17
- package/src/server/google-drive.js +7 -7
- package/src/server/google-maps-platform.js +4 -2
- package/src/server/google-search-console.js +4 -3
- package/src/server/google.js +8 -4
- package/src/server/googleAdsApi.js +14 -0
- package/src/server/granola.js +2 -2
- package/src/server/hubspot.js +2 -1
- package/src/server/incident-io.js +3 -2
- package/src/server/lexware.js +5 -4
- package/src/server/lightspeed.js +7 -5
- package/src/server/linkedin.js +2 -2
- package/src/server/mcpTransport.js +8 -4
- package/src/server/microsoft-fabric.js +3 -3
- package/src/server/microsoft-onedrive.js +2 -2
- package/src/server/microsoft-onenote.js +4 -3
- package/src/server/microsoft-outlook.js +6 -6
- package/src/server/microsoft-sharepoint.js +4 -2
- package/src/server/microsoft-teams.js +2 -1
- package/src/server/microsoft.js +20 -13
- package/src/server/notion.js +2 -1
- package/src/server/pipedrive.js +2 -1
- package/src/server/salesforce.js +12 -12
- package/src/server/semrush.js +9 -8
- package/src/server/snowflake.js +20 -20
- package/src/server/storyblok.js +2 -1
- package/src/server/tally.js +2 -1
- package/src/server/telegram.js +2 -6
- package/src/server/tiktok.js +3 -3
- package/src/server/twilio.js +3 -2
- package/src/server/twitch.js +5 -3
- package/src/server/types.js +13 -0
- package/src/server/wix.js +20 -878
- package/src/server/wixBookings.js +449 -0
- package/src/server/wixCms.js +132 -0
- package/src/server/wixStores.js +296 -0
- package/src/server/wixValues.js +35 -0
- package/src/server/wiz.js +18 -4
- package/src/server/woocommerce.js +1 -1
- package/src/server/wordpress-com.js +2 -1
- package/src/server/workday.js +9 -9
- package/src/server/x-twitter.js +2 -1
- package/src/server/xero.js +40 -40
- package/src/server/zoho-books.js +6 -6
- package/src/server/zoho-crm.js +5 -5
- package/src/shared/confidence.js +6 -1
- package/src/shared/dbt-semantic-layer.js +1 -1
- package/src/shared/figma.js +1 -1
- package/src/shared/google.js +8 -4
- package/src/shared/googleScope.js +4 -0
- package/src/shared/microsoft-fabric.js +4 -5
- package/src/shared/microsoft.js +8 -3
- package/src/shared/salesforce.js +1 -1
- package/src/shared/tokens.js +7 -3
- package/src/shared/uuid.js +13 -0
- package/src/shared/wix.js +4 -3
- package/src/shared/zoho-books.js +3 -4
- package/src/shared/zoho-crm.js +1 -1
- package/src/shared/{zoho-regions.js → zoho.js} +4 -1
- package/test/ai.test.js +1 -1
- package/test/brevo.test.js +1 -1
- package/test/chargebee.test.js +1 -1
- package/test/google-ads.test.js +8 -4
- package/test/linkedin.test.js +1 -1
- package/test/resend.test.js +2 -2
- package/test/salesforce.test.js +1 -1
- package/test/snowflake.test.js +7 -7
- package/test/tiktok.test.js +1 -1
- package/test/wiz.test.js +1 -1
|
@@ -2,6 +2,8 @@ import { createSchema } from "json-rest-schema";
|
|
|
2
2
|
import { geminiEnterpriseDefinition, geminiEnterpriseScope } from "../shared/gemini-enterprise.js";
|
|
3
3
|
import { googleProvider } from "./google.js";
|
|
4
4
|
import { jsonOperation } from "./jsonOperation.js";
|
|
5
|
+
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
6
|
+
import { isText } from "./connectorValues.js";
|
|
5
7
|
|
|
6
8
|
function apiOrigin({ location = "global" } = {}) {
|
|
7
9
|
return `https://${location === "global" ? "" : `${location}-`}discoveryengine.googleapis.com`;
|
|
@@ -9,12 +11,10 @@ function apiOrigin({ location = "global" } = {}) {
|
|
|
9
11
|
function engineUrl(settings) {
|
|
10
12
|
return `${apiOrigin(settings)}/v1/projects/${settings.projectId}/locations/${settings.location || "global"}/collections/default_collection/engines/${settings.engineId}`;
|
|
11
13
|
}
|
|
12
|
-
const object = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
13
|
-
const text = (value) => typeof value === "string" && value.length > 0;
|
|
14
14
|
const geminiEnterpriseProvider = Object.freeze({
|
|
15
15
|
...googleProvider(geminiEnterpriseDefinition, apiOrigin(), "engine.get", {
|
|
16
16
|
"engine.get": {
|
|
17
|
-
...jsonOperation(engineUrl, {}, (result) =>
|
|
17
|
+
...jsonOperation(engineUrl, {}, (result) => isRecord(result) && !result.error && isText(result.displayName)
|
|
18
18
|
&& typeof result.name === "string" && /^projects\/[a-z0-9-]+\/locations\/(global|us|eu)\/collections\/default_collection\/engines\/[a-z0-9][a-z0-9_-]{0,62}$/u.test(result.name)),
|
|
19
19
|
scopes: [geminiEnterpriseScope]
|
|
20
20
|
},
|
|
@@ -33,12 +33,12 @@ const geminiEnterpriseProvider = Object.freeze({
|
|
|
33
33
|
}) }
|
|
34
34
|
}) },
|
|
35
35
|
pageToken: { type: "string", minLength: 1, maxLength: 16000, noTrim: true }
|
|
36
|
-
}, (result) =>
|
|
36
|
+
}, (result) => isRecord(result) && !result.error && isText(result.attributionToken)
|
|
37
37
|
&& (result.totalSize === undefined || Number.isInteger(result.totalSize) && result.totalSize >= 0)
|
|
38
38
|
&& (result.nextPageToken === undefined || typeof result.nextPageToken === "string")
|
|
39
39
|
&& (result.redirectUri === undefined || typeof result.redirectUri === "string")
|
|
40
40
|
&& (result.results === undefined || Array.isArray(result.results) && result.results.length <= 25
|
|
41
|
-
&& result.results.every((item) =>
|
|
41
|
+
&& result.results.every((item) => isRecord(item) && isText(item.id) && isRecord(item.document))), "POST"),
|
|
42
42
|
scopes: [geminiEnterpriseScope]
|
|
43
43
|
}
|
|
44
44
|
}),
|
|
@@ -4,25 +4,25 @@ import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
|
|
|
4
4
|
import { ConnectorError } from "@jskit-ai/connectors-core/server";
|
|
5
5
|
import { validateGoogleAdsSearchPlan } from "../shared/google-ads-search.js";
|
|
6
6
|
import { validatedOperation } from "./jsonOperation.js";
|
|
7
|
+
import { googleAdsApi, googleAdsScope, loginCustomerHeaders } from "./googleAdsApi.js";
|
|
8
|
+
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
7
9
|
|
|
8
|
-
const api = "https://googleads.googleapis.com/v25";
|
|
9
|
-
const scope = "https://www.googleapis.com/auth/adwords";
|
|
10
10
|
const customerId = { type: "string", required: true, validator: value => /^[0-9]{10}$/.test(value) || "Use a ten-digit customer ID." };
|
|
11
11
|
const id = { type: "string", required: true, validator: value => /^[0-9]{1,20}$/.test(value) || "Use a numeric campaign ID." };
|
|
12
12
|
const error = (message, codes) => { throw new ConnectorError("connector_input_invalid", message, { statusCode: 422, ...codes }); };
|
|
13
13
|
const hash = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
14
14
|
function requestOperation(fields, build, validateResult) {
|
|
15
15
|
return validatedOperation(fields, (values, settings) => (
|
|
16
|
-
{ method: "POST", ...build(values), headers: settings
|
|
17
|
-
), validateResult, [
|
|
16
|
+
{ method: "POST", ...build(values), headers: loginCustomerHeaders(settings) }
|
|
17
|
+
), validateResult, [googleAdsScope]);
|
|
18
18
|
}
|
|
19
|
-
const mutated = result =>
|
|
19
|
+
const mutated = result => isRecord(result) && !result.partialFailureError &&
|
|
20
20
|
(result.results === undefined || Array.isArray(result.results)) && (result.mutateOperationResponses === undefined || Array.isArray(result.mutateOperationResponses));
|
|
21
21
|
|
|
22
22
|
function searchMutation(plan, validateOnly) {
|
|
23
23
|
const p = validateGoogleAdsSearchPlan(plan), root = `customers/${p.customerId}`;
|
|
24
24
|
const budget = `${root}/campaignBudgets/-1`, campaign = `${root}/campaigns/-2`, group = `${root}/adGroups/-3`, goal = `${root}/customConversionGoals/-4`;
|
|
25
|
-
return { url: `${
|
|
25
|
+
return { url: `${googleAdsApi}/${root}/googleAds:mutate`, body: { validateOnly, partialFailure: false, mutateOperations: [
|
|
26
26
|
{ campaignBudgetOperation: { create: { resourceName: budget, name: `${p.name} budget`, amountMicros: p.dailyBudgetMicros, deliveryMethod: "STANDARD", explicitlyShared: false } } },
|
|
27
27
|
{ campaignOperation: { create: { resourceName: campaign, name: p.name, advertisingChannelType: "SEARCH", status: "PAUSED", campaignBudget: budget,
|
|
28
28
|
manualCpc: {}, containsEuPoliticalAdvertising: "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING",
|
|
@@ -39,15 +39,28 @@ function searchMutation(plan, validateOnly) {
|
|
|
39
39
|
] } };
|
|
40
40
|
}
|
|
41
41
|
const googleAdsSearchOperations = {
|
|
42
|
-
"search.validate": { scopes: [
|
|
43
|
-
"search.createPaused": { scopes: [
|
|
42
|
+
"search.validate": { scopes: [googleAdsScope], request: (input, settings) => ({ method: "POST", ...searchMutation(input, true), headers: loginCustomerHeaders(settings) }), validateResult: mutated },
|
|
43
|
+
"search.createPaused": { scopes: [googleAdsScope], request: (input, settings) => ({ method: "POST", ...searchMutation(input, false), headers: loginCustomerHeaders(settings) }), validateResult: result => mutated(result) && Array.isArray(result.mutateOperationResponses) && result.mutateOperationResponses.some(item => /^customers\/[0-9]{10}\/campaigns\/[0-9]+$/.test(item.campaignResult?.resourceName || "")) },
|
|
44
44
|
"campaigns.setStatus": requestOperation({ customerId, campaignId: id, status: { type: "string", required: true, enum: ["PAUSED", "ENABLED"] } },
|
|
45
|
-
p => ({ url: `${
|
|
45
|
+
p => ({ url: `${googleAdsApi}/customers/${p.customerId}/campaigns:mutate`, body: { operations: [{ update: { resourceName: `customers/${p.customerId}/campaigns/${p.campaignId}`, status: p.status }, updateMask: "status" }] } }), result => mutated(result) && result.results?.length === 1),
|
|
46
46
|
"conversions.createWebsite": requestOperation({ customerId, name: { type: "string", required: true, minLength: 1, maxLength: 100 } },
|
|
47
|
-
p => ({ url: `${
|
|
47
|
+
p => ({ url: `${googleAdsApi}/customers/${p.customerId}/conversionActions:mutate`, body: { operations: [{ create: { name: p.name, type: "WEBPAGE", category: "SUBMIT_LEAD_FORM", status: "ENABLED", countingType: "ONE_PER_CLICK", primaryForGoal: true } }] } }), result => mutated(result) && result.results?.length === 1)
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Runs the saved Search plan through the Google Ads integration's operations: it
|
|
52
|
+
* discovers accounts and targets, reviews the plan and creates its campaign paused,
|
|
53
|
+
* creates a website conversion, inspects, launches and pauses a campaign, and reports
|
|
54
|
+
* campaign metrics. Creating and launching need the id of an unchanged review, a hash
|
|
55
|
+
* of the caller, the plan and what was reviewed; the service stores nothing.
|
|
56
|
+
* @param {object} options
|
|
57
|
+
* @param {import("@jskit-ai/connectors-core/server/types").ConnectorsCore} options.connections
|
|
58
|
+
* The connection service whose Google Ads integration it invokes.
|
|
59
|
+
* @param {Record<string, any>} options.configuration The integration configuration, with the Search plan under `extensions.googleAdsSearch`.
|
|
60
|
+
* @param {{ applicationId: string, subjectId: string }} options.context The caller's context, which authorize() receives;
|
|
61
|
+
* its application and subject also bind each review.
|
|
62
|
+
* @param {string} options.integrationId
|
|
63
|
+
*/
|
|
51
64
|
function createGoogleAdsSearchService({ connections, configuration, context, integrationId }) {
|
|
52
65
|
const slot = configuration.integrations?.[integrationId];
|
|
53
66
|
if (slot?.provider !== "google-ads") error("Choose a Google Ads integration.", { messageCode: "connectors.googleAdsSearch.chooseIntegration" });
|
|
@@ -90,44 +103,51 @@ function createGoogleAdsSearchService({ connections, configuration, context, int
|
|
|
90
103
|
if (details.ads.length > 20 || details.targets.length > 30 || details.keywords.length > 30) error("This campaign exceeds the bounded Search review. Manage it in Google Ads.", { messageCode: "connectors.googleAdsSearch.campaignExceedsBoundedSearchReview" });
|
|
91
104
|
return { ...details, reviewId: review(details) };
|
|
92
105
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
async function discover(id) {
|
|
107
|
+
if (!id) return { accounts: (await invoke("customers.listAccessible", {})).resourceNames || [] };
|
|
108
|
+
const selected = await account(id);
|
|
109
|
+
return { account: selected,
|
|
110
|
+
clients: selected.manager ? (await invoke("customers.listClients", { customerId: id })).results || [] : [],
|
|
111
|
+
conversions: await query(id, "SELECT conversion_action.id, conversion_action.name, conversion_action.status, conversion_action.type, conversion_action.tag_snippets FROM conversion_action WHERE conversion_action.type = 'WEBPAGE' AND conversion_action.status = 'ENABLED' LIMIT 50"),
|
|
112
|
+
campaigns: await query(id, "SELECT campaign.id, campaign.name, campaign.status FROM campaign WHERE campaign.advertising_channel_type = 'SEARCH' AND campaign.status != 'REMOVED' LIMIT 50") };
|
|
113
|
+
}
|
|
114
|
+
async function targets(id, name) {
|
|
115
|
+
customer(id);
|
|
116
|
+
if (typeof name !== "string" || !/^[\p{L}\p{N} .-]{2,80}$/u.test(name)) error("Enter a location name using letters, numbers, spaces, dots or hyphens.", { messageCode: "connectors.googleAdsSearch.enterLocationNameUsingLetters" });
|
|
117
|
+
return { locations: await query(id, `SELECT geo_target_constant.id, geo_target_constant.canonical_name, geo_target_constant.status FROM geo_target_constant WHERE geo_target_constant.name LIKE '%${name}%' AND geo_target_constant.status = 'ENABLED' LIMIT 20`),
|
|
118
|
+
languages: await query(id, "SELECT language_constant.id, language_constant.name FROM language_constant WHERE language_constant.targetable = TRUE LIMIT 100") };
|
|
119
|
+
}
|
|
120
|
+
async function create(reviewId) {
|
|
121
|
+
const checked = await preview();
|
|
122
|
+
if (reviewId !== checked.reviewId) error("The Search plan changed. Review it again before creation.", { messageCode: "connectors.googleAdsSearch.searchPlanChangedReviewAgain" });
|
|
123
|
+
const response = await invoke("search.createPaused", checked.plan);
|
|
124
|
+
const resource = response.mutateOperationResponses.find(item => item.campaignResult)?.campaignResult.resourceName;
|
|
125
|
+
return { campaignId: resource.split("/").at(-1), status: "PAUSED" };
|
|
126
|
+
}
|
|
127
|
+
const createConversion = ({ customerId, name }) => invoke("conversions.createWebsite", { customerId, name });
|
|
128
|
+
async function launch({ campaignId, reviewId, trackingConfirmed, billingConfirmed }) {
|
|
129
|
+
const current = await campaign(campaignId);
|
|
130
|
+
if (current.reviewId !== reviewId) error("The campaign changed. Inspect and approve it again.", { messageCode: "connectors.googleAdsSearch.campaignChangedInspectApproveAgain" });
|
|
131
|
+
if (trackingConfirmed !== true || billingConfirmed !== true) error("Confirm installed conversion tracking and billing/advertiser readiness before launch.", { messageCode: "connectors.googleAdsSearch.confirmInstalledConversionTrackingBilling" });
|
|
132
|
+
if (current.account.manager || current.account.status !== "ENABLED") error("Choose an active client account.", { messageCode: "connectors.googleAdsSearch.chooseActiveClientAccount" });
|
|
133
|
+
if (current.campaign.containsEuPoliticalAdvertising !== "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING" ||
|
|
134
|
+
!current.customGoals?.length || current.customGoals[0].customConversionGoal?.status !== "ENABLED") error("Review a non-political Search campaign with an enabled custom conversion goal.", { messageCode: "connectors.googleAdsSearch.reviewNonPoliticalSearchCampaign" });
|
|
135
|
+
if (!current.ads.length || !current.keywords.length || current.campaign.biddingStrategyType !== "MANUAL_CPC") error("This screen launches only Search campaigns with manual CPC, ads and keywords. Manage other campaigns in Google Ads.", { messageCode: "connectors.googleAdsSearch.screenLaunchesSearchCampaignsManual" });
|
|
136
|
+
if (current.campaign.status !== "PAUSED") error("Only a paused campaign can be launched here.", { messageCode: "connectors.googleAdsSearch.pausedCampaignCanLaunchedHere" });
|
|
137
|
+
return invoke("campaigns.setStatus", { customerId: current.customerId, campaignId, status: "ENABLED" });
|
|
138
|
+
}
|
|
139
|
+
const pause = id => invoke("campaigns.setStatus", { customerId: plan().customerId, campaignId: campaignId(id), status: "PAUSED" });
|
|
140
|
+
const report = async () => ({ campaigns: await query(plan().customerId, "SELECT campaign.id, campaign.name, campaign.status, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date DURING LAST_30_DAYS AND campaign.advertising_channel_type = 'SEARCH' LIMIT 50") });
|
|
141
|
+
return Object.freeze({
|
|
142
|
+
discover,
|
|
143
|
+
targets,
|
|
108
144
|
preview,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (reviewId !== checked.reviewId) error("The Search plan changed. Review it again before creation.", { messageCode: "connectors.googleAdsSearch.searchPlanChangedReviewAgain" });
|
|
112
|
-
const response = await invoke("search.createPaused", checked.plan);
|
|
113
|
-
const resource = response.mutateOperationResponses.find(item => item.campaignResult)?.campaignResult.resourceName;
|
|
114
|
-
return { campaignId: resource.split("/").at(-1), status: "PAUSED" };
|
|
115
|
-
},
|
|
116
|
-
createConversion: ({ customerId, name }) => invoke("conversions.createWebsite", { customerId, name }),
|
|
145
|
+
create,
|
|
146
|
+
createConversion,
|
|
117
147
|
campaign,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (current.account.manager || current.account.status !== "ENABLED") error("Choose an active client account.", { messageCode: "connectors.googleAdsSearch.chooseActiveClientAccount" });
|
|
123
|
-
if (current.campaign.containsEuPoliticalAdvertising !== "DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING" ||
|
|
124
|
-
!current.customGoals?.length || current.customGoals[0].customConversionGoal?.status !== "ENABLED") error("Review a non-political Search campaign with an enabled custom conversion goal.", { messageCode: "connectors.googleAdsSearch.reviewNonPoliticalSearchCampaign" });
|
|
125
|
-
if (!current.ads.length || !current.keywords.length || current.campaign.biddingStrategyType !== "MANUAL_CPC") error("This screen launches only Search campaigns with manual CPC, ads and keywords. Manage other campaigns in Google Ads.", { messageCode: "connectors.googleAdsSearch.screenLaunchesSearchCampaignsManual" });
|
|
126
|
-
if (current.campaign.status !== "PAUSED") error("Only a paused campaign can be launched here.", { messageCode: "connectors.googleAdsSearch.pausedCampaignCanLaunchedHere" });
|
|
127
|
-
return invoke("campaigns.setStatus", { customerId: current.customerId, campaignId, status: "ENABLED" });
|
|
128
|
-
},
|
|
129
|
-
pause: id => invoke("campaigns.setStatus", { customerId: plan().customerId, campaignId: campaignId(id), status: "PAUSED" }),
|
|
130
|
-
report: async () => ({ campaigns: await query(plan().customerId, "SELECT campaign.id, campaign.name, campaign.status, metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.conversions FROM campaign WHERE segments.date DURING LAST_30_DAYS AND campaign.advertising_channel_type = 'SEARCH' LIMIT 50") })
|
|
131
|
-
};
|
|
148
|
+
launch,
|
|
149
|
+
pause,
|
|
150
|
+
report
|
|
151
|
+
});
|
|
132
152
|
}
|
|
133
153
|
export { googleAdsSearchOperations, createGoogleAdsSearchService };
|
package/src/server/google-ads.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
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";
|
|
3
|
+
import { ConnectorError, providerStatusError } from "@jskit-ai/connectors-core/server";
|
|
4
4
|
import { googleAdsSearchOperations } from "./google-ads-search.js";
|
|
5
5
|
import { googleAdsDefinition } from "../shared/google-ads.js";
|
|
6
6
|
import { googleProvider, googleRead } from "./google.js";
|
|
7
|
+
import { googleAdsOrigin, googleAdsApi, googleAdsScope, loginCustomerHeaders } from "./googleAdsApi.js";
|
|
8
|
+
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
7
9
|
|
|
8
|
-
const origin = "https://googleads.googleapis.com";
|
|
9
|
-
const api = `${origin}/v25`;
|
|
10
|
-
const scope = "https://www.googleapis.com/auth/adwords";
|
|
11
|
-
const record = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
12
10
|
const customerId = { type: "string", required: true, noTrim: true, minLength: 10, maxLength: 10,
|
|
13
11
|
validator: (value) => /^[0-9]{10}$/u.test(value) || "Use a 10-digit customer ID without hyphens." };
|
|
14
12
|
const pageToken = { type: "string", minLength: 1, maxLength: 4096, noTrim: true,
|
|
@@ -21,27 +19,27 @@ const clientsSchema = createSchema({ customerId, pageToken });
|
|
|
21
19
|
|
|
22
20
|
function searchRequest(schema, input, settings, fixedQuery) {
|
|
23
21
|
const { customerId: id, ...body } = validateSchemaPayload({ schema, mode: "replace" }, input, { statusCode: 422 });
|
|
24
|
-
return { method: "POST", url: `${
|
|
25
|
-
headers: settings
|
|
22
|
+
return { method: "POST", url: `${googleAdsApi}/customers/${id}/googleAds:search`,
|
|
23
|
+
headers: loginCustomerHeaders(settings),
|
|
26
24
|
body: fixedQuery ? { ...body, query: fixedQuery } : body };
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
function searchResult(value) {
|
|
30
|
-
return
|
|
31
|
-
(value.results === undefined || Array.isArray(value.results) && value.results.length <= 10000 && value.results.every(
|
|
28
|
+
return isRecord(value) && !value.error &&
|
|
29
|
+
(value.results === undefined || Array.isArray(value.results) && value.results.length <= 10000 && value.results.every(isRecord)) &&
|
|
32
30
|
(value.nextPageToken === undefined || typeof value.nextPageToken === "string" && value.nextPageToken.length <= 4096 && !/[\p{Cc}]/u.test(value.nextPageToken)) &&
|
|
33
31
|
(value.fieldMask === undefined || typeof value.fieldMask === "string") &&
|
|
34
32
|
(value.totalResultsCount === undefined || typeof value.totalResultsCount === "string" && /^[0-9]+$/u.test(value.totalResultsCount));
|
|
35
33
|
}
|
|
36
34
|
|
|
37
35
|
const googleAdsProvider = Object.freeze({
|
|
38
|
-
...googleProvider(googleAdsDefinition,
|
|
36
|
+
...googleProvider(googleAdsDefinition, googleAdsOrigin, "customers.listAccessible", {
|
|
39
37
|
...googleAdsSearchOperations,
|
|
40
|
-
"customers.listAccessible": googleRead([
|
|
41
|
-
(value) =>
|
|
38
|
+
"customers.listAccessible": googleRead([googleAdsScope], {}, () => ({ url: `${googleAdsApi}/customers:listAccessibleCustomers` }),
|
|
39
|
+
(value) => isRecord(value) && !value.error && (value.resourceNames === undefined || Array.isArray(value.resourceNames) &&
|
|
42
40
|
value.resourceNames.every((name) => typeof name === "string" && /^customers\/[0-9]{10}$/u.test(name)))),
|
|
43
|
-
"reports.search": { scopes: [
|
|
44
|
-
"customers.listClients": { scopes: [
|
|
41
|
+
"reports.search": { scopes: [googleAdsScope], request: (input, settings) => searchRequest(searchSchema, input, settings), validateResult: searchResult },
|
|
42
|
+
"customers.listClients": { scopes: [googleAdsScope], request: (input, settings) => searchRequest(clientsSchema, input, settings,
|
|
45
43
|
"SELECT customer_client.client_customer, customer_client.level, customer_client.manager, customer_client.descriptive_name, customer_client.currency_code, customer_client.time_zone, customer_client.id, customer_client.status FROM customer_client"), validateResult: searchResult }
|
|
46
44
|
}),
|
|
47
45
|
async exchange(url, options, { fetchImpl }) {
|
|
@@ -55,7 +53,7 @@ const googleAdsProvider = Object.freeze({
|
|
|
55
53
|
if (!response.ok || value?.error) {
|
|
56
54
|
const details = Array.isArray(value?.error?.details) ? value.error.details : [];
|
|
57
55
|
const errors = details.flatMap((detail) => Array.isArray(detail?.errors) ? detail.errors : []);
|
|
58
|
-
const codes = errors.flatMap((error) =>
|
|
56
|
+
const codes = errors.flatMap((error) => isRecord(error?.errorCode) ? Object.values(error.errorCode) : []);
|
|
59
57
|
if (response.status === 429 || value?.error?.status === "RESOURCE_EXHAUSTED" || errors.some((error) => error?.errorCode?.quotaError)) {
|
|
60
58
|
throw new ConnectorError("connector_rate_limited", "Google Ads API capacity is exhausted. Check the Cloud project and account quotas.", { statusCode: 429, messageCode: "connectors.googleAds.apiCapacityExhaustedCheckCloud" });
|
|
61
59
|
}
|
|
@@ -70,9 +68,9 @@ const googleAdsProvider = Object.freeze({
|
|
|
70
68
|
}
|
|
71
69
|
if (response.status === 400) throw new ConnectorError("connector_input_invalid", "Google Ads rejected the operation. Check campaign fields, policy requirements and account permissions.", { statusCode: 422, messageCode: "connectors.googleAds.rejectedOperationCheckCampaignFields" });
|
|
72
70
|
if (response.status === 404) throw new ConnectorError("connector_resource_not_found", "This Google Ads resource is unavailable.", { statusCode: 404, messageCode: "connectors.googleAds.resourceUnavailable" });
|
|
73
|
-
throw
|
|
71
|
+
throw providerStatusError(response.status);
|
|
74
72
|
}
|
|
75
|
-
if (response.status !== 200 || !
|
|
73
|
+
if (response.status !== 200 || !isRecord(value)) throw new ConnectorError("connector_response_invalid", "Google Ads returned an unexpected response.", { statusCode: 502, messageCode: "connectors.shared.unexpectedResponse", messageParams: { provider: "Google Ads" } });
|
|
76
74
|
return value;
|
|
77
75
|
}
|
|
78
76
|
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { ConnectorError } from "@jskit-ai/connectors-core/server";
|
|
2
|
+
import { ConnectorError, providerStatusError } from "@jskit-ai/connectors-core/server";
|
|
3
3
|
import { googleDriveDefinition } from "../shared/google.js";
|
|
4
4
|
import { googleOperation, googleRead, googleProvider, inputInvalid, pageToken } from "./google.js";
|
|
5
|
+
import { hasStringId } from "./connectorValues.js";
|
|
5
6
|
const origin = "https://www.googleapis.com";
|
|
6
7
|
const fileId = { type: "string", required: true, minLength: 1, maxLength: 1024, validator: value => /^[A-Za-z0-9_-]+$/.test(value) || "Use a Drive file ID, not a URL." };
|
|
7
8
|
const contentScopes = ["drive", "drive.file", "drive.readonly"];
|
|
8
9
|
const writeScopes = ["drive", "drive.file"];
|
|
9
|
-
const validFile = value => typeof value?.id === "string";
|
|
10
10
|
const validBytes = value => typeof value?.bodyBase64 === "string" && Number.isInteger(value.size) && typeof value.contentType === "string";
|
|
11
11
|
const fields = "id,name,mimeType,parents,size,trashed,webViewLink,capabilities,exportLinks";
|
|
12
12
|
function url(fileId, query = {}, suffix = "") {
|
|
@@ -15,14 +15,14 @@ function url(fileId, query = {}, suffix = "") {
|
|
|
15
15
|
return address.href;
|
|
16
16
|
}
|
|
17
17
|
const baseProvider = googleProvider(googleDriveDefinition, origin, "files.list", {
|
|
18
|
-
"files.get": googleRead([...contentScopes, "drive.metadata", "drive.metadata.readonly"], { fileId }, ({ fileId }) => ({ url: url(fileId, { supportsAllDrives: true, fields }) }),
|
|
18
|
+
"files.get": googleRead([...contentScopes, "drive.metadata", "drive.metadata.readonly"], { fileId }, ({ fileId }) => ({ url: url(fileId, { supportsAllDrives: true, fields }) }), hasStringId),
|
|
19
19
|
"files.download": googleRead(contentScopes, { fileId }, ({ fileId }) => ({ url: url(fileId, { supportsAllDrives: true, alt: "media" }) }), validBytes),
|
|
20
20
|
"files.export": googleRead(contentScopes, { fileId, mimeType: { type: "string", required: true, minLength: 1, maxLength: 200 } }, ({ fileId, mimeType }) => ({ url: url(fileId, { mimeType }, "/export") }), validBytes),
|
|
21
|
-
"files.create": googleOperation(writeScopes, { name: { type: "string", required: true, minLength: 1, maxLength: 1024 }, parentId: { ...fileId, required: false }, folder: { type: "boolean", defaultTo: false } }, ({ name, parentId, folder }) => ({ method: "POST", url: url(undefined, { supportsAllDrives: true, fields }), body: { name, ...(parentId ? { parents: [parentId] } : {}), ...(folder ? { mimeType: "application/vnd.google-apps.folder" } : {}) } }),
|
|
21
|
+
"files.create": googleOperation(writeScopes, { name: { type: "string", required: true, minLength: 1, maxLength: 1024 }, parentId: { ...fileId, required: false }, folder: { type: "boolean", defaultTo: false } }, ({ name, parentId, folder }) => ({ method: "POST", url: url(undefined, { supportsAllDrives: true, fields }), body: { name, ...(parentId ? { parents: [parentId] } : {}), ...(folder ? { mimeType: "application/vnd.google-apps.folder" } : {}) } }), hasStringId),
|
|
22
22
|
"files.update": googleOperation([...writeScopes, "drive.metadata"], { fileId, name: { type: "string", minLength: 1, maxLength: 1024 }, description: { type: "string", maxLength: 16384 }, trashed: { type: "boolean" } }, ({ fileId, ...body }) => {
|
|
23
23
|
if (!Object.keys(body).length) throw inputInvalid("Supply a name, description or trashed value.", { messageCode: "connectors.googleDrive.metadataChangeRequired" });
|
|
24
24
|
return { method: "PATCH", url: url(fileId, { supportsAllDrives: true, fields }), body };
|
|
25
|
-
},
|
|
25
|
+
}, hasStringId),
|
|
26
26
|
"files.upload": googleOperation(writeScopes, {
|
|
27
27
|
fileId: { ...fileId, required: false }, name: { type: "string", required: true, minLength: 1, maxLength: 1024 },
|
|
28
28
|
parentId: { ...fileId, required: false }, contentType: { type: "string", required: true, minLength: 3, maxLength: 200 },
|
|
@@ -39,7 +39,7 @@ const baseProvider = googleProvider(googleDriveDefinition, origin, "files.list",
|
|
|
39
39
|
const metadata = { name, ...(parentId ? { parents: [parentId] } : {}) };
|
|
40
40
|
const body = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n--${boundary}\r\nContent-Type: ${contentType}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]);
|
|
41
41
|
return { method: fileId ? "PATCH" : "POST", url: url(fileId, { uploadType: "multipart", supportsAllDrives: true, fields }).replace("/drive/v3/", "/upload/drive/v3/"), headers: { "Content-Type": `multipart/related; boundary=${boundary}` }, body };
|
|
42
|
-
},
|
|
42
|
+
}, hasStringId),
|
|
43
43
|
"files.list": googleRead(["drive", "drive.file", "drive.metadata", "drive.metadata.readonly", "drive.readonly", "drive.meet.readonly", "drive.photos.readonly"], {
|
|
44
44
|
pageToken, pageSize: { type: "integer", min: 1, max: 1000, defaultTo: 100 },
|
|
45
45
|
q: { type: "string", maxLength: 8192 },
|
|
@@ -56,7 +56,7 @@ const googleDriveProvider = Object.freeze({ ...baseProvider,
|
|
|
56
56
|
const download = target.searchParams.get("alt") === "media" || target.pathname.endsWith("/export");
|
|
57
57
|
if (!upload && !download) return request(address, options);
|
|
58
58
|
const response = await fetchImpl(address, { ...options, credentials: "omit", redirect: "error" });
|
|
59
|
-
if (!response.ok) { await response.body?.cancel(); throw
|
|
59
|
+
if (!response.ok) { await response.body?.cancel(); throw providerStatusError(response.status); }
|
|
60
60
|
const chunks = []; let size = 0;
|
|
61
61
|
const reader = response.body?.getReader();
|
|
62
62
|
if (reader) try { while (true) {
|
|
@@ -3,6 +3,8 @@ import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
|
|
|
3
3
|
import { ConnectorError } from "@jskit-ai/connectors-core/server";
|
|
4
4
|
import { googleMapsPlatformDefinition } from "../shared/google-maps-platform.js";
|
|
5
5
|
import { jsonOperation, validatedOperation } from "./jsonOperation.js";
|
|
6
|
+
import { hasStringId } from "./connectorValues.js";
|
|
7
|
+
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
6
8
|
|
|
7
9
|
const endpoint = "https://maps.googleapis.com/maps/api/geocode/json";
|
|
8
10
|
const language = { type: "string", minLength: 2, maxLength: 35,
|
|
@@ -53,7 +55,7 @@ const googleMapsPlatformProvider = Object.freeze({
|
|
|
53
55
|
},
|
|
54
56
|
operations: {
|
|
55
57
|
"places.search": validatedOperation({ textQuery: { type: "string", required: true, minLength: 1, maxLength: 1000 }, pageSize: { type: "integer", min: 1, max: 20, defaultTo: 10 }, pageToken: { type: "string", minLength: 1, maxLength: 4096 }, languageCode: language }, body => ({ method: "POST", url: "https://places.googleapis.com/v1/places:searchText", headers: { "X-Goog-FieldMask": placeFields.split(",").map(field => `places.${field}`).join(",") + ",nextPageToken" }, body }),
|
|
56
|
-
result => Boolean(
|
|
58
|
+
result => Boolean(isRecord(result) && !result.error && (result.places === undefined || Array.isArray(result.places) && result.places.every(hasStringId)))),
|
|
57
59
|
"places.get": validatedOperation({ placeId: { type: "string", required: true, minLength: 1, maxLength: 512, validator: value => /^[A-Za-z0-9_-]+$/.test(value) || "Use a place ID, not a URL." } }, ({ placeId }) => ({ method: "GET", url: `https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}`, headers: { "X-Goog-FieldMask": placeFields } }), result => typeof result?.id === "string"),
|
|
58
60
|
"routes.compute": validatedOperation({ originLatitude: latitude, originLongitude: longitude, destinationLatitude: latitude, destinationLongitude: longitude,
|
|
59
61
|
travelMode: { type: "string", enum: ["DRIVE", "WALK", "BICYCLE"], defaultTo: "DRIVE" }
|
|
@@ -61,7 +63,7 @@ const googleMapsPlatformProvider = Object.freeze({
|
|
|
61
63
|
origin: { location: { latLng: { latitude: originLatitude, longitude: originLongitude } } },
|
|
62
64
|
destination: { location: { latLng: { latitude: destinationLatitude, longitude: destinationLongitude } } },
|
|
63
65
|
travelMode, polylineEncoding: "GEO_JSON_LINESTRING"
|
|
64
|
-
} }), result => Boolean(
|
|
66
|
+
} }), result => Boolean(isRecord(result) && !result.error && (result.routes === undefined || Array.isArray(result.routes)))),
|
|
65
67
|
"geocoding.forward": jsonOperation(endpoint, {
|
|
66
68
|
address: { type: "string", required: true, minLength: 1, maxLength: 1000,
|
|
67
69
|
validator: (value) => Boolean(value.trim()) || "Enter an address or plus code." },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { googleSearchConsoleDefinition } from "../shared/google.js";
|
|
2
2
|
import { googleOperation, googleRead, googleProvider, inputInvalid } from "./google.js";
|
|
3
|
+
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
4
|
const origin = "https://searchconsole.googleapis.com";
|
|
4
5
|
const readScopes = ["webmasters.readonly", "webmasters"];
|
|
5
6
|
const website = value => { try { const url = new URL(value); return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.hash; } catch { return false; } };
|
|
@@ -7,13 +8,13 @@ const siteUrl = { type: "string", required: true, minLength: 1, maxLength: 2048,
|
|
|
7
8
|
const feedpath = { type: "string", required: true, minLength: 1, maxLength: 2048, validator: value => website(value) || "Use the full sitemap URL." };
|
|
8
9
|
const date = { type: "string", required: true, maxLength: 10, validator: value => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value || "Use a valid YYYY-MM-DD date." };
|
|
9
10
|
const dimensions = ["date", "country", "device", "page", "query", "searchAppearance"];
|
|
10
|
-
const plain = value => Boolean(
|
|
11
|
+
const plain = value => Boolean(isRecord(value) && !value.error);
|
|
11
12
|
function propertyPath(site) { return `${origin}/webmasters/v3/sites/${encodeURIComponent(site)}`; }
|
|
12
13
|
const googleSearchConsoleProvider = googleProvider(googleSearchConsoleDefinition, origin, "sites.list", {
|
|
13
14
|
"sites.get": googleRead(readScopes, { siteUrl }, ({ siteUrl }) => ({ url: propertyPath(siteUrl) }), value => typeof value?.siteUrl === "string" && typeof value.permissionLevel === "string"),
|
|
14
15
|
"searchAnalytics.query": googleOperation(readScopes, { siteUrl, startDate: date, endDate: date,
|
|
15
16
|
dimensions: { type: "array", validator: value => value.length <= dimensions.length && new Set(value).size === value.length && value.every(dimension => dimensions.includes(dimension)) || "Choose distinct supported dimensions." },
|
|
16
|
-
filters: { type: "array", validator: filters => filters.length <= 100 && filters.every(filter =>
|
|
17
|
+
filters: { type: "array", validator: filters => filters.length <= 100 && filters.every(filter => isRecord(filter) && Object.keys(filter).every(key => ["dimension", "operator", "expression"].includes(key)) && dimensions.includes(filter.dimension) && filter.dimension !== "date" && ["contains", "equals", "notContains", "notEquals", "includingRegex", "excludingRegex"].includes(filter.operator) && typeof filter.expression === "string" && filter.expression.length <= 4096) || "Use supported dimension/operator/expression filters." },
|
|
17
18
|
type: { type: "string", enum: ["web", "image", "video", "news", "discover", "googleNews"], defaultTo: "web" },
|
|
18
19
|
rowLimit: { type: "integer", min: 1, max: 25000, defaultTo: 1000 }, startRow: { type: "integer", min: 0, defaultTo: 0 }
|
|
19
20
|
}, ({ siteUrl, filters, ...body }) => {
|
|
@@ -26,6 +27,6 @@ const googleSearchConsoleProvider = googleProvider(googleSearchConsoleDefinition
|
|
|
26
27
|
"sitemaps.submit": googleOperation(["webmasters"], { siteUrl, feedpath }, ({ siteUrl, feedpath }) => ({ method: "PUT", url: `${propertyPath(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}` }), value => value === null || plain(value) && !Object.keys(value).length),
|
|
27
28
|
"sitemaps.delete": googleOperation(["webmasters"], { siteUrl, feedpath }, ({ siteUrl, feedpath }) => ({ method: "DELETE", url: `${propertyPath(siteUrl)}/sitemaps/${encodeURIComponent(feedpath)}` }), value => value === null || plain(value) && !Object.keys(value).length),
|
|
28
29
|
"sites.list": googleRead(["webmasters.readonly", "webmasters"], {}, () => ({ url: `${origin}/webmasters/v3/sites` }),
|
|
29
|
-
(value) => Boolean(
|
|
30
|
+
(value) => Boolean(isRecord(value) && !value.error && (value.siteEntry === undefined || Array.isArray(value.siteEntry))))
|
|
30
31
|
});
|
|
31
32
|
export { googleSearchConsoleProvider };
|
package/src/server/google.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import { createSchema } from "json-rest-schema";
|
|
2
2
|
import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
|
|
3
3
|
import { ConnectorError } from "@jskit-ai/connectors-core/server";
|
|
4
|
+
import { googleScope } from "../shared/googleScope.js";
|
|
4
5
|
import { validatedOperation } from "./jsonOperation.js";
|
|
6
|
+
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
5
7
|
|
|
6
8
|
const oauth = Object.freeze({
|
|
7
9
|
issuer: "https://accounts.google.com",
|
|
8
10
|
authorization_endpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
9
11
|
token_endpoint: "https://oauth2.googleapis.com/token"
|
|
10
12
|
});
|
|
11
|
-
const googleScope = (name) => name.startsWith("https:") ? name : `https://www.googleapis.com/auth/${name}`;
|
|
12
13
|
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." };
|
|
13
14
|
const pageToken = { type: "string", minLength: 1, maxLength: 4096 };
|
|
14
15
|
|
|
15
16
|
// Request builders report rejected input as the caller's mistake, not a provider outage.
|
|
16
17
|
const inputInvalid = (message, { statusCode = 422, ...codes } = {}) => new ConnectorError("connector_input_invalid", message, { statusCode, ...codes });
|
|
17
|
-
const nativeRequest = request =>
|
|
18
|
+
const nativeRequest = request => isRecord(request) && Object.keys(request).length === 1 &&
|
|
18
19
|
/^[a-zA-Z][a-zA-Z0-9]+$/.test(Object.keys(request)[0]) && Boolean(Object.values(request)[0]) &&
|
|
19
20
|
typeof Object.values(request)[0] === "object" && !Array.isArray(Object.values(request)[0]);
|
|
20
21
|
// Docs, Slides and Sheets batchUpdate share one limit: 1-100 single-operation request objects within 1 MiB.
|
|
@@ -50,9 +51,12 @@ function googleRead(scopes, fields, destination, validateResult) {
|
|
|
50
51
|
|
|
51
52
|
function googleProvider(definition, origin, checkOperation, operations) {
|
|
52
53
|
return Object.freeze({
|
|
53
|
-
...definition,
|
|
54
|
+
...definition,
|
|
55
|
+
oauth,
|
|
56
|
+
apiOrigins: [origin],
|
|
54
57
|
authorizationParameters: { access_type: "offline", prompt: "consent" },
|
|
55
|
-
checkOperation,
|
|
58
|
+
checkOperation,
|
|
59
|
+
operations
|
|
56
60
|
});
|
|
57
61
|
}
|
|
58
62
|
function googleFileVerification(get, field) {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// What the Google Ads provider and its Search extension share: the API they call, the
|
|
2
|
+
// permission every operation needs and the header that acts through a manager account.
|
|
3
|
+
|
|
4
|
+
const googleAdsOrigin = "https://googleads.googleapis.com";
|
|
5
|
+
const googleAdsApi = `${googleAdsOrigin}/v25`;
|
|
6
|
+
const googleAdsScope = "https://www.googleapis.com/auth/adwords";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The header that sends a request as the configured manager account, when there is one.
|
|
10
|
+
* @param {{ loginCustomerId?: string }} settings
|
|
11
|
+
*/
|
|
12
|
+
const loginCustomerHeaders = (settings) => settings.loginCustomerId ? { "login-customer-id": settings.loginCustomerId } : {};
|
|
13
|
+
|
|
14
|
+
export { googleAdsOrigin, googleAdsApi, googleAdsScope, loginCustomerHeaders };
|
package/src/server/granola.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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";
|
|
3
|
+
import { ConnectorError, providerStatusError } from "@jskit-ai/connectors-core/server";
|
|
4
4
|
import { granolaDefinition } from "../shared/granola.js";
|
|
5
5
|
import { jsonOperation } from "./jsonOperation.js";
|
|
6
6
|
import { mcpOperations, exchangeMcp } from "./mcpTransport.js";
|
|
@@ -68,7 +68,7 @@ const granolaApiProvider = Object.freeze({
|
|
|
68
68
|
const response = await fetchImpl(address, { ...options, credentials: "omit" });
|
|
69
69
|
if (response.status === 404) throw new ConnectorError("connector_resource_not_found", "The Granola note is unavailable, inaccessible or still being processed.", { statusCode: 404, messageCode: "connectors.granola.noteUnavailable" });
|
|
70
70
|
if (response.status === 413) throw new ConnectorError("connector_response_too_large", "Read the transcript in pages with transcripts.list.", { statusCode: 413, messageCode: "connectors.granola.readTranscriptPagesTranscriptsList" });
|
|
71
|
-
if (!response.ok) throw
|
|
71
|
+
if (!response.ok) throw providerStatusError(response.status);
|
|
72
72
|
let value;
|
|
73
73
|
try { value = await response.json(); } catch { /* checked below */ }
|
|
74
74
|
if (response.status !== 200 || !value || typeof value !== "object" || value.error) {
|
package/src/server/hubspot.js
CHANGED
|
@@ -3,6 +3,7 @@ import { validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
|
|
|
3
3
|
import { ConnectorError } from "@jskit-ai/connectors-core/server";
|
|
4
4
|
import { hubspotDefinition } from "../shared/tokens.js";
|
|
5
5
|
import { jsonOperation } from "./jsonOperation.js";
|
|
6
|
+
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
6
7
|
|
|
7
8
|
const base = "https://api.hubapi.com/crm/objects/2026-09";
|
|
8
9
|
const recordId = { type: "string", required: true, minLength: 1, maxLength: 100,
|
|
@@ -18,7 +19,7 @@ const readFields = {
|
|
|
18
19
|
associations: { type: "string", enum: ["contacts", "deals", "contacts,deals"] },
|
|
19
20
|
archived: { type: "boolean", defaultTo: false }
|
|
20
21
|
};
|
|
21
|
-
const validRecord = value => typeof value?.id === "string" &&
|
|
22
|
+
const validRecord = value => typeof value?.id === "string" && isRecord(value.properties);
|
|
22
23
|
function objectOperations(object) {
|
|
23
24
|
const readScope = `crm.objects.${object}.read`, writeScope = `crm.objects.${object}.write`;
|
|
24
25
|
const read = createSchema({ id: recordId, ...readFields });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createSchema } from "json-rest-schema";
|
|
2
2
|
import { incidentIoDefinition } from "../shared/tokens.js";
|
|
3
3
|
import { jsonOperation, validatedOperation } from "./jsonOperation.js";
|
|
4
|
+
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
5
|
|
|
5
6
|
const id = { type: "string", required: true, minLength: 1, maxLength: 200,
|
|
6
7
|
validator: value => /^[a-zA-Z0-9_-]+$/u.test(value) || "Use the incident.io resource ID, not a URL." };
|
|
@@ -13,11 +14,11 @@ const address = path => `https://api.incident.io${path}`;
|
|
|
13
14
|
const incidentFields = { name: text, summary: text, severity_id: optionalId, incident_status_id: optionalId };
|
|
14
15
|
const followUpFields = { title: { ...text, required: true, maxLength: 1000 }, description: text,
|
|
15
16
|
assignee_id: optionalId, assignee_team_id: optionalId, follow_up_category_id: optionalId, follow_up_priority_option_id: optionalId };
|
|
16
|
-
const literal = value =>
|
|
17
|
+
const literal = value => isRecord(value) &&
|
|
17
18
|
Object.keys(value).length === 1 && typeof value.literal === "string" && value.literal.length <= 20000;
|
|
18
19
|
const attributes = { type: "object", required: true, additionalProperties: true,
|
|
19
20
|
validator: value => Object.keys(value).length <= 250 && Buffer.byteLength(JSON.stringify(value)) <= 100000 &&
|
|
20
|
-
Object.entries(value).every(([key, binding]) => /^[a-zA-Z0-9_-]+$/u.test(key) &&
|
|
21
|
+
Object.entries(value).every(([key, binding]) => /^[a-zA-Z0-9_-]+$/u.test(key) && isRecord(binding) &&
|
|
21
22
|
Object.keys(binding).every(field => ["value", "array_value"].includes(field)) &&
|
|
22
23
|
(binding.value === undefined || literal(binding.value)) &&
|
|
23
24
|
(binding.array_value === undefined || Array.isArray(binding.array_value) && binding.array_value.length <= 100 && binding.array_value.every(literal))) ||
|
package/src/server/lexware.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { createSchema } from "json-rest-schema";
|
|
2
2
|
import { lexwareDefinition } from "../shared/tokens.js";
|
|
3
|
+
import { hasStringId } from "./connectorValues.js";
|
|
3
4
|
import { jsonOperation, validatedOperation } from "./jsonOperation.js";
|
|
5
|
+
import { isUuid } from "../shared/uuid.js";
|
|
4
6
|
|
|
5
|
-
const id = { type: "string", required: true, validator: value =>
|
|
7
|
+
const id = { type: "string", required: true, validator: value => isUuid(value) || "Use a Lexware resource UUID." };
|
|
6
8
|
const text = { type: "string", minLength: 1, maxLength: 1000 };
|
|
7
9
|
const date = { type: "string", required: true, validator: value => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/u.test(value) && Number.isFinite(Date.parse(value)) && new Date(`${value.slice(0, 10)}T00:00:00Z`).toISOString().startsWith(value.slice(0, 10)) || "Use an ISO timestamp with timezone." };
|
|
8
10
|
const paging = { page: { type: "integer", min: 0, max: 100000, defaultTo: 0 }, size: { type: "integer", min: 1, max: 250, defaultTo: 25 } };
|
|
9
11
|
const pageResult = result => Array.isArray(result?.content) && typeof result.first === "boolean" && typeof result.last === "boolean" &&
|
|
10
12
|
["totalPages", "totalElements", "numberOfElements", "number"].every(field => Number.isInteger(result[field]) && result[field] >= 0) && Number.isInteger(result.size) && result.size > 0;
|
|
11
|
-
const resourceResult = result => typeof result?.id === "string";
|
|
12
13
|
const line = createSchema({
|
|
13
14
|
name: { ...text, required: true }, description: { ...text, maxLength: 5000 },
|
|
14
15
|
quantity: { type: "number", required: true, min: 0.000001, max: 1000000 },
|
|
@@ -26,7 +27,7 @@ const lexwareProvider = Object.freeze({
|
|
|
26
27
|
...paging, customer: { type: "boolean" }, vendor: { type: "boolean" }
|
|
27
28
|
}, pageResult),
|
|
28
29
|
...Object.fromEntries(["contacts", "articles", "invoices", "vouchers"].map(resource => [
|
|
29
|
-
`${resource}.get`, validatedOperation({ id }, ({ id }) => ({ method: "GET", url: `https://api.lexware.io/v1/${resource}/${id}` }),
|
|
30
|
+
`${resource}.get`, validatedOperation({ id }, ({ id }) => ({ method: "GET", url: `https://api.lexware.io/v1/${resource}/${id}` }), hasStringId)
|
|
30
31
|
])),
|
|
31
32
|
"articles.list": jsonOperation("https://api.lexware.io/v1/articles", {
|
|
32
33
|
...paging, articleNumber: text, gtin: text, type: { type: "string", enum: ["PRODUCT", "SERVICE"] }
|
|
@@ -48,7 +49,7 @@ const lexwareProvider = Object.freeze({
|
|
|
48
49
|
body: { ...other, voucherDate, address: { contactId },
|
|
49
50
|
lineItems: lineItems.map(({ netAmount, taxRatePercentage, ...item }) => ({ ...item, type: "custom", unitPrice: { currency: "EUR", netAmount, taxRatePercentage } })),
|
|
50
51
|
totalPrice: { currency: "EUR" }, taxConditions: { taxType: "net" }, shippingConditions: { shippingDate, shippingType } }
|
|
51
|
-
}),
|
|
52
|
+
}), hasStringId)
|
|
52
53
|
}
|
|
53
54
|
});
|
|
54
55
|
export { lexwareProvider };
|
package/src/server/lightspeed.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { ConnectorError } from "@jskit-ai/connectors-core/server";
|
|
2
2
|
import { lightspeedDefinition } from "../shared/lightspeed.js";
|
|
3
3
|
import { jsonOperation } from "./jsonOperation.js";
|
|
4
|
+
import { isCount } from "./connectorValues.js";
|
|
5
|
+
import { isUuid } from "../shared/uuid.js";
|
|
6
|
+
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
7
|
|
|
5
8
|
const apiOrigin = (settings) => `https://${settings.domainPrefix}.retail.lightspeed.app`;
|
|
6
|
-
const versionNumber = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
7
9
|
const identifier = (value) => typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/u.test(value);
|
|
8
10
|
|
|
9
11
|
function collection(resource) {
|
|
@@ -13,12 +15,12 @@ function collection(resource) {
|
|
|
13
15
|
page_size: { type: "integer", min: 1, max: 100, defaultTo: 20 },
|
|
14
16
|
...(resource === "sales" ? {} : { deleted: { type: "boolean", defaultTo: false } })
|
|
15
17
|
}, (result) => {
|
|
16
|
-
if (!Array.isArray(result?.data) || !result.data.every((row) => identifier(row?.id) &&
|
|
18
|
+
if (!Array.isArray(result?.data) || !result.data.every((row) => identifier(row?.id) && isCount(row.version))) return false;
|
|
17
19
|
// The customer reference includes a response without the optional envelope version.
|
|
18
20
|
if (resource === "customers" && result.version === undefined) return true;
|
|
19
21
|
const { min, max } = result.version || {};
|
|
20
22
|
return result.data.length === 0 ? min === null && max === null :
|
|
21
|
-
|
|
23
|
+
isCount(min) && isCount(max) && min <= max && result.data.every((row) => row.version >= min && row.version <= max);
|
|
22
24
|
});
|
|
23
25
|
return {
|
|
24
26
|
...operation, scopes: [`${resource}:read`],
|
|
@@ -39,9 +41,9 @@ const inventoryRead = jsonOperation(settings => `${apiOrigin(settings)}/api/2026
|
|
|
39
41
|
size: { type: "integer", min: 1, max: 1000, defaultTo: 100 },
|
|
40
42
|
include_deleted: { type: "boolean", defaultTo: false },
|
|
41
43
|
sort_direction: { type: "string", enum: ["asc", "desc"], defaultTo: "asc" },
|
|
42
|
-
product_id: { type: "string", validator: value =>
|
|
44
|
+
product_id: { type: "string", validator: value => isUuid(value) || "Use a product UUID." },
|
|
43
45
|
variants: { type: "boolean", defaultTo: false }
|
|
44
|
-
}, result => Array.isArray(result) && result.every(row =>
|
|
46
|
+
}, result => Array.isArray(result) && result.every(row => isRecord(row) && isCount(row.version)), "POST");
|
|
45
47
|
|
|
46
48
|
const lightspeedProvider = Object.freeze({
|
|
47
49
|
...lightspeedDefinition,
|