@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,188 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { n8nProvider, discoverN8nOAuth, registerN8nClient } from "../src/server/n8n.js";
4
+
5
+ function fixture(resource, issuer) {
6
+ const calls = [];
7
+ const base = issuer.replace(/\/$/u, "");
8
+ const metadata = {
9
+ issuer, authorization_endpoint: `${base}/mcp-oauth/authorize`,
10
+ token_endpoint: `${base}/mcp-oauth/token`, registration_endpoint: `${base}/mcp-oauth/register`,
11
+ response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"],
12
+ token_endpoint_auth_methods_supported: ["client_secret_post"], code_challenge_methods_supported: ["S256"],
13
+ scopes_supported: ["workflow:read", "workflow:execute"]
14
+ };
15
+ const protectedResource = { resource, authorization_servers: [issuer], scopes_supported: ["workflow:read"] };
16
+ return { calls, metadata, protectedResource, fetchImpl: async (address, options) => {
17
+ calls.push({ address, options });
18
+ const url = new URL(address);
19
+ assert.equal(options.method, "GET"); assert.equal(options.redirect, "error"); assert.equal(options.credentials, "omit");
20
+ assert.equal(new Headers(options.headers).has("authorization"), false);
21
+ if (url.pathname.startsWith("/.well-known/oauth-protected-resource")) return Response.json(protectedResource);
22
+ return Response.json(metadata);
23
+ } };
24
+ }
25
+
26
+ test("n8n OAuth discovery preserves separate authority and resource hosts and installation paths", async () => {
27
+ for (const [resource, issuer] of [
28
+ ["https://automation.example/mcp-server/http", "https://automation.example"],
29
+ ["https://tools.example:8443/team/mcp-server/http", "https://login.example/n8n"]
30
+ ]) {
31
+ const f = fixture(resource, issuer);
32
+ const result = await discoverN8nOAuth({ serverUrl: resource }, f);
33
+ assert.equal(result.resource, resource); assert.deepEqual(result.oauth, f.metadata);
34
+ assert.deepEqual(result.scopes, ["workflow:read"]);
35
+ assert.equal(f.calls[0].address, `${new URL(resource).origin}/.well-known/oauth-protected-resource${new URL(resource).pathname}`);
36
+ assert.equal(f.calls[1].address, `${new URL(issuer).origin}/.well-known/oauth-authorization-server${new URL(issuer).pathname === "/" ? "" : new URL(issuer).pathname}`);
37
+ assert.equal(f.calls.length, 2);
38
+ }
39
+ });
40
+
41
+ test("n8n OAuth discovery rejects mismatched resources, authorities, endpoints and unsupported consent contracts", async () => {
42
+ for (const mutate of [
43
+ (f) => { f.protectedResource.resource += "/other"; },
44
+ (f) => { f.protectedResource.authorization_servers.push("https://other.example"); },
45
+ (f) => { f.protectedResource.authorization_servers = ["http://insecure.example"]; },
46
+ (f) => { f.metadata.issuer = "https://other.example"; },
47
+ (f) => { f.metadata.token_endpoint = "https://other.example/steal"; },
48
+ (f) => { f.metadata.registration_endpoint += "?secret=private"; },
49
+ (f) => { f.metadata.code_challenge_methods_supported = ["plain"]; },
50
+ (f) => { f.metadata.token_endpoint_auth_methods_supported = ["none"]; },
51
+ (f) => { f.protectedResource.scopes_supported = ["invented:scope"]; },
52
+ (f) => { f.protectedResource.scopes_supported = []; }
53
+ ]) {
54
+ const f = fixture("https://tools.example/mcp-server/http", "https://login.example"); mutate(f);
55
+ await assert.rejects(discoverN8nOAuth({ serverUrl: "https://tools.example/mcp-server/http" }, f), { code: "connector_discovery_failed" });
56
+ assert.ok(f.calls.length <= 2);
57
+ }
58
+ });
59
+
60
+ test("n8n OAuth discovery validates input before transport and sanitizes interruption and server errors", async () => {
61
+ let calls = 0;
62
+ const fetchImpl = async () => { calls++; throw new Error("private-server-detail"); };
63
+ await assert.rejects(discoverN8nOAuth({ serverUrl: "http://bad.example/api" }, { fetchImpl }));
64
+ assert.equal(calls, 0);
65
+ await assert.rejects(discoverN8nOAuth({ serverUrl: "https://tools.example/mcp-server/http" }, { fetchImpl }), (e) => e.code === "connector_discovery_failed" && !e.message.includes("private-server-detail"));
66
+ const controller = new AbortController(); controller.abort();
67
+ const previous = calls;
68
+ await assert.rejects(discoverN8nOAuth({ serverUrl: "https://tools.example/mcp-server/http" }, { fetchImpl, signal: controller.signal }), { code: "connector_discovery_interrupted" });
69
+ assert.equal(calls, previous);
70
+ });
71
+
72
+
73
+ test("n8n registers once with discovered authority, exact callback and selected resource scopes", async () => {
74
+ const f = fixture("https://tools.example/team/mcp-server/http", "https://login.example/n8n");
75
+ let registrations = 0;
76
+ const callback = "https://app.example/integrations/n8n/callback";
77
+ const result = await registerN8nClient({ serverUrl: f.protectedResource.resource, clientName: "App assistant", callbackUrl: callback, scopes: ["workflow:read"] }, {
78
+ fetchImpl: async (url, options) => {
79
+ if (options.method !== "POST") return f.fetchImpl(url, options);
80
+ registrations++;
81
+ assert.equal(String(url), f.metadata.registration_endpoint);
82
+ assert.equal(options.redirect, "error"); assert.equal(options.credentials, "omit");
83
+ const body = JSON.parse(options.body);
84
+ assert.deepEqual(body.redirect_uris, [callback]);
85
+ assert.equal(body.scope, "workflow:read"); assert.equal(body.token_endpoint_auth_method, "client_secret_post");
86
+ return Response.json({ ...body, client_id: "fixture-client", client_secret: "fixture-secret", client_secret_expires_at: 2000000000 });
87
+ }
88
+ });
89
+ assert.equal(registrations, 1); assert.deepEqual(result.requestedScopes, ["workflow:read"]); assert.equal(result.clientId, "fixture-client");
90
+ assert.equal(result.clientSecret, "fixture-secret"); assert.equal(result.clientSecretExpiresAt, 2000000000);
91
+ assert.equal(result.resource, f.protectedResource.resource); assert.deepEqual(result.oauth, f.metadata);
92
+ });
93
+
94
+ test("n8n rejects unsupported registration scopes before POST and never retries an ambiguous registration", async () => {
95
+ const f = fixture("https://tools.example/mcp-server/http", "https://login.example");
96
+ const input = { serverUrl: f.protectedResource.resource, clientName: "App", callbackUrl: "https://app.example/callback", scopes: ["workflow:execute"] };
97
+ let posts = 0;
98
+ const options = { fetchImpl: async (url, request) => {
99
+ if (request.method !== "POST") return f.fetchImpl(url, request);
100
+ posts++; throw new Error("private-instance-error");
101
+ } };
102
+ await assert.rejects(registerN8nClient(input, options), { code: "connector_scope_unavailable" });
103
+ assert.equal(posts, 0);
104
+ await assert.rejects(registerN8nClient({ ...input, scopes: ["workflow:read"] }, options), (e) => e.code === "connector_registration_failed" && !e.message.includes("private-instance-error"));
105
+ assert.equal(posts, 1);
106
+ const previous = f.calls.length;
107
+ await assert.rejects(registerN8nClient({ ...input, callbackUrl: "https://app.example/callback?secret=value" }, options));
108
+ assert.equal(f.calls.length, previous); assert.equal(posts, 1);
109
+ });
110
+
111
+
112
+ test("n8n saved discovery drives project-owned consent, encrypted grants and refresh without rediscovery", async (t) => {
113
+ const { mkdtemp, rm } = await import("node:fs/promises");
114
+ const { tmpdir } = await import("node:os");
115
+ const { join } = await import("node:path");
116
+ const { createConnectionService } = await import("../../connectors-core/src/server/index.js");
117
+ const { createFileConnectionStore, createCredentialProtection } = await import("../../connectors-core/src/server/fileStorage.js");
118
+ const f = fixture("https://tools.example/team/mcp-server/http", "https://login.example/n8n");
119
+ const discovery = await discoverN8nOAuth({ serverUrl: f.protectedResource.resource }, f);
120
+ const directory = await mkdtemp(join(tmpdir(), "n8n-oauth-"));
121
+ t.after(() => rm(directory, { recursive: true, force: true }));
122
+ const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(3) }, activeKeyId: "current" });
123
+ const callback = "https://app.example/integrations/n8n/callback";
124
+ const input = { context: { applicationId: "app", subjectId: "owner" }, integrationId: "automation" };
125
+ const requests = [];
126
+ let now = Date.now();
127
+ const options = {
128
+ configuration: { schemaVersion: 1, registrations: { client: { source: "own", clientId: "fixture-client",
129
+ clientSecretRef: "env:N8N_CLIENT_SECRET", callbackUrlRef: "env:N8N_CALLBACK_URL" } }, integrations: {
130
+ automation: { provider: "n8n", accountMode: "assistant", scopes: ["workflow:read"],
131
+ settings: { serverUrl: discovery.resource, oauthDiscovery: discovery },
132
+ authentication: { method: "oauth2", registrationRef: "client" } }
133
+ } }, providers: [n8nProvider], store: createFileConnectionStore({ directory, protection }), now: () => now,
134
+ authorize: async (owner) => owner,
135
+ resolveReference: async (ref) => ref === "env:N8N_CLIENT_SECRET" ? "fixture-secret" : callback,
136
+ fetchImpl: async (address, init) => {
137
+ requests.push({ address: String(address), init });
138
+ if (String(address) === discovery.oauth.token_endpoint) {
139
+ const body = new URLSearchParams(init.body);
140
+ assert.equal(body.get("resource"), discovery.resource);
141
+ assert.equal(body.get("client_secret"), "fixture-secret");
142
+ const refresh = body.get("grant_type") === "refresh_token";
143
+ if (refresh) assert.equal(body.get("refresh_token"), "initial-refresh");
144
+ else { assert.equal(body.get("redirect_uri"), callback); assert.ok(body.get("code_verifier")); }
145
+ return Response.json({ token_type: "Bearer", access_token: refresh ? "renewed" : "initial", refresh_token: "initial-refresh",
146
+ expires_in: 60, scope: "workflow:read" });
147
+ }
148
+ assert.equal(String(address), discovery.resource);
149
+ assert.equal(new Headers(init.headers).get("authorization"), now > startedAt ? "Bearer renewed" : "Bearer initial");
150
+ if (init.method === "DELETE") return new Response(null, { status: 204 });
151
+ if (init.method === "GET") return new Response(null, { status: 405 });
152
+ const message = JSON.parse(init.body);
153
+ if (message.id === undefined) return new Response(null, { status: 202 });
154
+ assert.ok(["initialize", "tools/list"].includes(message.method));
155
+ return Response.json({ jsonrpc: "2.0", id: message.id, result: message.method === "initialize" ? {
156
+ protocolVersion: message.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "n8n", version: "1" }
157
+ } : { tools: [] } });
158
+ }
159
+ };
160
+ const startedAt = now;
161
+ const service = createConnectionService(options);
162
+ const start = await service.beginAuthorization(input);
163
+ const authorization = new URL(start.authorizationUrl);
164
+ assert.equal(authorization.origin + authorization.pathname, discovery.oauth.authorization_endpoint);
165
+ assert.equal(authorization.searchParams.get("resource"), discovery.resource);
166
+ assert.equal(authorization.searchParams.get("scope"), "workflow:read");
167
+ assert.equal(authorization.searchParams.get("code_challenge_method"), "S256");
168
+ assert.equal(requests.length, 0);
169
+ assert.equal((await service.completeAuthorization({ ...input,
170
+ callbackUrl: `${callback}?code=fixture&state=${authorization.searchParams.get("state")}&iss=${encodeURIComponent(discovery.oauth.issuer)}` })).status, "connected");
171
+ now += 61_000;
172
+ const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
173
+ assert.deepEqual(await restarted.invoke({ ...input, operation: "tools.list", input: {} }), { tools: [] });
174
+ assert.equal(requests.filter((request) => request.address === discovery.oauth.token_endpoint).length, 2);
175
+ const changed = structuredClone(options.configuration);
176
+ changed.integrations.automation.settings.serverUrl = "https://other.example/mcp-server/http";
177
+ const count = requests.length;
178
+ await assert.rejects(createConnectionService({ ...options, configuration: changed }).beginAuthorization(input), { code: "connector_discovery_required" });
179
+ assert.equal(requests.length, count);
180
+ for (const mutation of [
181
+ (d) => { d.oauth.token_endpoint = "https://other.example/token"; },
182
+ (d) => { d.scopes = ["unknown:scope"]; },
183
+ (d) => { d.scopes = ["workflow:read", "workflow:read"]; }
184
+ ]) {
185
+ const invalid = structuredClone(options.configuration); mutation(invalid.integrations.automation.settings.oauthDiscovery);
186
+ assert.throws(() => createConnectionService({ ...options, configuration: invalid }));
187
+ }
188
+ });
@@ -0,0 +1,183 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { notionProvider, registerNotionMcpClient } from "../src/server/notion.js";
7
+ import { createConnectionService } from "../../connectors-core/src/server/index.js";
8
+ import { createFileConnectionStore, createCredentialProtection } from "../../connectors-core/src/server/fileStorage.js";
9
+
10
+ test("Notion exchanges JSON with Basic auth and persists rotated refresh tokens across restart", async (t) => {
11
+ const directory = await mkdtemp(join(tmpdir(), "notion-oauth-"));
12
+ t.after(() => rm(directory, { recursive: true, force: true }));
13
+ const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(9) }, activeKeyId: "current" });
14
+ const callback = "https://app.example/integrations/notion/callback";
15
+ const input = { context: { applicationId: "app", subjectId: "alice" }, integrationId: "notes" };
16
+ let now = Date.now();
17
+ let tokenRequests = 0;
18
+ const options = {
19
+ configuration: { schemaVersion: 1, registrations: { notion: { source: "own", clientId: "client-id",
20
+ tokenEndpointAuthMethod: "client_secret_basic", clientSecretRef: "env:SECRET", callbackUrlRef: "env:CALLBACK" } },
21
+ integrations: { notes: { provider: "notion", accountMode: "per-user", scopes: [],
22
+ authentication: { method: "oauth2", registrationRef: "notion" } } } },
23
+ providers: [notionProvider], store: createFileConnectionStore({ directory, protection }), now: () => now,
24
+ authorize: async (owner) => owner,
25
+ resolveReference: async (ref) => ref === "env:SECRET" ? "private-secret" : callback,
26
+ fetchImpl: async (address, init) => {
27
+ const headers = new Headers(init.headers);
28
+ if (String(address) === "https://api.notion.com/v1/oauth/token") {
29
+ assert.equal(init.method, "POST"); assert.equal(init.redirect, "manual");
30
+ assert.equal(headers.get("content-type"), "application/json");
31
+ assert.equal(headers.get("authorization"), `Basic ${Buffer.from("client-id:private-secret").toString("base64")}`);
32
+ assert.ok(init.signal instanceof AbortSignal);
33
+ const body = JSON.parse(init.body);
34
+ assert.equal(body.client_secret, undefined); assert.equal(body.code_verifier, undefined);
35
+ if (tokenRequests === 0) assert.deepEqual(body, { grant_type: "authorization_code", code: "fixture", redirect_uri: callback });
36
+ else assert.deepEqual(body, { grant_type: "refresh_token", refresh_token: `refresh-${tokenRequests}` });
37
+ tokenRequests++;
38
+ return Response.json({ token_type: "bearer", access_token: `access-${tokenRequests}`, refresh_token: `refresh-${tokenRequests}`, expires_in: 60 });
39
+ }
40
+ assert.equal(String(address), "https://api.notion.com/v1/search");
41
+ assert.equal(headers.get("notion-version"), "2026-03-11");
42
+ assert.equal(headers.get("authorization"), `Bearer access-${tokenRequests}`);
43
+ return Response.json({ object: "list", results: [], has_more: false });
44
+ }
45
+ };
46
+ const service = createConnectionService(options);
47
+ const start = await service.beginAuthorization(input);
48
+ const url = new URL(start.authorizationUrl);
49
+ assert.equal(url.origin + url.pathname, notionProvider.oauth.authorization_endpoint);
50
+ assert.equal(url.searchParams.get("owner"), "user");
51
+ assert.equal(url.searchParams.get("redirect_uri"), callback);
52
+ assert.equal(url.searchParams.has("code_challenge"), false);
53
+ assert.equal(tokenRequests, 0);
54
+ const completion = { ...input, callbackUrl: `${callback}?code=fixture&state=${url.searchParams.get("state")}` };
55
+ assert.equal((await service.completeAuthorization(completion)).status, "connected");
56
+ await assert.rejects(service.completeAuthorization(completion));
57
+ assert.equal(tokenRequests, 1);
58
+ for (let i = 0; i < 2; i++) {
59
+ now += 61_000;
60
+ const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
61
+ assert.deepEqual(await restarted.invoke({ ...input, operation: "content.search", input: {} }), { object: "list", results: [], has_more: false });
62
+ }
63
+ assert.equal(tokenRequests, 3);
64
+ await assert.rejects(service.invoke({ ...input, context: { ...input.context, subjectId: "bob" }, operation: "content.search", input: {} }));
65
+ assert.equal(tokenRequests, 3);
66
+ });
67
+
68
+ test("Notion rejects failed or malformed token exchanges without replay or secret disclosure", async (t) => {
69
+ const directory = await mkdtemp(join(tmpdir(), "notion-oauth-errors-"));
70
+ t.after(() => rm(directory, { recursive: true, force: true }));
71
+ const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(8) }, activeKeyId: "current" });
72
+ const callback = "https://app.example/integrations/notion/callback";
73
+ const input = { context: { applicationId: "app", subjectId: "alice" }, integrationId: "notes" };
74
+ const configuration = { schemaVersion: 1, registrations: { notion: { source: "own", clientId: "client-id",
75
+ tokenEndpointAuthMethod: "client_secret_basic", clientSecretRef: "env:SECRET", callbackUrlRef: "env:CALLBACK" } },
76
+ integrations: { notes: { provider: "notion", accountMode: "per-user", scopes: [], authentication: { method: "oauth2", registrationRef: "notion" } } } };
77
+ for (const response of [
78
+ () => Response.json({ error: "invalid_client", error_description: "private-fixture-secret" }, { status: 401 }),
79
+ () => Response.json({ token_type: "bearer", refresh_token: "private-fixture-secret" }),
80
+ () => new Response("private-fixture-secret", { status: 502 }),
81
+ () => { throw new Error("private-fixture-secret"); }
82
+ ]) {
83
+ let requests = 0;
84
+ const service = createConnectionService({ configuration, providers: [notionProvider],
85
+ store: createFileConnectionStore({ directory, protection }), authorize: async (owner) => owner,
86
+ resolveReference: async (ref) => ref === "env:SECRET" ? "private-fixture-secret" : callback,
87
+ fetchImpl: async () => { requests++; return response(); }
88
+ });
89
+ const start = await service.beginAuthorization(input);
90
+ const state = new URL(start.authorizationUrl).searchParams.get("state");
91
+ const completion = { ...input, callbackUrl: `${callback}?code=fixture&state=${state}` };
92
+ await assert.rejects(service.completeAuthorization(completion), (error) => {
93
+ assert.equal(JSON.stringify(error).includes("private-fixture-secret"), false);
94
+ assert.equal(error.message.includes("private-fixture-secret"), false);
95
+ return true;
96
+ });
97
+ assert.equal(requests, 1);
98
+ await assert.rejects(service.completeAuthorization(completion));
99
+ assert.equal(requests, 1);
100
+ await assert.rejects(service.invoke({ ...input, operation: "content.search", input: {} }));
101
+ assert.equal(requests, 1);
102
+ }
103
+ });
104
+
105
+ test("Notion hosted MCP uses separate PKCE/form credentials and tool discovery", async (t) => {
106
+ const directory = await mkdtemp(join(tmpdir(), "notion-mcp-"));
107
+ t.after(() => rm(directory, { recursive: true, force: true }));
108
+ const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(4) }, activeKeyId: "current" });
109
+ const callback = "https://app.example/integrations/notion/callback";
110
+ const configuration = { schemaVersion: 1, registrations: { client: { source: "own", clientId: "mcp-client",
111
+ clientSecretRef: "env:SECRET", callbackUrlRef: "env:CALLBACK" } }, integrations: {
112
+ notes: { provider: "notion", accountMode: "assistant", settings: { connectionType: "mcp" }, scopes: ["default"],
113
+ authentication: { method: "oauth2", registrationRef: "client" } } } };
114
+ const input = { context: { applicationId: "app", subjectId: "alice" }, integrationId: "notes" };
115
+ let exchanges = 0;
116
+ let now = Date.now();
117
+ const options = { now: () => now, configuration, providers: [notionProvider], authorize: async (owner) => owner,
118
+ store: createFileConnectionStore({ directory, protection }),
119
+ resolveReference: async (ref) => ref === "env:SECRET" ? "mcp-secret" : callback,
120
+ fetchImpl: async (address, init) => {
121
+ if (String(address) === "https://mcp.notion.com/token") {
122
+ const body = new URLSearchParams(init.body);
123
+ if (exchanges === 0) assert.ok(body.get("code_verifier"));
124
+ else { assert.equal(body.get("grant_type"), "refresh_token"); assert.equal(body.get("refresh_token"), "mcp-refresh"); }
125
+ assert.equal(body.get("client_secret"), "mcp-secret");
126
+ assert.equal(new Headers(init.headers).has("authorization"), false);
127
+ assert.match(new Headers(init.headers).get("content-type"), /application\/x-www-form-urlencoded/u);
128
+ exchanges++;
129
+ return Response.json({ token_type: "bearer", access_token: "mcp-access", refresh_token: "mcp-refresh", expires_in: 3600, scope: "default" });
130
+ }
131
+ assert.equal(String(address), "https://mcp.notion.com/mcp");
132
+ assert.equal(new Headers(init.headers).get("authorization"), "Bearer mcp-access");
133
+ if (init.method === "GET") return new Response(null, { status: 405 });
134
+ if (init.method === "DELETE") return new Response(null, { status: 204 });
135
+ const body = JSON.parse(init.body);
136
+ if (body.id === undefined) return new Response(null, { status: 202 });
137
+ assert.ok(["initialize", "tools/list"].includes(body.method));
138
+ return Response.json({ jsonrpc: "2.0", id: body.id, result: body.method === "initialize" ? {
139
+ protocolVersion: body.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" }
140
+ } : { tools: [] } });
141
+ }
142
+ };
143
+ const service = createConnectionService(options);
144
+ const start = await service.beginAuthorization(input);
145
+ const url = new URL(start.authorizationUrl);
146
+ assert.equal(url.origin + url.pathname, "https://mcp.notion.com/authorize");
147
+ assert.equal(url.searchParams.get("code_challenge_method"), "S256");
148
+ assert.equal(url.searchParams.has("owner"), false);
149
+ await service.completeAuthorization({ ...input, callbackUrl: `${callback}?code=fixture&state=${url.searchParams.get("state")}` });
150
+ assert.equal(exchanges, 1);
151
+ now += 3_601_000;
152
+ const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
153
+ assert.deepEqual(await restarted.invoke({ ...input, operation: "tools.list", input: {} }), { tools: [] });
154
+ assert.equal(exchanges, 2);
155
+ await assert.rejects(service.invoke({ ...input, operation: "content.search", input: {} }));
156
+ const invalid = structuredClone(configuration); invalid.integrations.notes.accountMode = "per-user";
157
+ assert.throws(() => createConnectionService({ ...options, configuration: invalid }));
158
+ });
159
+
160
+
161
+ test("Notion MCP client registration validates callback and never retries an ambiguous failure", async () => {
162
+ const input = { clientName: "Notes assistant", callbackUrl: "https://app.example/integrations/notion/callback" };
163
+ let requests = 0;
164
+ const fetchImpl = async (address, options) => {
165
+ requests++;
166
+ assert.equal(String(address), "https://mcp.notion.com/register");
167
+ assert.equal(options.method, "POST"); assert.equal(options.redirect, "error");
168
+ assert.equal(options.credentials, "omit");
169
+ const body = JSON.parse(options.body);
170
+ assert.equal(body.scope, "default");
171
+ assert.equal(body.token_endpoint_auth_method, "client_secret_post");
172
+ assert.deepEqual(body.redirect_uris, [input.callbackUrl]);
173
+ return Response.json({ ...body, client_id: "mcp-client", client_secret: "private-secret" });
174
+ };
175
+ assert.deepEqual(await registerNotionMcpClient(input, { fetchImpl }), { clientId: "mcp-client", clientSecret: "private-secret" });
176
+ assert.equal(requests, 1);
177
+ await assert.rejects(registerNotionMcpClient({ ...input, callbackUrl: "https://app.example/callback?secret=bad" }, { fetchImpl }));
178
+ assert.equal(requests, 1);
179
+ await assert.rejects(registerNotionMcpClient(input, { fetchImpl: async () => {
180
+ requests++; throw new Error("private-secret");
181
+ } }), (error) => error.code === "connector_registration_failed" && !error.message.includes("private-secret"));
182
+ assert.equal(requests, 2);
183
+ });
@@ -0,0 +1,211 @@
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 { ouraProvider } from "../src/server/oura.js";
9
+
10
+ const callback = "https://app.example.test/oura/callback";
11
+ const context = { applicationId: "app-one", subjectId: "user-one" };
12
+ const input = { context, integrationId: "oura" };
13
+ const daily = { data: [{ id: "sleep-1", day: "2026-09-01", score: 82 }], next_token: "next +&2" };
14
+
15
+ async function fixture(t) {
16
+ const directory = await mkdtemp(path.join(tmpdir(), "oura-connector-"));
17
+ t.after(() => rm(directory, { recursive: true, force: true }));
18
+ const requests = [];
19
+ const state = { time: Date.now(), tokenCount: 0, tokenScope: undefined, status: 200, response: daily };
20
+ const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(6) }, activeKeyId: "current" });
21
+ const options = {
22
+ configuration: { schemaVersion: 1, integrations: {
23
+ oura: { provider: "oura", accountMode: "per-user", scopes: ["daily", "personal", "email"], authentication: { method: "oauth2", registrationRef: "oura" } }
24
+ }, registrations: { oura: { source: "own", clientId: "fixture-client", clientSecretRef: "env:OURA_SECRET", callbackUrlRef: "env:OURA_CALLBACK" } } },
25
+ providers: [ouraProvider], authorize: async (owner) => owner, now: () => state.time,
26
+ resolveReference: async (ref) => ref === "env:OURA_CALLBACK" ? callback : "fixture-client-secret",
27
+ store: createFileConnectionStore({ directory, protection }),
28
+ fetchImpl: async (address, init) => {
29
+ const url = new URL(String(address));
30
+ requests.push({ url, init, headers: new Headers(init.headers) });
31
+ assert.equal(url.origin, "https://api.ouraring.com");
32
+ if (url.pathname === "/oauth/token") {
33
+ const body = new URLSearchParams(init.body);
34
+ assert.equal(body.get("client_id"), "fixture-client");
35
+ assert.equal(body.get("client_secret"), "fixture-client-secret");
36
+ if (body.get("grant_type") === "refresh_token") assert.equal(body.get("refresh_token"), `fixture-refresh-${state.tokenCount}`);
37
+ state.tokenCount += 1;
38
+ return Response.json({ access_token: `fixture-access-${state.tokenCount}`, refresh_token: `fixture-refresh-${state.tokenCount}`, token_type: "bearer", expires_in: 60,
39
+ ...(state.tokenScope === undefined ? {} : { scope: state.tokenScope }) });
40
+ }
41
+ return Response.json(state.response, { status: state.status });
42
+ }
43
+ };
44
+ const service = createConnectionService(options);
45
+ async function start(scope = "daily personal") {
46
+ const { authorizationUrl } = await service.beginAuthorization(input);
47
+ const url = new URL(authorizationUrl);
48
+ const redirect = new URL(callback);
49
+ redirect.searchParams.set("code", "fixture-code");
50
+ redirect.searchParams.set("state", url.searchParams.get("state"));
51
+ if (scope !== null) redirect.searchParams.set("scope", scope);
52
+ return { url, callbackUrl: redirect.href };
53
+ }
54
+ return { service, options, protection, directory, requests, state, start };
55
+ }
56
+
57
+ test("Oura uses code consent, records reduced callback scopes and persists a private per-user grant", async (t) => {
58
+ const { service, options, protection, directory, requests, state, start } = await fixture(t);
59
+ const { url, callbackUrl } = await start();
60
+ assert.equal(url.origin + url.pathname, "https://cloud.ouraring.com/oauth/authorize");
61
+ assert.equal(url.searchParams.get("response_type"), "code");
62
+ assert.equal(url.searchParams.get("redirect_uri"), callback);
63
+ assert.equal(url.searchParams.get("scope"), "daily personal email");
64
+ assert.equal(url.searchParams.get("code_challenge_method"), "S256");
65
+ assert.equal(url.searchParams.has("client_secret"), false);
66
+ const connected = await service.completeAuthorization({ ...input, callbackUrl });
67
+ assert.equal(connected.status, "connected");
68
+ assert.deepEqual(connected.grantedScopes, ["daily", "personal"]);
69
+ const code = new URLSearchParams(requests[0].init.body);
70
+ assert.equal(code.get("grant_type"), "authorization_code");
71
+ assert.equal(code.get("redirect_uri"), callback);
72
+ assert.ok(code.get("code_verifier"));
73
+ assert.equal(requests[1].url.pathname, "/v2/usercollection/daily_sleep");
74
+ assert.equal(requests[1].headers.get("authorization"), "Bearer fixture-access-1");
75
+ assert.equal(requests[1].init.redirect, "error");
76
+ for (const name of await readdir(directory)) {
77
+ const text = await readFile(path.join(directory, name), "utf8");
78
+ for (const secret of ["fixture-client-secret", "fixture-access-1", "fixture-refresh-1"]) assert.equal(text.includes(secret), false);
79
+ }
80
+ const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
81
+ assert.deepEqual(await restarted.status(input), { ...connected, callbackUrl: callback });
82
+ const operation = { ...input, operation: "dailySleep.list", input: { start_date: "2026-09-01", end_date: "2026-09-08", next_token: "next +&2" } };
83
+ assert.deepEqual(await restarted.invoke(operation), daily);
84
+ assert.deepEqual(Object.fromEntries(requests.at(-1).url.searchParams), operation.input);
85
+ state.response = { id: "oura-person", age: null };
86
+ assert.deepEqual(await restarted.invoke({ ...input, operation: "personalInfo.read" }), state.response);
87
+ assert.equal(requests.at(-1).url.pathname, "/v2/usercollection/personal_info");
88
+ for (const owner of [{ ...context, applicationId: "other-app" }, { ...context, subjectId: "other-user" }]) {
89
+ await assert.rejects(restarted.invoke({ ...operation, context: owner }), { code: "connector_reconnect_required" });
90
+ }
91
+ await restarted.disconnect(input);
92
+ assert.equal((await service.status(input)).status, "disconnected");
93
+ });
94
+
95
+ test("Oura rejects cancelled, denied, missing or ambiguous consent and never restores declined scopes", async (t) => {
96
+ const { service, requests, start } = await fixture(t);
97
+ const cancelled = await start();
98
+ await service.cancelAuthorization({ ...input, state: cancelled.url.searchParams.get("state") });
99
+ await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: cancelled.callbackUrl }), { code: "connector_attempt_invalid" });
100
+ for (const kind of ["missing", "duplicate", "denied"]) {
101
+ const started = await start(kind === "missing" ? null : "daily");
102
+ const url = new URL(started.callbackUrl);
103
+ if (kind === "duplicate") url.searchParams.append("scope", "personal");
104
+ if (kind === "denied") { url.searchParams.delete("code"); url.searchParams.set("error", "access_denied"); }
105
+ await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: url.href }), {
106
+ code: kind === "denied" ? "connector_consent_denied" : "connector_response_invalid"
107
+ });
108
+ }
109
+ assert.equal(requests.length, 0);
110
+ await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: (await start("email")).callbackUrl }), { code: "connector_scope_missing" });
111
+ assert.equal(requests.length, 1);
112
+ const connected = await service.completeAuthorization({ ...input, callbackUrl: (await start("daily unexpected")).callbackUrl });
113
+ assert.deepEqual(connected.grantedScopes, ["daily"]);
114
+ const count = requests.length;
115
+ await assert.rejects(service.invoke({ ...input, operation: "personalInfo.read" }), { code: "connector_scope_missing" });
116
+ assert.equal(requests.length, count);
117
+ });
118
+
119
+ test("Oura serializes single-use refresh tokens and preserves reduced scopes after restart", async (t) => {
120
+ const { service, options, state, start } = await fixture(t);
121
+ await service.completeAuthorization({ ...input, callbackUrl: (await start("daily")).callbackUrl });
122
+ state.time += 40_000;
123
+ await Promise.all([service.invoke({ ...input, operation: "dailySleep.list" }), service.invoke({ ...input, operation: "dailySleep.list" })]);
124
+ assert.equal(state.tokenCount, 2);
125
+ const restarted = createConnectionService(options);
126
+ state.time += 40_000;
127
+ await restarted.invoke({ ...input, operation: "dailySleep.list" });
128
+ assert.equal(state.tokenCount, 3);
129
+ assert.deepEqual((await restarted.status(input)).grantedScopes, ["daily"]);
130
+ });
131
+
132
+ test("Oura provider errors and malformed successes remain disconnected and redact provider messages", async (t) => {
133
+ const { service, state, start } = await fixture(t);
134
+ for (const [status, code] of [[200, "connector_response_invalid"], [401, "connector_reconnect_required"], [403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
135
+ state.status = status;
136
+ state.response = { message: "fixture-client-secret" };
137
+ await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: (await start()).callbackUrl }), (error) => {
138
+ assert.equal(error.code, code);
139
+ assert.equal(error.message.includes("fixture-client-secret"), false);
140
+ assert.equal(JSON.stringify(error).includes("fixture-client-secret"), false);
141
+ return true;
142
+ });
143
+ assert.equal((await service.status(input)).status, "disconnected");
144
+ }
145
+ state.status = 200;
146
+ state.response = daily;
147
+ await service.completeAuthorization({ ...input, callbackUrl: (await start()).callbackUrl });
148
+ state.status = 401;
149
+ await assert.rejects(service.invoke({ ...input, operation: "dailySleep.list" }), { code: "connector_reconnect_required" });
150
+ assert.equal((await service.status(input)).status, "reconnect-required");
151
+ });
152
+
153
+ test("Oura validates dates and opaque paging inputs before any provider request", async (t) => {
154
+ const { service, requests, state, start } = await fixture(t);
155
+ await service.completeAuthorization({ ...input, callbackUrl: (await start()).callbackUrl });
156
+ const count = requests.length;
157
+ for (const invalid of [
158
+ { start_date: "2026-02-29" }, { end_date: "2026-09-31" }, { start_date: "2026-01-01T00:00:00Z" },
159
+ { next_token: "" }, { next_token: "x".repeat(4097) }, { url: "https://attacker.invalid" }, { limit: 100 }
160
+ ]) await assert.rejects(service.invoke({ ...input, operation: "dailySleep.list", input: invalid }), { code: "connector_input_invalid" });
161
+ assert.equal(requests.length, count);
162
+ state.response = { data: [], next_token: null };
163
+ assert.deepEqual(await service.invoke({ ...input, operation: "dailySleep.list", input: { start_date: "2024-02-29" } }), state.response);
164
+ });
165
+
166
+ test("Oura prefers token-response permissions when present over the authorization callback fallback", async (t) => {
167
+ const { service, state, start } = await fixture(t);
168
+ state.tokenScope = "daily";
169
+ const connected = await service.completeAuthorization({ ...input, callbackUrl: (await start("daily personal email")).callbackUrl });
170
+ assert.deepEqual(connected.grantedScopes, ["daily"]);
171
+ await assert.rejects(service.invoke({ ...input, operation: "personalInfo.read" }), { code: "connector_scope_missing" });
172
+ });
173
+
174
+
175
+ test("Oura rejects an empty permission selection before starting broad consent", async (t) => {
176
+ const { options, requests } = await fixture(t);
177
+ const configuration = structuredClone(options.configuration);
178
+ configuration.integrations.oura.scopes = [];
179
+ assert.throws(() => createConnectionService({ ...options, configuration }));
180
+ assert.equal(requests.length, 0);
181
+ });
182
+
183
+ test("Oura reads sleep readiness activity and separately consented heart rate with explicit paging", async t => {
184
+ const { options, state, requests } = await fixture(t);
185
+ options.configuration.integrations.oura.scopes.push("heartrate");
186
+ const expanded = createConnectionService(options);
187
+ const { authorizationUrl } = await expanded.beginAuthorization(input);
188
+ const cb = new URL(callback); cb.searchParams.set("state", new URL(authorizationUrl).searchParams.get("state")); cb.searchParams.set("code", "fixture-code"); cb.searchParams.set("scope", "daily heartrate");
189
+ await expanded.completeAuthorization({ ...input, callbackUrl: cb.href });
190
+ for (const [op, endpoint] of [["sleep.list", "sleep"], ["dailyReadiness.list", "daily_readiness"], ["dailyActivity.list", "daily_activity"]]) {
191
+ state.response = { data: [{ id: endpoint, day: "2026-09-01", score: 85 }], next_token: "next+&2" };
192
+ assert.deepEqual(await expanded.invoke({ ...input, operation: op, input: { start_date: "2026-09-01", end_date: "2026-09-02", next_token: "prior+&1" } }), state.response);
193
+ assert.equal(requests.at(-1).url.pathname, `/v2/usercollection/${endpoint}`);
194
+ assert.equal(requests.at(-1).url.searchParams.get("next_token"), "prior+&1");
195
+ }
196
+ state.response = { data: [{ bpm: 65, source: "sleep", timestamp: "2026-09-01T00:01:00+08:00" }], next_token: null };
197
+ const values = { start_datetime: "2026-09-01T00:00:00+08:00", end_datetime: "2026-09-02T00:00:00+08:00" };
198
+ assert.deepEqual(await expanded.invoke({ ...input, operation: "heartRate.list", input: values }), state.response);
199
+ assert.equal(requests.at(-1).url.searchParams.get("start_datetime"), values.start_datetime);
200
+ const count = requests.length;
201
+ for (const invalid of [{ start_datetime: "2026-02-30T00:00:00Z" }, { start_datetime: "2026-09-01T00:00:00" }, { ...values, end_datetime: "2026-08-01T00:00:00Z" }])
202
+ await assert.rejects(expanded.invoke({ ...input, operation: "heartRate.list", input: invalid }), { code: "connector_input_invalid" });
203
+ assert.equal(requests.length, count);
204
+ await expanded.disconnect(input);
205
+ const next = await expanded.beginAuthorization(input); cb.searchParams.set("state", new URL(next.authorizationUrl).searchParams.get("state")); cb.searchParams.set("scope", "daily");
206
+ state.response = daily;
207
+ await expanded.completeAuthorization({ ...input, callbackUrl: cb.href });
208
+ const before = requests.length;
209
+ await assert.rejects(expanded.invoke({ ...input, operation: "heartRate.list", input: values }), { code: "connector_scope_missing" });
210
+ assert.equal(requests.length, before);
211
+ });
@@ -0,0 +1,51 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createProductsWithPrices } from "../patterns/paddle-catalogue/example/create-products.js";
4
+
5
+ const item = () => ({ product: { name: "Example", tax_category: "saas" }, price: {
6
+ description: "One-time purchase", unit_price: { amount: "1200", currency_code: "USD" }
7
+ } });
8
+
9
+ test("Paddle batch validates all items before writing and preserves a failed price's product", async () => {
10
+ const calls = [];
11
+ const runtime = { async invoke(request) {
12
+ calls.push(request);
13
+ if (request.operation === "prices.create") throw new Error("private-provider-error");
14
+ return { data: { id: `pro_${"a".repeat(26)}` } };
15
+ } };
16
+ const options = { runtime, context: { subjectId: "admin", applicationId: "app" }, integrationId: "merchant" };
17
+ await assert.rejects(createProductsWithPrices({ ...options, items: [item(), { ...item(), price: { description: "invalid" } }] }));
18
+ assert.equal(calls.length, 0);
19
+ const result = await createProductsWithPrices({ ...options, items: [item(), item()] });
20
+ assert.equal(calls.length, 2);
21
+ assert.deepEqual(result, { completed: [], failed: { index: 0, stage: "price", productId: `pro_${"a".repeat(26)}`, outcome: "requires-review" } });
22
+ assert.equal(JSON.stringify(result).includes("private-provider-error"), false);
23
+ });
24
+
25
+ test("Paddle batch passes returned product IDs into prices and returns completed pairs", async () => {
26
+ const calls = [];
27
+ const runtime = { async invoke(request) {
28
+ calls.push(request);
29
+ return { data: { id: request.operation === "products.create" ? `pro_${"b".repeat(26)}` : `pri_${"c".repeat(26)}` } };
30
+ } };
31
+ const result = await createProductsWithPrices({ runtime, context: { subjectId: "admin" }, integrationId: "merchant", items: [item()] });
32
+ assert.equal(calls[1].input.product_id, `pro_${"b".repeat(26)}`);
33
+ assert.equal(calls[1].integrationId, "merchant");
34
+ assert.deepEqual(result, { completed: [{ index: 0, productId: `pro_${"b".repeat(26)}`, priceId: `pri_${"c".repeat(26)}` }], failed: null });
35
+ });
36
+
37
+
38
+ test("Paddle batch keeps earlier success when a later product fails and starts no further items", async () => {
39
+ let calls = 0;
40
+ const runtime = { async invoke() {
41
+ calls++;
42
+ if (calls === 3) throw new Error("network outcome unknown");
43
+ return { data: { id: calls === 1 ? `pro_${"d".repeat(26)}` : `pri_${"e".repeat(26)}` } };
44
+ } };
45
+ const result = await createProductsWithPrices({ runtime, context: {}, integrationId: "merchant", items: [item(), item(), item()] });
46
+ assert.equal(calls, 3);
47
+ assert.deepEqual(result, {
48
+ completed: [{ index: 0, productId: `pro_${"d".repeat(26)}`, priceId: `pri_${"e".repeat(26)}` }],
49
+ failed: { index: 1, stage: "product", outcome: "requires-review" }
50
+ });
51
+ });