@jini-ai/agent-plugins 0.6.16 → 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 +35 -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,171 @@
1
+ /**
2
+ * @file The server half of "Choose spreadsheet", shared by the Picker panel and the form-destination
3
+ * panel: the hosted Picker's bootstrap and its selection check, and the desktop native Picker's start
4
+ * and finish. Every path ends in `verifySelectedSpreadsheet` (`picker-selection.mjs`) before a
5
+ * selection is returned, so nothing a browser or a callback claims is saved unchecked.
6
+ *
7
+ * Hosted: the bootstrap opens a Picker session in the plugin's own settings (random id, the account
8
+ * the token belongs to, 15-minute expiry) and gives the surface ONLY a short-lived access token plus
9
+ * the public Picker config. A selection is accepted only for a live session of this surface, and
10
+ * only for that same account.
11
+ *
12
+ * Desktop: Google's native Picker runs in the system browser as a code + PKCE sign-in with
13
+ * `trigger_onepick` and the spreadsheet MIME filter. The pending attempt (the host's opaque attempt
14
+ * id plus the expected account) is kept in the plugin's settings, the frame polls `finish`, and the
15
+ * `picked_file_ids` callback field is read only through the host's consume-once inbox, which fills
16
+ * only after the sign-in's state and code checked out. A cancel returns no selection and changes
17
+ * nothing.
18
+ *
19
+ * Which path runs: the host says (`context.hostKind`), never the config. Desktop always uses the
20
+ * native Picker in the system browser; hosted uses the JS Picker, which needs a browser key and app
21
+ * id, so a hosted site without both is `unavailable` (`picker-not-configured`) rather than falling
22
+ * back to a flow built for the desktop app.
23
+ */
24
+ import { DESKTOP_PICKER_START_PARAMS, parsePickedFileIds, verifySelectedSpreadsheet } from './picker-selection.mjs';
25
+ import { GoogleWorkspaceError } from './request-policy.mjs';
26
+ import { SHEETS_PRODUCT_ID } from './sheets-client.mjs';
27
+ import { CONNECTION_ID, sheetsUnavailableReason, surfaceAccount, surfaceRefusal, surfaceSheetsClient } from './surface-actions.mjs';
28
+
29
+ // The names keep their `TOVU_` prefix: sites already set them, and renaming would unconfigure them.
30
+ /** Public config the hosted JS Picker needs; placeholders the operator sets, never secrets. */
31
+ export const PICKER_CONFIG_NAMES = Object.freeze({ developerKey: 'TOVU_GOOGLE_PICKER_BROWSER_KEY', appId: 'TOVU_GOOGLE_PICKER_APP_ID' });
32
+
33
+ /** Long enough to browse Drive in the Picker; short enough that a forgotten tab stops being usable. */
34
+ export const PICKER_SESSION_TTL_MS = 15 * 60_000;
35
+
36
+ const sessionKey = (surfaceId) => `picker-session:${surfaceId}`;
37
+ const attemptKey = (surfaceId) => `picker-attempt:${surfaceId}`;
38
+
39
+ /** What the frame and chat may see of a verified spreadsheet: metadata only. */
40
+ export const metadataOf = ({ spreadsheetId, title, url }) => ({ spreadsheetId, title, url });
41
+
42
+ /** Both hosted Picker values, or null when either is unset. @complexity O(1): two config reads. */
43
+ async function hostedPickerConfig(publicConfig) {
44
+ const developerKey = await publicConfig.get({ name: PICKER_CONFIG_NAMES.developerKey });
45
+ const appId = await publicConfig.get({ name: PICKER_CONFIG_NAMES.appId });
46
+ return developerKey && appId ? { developerKey, appId } : null;
47
+ }
48
+
49
+ /** The bootstrap for a Picker that cannot run here: the safe code and its fixed sentence. @complexity O(1). */
50
+ const unavailable = (reason) => ({ mode: 'unavailable', reason, message: new GoogleWorkspaceError({ code: reason }).message });
51
+
52
+ /** A stored session that is this surface's, unexpired, and the one the frame names. @complexity O(1). */
53
+ function isLiveSession(session, sessionId, nowMs) {
54
+ if (typeof session?.id !== 'string' || session.id !== sessionId) return false;
55
+ return typeof session.accountId === 'string' && typeof session.expiresAt === 'number' && session.expiresAt > nowMs;
56
+ }
57
+
58
+ /** A stored desktop attempt in the shape `start` wrote. @complexity O(1). */
59
+ function isPendingAttempt(pending) {
60
+ return typeof pending?.attemptId === 'string' && typeof pending.accountId === 'string' && typeof pending.expiresAt === 'number';
61
+ }
62
+
63
+ /**
64
+ * Builds the Picker flow.
65
+ * @param {object} [_required]
66
+ * @param {{ now?: () => number, randomId?: () => string }} [optional] Clock and session-id source (tests).
67
+ * @returns {{ bootstrap: Function, verifyHosted: Function, startSystemBrowser: Function, finishSystemBrowser: Function }}
68
+ * Each takes the handler call (`{ capabilities, context, input?, fail? }`); the verified results
69
+ * are `{ spreadsheetId, title, url, accountId }` (the caller strips `accountId` before replying).
70
+ * @complexity O(1) to build; each step is a bounded number of host and Google calls.
71
+ */
72
+ export function createPickerFlow(_required = {}, optional = {}) {
73
+ const now = optional.now ?? Date.now;
74
+ const randomId = optional.randomId ?? (() => globalThis.crypto.randomUUID());
75
+
76
+ /** Opens a hosted session: the account, the token, the public config. @complexity O(1): three requests. */
77
+ async function openHostedSession(capabilities, context, config) {
78
+ const account = await surfaceAccount({ capabilities });
79
+ const accessToken = await capabilities.connections.getAccessToken({ connectionId: CONNECTION_ID });
80
+ const session = { id: randomId(), accountId: account.accountId, expiresAt: now() + PICKER_SESSION_TTL_MS };
81
+ await capabilities.state.set({ key: sessionKey(context.surfaceId), value: session });
82
+ return { mode: 'hosted', accountLabel: account.displayName, picker: { accessToken, ...config, sessionId: session.id } };
83
+ }
84
+
85
+ return {
86
+ /**
87
+ * The surface's start data: `unavailable` (with the safe code and its fixed sentence), `system-browser`,
88
+ * or `hosted` with the Picker session. The one answer that carries an access token.
89
+ */
90
+ async bootstrap({ capabilities, context }) {
91
+ const reason = await sheetsUnavailableReason({ capabilities });
92
+ if (reason !== null) return unavailable(reason);
93
+ if (context.hostKind === 'desktop') return { mode: 'system-browser' };
94
+ const config = await hostedPickerConfig(capabilities.publicConfig);
95
+ return config === null ? unavailable('picker-not-configured') : openHostedSession(capabilities, context, config);
96
+ },
97
+
98
+ /** Hosted: the frame's `{ sessionId, fileId }` → the verified spreadsheet, for the session's account only. */
99
+ async verifyHosted({ capabilities, context, input, fail }) {
100
+ const session = await capabilities.state.get({ key: sessionKey(context.surfaceId) });
101
+ if (!isLiveSession(session, input.sessionId, now())) throw surfaceRefusal({ fail, code: 'picker-expired' });
102
+ return verifySelectedSpreadsheet({ client: surfaceSheetsClient({ capabilities }), connectionRef: CONNECTION_ID, fileId: input.fileId, expectedAccountId: session.accountId });
103
+ },
104
+
105
+ /** Desktop: starts the native Picker sign-in; the host page opens it in the system browser. */
106
+ async startSystemBrowser({ capabilities, context }) {
107
+ // The account read goes through the Sheets client, so Sheets being off refuses here, before any sign-in.
108
+ const account = await surfaceAccount({ capabilities });
109
+ // Native Picker allows drive.file only, so this re-consent asks for the Sheets scope set alone.
110
+ const started = await capabilities.connections.begin(
111
+ { connectionId: CONNECTION_ID, action: 'reconnect' },
112
+ { scopeSetIds: [SHEETS_PRODUCT_ID], startParams: { ...DESKTOP_PICKER_START_PARAMS } },
113
+ );
114
+ const pending = { attemptId: started.attemptId, accountId: account.accountId, expiresAt: Date.parse(started.expiresAt) };
115
+ await capabilities.state.set({ key: attemptKey(context.surfaceId), value: pending });
116
+ return { expiresAt: started.expiresAt };
117
+ },
118
+
119
+ /**
120
+ * Desktop: polls the pending attempt. `{ status: 'none' | 'waiting' | 'expired' | 'cancelled' }`,
121
+ * or `{ status: 'selected', spreadsheet }` once the callback's file id checked out.
122
+ */
123
+ async finishSystemBrowser({ capabilities, context }) {
124
+ const key = attemptKey(context.surfaceId);
125
+ const pending = await capabilities.state.get({ key });
126
+ if (!isPendingAttempt(pending)) return { status: 'none' };
127
+ if (pending.expiresAt <= now()) { await capabilities.state.delete({ key }); return { status: 'expired' }; }
128
+ const fields = await capabilities.connections.takeCallbackFields({ connectionId: CONNECTION_ID, attemptId: pending.attemptId });
129
+ if (fields === null) return { status: 'waiting' };
130
+ await capabilities.state.delete({ key });
131
+ const fileId = parsePickedFileIds({ value: fields.picked_file_ids });
132
+ if (fileId === null) return { status: 'cancelled' };
133
+ const spreadsheet = await verifySelectedSpreadsheet({ client: surfaceSheetsClient({ capabilities }), connectionRef: CONNECTION_ID, fileId, expectedAccountId: pending.accountId });
134
+ return { status: 'selected', spreadsheet };
135
+ },
136
+ };
137
+ }
138
+
139
+ /** The reply shape of a desktop poll: the spreadsheet's metadata only. @complexity O(1). */
140
+ export function pollReply(required, _optional = {}) {
141
+ const { outcome } = required;
142
+ return outcome.status === 'selected' ? { status: 'selected', spreadsheet: metadataOf(outcome.spreadsheet) } : { status: outcome.status };
143
+ }
144
+
145
+ /** The plugin-settings key holding the spreadsheets picked on the Picker panel, newest first. */
146
+ export const PICKED_SPREADSHEETS_KEY = 'picked-spreadsheets';
147
+ const MAX_PICKED = 10;
148
+
149
+ /**
150
+ * The picked spreadsheets kept in the plugin's settings (metadata and the account they were picked
151
+ * with), newest first; entries in any other shape are dropped.
152
+ * @param {{ state: { get(input: { key: string }): Promise<unknown> } }} required
153
+ * @returns {Promise<Array<{ spreadsheetId: string, title: string, url: string, accountId: string }>>}
154
+ * @complexity O(n), n ≤ 10.
155
+ */
156
+ export async function readPickedSpreadsheets(required, _optional = {}) {
157
+ const stored = await required.state.get({ key: PICKED_SPREADSHEETS_KEY });
158
+ return Array.isArray(stored) ? stored.filter((entry) => typeof entry?.spreadsheetId === 'string' && typeof entry.accountId === 'string') : [];
159
+ }
160
+
161
+ /**
162
+ * Saves a VERIFIED selection at the top of the picked list (one entry per spreadsheet, at most 10).
163
+ * @param {{ state: { get: Function, set: Function }, spreadsheet: { spreadsheetId: string, title: string, url: string, accountId: string } }} required
164
+ * @complexity O(n), n ≤ 10.
165
+ */
166
+ export async function rememberPickedSpreadsheet(required, _optional = {}) {
167
+ const { state, spreadsheet } = required;
168
+ const others = (await readPickedSpreadsheets({ state })).filter((entry) => entry.spreadsheetId !== spreadsheet.spreadsheetId);
169
+ const entry = { ...metadataOf(spreadsheet), accountId: spreadsheet.accountId };
170
+ await state.set({ key: PICKED_SPREADSHEETS_KEY, value: [entry, ...others].slice(0, MAX_PICKED) });
171
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @file Server-side checks for a Google Picker selection, shared by the hosted JS Picker surface and
3
+ * the desktop native Picker (system browser, `picked_file_ids` callback field).
4
+ *
5
+ * Exists because a selection is only a claim until the server re-checks it: the browser (or the
6
+ * callback) names a file id, and under `drive.file` that id is useful only if the Picker really
7
+ * granted it to THIS account. So a selection is saved only after: the id is a plain Drive id (it is
8
+ * placed in URL paths), the connection is still the account the Picker was opened for, Drive says
9
+ * the file is a Google Sheets spreadsheet, and the Sheets API actually opens it. A pasted URL never
10
+ * reaches here as a grant. Results are file metadata only, never contents or tokens.
11
+ */
12
+ import { DRIVE_FILES_URL, readAccount } from './drive-files.mjs';
13
+ import { pathSegment } from './http.mjs';
14
+ import { GoogleWorkspaceError } from './request-policy.mjs';
15
+ import { assertSpreadsheetId, sheetsUrl } from './sheets-client.mjs';
16
+
17
+ export const SPREADSHEET_MIME_TYPE = 'application/vnd.google-apps.spreadsheet';
18
+
19
+ /**
20
+ * Extra authorization parameters for "Choose spreadsheet" on desktop: Google's native Picker in the
21
+ * system browser, one file, spreadsheets only. The connection descriptor allowlists both names
22
+ * (`startParams`); `prompt=consent` already comes from its installed-app parameters.
23
+ */
24
+ export const DESKTOP_PICKER_START_PARAMS = Object.freeze({ trigger_onepick: 'true', mimetypes: SPREADSHEET_MIME_TYPE });
25
+
26
+ // Drive ids are URL-safe base64-like strings; anything else could reshape the request path.
27
+ const assertFileId = (value) => assertSpreadsheetId({ id: value }, { code: 'invalid-selection' });
28
+
29
+ /**
30
+ * The one file id a desktop native Picker callback carried, or null when the person cancelled.
31
+ * `picked_file_ids` is comma-separated; "Choose spreadsheet" asks for one file, so more than one is
32
+ * refused rather than guessing which to keep. Call only after the sign-in's state and code checked out.
33
+ * @param {{ value: string | undefined }} required The raw `picked_file_ids` callback field.
34
+ * @returns {string | null}
35
+ * @throws {GoogleWorkspaceError} `invalid-selection`.
36
+ * @complexity O(n) in the field length (the connection descriptor caps it at 4096).
37
+ */
38
+ export function parsePickedFileIds(required, _optional = {}) {
39
+ const { value } = required;
40
+ if (value === undefined || value === '') return null;
41
+ const ids = value.split(',');
42
+ if (ids.length !== 1) throw new GoogleWorkspaceError({ code: 'invalid-selection' });
43
+ return assertFileId(ids[0]);
44
+ }
45
+
46
+ /** Drive metadata for the file; refuses a trashed file and anything but a spreadsheet. @complexity O(1): one request. */
47
+ async function readSpreadsheetFile(client, connectionRef, fileId, optional) {
48
+ const file = await client.request({
49
+ connectionRef, url: `${DRIVE_FILES_URL}/${pathSegment({ value: fileId })}`, kind: 'read',
50
+ query: { fields: 'id,name,mimeType,trashed', supportsAllDrives: 'true' },
51
+ }, optional);
52
+ if (file?.id !== fileId) throw new GoogleWorkspaceError({ code: 'unexpected-response' });
53
+ // A trashed file still answers files.get, but it is no longer a spreadsheet anyone works in.
54
+ if (file.trashed === true) throw new GoogleWorkspaceError({ code: 'file-not-granted' });
55
+ if (file.mimeType !== SPREADSHEET_MIME_TYPE) throw new GoogleWorkspaceError({ code: 'not-a-spreadsheet' });
56
+ return file;
57
+ }
58
+
59
+ /** Opens the spreadsheet through the Sheets API itself: Drive metadata alone does not prove access. @complexity O(1). */
60
+ async function openSpreadsheet(client, connectionRef, fileId, optional) {
61
+ const sheet = await client.request({
62
+ connectionRef, url: sheetsUrl({ spreadsheetId: fileId }), kind: 'read',
63
+ query: { fields: 'spreadsheetId,properties.title' },
64
+ }, optional);
65
+ if (sheet?.spreadsheetId !== fileId) throw new GoogleWorkspaceError({ code: 'unexpected-response' });
66
+ return sheet;
67
+ }
68
+
69
+ /**
70
+ * Whether the connected account can really work in this spreadsheet: Drive says it is a live Google
71
+ * Sheets file, and the Sheets API opens it. Shared by the Picker check below and
72
+ * `sheets_open_existing`. The link is built from the verified id, never taken from Google.
73
+ * @param {{ client: { request(input: object, options?: object): Promise<unknown> }, connectionRef: string, fileId: string }} required
74
+ * `fileId` already passed {@link assertSpreadsheetId}.
75
+ * @param {{ signal?: AbortSignal }} [optional]
76
+ * @returns {Promise<{ spreadsheetId: string, title: string, url: string }>}
77
+ * @throws {GoogleWorkspaceError} `not-a-spreadsheet`, `file-not-granted` / `access-denied`, `unexpected-response`.
78
+ * @complexity O(1): two requests (Drive metadata, Sheets).
79
+ */
80
+ export async function verifySpreadsheetAccess(required, optional = {}) {
81
+ const { client, connectionRef, fileId } = required;
82
+ const file = await readSpreadsheetFile(client, connectionRef, fileId, optional);
83
+ const sheet = await openSpreadsheet(client, connectionRef, fileId, optional);
84
+ const title = sheet.properties?.title ?? file.name ?? 'Untitled spreadsheet';
85
+ return { spreadsheetId: fileId, title, url: `https://docs.google.com/spreadsheets/d/${fileId}/edit` };
86
+ }
87
+
88
+ /**
89
+ * Verifies a Picker selection before anything saves it: the id, the account, then
90
+ * {@link verifySpreadsheetAccess}.
91
+ * @param {{ client: { request(input: object, options?: object): Promise<unknown> }, connectionRef: string, fileId: unknown, expectedAccountId: string }} required
92
+ * `expectedAccountId` is the account the Picker session was opened for (hosted: the bootstrap's;
93
+ * desktop: the connection's account when "Choose spreadsheet" started).
94
+ * @param {{ signal?: AbortSignal }} [optional]
95
+ * @returns {Promise<{ spreadsheetId: string, title: string, accountId: string, url: string }>}
96
+ * @throws {GoogleWorkspaceError} `invalid-selection`, `account-changed`, `not-a-spreadsheet`,
97
+ * `file-not-granted` / `access-denied` (from the client), `unexpected-response`.
98
+ * @complexity O(1): three requests (account, Drive metadata, Sheets).
99
+ */
100
+ export async function verifySelectedSpreadsheet(required, optional = {}) {
101
+ const { client, connectionRef, expectedAccountId } = required;
102
+ const fileId = assertFileId(required.fileId);
103
+ const account = await readAccount({ client, connectionRef }, optional);
104
+ if (account.accountId !== expectedAccountId) throw new GoogleWorkspaceError({ code: 'account-changed' });
105
+ const verified = await verifySpreadsheetAccess({ client, connectionRef, fileId }, optional);
106
+ return { ...verified, accountId: account.accountId };
107
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @file The Google Workspace product catalog (`products.json`): which products the plugin ships and
3
+ * which Google scopes each one needs. Turns the host's opaque scope-set ids (the products being
4
+ * connected) into the exact scope list the sign-in requests.
5
+ *
6
+ * Exists because enabling a product and granting its scopes are separate steps: the catalog is the
7
+ * one place that says what a product needs, so the sign-in never asks for more than the products
8
+ * being connected. v1 ships Sheets only, on `drive.file` (files the person picked or the site created).
9
+ *
10
+ * Also the one on/off gate per product. The plugin's own settings (the host's plugin state, which the
11
+ * Google Workspace panel writes and tools and form delivery may only read) hold
12
+ * `products: { "<productId>": true }`. A product is OFF unless that value is exactly `true`
13
+ * (fail closed: no settings, no reader, or any other value). Turning a product off blocks its
14
+ * tools and refuses its form deliveries as retryable (the forms retry policy re-tries them for about
15
+ * a day, then marks them failed); it never revokes the Google authorization.
16
+ */
17
+ import catalog from '../products.json' with { type: 'json' };
18
+ import { GoogleWorkspaceError } from './request-policy.mjs';
19
+
20
+ const GOOGLE_SCOPE = /^https:\/\/www\.googleapis\.com\/auth\/[a-z.]+$/;
21
+
22
+ /** A catalog entry that is not well formed stops the plugin loading, instead of requesting odd scopes. @complexity O(s). */
23
+ function assertScopes(scopes, where) {
24
+ if (!Array.isArray(scopes) || !scopes.every((scope) => GOOGLE_SCOPE.test(scope))) {
25
+ throw new Error(`products.json ${where} must list Google OAuth scope URLs. Fix the plugin package.`);
26
+ }
27
+ return Object.freeze([...scopes]);
28
+ }
29
+
30
+ /**
31
+ * The validated, frozen catalog. Exported so the load-time check can be pinned against malformed
32
+ * catalogs (the shipped `products.json` always passes it).
33
+ * @param {{ raw: { baseScopes: unknown, products: readonly { id: string, requiredScopes: unknown }[] } }} required
34
+ * @throws {Error} When a scope list is not a list of Google OAuth scope URLs.
35
+ * @complexity O(p * s).
36
+ */
37
+ export function readCatalog(required, _optional = {}) {
38
+ const { raw } = required;
39
+ const products = raw.products.map((product) => Object.freeze({
40
+ ...product,
41
+ requiredScopes: assertScopes(product.requiredScopes, `products '${product.id}' requiredScopes`),
42
+ }));
43
+ return Object.freeze({ baseScopes: assertScopes(raw.baseScopes, 'baseScopes'), products: Object.freeze(products) });
44
+ }
45
+
46
+ export const PRODUCTS = readCatalog({ raw: catalog });
47
+
48
+ /**
49
+ * The scopes to request for these products: the base scope plus each product's required scopes,
50
+ * de-duplicated in catalog order. An id the catalog does not know is refused, never ignored, so a
51
+ * typo cannot silently connect less than the person chose.
52
+ * @param {{ productIds: readonly string[] }} required
53
+ * @returns {readonly string[]}
54
+ * @complexity O(p * s).
55
+ */
56
+ export function scopesFor(required, _optional = {}) {
57
+ const chosen = required.productIds.map((id) => {
58
+ const product = PRODUCTS.products.find((entry) => entry.id === id);
59
+ if (!product) throw new Error(`Google Workspace has no product '${id}'. Choose one of: ${PRODUCTS.products.map((entry) => entry.id).join(', ')}.`);
60
+ return product;
61
+ });
62
+ return [...new Set([...PRODUCTS.baseScopes, ...chosen.flatMap((product) => product.requiredScopes)])];
63
+ }
64
+
65
+ /** The plugin-state key whose value says which products are on: `{ "<productId>": true }`. */
66
+ export const PRODUCT_STATE_KEY = 'products';
67
+
68
+ /**
69
+ * Whether the product is on in the plugin's own settings. Off when there is no reader, nothing is
70
+ * stored, or the stored value is anything but `true` for this product.
71
+ * @param {{ state?: { get(input: { key: string }): Promise<unknown> }, productId: string }} required
72
+ * `state` is the host's read-only plugin-state reader (`capabilities.state` for tools).
73
+ * @returns {Promise<boolean>}
74
+ * @complexity O(1): one state read.
75
+ */
76
+ export async function isProductEnabled(required, _optional = {}) {
77
+ const { state, productId } = required;
78
+ if (typeof state?.get !== 'function') return false;
79
+ const value = await state.get({ key: PRODUCT_STATE_KEY });
80
+ return value?.[productId] === true;
81
+ }
82
+
83
+ /**
84
+ * Refuses unless the product is on, before anything is sent to Google.
85
+ * @param {{ state?: { get(input: { key: string }): Promise<unknown> }, productId: string }} required
86
+ * @throws {GoogleWorkspaceError} `product-disabled` (next action: turn the product on in the panel).
87
+ * @complexity O(1): one state read.
88
+ */
89
+ export async function assertProductEnabled(required, _optional = {}) {
90
+ if (await isProductEnabled(required)) return;
91
+ const label = PRODUCTS.products.find((product) => product.id === required.productId)?.label ?? required.productId;
92
+ throw new GoogleWorkspaceError({ code: 'product-disabled' }, { detail: label });
93
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @file Google request policy for the Google Workspace plugin: the safe error every failure becomes,
3
+ * whether a failed request may be retried and how long to wait. The per-minute budgets themselves
4
+ * are declared in `tovu-connections.json` and enforced by the host on every token read.
5
+ *
6
+ * Exists so chat tools, admin actions and form delivery share ONE answer to "may this run again?"
7
+ * and one error vocabulary. Errors never carry tokens, Google's own message text or sheet contents:
8
+ * Google echoes ranges and values in its messages, so only a fixed sentence per code is shown.
9
+ */
10
+
11
+ /** Longest wait spent inside one call; a longer Retry-After is returned to the caller instead. */
12
+ export const MAX_INLINE_WAIT_MS = 10_000;
13
+ export const MAX_ATTEMPTS = 3;
14
+ const BASE_BACKOFF_MS = 500;
15
+ const MAX_RETRY_AFTER_MS = 15 * 60_000;
16
+
17
+ /** Every safe code: whether a retry can help, what the person does next, and the fixed sentence. */
18
+ const SAFE_ERRORS = Object.freeze({
19
+ 'invalid-request': { retryable: false, nextAction: 'fix-request', message: 'Google rejected the request as invalid. Check the spreadsheet, tab, range and values, then try again.' },
20
+ 'not-connected': { retryable: false, nextAction: 'connect', message: 'Google Workspace is not connected. Connect it from the Google Workspace plugin, then retry.' },
21
+ 'reconnect-required': { retryable: false, nextAction: 'reconnect', message: 'Reconnect required: Google no longer accepts the saved sign-in. Reconnect Google Workspace, then retry.' },
22
+ 'account-changed': { retryable: false, nextAction: 'review-account', message: 'The connected Google account changed since this was confirmed. Review the account and confirm again.' },
23
+ 'missing-permission': { retryable: false, nextAction: 'grant-permission', message: 'The connected Google account has not granted a permission this needs. Reconnect and allow every requested permission.' },
24
+ 'file-not-granted': { retryable: false, nextAction: 're-pick-file', message: 'This site cannot open this file with the connected Google account. Choose it again with the file picker, or create a new spreadsheet.' },
25
+ 'access-denied': { retryable: false, nextAction: 're-pick-file', message: 'Google refused access to this file for the connected account. Check that the account can edit it, then choose it again.' },
26
+ 'admin-policy': { retryable: false, nextAction: 'contact-admin', message: "A Google Workspace administrator's policy blocks this. Ask the administrator to allow this site, then retry." },
27
+ 'conflict': { retryable: true, nextAction: 'retry-later', message: 'Google reported a conflicting change. Retry in a moment.' },
28
+ 'quota-delay': { retryable: true, nextAction: 'retry-later', message: 'Google asked this site to slow down. Nothing was lost; retry after the delay.' },
29
+ 'service-unavailable': { retryable: true, nextAction: 'retry-later', message: 'Google is temporarily unavailable. Nothing was lost; retry later.' },
30
+ 'network-error': { retryable: true, nextAction: 'retry-later', message: 'This site could not reach Google. Check the connection and retry later.' },
31
+ 'uncertain-outcome': { retryable: false, nextAction: 'review-outcome', message: 'Google did not confirm whether the change was applied. Check the spreadsheet before trying again.' },
32
+ 'unexpected-response': { retryable: false, nextAction: 'report', message: 'Google returned a response this site does not understand. Report this to the plugin author.' },
33
+ 'product-disabled': { retryable: false, nextAction: 'enable-product', message: 'This Google product is turned off. Turn it on in the Google Workspace panel, then retry; the Google sign-in is kept.' },
34
+ 'picker-not-configured': { retryable: false, nextAction: 'contact-admin', message: "Choosing an existing spreadsheet is not set up on this site yet (the Google file picker is not configured). Add this site's Google Picker browser key and app ID, or create a new spreadsheet." },
35
+ 'invalid-selection': { retryable: false, nextAction: 're-pick-file', message: 'The file picker returned a selection this site cannot use. Choose the spreadsheet again.' },
36
+ 'not-a-spreadsheet': { retryable: false, nextAction: 're-pick-file', message: 'The chosen file is not a Google Sheets spreadsheet. Choose a spreadsheet, or create a new one.' },
37
+ // Refused by the plugin before anything was sent to Google (bounds and input checks of the Sheets tools).
38
+ 'invalid-input': { retryable: false, nextAction: 'fix-request', message: 'This request is not valid for the tool. Check the spreadsheet id, tab, range and options, then try again. Nothing was sent to Google.' },
39
+ 'limit-exceeded': { retryable: false, nextAction: 'narrow-request', message: 'This request or its result is larger than the tool allows. Ask for a smaller range, fewer rows or fewer entries, then try again.' },
40
+ 'export-unavailable': { retryable: false, nextAction: 'read-in-chat', message: 'Saving a CSV file is not available on this site. Nothing was read from Google; read the values in chat instead (sheets_values_get or sheets_values_batch_get).' },
41
+ 'unsupported-request': { retryable: false, nextAction: 'fix-request', message: 'The batch contains a request kind Google Workspace tools do not support (only reviewed, generally available Sheets request kinds are allowed). Remove it and retry; nothing was sent to Google.' },
42
+ });
43
+
44
+ /** A failure a person can act on. `outcome` says whether the request may have changed anything. */
45
+ export class GoogleWorkspaceError extends Error {
46
+ /**
47
+ * @param {{ code: keyof typeof SAFE_ERRORS, outcome?: 'not-applied' | 'unknown' }} required
48
+ * @param {{ retryAfterMs?: number, cause?: unknown, detail?: string }} [optional] `detail` is a
49
+ * short pointer the plugin itself composes (a request index, a kind name it validated); never
50
+ * Google's text, a token or a cell value.
51
+ */
52
+ constructor(required, optional = {}) {
53
+ const safe = SAFE_ERRORS[required.code];
54
+ const message = optional.detail === undefined ? safe.message : `${safe.message} (${optional.detail})`;
55
+ super(message, optional.cause === undefined ? undefined : { cause: optional.cause });
56
+ this.name = 'GoogleWorkspaceError';
57
+ this.code = required.code;
58
+ this.retryable = safe.retryable;
59
+ this.nextAction = safe.nextAction;
60
+ this.outcome = required.outcome ?? 'not-applied';
61
+ if (optional.retryAfterMs !== undefined) this.retryAfterMs = optional.retryAfterMs;
62
+ }
63
+
64
+ /** The tool-output shape: never the cause, which may hold provider text. */
65
+ toJSON() {
66
+ const { code, message, retryable, outcome, nextAction, retryAfterMs } = this;
67
+ return { code, message, retryable, outcome, nextAction, ...(retryAfterMs === undefined ? {} : { retryAfterMs }) };
68
+ }
69
+ }
70
+
71
+ /** Google's machine-readable reasons (Drive `errors[].reason`, Sheets `details[].reason`) → code. */
72
+ const REASON_CODES = new Map([
73
+ ['rateLimitExceeded', 'quota-delay'], ['userRateLimitExceeded', 'quota-delay'], ['RATE_LIMIT_EXCEEDED', 'quota-delay'],
74
+ ['insufficientPermissions', 'missing-permission'], ['ACCESS_TOKEN_SCOPE_INSUFFICIENT', 'missing-permission'],
75
+ ['appNotAuthorizedToFile', 'file-not-granted'], ['domainPolicy', 'admin-policy'],
76
+ ]);
77
+ const STATUS_CODES = new Map([
78
+ [400, 'invalid-request'], [401, 'reconnect-required'], [403, 'access-denied'], [404, 'file-not-granted'],
79
+ [409, 'conflict'], [412, 'conflict'], [413, 'invalid-request'], [429, 'quota-delay'],
80
+ ]);
81
+
82
+ /** The reasons named in a Google error body; a body that is not Google's shape names none. @complexity O(r). */
83
+ export function errorReasons(required, _optional = {}) {
84
+ const error = required.body?.error;
85
+ if (typeof error !== 'object' || error === null) return [];
86
+ const listed = [...(Array.isArray(error.errors) ? error.errors : []), ...(Array.isArray(error.details) ? error.details : [])];
87
+ return listed.map((entry) => entry?.reason).filter((reason) => typeof reason === 'string');
88
+ }
89
+
90
+ /**
91
+ * Retry-After as milliseconds (delta-seconds or an HTTP date), capped; undefined when absent or unusable.
92
+ * @complexity O(1).
93
+ */
94
+ export function parseRetryAfter(required, optional = {}) {
95
+ const raw = required.header?.trim();
96
+ if (!raw) return undefined;
97
+ const nowMs = optional.nowMs ?? Date.now();
98
+ const ms = /^\d+$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw) - nowMs;
99
+ return capRetryAfterMs({ ms });
100
+ }
101
+
102
+ /**
103
+ * A wait in milliseconds, capped; undefined when it is not a finite, non-negative number. Shared by
104
+ * Google's Retry-After and the host's budget refusal so both obey one ceiling.
105
+ * @complexity O(1).
106
+ */
107
+ export function capRetryAfterMs(required, _optional = {}) {
108
+ const { ms } = required;
109
+ return Number.isFinite(ms) && ms >= 0 ? Math.min(ms, MAX_RETRY_AFTER_MS) : undefined;
110
+ }
111
+
112
+ /**
113
+ * The safe code for an HTTP failure: a known reason wins, then the status, then 5xx → unavailable.
114
+ * @complexity O(r) reasons.
115
+ */
116
+ export function codeForStatus(required, _optional = {}) {
117
+ const fromReason = required.reasons.map((reason) => REASON_CODES.get(reason)).find(Boolean);
118
+ if (fromReason) return fromReason;
119
+ return STATUS_CODES.get(required.status) ?? (required.status >= 500 ? 'service-unavailable' : 'unexpected-response');
120
+ }
121
+
122
+ /**
123
+ * Whether a failed request may have been applied. Only a non-read can be applied; 4xx means Google
124
+ * refused it before acting, while a 5xx or a lost response leaves it unknown.
125
+ * @complexity O(1).
126
+ */
127
+ function outcomeFor(kind, status) {
128
+ if (kind === 'read') return 'not-applied';
129
+ return status !== undefined && status < 500 ? 'not-applied' : 'unknown';
130
+ }
131
+
132
+ /**
133
+ * The safe error for one failed attempt. `kind` is `read`, `idempotent-write` (same request, same
134
+ * result: values update/clear) or `write` (append, create, copy). An unknown outcome of a `write`
135
+ * is `uncertain-outcome` and never retryable: replaying it could apply it twice.
136
+ * @param {{ kind: 'read' | 'idempotent-write' | 'write', status?: number, body?: unknown, retryAfterHeader?: string | null, cause?: unknown }} required
137
+ * @param {{ nowMs?: number }} [optional]
138
+ * @complexity O(r) reasons.
139
+ */
140
+ export function classifyFailure(required, optional = {}) {
141
+ const { kind, status, cause } = required;
142
+ const outcome = outcomeFor(kind, status);
143
+ if (kind === 'write' && outcome === 'unknown') return new GoogleWorkspaceError({ code: 'uncertain-outcome', outcome }, { cause });
144
+ const code = status === undefined ? 'network-error' : codeForStatus({ status, reasons: errorReasons({ body: required.body }) });
145
+ const retryAfterMs = parseRetryAfter({ header: required.retryAfterHeader ?? undefined }, optional);
146
+ return new GoogleWorkspaceError({ code, outcome }, { ...(retryAfterMs === undefined ? {} : { retryAfterMs }), ...(cause === undefined ? {} : { cause }) });
147
+ }
148
+
149
+ /**
150
+ * Delay before attempt `attempt + 1`: full-jitter exponential backoff, never shorter than Google's
151
+ * Retry-After.
152
+ * @complexity O(1).
153
+ */
154
+ export function backoffMs(required, optional = {}) {
155
+ const random = optional.random ?? Math.random;
156
+ const jittered = Math.round(random() * BASE_BACKOFF_MS * 2 ** required.attempt);
157
+ return Math.max(required.retryAfterMs ?? 0, jittered);
158
+ }
159
+
160
+ /** Whether to wait and try again inside this call. @complexity O(1). */
161
+ export function shouldRetryInline(required, _optional = {}) {
162
+ return required.error.retryable && required.attempt + 1 < MAX_ATTEMPTS && required.delayMs <= MAX_INLINE_WAIT_MS;
163
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @file The A1-notation helpers of the Google Workspace plugin's Sheets code (tools, CSV export and
3
+ * form delivery): column letters, Google's normalized range parsed into parts, the strict
4
+ * two-corner range the CSV export plans with, tab-title quoting, and the person-readable label of a
5
+ * Sheets `GridRange` the confirmation card shows.
6
+ *
7
+ * Exists so each of these has ONE name and ONE contract: a range Google returns is parsed one way
8
+ * (`parseA1Range`, undefined when not in Google's shape), a range a person gives the export another
9
+ * (`parseBoundsA1`, refused when not a plain `A1:B2`), and a tab title is quoted one way
10
+ * (`tabRange`), so a title can never change which cells a range names.
11
+ */
12
+ import { GoogleWorkspaceError } from './request-policy.mjs';
13
+
14
+ /** 0 → `A`, 25 → `Z`, 26 → `AA`. @complexity O(letters). */
15
+ export function columnName(required, _optional = {}) {
16
+ let name = '';
17
+ for (let n = required.index + 1; n > 0; n = Math.floor((n - 1) / 26)) name = String.fromCharCode(65 + ((n - 1) % 26)) + name;
18
+ return name;
19
+ }
20
+
21
+ /** `A` → 0, `AA` → 26. @complexity O(letters). */
22
+ export function columnIndex(required, _optional = {}) {
23
+ return [...required.column].reduce((index, letter) => index * 26 + letter.charCodeAt(0) - 64, 0) - 1;
24
+ }
25
+
26
+ /** `'Tab ''name'''!<cells>`: the title quoted, its quotes doubled, so a title cannot change the range. @complexity O(title). */
27
+ export function tabRange(required, _optional = {}) {
28
+ return `'${required.title.replaceAll("'", "''")}'!${required.cells}`;
29
+ }
30
+
31
+ const GOOGLE_RANGE = /^(?<sheet>.+)!(?<startColumn>[A-Z]+)(?<startRow>\d+)(?::(?<endColumn>[A-Z]+)(?<endRow>\d+))?$/;
32
+
33
+ /**
34
+ * Google's normalized A1 range (`'Tab name'!B2:D90`) as its parts; undefined when it is not in that
35
+ * shape (Google normalizes every values range it returns to it).
36
+ * @complexity O(n) characters.
37
+ */
38
+ export function parseA1Range(required, _optional = {}) {
39
+ const groups = GOOGLE_RANGE.exec(required.range ?? '')?.groups;
40
+ if (!groups) return undefined;
41
+ return { sheet: groups.sheet, startColumn: groups.startColumn, startRow: Number(groups.startRow), endColumn: groups.endColumn, endRow: groups.endRow === undefined ? undefined : Number(groups.endRow) };
42
+ }
43
+
44
+ const BOUNDS_RANGE = /^([A-Z]{1,3})([1-9]\d{0,6}):([A-Z]{1,3})([1-9]\d{0,6})$/;
45
+
46
+ /**
47
+ * Zero-based inclusive bounds of a two-corner A1 range such as `B2:F200` (no tab name): the CSV
48
+ * export bounds its reads from it before it sends any.
49
+ * @returns {{ firstRow: number, lastRow: number, firstColumn: number, lastColumn: number }}
50
+ * @throws {GoogleWorkspaceError} `invalid-input` for anything else, including reversed corners.
51
+ * @complexity O(1).
52
+ */
53
+ export function parseBoundsA1(required, _optional = {}) {
54
+ const match = BOUNDS_RANGE.exec(required.range);
55
+ const bounds = match && { firstColumn: columnIndex({ column: match[1] }), firstRow: Number(match[2]) - 1, lastColumn: columnIndex({ column: match[3] }), lastRow: Number(match[4]) - 1 };
56
+ if (!bounds || bounds.lastRow < bounds.firstRow || bounds.lastColumn < bounds.firstColumn) {
57
+ throw new GoogleWorkspaceError({ code: 'invalid-input' }, { detail: 'range must look like A1:D200, top-left corner first' });
58
+ }
59
+ return bounds;
60
+ }
61
+
62
+ /** "rows 2-10", "rows 2 onward" or "all rows" for zero-based [start, end). @complexity O(1). */
63
+ function span(name, start, end, show) {
64
+ if (start === undefined && end === undefined) return `all ${name}`;
65
+ const first = show(start ?? 0);
66
+ return end === undefined ? `${name} ${first} onward` : `${name} ${first}-${show(end - 1)}`;
67
+ }
68
+
69
+ /**
70
+ * The card label of a `GridRange`: `tab 0, rows 2-10, columns A-D`, instead of raw zero-based indices.
71
+ * @param {{ range: { sheetId: number, startRowIndex?: number, endRowIndex?: number, startColumnIndex?: number, endColumnIndex?: number } }} required
72
+ * @complexity O(1).
73
+ */
74
+ export function gridRangeLabel(required, _optional = {}) {
75
+ const { sheetId, startRowIndex, endRowIndex, startColumnIndex, endColumnIndex } = required.range;
76
+ const rows = span('rows', startRowIndex, endRowIndex, (index) => String(index + 1));
77
+ const columns = span('columns', startColumnIndex, endColumnIndex, (index) => columnName({ index }));
78
+ return `tab ${sheetId}, ${rows}, ${columns}`;
79
+ }