@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.
Files changed (399) hide show
  1. package/README.md +192 -0
  2. package/docs/ai-upstream-notices.md +51 -0
  3. package/docs/ai.md +191 -0
  4. package/docs/airtable.md +130 -0
  5. package/docs/algolia.md +190 -0
  6. package/docs/amazon-redshift.md +229 -0
  7. package/docs/amplitude.md +210 -0
  8. package/docs/apify.md +162 -0
  9. package/docs/apollo-io.md +200 -0
  10. package/docs/asana.md +126 -0
  11. package/docs/ashby.md +188 -0
  12. package/docs/atlassian.md +224 -0
  13. package/docs/attention.md +162 -0
  14. package/docs/aws-athena.md +150 -0
  15. package/docs/aws-credentials.md +75 -0
  16. package/docs/aws-s3.md +193 -0
  17. package/docs/bigquery.md +146 -0
  18. package/docs/brevo.md +122 -0
  19. package/docs/calendly.md +158 -0
  20. package/docs/canva.md +191 -0
  21. package/docs/chargebee.md +192 -0
  22. package/docs/clay.md +194 -0
  23. package/docs/clickhouse.md +242 -0
  24. package/docs/confidence-exp.md +74 -0
  25. package/docs/confidence-flags.md +75 -0
  26. package/docs/confidence-oauth.md +137 -0
  27. package/docs/contentful.md +123 -0
  28. package/docs/databricks.md +180 -0
  29. package/docs/dbt-semantic-layer.md +175 -0
  30. package/docs/elevenlabs.md +102 -0
  31. package/docs/figma.md +132 -0
  32. package/docs/firebase-cloud-messaging.md +262 -0
  33. package/docs/firecrawl.md +137 -0
  34. package/docs/fireflies.md +95 -0
  35. package/docs/fireworks-ai.md +53 -0
  36. package/docs/gatewayapi.md +113 -0
  37. package/docs/gemini-enterprise.md +224 -0
  38. package/docs/github-api.md +133 -0
  39. package/docs/gitlab-api.md +138 -0
  40. package/docs/gmail.md +133 -0
  41. package/docs/gong.md +205 -0
  42. package/docs/google-ads.md +156 -0
  43. package/docs/google-analytics.md +202 -0
  44. package/docs/google-docs.md +105 -0
  45. package/docs/google-drive.md +108 -0
  46. package/docs/google-maps-platform.md +208 -0
  47. package/docs/google-oauth.md +123 -0
  48. package/docs/google-search-console.md +124 -0
  49. package/docs/google-sheets.md +94 -0
  50. package/docs/google-slides.md +95 -0
  51. package/docs/granola.md +178 -0
  52. package/docs/hex.md +161 -0
  53. package/docs/heygen.md +124 -0
  54. package/docs/hubspot.md +142 -0
  55. package/docs/incident-io.md +118 -0
  56. package/docs/inngest.md +178 -0
  57. package/docs/klipy.md +149 -0
  58. package/docs/lexware.md +144 -0
  59. package/docs/lightspeed.md +193 -0
  60. package/docs/linear.md +137 -0
  61. package/docs/linkedin.md +196 -0
  62. package/docs/logo-dev.md +152 -0
  63. package/docs/mailgun.md +153 -0
  64. package/docs/mapbox.md +174 -0
  65. package/docs/microsoft-excel.md +97 -0
  66. package/docs/microsoft-fabric.md +249 -0
  67. package/docs/microsoft-oauth.md +143 -0
  68. package/docs/microsoft-onedrive.md +76 -0
  69. package/docs/microsoft-onenote.md +75 -0
  70. package/docs/microsoft-outlook.md +90 -0
  71. package/docs/microsoft-powerpoint.md +119 -0
  72. package/docs/microsoft-sharepoint.md +75 -0
  73. package/docs/microsoft-teams.md +72 -0
  74. package/docs/microsoft-word.md +119 -0
  75. package/docs/miro.md +117 -0
  76. package/docs/n8n.md +169 -0
  77. package/docs/notion.md +137 -0
  78. package/docs/oura.md +161 -0
  79. package/docs/paddle.md +168 -0
  80. package/docs/perplexity.md +85 -0
  81. package/docs/pipedrive.md +120 -0
  82. package/docs/polar.md +63 -0
  83. package/docs/posthog.md +227 -0
  84. package/docs/prestashop.md +151 -0
  85. package/docs/replicate.md +60 -0
  86. package/docs/resend.md +153 -0
  87. package/docs/salesforce.md +234 -0
  88. package/docs/sanity.md +122 -0
  89. package/docs/semrush.md +276 -0
  90. package/docs/sentry.md +112 -0
  91. package/docs/sevdesk.md +107 -0
  92. package/docs/shopify.md +206 -0
  93. package/docs/slack.md +257 -0
  94. package/docs/snowflake.md +271 -0
  95. package/docs/storyblok.md +130 -0
  96. package/docs/stripe.md +77 -0
  97. package/docs/supabase.md +51 -0
  98. package/docs/tally.md +105 -0
  99. package/docs/telegram.md +165 -0
  100. package/docs/tiktok.md +201 -0
  101. package/docs/twilio.md +284 -0
  102. package/docs/twitch.md +428 -0
  103. package/docs/wave.md +440 -0
  104. package/docs/wix.md +1249 -0
  105. package/docs/wiz.md +177 -0
  106. package/docs/woocommerce.md +400 -0
  107. package/docs/wordpress-com.md +358 -0
  108. package/docs/wordpress-self-hosted.md +252 -0
  109. package/docs/workday.md +292 -0
  110. package/docs/x-twitter.md +188 -0
  111. package/docs/xero.md +429 -0
  112. package/docs/zoho-books.md +214 -0
  113. package/docs/zoho-crm.md +192 -0
  114. package/package.json +153 -0
  115. package/patterns/ai-connections/PATTERN.md +84 -0
  116. package/patterns/ai-connections/example/ai-model.js +11 -0
  117. package/patterns/ai-connections/example/perplexity-answer.js +29 -0
  118. package/patterns/api-key-connection/PATTERN.md +368 -0
  119. package/patterns/api-key-connection/example/connections.js +18 -0
  120. package/patterns/api-key-connection/example/integrations.json +19 -0
  121. package/patterns/assistant-mcp/PATTERN.md +110 -0
  122. package/patterns/assistant-mcp/example/integrations.json +13 -0
  123. package/patterns/assistant-mcp-oauth/PATTERN.md +235 -0
  124. package/patterns/assistant-mcp-oauth/example/integrations.json +102 -0
  125. package/patterns/aws-storage-queries/PATTERN.md +169 -0
  126. package/patterns/aws-storage-queries/example/formats/data-formats.js +35 -0
  127. package/patterns/aws-storage-queries/example/formats/package-lock.json +49 -0
  128. package/patterns/aws-storage-queries/example/formats/package.json +12 -0
  129. package/patterns/aws-storage-queries/example/formats/verify-formats.mjs +37 -0
  130. package/patterns/aws-storage-queries/example/integrations.json +38 -0
  131. package/patterns/aws-storage-queries/example/s3-transfer.js +34 -0
  132. package/patterns/event-delivery/PATTERN.md +151 -0
  133. package/patterns/event-delivery/example/integrations.json +17 -0
  134. package/patterns/firebase-messaging/PATTERN.md +156 -0
  135. package/patterns/firebase-messaging/example/server/notifications.js +23 -0
  136. package/patterns/google-ads-search/PATTERN.md +92 -0
  137. package/patterns/google-ads-search/example/ads-setup.js +25 -0
  138. package/patterns/oauth-connection/PATTERN.md +724 -0
  139. package/patterns/oauth-connection/example/integrations.json +20 -0
  140. package/patterns/paddle-catalogue/PATTERN.md +78 -0
  141. package/patterns/paddle-catalogue/example/create-products.js +38 -0
  142. package/patterns/public-image/PATTERN.md +84 -0
  143. package/patterns/public-image/example/integrations.json +13 -0
  144. package/patterns/public-image/example/logo-url.js +16 -0
  145. package/patterns/redshift-queries/PATTERN.md +150 -0
  146. package/patterns/redshift-queries/example/integrations.json +33 -0
  147. package/patterns/source-scanning/PATTERN.md +83 -0
  148. package/patterns/source-scanning/example/source-scanner.js +13 -0
  149. package/src/client/firebase-cloud-messaging.js +10 -0
  150. package/src/client/google-analytics.js +72 -0
  151. package/src/client/google-maps-platform.js +39 -0
  152. package/src/client/logo-dev.js +36 -0
  153. package/src/server/ai.js +46 -0
  154. package/src/server/airtable.js +67 -0
  155. package/src/server/algolia.js +63 -0
  156. package/src/server/amazon-redshift.js +96 -0
  157. package/src/server/amplitude.js +31 -0
  158. package/src/server/apify.js +68 -0
  159. package/src/server/apollo-io.js +85 -0
  160. package/src/server/asana.js +64 -0
  161. package/src/server/ashby.js +61 -0
  162. package/src/server/atlassian.js +32 -0
  163. package/src/server/attention.js +93 -0
  164. package/src/server/aws-athena.js +82 -0
  165. package/src/server/aws-s3.js +49 -0
  166. package/src/server/awsTransport.js +75 -0
  167. package/src/server/bigquery.js +97 -0
  168. package/src/server/brevo.js +97 -0
  169. package/src/server/calendly.js +77 -0
  170. package/src/server/canva.js +13 -0
  171. package/src/server/chargebee.js +88 -0
  172. package/src/server/clay.js +78 -0
  173. package/src/server/clickhouse.js +66 -0
  174. package/src/server/confidence.js +29 -0
  175. package/src/server/contentful.js +40 -0
  176. package/src/server/databricks.js +55 -0
  177. package/src/server/dbt-semantic-layer.js +95 -0
  178. package/src/server/elevenlabs.js +81 -0
  179. package/src/server/figma.js +24 -0
  180. package/src/server/firebase-cloud-messaging.js +111 -0
  181. package/src/server/firecrawl.js +70 -0
  182. package/src/server/fireflies.js +31 -0
  183. package/src/server/fireworks-ai.js +16 -0
  184. package/src/server/gatewayapi.js +48 -0
  185. package/src/server/gemini-enterprise.js +47 -0
  186. package/src/server/github-api.js +90 -0
  187. package/src/server/gitlab-api.js +67 -0
  188. package/src/server/gmail.js +60 -0
  189. package/src/server/gong.js +52 -0
  190. package/src/server/google-ads-search.js +134 -0
  191. package/src/server/google-ads.js +80 -0
  192. package/src/server/google-docs.js +36 -0
  193. package/src/server/google-drive.js +71 -0
  194. package/src/server/google-maps-platform.js +90 -0
  195. package/src/server/google-search-console.js +31 -0
  196. package/src/server/google-sheets.js +60 -0
  197. package/src/server/google-slides.js +36 -0
  198. package/src/server/google.js +61 -0
  199. package/src/server/granola.js +115 -0
  200. package/src/server/graphqlOperation.js +15 -0
  201. package/src/server/hex.js +29 -0
  202. package/src/server/heygen.js +54 -0
  203. package/src/server/hubspot.js +85 -0
  204. package/src/server/incident-io.js +68 -0
  205. package/src/server/inngest.js +86 -0
  206. package/src/server/jsonOperation.js +34 -0
  207. package/src/server/klipy.js +34 -0
  208. package/src/server/lexware.js +59 -0
  209. package/src/server/lightspeed.js +82 -0
  210. package/src/server/linear.js +52 -0
  211. package/src/server/linkedin.js +74 -0
  212. package/src/server/mailgun.js +76 -0
  213. package/src/server/mapbox.js +46 -0
  214. package/src/server/mcpRegistration.js +41 -0
  215. package/src/server/mcpTransport.js +60 -0
  216. package/src/server/microsoft-excel.js +67 -0
  217. package/src/server/microsoft-fabric.js +81 -0
  218. package/src/server/microsoft-onedrive.js +58 -0
  219. package/src/server/microsoft-onenote.js +52 -0
  220. package/src/server/microsoft-outlook.js +58 -0
  221. package/src/server/microsoft-powerpoint.js +5 -0
  222. package/src/server/microsoft-sharepoint.js +46 -0
  223. package/src/server/microsoft-teams.js +29 -0
  224. package/src/server/microsoft-word.js +5 -0
  225. package/src/server/microsoft.js +88 -0
  226. package/src/server/miro.js +29 -0
  227. package/src/server/n8n.js +78 -0
  228. package/src/server/notion.js +76 -0
  229. package/src/server/oura.js +48 -0
  230. package/src/server/paddle.js +83 -0
  231. package/src/server/perplexity.js +15 -0
  232. package/src/server/pipedrive.js +95 -0
  233. package/src/server/polar.js +20 -0
  234. package/src/server/posthog.js +44 -0
  235. package/src/server/prestashop.js +42 -0
  236. package/src/server/replicate.js +42 -0
  237. package/src/server/resend.js +93 -0
  238. package/src/server/salesforce.js +84 -0
  239. package/src/server/sanity.js +31 -0
  240. package/src/server/semrush.js +359 -0
  241. package/src/server/sentry.js +30 -0
  242. package/src/server/sevdesk.js +20 -0
  243. package/src/server/shopify.js +99 -0
  244. package/src/server/slack.js +143 -0
  245. package/src/server/snowflake.js +139 -0
  246. package/src/server/storyblok.js +49 -0
  247. package/src/server/stripe.js +13 -0
  248. package/src/server/supabase.js +13 -0
  249. package/src/server/tally.js +78 -0
  250. package/src/server/telegram.js +55 -0
  251. package/src/server/tiktok.js +86 -0
  252. package/src/server/twilio.js +90 -0
  253. package/src/server/twitch.js +409 -0
  254. package/src/server/wave.js +520 -0
  255. package/src/server/wix.js +1048 -0
  256. package/src/server/wiz.js +117 -0
  257. package/src/server/woocommerce.js +412 -0
  258. package/src/server/wordpress-com.js +236 -0
  259. package/src/server/wordpress-self-hosted.js +134 -0
  260. package/src/server/workday.js +136 -0
  261. package/src/server/x-twitter.js +110 -0
  262. package/src/server/xero.js +397 -0
  263. package/src/server/zoho-books.js +90 -0
  264. package/src/server/zoho-crm.js +89 -0
  265. package/src/server/zohoOAuth.js +21 -0
  266. package/src/shared/ai-models.json +8049 -0
  267. package/src/shared/ai.js +63 -0
  268. package/src/shared/algolia.js +29 -0
  269. package/src/shared/amazon-redshift.js +51 -0
  270. package/src/shared/amplitude.js +28 -0
  271. package/src/shared/atlassian.js +43 -0
  272. package/src/shared/aws.js +72 -0
  273. package/src/shared/canva.js +66 -0
  274. package/src/shared/chargebee.js +26 -0
  275. package/src/shared/clickhouse.js +38 -0
  276. package/src/shared/confidence.js +28 -0
  277. package/src/shared/databricks.js +33 -0
  278. package/src/shared/dbt-semantic-layer.js +32 -0
  279. package/src/shared/definitions.js +140 -0
  280. package/src/shared/figma.js +21 -0
  281. package/src/shared/firebase-cloud-messaging.js +45 -0
  282. package/src/shared/gemini-enterprise.js +33 -0
  283. package/src/shared/gong.js +33 -0
  284. package/src/shared/google-ads-search.js +31 -0
  285. package/src/shared/google-ads.js +28 -0
  286. package/src/shared/google-maps-platform.js +28 -0
  287. package/src/shared/google.js +133 -0
  288. package/src/shared/granola.js +25 -0
  289. package/src/shared/hex.js +28 -0
  290. package/src/shared/inngest.js +35 -0
  291. package/src/shared/lightspeed.js +75 -0
  292. package/src/shared/linkedin.js +27 -0
  293. package/src/shared/logo-dev.js +21 -0
  294. package/src/shared/mapbox.js +38 -0
  295. package/src/shared/mcp.js +92 -0
  296. package/src/shared/microsoft-fabric.js +47 -0
  297. package/src/shared/microsoft.js +231 -0
  298. package/src/shared/miro.js +27 -0
  299. package/src/shared/oura.js +23 -0
  300. package/src/shared/posthog.js +33 -0
  301. package/src/shared/prestashop.js +23 -0
  302. package/src/shared/salesforce.js +36 -0
  303. package/src/shared/semrush.js +24 -0
  304. package/src/shared/shopify.js +51 -0
  305. package/src/shared/siteUrl.js +12 -0
  306. package/src/shared/slack.js +88 -0
  307. package/src/shared/snowflake.js +55 -0
  308. package/src/shared/tiktok.js +22 -0
  309. package/src/shared/tokens.js +747 -0
  310. package/src/shared/twilio.js +41 -0
  311. package/src/shared/twitch.js +49 -0
  312. package/src/shared/wave.js +52 -0
  313. package/src/shared/wix.js +40 -0
  314. package/src/shared/wiz.js +41 -0
  315. package/src/shared/wordpress-com.js +30 -0
  316. package/src/shared/wordpress.js +69 -0
  317. package/src/shared/workday.js +50 -0
  318. package/src/shared/x-twitter.js +19 -0
  319. package/src/shared/xero.js +46 -0
  320. package/src/shared/zoho-books.js +39 -0
  321. package/src/shared/zoho-crm.js +43 -0
  322. package/src/shared/zoho-regions.js +12 -0
  323. package/test/ai.test.js +136 -0
  324. package/test/airtable.test.js +125 -0
  325. package/test/algolia.test.js +207 -0
  326. package/test/amazon-redshift.test.js +272 -0
  327. package/test/amplitude.test.js +239 -0
  328. package/test/apify.test.js +93 -0
  329. package/test/apollo-io.test.js +125 -0
  330. package/test/asana.test.js +99 -0
  331. package/test/ashby.test.js +97 -0
  332. package/test/atlassian.test.js +249 -0
  333. package/test/attention.test.js +79 -0
  334. package/test/aws.test.js +405 -0
  335. package/test/brevo.test.js +71 -0
  336. package/test/calendly.test.js +136 -0
  337. package/test/canva.test.js +258 -0
  338. package/test/chargebee.test.js +195 -0
  339. package/test/clickhouse.test.js +278 -0
  340. package/test/confidence.test.js +205 -0
  341. package/test/databricks.test.js +253 -0
  342. package/test/dbt-semantic-layer.test.js +221 -0
  343. package/test/designMcp.test.js +336 -0
  344. package/test/elevenlabs.test.js +52 -0
  345. package/test/firebase-cloud-messaging.test.js +242 -0
  346. package/test/firecrawl.test.js +66 -0
  347. package/test/gemini-enterprise.test.js +218 -0
  348. package/test/github-api.test.js +167 -0
  349. package/test/gitlab-api.test.js +160 -0
  350. package/test/gong.test.js +192 -0
  351. package/test/google-ads.test.js +350 -0
  352. package/test/google-maps-platform.test.js +191 -0
  353. package/test/google.test.js +710 -0
  354. package/test/granola.test.js +240 -0
  355. package/test/hex.test.js +253 -0
  356. package/test/heygen.test.js +121 -0
  357. package/test/hubspot.test.js +103 -0
  358. package/test/inngest.test.js +219 -0
  359. package/test/lightspeed.test.js +250 -0
  360. package/test/linear.test.js +146 -0
  361. package/test/linkedin.test.js +243 -0
  362. package/test/logo-dev.test.js +79 -0
  363. package/test/mailgun.test.js +107 -0
  364. package/test/mapbox.test.js +181 -0
  365. package/test/mcp.test.js +227 -0
  366. package/test/microsoft-fabric.test.js +258 -0
  367. package/test/microsoft.test.js +554 -0
  368. package/test/n8nOAuth.test.js +188 -0
  369. package/test/notionOAuth.test.js +183 -0
  370. package/test/oura.test.js +211 -0
  371. package/test/paddleCatalogue.test.js +51 -0
  372. package/test/paddlePermissions.test.js +101 -0
  373. package/test/perplexityComposition.test.js +39 -0
  374. package/test/pipedriveOAuth.test.js +140 -0
  375. package/test/posthog.test.js +200 -0
  376. package/test/prestashop.test.js +207 -0
  377. package/test/resend.test.js +163 -0
  378. package/test/salesforce.test.js +276 -0
  379. package/test/semrush.test.js +479 -0
  380. package/test/sentry.test.js +35 -0
  381. package/test/shopify.test.js +247 -0
  382. package/test/slack.test.js +320 -0
  383. package/test/snowflake.test.js +391 -0
  384. package/test/tally.test.js +75 -0
  385. package/test/telegram.test.js +58 -0
  386. package/test/tiktok.test.js +243 -0
  387. package/test/tokens.test.js +1079 -0
  388. package/test/twilio.test.js +221 -0
  389. package/test/twitch.test.js +573 -0
  390. package/test/wave.test.js +678 -0
  391. package/test/wix.test.js +1099 -0
  392. package/test/wiz.test.js +185 -0
  393. package/test/wordpress-com.test.js +469 -0
  394. package/test/wordpress.test.js +660 -0
  395. package/test/workday.test.js +314 -0
  396. package/test/x-twitter.test.js +256 -0
  397. package/test/xero.test.js +700 -0
  398. package/test/zoho-books.test.js +253 -0
  399. package/test/zoho-crm.test.js +223 -0
