@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,469 @@
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 { wordpressComProvider } from "../src/server/wordpress-com.js";
9
+
10
+ const callback = "https://app.example.test/connections/wordpress/callback";
11
+ const context = { applicationId: "app-one", subjectId: "person-one" };
12
+ const input = { context, integrationId: "publishing" };
13
+ const profile = { ID: 17, username: "editor", display_name: "Editor", token_client_id: 12345, token_site_id: 81, token_scope: ["users", "sites", "posts"] };
14
+
15
+ async function fixture(t, scopes = profile.token_scope) {
16
+ const directory = await mkdtemp(path.join(tmpdir(), "wordpress-com-connector-"));
17
+ t.after(() => rm(directory, { recursive: true, force: true }));
18
+ const requests = [];
19
+ const state = { time: Date.now(), token: {}, profile: { ...structuredClone(profile), token_scope: [...scopes] }, status: 200, response: null };
20
+ const protection = createCredentialProtection({ keys: { current: new Uint8Array(32).fill(7) }, activeKeyId: "current" });
21
+ const options = {
22
+ configuration: { schemaVersion: 1, registrations: {
23
+ wordpress: { source: "own", clientId: "12345", clientSecretRef: "env:WP_CLIENT_SECRET", callbackUrlRef: "env:WP_CALLBACK" }
24
+ }, integrations: {
25
+ publishing: { provider: "wordpress-com", accountMode: "per-user", scopes: [...scopes], authentication: { method: "oauth2", registrationRef: "wordpress" } }
26
+ } },
27
+ providers: [wordpressComProvider], authorize: async (owner) => owner,
28
+ resolveReference: async (ref) => ref === "env:WP_CALLBACK" ? callback : "fixture-client-secret",
29
+ store: createFileConnectionStore({ directory, protection }), now: () => state.time,
30
+ async fetchImpl(address, init) {
31
+ const url = new URL(String(address));
32
+ requests.push({ url, init, headers: new Headers(init.headers) });
33
+ assert.equal(url.origin, "https://public-api.wordpress.com");
34
+ if (url.pathname === "/oauth2/token") {
35
+ const body = new URLSearchParams(init.body);
36
+ assert.equal(body.get("client_id"), "12345");
37
+ assert.equal(body.get("client_secret"), "fixture-client-secret");
38
+ assert.equal(body.get("grant_type"), "authorization_code");
39
+ return Response.json({ access_token: "fixture-access-token", token_type: "bearer", blog_id: "81", blog_url: "https://untrusted-blog.example/", ...state.token });
40
+ }
41
+ return Response.json(state.response ?? state.profile, { status: state.status });
42
+ }
43
+ };
44
+ const service = createConnectionService(options);
45
+ async function start(using = service) {
46
+ const { authorizationUrl } = await using.beginAuthorization(input);
47
+ const url = new URL(authorizationUrl);
48
+ const result = new URL(callback);
49
+ result.searchParams.set("code", "fixture-code");
50
+ result.searchParams.set("state", url.searchParams.get("state"));
51
+ return { url, callbackUrl: result.href };
52
+ }
53
+ async function connect(using = service) {
54
+ return using.completeAuthorization({ ...input, callbackUrl: (await start(using)).callbackUrl });
55
+ }
56
+ return { service, options, directory, protection, state, requests, start, connect };
57
+ }
58
+
59
+ test("WordPress.com verifies the issued client and scope grant, persists privately and isolates owners", async (t) => {
60
+ const { service, options, directory, protection, state, requests, start } = await fixture(t);
61
+ const { url, callbackUrl } = await start();
62
+ assert.equal(url.origin + url.pathname, "https://public-api.wordpress.com/oauth2/authorize");
63
+ assert.equal(url.searchParams.get("scope"), "users,sites,posts");
64
+ assert.equal(url.searchParams.get("response_type"), "code");
65
+ assert.equal(url.searchParams.get("redirect_uri"), callback);
66
+ assert.equal(url.searchParams.get("code_challenge_method"), "S256");
67
+ assert.equal(url.searchParams.has("client_secret"), false);
68
+ const connected = await service.completeAuthorization({ ...input, callbackUrl });
69
+ assert.equal(connected.status, "connected");
70
+ assert.deepEqual(connected.grantedScopes, ["users", "sites", "posts"]);
71
+ const tokenBody = new URLSearchParams(requests[0].init.body);
72
+ assert.equal(tokenBody.get("redirect_uri"), callback);
73
+ assert.ok(tokenBody.get("code_verifier"));
74
+ assert.equal(requests[1].url.pathname, "/rest/v1.1/me");
75
+ assert.equal(requests[1].url.searchParams.get("fields"), "ID,username,display_name,token_scope,token_client_id,token_site_id");
76
+ assert.equal(requests[1].headers.get("authorization"), "Bearer fixture-access-token");
77
+ assert.equal(requests[1].init.redirect, "error");
78
+ for (const name of await readdir(directory)) {
79
+ const file = await readFile(path.join(directory, name), "utf8");
80
+ for (const secret of ["fixture-client-secret", "fixture-access-token", "fixture-code"]) assert.equal(file.includes(secret), false);
81
+ }
82
+ const restarted = createConnectionService({ ...options, store: createFileConnectionStore({ directory, protection }) });
83
+ assert.deepEqual(await restarted.status(input), { ...connected, callbackUrl: callback });
84
+ // No expiry or refresh token is documented in this code-exchange response.
85
+ state.time += 1000 * 60 * 60 * 24 * 90;
86
+ assert.deepEqual(await restarted.invoke({ ...input, operation: "profile.read" }), profile);
87
+ assert.equal(requests.filter(({ url }) => url.pathname === "/oauth2/token").length, 1);
88
+ const count = requests.length;
89
+ for (const owner of [{ ...context, applicationId: "another-app" }, { ...context, subjectId: "another-person" }]) {
90
+ await assert.rejects(restarted.invoke({ ...input, context: owner, operation: "profile.read" }), { code: "connector_reconnect_required" });
91
+ }
92
+ assert.equal(requests.length, count);
93
+ await restarted.disconnect(input);
94
+ assert.equal((await service.status(input)).status, "disconnected");
95
+ });
96
+
97
+ test("WordPress.com records reduced permissions and cannot restore scopes omitted by the token response", async (t) => {
98
+ const { service, state, requests, connect } = await fixture(t);
99
+ state.profile.token_scope = ["users", "posts", "unrequested"];
100
+ assert.deepEqual((await connect()).grantedScopes, ["users", "posts"]);
101
+ const count = requests.length;
102
+ await assert.rejects(service.invoke({ ...input, operation: "sites.list" }), { code: "connector_scope_missing" });
103
+ assert.equal(requests.length, count);
104
+ state.token.scope = "users, posts";
105
+ state.profile.token_scope = ["users", "sites", "posts"];
106
+ assert.deepEqual((await connect()).grantedScopes, ["users", "posts"]);
107
+ state.token.scope = "users";
108
+ assert.deepEqual((await connect()).grantedScopes, ["users"]);
109
+ });
110
+
111
+ test("WordPress.com rejects wrong-client or malformed verification and preserves an existing grant", async (t) => {
112
+ const { service, state, connect, start } = await fixture(t);
113
+ await connect();
114
+ const connected = await service.status(input);
115
+ for (const invalid of [
116
+ { ...profile, token_client_id: 999 }, { ...profile, token_scope: "users,posts" },
117
+ { ...profile, token_scope: [17] }, { ...profile, token_scope: [] }, { ...profile, token_site_id: "https://another.example" },
118
+ { error: "invalid_token", message: "fixture-access-token" }
119
+ ]) {
120
+ state.profile = invalid;
121
+ const attempt = await start();
122
+ await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: attempt.callbackUrl }), {
123
+ code: Array.isArray(invalid.token_scope) && invalid.token_scope.length === 0 ? "connector_scope_missing" : "connector_response_invalid"
124
+ });
125
+ assert.deepEqual(await service.status(input), connected);
126
+ await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: attempt.callbackUrl }), { code: "connector_attempt_invalid" });
127
+ }
128
+ });
129
+
130
+ test("WordPress.com consent cancellation, denial and registration changes consume no credentials", async (t) => {
131
+ const { service, options, requests, start } = await fixture(t);
132
+ const cancelled = await start();
133
+ await service.cancelAuthorization({ ...input, state: cancelled.url.searchParams.get("state") });
134
+ await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: cancelled.callbackUrl }), { code: "connector_attempt_invalid" });
135
+ const denied = await start();
136
+ const callbackUrl = new URL(denied.callbackUrl);
137
+ callbackUrl.searchParams.delete("code");
138
+ callbackUrl.searchParams.set("error", "access_denied");
139
+ await assert.rejects(service.completeAuthorization({ ...input, callbackUrl: callbackUrl.href }), { code: "connector_consent_denied" });
140
+ const stale = await start();
141
+ const config = structuredClone(options.configuration);
142
+ config.registrations.wordpress.clientId = "98765";
143
+ const changed = createConnectionService({ ...options, configuration: config });
144
+ await assert.rejects(changed.completeAuthorization({ ...input, callbackUrl: stale.callbackUrl }), { code: "connector_attempt_invalid" });
145
+ assert.equal(requests.length, 0);
146
+ });
147
+
148
+ test("WordPress.com lists accessible sites and bounded post pages without following resource links", async (t) => {
149
+ const { service, state, requests, connect } = await fixture(t);
150
+ await connect();
151
+ state.response = { sites: [{ ID: 81, URL: "https://untrusted-site.example/path", name: "Company" }] };
152
+ assert.deepEqual(await service.invoke({ ...input, operation: "sites.list", input: { site_visibility: "visible", site_activity: "active" } }), state.response);
153
+ assert.deepEqual(Object.fromEntries(requests.at(-1).url.searchParams), { site_visibility: "visible", site_activity: "active" });
154
+ state.response = { found: 1, posts: [{ ID: 19, title: "<b>Draft</b>", content: "<p>Content</p>" }], meta: { next_page: "https://another.example/?cursor=+&2" } };
155
+ assert.deepEqual(await service.invoke({ ...input, operation: "posts.list", input: {
156
+ siteId: 81, number: 25, page_handle: "https://another.example/?cursor=+&2", search: "red & blue", status: "draft", context: "edit"
157
+ } }), state.response);
158
+ assert.equal(requests.at(-1).url.pathname, "/rest/v1.1/sites/81/posts/");
159
+ assert.deepEqual(Object.fromEntries(requests.at(-1).url.searchParams), {
160
+ number: "25", page_handle: "https://another.example/?cursor=+&2", search: "red & blue", status: "draft", context: "edit"
161
+ });
162
+ assert.equal(requests.length, 4);
163
+ for (const [operation, response, operationInput] of [["sites.list", { sites: [] }, {}], ["posts.list", { found: 0, posts: [] }, { siteId: 81 }]]) {
164
+ state.response = response;
165
+ assert.deepEqual(await service.invoke({ ...input, operation, input: operationInput }), response);
166
+ }
167
+ const count = requests.length;
168
+ for (const bad of [
169
+ {}, { siteId: 1.5 }, { siteId: "../../another" }, { siteId: 81, url: "https://another.example" },
170
+ { siteId: 81, number: 101 }, { siteId: 81, number: 1.5 }, { siteId: 81, page_handle: "" },
171
+ { siteId: 81, search: "x".repeat(501) }, { siteId: 81, status: "not-a-status" }, { siteId: 81, context: "raw" }
172
+ ]) await assert.rejects(service.invoke({ ...input, operation: "posts.list", input: bad }), { code: "connector_input_invalid" });
173
+ assert.equal(requests.length, count);
174
+ for (const response of [{ found: 1, posts: [{}] }, { found: 1, posts: [], meta: { next_page: {} } }, { error: "denied" }]) {
175
+ state.response = response;
176
+ await assert.rejects(service.invoke({ ...input, operation: "posts.list", input: { siteId: 81 } }), { code: "connector_response_invalid" });
177
+ }
178
+ });
179
+
180
+ test("WordPress.com redacts provider failures and requires consent again after rejection or explicit expiry", async (t) => {
181
+ const { service, state, requests, connect } = await fixture(t);
182
+ for (const [status, code] of [[401, "connector_reconnect_required"], [403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
183
+ state.status = 200; state.response = null;
184
+ await connect();
185
+ state.status = status; state.response = { error: "failure", message: "fixture-access-token fixture-client-secret" };
186
+ await assert.rejects(service.invoke({ ...input, operation: "profile.read" }), (error) => {
187
+ assert.equal(error.code, code);
188
+ assert.equal(String(error).includes("fixture-"), false);
189
+ return true;
190
+ });
191
+ assert.equal((await service.status(input)).status, status === 401 ? "reconnect-required" : "connected");
192
+ }
193
+ state.status = 200; state.response = null; state.token.expires_in = 60;
194
+ await connect();
195
+ state.time += 61_000;
196
+ const count = requests.length;
197
+ await assert.rejects(service.invoke({ ...input, operation: "profile.read" }), { code: "connector_reconnect_required" });
198
+ assert.equal(requests.length, count);
199
+ assert.equal((await service.status(input)).status, "reconnect-required");
200
+ });
201
+
202
+ test("WordPress.com publishes explicit drafts and sparse page edits, and never retries deletion", async (t) => {
203
+ const { service, state, requests, connect } = await fixture(t);
204
+ await connect();
205
+ const post = { ID: 19, site_ID: 81, title: "Draft & title", status: "draft", content: " <p>A + B & C</p>\n" };
206
+ state.response = post;
207
+ const invoke = (operation, values) => service.invoke({ ...input, operation, input: { siteId: 81, ...values } });
208
+ assert.deepEqual(await invoke("posts.create", { title: post.title, content: post.content, type: "page", parent: 0 }), post);
209
+ let request = requests.at(-1);
210
+ assert.equal(request.url.pathname, "/rest/v1.1/sites/81/posts/new");
211
+ assert.equal(request.url.searchParams.get("context"), "edit");
212
+ assert.equal(request.init.method, "POST");
213
+ assert.equal(request.headers.get("content-type"), "application/x-www-form-urlencoded");
214
+ assert.equal(request.headers.get("authorization"), "Bearer fixture-access-token");
215
+ assert.deepEqual(Object.fromEntries(new URLSearchParams(request.init.body)), {
216
+ title: post.title, content: post.content, type: "page", parent: "0", status: "draft", publicize: "false"
217
+ });
218
+ await invoke("posts.update", { postId: 19, status: "publish", featured_image: "", excerpt: "" });
219
+ request = requests.at(-1);
220
+ assert.equal(request.url.pathname, "/rest/v1.1/sites/81/posts/19");
221
+ assert.deepEqual(Object.fromEntries(new URLSearchParams(request.init.body)), { status: "publish", featured_image: "", excerpt: "" });
222
+ await invoke("posts.get", { postId: 19, context: "display" });
223
+ assert.equal(requests.at(-1).init.method, "GET");
224
+ assert.equal(requests.at(-1).url.searchParams.get("context"), "display");
225
+ const count = requests.length;
226
+ for (const [operation, values] of [
227
+ ["posts.create", {}], ["posts.update", { postId: 19 }], ["posts.update", { postId: 19, context: "edit" }],
228
+ ["posts.create", { title: "A", status: "future" }], ["posts.create", { title: "A", type: "unregistered" }],
229
+ ["posts.update", { postId: 19, featured_image: "https://untrusted.example/image" }],
230
+ ["posts.get", { postId: "../19" }], ["posts.get", { postId: 19, siteId: "another.example" }],
231
+ ["posts.delete", { postId: 19 }], ["posts.delete", { postId: 19, confirmDeletion: false }]
232
+ ]) await assert.rejects(invoke(operation, values), { code: "connector_input_invalid" });
233
+ assert.equal(requests.length, count);
234
+ for (const status of ["trash", "deleted"]) {
235
+ state.response = { ...post, status };
236
+ assert.equal((await invoke("posts.delete", { postId: 19, confirmDeletion: true })).status, status);
237
+ assert.equal(requests.at(-1).url.pathname, "/rest/v1.1/sites/81/posts/19/delete");
238
+ assert.equal(requests.at(-1).init.body, "");
239
+ }
240
+ state.response = post;
241
+ await invoke("posts.restore", { postId: 19 });
242
+ assert.equal(requests.at(-1).url.pathname, "/rest/v1.1/sites/81/posts/19/restore");
243
+ for (const [status, code] of [[403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
244
+ state.status = status; state.response = { error: "failure", message: "fixture-access-token" };
245
+ const before = requests.length;
246
+ await assert.rejects(invoke("posts.delete", { postId: 19, confirmDeletion: true }), { code });
247
+ assert.equal(requests.length, before + 1);
248
+ }
249
+ state.status = 200; state.response = { ...post, site_ID: null };
250
+ await assert.rejects(invoke("posts.update", { postId: 19, title: "Changed" }), { code: "connector_response_invalid" });
251
+ state.response = null; state.profile.token_scope = ["users"];
252
+ await connect();
253
+ const before = requests.length;
254
+ await assert.rejects(invoke("posts.create", { title: "Denied" }), { code: "connector_scope_missing" });
255
+ assert.equal(requests.length, before);
256
+ });
257
+
258
+ test("WordPress.com media uses indexed multipart bytes and preserves partial results without retries", async (t) => {
259
+ const { service, state, requests, connect } = await fixture(t, ["users", "posts", "media"]);
260
+ await connect();
261
+ const media = { ID: 88, URL: "https://untrusted.example/image.png", title: "Uploaded", post_ID: 0 };
262
+ const invoke = (operation, values) => service.invoke({ ...input, operation, input: { siteId: 81, ...values } });
263
+ const bytes = Buffer.from([0, 1, 255, 10, 13]);
264
+ const upload = { filename: "image.png", mimeType: "image/png", contentBase64: bytes.toString("base64"), caption: "A & B", parent_id: 0 };
265
+ state.response = { media: [media], media_errors: [] };
266
+ assert.deepEqual(await invoke("media.upload", upload), state.response);
267
+ let request = requests.at(-1);
268
+ assert.equal(request.url.pathname, "/rest/v1.1/sites/81/media/new");
269
+ assert.equal(request.headers.get("authorization"), "Bearer fixture-access-token");
270
+ assert.equal(request.headers.has("content-type"), false);
271
+ const encoded = new Request(request.url, request.init);
272
+ assert.match(encoded.headers.get("content-type"), /^multipart\/form-data; boundary=/u);
273
+ const form = await encoded.formData();
274
+ const file = form.get("media[0]");
275
+ assert.equal(file.name, "image.png"); assert.equal(file.type, "image/png");
276
+ assert.deepEqual(Buffer.from(await file.arrayBuffer()), bytes);
277
+ assert.equal(form.get("attrs[0][caption]"), "A & B");
278
+ assert.equal(form.get("attrs[0][parent_id]"), "0");
279
+ assert.equal(form.has("media_urls"), false);
280
+ state.response = { media: [media], media_errors: { 1: { error: "upload_error", message: "An attachment failed" } } };
281
+ const beforePartial = requests.length;
282
+ assert.deepEqual(await invoke("media.upload", upload), state.response);
283
+ assert.equal(requests.length, beforePartial + 1);
284
+ state.response = { media: [], media_errors: [{ error: "invalid_file" }] };
285
+ assert.deepEqual(await invoke("media.upload", upload), state.response);
286
+ state.response = { found: 1, media: [media], meta: { next_page: "https://cursor.example/?a=1&b=2" } };
287
+ assert.deepEqual(await invoke("media.list", { number: 30, page_handle: state.response.meta.next_page, post_ID: 0, mime_type: "image" }), state.response);
288
+ request = requests.at(-1);
289
+ assert.equal(request.url.pathname, "/rest/v1.1/sites/81/media/");
290
+ assert.equal(request.url.searchParams.get("page_handle"), state.response.meta.next_page);
291
+ assert.equal(request.url.searchParams.get("post_ID"), "0");
292
+ state.response = media;
293
+ await invoke("media.get", { mediaId: 88 });
294
+ assert.equal(requests.at(-1).url.pathname, "/rest/v1.1/sites/81/media/88");
295
+ await invoke("media.update", { mediaId: 88, alt: "", caption: "", parent_id: 19 });
296
+ assert.deepEqual(Object.fromEntries(new URLSearchParams(requests.at(-1).init.body)), { alt: "", caption: "", parent_id: "19" });
297
+ state.response = { ...media, status: "deleted" };
298
+ await invoke("media.delete", { mediaId: 88, confirmDeletion: true });
299
+ assert.equal(requests.at(-1).url.pathname, "/rest/v1.1/sites/81/media/88/delete");
300
+ const count = requests.length;
301
+ for (const [operation, values] of [
302
+ ["media.upload", { ...upload, filename: "../image.png" }], ["media.upload", { ...upload, contentBase64: "!!!!" }],
303
+ ["media.upload", { ...upload, contentBase64: Buffer.alloc(5 * 1024 * 1024 + 1).toString("base64") }],
304
+ ["media.upload", { ...upload, mimeType: "image/png\r\nX: y" }],
305
+ ["media.update", { mediaId: 88 }], ["media.delete", { mediaId: 88 }],
306
+ ["media.delete", { mediaId: 88, confirmDeletion: false }], ["media.list", { number: 101 }]
307
+ ]) await assert.rejects(invoke(operation, values), { code: "connector_input_invalid" });
308
+ assert.equal(requests.length, count);
309
+ for (const [status, code] of [[403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
310
+ state.status = status; state.response = { error: "failure" };
311
+ const before = requests.length;
312
+ await assert.rejects(invoke("media.upload", upload), { code });
313
+ assert.equal(requests.length, before + 1);
314
+ }
315
+ state.status = 200; state.response = { media: [{}] };
316
+ await assert.rejects(invoke("media.upload", upload), { code: "connector_response_invalid" });
317
+ state.response = null; state.profile.token_scope = ["users"];
318
+ await connect();
319
+ const before = requests.length;
320
+ await assert.rejects(invoke("media.upload", upload), { code: "connector_scope_missing" });
321
+ assert.equal(requests.length, before);
322
+ });
323
+
324
+ test("WordPress.com comments require explicit moderation and use the comments scope for post replies", async (t) => {
325
+ const { service, state, requests, connect } = await fixture(t, ["users", "comments"]);
326
+ await connect();
327
+ const comment = { ID: 9, content: " <p>A & B</p>\n", status: "unapproved" };
328
+ const invoke = (operation, values) => service.invoke({ ...input, operation, input: { siteId: 81, ...values } });
329
+ state.response = { found: 1, site_ID: 81, comments: [comment] };
330
+ assert.deepEqual(await invoke("comments.list", { status: "unapproved", page: 2, number: 10 }), state.response);
331
+ assert.equal(requests.at(-1).url.pathname, "/rest/v1.1/sites/81/comments/");
332
+ assert.equal(requests.at(-1).url.searchParams.get("page"), "2");
333
+ state.response = { found: 0, comments: [] };
334
+ assert.deepEqual(await invoke("comments.list", {}), state.response);
335
+ state.response = comment;
336
+ for (const [operation, values, suffix] of [
337
+ ["comments.create", { postId: 19, content: comment.content }, "posts/19/replies/new"],
338
+ ["comments.reply", { commentId: 9, content: comment.content }, "comments/9/replies/new"]
339
+ ]) {
340
+ assert.deepEqual(await invoke(operation, values), comment);
341
+ assert.equal(requests.at(-1).url.pathname, `/rest/v1.1/sites/81/${suffix}`);
342
+ assert.deepEqual(Object.fromEntries(new URLSearchParams(requests.at(-1).init.body)), { content: comment.content });
343
+ }
344
+ await invoke("comments.get", { commentId: 9 });
345
+ assert.equal(requests.at(-1).init.method, "GET");
346
+ await invoke("comments.update", { commentId: 9, content: "Edited", status: "unapproved" });
347
+ assert.deepEqual(Object.fromEntries(new URLSearchParams(requests.at(-1).init.body)), { content: "Edited", status: "unapproved" });
348
+ for (const status of ["approved", "unapproved", "spam", "unspam", "trash", "untrash"]) {
349
+ await invoke("comments.update", { commentId: 9, status });
350
+ assert.equal(new URLSearchParams(requests.at(-1).init.body).get("status"), status);
351
+ }
352
+ const count = requests.length;
353
+ for (const [operation, values] of [
354
+ ["comments.update", { commentId: 9, content: "Would implicitly approve" }],
355
+ ["comments.update", { commentId: 9, status: "invalid" }],
356
+ ["comments.delete", { commentId: 9 }], ["comments.delete", { commentId: 9, confirmDeletion: false }],
357
+ ["comments.reply", { commentId: 9, content: "" }], ["comments.list", { page: 0 }]
358
+ ]) await assert.rejects(invoke(operation, values), { code: "connector_input_invalid" });
359
+ assert.equal(requests.length, count);
360
+ state.response = { ...comment, status: "deleted" };
361
+ await invoke("comments.delete", { commentId: 9, confirmDeletion: true });
362
+ assert.equal(requests.at(-1).url.pathname, "/rest/v1.1/sites/81/comments/9/delete");
363
+ for (const [status, code] of [[403, "connector_permission_denied"], [429, "connector_rate_limited"], [500, "connector_provider_failed"]]) {
364
+ state.status = status; state.response = { error: "denied" };
365
+ const before = requests.length;
366
+ await assert.rejects(invoke("comments.reply", { commentId: 9, content: "Reply" }), { code });
367
+ assert.equal(requests.length, before + 1);
368
+ }
369
+ state.status = 200; state.response = null; state.profile.token_scope = ["users"];
370
+ await connect();
371
+ const before = requests.length;
372
+ await assert.rejects(invoke("comments.create", { postId: 19, content: "Denied" }), { code: "connector_scope_missing" });
373
+ assert.equal(requests.length, before);
374
+ });
375
+
376
+ test("WordPress.com statistics preserve provider metrics and require a statistics grant", async (t) => {
377
+ const { service, state, requests, connect } = await fixture(t, ["users", "stats"]);
378
+ await connect();
379
+ state.response = { date: "2026-09-12", stats: { views: 0, visitors: 0 }, visits: { fields: ["period", "views", "visitors"], data: [["2026-09-12", 0, 0]] } };
380
+ assert.deepEqual(await service.invoke({ ...input, operation: "stats.read", input: { siteId: 81 } }), state.response);
381
+ assert.equal(requests.at(-1).url.pathname, "/rest/v1.1/sites/81/stats/");
382
+ assert.equal(requests.at(-1).headers.get("authorization"), "Bearer fixture-access-token");
383
+ for (const response of [{}, { date: "2026-09-12", stats: null, visits: [] }, { date: "2026-09-12", stats: {}, visits: "invalid" }]) {
384
+ state.response = response;
385
+ await assert.rejects(service.invoke({ ...input, operation: "stats.read", input: { siteId: 81 } }), { code: "connector_response_invalid" });
386
+ }
387
+ state.status = 403; state.response = { error: "denied" };
388
+ await assert.rejects(service.invoke({ ...input, operation: "stats.read", input: { siteId: 81 } }), { code: "connector_permission_denied" });
389
+ state.status = 200; state.response = null; state.profile.token_scope = ["users"];
390
+ await connect();
391
+ const count = requests.length;
392
+ await assert.rejects(service.invoke({ ...input, operation: "stats.read", input: { siteId: 81 } }), { code: "connector_scope_missing" });
393
+ assert.equal(requests.length, count);
394
+ });
395
+
396
+ test("WordPress.com taxonomy manages terms by encoded slug and assigns existing IDs to posts", async (t) => {
397
+ const { service, state, requests, connect } = await fixture(t, ["users", "taxonomy", "posts"]);
398
+ await connect();
399
+ const invoke = (operation, values) => service.invoke({ ...input, operation, input: { siteId: 81, ...values } });
400
+ const term = { ID: 12, name: "News & updates", slug: "news&updates?x=1", description: "" };
401
+ for (const family of ["categories", "tags"]) {
402
+ state.response = { found: 1, [family]: [term] };
403
+ assert.deepEqual(await invoke(`${family}.list`, { number: 50, page: 2, search: "News & updates" }), state.response);
404
+ assert.equal(requests.at(-1).url.pathname, `/rest/v1.1/sites/81/${family}/`);
405
+ state.response = term;
406
+ await invoke(`${family}.create`, { name: term.name, description: "", ...(family === "categories" ? { parent: 0 } : {}) });
407
+ assert.equal(requests.at(-1).url.pathname, `/rest/v1.1/sites/81/${family}/new`);
408
+ assert.equal(new URLSearchParams(requests.at(-1).init.body).get("name"), term.name);
409
+ await invoke(`${family}.get`, { termSlug: term.slug });
410
+ assert.equal(requests.at(-1).url.pathname, `/rest/v1.1/sites/81/${family}/slug:news%26updates%3Fx%3D1`);
411
+ assert.equal(requests.at(-1).url.searchParams.has("x"), false);
412
+ await invoke(`${family}.update`, { termSlug: term.slug, description: "" });
413
+ assert.deepEqual(Object.fromEntries(new URLSearchParams(requests.at(-1).init.body)), { description: "" });
414
+ await invoke(`${family}.delete`, { termSlug: term.slug, confirmDeletion: true });
415
+ assert.ok(requests.at(-1).url.pathname.endsWith("/delete"));
416
+ const count = requests.length;
417
+ for (const [action, values] of [["update", { termSlug: term.slug }], ["get", { termSlug: "../news" }],
418
+ ["create", {}], ["delete", { termSlug: term.slug }]]) {
419
+ await assert.rejects(invoke(`${family}.${action}`, values), { code: "connector_input_invalid" });
420
+ }
421
+ assert.equal(requests.length, count);
422
+ }
423
+ state.response = { ID: 19, site_ID: 81, title: "Post", status: "draft" };
424
+ await invoke("posts.update", { postId: 19, categories: [12, 13], tags: [] });
425
+ assert.deepEqual(Object.fromEntries(new URLSearchParams(requests.at(-1).init.body)), { categories: "12,13", tags: "" });
426
+ state.status = 403; state.response = { error: "denied" };
427
+ await assert.rejects(invoke("categories.create", { name: "Denied" }), { code: "connector_permission_denied" });
428
+ state.status = 200; state.response = null; state.profile.token_scope = ["users", "posts"];
429
+ await connect();
430
+ const count = requests.length;
431
+ await assert.rejects(invoke("tags.create", { name: "Denied" }), { code: "connector_scope_missing" });
432
+ assert.equal(requests.length, count);
433
+ });
434
+
435
+ test("WordPress.com batches bounded same-site GETs and retains individual permission errors", async (t) => {
436
+ const { service, state, requests, connect } = await fixture(t, ["users", "batch", "posts"]);
437
+ await connect();
438
+ const postsPath = "/sites/81/posts?context=edit&number=20";
439
+ const mediaPath = "/sites/81/media?context=edit&number=20";
440
+ state.response = {
441
+ [postsPath]: { found: 1, posts: [{ ID: 19, title: "Hello" }] },
442
+ [mediaPath]: { error: "authorization_required", message: "No media permission" }
443
+ };
444
+ const count = requests.length;
445
+ assert.deepEqual(await service.invoke({ ...input, operation: "batch.read", input: { siteId: 81, resources: ["posts", "media"] } }), state.response);
446
+ assert.equal(requests.length, count + 1);
447
+ const request = requests.at(-1);
448
+ assert.equal(request.url.origin + request.url.pathname, "https://public-api.wordpress.com/rest/v1.3/batch/");
449
+ assert.deepEqual(request.url.searchParams.getAll("urls[]"), [postsPath, mediaPath]);
450
+ assert.equal(request.init.method, "GET");
451
+ assert.equal(request.headers.get("authorization"), "Bearer fixture-access-token");
452
+ const before = requests.length;
453
+ for (const bad of [[], ["posts", "posts"], ["https://other.example"], ["posts/new"], ["../me"]]) {
454
+ await assert.rejects(service.invoke({ ...input, operation: "batch.read", input: { siteId: 81, resources: bad } }), { code: "connector_input_invalid" });
455
+ }
456
+ assert.equal(requests.length, before);
457
+ state.response = { "/sites/81/stats?context=edit": { date: "2026-09-12", stats: {}, visits: [] } };
458
+ await service.invoke({ ...input, operation: "batch.read", input: { siteId: 81, resources: ["stats"] } });
459
+ assert.deepEqual(requests.at(-1).url.searchParams.getAll("urls[]"), ["/sites/81/stats?context=edit"]);
460
+ for (const malformed of [[], {}, { result: "invalid" }]) {
461
+ state.response = malformed;
462
+ await assert.rejects(service.invoke({ ...input, operation: "batch.read", input: { siteId: 81, resources: ["posts"] } }), { code: "connector_response_invalid" });
463
+ }
464
+ state.response = null; state.profile.token_scope = ["users", "posts"];
465
+ await connect();
466
+ const last = requests.length;
467
+ await assert.rejects(service.invoke({ ...input, operation: "batch.read", input: { siteId: 81, resources: ["posts"] } }), { code: "connector_scope_missing" });
468
+ assert.equal(requests.length, last);
469
+ });