@jskit-ai/connectors-catalog 0.1.1
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 +192 -0
- package/docs/ai-upstream-notices.md +51 -0
- package/docs/ai.md +191 -0
- package/docs/airtable.md +130 -0
- package/docs/algolia.md +190 -0
- package/docs/amazon-redshift.md +229 -0
- package/docs/amplitude.md +210 -0
- package/docs/apify.md +162 -0
- package/docs/apollo-io.md +200 -0
- package/docs/asana.md +126 -0
- package/docs/ashby.md +188 -0
- package/docs/atlassian.md +224 -0
- package/docs/attention.md +162 -0
- package/docs/aws-athena.md +150 -0
- package/docs/aws-credentials.md +75 -0
- package/docs/aws-s3.md +193 -0
- package/docs/bigquery.md +146 -0
- package/docs/brevo.md +122 -0
- package/docs/calendly.md +158 -0
- package/docs/canva.md +191 -0
- package/docs/chargebee.md +192 -0
- package/docs/clay.md +194 -0
- package/docs/clickhouse.md +242 -0
- package/docs/confidence-exp.md +74 -0
- package/docs/confidence-flags.md +75 -0
- package/docs/confidence-oauth.md +137 -0
- package/docs/contentful.md +123 -0
- package/docs/databricks.md +180 -0
- package/docs/dbt-semantic-layer.md +175 -0
- package/docs/elevenlabs.md +102 -0
- package/docs/figma.md +132 -0
- package/docs/firebase-cloud-messaging.md +262 -0
- package/docs/firecrawl.md +137 -0
- package/docs/fireflies.md +95 -0
- package/docs/fireworks-ai.md +53 -0
- package/docs/gatewayapi.md +113 -0
- package/docs/gemini-enterprise.md +224 -0
- package/docs/github-api.md +133 -0
- package/docs/gitlab-api.md +138 -0
- package/docs/gmail.md +133 -0
- package/docs/gong.md +205 -0
- package/docs/google-ads.md +156 -0
- package/docs/google-analytics.md +202 -0
- package/docs/google-docs.md +105 -0
- package/docs/google-drive.md +108 -0
- package/docs/google-maps-platform.md +208 -0
- package/docs/google-oauth.md +123 -0
- package/docs/google-search-console.md +124 -0
- package/docs/google-sheets.md +94 -0
- package/docs/google-slides.md +95 -0
- package/docs/granola.md +178 -0
- package/docs/hex.md +161 -0
- package/docs/heygen.md +124 -0
- package/docs/hubspot.md +142 -0
- package/docs/incident-io.md +118 -0
- package/docs/inngest.md +178 -0
- package/docs/klipy.md +149 -0
- package/docs/lexware.md +144 -0
- package/docs/lightspeed.md +193 -0
- package/docs/linear.md +137 -0
- package/docs/linkedin.md +196 -0
- package/docs/logo-dev.md +152 -0
- package/docs/mailgun.md +153 -0
- package/docs/mapbox.md +174 -0
- package/docs/microsoft-excel.md +97 -0
- package/docs/microsoft-fabric.md +249 -0
- package/docs/microsoft-oauth.md +143 -0
- package/docs/microsoft-onedrive.md +76 -0
- package/docs/microsoft-onenote.md +75 -0
- package/docs/microsoft-outlook.md +90 -0
- package/docs/microsoft-powerpoint.md +119 -0
- package/docs/microsoft-sharepoint.md +75 -0
- package/docs/microsoft-teams.md +72 -0
- package/docs/microsoft-word.md +119 -0
- package/docs/miro.md +117 -0
- package/docs/n8n.md +169 -0
- package/docs/notion.md +137 -0
- package/docs/oura.md +161 -0
- package/docs/paddle.md +168 -0
- package/docs/perplexity.md +85 -0
- package/docs/pipedrive.md +120 -0
- package/docs/polar.md +63 -0
- package/docs/posthog.md +227 -0
- package/docs/prestashop.md +151 -0
- package/docs/replicate.md +60 -0
- package/docs/resend.md +153 -0
- package/docs/salesforce.md +234 -0
- package/docs/sanity.md +122 -0
- package/docs/semrush.md +276 -0
- package/docs/sentry.md +112 -0
- package/docs/sevdesk.md +107 -0
- package/docs/shopify.md +206 -0
- package/docs/slack.md +257 -0
- package/docs/snowflake.md +271 -0
- package/docs/storyblok.md +130 -0
- package/docs/stripe.md +77 -0
- package/docs/supabase.md +51 -0
- package/docs/tally.md +105 -0
- package/docs/telegram.md +165 -0
- package/docs/tiktok.md +201 -0
- package/docs/twilio.md +284 -0
- package/docs/twitch.md +428 -0
- package/docs/wave.md +440 -0
- package/docs/wix.md +1249 -0
- package/docs/wiz.md +177 -0
- package/docs/woocommerce.md +400 -0
- package/docs/wordpress-com.md +358 -0
- package/docs/wordpress-self-hosted.md +252 -0
- package/docs/workday.md +292 -0
- package/docs/x-twitter.md +188 -0
- package/docs/xero.md +429 -0
- package/docs/zoho-books.md +214 -0
- package/docs/zoho-crm.md +192 -0
- package/package.json +153 -0
- package/patterns/ai-connections/PATTERN.md +84 -0
- package/patterns/ai-connections/example/ai-model.js +11 -0
- package/patterns/ai-connections/example/perplexity-answer.js +29 -0
- package/patterns/api-key-connection/PATTERN.md +368 -0
- package/patterns/api-key-connection/example/connections.js +18 -0
- package/patterns/api-key-connection/example/integrations.json +19 -0
- package/patterns/assistant-mcp/PATTERN.md +110 -0
- package/patterns/assistant-mcp/example/integrations.json +13 -0
- package/patterns/assistant-mcp-oauth/PATTERN.md +235 -0
- package/patterns/assistant-mcp-oauth/example/integrations.json +102 -0
- package/patterns/aws-storage-queries/PATTERN.md +169 -0
- package/patterns/aws-storage-queries/example/formats/data-formats.js +35 -0
- package/patterns/aws-storage-queries/example/formats/package-lock.json +49 -0
- package/patterns/aws-storage-queries/example/formats/package.json +12 -0
- package/patterns/aws-storage-queries/example/formats/verify-formats.mjs +37 -0
- package/patterns/aws-storage-queries/example/integrations.json +38 -0
- package/patterns/aws-storage-queries/example/s3-transfer.js +34 -0
- package/patterns/event-delivery/PATTERN.md +151 -0
- package/patterns/event-delivery/example/integrations.json +17 -0
- package/patterns/firebase-messaging/PATTERN.md +156 -0
- package/patterns/firebase-messaging/example/server/notifications.js +23 -0
- package/patterns/google-ads-search/PATTERN.md +92 -0
- package/patterns/google-ads-search/example/ads-setup.js +25 -0
- package/patterns/oauth-connection/PATTERN.md +724 -0
- package/patterns/oauth-connection/example/integrations.json +20 -0
- package/patterns/paddle-catalogue/PATTERN.md +78 -0
- package/patterns/paddle-catalogue/example/create-products.js +38 -0
- package/patterns/public-image/PATTERN.md +84 -0
- package/patterns/public-image/example/integrations.json +13 -0
- package/patterns/public-image/example/logo-url.js +16 -0
- package/patterns/redshift-queries/PATTERN.md +150 -0
- package/patterns/redshift-queries/example/integrations.json +33 -0
- package/patterns/source-scanning/PATTERN.md +83 -0
- package/patterns/source-scanning/example/source-scanner.js +13 -0
- package/src/client/firebase-cloud-messaging.js +10 -0
- package/src/client/google-analytics.js +72 -0
- package/src/client/google-maps-platform.js +39 -0
- package/src/client/logo-dev.js +36 -0
- package/src/server/ai.js +46 -0
- package/src/server/airtable.js +67 -0
- package/src/server/algolia.js +63 -0
- package/src/server/amazon-redshift.js +96 -0
- package/src/server/amplitude.js +31 -0
- package/src/server/apify.js +68 -0
- package/src/server/apollo-io.js +85 -0
- package/src/server/asana.js +64 -0
- package/src/server/ashby.js +61 -0
- package/src/server/atlassian.js +32 -0
- package/src/server/attention.js +93 -0
- package/src/server/aws-athena.js +82 -0
- package/src/server/aws-s3.js +49 -0
- package/src/server/awsTransport.js +75 -0
- package/src/server/bigquery.js +97 -0
- package/src/server/brevo.js +97 -0
- package/src/server/calendly.js +77 -0
- package/src/server/canva.js +13 -0
- package/src/server/chargebee.js +88 -0
- package/src/server/clay.js +78 -0
- package/src/server/clickhouse.js +66 -0
- package/src/server/confidence.js +29 -0
- package/src/server/contentful.js +40 -0
- package/src/server/databricks.js +55 -0
- package/src/server/dbt-semantic-layer.js +95 -0
- package/src/server/elevenlabs.js +81 -0
- package/src/server/figma.js +24 -0
- package/src/server/firebase-cloud-messaging.js +111 -0
- package/src/server/firecrawl.js +70 -0
- package/src/server/fireflies.js +31 -0
- package/src/server/fireworks-ai.js +16 -0
- package/src/server/gatewayapi.js +48 -0
- package/src/server/gemini-enterprise.js +47 -0
- package/src/server/github-api.js +90 -0
- package/src/server/gitlab-api.js +67 -0
- package/src/server/gmail.js +60 -0
- package/src/server/gong.js +52 -0
- package/src/server/google-ads-search.js +134 -0
- package/src/server/google-ads.js +80 -0
- package/src/server/google-docs.js +36 -0
- package/src/server/google-drive.js +71 -0
- package/src/server/google-maps-platform.js +90 -0
- package/src/server/google-search-console.js +31 -0
- package/src/server/google-sheets.js +60 -0
- package/src/server/google-slides.js +36 -0
- package/src/server/google.js +61 -0
- package/src/server/granola.js +115 -0
- package/src/server/graphqlOperation.js +15 -0
- package/src/server/hex.js +29 -0
- package/src/server/heygen.js +54 -0
- package/src/server/hubspot.js +85 -0
- package/src/server/incident-io.js +68 -0
- package/src/server/inngest.js +86 -0
- package/src/server/jsonOperation.js +34 -0
- package/src/server/klipy.js +34 -0
- package/src/server/lexware.js +59 -0
- package/src/server/lightspeed.js +82 -0
- package/src/server/linear.js +52 -0
- package/src/server/linkedin.js +74 -0
- package/src/server/mailgun.js +76 -0
- package/src/server/mapbox.js +46 -0
- package/src/server/mcpRegistration.js +41 -0
- package/src/server/mcpTransport.js +60 -0
- package/src/server/microsoft-excel.js +67 -0
- package/src/server/microsoft-fabric.js +81 -0
- package/src/server/microsoft-onedrive.js +58 -0
- package/src/server/microsoft-onenote.js +52 -0
- package/src/server/microsoft-outlook.js +58 -0
- package/src/server/microsoft-powerpoint.js +5 -0
- package/src/server/microsoft-sharepoint.js +46 -0
- package/src/server/microsoft-teams.js +29 -0
- package/src/server/microsoft-word.js +5 -0
- package/src/server/microsoft.js +88 -0
- package/src/server/miro.js +29 -0
- package/src/server/n8n.js +78 -0
- package/src/server/notion.js +76 -0
- package/src/server/oura.js +48 -0
- package/src/server/paddle.js +83 -0
- package/src/server/perplexity.js +15 -0
- package/src/server/pipedrive.js +95 -0
- package/src/server/polar.js +20 -0
- package/src/server/posthog.js +44 -0
- package/src/server/prestashop.js +42 -0
- package/src/server/replicate.js +42 -0
- package/src/server/resend.js +93 -0
- package/src/server/salesforce.js +84 -0
- package/src/server/sanity.js +31 -0
- package/src/server/semrush.js +359 -0
- package/src/server/sentry.js +30 -0
- package/src/server/sevdesk.js +20 -0
- package/src/server/shopify.js +99 -0
- package/src/server/slack.js +143 -0
- package/src/server/snowflake.js +139 -0
- package/src/server/storyblok.js +49 -0
- package/src/server/stripe.js +13 -0
- package/src/server/supabase.js +13 -0
- package/src/server/tally.js +78 -0
- package/src/server/telegram.js +55 -0
- package/src/server/tiktok.js +86 -0
- package/src/server/twilio.js +90 -0
- package/src/server/twitch.js +409 -0
- package/src/server/wave.js +520 -0
- package/src/server/wix.js +1048 -0
- package/src/server/wiz.js +117 -0
- package/src/server/woocommerce.js +412 -0
- package/src/server/wordpress-com.js +236 -0
- package/src/server/wordpress-self-hosted.js +134 -0
- package/src/server/workday.js +136 -0
- package/src/server/x-twitter.js +110 -0
- package/src/server/xero.js +397 -0
- package/src/server/zoho-books.js +90 -0
- package/src/server/zoho-crm.js +89 -0
- package/src/server/zohoOAuth.js +21 -0
- package/src/shared/ai-models.json +8049 -0
- package/src/shared/ai.js +63 -0
- package/src/shared/algolia.js +29 -0
- package/src/shared/amazon-redshift.js +51 -0
- package/src/shared/amplitude.js +28 -0
- package/src/shared/atlassian.js +43 -0
- package/src/shared/aws.js +72 -0
- package/src/shared/canva.js +66 -0
- package/src/shared/chargebee.js +26 -0
- package/src/shared/clickhouse.js +38 -0
- package/src/shared/confidence.js +28 -0
- package/src/shared/databricks.js +33 -0
- package/src/shared/dbt-semantic-layer.js +32 -0
- package/src/shared/definitions.js +140 -0
- package/src/shared/figma.js +21 -0
- package/src/shared/firebase-cloud-messaging.js +45 -0
- package/src/shared/gemini-enterprise.js +33 -0
- package/src/shared/gong.js +33 -0
- package/src/shared/google-ads-search.js +31 -0
- package/src/shared/google-ads.js +28 -0
- package/src/shared/google-maps-platform.js +28 -0
- package/src/shared/google.js +133 -0
- package/src/shared/granola.js +25 -0
- package/src/shared/hex.js +28 -0
- package/src/shared/inngest.js +35 -0
- package/src/shared/lightspeed.js +75 -0
- package/src/shared/linkedin.js +27 -0
- package/src/shared/logo-dev.js +21 -0
- package/src/shared/mapbox.js +38 -0
- package/src/shared/mcp.js +92 -0
- package/src/shared/microsoft-fabric.js +47 -0
- package/src/shared/microsoft.js +231 -0
- package/src/shared/miro.js +27 -0
- package/src/shared/oura.js +23 -0
- package/src/shared/posthog.js +33 -0
- package/src/shared/prestashop.js +23 -0
- package/src/shared/salesforce.js +36 -0
- package/src/shared/semrush.js +24 -0
- package/src/shared/shopify.js +51 -0
- package/src/shared/siteUrl.js +12 -0
- package/src/shared/slack.js +88 -0
- package/src/shared/snowflake.js +55 -0
- package/src/shared/tiktok.js +22 -0
- package/src/shared/tokens.js +747 -0
- package/src/shared/twilio.js +41 -0
- package/src/shared/twitch.js +49 -0
- package/src/shared/wave.js +52 -0
- package/src/shared/wix.js +40 -0
- package/src/shared/wiz.js +41 -0
- package/src/shared/wordpress-com.js +30 -0
- package/src/shared/wordpress.js +69 -0
- package/src/shared/workday.js +50 -0
- package/src/shared/x-twitter.js +19 -0
- package/src/shared/xero.js +46 -0
- package/src/shared/zoho-books.js +39 -0
- package/src/shared/zoho-crm.js +43 -0
- package/src/shared/zoho-regions.js +12 -0
- package/test/ai.test.js +136 -0
- package/test/airtable.test.js +125 -0
- package/test/algolia.test.js +207 -0
- package/test/amazon-redshift.test.js +272 -0
- package/test/amplitude.test.js +239 -0
- package/test/apify.test.js +93 -0
- package/test/apollo-io.test.js +125 -0
- package/test/asana.test.js +99 -0
- package/test/ashby.test.js +97 -0
- package/test/atlassian.test.js +249 -0
- package/test/attention.test.js +79 -0
- package/test/aws.test.js +405 -0
- package/test/brevo.test.js +71 -0
- package/test/calendly.test.js +136 -0
- package/test/canva.test.js +258 -0
- package/test/chargebee.test.js +195 -0
- package/test/clickhouse.test.js +278 -0
- package/test/confidence.test.js +205 -0
- package/test/databricks.test.js +253 -0
- package/test/dbt-semantic-layer.test.js +221 -0
- package/test/designMcp.test.js +336 -0
- package/test/elevenlabs.test.js +52 -0
- package/test/firebase-cloud-messaging.test.js +242 -0
- package/test/firecrawl.test.js +66 -0
- package/test/gemini-enterprise.test.js +218 -0
- package/test/github-api.test.js +167 -0
- package/test/gitlab-api.test.js +160 -0
- package/test/gong.test.js +192 -0
- package/test/google-ads.test.js +350 -0
- package/test/google-maps-platform.test.js +191 -0
- package/test/google.test.js +710 -0
- package/test/granola.test.js +240 -0
- package/test/hex.test.js +253 -0
- package/test/heygen.test.js +121 -0
- package/test/hubspot.test.js +103 -0
- package/test/inngest.test.js +219 -0
- package/test/lightspeed.test.js +250 -0
- package/test/linear.test.js +146 -0
- package/test/linkedin.test.js +243 -0
- package/test/logo-dev.test.js +79 -0
- package/test/mailgun.test.js +107 -0
- package/test/mapbox.test.js +181 -0
- package/test/mcp.test.js +227 -0
- package/test/microsoft-fabric.test.js +258 -0
- package/test/microsoft.test.js +554 -0
- package/test/n8nOAuth.test.js +188 -0
- package/test/notionOAuth.test.js +183 -0
- package/test/oura.test.js +211 -0
- package/test/paddleCatalogue.test.js +51 -0
- package/test/paddlePermissions.test.js +101 -0
- package/test/perplexityComposition.test.js +39 -0
- package/test/pipedriveOAuth.test.js +140 -0
- package/test/posthog.test.js +200 -0
- package/test/prestashop.test.js +207 -0
- package/test/resend.test.js +163 -0
- package/test/salesforce.test.js +276 -0
- package/test/semrush.test.js +479 -0
- package/test/sentry.test.js +35 -0
- package/test/shopify.test.js +247 -0
- package/test/slack.test.js +320 -0
- package/test/snowflake.test.js +391 -0
- package/test/tally.test.js +75 -0
- package/test/telegram.test.js +58 -0
- package/test/tiktok.test.js +243 -0
- package/test/tokens.test.js +1079 -0
- package/test/twilio.test.js +221 -0
- package/test/twitch.test.js +573 -0
- package/test/wave.test.js +678 -0
- package/test/wix.test.js +1099 -0
- package/test/wiz.test.js +185 -0
- package/test/wordpress-com.test.js +469 -0
- package/test/wordpress.test.js +660 -0
- package/test/workday.test.js +314 -0
- package/test/x-twitter.test.js +256 -0
- package/test/xero.test.js +700 -0
- package/test/zoho-books.test.js +253 -0
- package/test/zoho-crm.test.js +223 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createConnectionService } from "../../connectors-core/src/server/index.js";
|
|
7
|
+
import { createFileConnectionStore, createCredentialProtection } from "../../connectors-core/src/server/fileStorage.js";
|
|
8
|
+
import { linearProvider } from "../src/server/linear.js";
|
|
9
|
+
|
|
10
|
+
for (const perUser of [false, true]) test(`Linear OAuth ${perUser ? "per-user" : "shared rotating"} grants preserve protocol and ownership`, async (t) => {
|
|
11
|
+
const directory = await mkdtemp(path.join(tmpdir(), "linear-oauth-test-"));
|
|
12
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
13
|
+
const callback = "https://example.test/integrations/linear/callback";
|
|
14
|
+
const context = { applicationId: "app", subjectId: "owner" };
|
|
15
|
+
const input = { context, integrationId: "linear" };
|
|
16
|
+
let time = Date.now(), exchanges = 0;
|
|
17
|
+
let tokenScope = "read write";
|
|
18
|
+
const toolCalls = [];
|
|
19
|
+
// Controlled fixture schemas, not a claim about Linear's current tool names.
|
|
20
|
+
const tools = ["fixture_create_project", "fixture_save_issue"].map(name => ({ name,
|
|
21
|
+
inputSchema: { type: "object", properties: { projectId: { type: "string" } }, additionalProperties: true } }));
|
|
22
|
+
const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(4) }, activeKeyId: "current" });
|
|
23
|
+
const options = {
|
|
24
|
+
configuration: { schemaVersion: 1, registrations: { linear: { source: "own", clientId: "client", clientSecretRef: "env:SECRET", callbackUrlRef: "env:CALLBACK", tokenEndpointAuthMethod: "client_secret_post" } }, integrations: {
|
|
25
|
+
linear: { provider: "linear", accountMode: perUser ? "per-user" : "shared", scopes: ["read", "write"], authentication: { method: "oauth2", registrationRef: "linear" } },
|
|
26
|
+
personal: { provider: "linear", accountMode: "shared", scopes: ["read", "write"], authentication: { method: "api-key", secretRef: "env:TOKEN" } }
|
|
27
|
+
} }, providers: [linearProvider], authorize: async owner => owner,
|
|
28
|
+
store: createFileConnectionStore({ directory, protection }), now: () => time,
|
|
29
|
+
resolveReference: async ref => ref === "env:CALLBACK" ? callback : ref === "env:TOKEN" ? "personal" : "secret",
|
|
30
|
+
fetchImpl: async (address, init) => {
|
|
31
|
+
const url = new URL(String(address)), headers = new Headers(init.headers);
|
|
32
|
+
if (url.pathname === "/oauth/token") {
|
|
33
|
+
assert.equal(url.origin, "https://api.linear.app");
|
|
34
|
+
assert.equal(headers.get("accept"), "application/json");
|
|
35
|
+
const body = new URLSearchParams(init.body);
|
|
36
|
+
assert.equal(body.get("client_secret"), "secret");
|
|
37
|
+
if (body.get("grant_type") === "authorization_code") {
|
|
38
|
+
assert.equal(body.get("redirect_uri"), callback);
|
|
39
|
+
assert.ok(body.get("code_verifier"));
|
|
40
|
+
} else {
|
|
41
|
+
assert.equal(body.get("refresh_token"), `refresh-${exchanges}`);
|
|
42
|
+
assert.equal(body.has("redirect_uri"), false);
|
|
43
|
+
}
|
|
44
|
+
exchanges++;
|
|
45
|
+
return Response.json({ access_token: `access-${exchanges}`, refresh_token: `refresh-${exchanges}`, token_type: "Bearer", expires_in: 60, scope: tokenScope });
|
|
46
|
+
}
|
|
47
|
+
if (url.origin === "https://mcp.linear.app") {
|
|
48
|
+
assert.equal(url.pathname, "/mcp");
|
|
49
|
+
assert.ok(headers.get("authorization") === `Bearer access-${exchanges}` || headers.get("authorization") === "Bearer personal");
|
|
50
|
+
if (init.method === "DELETE") return new Response(null, { status: 204 });
|
|
51
|
+
if (init.method === "GET") return new Response(null, { status: 405 });
|
|
52
|
+
const message = JSON.parse(init.body);
|
|
53
|
+
if (message.id === undefined) return new Response(null, { status: 202 });
|
|
54
|
+
assert.ok(["initialize", "tools/list", "tools/call"].includes(message.method));
|
|
55
|
+
const result = message.method === "initialize" ? { protocolVersion: message.params.protocolVersion,
|
|
56
|
+
capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } } : message.method === "tools/list" ? { tools } : {
|
|
57
|
+
content: [{ type: "text", text: JSON.stringify({ id: message.params.name === "fixture_create_project" ? "project-1" : "issue-1", ...message.params.arguments }) }],
|
|
58
|
+
isError: message.params.arguments.fail === true
|
|
59
|
+
};
|
|
60
|
+
if (message.method === "tools/call") toolCalls.push(message.params);
|
|
61
|
+
return Response.json({ jsonrpc: "2.0", id: message.id, result });
|
|
62
|
+
}
|
|
63
|
+
assert.equal(url.origin, "https://api.linear.app");
|
|
64
|
+
assert.equal(url.pathname, "/graphql");
|
|
65
|
+
assert.ok(headers.get("authorization") === `Bearer access-${exchanges}` || headers.get("authorization") === "personal");
|
|
66
|
+
return Response.json({ data: { viewer: { id: "123", name: "Fixture" } } });
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const service = createConnectionService(options);
|
|
70
|
+
for (const outcome of ["cancel", "denied"]) {
|
|
71
|
+
const attempt = new URL((await service.beginAuthorization(input)).authorizationUrl);
|
|
72
|
+
const denied = new URL(callback);
|
|
73
|
+
denied.searchParams.set("state", attempt.searchParams.get("state"));
|
|
74
|
+
if (outcome === "cancel") {
|
|
75
|
+
await service.cancelAuthorization({ ...input, state: attempt.searchParams.get("state") });
|
|
76
|
+
denied.searchParams.set("code", "unused");
|
|
77
|
+
} else denied.searchParams.set("error", "access_denied");
|
|
78
|
+
await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: denied.href }), {
|
|
79
|
+
code: outcome === "cancel" ? "connector_attempt_invalid" : "connector_consent_denied"
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
assert.equal(exchanges, 0);
|
|
83
|
+
const { authorizationUrl } = await service.beginAuthorization(input);
|
|
84
|
+
const url = new URL(authorizationUrl);
|
|
85
|
+
assert.equal(url.origin + url.pathname, "https://linear.app/oauth/authorize");
|
|
86
|
+
assert.equal(url.searchParams.get("code_challenge_method"), "S256");
|
|
87
|
+
assert.equal(url.searchParams.get("actor"), "user");
|
|
88
|
+
assert.equal(url.searchParams.get("scope"), "read,write");
|
|
89
|
+
const returned = new URL(callback);
|
|
90
|
+
returned.searchParams.set("code", "code");
|
|
91
|
+
returned.searchParams.set("state", url.searchParams.get("state"));
|
|
92
|
+
assert.equal((await service.completeAuthorization({ ...input, callbackUrl: returned.href })).status, "connected");
|
|
93
|
+
await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: returned.href }), { code: "connector_attempt_invalid" });
|
|
94
|
+
time += 40_000;
|
|
95
|
+
await Promise.all([service.invoke({ ...input, operation: "profile.read" }), service.invoke({ ...input, operation: "profile.read" })]);
|
|
96
|
+
assert.equal(exchanges, 2);
|
|
97
|
+
const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
|
|
98
|
+
time += 40_000;
|
|
99
|
+
await restarted.invoke({ ...input, operation: "profile.read" });
|
|
100
|
+
assert.equal(exchanges, 3);
|
|
101
|
+
assert.equal((await restarted.connectApiKey({ context, integrationId: "personal" })).status, "connected");
|
|
102
|
+
assert.deepEqual(await restarted.invoke({ ...input, operation: "tools.list" }), { tools });
|
|
103
|
+
assert.deepEqual(await restarted.invoke({ context, integrationId: "personal", operation: "tools.list" }), { tools });
|
|
104
|
+
for (const integrationId of ["linear", "personal"]) {
|
|
105
|
+
const call = (name, args) => restarted.invoke({ context, integrationId, operation: "tools.call", input: { name, arguments: args } });
|
|
106
|
+
const project = await call("fixture_create_project", { name: "Release", teamId: "team-1" });
|
|
107
|
+
assert.equal(JSON.parse(project.content[0].text).id, "project-1");
|
|
108
|
+
const issue = await call("fixture_save_issue", { projectId: "project-1", title: "Ship" });
|
|
109
|
+
assert.equal(JSON.parse(issue.content[0].text).id, "issue-1");
|
|
110
|
+
await call("fixture_save_issue", { projectId: "project-1", id: "issue-1", title: "Ship today" });
|
|
111
|
+
assert.deepEqual(toolCalls.at(-1).arguments, { projectId: "project-1", id: "issue-1", title: "Ship today" });
|
|
112
|
+
assert.equal((await call("fixture_save_issue", { fail: true })).isError, true);
|
|
113
|
+
}
|
|
114
|
+
const guarded = createConnectionService({ ...options, authorize: async (owner, request) =>
|
|
115
|
+
request.operation === "tools.call" && (request.input.name !== "fixture_save_issue" || request.input.arguments.projectId !== "project-1") ? null : owner });
|
|
116
|
+
const beforeDenied = toolCalls.length;
|
|
117
|
+
for (const args of [{ name: "fixture_create_project", arguments: {} }, { name: "fixture_save_issue", arguments: { projectId: "foreign" } }]) {
|
|
118
|
+
await assert.rejects(guarded.invoke({ ...input, operation: "tools.call", input: args }), { code: "connector_access_denied" });
|
|
119
|
+
}
|
|
120
|
+
assert.equal(toolCalls.length, beforeDenied);
|
|
121
|
+
const otherApp = { context: { ...context, applicationId: "another-app" }, integrationId: "linear" };
|
|
122
|
+
assert.equal((await restarted.status(otherApp)).status, "disconnected");
|
|
123
|
+
if (perUser) {
|
|
124
|
+
const otherUser = { context: { ...context, subjectId: "another-user" }, integrationId: "linear" };
|
|
125
|
+
assert.equal((await restarted.status(otherUser)).status, "disconnected");
|
|
126
|
+
await assert.rejects(restarted.invoke({ ...otherUser, operation: "profile.read" }));
|
|
127
|
+
assert.equal((await restarted.status(input)).status, "connected");
|
|
128
|
+
}
|
|
129
|
+
tokenScope = "write";
|
|
130
|
+
time += 40_000;
|
|
131
|
+
await assert.rejects(restarted.invoke({ ...input, operation: "profile.read" }), { code: "connector_scope_missing" });
|
|
132
|
+
await restarted.disconnect(input);
|
|
133
|
+
assert.equal((await restarted.status(input)).status, "disconnected");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("Linear rejects malformed permission grants and preserves OAuth errors", async () => {
|
|
137
|
+
for (const scope of [undefined, 42, [42], ["bad scope"], "read,write"]) {
|
|
138
|
+
await assert.rejects(linearProvider.normalizeTokenResponse(Response.json({ access_token: "secret", scope })), { code: "connector_response_invalid" });
|
|
139
|
+
}
|
|
140
|
+
for (const scope of ["read write", ["read", "write"]]) {
|
|
141
|
+
const response = await linearProvider.normalizeTokenResponse(Response.json({ scope }));
|
|
142
|
+
assert.equal((await response.json()).scope, "read,write");
|
|
143
|
+
}
|
|
144
|
+
const failure = Response.json({ error: "invalid_grant" }, { status: 400 });
|
|
145
|
+
assert.equal(await linearProvider.normalizeTokenResponse(failure), failure);
|
|
146
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createConnectionService } from "../../connectors-core/src/server/index.js";
|
|
7
|
+
import { createFileConnectionStore, createCredentialProtection } from "../../connectors-core/src/server/fileStorage.js";
|
|
8
|
+
import { validateIntegrationConfiguration } from "../../connectors-core/src/shared/configuration.js";
|
|
9
|
+
import { linkedinProvider as provider } from "../src/server/linkedin.js";
|
|
10
|
+
|
|
11
|
+
const context = { applicationId: "linkedin-app", subjectId: "member-one" };
|
|
12
|
+
const input = { context, integrationId: "linkedin" };
|
|
13
|
+
const callback = "https://app.example.test/connections/linkedin/callback";
|
|
14
|
+
|
|
15
|
+
async function fixture(t) {
|
|
16
|
+
const directory = await mkdtemp(path.join(tmpdir(), "linkedin-connector-"));
|
|
17
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
18
|
+
const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(7) }, activeKeyId: "current" });
|
|
19
|
+
const configuration = { schemaVersion: 1, integrations: { linkedin: { provider: "linkedin", accountMode: "per-user",
|
|
20
|
+
scopes: ["openid", "profile", "email"], authentication: { method: "oauth2", registrationRef: "linkedin" } } },
|
|
21
|
+
registrations: { linkedin: { source: "own", clientId: "fixture-client", clientSecretRef: "env:LINKEDIN_SECRET", callbackUrlRef: "env:LINKEDIN_CALLBACK" } } };
|
|
22
|
+
const state = { time: Date.now(), calls: [], grants: 0, response: { sub: "member-1", name: "Fixture Member" },
|
|
23
|
+
scope: "openid profile email", status: 200, tokenStatus: 200, refresh: false, tokenOverrides: {}, deny: false, stall: false };
|
|
24
|
+
const options = { configuration, providers: [provider], store: createFileConnectionStore({ directory, protection }), now: () => state.time,
|
|
25
|
+
authorize: async (owner) => { if (state.deny) throw new Error("Host denied"); return owner; },
|
|
26
|
+
resolveReference: async (ref) => ref === "env:LINKEDIN_CALLBACK" ? callback : "fixture-client-secret",
|
|
27
|
+
fetchImpl: async (address, init) => {
|
|
28
|
+
const url = new URL(String(address)); const headers = new Headers(init.headers);
|
|
29
|
+
state.calls.push({ url, init, headers });
|
|
30
|
+
if (state.stall) return new Promise((resolve, reject) => {
|
|
31
|
+
init.signal.throwIfAborted(); init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
|
|
32
|
+
});
|
|
33
|
+
if (url.href === "https://www.linkedin.com/oauth/v2/accessToken") {
|
|
34
|
+
const body = new URLSearchParams(init.body);
|
|
35
|
+
assert.equal(body.get("client_id"), "fixture-client");
|
|
36
|
+
assert.equal(body.get("client_secret"), "fixture-client-secret");
|
|
37
|
+
assert.equal(headers.has("authorization"), false);
|
|
38
|
+
if (state.tokenStatus !== 200) return Response.json(state.tokenOverrides, { status: state.tokenStatus });
|
|
39
|
+
if (body.get("grant_type") === "refresh_token") assert.equal(body.get("refresh_token"), `fixture-refresh-${state.grants}`);
|
|
40
|
+
state.grants += 1;
|
|
41
|
+
return Response.json({ access_token: `fixture-access-${state.grants}`, expires_in: 120,
|
|
42
|
+
...(state.refresh ? { refresh_token: `fixture-refresh-${state.grants}` } : {}),
|
|
43
|
+
...(state.scope === undefined ? {} : { scope: state.scope }), ...state.tokenOverrides });
|
|
44
|
+
}
|
|
45
|
+
if (url.href === "https://api.linkedin.com/v2/ugcPosts") {
|
|
46
|
+
assert.equal(init.method, "POST"); assert.equal(init.redirect, "error");
|
|
47
|
+
assert.equal(headers.get("authorization"), `Bearer fixture-access-${state.grants}`);
|
|
48
|
+
assert.equal(headers.get("x-restli-protocol-version"), "2.0.0");
|
|
49
|
+
return new Response(null, { status: state.publishStatus || 201, headers: { "x-restli-id": state.postId ?? "urn:li:share:123" } });
|
|
50
|
+
}
|
|
51
|
+
assert.equal(url.href, "https://api.linkedin.com/v2/userinfo");
|
|
52
|
+
assert.equal(init.method, "GET"); assert.equal(init.redirect, "error");
|
|
53
|
+
assert.equal(headers.get("authorization"), `Bearer fixture-access-${state.grants}`);
|
|
54
|
+
assert.equal(String(init.body).includes("fixture-client-secret"), false);
|
|
55
|
+
return Response.json(state.response, { status: state.status });
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const service = createConnectionService(options);
|
|
59
|
+
async function start() {
|
|
60
|
+
const { authorizationUrl } = await service.beginAuthorization(input);
|
|
61
|
+
const url = new URL(authorizationUrl); const redirect = new URL(callback);
|
|
62
|
+
redirect.searchParams.set("code", "fixture-code"); redirect.searchParams.set("state", url.searchParams.get("state"));
|
|
63
|
+
return { url, callbackUrl: redirect.href };
|
|
64
|
+
}
|
|
65
|
+
return { service, options, configuration, directory, protection, state, start };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test("LinkedIn configuration requires both identity scopes and confidential registration across account modes", async (t) => {
|
|
69
|
+
const { configuration } = await fixture(t);
|
|
70
|
+
for (const accountMode of ["shared", "per-user", "assistant"]) {
|
|
71
|
+
const config = structuredClone(configuration); config.integrations.linkedin.accountMode = accountMode;
|
|
72
|
+
assert.equal(validateIntegrationConfiguration(config, { providers: [provider] }).integrations.linkedin.accountMode, accountMode);
|
|
73
|
+
}
|
|
74
|
+
for (const scopes of [["profile"], ["openid"], ["email"], ["openid", "profile", "admin"], ["openid", "profile", "profile"]]) {
|
|
75
|
+
const config = structuredClone(configuration); config.integrations.linkedin.scopes = scopes;
|
|
76
|
+
assert.throws(() => validateIntegrationConfiguration(config, { providers: [provider] }), { code: "integration_configuration_invalid" });
|
|
77
|
+
}
|
|
78
|
+
for (const update of [{ clientSecretRef: "raw-secret" }, { tokenEndpointAuthMethod: "none" }, { grantType: "client_credentials" }]) {
|
|
79
|
+
const config = structuredClone(configuration); Object.assign(config.registrations.linkedin, update);
|
|
80
|
+
assert.throws(() => validateIntegrationConfiguration(config, { providers: [provider] }), { code: "integration_configuration_invalid" });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("LinkedIn uses confidential Web OAuth, validates userinfo and persists only encrypted access credentials", async (t) => {
|
|
85
|
+
const { service, options, directory, protection, state, start } = await fixture(t);
|
|
86
|
+
state.tokenOverrides = { id_token: "unused-identity-token" };
|
|
87
|
+
const { url, callbackUrl } = await start();
|
|
88
|
+
assert.equal(url.origin + url.pathname, "https://www.linkedin.com/oauth/v2/authorization");
|
|
89
|
+
assert.equal(url.searchParams.get("redirect_uri"), callback);
|
|
90
|
+
assert.equal(url.searchParams.get("scope"), "openid profile email");
|
|
91
|
+
assert.equal(url.searchParams.get("code_challenge_method"), "S256");
|
|
92
|
+
assert.equal(url.searchParams.has("client_secret"), false);
|
|
93
|
+
const connected = await service.completeAuthorization({ ...input, callbackUrl });
|
|
94
|
+
assert.equal(connected.status, "connected");
|
|
95
|
+
const body = new URLSearchParams(state.calls[0].init.body);
|
|
96
|
+
assert.equal(body.get("grant_type"), "authorization_code"); assert.equal(body.get("redirect_uri"), callback); assert.ok(body.get("code_verifier"));
|
|
97
|
+
assert.deepEqual(connected.grantedScopes, ["openid", "profile", "email"]);
|
|
98
|
+
assert.equal(JSON.stringify(connected).includes("fixture-access"), false);
|
|
99
|
+
for (const name of await readdir(directory)) {
|
|
100
|
+
const text = await readFile(path.join(directory, name), "utf8");
|
|
101
|
+
for (const secret of ["fixture-client-secret", "fixture-access-1", "unused-identity-token"]) assert.equal(text.includes(secret), false);
|
|
102
|
+
}
|
|
103
|
+
const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
|
|
104
|
+
assert.deepEqual(await restarted.invoke({ ...input, operation: "profile.read" }), state.response);
|
|
105
|
+
state.response = { sub: "member-1", email: "member@example.test", email_verified: false };
|
|
106
|
+
assert.deepEqual(await restarted.invoke({ ...input, operation: "profile.read" }), state.response);
|
|
107
|
+
for (const owner of [{ ...context, applicationId: "another-app" }, { ...context, subjectId: "another-user" }]) {
|
|
108
|
+
await assert.rejects(restarted.invoke({ ...input, context: owner, operation: "profile.read" }), { code: "connector_reconnect_required" });
|
|
109
|
+
}
|
|
110
|
+
await restarted.disconnect(input); assert.equal((await service.status(input)).status, "disconnected");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("LinkedIn cancels pending consent, rejects replay and preserves a grant when replacement consent is declined", async (t) => {
|
|
114
|
+
const { service, state, start } = await fixture(t);
|
|
115
|
+
const cancelled = await start();
|
|
116
|
+
await service.cancelAuthorization({ ...input, state: cancelled.url.searchParams.get("state") });
|
|
117
|
+
await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: cancelled.callbackUrl }), { code: "connector_attempt_invalid" });
|
|
118
|
+
const first = await start(); const connected = await service.completeAuthorization({ ...input, callbackUrl: first.callbackUrl });
|
|
119
|
+
await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: first.callbackUrl }), { code: "connector_attempt_invalid" });
|
|
120
|
+
for (const error of ["user_cancelled_login", "user_cancelled_authorize", "access_denied"]) {
|
|
121
|
+
const attempt = await start(); const url = new URL(attempt.callbackUrl); url.searchParams.delete("code"); url.searchParams.set("error", error);
|
|
122
|
+
url.searchParams.set("error_description", "private-provider-detail");
|
|
123
|
+
await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: url.href }), { code: "connector_consent_denied" });
|
|
124
|
+
assert.deepEqual(await service.status(input), { ...connected, callbackUrl: callback });
|
|
125
|
+
}
|
|
126
|
+
assert.equal(state.grants, 1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("LinkedIn does not invent refresh access when ordinary access expires", async (t) => {
|
|
130
|
+
const { service, state, start } = await fixture(t);
|
|
131
|
+
state.scope = undefined;
|
|
132
|
+
await service.completeAuthorization({ ...input, callbackUrl: (await start()).callbackUrl });
|
|
133
|
+
state.time += 121_000;
|
|
134
|
+
await assert.rejects(service.invoke({ ...input, operation: "profile.read" }), { code: "connector_reconnect_required" });
|
|
135
|
+
assert.equal(state.grants, 1); assert.equal((await service.status(input)).status, "reconnect-required");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("LinkedIn refreshes approved grants once under contention and does not expand permissions", async (t) => {
|
|
139
|
+
const { service, state, start } = await fixture(t); state.refresh = true; state.scope = "openid profile";
|
|
140
|
+
await service.completeAuthorization({ ...input, callbackUrl: (await start()).callbackUrl });
|
|
141
|
+
state.time += 121_000; state.scope = "openid profile email w_member_social";
|
|
142
|
+
const results = await Promise.all([service.invoke({ ...input, operation: "profile.read" }), service.invoke({ ...input, operation: "profile.read" })]);
|
|
143
|
+
assert.deepEqual(results, [state.response, state.response]); assert.equal(state.grants, 2);
|
|
144
|
+
assert.deepEqual((await service.status(input)).grantedScopes, ["openid", "profile"]);
|
|
145
|
+
state.time += 121_000; state.tokenStatus = 400; state.tokenOverrides = { error: "invalid_request", error_description: "private-refresh-detail" };
|
|
146
|
+
await assert.rejects(service.invoke({ ...input, operation: "profile.read" }), { code: "connector_reconnect_required" });
|
|
147
|
+
assert.equal((await service.status(input)).status, "reconnect-required");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("LinkedIn rejects malformed or insufficient token responses before saving", async (t) => {
|
|
151
|
+
const { service, state, start } = await fixture(t);
|
|
152
|
+
for (const update of [{ expires_in: 0 }, { expires_in: null }, { scope: ["openid", "profile"] }, { scope: "" }, { token_type: "mac" }, { token_type: null }, { access_token: "" }]) {
|
|
153
|
+
state.tokenOverrides = update;
|
|
154
|
+
await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: (await start()).callbackUrl }));
|
|
155
|
+
assert.equal((await service.status(input)).status, "disconnected");
|
|
156
|
+
}
|
|
157
|
+
for (const scope of ["openid", "profile", "email"]) {
|
|
158
|
+
state.tokenOverrides = { scope };
|
|
159
|
+
await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: (await start()).callbackUrl }), { code: "connector_reconnect_required" });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("LinkedIn rejects invalid profiles, arbitrary input and unauthorized publishing without contacting other endpoints", async (t) => {
|
|
164
|
+
const { service, state, start } = await fixture(t);
|
|
165
|
+
await service.completeAuthorization({ ...input, callbackUrl: (await start()).callbackUrl });
|
|
166
|
+
const count = state.calls.length;
|
|
167
|
+
for (const values of [{ url: "https://attacker.invalid" }, { memberId: "other" }, { fields: "email" }]) {
|
|
168
|
+
await assert.rejects(service.invoke({ ...input, operation: "profile.read", input: values }), { code: "connector_input_invalid" });
|
|
169
|
+
}
|
|
170
|
+
await assert.rejects(service.invoke({ ...input, operation: "posts.create", input: { text: "Do not send", visibility: "PUBLIC" } }), { code: "connector_scope_missing" });
|
|
171
|
+
assert.equal(state.calls.length, count);
|
|
172
|
+
for (const response of [{}, { sub: "" }, { sub: 3 }, { sub: "one", email: null }, { sub: "one", email_verified: "true" }]) {
|
|
173
|
+
state.response = response;
|
|
174
|
+
await assert.rejects(service.invoke({ ...input, operation: "profile.read" }), { code: "connector_response_invalid" });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("LinkedIn sanitizes API failures and handles provider cancellation and timeout", async (t) => {
|
|
179
|
+
const { service, options, state, start } = await fixture(t);
|
|
180
|
+
await service.completeAuthorization({ ...input, callbackUrl: (await start()).callbackUrl });
|
|
181
|
+
for (const [status, code] of [[403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
|
|
182
|
+
state.status = status; state.response = { message: "private-provider-detail" };
|
|
183
|
+
await assert.rejects(service.invoke({ ...input, operation: "profile.read" }), (error) => error.code === code && !error.message.includes("private-provider-detail"));
|
|
184
|
+
}
|
|
185
|
+
state.stall = true;
|
|
186
|
+
const controller = new AbortController(); controller.abort();
|
|
187
|
+
await assert.rejects(service.invoke({ ...input, operation: "profile.read", signal: controller.signal }), { code: "connector_cancelled" });
|
|
188
|
+
const timeoutService = createConnectionService({ ...options, providers: [{ ...provider, requestTimeoutMs: 20 }] });
|
|
189
|
+
const keepAlive = setTimeout(() => {}, 1000);
|
|
190
|
+
try { await assert.rejects(timeoutService.invoke({ ...input, operation: "profile.read" }), { code: "connector_provider_timeout" }); }
|
|
191
|
+
finally { clearTimeout(keepAlive); }
|
|
192
|
+
state.stall = false; state.status = 401;
|
|
193
|
+
await assert.rejects(service.invoke({ ...input, operation: "profile.read" }), { code: "connector_reconnect_required" });
|
|
194
|
+
assert.equal((await service.status(input)).status, "reconnect-required");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("LinkedIn respects host policy, registration identity and unavailable managed assignments", async (t) => {
|
|
198
|
+
const { service, options, configuration, state, start } = await fixture(t);
|
|
199
|
+
await service.completeAuthorization({ ...input, callbackUrl: (await start()).callbackUrl });
|
|
200
|
+
const count = state.calls.length; state.deny = true;
|
|
201
|
+
await assert.rejects(service.invoke({ ...input, operation: "profile.read" })); assert.equal(state.calls.length, count); state.deny = false;
|
|
202
|
+
const changed = structuredClone(configuration); changed.registrations.linkedin.clientId = "other-client";
|
|
203
|
+
await assert.rejects(createConnectionService({ ...options, configuration: changed }).invoke({ ...input, operation: "profile.read" }), { code: "connector_reconnect_required" });
|
|
204
|
+
changed.registrations.linkedin = { source: "managed", serviceUrlRef: "env:GATEWAY", serviceCredentialRef: "env:GATEWAY_KEY", assignmentRef: "online" };
|
|
205
|
+
assert.throws(() => createConnectionService({ ...options, configuration: changed }), { code: "integration_configuration_invalid" });
|
|
206
|
+
assert.equal(state.calls.length, count);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("LinkedIn's documented portable JSON validates unchanged", async () => {
|
|
210
|
+
const guide = await readFile(new URL("../docs/linkedin.md", import.meta.url), "utf8");
|
|
211
|
+
const config = JSON.parse(guide.match(/```json\n([\s\S]*?)\n```/u)[1]);
|
|
212
|
+
assert.deepEqual(validateIntegrationConfiguration(config, { providers: [provider] }), config);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("LinkedIn publishes text as the connected member and respects host policy without retries", async (t) => {
|
|
216
|
+
const f = await fixture(t); f.configuration.integrations.linkedin.scopes.push("w_member_social");
|
|
217
|
+
f.state.scope += " w_member_social";
|
|
218
|
+
const service = createConnectionService(f.options);
|
|
219
|
+
const auth = new URL((await service.beginAuthorization(input)).authorizationUrl);
|
|
220
|
+
const returned = new URL(callback); returned.searchParams.set("code", "code"); returned.searchParams.set("state", auth.searchParams.get("state"));
|
|
221
|
+
await service.completeAuthorization({ ...input, callbackUrl: returned.href });
|
|
222
|
+
const call = values => service.invoke({ ...input, operation: "posts.create", input: values });
|
|
223
|
+
const post = { text: "Approved announcement", visibility: "CONNECTIONS" };
|
|
224
|
+
assert.deepEqual(await call(post), { id: "urn:li:share:123" });
|
|
225
|
+
assert.deepEqual(JSON.parse(f.state.calls.at(-1).init.body), {
|
|
226
|
+
author: "urn:li:person:member-1", lifecycleState: "PUBLISHED",
|
|
227
|
+
specificContent: { "com.linkedin.ugc.ShareContent": { shareCommentary: { text: post.text }, shareMediaCategory: "NONE" } },
|
|
228
|
+
visibility: { "com.linkedin.ugc.MemberNetworkVisibility": "CONNECTIONS" }
|
|
229
|
+
});
|
|
230
|
+
let count = f.state.calls.length;
|
|
231
|
+
for (const bad of [{ ...post, author: "urn:li:person:foreign" }, { ...post, text: " " }, { ...post, visibility: "PRIVATE" }, { text: "missing audience" }])
|
|
232
|
+
await assert.rejects(call(bad), { code: "connector_input_invalid" });
|
|
233
|
+
f.state.deny = true; await assert.rejects(call(post)); f.state.deny = false;
|
|
234
|
+
assert.equal(f.state.calls.length, count);
|
|
235
|
+
f.state.response = { sub: "bad/person" }; await assert.rejects(call(post), { code: "connector_response_invalid" });
|
|
236
|
+
assert.equal(f.state.calls.length, count + 1); f.state.response = { sub: "member-1" };
|
|
237
|
+
for (const status of [403, 429, 500]) {
|
|
238
|
+
f.state.publishStatus = status; count = f.state.calls.length;
|
|
239
|
+
await assert.rejects(call(post)); assert.equal(f.state.calls.length, count + 2);
|
|
240
|
+
}
|
|
241
|
+
f.state.publishStatus = 201; f.state.postId = "";
|
|
242
|
+
await assert.rejects(call(post), { code: "connector_response_invalid" });
|
|
243
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { logoUrlFromConfiguration } from "../patterns/public-image/example/logo-url.js";
|
|
5
|
+
import { createLogoDevImageUrl } from "../src/client/logo-dev.js";
|
|
6
|
+
import { logoDevDefinition } from "../src/shared/logo-dev.js";
|
|
7
|
+
import { createConnectionService } from "../../connectors-core/src/server/index.js";
|
|
8
|
+
import { parseIntegrationConfiguration } from "../../connectors-core/src/shared/configuration.js";
|
|
9
|
+
|
|
10
|
+
test("Logo.dev builds an image URL with explicit public credentials and portable defaults", () => {
|
|
11
|
+
const original = { publishableKey: "pk_fixture", domain: "EXAMPLE.COM" };
|
|
12
|
+
assert.equal(createLogoDevImageUrl(original), "https://img.logo.dev/example.com?token=pk_fixture&size=128&format=png&theme=auto&greyscale=false&retina=false&fallback=monogram");
|
|
13
|
+
assert.deepEqual(original, { publishableKey: "pk_fixture", domain: "EXAMPLE.COM" });
|
|
14
|
+
const url = new URL(createLogoDevImageUrl({ ...original, size: 800, format: "webp", theme: "dark", greyscale: true, retina: true, fallback: "404" }));
|
|
15
|
+
assert.equal(url.origin, "https://img.logo.dev");
|
|
16
|
+
assert.deepEqual(Object.fromEntries(url.searchParams), { token: "pk_fixture", size: "800", format: "webp", theme: "dark", greyscale: "true", retina: "true", fallback: "404" });
|
|
17
|
+
assert.equal(new URL(createLogoDevImageUrl({ ...original, domain: "xn--bcher-kva.example", format: "svg" })).pathname, "/xn--bcher-kva.example");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("Logo.dev refuses private keys, path injection and invalid image options without echoing credentials", () => {
|
|
21
|
+
const input = { publishableKey: "pk_fixture", domain: "example.com" };
|
|
22
|
+
for (const publishableKey of ["sk_do-not-expose-this", "pk_", "pk_bad&token=other", "pk_bad/key", "env:LOGO_DEV_KEY"]) {
|
|
23
|
+
assert.throws(() => createLogoDevImageUrl({ ...input, publishableKey }), (error) => {
|
|
24
|
+
assert.ok(error.fieldErrors.publishableKey);
|
|
25
|
+
assert.equal(error.message.includes(publishableKey), false);
|
|
26
|
+
return true;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
for (const domain of ["https://example.com", "example.com/path", "example.com?token=other", "example.com:443", "example.com#x", "user@example.com", "../example.com", "example..com", "127.0.0.1", "-example.com", "example.com\n/", "a".repeat(64) + ".com"]) {
|
|
30
|
+
assert.throws(() => createLogoDevImageUrl({ ...input, domain }), (error) => Boolean(error.fieldErrors.domain));
|
|
31
|
+
}
|
|
32
|
+
for (const changes of [{ size: 0 }, { size: 801 }, { size: 1.5 }, { format: "html" }, { theme: "custom" }, { fallback: "https://attacker.invalid" }, { retina: "maybe" }, { url: "https://attacker.invalid" }]) {
|
|
33
|
+
assert.throws(() => createLogoDevImageUrl({ ...input, ...changes }), (error) => Boolean(error.fieldErrors));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("Logo.dev shares source configuration with the editor and never treats URL construction as a verified connection", async () => {
|
|
38
|
+
assert.equal(logoDevDefinition.configurationOnly, true);
|
|
39
|
+
const configuration = { schemaVersion: 1, registrations: {}, integrations: {
|
|
40
|
+
logos: { provider: "logo-dev", displayName: "Brand logos", accountMode: "shared", scopes: [],
|
|
41
|
+
authentication: { method: "api-key", secretRef: "env:LOGO_DEV_PUBLISHABLE_KEY" } }
|
|
42
|
+
}, extensions: { fromCli: true } };
|
|
43
|
+
const parse = (value) => parseIntegrationConfiguration(JSON.stringify(value), { providers: [logoDevDefinition] });
|
|
44
|
+
assert.deepEqual(parse(configuration), configuration);
|
|
45
|
+
const invalid = structuredClone(configuration);
|
|
46
|
+
invalid.integrations.logos.authentication.secretRef = "pk_should-not-be-in-source";
|
|
47
|
+
assert.throws(() => parse(invalid), (error) => Boolean(error.fieldErrors["integrations.logos.authentication.secretRef"]));
|
|
48
|
+
const service = createConnectionService({ configuration, providers: [logoDevDefinition],
|
|
49
|
+
authorize: async (context) => context,
|
|
50
|
+
resolveReference: async () => assert.fail("Image URL configuration must not resolve private credentials through server verification."),
|
|
51
|
+
store: { withConnection: async () => assert.fail("No connection record should be written.") },
|
|
52
|
+
fetchImpl: async () => assert.fail("No provider verification call exists for this public image flow.")
|
|
53
|
+
});
|
|
54
|
+
await assert.rejects(service.connectApiKey({ context: { applicationId: "app", subjectId: "team" }, integrationId: "logos" }), { code: "connector_mode_unavailable" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
test("the public-image source pattern resolves only the chosen reference and rejects other provider slots", async () => {
|
|
59
|
+
const configurationText = await readFile(new URL("../patterns/public-image/example/integrations.json", import.meta.url), "utf8");
|
|
60
|
+
const resolveReference = async (reference) => {
|
|
61
|
+
assert.equal(reference, "env:LOGO_DEV_PUBLISHABLE_KEY");
|
|
62
|
+
return "pk_from_binding";
|
|
63
|
+
};
|
|
64
|
+
const options = { configurationText, integrationId: "logos", resolveReference, image: { domain: "example.com", publishableKey: "pk_caller_override" } };
|
|
65
|
+
assert.equal(new URL(await logoUrlFromConfiguration(options)).searchParams.get("token"), "pk_from_binding");
|
|
66
|
+
await assert.rejects(logoUrlFromConfiguration({ ...options, integrationId: "missing" }), /Select a configured Logo.dev integration/);
|
|
67
|
+
const other = JSON.parse(configurationText);
|
|
68
|
+
other.integrations.logos.provider = "another-provider";
|
|
69
|
+
await assert.rejects(logoUrlFromConfiguration({ ...options, configurationText: JSON.stringify(other), resolveReference: async () => assert.fail("Do not resolve another provider's credential.") }), /Select a configured Logo.dev integration/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Logo.dev resolves stock tickers and email domains without disclosing the email local part", () => {
|
|
73
|
+
const key = { publishableKey: "pk_fixture" };
|
|
74
|
+
assert.equal(new URL(createLogoDevImageUrl({ ...key, ticker: "shel.l" })).pathname, "/ticker/SHEL.L");
|
|
75
|
+
const url = createLogoDevImageUrl({ ...key, email: "private.person+tag@EXAMPLE.COM" });
|
|
76
|
+
assert.equal(new URL(url).pathname, "/example.com"); assert.equal(url.includes("private"), false);
|
|
77
|
+
for (const lookup of [{}, { domain: "example.com", ticker: "AAPL" }, { ticker: "../AAPL" }, { ticker: "AAPL?token=other" }, { email: "user@example.com/path" }, { email: "user@127.0.0.1" }, { email: "a@b@example.com" }])
|
|
78
|
+
assert.throws(() => createLogoDevImageUrl({ ...key, ...lookup }));
|
|
79
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createConnectionService } from "../../connectors-core/src/server/index.js";
|
|
7
|
+
import { createFileConnectionStore, createCredentialProtection } from "../../connectors-core/src/server/fileStorage.js";
|
|
8
|
+
import { mailgunProvider } from "../src/server/mailgun.js";
|
|
9
|
+
|
|
10
|
+
const context = { applicationId: "mail-app", subjectId: "business-mail" };
|
|
11
|
+
const domain = "mail.example.com";
|
|
12
|
+
const message = { domain, from: "Support <support@mail.example.com>", to: ["one@example.com", "two@example.com"],
|
|
13
|
+
subject: "Your receipt", text: "Receipt\n", html: "<p>Receipt</p>" };
|
|
14
|
+
const dns = { domain: { name: domain, state: "unverified" },
|
|
15
|
+
sending_dns_records: [{ record_type: "TXT", name: domain, value: "fixture-spf", valid: "unknown" }], receiving_dns_records: [] };
|
|
16
|
+
async function fixture(t, region = "us") {
|
|
17
|
+
const directory = await mkdtemp(path.join(tmpdir(), "mailgun-repair-"));
|
|
18
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
19
|
+
const requests = [];
|
|
20
|
+
const state = { fail: false, status: 200, malformed: false, deny: false };
|
|
21
|
+
const service = createConnectionService({
|
|
22
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: { mail: {
|
|
23
|
+
provider: "mailgun", accountMode: "shared", scopes: [], settings: { region },
|
|
24
|
+
authentication: { method: "api-key", secretRef: "env:MAILGUN_KEY" }
|
|
25
|
+
} } }, providers: [mailgunProvider],
|
|
26
|
+
authorize: async owner => { if (state.deny) throw new Error("permission denied"); return owner; },
|
|
27
|
+
resolveReference: async () => "fixture-private-key",
|
|
28
|
+
store: createFileConnectionStore({ directory, protection: createCredentialProtection({
|
|
29
|
+
keys: { current: new Uint8Array(32).fill(8) }, activeKeyId: "current"
|
|
30
|
+
}) }),
|
|
31
|
+
fetchImpl: async (url, init) => {
|
|
32
|
+
requests.push({ url: String(url), init });
|
|
33
|
+
if (state.fail) throw new Error("connection lost after request");
|
|
34
|
+
if (state.status !== 200) return Response.json({ message: "fixture rejection" }, { status: state.status });
|
|
35
|
+
if (state.malformed) return Response.json({});
|
|
36
|
+
const pathname = new URL(url).pathname;
|
|
37
|
+
if (pathname === "/v4/domains" && init.method === "GET") return Response.json({ items: [], total_count: 0 });
|
|
38
|
+
if (pathname.endsWith("/messages")) return Response.json({ id: "<fixture@mail.example.com>", message: "Queued. Thank you." });
|
|
39
|
+
if (pathname === "/v1/analytics/logs") return Response.json({ items: [
|
|
40
|
+
{ event: "failed", severity: "permanent", recipient: "one@example.com", "delivery-status": { code: 550 } }
|
|
41
|
+
], pagination: { next: "next-fixture-token" } });
|
|
42
|
+
return Response.json(dns);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
await service.connectApiKey({ context, integrationId: "mail" });
|
|
46
|
+
const invoke = (operation, input) => service.invoke({ context, integrationId: "mail", operation, input });
|
|
47
|
+
return { service, requests, state, invoke };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const region of ["us", "eu"]) test(`Mailgun ${region}: verify key, send multipart, manage domain DNS, page delivery logs`, async t => {
|
|
51
|
+
const { requests, invoke } = await fixture(t, region);
|
|
52
|
+
assert.equal(requests.length, 1);
|
|
53
|
+
assert.equal(requests[0].init.method, "GET");
|
|
54
|
+
const queued = await invoke("messages.send", message);
|
|
55
|
+
assert.equal(queued.id, "<fixture@mail.example.com>");
|
|
56
|
+
const sent = requests.at(-1);
|
|
57
|
+
assert.equal(sent.url, `https://api${region === "eu" ? ".eu" : ""}.mailgun.net/v3/${domain}/messages`);
|
|
58
|
+
assert.equal(sent.init.method, "POST");
|
|
59
|
+
assert.ok(sent.init.body instanceof FormData);
|
|
60
|
+
assert.deepEqual(sent.init.body.getAll("to"), message.to);
|
|
61
|
+
assert.equal(sent.init.body.get("text"), message.text);
|
|
62
|
+
assert.equal(sent.init.body.get("html"), message.html);
|
|
63
|
+
assert.equal(new Headers(sent.init.headers).get("content-type"), null);
|
|
64
|
+
assert.equal(new Headers(sent.init.headers).get("authorization"), `Basic ${Buffer.from("api:fixture-private-key").toString("base64")}`);
|
|
65
|
+
for (const [operation, method, suffix] of [["domains.create", "POST", ""], ["domains.get", "GET", `/${domain}`],
|
|
66
|
+
["domains.verify", "PUT", `/${domain}/verify`]]) {
|
|
67
|
+
assert.deepEqual(await invoke(operation, { domain }), dns);
|
|
68
|
+
assert.equal(requests.at(-1).init.method, method);
|
|
69
|
+
assert.equal(new URL(requests.at(-1).url).pathname, `/v4/domains${suffix}`);
|
|
70
|
+
}
|
|
71
|
+
const logs = await invoke("logs.list", { domain });
|
|
72
|
+
assert.equal(logs.items[0].severity, "permanent");
|
|
73
|
+
await invoke("logs.list", { domain, token: logs.pagination.next });
|
|
74
|
+
const body = JSON.parse(requests.at(-1).init.body);
|
|
75
|
+
assert.equal(body.pagination.token, "next-fixture-token");
|
|
76
|
+
assert.equal(body.filter.AND[0].values[0].value, domain);
|
|
77
|
+
assert.equal(body.include_subaccounts, false);
|
|
78
|
+
assert.equal(body.duration, "1d");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("Mailgun rejects unsafe/incomplete input, unauthorized use and uncertain sends without retry", async t => {
|
|
82
|
+
const { service, requests, state, invoke } = await fixture(t);
|
|
83
|
+
for (const bad of [{ ...message, domain: "../keys" }, { ...message, to: [] }, { ...message, to: ["invalid"] },
|
|
84
|
+
{ ...message, subject: "header\r\ninjection" }, { ...message, text: "" }]) {
|
|
85
|
+
await assert.rejects(invoke("messages.send", bad));
|
|
86
|
+
}
|
|
87
|
+
await assert.rejects(service.invoke({ context: { ...context, applicationId: "other" }, integrationId: "mail",
|
|
88
|
+
operation: "messages.send", input: message }));
|
|
89
|
+
state.deny = true;
|
|
90
|
+
await assert.rejects(invoke("domains.create", { domain }));
|
|
91
|
+
state.deny = false;
|
|
92
|
+
assert.equal(requests.length, 1);
|
|
93
|
+
state.fail = true;
|
|
94
|
+
await assert.rejects(invoke("messages.send", message));
|
|
95
|
+
assert.equal(requests.length, 2);
|
|
96
|
+
state.fail = false;
|
|
97
|
+
for (const status of [429, 401]) {
|
|
98
|
+
state.status = status;
|
|
99
|
+
const before = requests.length;
|
|
100
|
+
await assert.rejects(invoke("messages.send", message));
|
|
101
|
+
assert.equal(requests.length, before + 1);
|
|
102
|
+
}
|
|
103
|
+
state.status = 200;
|
|
104
|
+
await service.connectApiKey({ context, integrationId: "mail" });
|
|
105
|
+
state.malformed = true;
|
|
106
|
+
await assert.rejects(invoke("logs.list", { domain }), { code: "connector_response_invalid" });
|
|
107
|
+
});
|