@@ -0,0 +1,272 @@
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 path from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { createConnectionService } from "../../connectors-core/src/server/index.js";
7
+ import { createFileConnectionStore, createCredentialProtection } from "../../connectors-core/src/server/fileStorage.js";
8
+ import { parseIntegrationConfiguration, getProviderSettingsSchema } from "../../connectors-core/src/shared/configuration.js";
9
+ import { connectorDefinitions } from "../src/shared/definitions.js";
10
+ import { amazonRedshiftProvider as provider } from "../src/server/amazon-redshift.js";
11
+
12
+ const context = { applicationId: "reports", subjectId: "owner" };
13
+ const input = { context, integrationId: "warehouse" };
14
+ const statementId = "12345678-1234-1234-1234-123456789abc";
15
+ const columns = [{ name: "id", typeName: "int8" }];
16
+ const table = { name: "sales", schema: "public", type: "TABLE" };
17
+
18
+ async function fixture(t, deploymentType = "serverless", databaseUser) {
19
+ const directory = await mkdtemp(path.join(tmpdir(), "redshift-connector-"));
20
+ t.after(() => rm(directory, { recursive: true, force: true }));
21
+ const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(6) }, activeKeyId: "current" });
22
+ const requests = [], resolutions = [];
23
+ const target = { Database: "dev", ...(deploymentType === "serverless" ? { WorkgroupName: "analytics" } : { ClusterIdentifier: "analytics-cluster", DbUser: databaseUser || "IAM:reader" }) };
24
+ const state = { key: "AKIAEXAMPLEKEY1234567", secret: "not-live-redshift-secret", session: undefined, status: 200, response: undefined,
25
+ statement: { ...target, Id: statementId, Status: "FINISHED", HasResultSet: true },
26
+ started: { ...target, Id: statementId }, results: { ColumnMetadata: columns, Records: [[{ longValue: 7 }]], TotalNumRows: 1, NextToken: "opaque +/=" } };
27
+ const options = {
28
+ configuration: { schemaVersion: 1, registrations: {}, integrations: { warehouse: {
29
+ provider: provider.id, displayName: "Warehouse", accountMode: "shared", scopes: [],
30
+ authentication: { method: "api-key", secretRef: "env:AWS_SECRET_ACCESS_KEY" },
31
+ settings: { deploymentType, region: "us-east-1", accessKeyIdRef: "env:AWS_ACCESS_KEY_ID", database: "dev",
32
+ ...(deploymentType === "serverless" ? { workgroup: "analytics" } : { clusterIdentifier: "analytics-cluster", ...(databaseUser ? { databaseUser } : {}) }) }
33
+ } }, extensions: { fromCli: true } },
34
+ providers: [provider], authorize: async (owner) => owner,
35
+ resolveReference: async (ref) => { resolutions.push(ref); return { "env:AWS_ACCESS_KEY_ID": state.key, "env:AWS_SECRET_ACCESS_KEY": state.secret, "env:AWS_SESSION_TOKEN": state.session }[ref]; },
36
+ store: createFileConnectionStore({ directory, protection }),
37
+ fetchImpl: async (address, init) => {
38
+ const headers = new Headers(init.headers), action = headers.get("x-amz-target").split(".").at(-1);
39
+ requests.push({ url: new URL(address), init, headers, action, body: JSON.parse(init.body) });
40
+ return Response.json(state.response ?? { ListTables: { Tables: [table] }, DescribeTable: { ColumnList: columns, TableName: "sales" },
41
+ ExecuteStatement: state.started, DescribeStatement: state.statement, GetStatementResult: state.results, CancelStatement: { Status: true } }[action], { status: state.status });
42
+ }
43
+ };
44
+ return { service: createConnectionService(options), options, requests, resolutions, state, target, directory, protection };
45
+ }
46
+
47
+ for (const [type, user] of [["serverless"], ["provisioned"], ["provisioned", "report_reader"]]) {
48
+ test(`Redshift ${type}${user ? " explicit DB user" : " IAM user"} signs a target-bound connection check and survives a file-store restart`, async (t) => {
49
+ const { service, options, requests, state, target, directory, protection } = await fixture(t, type, user);
50
+ const connected = await service.connectApiKey(input);
51
+ assert.equal(connected.status, "connected");
52
+ assert.deepEqual(requests.map((r) => r.action), ["ListTables"]);
53
+ const expected = { ...target }; if (!user) delete expected.DbUser;
54
+ assert.deepEqual(requests[0].body, { ...expected, MaxResults: 100 });
55
+ assert.equal(requests[0].url.origin, "https://redshift-data.us-east-1.amazonaws.com");
56
+ assert.match(requests[0].headers.get("authorization"), /AWS4-HMAC-SHA256 Credential=AKIAEXAMPLEKEY1234567\/\d{8}\/us-east-1\/redshift-data\/aws4_request/u);
57
+ assert.equal(requests[0].init.redirect, "error"); assert.equal(requests[0].init.credentials, "omit");
58
+ assert.equal(requests[0].headers.has("cookie"), false);
59
+ for (const name of await readdir(directory)) {
60
+ const file = await readFile(path.join(directory, name), "utf8");
61
+ assert.equal(file.includes(state.secret), false); assert.equal(file.includes(state.key), false);
62
+ }
63
+ const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
64
+ assert.deepEqual(await restarted.status(input), connected);
65
+ state.key = "AKIAROTATEDKEY1234567";
66
+ await restarted.invoke({ ...input, operation: "tables.list" });
67
+ assert.ok(requests.at(-1).headers.get("authorization").includes(state.key));
68
+ await restarted.disconnect(input);
69
+ assert.equal((await service.status(input)).status, "disconnected");
70
+ });
71
+ }
72
+
73
+ test("Redshift CLI and form use the same conditional fields, defaults and credential references", async (t) => {
74
+ const { options, resolutions } = await fixture(t);
75
+ const parse = (config) => parseIntegrationConfiguration(JSON.stringify(config), { providers: connectorDefinitions });
76
+ assert.deepEqual(parse(options.configuration), options.configuration);
77
+ const defaults = structuredClone(options.configuration); delete defaults.integrations.warehouse.settings.deploymentType; delete defaults.integrations.warehouse.settings.region;
78
+ assert.equal(parse(defaults).integrations.warehouse.settings.deploymentType, "serverless");
79
+ assert.equal(parse(defaults).integrations.warehouse.settings.region, "us-east-1");
80
+ assert.equal(provider.settingsFields.find((f) => f.name === "region").items.length, 34);
81
+ const serverless = getProviderSettingsSchema(provider).getFieldDefinitions();
82
+ const provisioned = getProviderSettingsSchema(provider, { deploymentType: "provisioned" }).getFieldDefinitions();
83
+ assert.ok(serverless.workgroup.required); assert.equal(serverless.clusterIdentifier, undefined);
84
+ assert.ok(provisioned.clusterIdentifier.required); assert.equal(provisioned.workgroup, undefined); assert.equal(provisioned.databaseUser.required, false);
85
+ for (const change of [{ region: "https://other.example" }, { region: "cn-north-1" }, { deploymentType: "other" }, { deploymentType: "provisioned" },
86
+ { clusterIdentifier: "mixed-mode" }, { databaseUser: "wrong-mode" }, { workgroup: "arn:aws:example" }, { workgroup: "ab" }, { workgroup: undefined },
87
+ { database: "\n" }, { database: "é".repeat(64) }, { accessKeyIdRef: "raw-access-key" }, { sessionTokenRef: "raw-token" }]) {
88
+ const config = structuredClone(options.configuration); Object.assign(config.integrations.warehouse.settings, change);
89
+ assert.throws(() => parse(config), { code: "integration_configuration_invalid" });
90
+ }
91
+ const perUser = structuredClone(options.configuration); perUser.integrations.warehouse.accountMode = "per-user";
92
+ assert.throws(() => parse(perUser), { code: "integration_configuration_invalid" });
93
+ assert.equal(resolutions.length, 0);
94
+ });
95
+
96
+ test("Redshift metadata preserves filters, exact identifiers and opaque page tokens", async (t) => {
97
+ const { service, requests, state } = await fixture(t); await service.connectApiKey(input);
98
+ state.response = { Tables: [table], NextToken: " page +/= " };
99
+ const page = await service.invoke({ ...input, operation: "tables.list", input: { maxResults: 2, nextToken: " page +/= ", schemaPattern: "pub%", tablePattern: "sales_" } });
100
+ assert.equal(page.NextToken, " page +/= ");
101
+ assert.deepEqual(requests.at(-1).body, { Database: "dev", WorkgroupName: "analytics", MaxResults: 2, NextToken: " page +/= ", SchemaPattern: "pub%", TablePattern: "sales_" });
102
+ state.response = undefined;
103
+ await service.invoke({ ...input, operation: "table.describe", input: { table: " sales ", schema: " Mixed Case ", maxResults: 1 } });
104
+ assert.equal(requests.at(-1).body.Table, " sales "); assert.equal(requests.at(-1).body.Schema, " Mixed Case ");
105
+ for (const invalid of [{ maxResults: 1001 }, { maxResults: 0 }, { database: "other" }, { ConnectedDatabase: "other" }, { nextToken: "" }]) {
106
+ const count = requests.length;
107
+ await assert.rejects(service.invoke({ ...input, operation: "tables.list", input: invalid })); assert.equal(requests.length, count);
108
+ }
109
+ });
110
+
111
+ test("Redshift submits exact parameterized SQL with caller-owned idempotency and no automatic polling", async (t) => {
112
+ const { service, requests, state } = await fixture(t); await service.connectApiKey(input);
113
+ const values = { sql: " select :amount::bigint;\n", clientToken: "logical-request-1", parameters: [{ name: "amount", value: "9007199254740993" }] };
114
+ const started = await service.invoke({ ...input, operation: "query.start", input: values });
115
+ assert.equal(started.Id, statementId);
116
+ assert.deepEqual(requests.at(-1).body, { Database: "dev", WorkgroupName: "analytics", Sql: values.sql, ClientToken: values.clientToken, Parameters: values.parameters, ResultFormat: "JSON" });
117
+ assert.deepEqual(requests.map((r) => r.action), ["ListTables", "ExecuteStatement"]);
118
+ for (const change of [{ sql: " " }, { sql: "é".repeat(100001) }, { clientToken: undefined }, { clientToken: "a".repeat(65) },
119
+ { parameters: [] }, { parameters: Array.from({ length: 101 }, (_, i) => ({ name: `p${i}`, value: "1" })) },
120
+ { parameters: [{ name: "bad-name", value: "1" }] }, { parameters: [{ name: "amount", value: "" }] },
121
+ { parameters: [{ name: "amount", value: "1" }, { name: "amount", value: "2" }] }, { database: "other" }, { SecretArn: "arn:aws:secretsmanager:other" }]) {
122
+ const count = requests.length; await assert.rejects(service.invoke({ ...input, operation: "query.start", input: { ...values, ...change } })); assert.equal(requests.length, count);
123
+ }
124
+ state.started.Database = "other";
125
+ await assert.rejects(service.invoke({ ...input, operation: "query.start", input: values }), { code: "connector_access_denied" });
126
+ });
127
+
128
+ test("Redshift checks statement target and ID before returning status, results or cancelling", async (t) => {
129
+ const { service, requests, state } = await fixture(t); await service.connectApiKey(input);
130
+ for (const operation of ["query.status", "query.results", "query.cancel"]) {
131
+ for (const mismatch of [{ Database: "other" }, { WorkgroupName: "other" }, { ClusterIdentifier: "other" }]) {
132
+ const old = state.statement; state.statement = { ...old, ...mismatch }; const count = requests.length;
133
+ await assert.rejects(service.invoke({ ...input, operation, input: { statementId } }), { code: "connector_access_denied" });
134
+ assert.deepEqual(requests.slice(count).map((r) => r.action), ["DescribeStatement"]); state.statement = old;
135
+ }
136
+ }
137
+ state.statement.Id = "abcdefab-1234-1234-1234-123456789abc";
138
+ await assert.rejects(service.invoke({ ...input, operation: "query.status", input: { statementId } }), { code: "connector_response_invalid" });
139
+ });
140
+
141
+ test("Redshift provisioned statement access checks explicit database user", async (t) => {
142
+ const { service, state, requests } = await fixture(t, "provisioned", "report_reader"); await service.connectApiKey(input);
143
+ state.statement.DbUser = "admin";
144
+ await assert.rejects(service.invoke({ ...input, operation: "query.results", input: { statementId } }), { code: "connector_access_denied" });
145
+ assert.equal(requests.at(-1).action, "DescribeStatement");
146
+ });
147
+
148
+ test("Redshift returns one result page with typed cells, nulls, empty strings and exact cursors", async (t) => {
149
+ const { service, state, requests } = await fixture(t); await service.connectApiKey(input);
150
+ state.results = { ColumnMetadata: [{ name: "value", typeName: "varchar" }], Records: [[{ isNull: true }], [{ stringValue: "" }], [{ stringValue: "9007199254740993" }], [{ longValue: 7 }], [{ doubleValue: 1.5 }], [{ booleanValue: false }], [{ blobValue: "AQID" }]], TotalNumRows: 7, NextToken: " next +/= " };
151
+ const result = await service.invoke({ ...input, operation: "query.results", input: { statementId, nextToken: " previous +/= " } });
152
+ assert.equal(result.NextToken, " next +/= "); assert.deepEqual(result.Records[2], [{ stringValue: "9007199254740993" }]);
153
+ assert.deepEqual(result.Records[6], [{ blobValue: new Uint8Array([1, 2, 3]) }]);
154
+ assert.deepEqual(requests.slice(-2).map((r) => r.action), ["DescribeStatement", "GetStatementResult"]);
155
+ assert.deepEqual(requests.at(-1).body, { Id: statementId, NextToken: " previous +/= " });
156
+ state.results.Records = [[{ longValue: 9007199254740992 }]];
157
+ await assert.rejects(service.invoke({ ...input, operation: "query.results", input: { statementId } }), { code: "connector_response_invalid" });
158
+ });
159
+
160
+ test("Redshift exposes job states and requires a finished result set before fetching", async (t) => {
161
+ const { service, state, requests } = await fixture(t); await service.connectApiKey(input);
162
+ for (const status of ["SUBMITTED", "PICKED", "STARTED", "FINISHED", "ABORTED", "FAILED"]) {
163
+ state.statement.Status = status;
164
+ assert.equal((await service.invoke({ ...input, operation: "query.status", input: { statementId } })).Status, status);
165
+ if (status !== "FINISHED") {
166
+ const count = requests.length;
167
+ await assert.rejects(service.invoke({ ...input, operation: "query.results", input: { statementId } }), { code: "connector_result_unavailable" });
168
+ assert.equal(requests.length, count + 1);
169
+ }
170
+ }
171
+ state.statement.Status = "FINISHED"; state.statement.HasResultSet = false;
172
+ await assert.rejects(service.invoke({ ...input, operation: "query.results", input: { statementId } }), { code: "connector_result_unavailable" });
173
+ assert.deepEqual(await service.invoke({ ...input, operation: "query.cancel", input: { statementId } }), { Status: true });
174
+ assert.deepEqual(requests.slice(-2).map((r) => r.action), ["DescribeStatement", "CancelStatement"]);
175
+ const count = requests.length; await service.disconnect(input); assert.equal(requests.length, count);
176
+ });
177
+
178
+ test("Redshift fails on malformed metadata, job and result responses", async (t) => {
179
+ const { service, state } = await fixture(t); await service.connectApiKey(input);
180
+ for (const [operation, bad] of [["tables.list", {}], ["tables.list", { Tables: [{ name: "x" }] }], ["table.describe", { ColumnList: [{}], TableName: "x" }],
181
+ ["query.status", { Id: statementId }], ["query.start", { Id: "not-an-id" }]]) {
182
+ state.response = bad;
183
+ await assert.rejects(service.invoke({ ...input, operation, input: { ...(operation === "table.describe" ? { table: "sales" } : {}), ...(operation === "query.status" ? { statementId } : {}), ...(operation === "query.start" ? { sql: "select 1", clientToken: "request-1" } : {}) } }), { code: "connector_response_invalid" });
184
+ }
185
+ });
186
+
187
+ test("Redshift credential errors, throttling and missing resources are redacted and never retried", async (t) => {
188
+ const { service, requests, state } = await fixture(t);
189
+ for (const [status, name, code] of [[400, "ExpiredTokenException", "connector_reconnect_required"], [403, "AccessDeniedException", "connector_permission_denied"],
190
+ [400, "ThrottlingException", "connector_rate_limited"], [400, "ActiveStatementsExceededException", "connector_rate_limited"],
191
+ [400, "ResourceNotFoundException", "connector_not_found"], [400, "ValidationException", "connector_configuration_invalid"], [500, "InternalServerException", "connector_provider_failed"]]) {
192
+ state.status = status; state.response = { __type: name, Message: state.secret }; const count = requests.length;
193
+ await assert.rejects(service.connectApiKey(input), (e) => { assert.equal(e.code, code); assert.equal(e.stack.includes(state.secret), false); return true; });
194
+ assert.equal(requests.length, count + 1);
195
+ }
196
+ });
197
+
198
+ test("Redshift supports explicit temporary AWS credentials, never host defaults", async (t) => {
199
+ const { service, options, state, requests } = await fixture(t);
200
+ state.key = undefined; await assert.rejects(service.connectApiKey(input), { code: "connector_binding_missing" });
201
+ state.key = "ASIAEXAMPLEKEY1234567"; await assert.rejects(service.connectApiKey(input), { code: "connector_binding_missing" });
202
+ assert.equal(requests.length, 0);
203
+ options.configuration.integrations.warehouse.settings.sessionTokenRef = "env:AWS_SESSION_TOKEN";
204
+ state.session = "temporary/+="; const temporary = createConnectionService(options); await temporary.connectApiKey(input);
205
+ assert.equal(requests[0].headers.get("x-amz-security-token"), state.session);
206
+ });
207
+
208
+ test("Redshift authorizes before secrets and binds connections to owners and warehouse settings", async (t) => {
209
+ const { service, options, requests, resolutions } = await fixture(t); await service.connectApiKey(input);
210
+ for (const owner of [{ ...context, subjectId: "other" }, { ...context, applicationId: "other" }]) {
211
+ await assert.rejects(service.invoke({ ...input, context: owner, operation: "tables.list" }), { code: "connector_reconnect_required" });
212
+ }
213
+ const configuration = structuredClone(options.configuration); configuration.integrations.warehouse.settings.workgroup = "other";
214
+ const changed = createConnectionService({ ...options, configuration }); assert.equal((await changed.status(input)).status, "reconnect-required");
215
+ await assert.rejects(changed.invoke({ ...input, operation: "tables.list" }), { code: "connector_reconnect_required" });
216
+ const count = resolutions.length;
217
+ const denied = createConnectionService({ ...options, authorize: async () => null });
218
+ await assert.rejects(denied.invoke({ ...input, operation: "query.start", input: { sql: "select 1", clientToken: "request-1" } }), { code: "connector_access_denied" });
219
+ assert.equal(resolutions.length, count); assert.equal(requests.length, 1);
220
+ });
221
+
222
+ test("Redshift local cancellation and timeout do not replay or silently cancel remote SQL", async (t) => {
223
+ const { options } = await fixture(t);
224
+ for (const [name, code] of [["AbortError", "connector_cancelled"], ["TimeoutError", "connector_provider_timeout"]]) {
225
+ const controller = new AbortController(), actions = [];
226
+ const interrupted = createConnectionService({ ...options, fetchImpl: async (address, init) => {
227
+ const action = new Headers(init.headers).get("x-amz-target").split(".").at(-1);
228
+ if (action !== "ExecuteStatement") return options.fetchImpl(address, init);
229
+ actions.push(action); controller.abort(new DOMException("Interrupted", name)); throw new Error("wrapped");
230
+ } });
231
+ await interrupted.connectApiKey(input);
232
+ await assert.rejects(interrupted.invoke({ ...input, operation: "query.start", input: { sql: "select 1", clientToken: "request-1" }, signal: controller.signal }), { code });
233
+ assert.deepEqual(actions, ["ExecuteStatement"]);
234
+ assert.equal((await interrupted.status(input)).status, "connected");
235
+ }
236
+ });
237
+
238
+
239
+ test("Redshift database/schema discovery preserves native names and paging in each configured target", async (t) => {
240
+ for (const [type, user] of [["serverless"], ["provisioned"], ["provisioned", "report_reader"]]) {
241
+ const { service, options, requests, state, target, resolutions } = await fixture(t, type, user);
242
+ await service.connectApiKey(input);
243
+ const expectedTarget = { ...target }; if (!user) delete expectedTarget.DbUser;
244
+ for (const [operation, action, field, filters] of [
245
+ ["databases.list", "ListDatabases", "Databases", {}],
246
+ ["schemas.list", "ListSchemas", "Schemas", { schemaPattern: "Mixed_%" }]
247
+ ]) {
248
+ state.response = { [field]: [" Mixed Case ", "資料"], NextToken: " next +/= " };
249
+ const result = await service.invoke({ ...input, operation, input: { maxResults: 2, nextToken: " previous +/= ", ...filters } });
250
+ assert.deepEqual(result, state.response);
251
+ assert.equal(requests.at(-1).action, action);
252
+ assert.deepEqual(requests.at(-1).body, { ...expectedTarget, MaxResults: 2, NextToken: " previous +/= ",
253
+ ...(filters.schemaPattern ? { SchemaPattern: filters.schemaPattern } : {}) });
254
+ state.response = { [field]: [], NextToken: "" };
255
+ assert.deepEqual(await service.invoke({ ...input, operation }), state.response);
256
+ for (const invalid of [{ maxResults: 1001 }, { maxResults: 0 }, { nextToken: "" },
257
+ { database: "other" }, { ConnectedDatabase: "other" }, { workgroup: "other" }]) {
258
+ const count = requests.length;
259
+ await assert.rejects(service.invoke({ ...input, operation, input: invalid }));
260
+ assert.equal(requests.length, count);
261
+ }
262
+ state.response = { [field]: "not-a-list" };
263
+ await assert.rejects(service.invoke({ ...input, operation }), { code: "connector_response_invalid" });
264
+ state.response = { [field]: [] };
265
+ const before = resolutions.length, calls = requests.length;
266
+ const denied = createConnectionService({ ...options, authorize: async () => false });
267
+ await assert.rejects(denied.invoke({ ...input, operation }));
268
+ assert.equal(resolutions.length, before); assert.equal(requests.length, calls);
269
+ }
270
+ assert.equal(requests.some((request) => request.action === "ExecuteStatement"), false);
271
+ }
272
+ });
@@ -0,0 +1,239 @@
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 { parseIntegrationConfiguration } from "../../connectors-core/src/shared/configuration.js";
9
+ import { amplitudeProvider, registerAmplitudeClient } from "../src/server/amplitude.js";
10
+
11
+ const context = { applicationId: "assistant-host", subjectId: "account-owner" };
12
+ const input = { context, integrationId: "analytics" };
13
+ const callback = "http://127.0.0.1:4919/amplitude/callback";
14
+ const scopes = ["mcp:read", "offline_access"];
15
+ const discovery = { tools: [{ name: "get_amplitude_context", inputSchema: { type: "object", properties: { project_id: { type: "string" } } } }] };
16
+
17
+ async function fixture(t, region = "us") {
18
+ const directory = await mkdtemp(path.join(tmpdir(), "amplitude-connector-"));
19
+ t.after(() => rm(directory, { recursive: true, force: true }));
20
+ const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(3) }, activeKeyId: "current" });
21
+ const origin = region === "eu" ? "https://mcp.eu.amplitude.com" : "https://mcp.amplitude.com";
22
+ const state = { time: Date.now(), scopes, status: 200, list: discovery, call: { content: [{ type: "text", text: "Project analytics" }] } };
23
+ const requests = [];
24
+ const options = {
25
+ configuration: { schemaVersion: 1, registrations: { amplitude: { source: "own", clientId: "fixture-client",
26
+ clientSecretRef: "env:AMPLITUDE_CLIENT_SECRET", callbackUrlRef: "env:AMPLITUDE_CALLBACK" } }, integrations: {
27
+ analytics: { provider: "amplitude", accountMode: "assistant", scopes, settings: { region },
28
+ authentication: { method: "oauth2", registrationRef: "amplitude" } }
29
+ } },
30
+ providers: [amplitudeProvider], store: createFileConnectionStore({ directory, protection }), now: () => state.time,
31
+ authorize: async (owner, request) => request.operation === "tools.call" &&
32
+ (request.input.name !== "get_amplitude_context" || request.input.arguments.project_id !== "allowed-project") ? null : owner,
33
+ resolveReference: async (reference) => reference === "env:AMPLITUDE_CLIENT_SECRET" ? "fixture-client-secret" : callback,
34
+ fetchImpl: async (address, init) => {
35
+ init.signal.throwIfAborted();
36
+ const url = String(address);
37
+ requests.push({ url, init, headers: new Headers(init.headers) });
38
+ if (url === `${origin}/token`) {
39
+ if (state.tokenError) return Response.json({ error: state.tokenError, error_description: "fixture-client-secret" }, { status: 400 });
40
+ const refresh = new URLSearchParams(init.body).get("grant_type") === "refresh_token";
41
+ return Response.json({ token_type: "Bearer", access_token: refresh ? "refreshed-access" : "initial-access",
42
+ refresh_token: refresh ? "rotated-refresh" : "initial-refresh", expires_in: 60, scope: state.scopes.join(" ") });
43
+ }
44
+ assert.equal(url, `${origin}/mcp`);
45
+ if (init.method === "DELETE") return new Response(null, { status: 204 });
46
+ if (init.method === "GET") return new Response(null, { status: 405 });
47
+ if (state.status !== 200) return new Response("initial-access fixture-client-secret", { status: state.status,
48
+ headers: { "WWW-Authenticate": 'Bearer resource_metadata="https://unexpected.invalid/auth"' } });
49
+ const message = JSON.parse(init.body);
50
+ requests.at(-1).message = message;
51
+ if (message.id === undefined) return new Response(null, { status: 202 });
52
+ const result = message.method === "initialize" ? { protocolVersion: message.params.protocolVersion,
53
+ capabilities: { tools: {} }, serverInfo: { name: "amplitude-fixture", version: "1" } }
54
+ : message.method === "tools/list" ? state.list : state.call;
55
+ if (state.pause && message.method === "tools/call") {
56
+ state.started();
57
+ return new Promise((resolve, reject) => init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true }));
58
+ }
59
+ return Response.json({ jsonrpc: "2.0", id: message.id, result }, { headers: { "Mcp-Session-Id": "amplitude-session" } });
60
+ }
61
+ };
62
+ const service = createConnectionService(options);
63
+ const complete = async (start, extra = "code=fixture-code") => service.completeAuthorization({ ...input,
64
+ callbackUrl: `${callback}?state=${new URL(start.authorizationUrl).searchParams.get("state")}&${extra}` });
65
+ return { service, options, state, requests, origin, directory, protection, complete };
66
+ }
67
+
68
+ for (const region of ["us", "eu"]) {
69
+ test(`Amplitude ${region} uses regional PKCE and resource-bound grants, verifies discovery and refreshes after file restart`, async (t) => {
70
+ const { service, options, requests, state, origin, complete, directory, protection } = await fixture(t, region);
71
+ const start = await service.beginAuthorization(input);
72
+ const url = new URL(start.authorizationUrl);
73
+ assert.equal(url.origin + url.pathname, `${origin}/authorize`);
74
+ assert.equal(url.searchParams.get("resource"), origin);
75
+ assert.equal(url.searchParams.get("scope"), scopes.join(" "));
76
+ assert.equal(url.searchParams.get("code_challenge_method"), "S256");
77
+ assert.equal(requests.length, 0);
78
+ assert.equal((await complete(start)).status, "connected");
79
+ const grant = requests.find((request) => request.url === `${origin}/token`);
80
+ const body = new URLSearchParams(grant.init.body);
81
+ assert.equal(body.get("resource"), origin);
82
+ assert.equal(body.get("client_secret"), "fixture-client-secret");
83
+ assert.equal(body.get("redirect_uri"), callback);
84
+ assert(body.get("code_verifier"));
85
+ assert.equal(requests.filter(({ message }) => message?.method === "tools/call").length, 0);
86
+ for (const request of requests.filter(({ url }) => url.endsWith("/mcp"))) {
87
+ assert.equal(request.headers.get("authorization"), "Bearer initial-access");
88
+ assert.equal(request.init.redirect, "error");
89
+ assert.equal(request.init.credentials, "omit");
90
+ }
91
+ const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
92
+ state.time += 61_000;
93
+ assert.deepEqual(await restarted.invoke({ ...input, operation: "tools.list" }), discovery);
94
+ const refresh = new URLSearchParams(requests.findLast(({ url }) => url.endsWith("/token")).init.body);
95
+ assert.equal(refresh.get("resource"), origin);
96
+ assert.equal(refresh.get("refresh_token"), "initial-refresh");
97
+ assert.equal(requests.at(-1).headers.get("authorization"), "Bearer refreshed-access");
98
+ assert.equal(requests.at(-1).init.method, "DELETE");
99
+ for (const name of await readdir(directory)) {
100
+ const stored = await readFile(path.join(directory, name), "utf8");
101
+ for (const secret of ["initial-access", "refreshed-access", "initial-refresh", "rotated-refresh", "fixture-client-secret", body.get("code_verifier")]) assert(!stored.includes(secret));
102
+ }
103
+ const count = requests.length;
104
+ await assert.rejects(complete(start), { code: "connector_attempt_invalid" });
105
+ for (const owner of [{ ...context, applicationId: "another-host" }, { ...context, subjectId: "another-owner" }]) {
106
+ await assert.rejects(restarted.invoke({ ...input, context: owner, operation: "tools.list" }), { code: "connector_reconnect_required" });
107
+ }
108
+ assert.equal(requests.length, count);
109
+ await restarted.disconnect(input);
110
+ assert.equal((await service.status(input)).status, "disconnected");
111
+ assert.equal(requests.length, count);
112
+ });
113
+ }
114
+
115
+ test("Amplitude configuration and changed regions cannot bypass account ownership or reuse a pending grant", async (t) => {
116
+ const { options, service, complete, requests } = await fixture(t);
117
+ const parse = (config) => parseIntegrationConfiguration(JSON.stringify(config), { providers: [amplitudeProvider] });
118
+ for (const [field, value] of [["accountMode", "shared"], ["accountMode", "per-user"], ["scopes", ["admin"]], ["settings", { region: "https://unexpected.invalid" }]]) {
119
+ const config = structuredClone(options.configuration);
120
+ config.integrations.analytics[field] = value;
121
+ assert.throws(() => parse(config));
122
+ }
123
+ const start = await service.beginAuthorization(input);
124
+ const config = structuredClone(options.configuration);
125
+ config.integrations.analytics.settings.region = "eu";
126
+ const changed = createConnectionService({ ...options, configuration: config });
127
+ await assert.rejects(changed.completeAuthorization({ ...input, callbackUrl: `${callback}?code=x&state=${new URL(start.authorizationUrl).searchParams.get("state")}` }), { code: "connector_attempt_invalid" });
128
+ assert.equal(requests.length, 0);
129
+ await complete(await service.beginAuthorization(input));
130
+ assert.equal((await changed.status(input)).status, "reconnect-required");
131
+ const count = requests.length;
132
+ await assert.rejects(changed.invoke({ ...input, operation: "tools.list" }), { code: "connector_reconnect_required" });
133
+ assert.equal(requests.length, count);
134
+ });
135
+
136
+ test("Amplitude authorizes exact tools and arguments, preserves tool errors and cancels without replay", async (t) => {
137
+ const { service, complete, requests, state } = await fixture(t);
138
+ await complete(await service.beginAuthorization(input));
139
+ const count = requests.length;
140
+ for (const payload of [{ name: "delete_chart", arguments: {} }, { name: "get_amplitude_context", arguments: { project_id: "another-project" } }]) {
141
+ await assert.rejects(service.invoke({ ...input, operation: "tools.call", input: payload }), { code: "connector_access_denied" });
142
+ }
143
+ await assert.rejects(service.invoke({ ...input, operation: "tools.list", input: { url: "https://unexpected.invalid" } }), { code: "connector_input_invalid" });
144
+ assert.equal(requests.length, count);
145
+ const payload = { name: "get_amplitude_context", arguments: { project_id: "allowed-project" } };
146
+ const analytics = { content: [{ type: "text", text: "Allowed project: checkout analytics and saved funnel reports" }] };
147
+ state.call = analytics;
148
+ assert.deepEqual(await service.invoke({ ...input, operation: "tools.call", input: payload }), analytics);
149
+ state.call = { content: [{ type: "text", text: "No matching project" }], isError: true };
150
+ assert.deepEqual(await service.invoke({ ...input, operation: "tools.call", input: payload }), state.call);
151
+ assert.deepEqual(requests.findLast(({ message }) => message?.method === "tools/call").message.params, payload);
152
+ state.pause = true;
153
+ const started = new Promise((resolve) => { state.started = resolve; });
154
+ const controller = new AbortController();
155
+ const pending = assert.rejects(service.invoke({ ...input, operation: "tools.call", input: payload, signal: controller.signal }), { code: "connector_cancelled" });
156
+ await started;
157
+ controller.abort();
158
+ await pending;
159
+ assert.equal(requests.filter(({ message }) => message?.method === "tools/call").length, 3);
160
+ assert.equal(requests.at(-1).init.method, "DELETE");
161
+ });
162
+
163
+ test("Amplitude denied consent, missing read permission and malformed discovery never create a grant", async (t) => {
164
+ const { service, complete, requests, state } = await fixture(t);
165
+ await assert.rejects(complete(await service.beginAuthorization(input), "error=access_denied"), { code: "connector_consent_denied" });
166
+ assert.equal(requests.length, 0);
167
+ state.scopes = ["mcp:write"];
168
+ await assert.rejects(complete(await service.beginAuthorization(input)), { code: "connector_scope_missing" });
169
+ assert.equal(requests.length, 1);
170
+ state.scopes = scopes;
171
+ state.list = { wrong: [] };
172
+ await assert.rejects(complete(await service.beginAuthorization(input)), { code: "connector_provider_failed" });
173
+ assert.equal((await service.status(input)).status, "disconnected");
174
+ });
175
+
176
+ test("Amplitude HTTP and refresh failures are sanitized and do not follow authentication metadata", async (t) => {
177
+ const { service, complete, requests, state, origin } = await fixture(t);
178
+ for (const [status, code] of [[401, "connector_reconnect_required"], [403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
179
+ state.status = status;
180
+ await assert.rejects(complete(await service.beginAuthorization(input)), (error) => {
181
+ assert.equal(error.code, code);
182
+ assert(!error.message.includes("fixture-client-secret"));
183
+ assert.equal(error.cause, undefined);
184
+ return true;
185
+ });
186
+ }
187
+ assert(requests.every(({ url }) => url === `${origin}/mcp` || url === `${origin}/token`));
188
+ state.status = 200;
189
+ await complete(await service.beginAuthorization(input));
190
+ state.time += 61_000;
191
+ state.tokenError = "invalid_grant";
192
+ await assert.rejects(service.invoke({ ...input, operation: "tools.list" }), { code: "connector_reconnect_required" });
193
+ assert.equal((await service.status(input)).status, "reconnect-required");
194
+ });
195
+
196
+ test("Amplitude registration uses the SDK and exact regional endpoint with explicit confidential client metadata", async () => {
197
+ for (const region of ["us", "eu"]) {
198
+ const requests = [];
199
+ const result = await registerAmplitudeClient({ region, clientName: "My assistant", callbackUrl: callback, scopes }, {
200
+ fetchImpl: async (url, init) => {
201
+ requests.push({ url: String(url), init });
202
+ return Response.json({ ...JSON.parse(init.body), client_id: "created-client", client_secret: "created-secret", client_secret_expires_at: 0 });
203
+ }
204
+ });
205
+ assert.deepEqual(result, { clientId: "created-client", clientSecret: "created-secret", clientSecretExpiresAt: 0 });
206
+ assert.equal(requests.length, 1);
207
+ assert.equal(requests[0].url, region === "eu" ? "https://mcp.eu.amplitude.com/register" : "https://mcp.amplitude.com/register");
208
+ assert.equal(requests[0].init.redirect, "error");
209
+ assert.equal(requests[0].init.credentials, "omit");
210
+ assert.deepEqual(JSON.parse(requests[0].init.body), { client_name: "My assistant", redirect_uris: [callback], token_endpoint_auth_method: "client_secret_post",
211
+ grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], scope: scopes.join(" ") });
212
+ }
213
+ });
214
+
215
+ test("Amplitude registration rejects invalid input before HTTP and sanitizes ambiguous provider results without retry", async () => {
216
+ const valid = { clientName: "CLI client", callbackUrl: callback, scopes };
217
+ let requests = 0;
218
+ const options = { fetchImpl: async () => { requests++; throw new Error("secret-provider-response"); } };
219
+ for (const fields of [{ region: "other" }, { callbackUrl: "http://remote.example/callback" }, { callbackUrl: "https://user:secret@example.test/callback" },
220
+ { callbackUrl: "https://example.test/callback?other=1" }, { scopes: [] }, { scopes: ["mcp:write"] }, { scopes: ["mcp:read", "mcp:read"] }, { clientName: "" }]) {
221
+ await assert.rejects(registerAmplitudeClient({ ...valid, ...fields }, options));
222
+ }
223
+ assert.equal(requests, 0);
224
+ await assert.rejects(registerAmplitudeClient(valid, options), (error) => {
225
+ assert.equal(error.code, "connector_registration_failed");
226
+ assert.equal(error.cause, undefined);
227
+ assert(!error.message.includes("secret-provider-response"));
228
+ return true;
229
+ });
230
+ assert.equal(requests, 1);
231
+ for (const changes of [{ client_secret: undefined }, { token_endpoint_auth_method: "none" }, { redirect_uris: ["https://unexpected.invalid"] }]) {
232
+ await assert.rejects(registerAmplitudeClient(valid, { fetchImpl: async (url, init) => Response.json({ ...JSON.parse(init.body),
233
+ client_id: "client", client_secret: "secret", ...changes }) }), { code: "connector_registration_failed" });
234
+ }
235
+ const controller = new AbortController();
236
+ controller.abort();
237
+ await assert.rejects(registerAmplitudeClient(valid, { ...options, signal: controller.signal }), { code: "connector_registration_interrupted" });
238
+ assert.equal(requests, 1);
239
+ });
@@ -0,0 +1,93 @@
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 { apifyProvider } from "../src/server/apify.js";
9
+
10
+ const context = { applicationId: "scraping-app", subjectId: "workspace" };
11
+ test("Apify submits a bounded run, observes terminal states, aborts and retrieves paged storage results", async t => {
12
+ const directory = await mkdtemp(path.join(tmpdir(), "apify-repair-"));
13
+ t.after(() => rm(directory, { recursive: true, force: true }));
14
+ const requests = [];
15
+ let runStatus = "RUNNING";
16
+ let fail = false;
17
+ let status = 200;
18
+ let deny = false;
19
+ const service = createConnectionService({
20
+ configuration: { schemaVersion: 1, registrations: {}, integrations: { scraper: {
21
+ provider: "apify", accountMode: "shared", scopes: [],
22
+ authentication: { method: "api-key", secretRef: "env:APIFY_TOKEN" }
23
+ } } }, providers: [apifyProvider], authorize: async owner => { if (deny) throw new Error("forbidden"); return owner; },
24
+ resolveReference: async () => "fixture-token",
25
+ store: createFileConnectionStore({ directory, protection: createCredentialProtection({
26
+ keys: { current: new Uint8Array(32).fill(6) }, activeKeyId: "current"
27
+ }) }), fetchImpl: async (url, init) => {
28
+ requests.push({ url: new URL(url), init });
29
+ if (fail) throw new Error("unknown submission outcome");
30
+ if (status !== 200) return Response.json({ error: { type: "fixture" } }, { status });
31
+ const pathname = new URL(url).pathname;
32
+ if (pathname === "/v2/actors") return Response.json({ data: { items: [], offset: 0, total: 0 } });
33
+ if (pathname === "/v2/actors/owner~actor") return Response.json({ data: { id: "actor-id", name: "actor" } });
34
+ if (pathname.includes("/datasets/")) return Response.json([{ title: "Fixture result" }]);
35
+ if (pathname.endsWith("/binary")) return new Response(new Uint8Array([0, 255, 1]), { headers: { "content-type": "application/octet-stream" } });
36
+ if (pathname.endsWith("/oversized")) return new Response(new Uint8Array(8 * 1024 * 1024 + 1));
37
+ if (pathname.includes("/key-value-stores/")) return Response.json({ result: "Stored output" });
38
+ return Response.json({ data: { id: "run-fixture", status: runStatus,
39
+ defaultDatasetId: "dataset-fixture", defaultKeyValueStoreId: "store-fixture", statusMessage: runStatus } });
40
+ }
41
+ });
42
+ await service.connectApiKey({ context, integrationId: "scraper" });
43
+ const invoke = (operation, input) => service.invoke({ context, integrationId: "scraper", operation, input });
44
+ await invoke("actors.get", { actorId: "owner~actor" });
45
+ const start = { actorId: "owner~actor", input: { query: "example" }, timeout: 120, maxTotalChargeUsd: 1 };
46
+ const run = await invoke("runs.start", start);
47
+ assert.equal(run.data.status, "RUNNING");
48
+ assert.equal(requests.at(-1).url.pathname, "/v2/actors/owner~actor/runs");
49
+ assert.equal(requests.at(-1).url.searchParams.get("maxTotalChargeUsd"), "1");
50
+ assert.equal(requests.at(-1).url.searchParams.get("restartOnError"), "false");
51
+ assert.equal(requests.at(-1).url.searchParams.get("waitForFinish"), "0");
52
+ assert.deepEqual(JSON.parse(requests.at(-1).init.body), start.input);
53
+ for (const state of ["SUCCEEDED", "FAILED", "TIMED-OUT"]) {
54
+ runStatus = state;
55
+ assert.equal((await invoke("runs.get", { runId: run.data.id })).data.status, state);
56
+ }
57
+ runStatus = "ABORTING";
58
+ assert.equal((await invoke("runs.abort", { runId: run.data.id })).data.status, "ABORTING");
59
+ assert.equal(requests.at(-1).init.method, "POST");
60
+ assert.equal(requests.at(-1).url.searchParams.get("gracefully"), "true");
61
+ runStatus = "ABORTED";
62
+ assert.equal((await invoke("runs.get", { runId: run.data.id })).data.status, "ABORTED");
63
+ const page = await invoke("datasets.items", { datasetId: run.data.defaultDatasetId, limit: 1 });
64
+ assert.equal(page[0].title, "Fixture result");
65
+ await invoke("datasets.items", { datasetId: run.data.defaultDatasetId, limit: 1, offset: 1 });
66
+ assert.equal(requests.at(-1).url.searchParams.get("offset"), "1");
67
+ const stored = await invoke("stores.record", { storeId: run.data.defaultKeyValueStoreId, key: "OUTPUT/report" });
68
+ assert.deepEqual(JSON.parse(Buffer.from(stored.bodyBase64, "base64").toString()), { result: "Stored output" });
69
+ assert.equal(stored.contentType, "application/json");
70
+ assert.ok(requests.at(-1).url.pathname.endsWith("/OUTPUT%2Freport"));
71
+ assert.equal(new Headers(requests.at(-1).init.headers).get("authorization"), "Bearer fixture-token");
72
+ const binary = await invoke("stores.record", { storeId: "store-fixture", key: "binary" });
73
+ assert.deepEqual([...Buffer.from(binary.bodyBase64, "base64")], [0, 255, 1]);
74
+ await assert.rejects(invoke("stores.record", { storeId: "store-fixture", key: "oversized" }), { code: "connector_response_too_large" });
75
+ const before = requests.length;
76
+ for (const invalid of [{ ...start, actorId: "../keys" }, { ...start, timeout: 0 },
77
+ { ...start, maxTotalChargeUsd: undefined }, { ...start, input: [] }]) await assert.rejects(invoke("runs.start", invalid));
78
+ deny = true;
79
+ await assert.rejects(invoke("runs.start", start));
80
+ deny = false;
81
+ await assert.rejects(service.invoke({ context: { ...context, applicationId: "other" }, integrationId: "scraper",
82
+ operation: "runs.start", input: start }));
83
+ assert.equal(requests.length, before);
84
+ for (const errorStatus of [403, 429]) {
85
+ status = errorStatus;
86
+ await assert.rejects(invoke("runs.start", start));
87
+ }
88
+ status = 200;
89
+ fail = true;
90
+ const beforeFailure = requests.length;
91
+ await assert.rejects(invoke("runs.start", start));
92
+ assert.equal(requests.length, beforeFailure + 1);
93
+ });