@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,1054 @@
1
+ /**
2
+ * @file S3-compatible deploy target of the `s3-compatible` Agent Plugin (AWS S3, Cloudflare R2,
3
+ * Backblaze B2, DigitalOcean Spaces, Wasabi, MinIO, ...).
4
+ *
5
+ * Moved from Tovu core `apps/website/src/features/deployments/static-publish/s3-compatible-target.ts`:
6
+ * the body of {@link bindS3Compatible} is that source with its TypeScript types stripped, same
7
+ * requests, manifest protocol and error texts. SigV4 signing, reachability and `DeployError` come
8
+ * from the host kit (the first host's `kit.createSigV4Client` is the `aws4fetch` `AwsClient` its core used), because
9
+ * an installed plugin has no npm dependencies. S3 static hosting has no response-header config, so
10
+ * `responseHeaders` is ignored. Plain JS by owner decision (2026-09-29).
11
+ */
12
+
13
+ export const S3_COMPATIBLE_TARGET_ID = "s3-compatible";
14
+
15
+ /** The credential fields the target needs beyond `token` (the secret access key). */
16
+ const REQUIRED_S3_CREDENTIAL_FIELDS = ["accessKeyId", "bucket", "region", "publicUrl"];
17
+ // Anchoring the retained prefix avoids retrying a suffix match from every slash.
18
+ const TRAILING_URL_SLASHES = /^(.*[^/])?\/+$/s;
19
+
20
+ /**
21
+ * Binds the moved S3-compatible code to one host kit.
22
+ *
23
+ * @param {{ createSigV4Client(options: { accessKeyId: string, secretAccessKey: string, service: string, region: string }):
24
+ * { fetch(input: string, init?: RequestInit): Promise<Response>, sign(input: string, init?: RequestInit): Promise<Request> } }
25
+ * & Record<string, any>} kit The host's deploy kit in the positional ABI installed modules are written
26
+ * against (devops' `DeployHostKit` helpers, called positionally).
27
+ * @returns The target class (constructed with `{ accessKeyId, secretAccessKey, bucket, region, publicUrl, endpoint? }`)
28
+ * and the link-status mapper.
29
+ */
30
+ export function bindS3Compatible(kit) {
31
+ const { checkDeploymentUrl, DeployError, createSigV4Client } = kit;
32
+ /**
33
+ * Original notes (moved from Tovu core `s3-compatible-target.ts`): `DeployTarget` implementation for S3-compatible object storage (AWS S3, Cloudflare R2,
34
+ * Backblaze B2, DigitalOcean Spaces, Wasabi, MinIO, ...) — spec
35
+ * `ADS-memory/specs/custom-publish-provider-contract.md` §1-§2.
36
+ *
37
+ * Purpose:
38
+ * A plain signed-HTTP client, identical in kind to how `@jini-ai/devops/deploy`'s
39
+ * `GitHubPagesDeployTarget`/`VercelDeployTarget`/etc. talk to their own provider's REST API (spec
40
+ * §1a — nothing here shells out to anything). Signs each request with `aws4fetch`'s `AwsClient`
41
+ * (SigV4 — decided over `@aws-sdk/client-s3` and hand-rolled signing, spec §1) and `PUT`s every
42
+ * `DeployFile` to `{endpoint-or-derived-AWS-host}/{bucket}/{file}` — path-style addressing uniformly
43
+ * across every provider in scope, a deliberate implementation choice the spec itself calls out as
44
+ * "not architecture" (§2).
45
+ *
46
+ * How it relates to the project:
47
+ * Lives in Tovu, not in `@jini-ai/devops` — no second consumer exists yet (spec §2's extraction
48
+ * ruling). `adapter.ts` is this feature's ONLY caller; every OTHER caller in this codebase sees just
49
+ * `./types.ts`'s `StaticPublishConfig`/`StaticPublishOutcome`, never this file's types (the same
50
+ * "keep Jini's types at the boundary" discipline `adapter.ts`'s own header documents, extended one
51
+ * level further here since even a Tovu-local deploy target should not leak past its one caller).
52
+ *
53
+ * `checkDeploymentUrl` is reused verbatim from `@jini-ai/devops/deploy`'s `reachability.ts` (already a
54
+ * dependency) rather than reimplemented — it already carries the SSRF-hardened connection-time DNS
55
+ * validation `reachability.js`'s own header documents (SEC-003), which a hand-rolled `fetch(url)` here
56
+ * would not get for free. This is the same "plain unauthenticated HEAD/GET... identical in kind to
57
+ * what a browser does" check spec §2 describes, just the maintained implementation of it rather than a
58
+ * second one.
59
+ *
60
+ * `publish()` decides its own terminal `status` from that SAME check (not merely "did every PUT
61
+ * succeed") — mirroring the pattern `VercelDeployTarget.publish()` already uses internally
62
+ * (`waitForReachableDeploymentUrl`, verified by reading `vercel.js` directly): every `DeployTarget` in
63
+ * this codebase resolves its own honest terminal state before returning, rather than reporting "the
64
+ * API call worked" and leaving reachability to a caller. `adapter.ts`'s `publishStaticSite` is the one
65
+ * place that turns a non-`'ready'` `status` into `StaticPublishOutcome`'s `ok: "partial"` branch — see
66
+ * that file's own doc.
67
+ *
68
+ * CRITICAL FIX (2026-08-19, Codex 5.6-sol audit — data-loss/confidential-exposure class): `publish()`
69
+ * used to only ever upload the current export and never delete anything, deliberately, per spec §7
70
+ * ("Object cleanup / delete-on-republish ... assume S3-compatible ... leaves orphaned old keys in
71
+ * place for v1 ... the safer default"). That assumption does not hold once a page can be UNPUBLISHED:
72
+ * publish `/secret-announcement/index.html`, unpublish the page (it no longer appears in any later
73
+ * export), publish again — the object stays in the bucket and keeps being served at its old public URL
74
+ * INDEFINITELY, silently. "No delete" is only the safer default when nothing is ever supposed to stop
75
+ * being public; for a CMS where unpublishing is a real, expected action, it is the opposite — this
76
+ * finding supersedes spec §7's "no delete" ruling for this target (spec text not yet updated to match —
77
+ * whoever owns `custom-publish-provider-contract.md` should reconcile §7 with this file).
78
+ *
79
+ * The fix: {@link MANAGED_MANIFEST_KEY} is a small JSON object this target PUTs to the bucket itself,
80
+ * under a dedicated, Tovu-namespaced key, listing exactly which keys the LAST publish wrote. Every
81
+ * `publish()` call now (1) uploads the current export (unchanged), (2) reads the previous manifest and
82
+ * deletes any key it lists that the CURRENT export no longer produces — a targeted delete of a key this
83
+ * target is certain it owns, never a key with no such record — and (3) writes the new manifest ONLY
84
+ * after both of those steps succeed. That ordering is load-bearing for crash-safety: if a run dies
85
+ * between steps, the OLD manifest (on disk in the bucket, untouched until step 3) still lists whatever
86
+ * was not yet confirmed deleted, so the next run's diff naturally retries exactly the right work —
87
+ * nothing is ever silently forgotten (re-deleting an already-gone key or re-uploading an
88
+ * already-current one is a harmless no-op), and nothing not YET recorded as managed is ever deleted. A
89
+ * bucket with no manifest yet (the first publish ever, or content a human uploaded before Tovu ever
90
+ * touched this bucket) has nothing "known managed," so nothing is ever inferred safe to delete — the
91
+ * same "unknown means untouched" default the github plugin's `source-control/commit-site.mjs` manifest-based fix uses for the
92
+ * identical class of finding in that adapter.
93
+ *
94
+ * SECOND-ROUND CRITICAL FIX (2026-08-19, three independent auditors — Claude Sonnet 5, Codex 5.6-sol,
95
+ * Codex 5.6-terra, all three converging on overlapping findings with no communication between them): the
96
+ * manifest layer above closed the "content silently never cleaned up" gap, but had three of its own
97
+ * defects, all now fixed together (mirroring the same round's fixes to its git sibling, now the github plugin's
98
+ * `source-control/commit-site.mjs`):
99
+ *
100
+ * 1. OWNERSHIP WAS TRUSTED BLINDLY. Any key the manifest LISTED was deleted, with no check that the
101
+ * key's LIVE object still matched what this target itself last wrote. A human (or another tool)
102
+ * overwriting a Tovu-published object directly in the bucket, then a later export simply no longer
103
+ * producing that key, meant the overwrite got silently deleted on the very next publish. Fixed by
104
+ * giving the manifest real provenance: it is now `{version: 2, keys: [{key, etag}]}`, recording the
105
+ * ETag THIS target itself observed immediately after writing each key, not just the key string.
106
+ * {@link publish} now verifies, via {@link fetchLiveETag}, that a candidate deletion's CURRENT live
107
+ * ETag still equals the recorded one before ever deleting it — a key is deleted only when this
108
+ * target can prove nothing wrote to it since. A key with no recorded etag (a pre-this-fix
109
+ * `{version: 1, keys: [...]}` manifest, or a malformed entry) and a key whose live etag has DIVERGED
110
+ * from the recorded one are treated identically: never deleted, and reported back to the caller via
111
+ * `DeployPublishResult.statusMessage` (`adapter.ts`'s `publishStaticSite` passes this straight
112
+ * through to `StaticPublishOutcome`, the same channel every other status note already uses — no
113
+ * Jini-defined type could be widened for this, since `DeployPublishResult` is `@jini-ai/devops`'s own
114
+ * port, out of this fix's scope). ASSUMPTION, disclosed here rather than silently relied on: this
115
+ * comparison trusts that two GETs/HEADs of byte-identical content return the SAME ETag on a given
116
+ * provider (true for AWS S3, R2, MinIO, and every other provider in scope for a plain, non-multipart
117
+ * `PUT` — the shape every upload in this file always is); a provider whose ETag is not a deterministic
118
+ * function of content would make this check unreliable, which is the same class of caveat every
119
+ * ETag-based cache-validation scheme in the wild already carries.
120
+ *
121
+ * 2. A TRANSIENT MANIFEST READ FAILURE PERMANENTLY FORGOT STALE CONTENT. {@link fetchManagedManifest}
122
+ * used to return `undefined` on ANY failure — network, a non-404 HTTP error, or a body that failed to
123
+ * parse — and `publish()` treated `undefined` exactly like "verified: nothing was ever managed." The
124
+ * very next successful publish then wrote a brand-new manifest reflecting only the current export,
125
+ * permanently overwriting the only record of what a prior pass had tracked. A key removed from the
126
+ * export during exactly the same window as a manifest-read blip would then survive in the bucket
127
+ * forever, publicly reachable, invisible to every subsequent manifest. Fixed: {@link
128
+ * fetchManagedManifest} now throws a real {@link DeployError} for every unreadable case, and only a
129
+ * VERIFIED 404 is treated as "zero prior keys." `publish()` never uploads-then-silently-forgets on an
130
+ * unreadable manifest — the whole publish fails instead, the same fail-loud posture {@link deleteOne}
131
+ * already documents for a cleanup failure, extended one step earlier to the read that gates it.
132
+ *
133
+ * 3. NO CROSS-PUBLISHER CONCURRENCY GUARD AT ALL. `publish()` used to read the manifest, upload, delete
134
+ * stale keys, then unconditionally overwrite the manifest — with no lock, no generation check, no
135
+ * precondition. Two publishers racing (traced in the second-round audit): both read manifest `{x}`;
136
+ * A's export still includes `x`; B's export no longer does; B deletes `x` and writes `{}`; A then
137
+ * writes `{x}` back over top — the manifest now claims `x` exists, but the object is gone, forever
138
+ * mismatched. Fixed with a conditional write + bounded retry: {@link writeManagedManifestConditional}
139
+ * sends `If-Match` (keyed to the ETag this run observed reading the manifest) or `If-None-Match: *`
140
+ * (when no manifest existed yet) on the manifest `PUT` — a real compare-and-swap, the object-storage
141
+ * equivalent of the non-force git ref update `commit-site.mjs`'s own `writeRef` already relies
142
+ * on. A precondition failure (412, or 409 on providers that signal conflict that way) means a genuine
143
+ * racer won since
144
+ * this run's own read — {@link publish} re-reads the manifest, RE-VERIFIES and re-diffs the stale-key
145
+ * set against that fresh state (never assumes the old diff is still valid), and retries, bounded to
146
+ * {@link MAX_MANIFEST_WRITE_ATTEMPTS} attempts before giving up loudly.
147
+ *
148
+ * RESIDUAL RISK, disclosed rather than pretended closed: conditional writes on `PUT` (`If-Match`/
149
+ * `If-None-Match`) are NOT universally supported across every S3-compatible provider in this target's
150
+ * own scope — real AWS S3 added them relatively recently, and older MinIO/Backblaze/other
151
+ * deployments may still reject them. {@link writeManagedManifestConditional} detects this (a `501`,
152
+ * or a `400` whose body names an unsupported/not-implemented operation) and DEGRADES: the rest of
153
+ * THIS publish call falls back to a plain, unconditional manifest write rather than refusing to
154
+ * publish at all (this feature's own hard requirement — publishing must keep working end to end). A
155
+ * degraded publish still SUCCEEDS and still reports its result honestly: `statusMessage` names that
156
+ * the concurrency guard was unavailable for this call, so two publishers racing against a
157
+ * non-conditional-capable provider can still lose data the same way pre-fix code could — that
158
+ * specific residual risk is real, provider-dependent, and disclosed to the caller rather than
159
+ * silently absorbed.
160
+ *
161
+ * Architectural role:
162
+ * `features/deployments/static-publish` domain logic, this feature's second (Tovu-local) seam into
163
+ * the `DeployTarget` port `@jini-ai/devops/deploy` defines.
164
+ */
165
+ /** Fallback `Content-Type` for a `DeployFile` with none set — same gap `adapter.ts`'s `toDeployFile`
166
+ * already tolerates for the other four targets (their own provider APIs infer it from the file
167
+ * extension server-side; S3-compatible storage does not, so an explicit fallback is required here
168
+ * specifically, not merely nice-to-have). */
169
+ const DEFAULT_CONTENT_TYPE = "application/octet-stream";
170
+ /** Bounds how many `PUT` requests run concurrently per `publish()` call — resource-bounds pre-check
171
+ * (this codebase's Programmer skill 5a4): an export with thousands of files must not open thousands
172
+ * of concurrent sockets against a caller-configured host. 8 is a conservative, arbitrary-but-documented
173
+ * choice — enough to amortize per-request latency without behaving like a burst scanner against a
174
+ * third-party host holding a human's real credentials. */
175
+ const MAX_CONCURRENT_UPLOADS = 8;
176
+ /** Per-file upload timeout — resource-bounds pre-check, the "timeout on external service calls" half.
177
+ * A single slow/hanging object PUT must not stall the whole publish indefinitely; 30s is generous for
178
+ * a single static asset over a normal connection while still bounding the worst case. Reused verbatim
179
+ * for the manifest read/write and stale-key deletes below — none of those are larger than a single
180
+ * static asset either. */
181
+ const UPLOAD_TIMEOUT_MS = 30_000;
182
+ /** Bounds how many times {@link S3CompatibleDeployTarget.publish} retries the read-verify-delete-write
183
+ * cycle after a genuine manifest-write conflict (a `412`/`409` from {@link
184
+ * writeManagedManifestConditional}) before giving up loudly — this file's header SECOND-ROUND CRITICAL
185
+ * FIX note, finding 3. 3 is a small, documented bound: a real conflict resolves in one retry almost
186
+ * always (the losing writer simply re-reads the winner's now-current state); repeated conflicts beyond
187
+ * that indicate sustained contention this target should surface rather than retry indefinitely against. */
188
+ const MAX_MANIFEST_WRITE_ATTEMPTS = 3;
189
+ /** Where this target records exactly which keys IT wrote — see this file's header CRITICAL fix note.
190
+ * A hidden, Tovu-namespaced key deliberately: a real static export does not produce a dotfile
191
+ * directory, so collision with genuine site content is not a practical concern the way a plain
192
+ * `manifest.json` at the bucket root would be. Byte-identical NAMING PATTERN to
193
+ * `commit-site.mjs`'s `MANAGED_MANIFEST_PATH` — not shared code (this is an S3 object key, that
194
+ * is a git tree path; the two adapters have no dependency on each other), just the same "small
195
+ * Tovu-owned tracking file, physically stored inside what this adapter manages" shape applied to two
196
+ * different storage systems. */
197
+ const MANAGED_MANIFEST_KEY = ".tovu/managed-keys.json";
198
+ /**
199
+ * Derives the object-storage API host to sign requests against when `config.endpoint` is blank —
200
+ * "Plain AWS S3: leave this blank" per the credential form's own hint copy (spec §4c). Every other
201
+ * provider in scope (R2, B2, DigitalOcean Spaces, Wasabi, MinIO) requires an explicit endpoint, so this
202
+ * derivation only ever needs to know AWS's own host-naming convention.
203
+ *
204
+ * @complexity O(1) — a fixed string template.
205
+ */
206
+ function deriveEndpoint(region) {
207
+ return `https://s3.${region}.amazonaws.com`;
208
+ }
209
+ /**
210
+ * Builds the path-style object URL for one file — `{endpoint}/{bucket}/{key}`, each path segment
211
+ * percent-encoded independently so a `/` inside a single deploy-relative path SEGMENT (never the
212
+ * separators themselves, which `toDeployFile` in `adapter.ts` already normalizes to forward slashes)
213
+ * cannot be misread as an extra path boundary.
214
+ *
215
+ * @complexity O(n) in the number of path segments.
216
+ */
217
+ function objectUrl(config, file) {
218
+ const host = (config.endpoint && config.endpoint.trim() !== "" ? config.endpoint : deriveEndpoint(config.region)).replace(TRAILING_URL_SLASHES, "$1");
219
+ const key = file
220
+ .split("/")
221
+ .map((segment) => encodeURIComponent(segment))
222
+ .join("/");
223
+ return `${host}/${encodeURIComponent(config.bucket)}/${key}`;
224
+ }
225
+ /** Reads a response body defensively for an error message — never throws itself (a body-read failure
226
+ * must not mask the original HTTP error), and caps length so an unexpectedly large error page never
227
+ * balloons a thrown `DeployError`'s message. */
228
+ async function safeErrorBody(resp) {
229
+ try {
230
+ return (await resp.text()).slice(0, 300);
231
+ }
232
+ catch {
233
+ return "";
234
+ }
235
+ }
236
+ /**
237
+ * Signs and PUTs one `DeployFile` to its object URL, bounded by {@link UPLOAD_TIMEOUT_MS}.
238
+ *
239
+ * @returns The `ETag` this provider reported for the just-written object, or `undefined` when the
240
+ * response carried none — this target's own recorded provenance for the key (this file's header
241
+ * SECOND-ROUND CRITICAL FIX note, finding 1). A key uploaded with no observed ETag is recorded in the
242
+ * NEXT manifest with `etag: undefined`, meaning a future publish can never verify-and-delete it either
243
+ * — the same disclosed, bounded limitation a pre-provenance manifest entry gets.
244
+ * @throws {DeployError} A non-2xx response, or the request timing out/erroring at the network layer.
245
+ * Never leaks the response body raw — capped to 300 chars, the same "actionable but bounded" shape
246
+ * `adapter.ts`'s own `catch` uses for provider errors.
247
+ * @complexity O(1) — one signed HTTP request.
248
+ */
249
+ async function uploadOne(client, config, file) {
250
+ const url = objectUrl(config, file.file);
251
+ let resp;
252
+ try {
253
+ resp = await client.fetch(url, {
254
+ method: "PUT",
255
+ // `DeployFile.data`'s declared type (`Buffer | Uint8Array | string`) is always a valid fetch
256
+ // body at runtime — Node's `Buffer` IS a `Uint8Array`. The cast is needed only because
257
+ // `@types/node`'s `Buffer<ArrayBufferLike>` and lib.dom's `BodyInit` (which expects
258
+ // `Uint8Array<ArrayBuffer>`) disagree on the typed array's generic parameter, a known
259
+ // ecosystem type mismatch rather than a real runtime concern.
260
+ body: file.data,
261
+ headers: { "Content-Type": file.contentType ?? DEFAULT_CONTENT_TYPE, ...(file.headers ?? {}) },
262
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
263
+ });
264
+ }
265
+ catch (err) {
266
+ throw new DeployError(`Failed to upload '${file.file}' to S3-compatible storage: ${err instanceof Error ? err.message : String(err)}`, 502);
267
+ }
268
+ if (!resp.ok) {
269
+ const body = await safeErrorBody(resp);
270
+ const detail = body ? ` — ${body}` : "";
271
+ throw new DeployError(`Failed to upload '${file.file}' to S3-compatible storage: HTTP ${resp.status}${detail}`, resp.status >= 500 ? 502 : 400);
272
+ }
273
+ return resp.headers.get("etag") ?? undefined;
274
+ }
275
+ /**
276
+ * Reads the CURRENT `ETag` the provider reports for `key` right now, via a signed HEAD, used ONLY to
277
+ * verify a single candidate deletion still matches what this target itself last recorded writing (this
278
+ * file's header SECOND-ROUND CRITICAL FIX note,
279
+ * finding 1). Best-effort: `undefined` on ANY failure (network, non-2xx, or a response with no `ETag`
280
+ * header) or when the key is genuinely absent (404, e.g. already deleted by a previous pass or a human).
281
+ * Every one of those outcomes means the same thing to {@link S3CompatibleDeployTarget.publish} — "cannot
282
+ * confirm this ONE deletion is safe" — which always resolves to skipping just that one key, never to
283
+ * failing the whole publish. That narrower blast radius is exactly why this read gets the OPPOSITE
284
+ * tolerance from {@link fetchManagedManifest} itself: a failure here can only ever cost one candidate's
285
+ * cleanup this pass, never the ownership record as a whole.
286
+ *
287
+ * @complexity One signed HTTP request per call — bounded by the (typically small) number of candidate
288
+ * deletions, never by the size of the whole bucket.
289
+ */
290
+ async function fetchLiveETag(client, config, key) {
291
+ let resp;
292
+ try {
293
+ resp = await client.fetch(objectUrl(config, key), { method: "HEAD", signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS) });
294
+ }
295
+ catch {
296
+ return undefined;
297
+ }
298
+ if (!resp.ok)
299
+ return undefined;
300
+ return resp.headers.get("etag") ?? undefined;
301
+ }
302
+ /**
303
+ * Deletes one object by key, bounded by {@link UPLOAD_TIMEOUT_MS}. A 404 is treated as SUCCESS, not an
304
+ * error — the object is gone either way, and this delete is always driven by a diff against a manifest
305
+ * that may itself be stale by the time this call lands (a previous run already deleted it, a human
306
+ * deleted it directly), so "already absent" is the same outcome as "just removed," not a failure.
307
+ *
308
+ * @throws {DeployError} A non-404 non-2xx response, or the request timing out/erroring at the network
309
+ * layer — deliberately fails LOUD (never silently swallowed) so a real cleanup failure surfaces to
310
+ * the caller instead of letting the manifest "forget" a key that is still actually live in the
311
+ * bucket (see this file's header CRITICAL fix note on why the manifest write only ever happens after
312
+ * this succeeds).
313
+ * @complexity O(1) — one signed HTTP request.
314
+ */
315
+ async function deleteOne(client, config, key) {
316
+ const url = objectUrl(config, key);
317
+ let resp;
318
+ try {
319
+ resp = await client.fetch(url, { method: "DELETE", signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS) });
320
+ }
321
+ catch (err) {
322
+ throw new DeployError(`Failed to delete stale object '${key}' from S3-compatible storage: ${err instanceof Error ? err.message : String(err)}`, 502);
323
+ }
324
+ if (!resp.ok && resp.status !== 404) {
325
+ const body = await safeErrorBody(resp);
326
+ const detail = body ? ` — ${body}` : "";
327
+ throw new DeployError(`Failed to delete stale object '${key}' from S3-compatible storage: HTTP ${resp.status}${detail}`, resp.status >= 500 ? 502 : 400);
328
+ }
329
+ }
330
+ /** Recognizes both manifest shapes this target has ever written: the CURRENT one (`{version: 2, keys:
331
+ * [{key, etag}]}`, real per-key provenance) and the shape it wrote before this file's second-round fix
332
+ * (`{version: 1, keys: [...]}`, bare key strings, no per-key etag at all — a real historical format, not
333
+ * corruption). Anything else — an unrecognized `version`, an entry of the wrong shape, a hand-edited
334
+ * file that merely resembles one of these — is treated as UNRECOGNIZED, never as "zero prior keys": see
335
+ * {@link fetchManagedManifest}'s own doc for why an unrecognized shape must fail the whole publish
336
+ * (this file's header SECOND-ROUND CRITICAL FIX note, finding 2).
337
+ *
338
+ * @returns `undefined` when `parsed` matches neither recognized shape.
339
+ * @complexity O(n) in the manifest's own entry count.
340
+ */
341
+ /** One `v2` manifest `keys[]` entry: `{key, etag}`, `etag` blank/missing normalized to `undefined`. */
342
+ function parseManagedManifestV2Entry(entry) {
343
+ if (typeof entry !== "object" || entry === null)
344
+ return undefined;
345
+ const e = entry;
346
+ if (typeof e.key !== "string")
347
+ return undefined;
348
+ const etag = typeof e.etag === "string" && e.etag.length > 0 ? e.etag : undefined;
349
+ return { key: e.key, etag };
350
+ }
351
+ /** The current `{version: 2, keys: [{key, etag}]}` shape — real per-key provenance. */
352
+ function parseManagedManifestV2(obj) {
353
+ if (obj.version !== 2 || !Array.isArray(obj.keys))
354
+ return undefined;
355
+ const files = [];
356
+ for (const entry of obj.keys) {
357
+ const parsedEntry = parseManagedManifestV2Entry(entry);
358
+ if (parsedEntry === undefined)
359
+ return undefined;
360
+ files.push(parsedEntry);
361
+ }
362
+ return files;
363
+ }
364
+ /** The legacy `{version: 1, keys: [...]}` shape — bare key strings, no per-key provenance. Every
365
+ * listed key is KNOWN but UNVERIFIABLE. See this file's header, SECOND-ROUND CRITICAL FIX note,
366
+ * finding 1. */
367
+ function parseManagedManifestV1(obj) {
368
+ if (obj.version !== 1 || !Array.isArray(obj.keys) || !obj.keys.every((key) => typeof key === "string")) {
369
+ return undefined;
370
+ }
371
+ return obj.keys.map((key) => ({ key, etag: undefined }));
372
+ }
373
+ function parseManagedManifestShape(parsed) {
374
+ if (typeof parsed !== "object" || parsed === null)
375
+ return undefined;
376
+ const obj = parsed;
377
+ // `parseManagedManifestV1` re-checks `obj.version === 1` itself, so it can never accidentally
378
+ // match a `v2` object whose `keys[]` failed entry validation above.
379
+ return parseManagedManifestV2(obj) ?? parseManagedManifestV1(obj);
380
+ }
381
+ /**
382
+ * Reads {@link MANAGED_MANIFEST_KEY} — the previous publish's own record of which keys IT wrote, and
383
+ * (from `v2` onward) the ETag it observed for each (this file's header CRITICAL fix note, and its
384
+ * SECOND-ROUND CRITICAL FIX note, finding 2, for why this read is no longer best-effort).
385
+ *
386
+ * A VERIFIED 404 is the ONLY case treated as "zero prior keys" — it is a genuinely different fact than
387
+ * "this object could not be read," and is exactly what the first publish ever to a bucket (or one a
388
+ * human populated before Tovu ever touched it) looks like. Every OTHER failure (network, a non-404 HTTP
389
+ * error, a body that fails to parse, or a body that parses but matches neither manifest shape {@link
390
+ * parseManagedManifestShape} recognizes) now THROWS a real {@link DeployError} — `publish()` fails the
391
+ * whole call rather than risk writing a new manifest that silently forgets what an unreadable one
392
+ * recorded. This is the OPPOSITE tolerance from {@link fetchLiveETag}, which stays best-effort per-key —
393
+ * see that function's own doc for why the two reads get different treatment.
394
+ *
395
+ * @throws {DeployError} Any failure other than a verified 404.
396
+ * @complexity One signed HTTP request.
397
+ */
398
+ /** The GET half of {@link fetchManagedManifest} — network/transport failures only; HTTP-status and
399
+ * body handling stay with the caller. */
400
+ async function fetchManagedManifestResponse(client, config) {
401
+ try {
402
+ return await client.fetch(objectUrl(config, MANAGED_MANIFEST_KEY), { method: "GET", signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS) });
403
+ }
404
+ catch (err) {
405
+ throw new DeployError(`Failed to read the Tovu-managed object manifest — cannot confirm which keys this target previously owned: ${err instanceof Error ? err.message : String(err)}`, 502);
406
+ }
407
+ }
408
+ /** The parse-and-validate half of {@link fetchManagedManifest}, for an already-`ok` response. */
409
+ async function parseManagedManifestBody(resp) {
410
+ let parsed;
411
+ try {
412
+ parsed = JSON.parse(await resp.text());
413
+ }
414
+ catch {
415
+ throw new DeployError("The Tovu-managed object manifest is not valid JSON — cannot confirm which keys this target previously owned.", 502);
416
+ }
417
+ const files = parseManagedManifestShape(parsed);
418
+ if (files === undefined) {
419
+ throw new DeployError("The Tovu-managed object manifest did not match a recognized shape — cannot confirm which keys this target previously owned.", 502);
420
+ }
421
+ return { files, etag: resp.headers.get("etag") ?? undefined };
422
+ }
423
+ async function fetchManagedManifest(client, config) {
424
+ const resp = await fetchManagedManifestResponse(client, config);
425
+ if (resp.status === 404)
426
+ return { status: "not-found" };
427
+ if (!resp.ok) {
428
+ const body = await safeErrorBody(resp);
429
+ const detail = body ? ` — ${body}` : "";
430
+ throw new DeployError(`Failed to read the Tovu-managed object manifest — cannot confirm which keys this target previously owned: HTTP ${resp.status}${detail}`, resp.status >= 500 ? 502 : 400);
431
+ }
432
+ const { files, etag } = await parseManagedManifestBody(resp);
433
+ return { status: "found", files, etag };
434
+ }
435
+ /**
436
+ * Overwrites {@link MANAGED_MANIFEST_KEY} with exactly `files`, honoring `precondition` — the LAST step
437
+ * of every `publish()` attempt (this file's header CRITICAL fix note on why the ordering is
438
+ * load-bearing). A GENUINE failure (not a precondition outcome) is NOT tolerated silently: a manifest
439
+ * write this target cannot confirm means the NEXT publish cannot trust what it reads, so this throws the
440
+ * same as a real upload/delete failure rather than degrading.
441
+ *
442
+ * @returns `"written"`, `"conflict"`, or `"unsupported"` — see {@link ConditionalWriteOutcome}'s own doc.
443
+ * Never throws for either of the latter two; those are real, expected outcomes the caller decides how
444
+ * to act on, not failures.
445
+ * @throws {DeployError} Any OTHER non-2xx response, or the request timing out/erroring at the network
446
+ * layer.
447
+ * @complexity One signed HTTP request.
448
+ */
449
+ function buildManifestBody(files) {
450
+ return JSON.stringify({
451
+ version: 2,
452
+ keys: files.map((f) => ({ key: f.key, etag: f.etag })).sort((a, b) => {
453
+ if (a.key < b.key) return -1;
454
+ return a.key > b.key ? 1 : 0;
455
+ }),
456
+ });
457
+ }
458
+ function buildManifestHeaders(precondition) {
459
+ const headers = { "Content-Type": "application/json" };
460
+ if (precondition.kind === "if-match")
461
+ headers["If-Match"] = precondition.etag;
462
+ if (precondition.kind === "if-none-match")
463
+ headers["If-None-Match"] = "*";
464
+ return headers;
465
+ }
466
+ /** The PUT half of {@link writeManagedManifestConditional} — network/transport failures only;
467
+ * status-code interpretation stays with the caller. */
468
+ async function putManagedManifest(client, config, body, headers) {
469
+ try {
470
+ return await client.fetch(objectUrl(config, MANAGED_MANIFEST_KEY), { method: "PUT", body, headers, signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS) });
471
+ }
472
+ catch (err) {
473
+ throw new DeployError(`Failed to update the Tovu-managed object manifest: ${err instanceof Error ? err.message : String(err)}`, 502);
474
+ }
475
+ }
476
+ /** Classifies a `400` manifest-write response. Some S3-compatible providers report an unsupported
477
+ * conditional header this way rather than `501` — recognized only by a body naming the operation
478
+ * as unsupported, never assumed from the bare status code alone (a genuine 400 — a malformed
479
+ * request for an unrelated reason — must still throw, not be silently treated as "this provider
480
+ * just doesn't support preconditions"). */
481
+ async function classifyManifestWrite400(resp) {
482
+ const body = await safeErrorBody(resp);
483
+ if (/notimplemented|not implemented|unsupportedoperation/i.test(body))
484
+ return "unsupported";
485
+ const detail = body ? ` — ${body}` : "";
486
+ throw new DeployError(`Failed to update the Tovu-managed object manifest: HTTP 400${detail}`, 400);
487
+ }
488
+ async function classifyManifestWriteResponse(resp) {
489
+ if (resp.ok)
490
+ return "written";
491
+ if (resp.status === 412 || resp.status === 409)
492
+ return "conflict";
493
+ if (resp.status === 501)
494
+ return "unsupported";
495
+ if (resp.status === 400)
496
+ return classifyManifestWrite400(resp);
497
+ const errorBody = await safeErrorBody(resp);
498
+ const detail = errorBody ? ` — ${errorBody}` : "";
499
+ throw new DeployError(`Failed to update the Tovu-managed object manifest: HTTP ${resp.status}${detail}`, resp.status >= 500 ? 502 : 400);
500
+ }
501
+ async function writeManagedManifestConditional(client, config, files, precondition) {
502
+ const body = buildManifestBody(files);
503
+ const headers = buildManifestHeaders(precondition);
504
+ const resp = await putManagedManifest(client, config, body, headers);
505
+ return classifyManifestWriteResponse(resp);
506
+ }
507
+ /**
508
+ * Runs `task` over every item in `items` with at most {@link MAX_CONCURRENT_UPLOADS} in flight at
509
+ * once — a fixed-size worker pool rather than `Promise.all` over the whole set, per this codebase's
510
+ * resource-bounds pre-check (a large export or a large stale-key set must not open one socket per item
511
+ * simultaneously). The FIRST rejection wins and is rethrown; in-flight siblings are not explicitly
512
+ * cancelled (their own `AbortSignal.timeout` still bounds them), matching `Promise.all`'s own "first
513
+ * rejection propagates" semantics that every other target's own multi-request internals already rely
514
+ * on. Generic over both the item type AND the result type (not `DeployFile`-specific, not `void`-only) —
515
+ * reused for uploads (which need each file's own observed ETag back, in order) and deletes (which need
516
+ * nothing back).
517
+ *
518
+ * @returns Each item's own result, in the SAME order as `items` — never reordered by completion time.
519
+ * @complexity O(items) requests, bounded to {@link MAX_CONCURRENT_UPLOADS} concurrent in-flight.
520
+ */
521
+ async function runBounded(items, task) {
522
+ const results = Array.from({ length: items.length });
523
+ let nextIndex = 0;
524
+ let firstError;
525
+ let sawError = false;
526
+ // Claims the next unclaimed index, or `undefined` once the queue is drained or a sibling worker
527
+ // has already recorded a failure (no further claims once `sawError` — a claimed-but-unprocessed
528
+ // index is harmless since `results` is discarded whenever `sawError` ends up true).
529
+ const claimNextIndex = () => {
530
+ if (sawError || nextIndex >= items.length)
531
+ return undefined;
532
+ const index = nextIndex;
533
+ nextIndex += 1;
534
+ return index;
535
+ };
536
+ // Runs `task` for one claimed index, recording only the FIRST failure across every worker.
537
+ const runOne = async (index) => {
538
+ try {
539
+ results[index] = await task(items[index]);
540
+ }
541
+ catch (err) {
542
+ if (!sawError) {
543
+ sawError = true;
544
+ firstError = err;
545
+ }
546
+ }
547
+ };
548
+ async function worker() {
549
+ let index = claimNextIndex();
550
+ while (index !== undefined) {
551
+ await runOne(index);
552
+ if (sawError)
553
+ return;
554
+ index = claimNextIndex();
555
+ }
556
+ }
557
+ const poolSize = Math.max(1, Math.min(MAX_CONCURRENT_UPLOADS, items.length));
558
+ await Promise.all(Array.from({ length: poolSize }, () => worker()));
559
+ if (sawError)
560
+ throw firstError;
561
+ return results;
562
+ }
563
+ /** Maps a reachability probe to `DeployPublishResult.status`'s fixed vocabulary — see this file's
564
+ * header for why `publish()` decides this itself rather than leaving it to a caller.
565
+ *
566
+ * Exported for direct-invoke testing only: `publish()` always calls `checkDeploymentUrl` with no
567
+ * `detectProtected` callback (see call site below), so the `"protected"` arm can never be reached
568
+ * through this file's own real call path — a real caller would need to pass that option in, which
569
+ * none currently does. */
570
+ function toDeployLinkStatus(check) {
571
+ if (check.reachable)
572
+ return "ready";
573
+ if (check.status === "protected")
574
+ return "protected";
575
+ return "link-delayed";
576
+ }
577
+ /**
578
+ * Classifies one previously-managed key against `currentKeys`, verifying its LIVE etag against
579
+ * what this target recorded writing before ever calling it stale (this file's header SECOND-ROUND
580
+ * CRITICAL FIX note, finding 1). `MANAGED_MANIFEST_KEY` is excluded defensively (never part of
581
+ * `previousFiles`' own meaning, but a manifest written by some future/other version should not be
582
+ * able to delete itself via this path).
583
+ *
584
+ * @complexity One HEAD request when the key has a recorded etag to verify; O(1) otherwise.
585
+ */
586
+ async function classifyManagedKey(client, config, previous, currentKeys) {
587
+ if (currentKeys.has(previous.key) || previous.key === MANAGED_MANIFEST_KEY || previous.key.startsWith("_share/"))
588
+ return "skip";
589
+ if (previous.etag === undefined) {
590
+ // No recorded provenance (a pre-provenance v1 manifest entry, or a malformed v2 one) — nothing
591
+ // to verify against, so it is never auto-deleted.
592
+ return "diverged";
593
+ }
594
+ const liveEtag = await fetchLiveETag(client, config, previous.key);
595
+ if (liveEtag === undefined) {
596
+ // Already gone, or this ONE key's verification read failed — nothing this pass can safely
597
+ // delete now. Not reported as a divergence: an absent/unverifiable key in isolation is not
598
+ // evidence of tampering, just nothing this pass could act on.
599
+ return "skip";
600
+ }
601
+ // content changed since this target wrote it — never delete unverified content
602
+ return liveEtag === previous.etag ? "stale" : "diverged";
603
+ }
604
+ /**
605
+ * Diffs `previousFiles` (the last publish's own manifest) against `currentKeys` (this export) —
606
+ * {@link S3CompatibleDeployTarget.publish}'s per-attempt read/verify step, extracted so the retry
607
+ * loop above it doesn't carry this nesting itself. Only a key {@link classifyManagedKey} verifies
608
+ * `"stale"` is ever returned for deletion.
609
+ *
610
+ * @complexity O(previousFiles.length) HEAD requests (one per candidate whose etag is known).
611
+ */
612
+ async function diffManagedKeys(client, config, previousFiles, currentKeys) {
613
+ const staleKeys = [];
614
+ const divergedKeys = [];
615
+ for (const previous of previousFiles) {
616
+ const classification = await classifyManagedKey(client, config, previous, currentKeys);
617
+ if (classification === "stale")
618
+ staleKeys.push(previous.key);
619
+ if (classification === "diverged")
620
+ divergedKeys.push(previous.key);
621
+ }
622
+ return { staleKeys, divergedKeys };
623
+ }
624
+ /** The manifest-write `precondition` for one {@link attemptManifestSync} attempt: no condition once
625
+ * the concurrency guard has degraded, `if-none-match` for "I saw no manifest," `if-match` keyed to
626
+ * the etag this attempt's own read observed, or `none` when a `"found"` read reported no etag to
627
+ * condition on. */
628
+ function buildWritePrecondition(concurrencyGuardActive, manifestRead) {
629
+ if (!concurrencyGuardActive)
630
+ return { kind: "none" };
631
+ if (manifestRead.status === "not-found")
632
+ return { kind: "if-none-match" };
633
+ if (manifestRead.etag !== undefined)
634
+ return { kind: "if-match", etag: manifestRead.etag };
635
+ return { kind: "none" };
636
+ }
637
+ /**
638
+ * Runs one full read-verify-delete-write attempt at reconciling the managed-object manifest with
639
+ * `currentFiles` — the body of {@link S3CompatibleDeployTarget.publish}'s retry loop, extracted so
640
+ * the loop's own break/continue/throw control flow isn't nested three deep with this attempt's own
641
+ * work. Reads the manifest FRESH on every call (never reused across a retry — this file's header
642
+ * SECOND-ROUND CRITICAL FIX note, finding 3: a stale diff computed against an outdated manifest is
643
+ * exactly the race this fix closes), diffs and deletes verified-stale keys, then attempts the
644
+ * conditional manifest write.
645
+ *
646
+ * @complexity One manifest read, {@link diffManagedKeys}'s own cost, O(stale keys) DELETE requests
647
+ * (bounded concurrency, see {@link runBounded}), and one manifest write.
648
+ */
649
+ /** The `DeployError` to throw when {@link MAX_MANIFEST_WRITE_ATTEMPTS} is exhausted without a
650
+ * successful write — distinct wording and status per outcome so an operator can tell "this provider
651
+ * cannot do this write at all" (`"unsupported"`, 502) apart from "another publish is racing this
652
+ * one" (`"conflict"`, 409). Extracted out of {@link S3CompatibleDeployTarget.publish}'s own retry
653
+ * loop purely to keep that loop's cognitive complexity down — this function has no loop of its own. */
654
+ function manifestWriteExhaustedError(outcome) {
655
+ if (outcome === "unsupported") {
656
+ return new DeployError("This provider reported the Tovu-managed object manifest write as 'unsupported' even after degrading to an unconditional write, and retries were exhausted — it may not support the write shape this target requires.", 502);
657
+ }
658
+ return new DeployError("Concurrent publish detected on the Tovu-managed object manifest and retries were exhausted — another publish updated it while this one was running. Try publishing again.", 409);
659
+ }
660
+ async function attemptManifestSync(client, config, currentKeys, currentFiles, concurrencyGuardActive) {
661
+ const manifestRead = await fetchManagedManifest(client, config);
662
+ const previousFiles = manifestRead.status === "found" ? manifestRead.files : [];
663
+ const { staleKeys, divergedKeys } = await diffManagedKeys(client, config, previousFiles, currentKeys);
664
+ if (staleKeys.length > 0) {
665
+ await runBounded(staleKeys, (key) => deleteOne(client, config, key));
666
+ }
667
+ // LAST step, and only reached after every upload and verified delete above has succeeded — see
668
+ // this file's header CRITICAL fix note on why this ordering is what makes a crash mid-publish
669
+ // self-healing rather than an over-delete or a permanently orphaned, never-cleaned-up key.
670
+ const precondition = buildWritePrecondition(concurrencyGuardActive, manifestRead);
671
+ const outcome = await writeManagedManifestConditional(client, config, currentFiles, precondition);
672
+ return { outcome, divergedKeys };
673
+ }
674
+ function buildStatusMessage(base, divergedKeys, concurrencyGuardActive) {
675
+ let message = base;
676
+ if (divergedKeys.length > 0) {
677
+ const preview = divergedKeys.slice(0, 3).join(", ");
678
+ message += ` ${divergedKeys.length} previously-managed key(s) were preserved because their live content no longer matched this target's own record (or pre-dates that record): ${preview}${divergedKeys.length > 3 ? ", ..." : ""}.`;
679
+ }
680
+ if (!concurrencyGuardActive) {
681
+ message += " This provider does not support conditional writes; the concurrent-publish safety check (precondition on the managed-object manifest) was skipped for this publish.";
682
+ }
683
+ return message;
684
+ }
685
+ /**
686
+ * `DeployTarget` for S3-compatible object storage. See this file's header for the full design —
687
+ * SigV4-signed `PUT` per file, then a real reachability check against `config.publicUrl` before
688
+ * reporting a terminal status.
689
+ */
690
+ class S3CompatibleDeployTarget {
691
+ /** The operator supplied the bucket's public address; resolving it needs no storage call.
692
+ * @complexity O(1), no side effects.
693
+ */
694
+ async resolvePublicUrl(_required, _options = {}) { return this.config.publicUrl; }
695
+ config;
696
+ id = "s3-compatible";
697
+ client;
698
+ constructor(config) {
699
+ this.config = config;
700
+ this.client = createSigV4Client({
701
+ accessKeyId: config.accessKeyId,
702
+ secretAccessKey: config.secretAccessKey,
703
+ service: "s3",
704
+ region: config.region,
705
+ });
706
+ }
707
+ /** A share has its own exact object key and never participates in publish cleanup. The token is
708
+ * 16 ASCII letters, the shape core's `SHARE_TOKEN` mints (a plugin cannot import core). */
709
+ artifactStore(_required, _optional = {}) {
710
+ const requireKey = (key) => {
711
+ if (!/^_share\/[A-Za-z]{16}\/[a-z0-9][a-z0-9-]{0,79}\.tovu\.zip$/.test(key)) throw new DeployError("Invalid share artifact key.", 400);
712
+ };
713
+ const publicUrl = (key) => `${this.config.publicUrl.replace(TRAILING_URL_SLASHES, "$1")}/${key}`;
714
+ return {
715
+ upload: async ({ key, bytes, headers }, options = {}) => {
716
+ requireKey(key);
717
+ options.signal?.throwIfAborted();
718
+ // S3 can persist cache/disposition headers. Arbitrary response headers (nosniff,
719
+ // robots) are best-effort at a CDN and are not part of the S3 verification contract.
720
+ const supported = Object.fromEntries(Object.entries(headers).filter(([name]) => /^(?:content-type|content-disposition|cache-control)$/i.test(name)));
721
+ await uploadOne(this.client, this.config, { file: key, data: bytes, contentType: "application/zip", headers: supported });
722
+ return { url: publicUrl(key) };
723
+ },
724
+ delete: async ({ key }, _options = {}) => { requireKey(key); await deleteOne(this.client, this.config, key); },
725
+ verify: async ({ key, url, present, bytes }, options = {}) => {
726
+ requireKey(key);
727
+ if (url !== publicUrl(key)) return false;
728
+ const response = await kit.fetch(url, { method: "HEAD", redirect: "error", signal: options.signal }, { timeoutMs: kit.timeouts.QUICK });
729
+ if (!present) return response.status === 404 || response.status === 410;
730
+ return response.status === 200 && Number(response.headers.get("content-length")) === bytes &&
731
+ response.headers.get("cache-control") === "no-store" &&
732
+ /^attachment(?:;|$)/i.test(response.headers.get("content-disposition") ?? "");
733
+ },
734
+ };
735
+ }
736
+ /**
737
+ * Uploads every file, then checks whether `config.publicUrl` is actually reachable — see this file's
738
+ * header for why this is checked here rather than left to a caller. A successful upload with an
739
+ * unreachable public URL still returns normally (`status` reflects it as `'link-delayed'`/
740
+ * `'protected'`, never thrown) — `adapter.ts` is what turns that into `StaticPublishOutcome`'s
741
+ * `ok: "partial"` branch; this method's own job is only to report the honest terminal state.
742
+ *
743
+ * Deletion is now a VERIFY-then-delete pass, and the manifest write is a CONDITIONAL, retried one —
744
+ * see this file's header SECOND-ROUND CRITICAL FIX note for the full reasoning behind both:
745
+ * 1. Uploads happen ONCE, up front — idempotent (re-running a PUT with the same content is a no-op),
746
+ * so a conflict retry below never needs to re-upload.
747
+ * 2. Each retry attempt re-reads the manifest FRESH (never reuses a stale read across attempts),
748
+ * re-verifies every candidate deletion's LIVE etag against what this target itself recorded
749
+ * writing, deletes only the ones that verify, then attempts a CONDITIONAL manifest write keyed to
750
+ * that same fresh read.
751
+ * 3. `"conflict"` (a real precondition failure — something else changed the manifest since THIS
752
+ * attempt's own read) retries from step 2, bounded to {@link MAX_MANIFEST_WRITE_ATTEMPTS}.
753
+ * `"unsupported"` (this provider cannot do conditional writes at all) degrades to an unconditional
754
+ * write for the rest of this call and keeps going — publishing must still work end to end.
755
+ *
756
+ * @throws {DeployError} Any file's upload fails (see {@link uploadOne}), the manifest cannot be read
757
+ * for any reason other than a verified 404 (see {@link fetchManagedManifest}), any verified stale
758
+ * key's delete fails (see {@link deleteOne}), the manifest write itself fails for a reason other than
759
+ * a precondition outcome (see {@link writeManagedManifestConditional}), or every conflict retry is
760
+ * exhausted. A partially-completed run remains SAFE to retry (this file's header CRITICAL fix note):
761
+ * the manifest is rewritten only after uploads and verified deletes succeed, so a crash anywhere
762
+ * before that point is self-healing on the next `publish()` call rather than orphaning or
763
+ * over-deleting anything.
764
+ * @complexity O(files) PUT requests (bounded concurrency, see {@link runBounded}) plus, PER ATTEMPT
765
+ * (bounded to {@link MAX_MANIFEST_WRITE_ATTEMPTS}): one manifest read, O(candidate deletions)
766
+ * HEAD requests to verify live content, O(verified stale keys) DELETE requests (bounded
767
+ * concurrency), and one manifest write — plus one reachability probe at the end
768
+ * (`checkDeploymentUrl`, itself O(1)-O(2) requests).
769
+ */
770
+ async publish(input) {
771
+ if (input.files?.some(file => file.file.startsWith("_share/"))) throw new DeployError("Static publishing cannot write share artifacts.", 400);
772
+ const currentKeys = new Set(input.files.map((file) => file.file));
773
+ // Uploads happen ONCE, up front — a PUT is idempotent, so a conflict retry below never re-uploads.
774
+ const observedEtags = await runBounded(input.files, (file) => uploadOne(this.client, this.config, file));
775
+ const currentFiles = input.files.map((file, index) => ({ key: file.file, etag: observedEtags[index] }));
776
+ let concurrencyGuardActive = true;
777
+ let divergedKeys = [];
778
+ for (let attempt = 1;; attempt++) {
779
+ const attempted = await attemptManifestSync(this.client, this.config, currentKeys, currentFiles, concurrencyGuardActive);
780
+ divergedKeys = attempted.divergedKeys;
781
+ if (attempted.outcome === "written")
782
+ break;
783
+ // The ceiling applies to EVERY non-written outcome, checked before either one's own handling —
784
+ // not just "conflict". A provider that answers "unsupported" again on the now-unconditional
785
+ // write (this arm's own degrade below) would otherwise re-enter that arm forever: `continue`
786
+ // returns to the top of the loop without ever reaching a bound check, an unbounded request
787
+ // flood rather than a single hang (this file's header SECOND-ROUND CRITICAL FIX note).
788
+ if (attempt >= MAX_MANIFEST_WRITE_ATTEMPTS)
789
+ throw manifestWriteExhaustedError(attempted.outcome);
790
+ if (attempted.outcome === "unsupported") {
791
+ // This provider cannot do conditional writes at all — degrade for the REST of this call and
792
+ // retry immediately with an unconditional write. Not a real conflict, so it does not need a
793
+ // fresh manifest re-read; nothing about the bucket's OWN state changed because of this outcome.
794
+ concurrencyGuardActive = false;
795
+ }
796
+ // outcome === "conflict": a real racing publisher changed the manifest since this attempt's own
797
+ // read. Retry from a fresh read (top of loop) — bounded, so sustained contention surfaces loudly
798
+ // rather than retrying forever.
799
+ }
800
+ const check = await checkDeploymentUrl(this.config.publicUrl);
801
+ return {
802
+ targetId: this.id,
803
+ url: this.config.publicUrl,
804
+ status: toDeployLinkStatus(check),
805
+ // INVARIANT: the `"Not yet reachable."` fallback can never actually be selected. Jini's
806
+ // `reachability.ts` (`checkDeploymentUrl` / `requestDeploymentUrl`) guarantees a non-empty
807
+ // `statusMessage` on every `reachable: false` result — the empty-URL short-circuit, both
808
+ // `assertSafeDeploymentUrl` catch sites, and the generic non-2xx/3xx fallback each set one
809
+ // explicitly, and `status: 'protected'` also always carries a message. So `check.statusMessage`
810
+ // is only ever nullish when `check.reachable` is true (the bare `{ reachable: true, statusCode
811
+ // }` shape), which is exactly the case the `check.reachable ? "Reachable." : ...` ternary's
812
+ // TRUE arm covers. Kept rather than removed: `??`'s right side is one expression, so trimming
813
+ // it to only the reachable case would need restructuring for no behavior change, and a future
814
+ // change to `reachability.ts` that drops that guarantee should fail loudly here rather than
815
+ // silently reporting "Reachable." for a failed check.
816
+ statusMessage: buildStatusMessage(check.statusMessage ?? (check.reachable ? "Reachable." : "Not yet reachable."), divergedKeys, concurrencyGuardActive),
817
+ };
818
+ }
819
+ /** Plain unauthenticated HEAD/GET against `url` — no S3 API call, no credential used. Satisfies the
820
+ * `DeployTarget` port; `publish()` above already runs the identical check against `config.publicUrl`
821
+ * as part of every publish, so this method exists for a caller that wants to re-check reachability
822
+ * independently of a fresh publish. */
823
+ async checkReachability(url) {
824
+ return checkDeploymentUrl(url);
825
+ }
826
+ }
827
+
828
+ return { S3CompatibleDeployTarget, toDeployLinkStatus };
829
+ }
830
+
831
+ /**
832
+ * Reads one credential-check response WITHOUT touching its body: 401/403 means the host refused the
833
+ * credential, any other failure says nothing about it (a 5xx, a rate limit).
834
+ *
835
+ * @param {Response} response
836
+ * @returns {{ ok: true } | { ok: false, reason: "rejected" | "unreachable", statusCode: number }}
837
+ */
838
+ function classifyCredentialResponse(response) {
839
+ if (response.ok) return { ok: true };
840
+ return { ok: false, reason: response.status === 401 || response.status === 403 ? "rejected" : "unreachable", statusCode: response.status };
841
+ }
842
+
843
+ // ---------------------------------------------------------------------------
844
+ // Hosting setup: the steps that make a bucket servable, which the person applies in their own
845
+ // console. Moved from Tovu core `publish-agent-tools.ts` (`inferHostingSetupProvider` /
846
+ // `buildHostingSetupContent`), types stripped, same text.
847
+ // ---------------------------------------------------------------------------
848
+
849
+ /**
850
+ * Which provider's steps to return, inferred from the credential's `endpoint` only. Blank reads as
851
+ * plain AWS S3, matching the credential form's "leave this blank" convention.
852
+ * @param {string | undefined} endpoint
853
+ * @returns {"aws" | "backblaze-b2" | "cloudflare-r2" | "digitalocean-spaces" | "wasabi" | "minio" | "generic"}
854
+ */
855
+ function inferHostingSetupProvider(endpoint) {
856
+ const host = (endpoint ?? "").trim().toLowerCase();
857
+ if (host === "") return "aws";
858
+ if (host.includes("r2.cloudflarestorage.com")) return "cloudflare-r2";
859
+ if (host.includes("backblazeb2.com")) return "backblaze-b2";
860
+ if (host.includes("digitaloceanspaces.com")) return "digitalocean-spaces";
861
+ if (host.includes("wasabisys.com")) return "wasabi";
862
+ if (host.includes("amazonaws.com")) return "aws";
863
+ if (host.includes("minio")) return "minio";
864
+ return "generic";
865
+ }
866
+
867
+ /**
868
+ * The steps for one provider, with `bucket` substituted into any policy JSON. `wasabi`/`minio` are not
869
+ * independently verified against their vendors' docs; their `warning` says so.
870
+ * @param {string} provider
871
+ * @param {string} bucket
872
+ * @param {string} region
873
+ * @returns {{ steps: { title: string, description: string, consoleJson?: string }[], warning: string }}
874
+ */
875
+ function buildHostingSetupContent(provider, bucket, region) {
876
+ if (provider === "aws") {
877
+ const policy = JSON.stringify(
878
+ {
879
+ Version: "2012-10-17",
880
+ Statement: [{ Sid: "PublicReadGetObject", Effect: "Allow", Principal: "*", Action: "s3:GetObject", Resource: `arn:aws:s3:::${bucket}/*` }],
881
+ },
882
+ null,
883
+ 2
884
+ );
885
+ return {
886
+ steps: [
887
+ {
888
+ title: "Turn off Block Public Access for this bucket",
889
+ description:
890
+ `AWS turns this on for every new bucket by default, and it overrides any bucket policy while it's on — a public-read policy alone will not work until this is off. ` +
891
+ `S3 console → bucket '${bucket}' → Permissions → Block public access (bucket settings) → Edit → uncheck all four boxes → confirm.`,
892
+ },
893
+ {
894
+ title: "Enable static website hosting",
895
+ description: `S3 console → bucket '${bucket}' → Properties → Static website hosting → Enable → set the index document to 'index.html'.`,
896
+ },
897
+ {
898
+ title: "Allow public reads (bucket policy)",
899
+ description: `S3 console → bucket '${bucket}' → Permissions → Bucket policy → paste the JSON below exactly (it already has your bucket name in it) → Save.`,
900
+ consoleJson: policy,
901
+ },
902
+ ],
903
+ warning: "",
904
+ };
905
+ }
906
+ if (provider === "cloudflare-r2") {
907
+ return {
908
+ steps: [
909
+ {
910
+ title: "Enable a public URL for this bucket",
911
+ description: `Cloudflare dashboard → R2 → bucket '${bucket}' → Settings → either turn on the free 'r2.dev' subdomain, or connect a custom domain you own.`,
912
+ },
913
+ ],
914
+ warning:
915
+ "This step needs a DIFFERENT credential than the S3-compatible key you saved on the Custom tab — your Cloudflare account login (or a separate Cloudflare API token), not the R2 access key/secret pair. " +
916
+ "R2's S3-compatible keys work only with S3-compatible SDKs/APIs; enabling public access is a Cloudflare-dashboard/API operation with no overlap in permissions.",
917
+ };
918
+ }
919
+ if (provider === "backblaze-b2") {
920
+ return {
921
+ steps: [
922
+ {
923
+ title: "Make the bucket public",
924
+ description: `Backblaze B2 dashboard → Buckets → '${bucket}' → change the bucket's Files setting from 'Private' to 'Public'.`,
925
+ },
926
+ ],
927
+ warning:
928
+ "Changing bucket visibility needs the 'writeBuckets' capability, a DIFFERENT capability than the object-write key saved on the Custom tab (which typically only has 'writeFiles'). " +
929
+ "If your saved key doesn't have it, do this step in the B2 web dashboard with your regular B2 login instead of trying to script it with the saved key.",
930
+ };
931
+ }
932
+ if (provider === "digitalocean-spaces") {
933
+ return {
934
+ steps: [
935
+ {
936
+ title: "Enable the CDN endpoint for this Space",
937
+ description: `DigitalOcean control panel → Spaces → '${bucket}' → Settings → enable the CDN (or 'doctl' / the DigitalOcean API v2 with a personal access token).`,
938
+ },
939
+ ],
940
+ warning:
941
+ "This step needs a DIFFERENT credential than the S3-compatible key you saved on the Custom tab — a DigitalOcean personal access token (from your account login), not the Spaces access key/secret pair. " +
942
+ "Basic object-level public-read via the S3-compatible API's own ACL mechanism may be reachable with the saved key without the CDN, but this has not been independently verified — treat it as worth trying, not guaranteed.",
943
+ };
944
+ }
945
+ if (provider === "wasabi") {
946
+ return {
947
+ steps: [
948
+ {
949
+ title: "Set a public-read bucket policy",
950
+ description: `Wasabi markets itself as closely AWS-S3-API-compatible, including bucket-policy support through the same signed-request surface your saved key already uses — try the AWS-shaped policy below in your Wasabi console (bucket '${bucket}', region '${region}').`,
951
+ consoleJson: JSON.stringify(
952
+ { Version: "2012-10-17", Statement: [{ Sid: "PublicReadGetObject", Effect: "Allow", Principal: "*", Action: "s3:GetObject", Resource: `arn:aws:s3:::${bucket}/*` }] },
953
+ null,
954
+ 2
955
+ ),
956
+ },
957
+ ],
958
+ warning: "Wasabi's compatibility with this exact flow has not been independently verified against Wasabi's own documentation — this is inferred from Wasabi's own compatibility claims, not confirmed. Double-check against Wasabi's current docs before relying on it.",
959
+ };
960
+ }
961
+ if (provider === "minio") {
962
+ return {
963
+ steps: [
964
+ {
965
+ title: "Set an anonymous download policy",
966
+ description: `Self-hosted MinIO — use the MinIO Console's bucket Access Policy setting, or the 'mc' CLI: 'mc anonymous set download <alias>/${bucket}'.`,
967
+ },
968
+ ],
969
+ warning:
970
+ "A self-hosted MinIO instance often has no public DNS name at all — 'Public URL' on the credential form only makes sense once you've set one up yourself (a reverse proxy, a load balancer, a domain pointed at this server). If you haven't, the site will not be reachable from the public internet no matter what is configured here.",
971
+ };
972
+ }
973
+ // provider === "generic" — endpoint didn't match any known provider's host pattern.
974
+ return {
975
+ steps: [
976
+ {
977
+ title: "Check your provider's own documentation for making a bucket publicly readable",
978
+ description:
979
+ `Your storage endpoint wasn't recognized as one of the providers Tovu has specific guidance for (bucket '${bucket}', region '${region}'). ` +
980
+ "Most S3-compatible providers support a public-read bucket policy similar to AWS's own — search your provider's docs for 'bucket policy' or 'public access'.",
981
+ },
982
+ ],
983
+ warning: "This provider is not one Tovu has specific hosting-setup guidance for yet — the steps above are generic, not verified against your provider's own documentation.",
984
+ };
985
+ }
986
+
987
+ /**
988
+ * @param {Readonly<Record<string, string>>} fields
989
+ * @param {string} name
990
+ * @param {string} what
991
+ * @returns {string}
992
+ * @throws {Error} The field is missing or blank.
993
+ */
994
+ function requireSetupField(fields, name, what) {
995
+ const value = fields[name]?.trim();
996
+ if (!value) throw new Error(`'${name}' (${what}) is required for the hosting setup steps.`);
997
+ return value;
998
+ }
999
+
1000
+ /** The module contract a host's deploy-target registry loads (`DeployTargetModule`). Every identifying
1001
+ * field lives on the saved credential; the publish config has none. */
1002
+ export default {
1003
+ /**
1004
+ * @param {{ credential: Record<string, string | undefined> & { token: string }, kit: any }} context
1005
+ * @throws {DeployError} A required credential field is missing (same text the first host's core used).
1006
+ */
1007
+ create({ credential, kit }) {
1008
+ const { S3CompatibleDeployTarget } = bindS3Compatible(kit);
1009
+ const missing = REQUIRED_S3_CREDENTIAL_FIELDS.filter((field) => credential[field] === undefined);
1010
+ if (missing.length > 0) throw new kit.DeployError(`S3-compatible credential is missing required field(s): ${missing.join(", ")}.`, 400);
1011
+ return new S3CompatibleDeployTarget({
1012
+ accessKeyId: credential.accessKeyId,
1013
+ secretAccessKey: credential.token,
1014
+ bucket: credential.bucket,
1015
+ region: credential.region,
1016
+ publicUrl: credential.publicUrl,
1017
+ ...(credential.endpoint !== undefined ? { endpoint: credential.endpoint } : {}),
1018
+ });
1019
+ },
1020
+ /** Minimal artifact capability on the existing vendor owner and signing helpers. */
1021
+ createArtifactStore(required, _optional = {}) {
1022
+ return this.create(required).artifactStore({});
1023
+ },
1024
+ /**
1025
+ * A SigV4-signed `HEAD` on the bucket (`HeadBucket`, the standard S3-API access check), sent through
1026
+ * `kit.fetch`. A blank endpoint means plain AWS S3, derived from the region; the endpoint is trimmed
1027
+ * first so a pasted trailing space cannot turn into an unsignable URL. Reads nothing back.
1028
+ * @param {{ credential: Record<string, string | undefined> & { token: string }, kit: any }} context
1029
+ * @throws {TypeError} The URL cannot be signed (a malformed endpoint); the host reports it as unreachable.
1030
+ */
1031
+ async verifyCredential({ credential, kit }) {
1032
+ const client = kit.createSigV4Client({ accessKeyId: credential.accessKeyId ?? "", secretAccessKey: credential.token, service: "s3", region: credential.region ?? "" });
1033
+ const endpoint = credential.endpoint?.trim();
1034
+ const host = (endpoint ? endpoint : `https://s3.${credential.region}.amazonaws.com`).replace(TRAILING_URL_SLASHES, "$1");
1035
+ const signed = await client.sign(`${host}/${encodeURIComponent(credential.bucket ?? "")}`, { method: "HEAD" });
1036
+ return classifyCredentialResponse(await kit.fetch(signed.url, { method: "HEAD", headers: signed.headers }, { timeoutMs: kit.timeouts.QUICK }));
1037
+ },
1038
+ validateConfig() {
1039
+ return null;
1040
+ },
1041
+ basePath() {
1042
+ return undefined;
1043
+ },
1044
+ /**
1045
+ * @param {{ fields: Readonly<Record<string, string>> }} input The non-secret credential fields the agent passed.
1046
+ * @throws {Error} `bucket` or `region` is missing.
1047
+ */
1048
+ hostingSetup({ fields }) {
1049
+ const bucket = requireSetupField(fields, "bucket", "the exact bucket name");
1050
+ const region = requireSetupField(fields, "region", "the bucket's region");
1051
+ const provider = inferHostingSetupProvider(fields.endpoint);
1052
+ return { provider, ...buildHostingSetupContent(provider, bucket, region) };
1053
+ },
1054
+ };