@jini-ai/agent-plugins 0.6.17 → 0.6.19

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 (335) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/NOTICE +14 -0
  3. package/README.md +35 -14
  4. package/charts-echarts/plugin.json +39 -0
  5. package/charts-echarts/skills/charts-echarts/SKILL.md +110 -0
  6. package/charts-recharts/plugin.json +32 -0
  7. package/charts-recharts/skills/charts-recharts/SKILL.md +108 -0
  8. package/cloudflare/NOTES.md +16 -0
  9. package/cloudflare/form-destinations/cloudflare-d1.mjs +9 -0
  10. package/cloudflare/lib/api.mjs +96 -0
  11. package/cloudflare/lib/assets.mjs +65 -0
  12. package/cloudflare/lib/dns.mjs +32 -0
  13. package/cloudflare/lib/domain-inventory.mjs +61 -0
  14. package/cloudflare/lib/domain-status.mjs +48 -0
  15. package/cloudflare/lib/domains.mjs +95 -0
  16. package/cloudflare/lib/forms.mjs +254 -0
  17. package/cloudflare/lib/pages-upload.mjs +51 -0
  18. package/cloudflare/lib/target.mjs +165 -0
  19. package/cloudflare/lib/workers-upload.mjs +48 -0
  20. package/cloudflare/lib/zones.mjs +25 -0
  21. package/cloudflare/mcp.json +4 -0
  22. package/cloudflare/plugin.json +96 -0
  23. package/cloudflare/skills/cloudflare/SKILL.md +57 -0
  24. package/cloudflare/skills/cloudflare/references/domains.md +31 -0
  25. package/cloudflare/skills/cloudflare/references/pages.md +18 -0
  26. package/cloudflare/skills/cloudflare/references/permissions.md +29 -0
  27. package/cloudflare/skills/cloudflare/references/troubleshooting.md +20 -0
  28. package/cloudflare/skills/cloudflare/references/workers.md +42 -0
  29. package/cloudflare/targets/cloudflare-pages.mjs +12 -0
  30. package/cloudflare/targets/cloudflare-workers.mjs +17 -0
  31. package/cloudflare/tovu-consent.json +6 -0
  32. package/cloudflare/tovu-deploy-targets.json +630 -0
  33. package/cloudflare/tovu-form-destinations.json +12 -0
  34. package/cloudflare/worker/forms-receiver.mjs +249 -0
  35. package/composio/mcp.json +10 -0
  36. package/composio/plugin.json +68 -0
  37. package/composio/skills/composio/SKILL.md +85 -0
  38. package/dist/core/identity-namespace.d.ts +12 -0
  39. package/dist/core/identity-namespace.js +20 -0
  40. package/dist/folder-zip-node.js +29 -7
  41. package/dist/lifecycle/bundled-digests.js +4 -3
  42. package/dist/lifecycle/install.d.ts +7 -0
  43. package/dist/lifecycle/install.js +45 -3
  44. package/dist/lifecycle/manifest.d.ts +2 -2
  45. package/dist/lifecycle/manifest.js +5 -3
  46. package/dist/lifecycle/node.js +2 -0
  47. package/dist/lifecycle/persistent-state.js +1 -1
  48. package/dist/lifecycle/ports.d.ts +8 -0
  49. package/dist/lifecycle/seed-bundled.js +4 -1
  50. package/dist/manifest.d.ts +1 -1
  51. package/dist/manifest.js +3 -2
  52. package/fly/deploy-configs/fly.mjs +115 -0
  53. package/fly/deploy-ops/fly.mjs +102 -0
  54. package/fly/mcp.json +4 -0
  55. package/fly/plugin.json +81 -0
  56. package/fly/scripts/render-deploy-workflow.mjs +150 -0
  57. package/fly/skills/fly/SKILL.md +43 -0
  58. package/fly/skills/fly/references/fly-deploy.template.yml +117 -0
  59. package/fly/skills/fly/references/fly.template.toml +81 -0
  60. package/fly/tovu-credential-schemes.json +11 -0
  61. package/fly/tovu-deploy-configs.json +21 -0
  62. package/fly/tovu-deploy-ops.json +14 -0
  63. package/github/deploy-ops/github-actions.mjs +164 -0
  64. package/github/deployments/github.mjs +415 -0
  65. package/github/mcp.json +4 -0
  66. package/github/plugin.json +105 -0
  67. package/github/skills/github/SKILL.md +249 -0
  68. package/github/skills/github/references/actions.md +224 -0
  69. package/github/skills/github/references/auth-and-tokens.md +140 -0
  70. package/github/skills/github/references/repo-files.md +135 -0
  71. package/github/skills/github/references/site-backup.md +159 -0
  72. package/github/skills/github/references/source-control.md +123 -0
  73. package/github/source-control/backup-push.mjs +242 -0
  74. package/github/source-control/commit-site.mjs +459 -0
  75. package/github/source-control/github.mjs +93 -0
  76. package/github/source-control/write-files.mjs +468 -0
  77. package/github/tovu-deploy-ops.json +13 -0
  78. package/github/tovu-source-control.json +117 -0
  79. package/github-pages/NOTES.md +19 -0
  80. package/github-pages/mcp.json +4 -0
  81. package/github-pages/plugin.json +48 -0
  82. package/github-pages/skills/github-pages/SKILL.md +44 -0
  83. package/github-pages/targets/github-pages.mjs +543 -0
  84. package/github-pages/tovu-deploy-targets.json +327 -0
  85. package/google-login/exports/google-g.svg +1 -0
  86. package/google-login/exports/sign-in-button.css +68 -0
  87. package/google-login/exports/sign-in-button.html +4 -0
  88. package/google-login/mcp.json +4 -0
  89. package/google-login/plugin.json +59 -0
  90. package/google-login/skills/google-login/SKILL.md +96 -0
  91. package/google-login/tovu-login-providers.json +21 -0
  92. package/google-workspace/README.md +132 -0
  93. package/google-workspace/form-destinations/README.md +87 -0
  94. package/google-workspace/form-destinations/dedupe.mjs +43 -0
  95. package/google-workspace/form-destinations/delivery-result.mjs +69 -0
  96. package/google-workspace/form-destinations/mapping.mjs +152 -0
  97. package/google-workspace/form-destinations/sheets.mjs +202 -0
  98. package/google-workspace/lib/auth.mjs +81 -0
  99. package/google-workspace/lib/drive-files.mjs +54 -0
  100. package/google-workspace/lib/form-destination-settings.mjs +72 -0
  101. package/google-workspace/lib/http.mjs +168 -0
  102. package/google-workspace/lib/picker-flow.mjs +171 -0
  103. package/google-workspace/lib/picker-selection.mjs +107 -0
  104. package/google-workspace/lib/product-state.mjs +93 -0
  105. package/google-workspace/lib/request-policy.mjs +163 -0
  106. package/google-workspace/lib/sheets-a1.mjs +79 -0
  107. package/google-workspace/lib/sheets-admin-csv.mjs +82 -0
  108. package/google-workspace/lib/sheets-admin-request-kinds.mjs +183 -0
  109. package/google-workspace/lib/sheets-client.mjs +286 -0
  110. package/google-workspace/lib/surface-actions.mjs +107 -0
  111. package/google-workspace/mcp.json +4 -0
  112. package/google-workspace/plugin.json +77 -0
  113. package/google-workspace/products.json +12 -0
  114. package/google-workspace/skills/google-workspace/SKILL.md +82 -0
  115. package/google-workspace/skills/google-workspace/references/connection.md +102 -0
  116. package/google-workspace/skills/google-workspace/references/form-delivery.md +50 -0
  117. package/google-workspace/skills/google-workspace/references/sheets.md +149 -0
  118. package/google-workspace/skills/google-workspace/references/troubleshooting.md +31 -0
  119. package/google-workspace/tools/account-info.mjs +18 -0
  120. package/google-workspace/tools/sheets/README.md +54 -0
  121. package/google-workspace/tools/sheets/copy-tab.mjs +23 -0
  122. package/google-workspace/tools/sheets/copy.mjs +23 -0
  123. package/google-workspace/tools/sheets/create.mjs +35 -0
  124. package/google-workspace/tools/sheets/find-replace.mjs +38 -0
  125. package/google-workspace/tools/sheets/find.mjs +52 -0
  126. package/google-workspace/tools/sheets/get-by-data-filter.mjs +23 -0
  127. package/google-workspace/tools/sheets/get.mjs +22 -0
  128. package/google-workspace/tools/sheets/values-append.mjs +27 -0
  129. package/google-workspace/tools/sheets/values-batch-clear.mjs +20 -0
  130. package/google-workspace/tools/sheets/values-batch-get.mjs +20 -0
  131. package/google-workspace/tools/sheets/values-batch-update.mjs +29 -0
  132. package/google-workspace/tools/sheets/values-by-data-filter.mjs +62 -0
  133. package/google-workspace/tools/sheets/values-clear.mjs +20 -0
  134. package/google-workspace/tools/sheets/values-get.mjs +21 -0
  135. package/google-workspace/tools/sheets/values-update.mjs +26 -0
  136. package/google-workspace/tools/sheets-admin/README.md +45 -0
  137. package/google-workspace/tools/sheets-admin/basic-filter.mjs +32 -0
  138. package/google-workspace/tools/sheets-admin/batch-update.mjs +25 -0
  139. package/google-workspace/tools/sheets-admin/developer-metadata.mjs +28 -0
  140. package/google-workspace/tools/sheets-admin/export-csv.mjs +67 -0
  141. package/google-workspace/tools/sheets-admin/filter-views.mjs +38 -0
  142. package/google-workspace/tools/sheets-admin/format.mjs +70 -0
  143. package/google-workspace/tools/sheets-admin/named-ranges.mjs +42 -0
  144. package/google-workspace/tools/sheets-admin/open-existing.mjs +34 -0
  145. package/google-workspace/tools/sheets-admin/protected-ranges-get.mjs +18 -0
  146. package/google-workspace/tools/sheets-admin/sort.mjs +18 -0
  147. package/google-workspace/tools/sheets-admin/tab-modify.mjs +36 -0
  148. package/google-workspace/tovu-admin-surfaces.json +63 -0
  149. package/google-workspace/tovu-agent-tools.json +1178 -0
  150. package/google-workspace/tovu-connections.json +35 -0
  151. package/google-workspace/tovu-form-destinations.json +10 -0
  152. package/google-workspace/ui/README.md +73 -0
  153. package/google-workspace/ui/connection-panel.handler.mjs +167 -0
  154. package/google-workspace/ui/connection-panel.html +17 -0
  155. package/google-workspace/ui/connection-panel.mjs +189 -0
  156. package/google-workspace/ui/connection-view.mjs +120 -0
  157. package/google-workspace/ui/destination-panel.handler.mjs +106 -0
  158. package/google-workspace/ui/destination-panel.html +17 -0
  159. package/google-workspace/ui/destination-panel.mjs +150 -0
  160. package/google-workspace/ui/dom.mjs +140 -0
  161. package/google-workspace/ui/panel.css +170 -0
  162. package/google-workspace/ui/picker-flow.mjs +87 -0
  163. package/google-workspace/ui/picker-view.mjs +44 -0
  164. package/google-workspace/ui/picker.handler.mjs +56 -0
  165. package/google-workspace/ui/picker.html +17 -0
  166. package/google-workspace/ui/picker.mjs +88 -0
  167. package/google-workspace/ui/surface-client.mjs +227 -0
  168. package/higgsfield-media/mcp.json +9 -0
  169. package/higgsfield-media/plugin.json +109 -0
  170. package/higgsfield-media/skills/higgsfield-media/SKILL.md +325 -0
  171. package/higgsfield-media/skills/higgsfield-media/references/failure-modes.md +116 -0
  172. package/higgsfield-media/skills/higgsfield-media/references/models-and-plan-gates.md +142 -0
  173. package/jev/mcp.json +4 -0
  174. package/jev/plugin.json +44 -0
  175. package/jev/skills/jev/SKILL.md +308 -0
  176. package/jev/skills/jev/references/cookbooks.md +197 -0
  177. package/jev/skills/jev/references/jaggedness.md +135 -0
  178. package/jev/skills/jev/references/javascript-sdk.md +146 -0
  179. package/jev/skills/jev/references/patterns.md +123 -0
  180. package/jev/skills/jev/references/primitives-reference.md +187 -0
  181. package/langfuse/plugin.json +22 -0
  182. package/meilisearch/mcp.json +4 -0
  183. package/meilisearch/plugin.json +54 -0
  184. package/meilisearch/providers/meilisearch.mjs +651 -0
  185. package/meilisearch/skills/meilisearch/SKILL.md +43 -0
  186. package/meilisearch/tovu-search-providers.json +10 -0
  187. package/netlify/NOTES.md +13 -0
  188. package/netlify/mcp.json +4 -0
  189. package/netlify/plugin.json +47 -0
  190. package/netlify/skills/netlify/SKILL.md +39 -0
  191. package/netlify/targets/netlify.mjs +384 -0
  192. package/netlify/tovu-consent.json +6 -0
  193. package/netlify/tovu-deploy-targets.json +163 -0
  194. package/opentelemetry/mcp.json +4 -0
  195. package/opentelemetry/plugin.json +52 -0
  196. package/opentelemetry/skills/opentelemetry/SKILL.md +39 -0
  197. package/package.json +161 -6
  198. package/posthog/mcp.json +4 -0
  199. package/posthog/plugin.json +46 -0
  200. package/posthog/skills/posthog/SKILL.md +36 -0
  201. package/railway/deploy-configs/railway.mjs +135 -0
  202. package/railway/deploy-ops/railway.mjs +167 -0
  203. package/railway/mcp.json +4 -0
  204. package/railway/plugin.json +45 -0
  205. package/railway/skills/railway/SKILL.md +66 -0
  206. package/railway/tovu-deploy-configs.json +16 -0
  207. package/railway/tovu-deploy-ops.json +11 -0
  208. package/render/deploy-configs/render.mjs +141 -0
  209. package/render/deploy-ops/render.mjs +152 -0
  210. package/render/mcp.json +4 -0
  211. package/render/plugin.json +47 -0
  212. package/render/skills/render/SKILL.md +63 -0
  213. package/render/tovu-deploy-configs.json +16 -0
  214. package/render/tovu-deploy-ops.json +11 -0
  215. package/resend/NOTES.md +52 -0
  216. package/resend/adapters/resend.mjs +161 -0
  217. package/resend/lib/domains.mjs +137 -0
  218. package/resend/lib/errors.mjs +25 -0
  219. package/resend/lib/feedback.mjs +118 -0
  220. package/resend/mcp.json +13 -0
  221. package/resend/plugin.json +192 -0
  222. package/resend/skills/resend/SKILL.md +107 -0
  223. package/resend/tovu-mail-adapters.json +22 -0
  224. package/s3-compatible/NOTES.md +11 -0
  225. package/s3-compatible/mcp.json +4 -0
  226. package/s3-compatible/object-stores/s3-compatible.mjs +287 -0
  227. package/s3-compatible/plugin.json +56 -0
  228. package/s3-compatible/skills/s3-compatible/SKILL.md +39 -0
  229. package/s3-compatible/targets/s3-compatible.mjs +1054 -0
  230. package/s3-compatible/tovu-deploy-targets.json +432 -0
  231. package/s3-compatible/tovu-object-stores.json +29 -0
  232. package/sentry/mcp.json +4 -0
  233. package/sentry/plugin.json +49 -0
  234. package/sentry/skills/sentry/SKILL.md +34 -0
  235. package/shopify/mcp.json +4 -0
  236. package/shopify/plugin.json +22 -0
  237. package/shopify/skills/shopify/SKILL.md +103 -0
  238. package/shopify/skills/shopify/references/catalog-sync.md +74 -0
  239. package/shopify/skills/shopify/references/domains.md +26 -0
  240. package/shopify/skills/shopify/references/live-storefront.md +34 -0
  241. package/shopify/skills/shopify/references/storefront-snippets.md +21 -0
  242. package/site-compliance/mcp.json +4 -0
  243. package/site-compliance/plugin.json +34 -0
  244. package/site-compliance/skills/site-compliance/SKILL.md +220 -0
  245. package/site-compliance/skills/site-compliance/references/ccpa-cpra.md +115 -0
  246. package/site-compliance/skills/site-compliance/references/gdpr-eu-uk.md +143 -0
  247. package/site-compliance/skills/site-compliance/references/wcag-2-2.md +129 -0
  248. package/site-import/mcp.json +4 -0
  249. package/site-import/plugin.json +41 -0
  250. package/site-import/skills/site-import/SKILL.md +232 -0
  251. package/site-import/skills/site-import/references/content-mapping.md +166 -0
  252. package/site-import/skills/site-import/references/discovery-and-classification.md +117 -0
  253. package/site-import/skills/site-import/references/theme-extraction.md +34 -0
  254. package/slide-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
  255. package/slide-design/mcp.json +4 -0
  256. package/slide-design/plugin.json +26 -0
  257. package/slide-design/skills/slide-design/SKILL.md +103 -0
  258. package/slide-design/skills/slide-design/references/charts-and-exhibits.md +100 -0
  259. package/slide-design/skills/slide-design/references/html-deck.md +175 -0
  260. package/slide-design/skills/slide-design/references/layout-and-style.md +131 -0
  261. package/slide-design/skills/slide-design/references/review.md +93 -0
  262. package/slide-design/skills/slide-design/references/storyline.md +118 -0
  263. package/stripe/plugin.json +22 -0
  264. package/supabase/mcp.json +26 -0
  265. package/supabase/plugin.json +78 -0
  266. package/supabase/skills/supabase/SKILL.md +169 -0
  267. package/supabase/skills/supabase/references/failure-modes.md +101 -0
  268. package/ui-ux-design/CHANGELOG.md +28 -0
  269. package/ui-ux-design/LICENSE +201 -0
  270. package/ui-ux-design/NOTICE +14 -0
  271. package/ui-ux-design/README.md +62 -0
  272. package/ui-ux-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
  273. package/ui-ux-design/mcp.json +4 -0
  274. package/ui-ux-design/plugin.json +26 -0
  275. package/ui-ux-design/skills/frontend-accessibility/SKILL.md +60 -0
  276. package/ui-ux-design/skills/gstack-design/SKILL.md +53 -0
  277. package/ui-ux-design/skills/gstack-design/references/consultation.md +30 -0
  278. package/ui-ux-design/skills/gstack-design/references/html.md +29 -0
  279. package/ui-ux-design/skills/gstack-design/references/review.md +28 -0
  280. package/ui-ux-design/skills/gstack-design/references/shotgun.md +33 -0
  281. package/ui-ux-design/skills/gstack-design/references/upstream-notes.md +23 -0
  282. package/ui-ux-design/skills/interface-design/ORIGINAL.md +16 -0
  283. package/ui-ux-design/skills/interface-design/SKILL.md +41 -0
  284. package/ui-ux-design/skills/interface-design/references/commands/audit.md +40 -0
  285. package/ui-ux-design/skills/interface-design/references/commands/critique.md +21 -0
  286. package/ui-ux-design/skills/interface-design/references/commands/extract.md +34 -0
  287. package/ui-ux-design/skills/interface-design/references/commands/init.md +31 -0
  288. package/ui-ux-design/skills/interface-design/references/commands/status.md +22 -0
  289. package/ui-ux-design/skills/interface-design/references/examples/system-precision.md +29 -0
  290. package/ui-ux-design/skills/interface-design/references/examples/system-warmth.md +29 -0
  291. package/ui-ux-design/skills/interface-design/references/system-template.md +57 -0
  292. package/ui-ux-design/skills/shadcn-ui/README.md +244 -0
  293. package/ui-ux-design/skills/shadcn-ui/SKILL.md +333 -0
  294. package/ui-ux-design/skills/shadcn-ui/examples/auth-layout.tsx +177 -0
  295. package/ui-ux-design/skills/shadcn-ui/examples/data-table.tsx +313 -0
  296. package/ui-ux-design/skills/shadcn-ui/examples/form-pattern.tsx +177 -0
  297. package/ui-ux-design/skills/shadcn-ui/resources/component-catalog.md +481 -0
  298. package/ui-ux-design/skills/shadcn-ui/resources/customization-guide.md +516 -0
  299. package/ui-ux-design/skills/shadcn-ui/resources/migration-guide.md +463 -0
  300. package/ui-ux-design/skills/shadcn-ui/resources/setup-guide.md +412 -0
  301. package/ui-ux-design/skills/shadcn-ui/scripts/verify-setup.sh +134 -0
  302. package/ui-ux-design/skills/ui-ux-design/SKILL.md +171 -0
  303. package/ui-ux-design/skills/ui-ux-design/references/brand-and-voice.md +78 -0
  304. package/ui-ux-design/skills/ui-ux-design/references/chrome-mobile-test-matrix.md +137 -0
  305. package/ui-ux-design/skills/ui-ux-design/references/components-and-states.md +112 -0
  306. package/ui-ux-design/skills/ui-ux-design/references/delight-and-motion.md +54 -0
  307. package/ui-ux-design/skills/ui-ux-design/references/foundations.md +145 -0
  308. package/ui-ux-design/skills/ui-ux-design/references/inclusive-ai-imagery.md +57 -0
  309. package/ui-ux-design/skills/ui-ux-design/references/kole-jain-uiux-concepts.md +104 -0
  310. package/ui-ux-design/skills/ui-ux-design/references/kuinetic-reference.md +184 -0
  311. package/ui-ux-design/skills/ui-ux-design/references/openai-frontend-skill.md +139 -0
  312. package/ui-ux-design/skills/ui-ux-design/references/premium-ui.md +55 -0
  313. package/ui-ux-design/skills/ui-ux-design/references/research-and-validation.md +88 -0
  314. package/ui-ux-design/skills/ui-ux-design/references/sam-crawford-premium-websites.md +103 -0
  315. package/ui-ux-design/skills/ui-ux-design/references/self-made-web-designer-core-skills.md +119 -0
  316. package/ui-ux-design/skills/ui-ux-design/references/ui-animation.md +173 -0
  317. package/ui-ux-design/skills/ui-ux-design/references/ui-loop.md +234 -0
  318. package/ui-ux-design/skills/ui-ux-design/references/visual-storytelling.md +54 -0
  319. package/ui-ux-design/skills/vercel-web-design-guidelines/SKILL.md +39 -0
  320. package/ui-ux-design/skills/web-compliance/SKILL.md +134 -0
  321. package/ui-ux-design/skills/web-compliance/references/audit-evidence.md +59 -0
  322. package/vercel/NOTES.md +23 -0
  323. package/vercel/mcp.json +4 -0
  324. package/vercel/object-stores/vercel-blob.mjs +147 -0
  325. package/vercel/plugin.json +47 -0
  326. package/vercel/skills/vercel/SKILL.md +48 -0
  327. package/vercel/targets/vercel.mjs +586 -0
  328. package/vercel/tovu-consent.json +6 -0
  329. package/vercel/tovu-deploy-targets.json +219 -0
  330. package/vercel/tovu-object-stores.json +10 -0
  331. package/wordpress/NOTES.md +33 -0
  332. package/wordpress/plugin.json +30 -0
  333. package/wordpress/skills/wordpress/SKILL.md +43 -0
  334. package/wordpress/skills/wordpress/references/free-site-redirect.md +46 -0
  335. package/wordpress/skills/wordpress/references/subdomain.md +66 -0
