@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,202 @@
1
+ /**
2
+ * @file Form destination `google-sheets` (push only): appends each accepted form submission as one
3
+ * row of the tab its binding names, through the same Google client the Sheets tools use.
4
+ *
5
+ * Exists to turn one frozen delivery job (form, submission, binding config, all fixed when the
6
+ * submission was planned) into at most one spreadsheet row, and to report the outcome in the forms
7
+ * delivery contract's vocabulary. Per attempt it: refuses while the Sheets product is off; finds
8
+ * the tab by its numeric sheet id (renames are safe); matches columns by their field markers and
9
+ * headers (`mapping.mjs`); scans the `_tovu_submission_id` column for this submission
10
+ * (`dedupe.mjs`) and stops as delivered when the row is already there (more than one such row is
11
+ * reported as delivered, never appended again; its "review" note is dropped by the ledger); in one atomic batch grows the
12
+ * grid, writes the new headers and tags new field columns; appends the row with RAW values. An append whose answer was lost is never replayed: the column is scanned again,
13
+ * and when the row is not found the job reports `outcome-unknown`, so the next attempt scans before
14
+ * appending. Delivery is at-least-once with best-effort dedupe; nothing here claims exactly-once.
15
+ *
16
+ * Binding config (non-secret, frozen per job):
17
+ * `{ spreadsheetId: string, tabs: { [formSlug]: { sheetId: number } } }`. Which column holds which
18
+ * field lives in the sheet itself (column markers), not in the binding.
19
+ */
20
+ import { columnName, parseA1Range, tabRange } from '../lib/sheets-a1.mjs';
21
+ import { googleClientFor, sheetsUrl } from '../lib/sheets-client.mjs';
22
+ import { isProductEnabled } from '../lib/product-state.mjs';
23
+ import { GoogleWorkspaceError } from '../lib/request-policy.mjs';
24
+ import { findSubmissionRows } from './dedupe.mjs';
25
+ import { DeliveryRefusal, failureResult, resultForError } from './delivery-result.mjs';
26
+ import { FIELD_MARKER_KEY, buildMapping, buildRow, planColumns, readMarkers } from './mapping.mjs';
27
+
28
+ /** Per-request cap passed to the host kit; a request that runs past it counts as a lost answer. */
29
+ export const DELIVERY_REQUEST_TIMEOUT_MS = 15_000;
30
+ const TAB_FIELDS = 'sheets.properties(sheetId,title,gridProperties(rowCount,columnCount))';
31
+
32
+ const configurationInvalid = () => new DeliveryRefusal({ code: 'configuration-invalid', retryable: false });
33
+
34
+ /**
35
+ * The spreadsheet and tab this form's rows go to, from the frozen binding config.
36
+ * @throws {DeliveryRefusal} `configuration-invalid` when the config does not name them properly.
37
+ * @complexity O(1).
38
+ */
39
+ function targetFor({ binding, form }) {
40
+ const { spreadsheetId, tabs } = binding.config ?? {};
41
+ const tab = tabs && Object.hasOwn(tabs, form.slug) ? tabs[form.slug] : undefined;
42
+ if (typeof spreadsheetId !== 'string' || !Number.isInteger(tab?.sheetId) || tab.sheetId < 0) throw configurationInvalid();
43
+ return { spreadsheetId, tab };
44
+ }
45
+
46
+ const a1 = (title, cells) => tabRange({ title, cells });
47
+
48
+ /**
49
+ * The Sheets requests one delivery makes, over the shared Google client.
50
+ * @complexity O(1) to build; each call is one request (plus the client's retries).
51
+ */
52
+ function sheetsRequests({ client, connectionRef, spreadsheetId }) {
53
+ const send = (request) => client.request({ connectionRef, ...request });
54
+ const valuesUrl = (range, method) => sheetsUrl({ spreadsheetId }, { segments: ['values', range], ...(method ? { method } : {}) });
55
+ return {
56
+ async tab(sheetId) {
57
+ const body = await send({ kind: 'read', url: sheetsUrl({ spreadsheetId }), query: { fields: TAB_FIELDS } });
58
+ const properties = (body?.sheets ?? []).map((sheet) => sheet?.properties).find((entry) => entry?.sheetId === sheetId);
59
+ // The tab was deleted (or never existed): the person picks the tab again.
60
+ if (typeof properties?.title !== 'string') throw configurationInvalid();
61
+ const grid = properties.gridProperties ?? {};
62
+ return { sheetId, title: properties.title, rowCount: grid.rowCount ?? 0, columnCount: grid.columnCount ?? 0 };
63
+ },
64
+ async rows(range) {
65
+ return (await send({ kind: 'read', url: valuesUrl(range), query: { majorDimension: 'ROWS' } }))?.values ?? [];
66
+ },
67
+ async markers(sheetId) {
68
+ const lookup = { metadataKey: FIELD_MARKER_KEY, locationType: 'COLUMN', metadataLocation: { sheetId }, locationMatchingStrategy: 'INTERSECTING_LOCATION' };
69
+ // A search changes nothing: `read` lets the client retry it.
70
+ const body = await send({ kind: 'read', method: 'POST', url: sheetsUrl({ spreadsheetId }, { segments: ['developerMetadata'], method: ':search' }), body: { dataFilters: [{ developerMetadataLookup: lookup }] } });
71
+ return readMarkers({ matches: Array.isArray(body?.matchedDeveloperMetadata) ? body.matchedDeveloperMetadata : [], sheetId });
72
+ },
73
+ async batch(changes) {
74
+ // Adds columns and metadata, so a replay could apply twice: a `write`, never replayed when its outcome is unknown.
75
+ await send({ kind: 'write', method: 'POST', url: sheetsUrl({ spreadsheetId }, { method: ':batchUpdate' }), body: { requests: changes } });
76
+ },
77
+ async append(range, row) {
78
+ const body = await send({ kind: 'write', method: 'POST', url: valuesUrl(range, ':append'), query: { valueInputOption: 'RAW', insertDataOption: 'INSERT_ROWS' }, body: { range, majorDimension: 'ROWS', values: [row] } });
79
+ return body?.updates?.updatedRange;
80
+ },
81
+ };
82
+ }
83
+
84
+ /** The rows of `tab` holding the submission id, in the id column at `idColumn`. @complexity see `findSubmissionRows`. */
85
+ function scanFor(requests, tab, idColumn, submissionId) {
86
+ const column = columnName({ index: idColumn });
87
+ return findSubmissionRows({ rowCount: tab.rowCount, submissionId, readColumn: ({ from, to }) => requests.rows(a1(tab.title, `${column}${from}:${column}${to}`)) });
88
+ }
89
+
90
+ /**
91
+ * Success, naming where the row is (sheet id and row numbers only: no titles, no values). More than
92
+ * one row with this submission id (another writer, or a late append) is still delivered, never
93
+ * appended again. The reference names the rows for review, but the forms ledger keeps only `ok`
94
+ * (the contract has no reviewable success), so today no person sees it; see the README.
95
+ * @complexity O(rows).
96
+ */
97
+ function delivered(sheetId, rows) {
98
+ if (rows.length === 0) return { ok: true, reference: `sheet ${sheetId}` };
99
+ if (rows.length === 1) return { ok: true, reference: `sheet ${sheetId} row ${rows[0]}` };
100
+ return { ok: true, reference: `sheet ${sheetId} rows ${rows.join(', ')} (duplicate submission id: review)` };
101
+ }
102
+
103
+ /** `updateCells` writing the new headers into row 1 as text (a header can never become a formula). @complexity O(headers). */
104
+ function headerCells(sheetId, newHeaders) {
105
+ const values = newHeaders.map(({ header }) => ({ userEnteredValue: { stringValue: header } }));
106
+ return { updateCells: { start: { sheetId, rowIndex: 0, columnIndex: newHeaders[0].index }, rows: [{ values }], fields: 'userEnteredValue' } };
107
+ }
108
+
109
+ /** `createDeveloperMetadata` tagging one column with the field id it holds. @complexity O(1). */
110
+ function markerRequest(sheetId, { field, index }) {
111
+ const location = { dimensionRange: { sheetId, dimension: 'COLUMNS', startIndex: index, endIndex: index + 1 } };
112
+ return { createDeveloperMetadata: { developerMetadata: { metadataKey: FIELD_MARKER_KEY, metadataValue: field, visibility: 'DOCUMENT', location } } };
113
+ }
114
+
115
+ /**
116
+ * Before the row that needs them: grows the grid, writes new headers and tags untagged field
117
+ * columns, in ONE atomic batch, so a half-prepared tab can never be left behind.
118
+ * @complexity O(new headers + untagged columns).
119
+ */
120
+ async function prepareColumns(requests, tab, layout) {
121
+ const changes = [
122
+ ...(layout.width > tab.columnCount ? [{ appendDimension: { sheetId: tab.sheetId, dimension: 'COLUMNS', length: layout.width - tab.columnCount } }] : []),
123
+ ...(layout.newHeaders.length > 0 ? [headerCells(tab.sheetId, layout.newHeaders)] : []),
124
+ ...layout.untagged.map((column) => markerRequest(tab.sheetId, column)),
125
+ ];
126
+ if (changes.length > 0) await requests.batch(changes);
127
+ }
128
+
129
+ /**
130
+ * After an append whose answer was lost: scan again (the tab may have grown). Found → delivered.
131
+ * Not found, or the scan itself failed → `outcome-unknown`, retryable: the next attempt scans
132
+ * before it appends, which also gives a late append time to land.
133
+ * @complexity see `findSubmissionRows`.
134
+ */
135
+ async function confirmUncertainAppend(requests, sheetId, idColumn, submissionId) {
136
+ try {
137
+ const rows = await scanFor(requests, await requests.tab(sheetId), idColumn, submissionId);
138
+ if (rows.length > 0) return delivered(sheetId, rows);
139
+ } catch {
140
+ // A failed re-scan proves nothing either way; the outcome stays unknown.
141
+ }
142
+ return failureResult({ code: 'outcome-unknown', retryable: true, outcome: 'unknown' });
143
+ }
144
+
145
+ const isUncertain = (error) => error instanceof GoogleWorkspaceError && error.code === 'uncertain-outcome';
146
+
147
+ /** Appends the row once; a lost answer is re-checked, never replayed. @complexity O(1) requests plus a re-scan. */
148
+ async function appendOnce(requests, tab, layout, row, submissionId) {
149
+ try {
150
+ const updatedRange = await requests.append(a1(tab.title, 'A1'), row);
151
+ const startRow = parseA1Range({ range: updatedRange })?.startRow;
152
+ return delivered(tab.sheetId, startRow === undefined ? [] : [startRow]);
153
+ } catch (error) {
154
+ if (!isUncertain(error)) throw error;
155
+ return confirmUncertainAppend(requests, tab.sheetId, layout.columns[0], submissionId);
156
+ }
157
+ }
158
+
159
+ /** The product this destination belongs to (`products.json`). */
160
+ const SHEETS_PRODUCT = 'sheets';
161
+
162
+ /** One delivery attempt; throws for every failure (mapped by the caller). @complexity O(rowCount / chunk) requests. */
163
+ async function deliverRow(required, optional) {
164
+ // Retryable, but not a pause: the forms retry policy re-tries it (about 8 attempts over about a
165
+ // day) and then marks the job failed.
166
+ // Off unless the plugin's own settings say Sheets is on: no state reader never enables delivery.
167
+ if (!(await isProductEnabled({ state: optional.state, productId: SHEETS_PRODUCT }))) throw new DeliveryRefusal({ code: 'adapter-unavailable', retryable: true });
168
+ const { spreadsheetId, tab: tabConfig } = targetFor(required);
169
+ // One token read per Google request when the host offers it (OAuth: budgets count every request
170
+ // and the job's frozen account is re-checked); a plain credential carries just `token`.
171
+ const { credential } = required;
172
+ const connections = { getAccessToken: () => (credential.getAccessToken ? credential.getAccessToken() : Promise.resolve(credential.token)) };
173
+ const client = googleClientFor({ capabilities: { http: { fetch: ({ url }, init) => required.kit.fetch(url, init, { timeoutMs: DELIVERY_REQUEST_TIMEOUT_MS }) }, connections } });
174
+ const requests = sheetsRequests({ client, connectionRef: required.binding.connectionRef, spreadsheetId });
175
+ const tab = await requests.tab(tabConfig.sheetId);
176
+ const mapping = buildMapping({ form: required.form });
177
+ const headerRow = (await requests.rows(a1(tab.title, '1:1')))[0] ?? [];
178
+ const layout = planColumns({ mapping, headerRow, markers: await requests.markers(tab.sheetId) });
179
+ const existing = await scanFor(requests, tab, layout.columns[0], required.submission.id);
180
+ if (existing.length > 0) return delivered(tab.sheetId, existing);
181
+ await prepareColumns(requests, tab, layout);
182
+ return appendOnce(requests, tab, layout, buildRow({ mapping, ...layout, submission: required.submission }), required.submission.id);
183
+ }
184
+
185
+ export default {
186
+ /**
187
+ * The forms delivery contract's `deliver`. Never throws: every failure is a contract result.
188
+ * @param {{ kit: { fetch(url: string, init: RequestInit, options: { timeoutMs: number }): Promise<Response> }, credential: { token: string, getAccessToken?: () => Promise<string> }, form: { slug: string, fields: readonly { name: string, label?: string }[] }, submission: { id: string, answers: Readonly<Record<string, unknown>>, submittedAt: string }, binding: { connectionRef: string, config: Readonly<Record<string, unknown>> }, idempotencyKey?: string }} required
189
+ * @param {{ state?: { get(input: { key: string }): Promise<unknown> } }} [optional]
190
+ * `state`: the host's read-only reader of this plugin's settings, the same store and gate the
191
+ * tools use (`isProductEnabled`); absent = Sheets off. Quotas are the host's budgets, charged
192
+ * on every token read (`credential.getAccessToken`).
193
+ * @returns {Promise<{ ok: true, reference?: string } | { ok: false, error: { code: string, retryable: boolean, retryAfterMs?: number, outcome?: 'not-applied' | 'unknown' } }>}
194
+ */
195
+ async deliver(required, optional = {}) {
196
+ try {
197
+ return await deliverRow(required, optional);
198
+ } catch (error) {
199
+ return resultForError({ error });
200
+ }
201
+ },
202
+ };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @file The Google Workspace sign-in hooks (the `lib/auth.mjs` module `tovu-connections.json`
3
+ * names): which scopes a sign-in requests, who signed in, and how a grant is revoked at Google.
4
+ *
5
+ * Exists because the host's OAuth engine is provider-neutral: Google's endpoints live in
6
+ * `tovu-connections.json`, and everything Google-specific the engine must call back into lives
7
+ * here. The engine handles state, PKCE, the code exchange, refresh and token storage.
8
+ *
9
+ * Revocation honesty: `revoke` resolves only when Google answered 200, or answered `invalid_token`
10
+ * (the token is already revoked: a retry after a lost answer resends a token Google already
11
+ * revoked, and the host's engine requires that to count as success). Both clients request offline
12
+ * access, so the engine hands over the refresh token, whose `invalid_token` means the grant is gone;
13
+ * only a credential stored without a refresh token could see it for a merely expired access token.
14
+ * Anything else throws, so the host shows "Disconnected locally; Google revocation pending", keeps
15
+ * the disabled sealed credential for a retry, and the person can remove access at
16
+ * {@link GOOGLE_ACCOUNT_ACCESS_PAGE}. The thrown error's `code` is the host engine's classification:
17
+ * `provider-unreachable` (no answer: network failure, timeout, 5xx) or `provider-refused` (an
18
+ * answer that was not a confirmation); its message is a fixed sentence, never Google's text.
19
+ */
20
+ import { readAccount } from './drive-files.mjs';
21
+ import { createGoogleClient } from './http.mjs';
22
+ import { scopesFor } from './product-state.mjs';
23
+
24
+ export const GOOGLE_REVOCATION_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
25
+ /** Where a person removes this site's access themselves ("Third-party apps & services"). */
26
+ export const GOOGLE_ACCOUNT_ACCESS_PAGE = 'https://myaccount.google.com/connections';
27
+
28
+ const REVOCATION_PENDING = 'Google did not confirm the revocation. Disconnected locally; Google revocation pending. Retry, or remove access at https://myaccount.google.com/connections.';
29
+
30
+ /** The revoke hook's failure: the engine records only `code`, and the message is fixed. @complexity O(1). */
31
+ function revocationFailure(code, cause) {
32
+ return Object.assign(new Error(REVOCATION_PENDING, cause === undefined ? undefined : { cause }), { code });
33
+ }
34
+
35
+ /**
36
+ * Asks Google to revoke the grant behind `token` (the refresh token when there is one, which ends
37
+ * the whole grant). Throws unless Google confirmed, or said the token is already revoked.
38
+ * @throws {Error} `code: 'provider-unreachable'` (no answer, timeout, 5xx) or `'provider-refused'`.
39
+ * @complexity O(1): one request.
40
+ */
41
+ async function revokeAtGoogle(http, token) {
42
+ let response;
43
+ try {
44
+ response = await http.fetch({ url: GOOGLE_REVOCATION_ENDPOINT }, {
45
+ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token }).toString(),
46
+ });
47
+ } catch (cause) {
48
+ throw revocationFailure('provider-unreachable', cause);
49
+ }
50
+ if (response.status === 200 || (await alreadyRevoked(response))) return;
51
+ // A 5xx is Google failing to answer, not Google refusing: a retry may well confirm.
52
+ throw revocationFailure(response.status >= 500 ? 'provider-unreachable' : 'provider-refused');
53
+ }
54
+
55
+ /** Google's "this token is already revoked" answer (400 `invalid_token`); an unreadable body is not it. */
56
+ async function alreadyRevoked(response) {
57
+ if (response.status !== 400) return false;
58
+ const body = await response.json().catch(() => null);
59
+ return body?.error === 'invalid_token';
60
+ }
61
+
62
+ /**
63
+ * The hooks the host's API-connection service calls (`ApiConnectionHooks`).
64
+ * @param {{ http: { fetch(target: { url: string }, init?: RequestInit): Promise<Response> } }} required
65
+ * The host's guarded HTTP capability (the same one tools get).
66
+ * @complexity O(1) to build; each hook is at most one request.
67
+ */
68
+ export function createHooks(required, _optional = {}) {
69
+ const { http } = required;
70
+ return {
71
+ async resolveScopes({ scopeSetIds }) { return scopesFor({ productIds: scopeSetIds }); },
72
+ async readAccount({ accessToken }) {
73
+ // A freshly issued token, not a stored connection yet: the client asks for no budget.
74
+ const client = createGoogleClient({ http, connections: { getAccessToken: async () => accessToken } });
75
+ return readAccount({ client, connectionRef: 'sign-in' });
76
+ },
77
+ async revoke({ token }) { await revokeAtGoogle(http, token); },
78
+ };
79
+ }
80
+
81
+ export default { createHooks };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @file Drive helpers shared by every Google Workspace product: who the connection is signed in as
3
+ * and whole-file copy (`files.copy`, used by `sheets_copy`). File metadata reads (`files.get`) stay
4
+ * with their callers (Picker verification, `sheets_open_existing`) and share `DRIVE_FILES_URL` here.
5
+ *
6
+ * Exists so identity costs no extra permission: Drive `about.get` answers under `drive.file`, so
7
+ * the sign-in never adds `openid`, `email` or `profile` scopes just to learn the account.
8
+ * `permissionId` is the account's stable Drive id; the email is only for display.
9
+ */
10
+ import { pathSegment } from './http.mjs';
11
+ import { GoogleWorkspaceError } from './request-policy.mjs';
12
+
13
+ export const DRIVE_ABOUT_URL = 'https://www.googleapis.com/drive/v3/about';
14
+ export const DRIVE_FILES_URL = 'https://www.googleapis.com/drive/v3/files';
15
+ const COPY_FIELDS = 'id,name,mimeType,webViewLink';
16
+
17
+ /**
18
+ * The signed-in account: `{ accountId, displayName }`.
19
+ * @param {{ client: { request(input: object, options?: object): Promise<unknown> }, connectionRef: string }} required
20
+ * @param {{ signal?: AbortSignal }} [optional]
21
+ * @throws {GoogleWorkspaceError} `unexpected-response` when Google's answer names no account.
22
+ * @complexity O(1): one request.
23
+ */
24
+ export async function readAccount(required, optional = {}) {
25
+ const body = await required.client.request({
26
+ connectionRef: required.connectionRef, url: DRIVE_ABOUT_URL, kind: 'read',
27
+ query: { fields: 'user(displayName,emailAddress,permissionId)' },
28
+ }, optional);
29
+ const user = body?.user;
30
+ if (typeof user?.permissionId !== 'string' || user.permissionId === '') throw new GoogleWorkspaceError({ code: 'unexpected-response' });
31
+ const label = [user.emailAddress, user.displayName].find((value) => typeof value === 'string' && value !== '');
32
+ return { accountId: user.permissionId, displayName: label ?? 'Google account' };
33
+ }
34
+
35
+ /**
36
+ * Copies one file the connection can open (`files.copy`); the copy is created by the site, so
37
+ * `drive.file` grants it automatically. A copy is not idempotent: an unknown outcome is
38
+ * `uncertain-outcome` and is never replayed (the shared client's `write` kind).
39
+ * @param {{ client: { request(input: object, options?: object): Promise<unknown> }, connectionRef: string, fileId: string, name: string }} required
40
+ * @param {{ parentId?: string, signal?: AbortSignal }} [optional] `parentId`: a folder the connection can open.
41
+ * @returns {Promise<{ fileId: string, name: string, mimeType: string, url: string | undefined }>}
42
+ * @throws {GoogleWorkspaceError} `unexpected-response` when Google's answer names no file id.
43
+ * @complexity O(1): one request.
44
+ */
45
+ export async function copyFile(required, optional = {}) {
46
+ const body = await required.client.request({
47
+ connectionRef: required.connectionRef, kind: 'write', method: 'POST',
48
+ url: `${DRIVE_FILES_URL}/${pathSegment({ value: required.fileId })}/copy`,
49
+ query: { fields: COPY_FIELDS, supportsAllDrives: 'true' },
50
+ body: { name: required.name, ...(optional.parentId === undefined ? {} : { parents: [optional.parentId] }) },
51
+ }, optional.signal ? { signal: optional.signal } : {});
52
+ if (typeof body?.id !== 'string' || body.id === '') throw new GoogleWorkspaceError({ code: 'unexpected-response', outcome: 'unknown' });
53
+ return { fileId: body.id, name: body.name, mimeType: body.mimeType, url: body.webViewLink };
54
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @file The form destinations the destination panel saves: one host form-delivery binding per form
3
+ * (by its short name), for this plugin's Sheets adapter over its own connection, through the host's
4
+ * `deliveryBindings` capability. The binding config is what the Sheets destination reads
5
+ * (`{ spreadsheetId, accountId, tabs: { <formSlug>: { sheetId } } }`, see
6
+ * `form-destinations/README.md`) plus the spreadsheet and tab names the panels show.
7
+ *
8
+ * Exists so a saved choice IS the delivery binding: there is no second copy to keep in step, and the
9
+ * connection panel's account-switch screen lists exactly what delivery uses. Values are metadata
10
+ * only, never a token or a cell.
11
+ */
12
+ import { CONNECTION_ID } from './surface-actions.mjs';
13
+
14
+ /** This plugin's form-destination adapter id (`tovu-form-destinations.json`). */
15
+ export const SHEETS_ADAPTER_ID = 'google-sheets';
16
+ export const MAX_FORM_DESTINATIONS = 50;
17
+
18
+ /** A form's short name: also a property name in the binding config, so no `__proto__`-style names. */
19
+ export const FORM_SLUG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
20
+
21
+ /** The one form a binding saved here is for, or null for a binding this panel did not write. @complexity O(1). */
22
+ function formSlugOf(binding) {
23
+ const slug = binding.formIds?.length === 1 ? binding.formIds[0] : null;
24
+ return slug !== null && FORM_SLUG_PATTERN.test(slug) ? slug : null;
25
+ }
26
+
27
+ /** The saved destination a binding holds, or null when its config is not in the shape `saveFormDestination` writes. @complexity O(1). */
28
+ function destinationOf(binding, formSlug) {
29
+ const { spreadsheetId, accountId, tabs, title, tabTitle } = binding.config;
30
+ const sheetId = tabs?.[formSlug]?.sheetId;
31
+ const validIdentity = typeof spreadsheetId === 'string' && typeof accountId === 'string';
32
+ const valid = validIdentity && Number.isInteger(sheetId) && typeof title === 'string' && typeof tabTitle === 'string';
33
+ return valid ? { bindingId: binding.bindingId, enabled: binding.enabled, spreadsheetId, title, url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}/edit`, sheetId, tabTitle, accountId } : null;
34
+ }
35
+
36
+ /**
37
+ * The saved destinations, keyed by form slug: this plugin's Sheets bindings that name one form;
38
+ * any other binding of the adapter is left out (and left alone).
39
+ * @param {{ deliveryBindings: { list(): Promise<readonly { bindingId: string, enabled: boolean, adapterId: string, formIds?: readonly string[], config: Record<string, unknown> }[]> } }} required
40
+ * @returns {Promise<Record<string, { bindingId: string, enabled: boolean, spreadsheetId: string, title: string, url: string, sheetId: number, tabTitle: string, accountId: string }>>}
41
+ * @complexity O(b), one host read.
42
+ */
43
+ export async function readFormDestinations(required, _optional = {}) {
44
+ const entries = (await required.deliveryBindings.list()).flatMap((binding) => {
45
+ const formSlug = binding.adapterId === SHEETS_ADAPTER_ID ? formSlugOf(binding) : null;
46
+ const destination = formSlug === null ? null : destinationOf(binding, formSlug);
47
+ return destination === null ? [] : [[formSlug, destination]];
48
+ });
49
+ return Object.fromEntries(entries);
50
+ }
51
+
52
+ /**
53
+ * Saves one form's destination as its delivery binding (a new one, or the form's existing one).
54
+ * `accountId` is the account the spreadsheet was verified with: the host freezes it into every
55
+ * delivery job and refuses a job once the connection holds another account (an account switch never
56
+ * silently moves where rows go).
57
+ * @param {{ deliveryBindings: { save(draft: object): Promise<object> }, formSlug: string, destination: { spreadsheetId: string, title: string, sheetId: number, tabTitle: string, accountId: string } }} required
58
+ * @param {{ bindingId?: string, enabled?: boolean }} [optional] The existing binding id and enabled
59
+ * state for reassignment; normal destination saves enable delivery by default.
60
+ * @complexity O(1), one host write.
61
+ */
62
+ export async function saveFormDestination(required, optional = {}) {
63
+ const { formSlug, destination } = required;
64
+ await required.deliveryBindings.save({
65
+ ...(optional.bindingId === undefined ? {} : { bindingId: optional.bindingId }),
66
+ adapterId: SHEETS_ADAPTER_ID, connectionId: CONNECTION_ID, enabled: optional.enabled ?? true, formIds: [formSlug],
67
+ config: {
68
+ spreadsheetId: destination.spreadsheetId, accountId: destination.accountId, tabs: { [formSlug]: { sheetId: destination.sheetId } },
69
+ title: destination.title, tabTitle: destination.tabTitle,
70
+ },
71
+ });
72
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * @file The one Google HTTP client of the Google Workspace plugin: every Sheets and Drive call (chat
3
+ * tools, admin actions, form delivery) goes through `createGoogleClient(...).request(...)`.
4
+ *
5
+ * Exists so the rules that keep a person's data and sign-in safe live in one place:
6
+ * - the access token is fetched from the host immediately before each attempt and is never
7
+ * returned, logged or put in an error;
8
+ * - quotas are the host's: it charges the budgets the connection declares (per account and per
9
+ * instance) on every token read, so this client keeps no limiter of its own; a budget refusal
10
+ * is a `quota-delay` like Google's 429;
11
+ * - a failure becomes a safe {@link GoogleWorkspaceError} (code, `retryable`, `retryAfterMs`,
12
+ * `outcome`, next action); Google's message text is dropped because it can echo sheet contents;
13
+ * - only retryable failures are retried, with jittered backoff that honours Retry-After, and a
14
+ * non-idempotent write whose outcome is unknown is never replayed;
15
+ * - a token can expire between the host's freshness check and Google's: a 401 gets one more token
16
+ * read, and is retried only if that token differs; an unchanged token or second 401 requires reconnect.
17
+ * Redirects, HTTPS-only and response-size bounds are enforced by the host's guarded HTTP capability.
18
+ */
19
+ import { GoogleWorkspaceError, backoffMs, capRetryAfterMs, classifyFailure, shouldRetryInline } from './request-policy.mjs';
20
+
21
+ const INVALID_JSON = Symbol('invalid-json');
22
+
23
+ /** Host connection refusals (Jini api-connections codes) → the plugin's safe codes. */
24
+ const HOST_TOKEN_CODES = new Map([
25
+ ['reconnect-required', 'reconnect-required'], ['not-connected', 'not-connected'], ['connection-not-found', 'not-connected'],
26
+ ['revocation-pending', 'not-connected'], ['account-changed', 'account-changed'],
27
+ // The host's per-connection budget refused the token read: nothing was sent, so it is a quota wait.
28
+ ['rate-limited', 'quota-delay'],
29
+ ]);
30
+
31
+ /** The host's wait, through the same cap as Google's Retry-After; dropped when unusable. @complexity O(1). */
32
+ function hostRetryAfter(cause) {
33
+ const retryAfterMs = capRetryAfterMs({ ms: cause?.retryAfterMs });
34
+ return retryAfterMs === undefined ? {} : { retryAfterMs };
35
+ }
36
+
37
+ /**
38
+ * A fresh access token from the host, read right before the request that uses it. A host refusal
39
+ * becomes the matching safe error (`invalid_grant` surfaces here as `reconnect-required`; a form
40
+ * delivery then fails at once and stays listed as failed); any other host error passes through
41
+ * unchanged, since host errors never carry secrets.
42
+ * @complexity O(1) plus the host's token read.
43
+ */
44
+ export async function accessTokenFor(required, _optional = {}) {
45
+ try {
46
+ return await required.connections.getAccessToken({ connectionRef: required.connectionRef });
47
+ } catch (cause) {
48
+ const code = HOST_TOKEN_CODES.get(cause?.code);
49
+ if (code) throw new GoogleWorkspaceError({ code }, { cause, ...hostRetryAfter(cause) });
50
+ throw cause;
51
+ }
52
+ }
53
+
54
+ function parseJson(text) {
55
+ try { return JSON.parse(text); } catch { return INVALID_JSON; }
56
+ }
57
+
58
+ /**
59
+ * One caller-supplied value as one encoded URL path segment.
60
+ * @throws {GoogleWorkspaceError} `invalid-input` for an empty, `.` or `..` segment: URL parsing
61
+ * resolves those as dot segments and would move the request to another endpoint.
62
+ * @complexity O(n) characters.
63
+ */
64
+ export function pathSegment(required, _optional = {}) {
65
+ const { value } = required;
66
+ if (typeof value !== 'string' || value === '' || value === '.' || value === '..') throw new GoogleWorkspaceError({ code: 'invalid-input' });
67
+ return encodeURIComponent(value);
68
+ }
69
+
70
+ /** `url` plus query parameters; an array value repeats the key (Sheets `ranges=`). @complexity O(q). */
71
+ export function buildUrl(required, _optional = {}) {
72
+ const url = new URL(required.url);
73
+ for (const [name, value] of Object.entries(required.query ?? {})) {
74
+ for (const item of [value].flat()) url.searchParams.append(name, String(item));
75
+ }
76
+ return url.href;
77
+ }
78
+
79
+ function requestInit(request, token, signal) {
80
+ const headers = { authorization: `Bearer ${token}`, accept: 'application/json' };
81
+ const body = request.body === undefined ? {} : { body: JSON.stringify(request.body) };
82
+ if (request.body !== undefined) headers['content-type'] = 'application/json';
83
+ return { method: request.method ?? 'GET', headers, ...body, ...(signal ? { signal } : {}) };
84
+ }
85
+
86
+ /** A 2xx body as JSON (empty = null), or the failure a non-2xx or unreadable answer becomes. @complexity O(response). */
87
+ async function readResponse(response, kind) {
88
+ const text = await response.text();
89
+ const body = text === '' ? null : parseJson(text);
90
+ if (response.ok && body !== INVALID_JSON) return { ok: true, value: body };
91
+ if (response.ok) return { ok: false, error: new GoogleWorkspaceError({ code: 'unexpected-response', outcome: kind === 'read' ? 'not-applied' : 'unknown' }) };
92
+ return { ok: false, error: classifyFailure({ kind, status: response.status, body: body === INVALID_JSON ? undefined : body, retryAfterHeader: response.headers.get('retry-after') }) };
93
+ }
94
+
95
+ /**
96
+ * Creates the client over the host capabilities a module received.
97
+ * @param {{ http: { fetch(target: { url: string }, init?: RequestInit): Promise<Response> }, connections: { getAccessToken(input: { connectionRef: string }): Promise<string> } }} required
98
+ * @param {{ sleep?: (ms: number) => Promise<void>, random?: () => number }} [optional]
99
+ * @complexity O(attempts); each attempt is one token read (where the host charges its budgets) and one request (two after a 401).
100
+ */
101
+ export function createGoogleClient(required, optional = {}) {
102
+ const { http, connections } = required;
103
+ const sleep = optional.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
104
+
105
+ /**
106
+ * The token, or the host budget's refusal as a failed attempt: that refusal happens before
107
+ * anything is sent, so it follows the same backoff rules as Google's 429 (even an append may
108
+ * wait and retry). Every other host refusal ends the call.
109
+ */
110
+ async function tokenOrRefusal(connectionRef) {
111
+ try {
112
+ return { token: await accessTokenFor({ connections, connectionRef }) };
113
+ } catch (error) {
114
+ if (error instanceof GoogleWorkspaceError && error.code === 'quota-delay') return { error };
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ /** One token read and at most one request; never resend a rejected token. @complexity O(1) plus response parsing. */
120
+ async function attemptOnce(request, { signal, rejectedToken }) {
121
+ const { token, error } = await tokenOrRefusal(request.connectionRef);
122
+ if (error) return { ok: false, error, rejectedToken };
123
+ if (token === rejectedToken) return { ok: false, error: new GoogleWorkspaceError({ code: 'reconnect-required' }) };
124
+ let response;
125
+ try {
126
+ response = await http.fetch({ url: buildUrl(request) }, requestInit(request, token, signal));
127
+ } catch (cause) {
128
+ if (signal?.aborted) throw cause;
129
+ return { ok: false, error: classifyFailure({ kind: request.kind, cause }), rejectedToken };
130
+ }
131
+ return { ...(await readResponse(response, request.kind)), unauthorized: response.status === 401, rejectedToken: response.status === 401 ? token : rejectedToken };
132
+ }
133
+
134
+ /**
135
+ * One attempt, and one more with a different token when Google answered 401. Google refused a
136
+ * 401 before acting, so even a `write` is safe to send again; a second 401 is final. Jini exposes
137
+ * no plugin force-refresh option and can return a cached token, which must not be sent again.
138
+ * @complexity O(1) attempts, at most two token reads and two requests.
139
+ */
140
+ async function attemptWithFreshToken(request, { signal, rejectedToken }) {
141
+ const first = await attemptOnce(request, { signal, rejectedToken });
142
+ return first.unauthorized ? attemptOnce(request, { signal, rejectedToken: first.rejectedToken }) : first;
143
+ }
144
+
145
+ return {
146
+ /**
147
+ * One Google API call with the policy's retries. Resolves the parsed JSON body.
148
+ * @param {{ connectionRef: string, url: string, method?: string, query?: Record<string, string | number | boolean | readonly (string | number)[]>, body?: unknown, kind: 'read' | 'idempotent-write' | 'write' }} request
149
+ * @param {{ signal?: AbortSignal }} [options]
150
+ */
151
+ async request(request, options = {}) {
152
+ // One attempt, then (when the policy allows) a wait and the next attempt. Written as a bounded
153
+ // recursion rather than an endless `for (;;)`: `shouldRetryInline` caps the attempts, so every
154
+ // path ends in a return or a throw and there is no unreachable loop exit.
155
+ // A host quota wait must not let a later attempt reuse a token Google already rejected, so the
156
+ // rejected token is carried into the next attempt.
157
+ const attemptFrom = async (attempt, rejectedToken) => {
158
+ const result = await attemptWithFreshToken(request, { signal: options.signal, rejectedToken });
159
+ if (result.ok) return result.value;
160
+ const delayMs = backoffMs({ attempt, retryAfterMs: result.error.retryAfterMs }, optional.random ? { random: optional.random } : {});
161
+ if (!shouldRetryInline({ error: result.error, attempt, delayMs })) throw result.error;
162
+ await sleep(delayMs);
163
+ return attemptFrom(attempt + 1, result.rejectedToken);
164
+ };
165
+ return attemptFrom(0, undefined);
166
+ },
167
+ };
168
+ }