@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.
- package/CHANGELOG.md +35 -1
- package/NOTICE +14 -0
- package/README.md +35 -14
- package/charts-echarts/plugin.json +39 -0
- package/charts-echarts/skills/charts-echarts/SKILL.md +110 -0
- package/charts-recharts/plugin.json +32 -0
- package/charts-recharts/skills/charts-recharts/SKILL.md +108 -0
- package/cloudflare/NOTES.md +16 -0
- package/cloudflare/form-destinations/cloudflare-d1.mjs +9 -0
- package/cloudflare/lib/api.mjs +96 -0
- package/cloudflare/lib/assets.mjs +65 -0
- package/cloudflare/lib/dns.mjs +32 -0
- package/cloudflare/lib/domain-inventory.mjs +61 -0
- package/cloudflare/lib/domain-status.mjs +48 -0
- package/cloudflare/lib/domains.mjs +95 -0
- package/cloudflare/lib/forms.mjs +254 -0
- package/cloudflare/lib/pages-upload.mjs +51 -0
- package/cloudflare/lib/target.mjs +165 -0
- package/cloudflare/lib/workers-upload.mjs +48 -0
- package/cloudflare/lib/zones.mjs +25 -0
- package/cloudflare/mcp.json +4 -0
- package/cloudflare/plugin.json +96 -0
- package/cloudflare/skills/cloudflare/SKILL.md +57 -0
- package/cloudflare/skills/cloudflare/references/domains.md +31 -0
- package/cloudflare/skills/cloudflare/references/pages.md +18 -0
- package/cloudflare/skills/cloudflare/references/permissions.md +29 -0
- package/cloudflare/skills/cloudflare/references/troubleshooting.md +20 -0
- package/cloudflare/skills/cloudflare/references/workers.md +42 -0
- package/cloudflare/targets/cloudflare-pages.mjs +12 -0
- package/cloudflare/targets/cloudflare-workers.mjs +17 -0
- package/cloudflare/tovu-consent.json +6 -0
- package/cloudflare/tovu-deploy-targets.json +630 -0
- package/cloudflare/tovu-form-destinations.json +12 -0
- package/cloudflare/worker/forms-receiver.mjs +249 -0
- package/composio/mcp.json +10 -0
- package/composio/plugin.json +68 -0
- package/composio/skills/composio/SKILL.md +85 -0
- package/dist/core/identity-namespace.d.ts +12 -0
- package/dist/core/identity-namespace.js +20 -0
- package/dist/folder-zip-node.js +29 -7
- package/dist/lifecycle/bundled-digests.js +4 -3
- package/dist/lifecycle/install.d.ts +7 -0
- package/dist/lifecycle/install.js +45 -3
- package/dist/lifecycle/manifest.d.ts +2 -2
- package/dist/lifecycle/manifest.js +5 -3
- package/dist/lifecycle/node.js +2 -0
- package/dist/lifecycle/persistent-state.js +1 -1
- package/dist/lifecycle/ports.d.ts +8 -0
- package/dist/lifecycle/seed-bundled.js +4 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.js +3 -2
- package/fly/deploy-configs/fly.mjs +115 -0
- package/fly/deploy-ops/fly.mjs +102 -0
- package/fly/mcp.json +4 -0
- package/fly/plugin.json +81 -0
- package/fly/scripts/render-deploy-workflow.mjs +150 -0
- package/fly/skills/fly/SKILL.md +43 -0
- package/fly/skills/fly/references/fly-deploy.template.yml +117 -0
- package/fly/skills/fly/references/fly.template.toml +81 -0
- package/fly/tovu-credential-schemes.json +11 -0
- package/fly/tovu-deploy-configs.json +21 -0
- package/fly/tovu-deploy-ops.json +14 -0
- package/github/deploy-ops/github-actions.mjs +164 -0
- package/github/deployments/github.mjs +415 -0
- package/github/mcp.json +4 -0
- package/github/plugin.json +105 -0
- package/github/skills/github/SKILL.md +249 -0
- package/github/skills/github/references/actions.md +224 -0
- package/github/skills/github/references/auth-and-tokens.md +140 -0
- package/github/skills/github/references/repo-files.md +135 -0
- package/github/skills/github/references/site-backup.md +159 -0
- package/github/skills/github/references/source-control.md +123 -0
- package/github/source-control/backup-push.mjs +242 -0
- package/github/source-control/commit-site.mjs +459 -0
- package/github/source-control/github.mjs +93 -0
- package/github/source-control/write-files.mjs +468 -0
- package/github/tovu-deploy-ops.json +13 -0
- package/github/tovu-source-control.json +117 -0
- package/github-pages/NOTES.md +19 -0
- package/github-pages/mcp.json +4 -0
- package/github-pages/plugin.json +48 -0
- package/github-pages/skills/github-pages/SKILL.md +44 -0
- package/github-pages/targets/github-pages.mjs +543 -0
- package/github-pages/tovu-deploy-targets.json +327 -0
- package/google-login/exports/google-g.svg +1 -0
- package/google-login/exports/sign-in-button.css +68 -0
- package/google-login/exports/sign-in-button.html +4 -0
- package/google-login/mcp.json +4 -0
- package/google-login/plugin.json +59 -0
- package/google-login/skills/google-login/SKILL.md +96 -0
- package/google-login/tovu-login-providers.json +21 -0
- package/google-workspace/README.md +132 -0
- package/google-workspace/form-destinations/README.md +87 -0
- package/google-workspace/form-destinations/dedupe.mjs +43 -0
- package/google-workspace/form-destinations/delivery-result.mjs +69 -0
- package/google-workspace/form-destinations/mapping.mjs +152 -0
- package/google-workspace/form-destinations/sheets.mjs +202 -0
- package/google-workspace/lib/auth.mjs +81 -0
- package/google-workspace/lib/drive-files.mjs +54 -0
- package/google-workspace/lib/form-destination-settings.mjs +72 -0
- package/google-workspace/lib/http.mjs +168 -0
- package/google-workspace/lib/picker-flow.mjs +171 -0
- package/google-workspace/lib/picker-selection.mjs +107 -0
- package/google-workspace/lib/product-state.mjs +93 -0
- package/google-workspace/lib/request-policy.mjs +163 -0
- package/google-workspace/lib/sheets-a1.mjs +79 -0
- package/google-workspace/lib/sheets-admin-csv.mjs +82 -0
- package/google-workspace/lib/sheets-admin-request-kinds.mjs +183 -0
- package/google-workspace/lib/sheets-client.mjs +286 -0
- package/google-workspace/lib/surface-actions.mjs +107 -0
- package/google-workspace/mcp.json +4 -0
- package/google-workspace/plugin.json +77 -0
- package/google-workspace/products.json +12 -0
- package/google-workspace/skills/google-workspace/SKILL.md +82 -0
- package/google-workspace/skills/google-workspace/references/connection.md +102 -0
- package/google-workspace/skills/google-workspace/references/form-delivery.md +50 -0
- package/google-workspace/skills/google-workspace/references/sheets.md +149 -0
- package/google-workspace/skills/google-workspace/references/troubleshooting.md +31 -0
- package/google-workspace/tools/account-info.mjs +18 -0
- package/google-workspace/tools/sheets/README.md +54 -0
- package/google-workspace/tools/sheets/copy-tab.mjs +23 -0
- package/google-workspace/tools/sheets/copy.mjs +23 -0
- package/google-workspace/tools/sheets/create.mjs +35 -0
- package/google-workspace/tools/sheets/find-replace.mjs +38 -0
- package/google-workspace/tools/sheets/find.mjs +52 -0
- package/google-workspace/tools/sheets/get-by-data-filter.mjs +23 -0
- package/google-workspace/tools/sheets/get.mjs +22 -0
- package/google-workspace/tools/sheets/values-append.mjs +27 -0
- package/google-workspace/tools/sheets/values-batch-clear.mjs +20 -0
- package/google-workspace/tools/sheets/values-batch-get.mjs +20 -0
- package/google-workspace/tools/sheets/values-batch-update.mjs +29 -0
- package/google-workspace/tools/sheets/values-by-data-filter.mjs +62 -0
- package/google-workspace/tools/sheets/values-clear.mjs +20 -0
- package/google-workspace/tools/sheets/values-get.mjs +21 -0
- package/google-workspace/tools/sheets/values-update.mjs +26 -0
- package/google-workspace/tools/sheets-admin/README.md +45 -0
- package/google-workspace/tools/sheets-admin/basic-filter.mjs +32 -0
- package/google-workspace/tools/sheets-admin/batch-update.mjs +25 -0
- package/google-workspace/tools/sheets-admin/developer-metadata.mjs +28 -0
- package/google-workspace/tools/sheets-admin/export-csv.mjs +67 -0
- package/google-workspace/tools/sheets-admin/filter-views.mjs +38 -0
- package/google-workspace/tools/sheets-admin/format.mjs +70 -0
- package/google-workspace/tools/sheets-admin/named-ranges.mjs +42 -0
- package/google-workspace/tools/sheets-admin/open-existing.mjs +34 -0
- package/google-workspace/tools/sheets-admin/protected-ranges-get.mjs +18 -0
- package/google-workspace/tools/sheets-admin/sort.mjs +18 -0
- package/google-workspace/tools/sheets-admin/tab-modify.mjs +36 -0
- package/google-workspace/tovu-admin-surfaces.json +63 -0
- package/google-workspace/tovu-agent-tools.json +1178 -0
- package/google-workspace/tovu-connections.json +35 -0
- package/google-workspace/tovu-form-destinations.json +10 -0
- package/google-workspace/ui/README.md +73 -0
- package/google-workspace/ui/connection-panel.handler.mjs +167 -0
- package/google-workspace/ui/connection-panel.html +17 -0
- package/google-workspace/ui/connection-panel.mjs +189 -0
- package/google-workspace/ui/connection-view.mjs +120 -0
- package/google-workspace/ui/destination-panel.handler.mjs +106 -0
- package/google-workspace/ui/destination-panel.html +17 -0
- package/google-workspace/ui/destination-panel.mjs +150 -0
- package/google-workspace/ui/dom.mjs +140 -0
- package/google-workspace/ui/panel.css +170 -0
- package/google-workspace/ui/picker-flow.mjs +87 -0
- package/google-workspace/ui/picker-view.mjs +44 -0
- package/google-workspace/ui/picker.handler.mjs +56 -0
- package/google-workspace/ui/picker.html +17 -0
- package/google-workspace/ui/picker.mjs +88 -0
- package/google-workspace/ui/surface-client.mjs +227 -0
- package/higgsfield-media/mcp.json +9 -0
- package/higgsfield-media/plugin.json +109 -0
- package/higgsfield-media/skills/higgsfield-media/SKILL.md +325 -0
- package/higgsfield-media/skills/higgsfield-media/references/failure-modes.md +116 -0
- package/higgsfield-media/skills/higgsfield-media/references/models-and-plan-gates.md +142 -0
- package/jev/mcp.json +4 -0
- package/jev/plugin.json +44 -0
- package/jev/skills/jev/SKILL.md +308 -0
- package/jev/skills/jev/references/cookbooks.md +197 -0
- package/jev/skills/jev/references/jaggedness.md +135 -0
- package/jev/skills/jev/references/javascript-sdk.md +146 -0
- package/jev/skills/jev/references/patterns.md +123 -0
- package/jev/skills/jev/references/primitives-reference.md +187 -0
- package/langfuse/plugin.json +22 -0
- package/meilisearch/mcp.json +4 -0
- package/meilisearch/plugin.json +54 -0
- package/meilisearch/providers/meilisearch.mjs +651 -0
- package/meilisearch/skills/meilisearch/SKILL.md +43 -0
- package/meilisearch/tovu-search-providers.json +10 -0
- package/netlify/NOTES.md +13 -0
- package/netlify/mcp.json +4 -0
- package/netlify/plugin.json +47 -0
- package/netlify/skills/netlify/SKILL.md +39 -0
- package/netlify/targets/netlify.mjs +384 -0
- package/netlify/tovu-consent.json +6 -0
- package/netlify/tovu-deploy-targets.json +163 -0
- package/opentelemetry/mcp.json +4 -0
- package/opentelemetry/plugin.json +52 -0
- package/opentelemetry/skills/opentelemetry/SKILL.md +39 -0
- package/package.json +161 -6
- package/posthog/mcp.json +4 -0
- package/posthog/plugin.json +46 -0
- package/posthog/skills/posthog/SKILL.md +36 -0
- package/railway/deploy-configs/railway.mjs +135 -0
- package/railway/deploy-ops/railway.mjs +167 -0
- package/railway/mcp.json +4 -0
- package/railway/plugin.json +45 -0
- package/railway/skills/railway/SKILL.md +66 -0
- package/railway/tovu-deploy-configs.json +16 -0
- package/railway/tovu-deploy-ops.json +11 -0
- package/render/deploy-configs/render.mjs +141 -0
- package/render/deploy-ops/render.mjs +152 -0
- package/render/mcp.json +4 -0
- package/render/plugin.json +47 -0
- package/render/skills/render/SKILL.md +63 -0
- package/render/tovu-deploy-configs.json +16 -0
- package/render/tovu-deploy-ops.json +11 -0
- package/resend/NOTES.md +52 -0
- package/resend/adapters/resend.mjs +161 -0
- package/resend/lib/domains.mjs +137 -0
- package/resend/lib/errors.mjs +25 -0
- package/resend/lib/feedback.mjs +118 -0
- package/resend/mcp.json +13 -0
- package/resend/plugin.json +192 -0
- package/resend/skills/resend/SKILL.md +107 -0
- package/resend/tovu-mail-adapters.json +22 -0
- package/s3-compatible/NOTES.md +11 -0
- package/s3-compatible/mcp.json +4 -0
- package/s3-compatible/object-stores/s3-compatible.mjs +287 -0
- package/s3-compatible/plugin.json +56 -0
- package/s3-compatible/skills/s3-compatible/SKILL.md +39 -0
- package/s3-compatible/targets/s3-compatible.mjs +1054 -0
- package/s3-compatible/tovu-deploy-targets.json +432 -0
- package/s3-compatible/tovu-object-stores.json +29 -0
- package/sentry/mcp.json +4 -0
- package/sentry/plugin.json +49 -0
- package/sentry/skills/sentry/SKILL.md +34 -0
- package/shopify/mcp.json +4 -0
- package/shopify/plugin.json +22 -0
- package/shopify/skills/shopify/SKILL.md +103 -0
- package/shopify/skills/shopify/references/catalog-sync.md +74 -0
- package/shopify/skills/shopify/references/domains.md +26 -0
- package/shopify/skills/shopify/references/live-storefront.md +34 -0
- package/shopify/skills/shopify/references/storefront-snippets.md +21 -0
- package/site-compliance/mcp.json +4 -0
- package/site-compliance/plugin.json +34 -0
- package/site-compliance/skills/site-compliance/SKILL.md +220 -0
- package/site-compliance/skills/site-compliance/references/ccpa-cpra.md +115 -0
- package/site-compliance/skills/site-compliance/references/gdpr-eu-uk.md +143 -0
- package/site-compliance/skills/site-compliance/references/wcag-2-2.md +129 -0
- package/site-import/mcp.json +4 -0
- package/site-import/plugin.json +41 -0
- package/site-import/skills/site-import/SKILL.md +232 -0
- package/site-import/skills/site-import/references/content-mapping.md +166 -0
- package/site-import/skills/site-import/references/discovery-and-classification.md +117 -0
- package/site-import/skills/site-import/references/theme-extraction.md +34 -0
- package/slide-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
- package/slide-design/mcp.json +4 -0
- package/slide-design/plugin.json +26 -0
- package/slide-design/skills/slide-design/SKILL.md +103 -0
- package/slide-design/skills/slide-design/references/charts-and-exhibits.md +100 -0
- package/slide-design/skills/slide-design/references/html-deck.md +175 -0
- package/slide-design/skills/slide-design/references/layout-and-style.md +131 -0
- package/slide-design/skills/slide-design/references/review.md +93 -0
- package/slide-design/skills/slide-design/references/storyline.md +118 -0
- package/stripe/plugin.json +22 -0
- package/supabase/mcp.json +26 -0
- package/supabase/plugin.json +78 -0
- package/supabase/skills/supabase/SKILL.md +169 -0
- package/supabase/skills/supabase/references/failure-modes.md +101 -0
- package/ui-ux-design/CHANGELOG.md +28 -0
- package/ui-ux-design/LICENSE +201 -0
- package/ui-ux-design/NOTICE +14 -0
- package/ui-ux-design/README.md +62 -0
- package/ui-ux-design/com.anthropic.claude-code/hooks/hooks.json +4 -0
- package/ui-ux-design/mcp.json +4 -0
- package/ui-ux-design/plugin.json +26 -0
- package/ui-ux-design/skills/frontend-accessibility/SKILL.md +60 -0
- package/ui-ux-design/skills/gstack-design/SKILL.md +53 -0
- package/ui-ux-design/skills/gstack-design/references/consultation.md +30 -0
- package/ui-ux-design/skills/gstack-design/references/html.md +29 -0
- package/ui-ux-design/skills/gstack-design/references/review.md +28 -0
- package/ui-ux-design/skills/gstack-design/references/shotgun.md +33 -0
- package/ui-ux-design/skills/gstack-design/references/upstream-notes.md +23 -0
- package/ui-ux-design/skills/interface-design/ORIGINAL.md +16 -0
- package/ui-ux-design/skills/interface-design/SKILL.md +41 -0
- package/ui-ux-design/skills/interface-design/references/commands/audit.md +40 -0
- package/ui-ux-design/skills/interface-design/references/commands/critique.md +21 -0
- package/ui-ux-design/skills/interface-design/references/commands/extract.md +34 -0
- package/ui-ux-design/skills/interface-design/references/commands/init.md +31 -0
- package/ui-ux-design/skills/interface-design/references/commands/status.md +22 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-precision.md +29 -0
- package/ui-ux-design/skills/interface-design/references/examples/system-warmth.md +29 -0
- package/ui-ux-design/skills/interface-design/references/system-template.md +57 -0
- package/ui-ux-design/skills/shadcn-ui/README.md +244 -0
- package/ui-ux-design/skills/shadcn-ui/SKILL.md +333 -0
- package/ui-ux-design/skills/shadcn-ui/examples/auth-layout.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/examples/data-table.tsx +313 -0
- package/ui-ux-design/skills/shadcn-ui/examples/form-pattern.tsx +177 -0
- package/ui-ux-design/skills/shadcn-ui/resources/component-catalog.md +481 -0
- package/ui-ux-design/skills/shadcn-ui/resources/customization-guide.md +516 -0
- package/ui-ux-design/skills/shadcn-ui/resources/migration-guide.md +463 -0
- package/ui-ux-design/skills/shadcn-ui/resources/setup-guide.md +412 -0
- package/ui-ux-design/skills/shadcn-ui/scripts/verify-setup.sh +134 -0
- package/ui-ux-design/skills/ui-ux-design/SKILL.md +171 -0
- package/ui-ux-design/skills/ui-ux-design/references/brand-and-voice.md +78 -0
- package/ui-ux-design/skills/ui-ux-design/references/chrome-mobile-test-matrix.md +137 -0
- package/ui-ux-design/skills/ui-ux-design/references/components-and-states.md +112 -0
- package/ui-ux-design/skills/ui-ux-design/references/delight-and-motion.md +54 -0
- package/ui-ux-design/skills/ui-ux-design/references/foundations.md +145 -0
- package/ui-ux-design/skills/ui-ux-design/references/inclusive-ai-imagery.md +57 -0
- package/ui-ux-design/skills/ui-ux-design/references/kole-jain-uiux-concepts.md +104 -0
- package/ui-ux-design/skills/ui-ux-design/references/kuinetic-reference.md +184 -0
- package/ui-ux-design/skills/ui-ux-design/references/openai-frontend-skill.md +139 -0
- package/ui-ux-design/skills/ui-ux-design/references/premium-ui.md +55 -0
- package/ui-ux-design/skills/ui-ux-design/references/research-and-validation.md +88 -0
- package/ui-ux-design/skills/ui-ux-design/references/sam-crawford-premium-websites.md +103 -0
- package/ui-ux-design/skills/ui-ux-design/references/self-made-web-designer-core-skills.md +119 -0
- package/ui-ux-design/skills/ui-ux-design/references/ui-animation.md +173 -0
- package/ui-ux-design/skills/ui-ux-design/references/ui-loop.md +234 -0
- package/ui-ux-design/skills/ui-ux-design/references/visual-storytelling.md +54 -0
- package/ui-ux-design/skills/vercel-web-design-guidelines/SKILL.md +39 -0
- package/ui-ux-design/skills/web-compliance/SKILL.md +134 -0
- package/ui-ux-design/skills/web-compliance/references/audit-evidence.md +59 -0
- package/vercel/NOTES.md +23 -0
- package/vercel/mcp.json +4 -0
- package/vercel/object-stores/vercel-blob.mjs +147 -0
- package/vercel/plugin.json +47 -0
- package/vercel/skills/vercel/SKILL.md +48 -0
- package/vercel/targets/vercel.mjs +586 -0
- package/vercel/tovu-consent.json +6 -0
- package/vercel/tovu-deploy-targets.json +219 -0
- package/vercel/tovu-object-stores.json +10 -0
- package/wordpress/NOTES.md +33 -0
- package/wordpress/plugin.json +30 -0
- package/wordpress/skills/wordpress/SKILL.md +43 -0
- package/wordpress/skills/wordpress/references/free-site-redirect.md +46 -0
- 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
|
+
};
|