@@ -0,0 +1,152 @@
1
+ /** Render REST adapter. The host owns credentials, HTTP policy, cancellation and polling. */
2
+ const API = "https://api.render.com/v1";
3
+ const SERVER_TYPES = ["web_service", "private_service", "background_worker"];
4
+
5
+ /** Validate before encoding: a target or deployment is one bounded opaque path segment. */
6
+ function segment({ ctx, value, prefix }, _options = {}) {
7
+ if (typeof value !== "string" || !value.startsWith(prefix) || !/^[A-Za-z0-9_-]+$/.test(value)) ctx.fail("Use a valid Render service or deployment ID.");
8
+ if (value.length <= prefix.length || value.length > 128) ctx.fail("Use a valid Render service or deployment ID.");
9
+ return encodeURIComponent(value);
10
+ }
11
+ function serviceUrl({ ctx, target }, _options = {}) {
12
+ return `${API}/services/${segment({ ctx, value: target, prefix: "srv-" })}`;
13
+ }
14
+ /** Reject unsuitable services rather than dispatching a static site or provisioning resources. */
15
+ async function service({ ctx, target }, _options = {}) {
16
+ const response = await ctx.get(serviceUrl({ ctx, target }));
17
+ const found = response.json;
18
+ if (response.truncated || found?.id !== target) ctx.fail("Render did not return the requested service.");
19
+ if (!SERVER_TYPES.includes(found.type)) ctx.fail("Choose an existing Render server service; static sites and cron jobs are not supported.");
20
+ return found;
21
+ }
22
+ /** Read at most five 100-row pages. A full last page means the result may be incomplete.
23
+ * Values are projected away on each page, before they can enter an operation result. */
24
+ async function pages({ ctx, url, field, project }, _options = {}) {
25
+ const items = []; let cursor;
26
+ for (let page = 0; page < 5; page++) {
27
+ const query = new URLSearchParams({ limit: "100" });
28
+ if (cursor) query.set("cursor", cursor);
29
+ const response = await ctx.get(`${url}${url.includes("?") ? "&" : "?"}${query}`);
30
+ const rows = listPage({ ctx, response });
31
+ items.push(...rows.map(row => project({ ctx, value: row?.[field] })));
32
+ if (rows.length < 100) return { items, truncated: false };
33
+ cursor = nextCursor({ ctx, rows, cursor });
34
+ }
35
+ return { items, truncated: true };
36
+ }
37
+ function listPage({ ctx, response }, _options = {}) {
38
+ const rows = response.json;
39
+ if (response.truncated || !Array.isArray(rows) || rows.length > 100) ctx.fail("Render returned an incomplete or invalid list.");
40
+ return rows;
41
+ }
42
+ function nextCursor({ ctx, rows, cursor }, _options = {}) {
43
+ const next = rows.at(-1)?.cursor;
44
+ if (typeof next !== "string" || !next || next.length > 2000 || next === cursor) ctx.fail("Render did not return a usable pagination cursor.");
45
+ return next;
46
+ }
47
+ function targetInfo({ ctx, value }, _options = {}) {
48
+ segment({ ctx, value: value?.id, prefix: "srv-" });
49
+ if (typeof value.name !== "string" || typeof value.type !== "string") ctx.fail("Render returned an invalid service entry.");
50
+ return { id: value.id, name: value.name.slice(0, 2000), type: value.type };
51
+ }
52
+ /** Discovery is bounded; never present a capped inventory as a complete choice list. */
53
+ async function listTargets(ctx, { org } = {}) {
54
+ const query = org === undefined ? "" : `?ownerId=${encodeURIComponent(org)}`;
55
+ const result = await pages({ ctx, url: `${API}/services${query}`, field: "service", project: targetInfo });
56
+ if (result.truncated) ctx.fail("Render service discovery reached 500 services. Narrow it with the workspace ID in org.");
57
+ return { platform: "render", targets: result.items.filter(item => SERVER_TYPES.includes(item.type)).map(({ id, name }) => ({ id, name })) };
58
+ }
59
+ /** Unknown provider states remain unknown so waiting cannot invent completion. */
60
+ function overall({ state }, _options = {}) {
61
+ if (state === "live") return "healthy";
62
+ if (["build_failed", "update_failed", "pre_deploy_failed"].includes(state)) return "failing";
63
+ if (["canceled", "deactivated"].includes(state)) return "stopped";
64
+ if (["created", "queued", "build_in_progress", "pre_deploy_in_progress", "update_in_progress"].includes(state)) return "deploying";
65
+ return "unknown";
66
+ }
67
+ function deployment({ ctx, value, runId }, _options = {}) {
68
+ segment({ ctx, value: value?.id, prefix: "dep-" });
69
+ if (typeof value.status !== "string" || (runId !== undefined && value.id !== runId)) ctx.fail("Render did not return the requested deployment.");
70
+ return value;
71
+ }
72
+ /** The service-scoped endpoint enforces run ownership in this read, with no membership pre-query. */
73
+ async function readDeployment({ ctx, target, runId }, _options = {}) {
74
+ const base = `${serviceUrl({ ctx, target })}/deploys`;
75
+ const suffix = runId === undefined ? "?limit=1" : `/${segment({ ctx, value: runId, prefix: "dep-" })}`;
76
+ const response = await ctx.get(`${base}${suffix}`);
77
+ if (response.truncated) ctx.fail("Render returned an incomplete deployment response.");
78
+ if (runId !== undefined) return deployment({ ctx, value: response.json, runId });
79
+ if (!Array.isArray(response.json)) ctx.fail("Render did not return a deployment list.");
80
+ if (!response.json.length) return undefined;
81
+ return deployment({ ctx, value: response.json[0]?.deploy });
82
+ }
83
+ async function status(ctx, { target, runId, branch }) {
84
+ if (branch !== undefined) ctx.fail("Render status uses a deployment ID, not a branch filter.");
85
+ const run = await readDeployment({ ctx, target, runId });
86
+ if (!run) return { platform: "render", target, state: "unknown", summary: "No Render deployments found.", items: [], checkedAt: ctx.nowIso() };
87
+ return { platform: "render", target, state: overall({ state: run.status }), summary: `Render deployment: ${run.status.slice(0, 2000)}`, items: [{ id: run.id, state: run.status.slice(0, 2000) }], checkedAt: ctx.nowIso() };
88
+ }
89
+ function validateRef({ ctx, ref }, _options = {}) {
90
+ if (ref !== undefined && (typeof ref !== "string" || !/^[0-9a-fA-F]{40}$/.test(ref))) ctx.fail("Render ref must be a full commit SHA; omit it to deploy the connected branch.");
91
+ }
92
+ /** A full SHA pins the commit; omission uses the service's connected branch. Never retry writes here. */
93
+ async function deploy(ctx, { target, ref }) {
94
+ validateRef({ ctx, ref });
95
+ await service({ ctx, target });
96
+ const response = await ctx.send({ method: "POST", url: `${serviceUrl({ ctx, target })}/deploys`, body: ref === undefined ? {} : { commitId: ref } });
97
+ // 202 means queued behind a running deploy and carries no body, so there is no run ID to follow yet.
98
+ if (response.status === 202) return { platform: "render", target, started: true, ...(ref === undefined ? {} : { sha: ref }), summary: "Render queued the deployment behind one already running. Wait for the service's latest deployment status." };
99
+ if (response.truncated || response.status !== 201) ctx.fail("Render did not confirm deployment acceptance. Check Render before retrying.");
100
+ const runId = response.json?.id;
101
+ if (typeof runId !== "string" || !/^dep-[A-Za-z0-9_-]{1,124}$/.test(runId)) ctx.fail("Render accepted the request without a usable deployment ID. Check Render before retrying.");
102
+ return { platform: "render", target, started: true, runId, ...(ref === undefined ? {} : { sha: ref }), summary: "Render accepted the deployment. Check its status to see when it is live." };
103
+ }
104
+ /** Log labels are metadata; return no raw service or log response objects. */
105
+ function logLine({ ctx, entry }, _options = {}) {
106
+ if (typeof entry?.message !== "string" || typeof entry.timestamp !== "string" || !Array.isArray(entry.labels)) ctx.fail("Render returned an invalid log entry.");
107
+ const level = entry.labels.find(label => label?.name === "level")?.value;
108
+ return { at: entry.timestamp.slice(0, 2000), ...(typeof level === "string" ? { level: level.slice(0, 2000) } : {}), message: entry.message.slice(0, 2000) };
109
+ }
110
+ /** One bounded service-log page. These are recent service logs, not a deploy-specific stream. */
111
+ async function logs(ctx, { target, limit, runId, branch }) {
112
+ if (branch !== undefined) ctx.fail("Render logs do not support a branch filter.");
113
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000) ctx.fail("Render log limit must be between 1 and 1000.");
114
+ const found = await service({ ctx, target });
115
+ if (typeof found.ownerId !== "string" || !found.ownerId) ctx.fail("Render did not return the service workspace ID needed for logs.");
116
+ if (runId !== undefined) await readDeployment({ ctx, target, runId });
117
+ const query = new URLSearchParams({ ownerId: found.ownerId, resource: target, limit: String(Math.min(limit, 100)), direction: "backward" });
118
+ const response = await ctx.get(`${API}/logs?${query}`);
119
+ const entries = response.json?.logs;
120
+ if (!Array.isArray(entries)) ctx.fail("Render did not return a logs array.");
121
+ const lines = entries.slice(0, limit).map(entry => logLine({ ctx, entry }));
122
+ return { platform: "render", target, lines, truncated: logsTruncated({ response, entries, limit }) };
123
+ }
124
+ function logsTruncated({ response, entries, limit }, _options = {}) {
125
+ return response.truncated === true || response.json.hasMore === true || entries.length > limit || entries.slice(0, limit).some(entry => entry.message.length > 2000);
126
+ }
127
+ function secretInfo({ ctx, value }, _options = {}) {
128
+ if (typeof value?.key !== "string" || !value.key || value.key.length > 128) ctx.fail("Render returned an invalid environment variable key.");
129
+ return { name: value.key };
130
+ }
131
+ /** Only names leave this adapter; no value reads, digests or invented revision timestamps. */
132
+ async function listSecrets(ctx, { target }) {
133
+ const result = await pages({ ctx, url: `${serviceUrl({ ctx, target })}/env-vars`, field: "envVar", project: secretInfo });
134
+ return { secrets: result.items, truncated: result.truncated };
135
+ }
136
+ function envUrl({ ctx, target, name }, _options = {}) {
137
+ if (typeof name !== "string" || !name || name.length > 128) ctx.fail("Use a valid Render environment variable key.");
138
+ if (name === "." || name === ".." || /[\x00-\x1f\x7f]/.test(name)) ctx.fail("Use a valid Render environment variable key.");
139
+ return `${serviceUrl({ ctx, target })}/env-vars/${encodeURIComponent(name)}`;
140
+ }
141
+ /** Per-key PUT preserves unrelated variables. The new value applies on the next deploy. */
142
+ async function setSecret(ctx, { target, name, value }) {
143
+ await ctx.send({ method: "PUT", url: envUrl({ ctx, target, name }), body: { value } });
144
+ return {};
145
+ }
146
+ /** Host revision safeguards normally refuse this call: Render lists no revision metadata. */
147
+ async function unsetSecret(ctx, { target, name }) {
148
+ await ctx.send({ method: "DELETE", url: envUrl({ ctx, target, name }) });
149
+ return {};
150
+ }
151
+ const secretCapabilities = { appliesOn: "next-deploy", supportsStaging: true };
152
+ export default { listTargets, deploy, status, logs, secretCapabilities, listSecrets, setSecret, unsetSecret };
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
3
+ "mcpServers": {}
4
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
+ "name": "render",
4
+ "version": "1.0.0",
5
+ "description": "Generates render.yaml and deploys existing Render server services through the API, with status, logs and environment variable tools.",
6
+ "author": "Tovu",
7
+ "license": "Apache-2.0",
8
+ "extensions": {
9
+ "tovu": {
10
+ "maturity": "soon",
11
+ "vendorAliases": ["render.com", "render.yaml", "onrender.com", "RENDER_API_KEY"],
12
+ "displayName": "Render",
13
+ "summary": "Configure and deploy an existing Render server service, then check its status and logs.",
14
+ "id": "tovu/render",
15
+ "provides": [
16
+ "deploy-config:render",
17
+ "deploy-ops:render"
18
+ ],
19
+ "credentialCards": [
20
+ {
21
+ "target": "render",
22
+ "title": "Connect Render",
23
+ "label": "Render API",
24
+ "ownedApiLabel": "Render API",
25
+ "category": "hosting",
26
+ "intro": "Save a Render API key for deploying this site from Tovu. Use it to look up existing services, deploy your server, check status and logs, and manage environment variables. Writing the deployment settings file needs no key. Untested: this connection hasn't been tried end to end yet.",
27
+ "usedFor": [
28
+ "Looks up your Render services",
29
+ "Deploys your server"
30
+ ],
31
+ "keyHint": "In Render, open Account Settings → API Keys → Create API Key. The key has the same access as your Render account, so use an account that can deploy the site's service.",
32
+ "saveKind": "api",
33
+ "helpUrl": "https://render.com/docs/api#1-create-an-api-key",
34
+ "helpLabel": "Create a Render API key",
35
+ "baseUrl": "https://api.render.com/v1",
36
+ "probeUrl": "https://api.render.com/v1/owners?limit=1"
37
+ }
38
+ ]
39
+ }
40
+ },
41
+ "keywords": [
42
+ "render",
43
+ "deploy",
44
+ "deploy-config:render",
45
+ "deploy-ops:render"
46
+ ]
47
+ }
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: render
3
+ description: Configure and deploy existing Render server services, inspect status and recent logs, and stage environment variables securely.
4
+ ---
5
+
6
+ # Render server deployment
7
+
8
+ The in-app assistant uses tools, never Bash or a Render CLI. Reuse an existing,
9
+ already-configured server service. Do not create paid resources, change billing,
10
+ scale services or provision disks. Static sites and cron jobs are unsupported.
11
+ Deploying ships code, not site content. Keep one server instance when its state
12
+ lives on the persistent disk (SQLite), and configure that disk before deployment.
13
+ Follow the host guidance first: it names how this host generates `render.yaml`.
14
+
15
+ The `render.yaml` generator uses the host's server descriptor for the Dockerfile,
16
+ port, health check, persistent disk and secret names, plus the non-secret variables
17
+ the host passes. Secret values are never emitted; `sync: false` leaves them for
18
+ secure setup.
19
+ Writing `render.yaml` needs **no key**. Do not open a key card for that task.
20
+
21
+ ## Render API key
22
+
23
+ Choose the card yourself from its `usedFor` list. Reuse a saved key or working
24
+ sign-in for that task. Never offer a replacement unless the person asks.
25
+ Check `content_read.custom_credential {}` for a saved key first.
26
+ Otherwise open **Connect Render** with
27
+ `credential_save { kind: "plugin-provider", target: "render" }`; it saves as `Render API`.
28
+ The person creates the key under Account Settings → API Keys. A deploy hook URL
29
+ is not a key.
30
+ Saving a key deploys nothing. Untested: this connection has not been tried end to end.
31
+ Never ask for a key in chat. Never offer to replace a working key unless asked.
32
+
33
+ ## Deploy and observe
34
+
35
+ Use `deployment_ops_list_targets` with platform `render` to discover server
36
+ services. Optional `org` filters by workspace ID. Choose the existing `srv-…`
37
+ service; discovery refuses incomplete results at 500 services rather than hiding
38
+ choices. Then use `deployment_ops_deploy` with that target. Omit `ref` to use the
39
+ latest commit on the service's connected branch. Only a full 40-character commit
40
+ SHA is supported as an explicit `ref`; branch and tag names are refused.
41
+ The API must accept the request before the tool reports it started. A connection
42
+ failure may mean the request was accepted: inspect Render before retrying.
43
+
44
+ Keep the returned `dep-…` runId for `deployment_ops_status` and
45
+ `deployment_ops_wait`. A deploy queued behind a running one returns no runId; wait on
46
+ the service's latest deployment instead. Accepted does not mean live. Unknown states remain unknown.
47
+ `deployment_ops_logs` resolves the service's workspace and returns a bounded page
48
+ (at most 100 lines) of recent **service logs**, newest first, defaulting to Render's last-hour window.
49
+ These logs are not isolated to the selected deployment; a supplied runId is
50
+ checked through the service-scoped deployment endpoint. Branch filters are unsupported.
51
+ Check the live service after the deployment becomes healthy.
52
+
53
+ ## Environment variables
54
+
55
+ Use the existing deployment secret tools and masked entry/site-key source; never
56
+ put values in chat. Listing returns names only, never values. Setting a key uses
57
+ an individual-key PUT and preserves unrelated variables. Changes are staged and
58
+ need a deployment to reach the running service. Set all needed keys, then deploy.
59
+ The listing is capped at 500 and reports truncation. No secret readback or copying
60
+ is provided. Render supplies no revision metadata for safe removal, so use the
61
+ provider console to remove variables when the host refuses the removal tool.
62
+
63
+ The Render API key card checks access with a read-only GET before saving the key.
@@ -0,0 +1,16 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "generators": [
4
+ {
5
+ "id": "render",
6
+ "label": "Render",
7
+ "module": "deploy-configs/render.mjs",
8
+ "candidatePaths": [
9
+ {
10
+ "path": "render.yaml",
11
+ "order": 2
12
+ }
13
+ ]
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "platforms": [
4
+ {
5
+ "id": "render",
6
+ "label": "Render",
7
+ "module": "deploy-ops/render.mjs",
8
+ "hosts": ["api.render.com"]
9
+ }
10
+ ]
11
+ }
@@ -0,0 +1,52 @@
1
+ # Resend — implementation notes
2
+
3
+ Vendor-specific notes moved out of the host's core comments (2026-10-22, vendor-to-plugins). Core describes
4
+ these behaviours generically; the vendor detail lives here.
5
+
6
+ - **Rate limit.** Resend's 2/s, burst-2 bucket (`capabilities.rateLimit` in `adapters/resend.mjs`) is why
7
+ core's newsletter send pipeline treats a quota answer as back-pressure, not a failure (review email#4,
8
+ 2026-10-21): a batch of 20 deferred on almost every other row and used to exhaust the outbox budget.
9
+ - **Batch idempotency keys.** One key per message (`<key>:<index>`), which core's mail delivery policy now
10
+ mirrors for per-recipient dispatch.
11
+ - **Credential label.** `Tovu Mail — Resend API` is the v1 label shape core's `defaultCredentialLabel`
12
+ reproduces for v2 descriptors without one.
13
+ - **Feedback inbox.** Resend webhook events are recorded under `providerId: "resend"`.
14
+
15
+ ## Resend's hosted MCP server (2026-10-22)
16
+
17
+ - **Server.** Resend's official hosted server, `https://mcp.resend.com/mcp` (streamable HTTP), the same
18
+ open-source code as the `resend-mcp` npm package (github.com/resend/resend-mcp). Docs:
19
+ https://resend.com/docs/mcp-server. The hosted URL is unversioned; tool names were read from
20
+ `resend-mcp@2.25.0` (2026-10-06) and checked unchanged in 2.27.1, which only adds inbox tools.
21
+ - **Why remote, not `npx resend-mcp`.** Core never auto-wires a plugin's `stdio` server
22
+ (`federate-mcp.ts`), and a remote row is what `agent_plugin_connect` and the token form act on.
23
+ - **Auth (2026-10-25).** The account connection uses `tovuAuthMode: "none"` plus the existing
24
+ `tovuTokenAuth` declaration. The Full access key arrives through the plugin's secure account card,
25
+ is sealed on the MCP row and sent as `Authorization: Bearer`. `agent_plugin_connect` reuses a saved
26
+ credential or opens that card; it does not start OAuth for this declaration.
27
+ - **Historical OAuth rationale (superseded 2026-10-25).** Resend's MCP advertises RFC 9728 metadata pointing at `https://api.resend.com`, which
28
+ supports dynamic client registration and PKCE (scopes `full_access`, `emails:send`), so
29
+ the earlier declaration selected `tovuAuthMode: "oauth"` as the primary sign-in and `tovuTokenAuth`
30
+ as its fallback: the key arrives
31
+ through `credential_save { kind: "agent-plugin-token", target: "resend" }`, is sealed on the row and
32
+ sent as `Authorization: Bearer`, the header Resend documents for headless clients.
33
+ - **Historical OAuth discovery mismatch (2026-10-24).** The server's protected-resource metadata lives only at the origin
34
+ root and names `"resource": "https://mcp.resend.com"`, not the `/mcp` endpoint. RFC 9728 wants an exact
35
+ match and `oauth4webapi` enforces it, so Jini's discovery fails ("did not return a JSON object") and no
36
+ sign-in card opens; the chat falls back to the Full access key card. MCP's own SDK accepts a same-origin
37
+ path-prefix resource, which is the generic Jini fix.
38
+ - **Probe.** `GET /domains` answers 200 for a Full access key and 401 `restricted_api_key` for a
39
+ Sending access key, so a key that cannot manage the account is refused before it is saved. A
40
+ malformed key gets 400, which core's generic probe reports as "unavailable", not "rejected"; the
41
+ skill tells the assistant what that usually means.
42
+ - **Two keys on purpose.** The site's mail adapter keeps its own Sending access key
43
+ (`Tovu Mail — Resend API`); the account tools use the separate Full access key. Site mail keeps
44
+ least privilege, and revoking one never breaks the other.
45
+ - **Default tools.** 62 of the 64 allowed. Left out: every `remove-*`, `batch-remove-suppressions`,
46
+ `send-broadcast`, API key create/update/remove, webhook create/update/rotate/replay, OAuth-grant
47
+ revoke, automations, events, the dashboard editor, contact imports and inboxes. Resend's tool names
48
+ are hyphenated and most carry no annotations, so the federation's per-call confirm classifier
49
+ (which splits on `_` and knows `delete`, not `remove`) would let removals and `send-broadcast` run
50
+ without a card; keeping them out of the defaults is the plugin-side guard until that is fixed.
51
+
52
+ - **0.2.1 (2026-10-25).** Version bump distributes the token-based account authentication declaration to already installed bundled packages.
@@ -0,0 +1,161 @@
1
+ // @ts-check
2
+ import { parseErrorBody } from '../lib/errors.mjs';
3
+ import { domainRequirements, domainStatus, retryAfterMs } from '../lib/domains.mjs';
4
+ import { feedback } from '../lib/feedback.mjs';
5
+
6
+ /**
7
+ * @file Resend mail adapter for the `resend` Agent Plugin.
8
+ *
9
+ * Moved out of core (`apps/website/src/platform/mail/adapters/http-api.resend.ts`) unchanged in
10
+ * behavior: same request, same error mapping, same batch strategy. Core now knows only the generic
11
+ * mail-adapter contract (`apps/website/src/platform/mail/adapter-module.ts`); it loads this file
12
+ * through its mail-adapter registry and hands it the guarded outbound-HTTP client, so every request
13
+ * still goes through the host's egress policy. Plain JS, no npm imports: an installed plugin has none.
14
+ *
15
+ * Why a hosted API is the recommended mailer: outbound SMTP (587/465) is blocked or throttled by most
16
+ * residential ISPs and cloud providers, while HTTPS always works, and a provider API gives warmed
17
+ * sending IPs plus bounce/complaint webhooks (the only thing that can ever fill the suppression ledger).
18
+ *
19
+ * `sendBatch` loops `send()` rather than calling Resend's `/emails/batch`: the mail port requires
20
+ * per-message results with partial failure allowed, and Resend's batch endpoint reports success or
21
+ * failure for the whole request only. `maxBatchSize` is `1` accordingly.
22
+ *
23
+ * The default export is a full Jini `MailProviderModule` (2026-10-21): id, credential keys,
24
+ * capabilities (Resend's documented 2 requests/second default), the Svix feedback parser
25
+ * (`../lib/feedback.mjs`) and sending-domain verification (`../lib/domains.mjs`). `create()` keeps its
26
+ * original call shape (`send(message, opts)`), which the host's mailer still uses.
27
+ *
28
+ * @typedef {{ email: string, name?: string }} EmailAddress
29
+ * @typedef {{ filename: string, contentType: string, contentBase64: string }} EmailAttachment
30
+ * @typedef {{ to: EmailAddress, from: EmailAddress, replyTo?: EmailAddress, subject: string, html?: string,
31
+ * text?: string, headers?: Readonly<Record<string, string>>, attachments?: readonly EmailAttachment[] }} OutboundEmail
32
+ * @typedef {{ idempotencyKey: string, timeoutMs?: number }} SendOptions
33
+ * @typedef {{ ok: true, providerMessageId: string, acceptedAt: string }
34
+ * | { ok: false, retryable: boolean, errorCode: string, message: string, retryAfterMs?: number }} SendResult
35
+ * @typedef {{ send(request: { method: string, url: string, headers: Record<string, string>, body?: string,
36
+ * timeoutMs: number }): Promise<{ status: number, bodyText: string, headers?: Readonly<Record<string, string>> }> }} HttpClient
37
+ * @typedef {{ credential: { token: string, baseUrl?: string }, kit: { httpClient: HttpClient } }} CreateContext
38
+ */
39
+
40
+ /** Resend's real API origin; a saved credential's `baseUrl` normally equals it. */
41
+ const DEFAULT_BASE_URL = "https://api.resend.com";
42
+
43
+ /** Bounds a hung request; a caller's own `timeoutMs` wins. */
44
+ const DEFAULT_TIMEOUT_MS = 10_000;
45
+
46
+ /** `EmailAddress` to Resend's `"Name <email>"` / bare-email string. @param {EmailAddress} address */
47
+ function formatAddress(address) {
48
+ return address.name ? `${address.name} <${address.email}>` : address.email;
49
+ }
50
+
51
+ /** Resend's attachment shape (snake_case, base64 in `content`). @param {EmailAttachment} attachment */
52
+ function toResendAttachment(attachment) {
53
+ return { filename: attachment.filename, content: attachment.contentBase64, content_type: attachment.contentType };
54
+ }
55
+
56
+ /** The JSON body of `POST /emails`. @param {OutboundEmail} message @returns {Record<string, unknown>} */
57
+ function toResendPayload(message) {
58
+ /** @type {Record<string, unknown>} */
59
+ const payload = { from: formatAddress(message.from), to: formatAddress(message.to), subject: message.subject };
60
+ if (message.replyTo) payload.reply_to = formatAddress(message.replyTo);
61
+ if (message.html !== undefined) payload.html = message.html;
62
+ if (message.text !== undefined) payload.text = message.text;
63
+ if (message.headers) payload.headers = message.headers;
64
+ if (message.attachments && message.attachments.length > 0) payload.attachments = message.attachments.map(toResendAttachment);
65
+ return payload;
66
+ }
67
+
68
+ /** Transient per Resend's error reference: rate limit, idempotency conflict, server outage. @param {number} status */
69
+ function isRetryableStatus(status) {
70
+ return status === 429 || status === 409 || status >= 500;
71
+ }
72
+
73
+ /** Shared mail contract's throttling code (`@jini-ai/platform/mail` `MAIL_RATE_LIMITED`); a literal,
74
+ * since an installed plugin has no npm imports. Callers defer it instead of failing the recipient. */
75
+ const MAIL_RATE_LIMITED = "MAIL_RATE_LIMITED";
76
+
77
+ /**
78
+ * Maps a non-2xx response to a send result. Never throws.
79
+ * A 429 carries Resend's `Retry-After` as `retryAfterMs` so the caller backs off for that long, and is
80
+ * normalized to {@link MAIL_RATE_LIMITED} (Resend's own name kept in the message) so it is deferred,
81
+ * not recorded as a failed recipient.
82
+ * @param {number} status @param {string} bodyText @param {Readonly<Record<string, string>>} [headers] @returns {SendResult}
83
+ */
84
+ function classifyError(status, bodyText, headers) {
85
+ const { errorCode, message } = parseErrorBody({ status, bodyText });
86
+ if (status !== 429) return { ok: false, retryable: isRetryableStatus(status), errorCode, message };
87
+ const wait = retryAfterMs(headers);
88
+ return { ok: false, retryable: true, errorCode: MAIL_RATE_LIMITED, message: `${message} (${errorCode})`, ...(wait === undefined ? {} : { retryAfterMs: wait }) };
89
+ }
90
+
91
+ /** Builds the mailer from a saved credential. @param {CreateContext} context */
92
+ function create({ credential, kit }) {
93
+ const baseUrl = credential.baseUrl || DEFAULT_BASE_URL;
94
+ const { httpClient } = kit;
95
+
96
+ /** @param {OutboundEmail} message @param {SendOptions} opts @returns {Promise<SendResult>} */
97
+ async function send(message, opts) {
98
+ let response;
99
+ try {
100
+ response = await httpClient.send({
101
+ method: "POST",
102
+ url: `${baseUrl}/emails`,
103
+ headers: {
104
+ Authorization: `Bearer ${credential.token}`,
105
+ "content-type": "application/json",
106
+ "Idempotency-Key": opts.idempotencyKey,
107
+ },
108
+ body: JSON.stringify(toResendPayload(message)),
109
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
110
+ });
111
+ } catch (err) {
112
+ // Network failure, timeout or an egress-policy refusal: retryable, and never thrown across
113
+ // the mail port.
114
+ return { ok: false, retryable: true, errorCode: "TRANSPORT_ERROR", message: err instanceof Error ? err.message : String(err) };
115
+ }
116
+ if (response.status < 200 || response.status >= 300) return classifyError(response.status, response.bodyText, response.headers);
117
+
118
+ let parsed;
119
+ try {
120
+ parsed = JSON.parse(response.bodyText);
121
+ } catch {
122
+ // HTTP acceptance still stands if a proxy strips or corrupts the optional response body.
123
+ // Keep the existing idempotency-key fallback so this batch can continue without resending.
124
+ }
125
+ const providerMessageId = typeof parsed?.id === "string" ? parsed.id : opts.idempotencyKey;
126
+ return { ok: true, providerMessageId, acceptedAt: new Date().toISOString() };
127
+ }
128
+
129
+ return {
130
+ capabilities() {
131
+ // Resend honours `Idempotency-Key` (24h window) and emits bounce/complaint webhooks.
132
+ return { driver: "resend", supportsIdempotencyKey: true, supportsWebhookFeedback: true, maxBatchSize: 1, supportsAttachments: true };
133
+ },
134
+ send,
135
+ /** @param {readonly OutboundEmail[]} messages @param {SendOptions} opts */
136
+ async sendBatch(messages, opts) {
137
+ /** @type {SendResult[]} */
138
+ const results = [];
139
+ for (const [index, message] of messages.entries()) {
140
+ // One key per message: reusing the batch key would make messages 2..n look like retries of
141
+ // message 1 to Resend. `:index` is deterministic, so a redelivery reproduces the same keys.
142
+ results.push(await send(message, { ...opts, idempotencyKey: `${opts.idempotencyKey}:${index}` }));
143
+ }
144
+ return results;
145
+ },
146
+ };
147
+ }
148
+
149
+ export default {
150
+ id: 'resend',
151
+ label: 'Resend',
152
+ // Sending access cannot read domains; the plugin's separate account key can.
153
+ domainReadConnection: { serverId: 'resend', baseUrl: DEFAULT_BASE_URL },
154
+ credentialKeys: ['token'],
155
+ webhookSecretKey: 'webhookSecret',
156
+ capabilities: { hosted: true, feedback: true, domainVerification: true, rateLimit: { perSecond: 2, burst: 2 } },
157
+ create,
158
+ feedback,
159
+ domainRequirements,
160
+ domainStatus,
161
+ };
@@ -0,0 +1,137 @@
1
+ // @ts-check
2
+ import { parseErrorBody } from './errors.mjs';
3
+
4
+ /**
5
+ * @file Resend sending-domain verification over the Domains API (Jini `MailProviderModule`
6
+ * `domainRequirements` / `domainStatus`). Never throws: every outcome is a `ProviderResult`.
7
+ *
8
+ * `GET /domains` lists the account's domains (no records); `GET /domains/{id}` carries the DNS
9
+ * records Resend needs: SPF as an MX and a TXT on the `send` subdomain, DKIM as a TXT. Record names
10
+ * come back relative to the domain and are qualified here, because the DNS port takes full names.
11
+ * Requests go through the host's guarded client (`ctx.httpClient`; see `getJson` for its shape).
12
+ *
13
+ * @typedef {{ status: 'ok', value: any } | { status: 'rejected' | 'unknown', error: { code: string, message: string, retryAfterMs?: number } }} ProviderResult
14
+ * @typedef {{ method: string, url: string, headers: Record<string, string>, timeoutMs: number }} HttpRequest
15
+ * @typedef {{ status: number, bodyText: string, headers?: Readonly<Record<string, string>> }} HttpResponse
16
+ * @typedef {{ credential: { token: string, baseUrl?: string }, httpClient: { send(required: { request: HttpRequest }): Promise<HttpResponse> } }} CallContext
17
+ */
18
+
19
+ export const DEFAULT_BASE_URL = 'https://api.resend.com';
20
+ const TIMEOUT_MS = 10_000;
21
+ const DNS_TYPES = new Set(['TXT', 'MX', 'CNAME']);
22
+ /** @type {Readonly<Record<number, string>>} */
23
+ const REJECTED_CODES = { 401: 'unauthorized', 403: 'forbidden', 404: 'not_found' };
24
+
25
+ /** `Retry-After` (seconds) in ms, when present and sane. @param {Readonly<Record<string, string>> | undefined} headers */
26
+ export function retryAfterMs(headers) {
27
+ const raw = headers?.['retry-after'] ?? headers?.['Retry-After'] ?? '';
28
+ const seconds = raw.trim() === '' ? Number.NaN : Number(raw);
29
+ return Number.isFinite(seconds) && seconds >= 0 ? Math.ceil(seconds * 1000) : undefined;
30
+ }
31
+
32
+ /** Legacy fallback when Resend supplies no structured error body. @param {{status: number}} required */
33
+ function domainFailureFallback({ status }) {
34
+ if (status === 429) return { errorCode: 'rate_limited', message: 'Resend rate limit reached' };
35
+ if (status >= 500) return { errorCode: 'provider_unavailable', message: `Resend responded with HTTP ${status}` };
36
+ return { errorCode: REJECTED_CODES[status] ?? 'provider_error', message: `Resend refused the request (HTTP ${status})` };
37
+ }
38
+
39
+ /** HTTP status to a rejected/unknown result. The code stays Jini's canonical one for the status;
40
+ * Resend's own error name and message ride in `message` so the owner sees what Resend said.
41
+ * A 401 `restricted_api_key` is a valid Sending access key without domain permission. The host
42
+ * retries only permission refusals with the account key, so it must be `forbidden`, not the
43
+ * `unauthorized` a bad key gets. @param {HttpResponse} response @returns {ProviderResult} */
44
+ function failure(response) {
45
+ const { status } = response;
46
+ const fallback = domainFailureFallback({ status });
47
+ const parsed = parseErrorBody({ status, bodyText: response.bodyText }, { fallback });
48
+ const restricted = status === 401 && parsed.errorCode === 'restricted_api_key';
49
+ const named = parsed.errorCode !== fallback.errorCode;
50
+ const detail = { code: restricted ? 'forbidden' : fallback.errorCode, message: named ? `${parsed.errorCode}: ${parsed.message}` : parsed.message };
51
+ if (status === 429) {
52
+ const wait = retryAfterMs(response.headers);
53
+ return { status: 'rejected', error: { ...detail, ...(wait === undefined ? {} : { retryAfterMs: wait }) } };
54
+ }
55
+ if (status >= 500) return { status: 'unknown', error: { ...detail } };
56
+ return { status: 'rejected', error: detail };
57
+ }
58
+
59
+ /** One GET, parsed. @param {CallContext} ctx @param {string} path @returns {Promise<ProviderResult>} */
60
+ async function getJson(ctx, path) {
61
+ const baseUrl = ctx.credential.baseUrl || DEFAULT_BASE_URL;
62
+ let response;
63
+ try {
64
+ const request = { method: 'GET', url: `${baseUrl}${path}`, headers: { Authorization: `Bearer ${ctx.credential.token}` }, timeoutMs: TIMEOUT_MS };
65
+ // Both client shapes: Jini's `send({ request })` reads `.request`, the host's positional
66
+ // `send(request)` reads the fields. The provider contract names Jini's; `create()` has always been
67
+ // handed the host's positional shape.
68
+ response = await ctx.httpClient.send({ request, ...request });
69
+ } catch (err) {
70
+ return { status: 'unknown', error: { code: 'network', message: err instanceof Error ? err.message : String(err) } };
71
+ }
72
+ if (response.status < 200 || response.status >= 300) return failure(response);
73
+ try {
74
+ return { status: 'ok', value: JSON.parse(response.bodyText) };
75
+ } catch {
76
+ return { status: 'unknown', error: { code: 'unexpected_response', message: 'Resend returned a body that is not JSON' } };
77
+ }
78
+ }
79
+
80
+ /** The account's domain named `domain` (case-insensitive), or null. @param {CallContext} ctx @param {string} domain */
81
+ async function findDomain(ctx, domain) {
82
+ const listed = await getJson(ctx, '/domains');
83
+ if (listed.status !== 'ok') return listed;
84
+ const rows = Array.isArray(listed.value?.data) ? listed.value.data : [];
85
+ const wanted = domain.toLowerCase();
86
+ return { status: 'ok', value: rows.find((/** @type {any} */ row) => String(row?.name ?? '').toLowerCase() === wanted) ?? null };
87
+ }
88
+
89
+ /** Resend's domain status to the contract's four states. @param {unknown} status */
90
+ export function domainState(status) {
91
+ if (status === 'verified') return 'verified';
92
+ return status === 'failed' ? 'failed' : 'pending';
93
+ }
94
+
95
+ /** A relative record name under `domain` as a full name. @param {string} name @param {string} domain */
96
+ export function qualify(name, domain) {
97
+ const lower = name.toLowerCase();
98
+ if (lower === '' || lower === '@' || lower === domain) return domain;
99
+ return lower.endsWith(`.${domain}`) ? lower : `${lower}.${domain}`;
100
+ }
101
+
102
+ /** @param {any} record @param {string} domain */
103
+ function toRequirement(record, domain) {
104
+ return {
105
+ type: record.type, name: qualify(String(record.name ?? ''), domain), value: String(record.value ?? ''),
106
+ ...(typeof record.ttl === 'number' ? { ttl: record.ttl } : {}),
107
+ ...(typeof record.priority === 'number' ? { priority: record.priority } : {}),
108
+ note: `${record.record ?? 'Resend'} record`,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * @param {CallContext} ctx @param {{ domain: string }} required
114
+ * @returns {Promise<ProviderResult>}
115
+ */
116
+ export async function domainRequirements(ctx, { domain }) {
117
+ const name = domain.toLowerCase();
118
+ const found = await findDomain(ctx, name);
119
+ if (found.status !== 'ok') return found;
120
+ if (found.value === null) return { status: 'rejected', error: { code: 'not_found', message: `Add ${name} under Domains in Resend first` } };
121
+ const detail = await getJson(ctx, `/domains/${encodeURIComponent(String(found.value.id))}`);
122
+ if (detail.status !== 'ok') return detail;
123
+ const records = (Array.isArray(detail.value?.records) ? detail.value.records : [])
124
+ .filter((/** @type {any} */ record) => DNS_TYPES.has(record?.type)).map((/** @type {any} */ record) => toRequirement(record, name));
125
+ return { status: 'ok', value: { purpose: 'mail', domain: name, providerId: 'resend', records } };
126
+ }
127
+
128
+ /**
129
+ * @param {CallContext} ctx @param {{ domain: string }} required
130
+ * @returns {Promise<ProviderResult>}
131
+ */
132
+ export async function domainStatus(ctx, { domain }) {
133
+ const name = domain.toLowerCase();
134
+ const found = await findDomain(ctx, name);
135
+ if (found.status !== 'ok') return found;
136
+ return { status: 'ok', value: { domain: name, state: found.value === null ? 'not_found' : domainState(found.value.status) } };
137
+ }