@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,32 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { segment } from './api.mjs';
|
|
3
|
+
|
|
4
|
+
/** A stable identifier stamped as a Cloudflare DNS record comment so a later publish can recognize
|
|
5
|
+
* (and safely patch) a CNAME record it created itself, versus one an operator manages by hand.
|
|
6
|
+
* Genericized from the OD origin's `od:cfp:` marker prefix. Keep existing Pages markers byte-identical. */
|
|
7
|
+
export function dnsMarker({ resourceName, targetHost }, _options = {}) {
|
|
8
|
+
const short = ({ value }, _options = {}) => createHash('sha256').update(value).digest('hex').slice(0, 12);
|
|
9
|
+
return `jini-deploy:${short({ value: resourceName })}:${short({ value: targetHost })}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function matchesHostname({ pattern, hostname }, _options = {}) {
|
|
13
|
+
const hostPattern = pattern.replace(/^https?:\/\//, '').split('/')[0].toLowerCase();
|
|
14
|
+
const expression = hostPattern.split('*').map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*');
|
|
15
|
+
return new RegExp(`^${expression}$`).test(hostname);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Include mail/TXT/CAA in the review, but change only conflicting address records.
|
|
19
|
+
* Wildcard records are disclosed and preserved: exact hostname records override their DNS match. */
|
|
20
|
+
export function planDns({ records, hostname, targetId, resourceName, zoneId, preserveWorkerDns }, _options = {}) {
|
|
21
|
+
const targetHost = `${resourceName}.pages.dev`;
|
|
22
|
+
const relevant = records.filter(record => matchesHostname({ pattern: record.name, hostname }) || (record.type === 'CAA' && hostname.endsWith(`.${record.name}`)));
|
|
23
|
+
// A Custom Domain already on this Worker owns its origin DNS. Never tear down that binding's
|
|
24
|
+
// managed address records merely because they are not Pages-style CNAMEs.
|
|
25
|
+
if (preserveWorkerDns) return { records: relevant, changes: [], dnsOwnership: 'worker-managed' };
|
|
26
|
+
const addresses = relevant.filter(record => record.name === hostname && ['A', 'AAAA', 'CNAME'].includes(record.type));
|
|
27
|
+
const exact = targetId === 'cloudflare-pages' && addresses.find(record => record.type === 'CNAME' && record.content.replace(/\.$/, '') === targetHost);
|
|
28
|
+
const path = `/zones/${segment({ value: zoneId })}/dns_records`;
|
|
29
|
+
const changes = addresses.filter(record => record !== exact).map(record => ({ method: 'DELETE', path: `${path}/${segment({ value: record.id })}`, hostname, before: record, reason: `Replace ${record.type} record ${record.id} for ${hostname}.` }));
|
|
30
|
+
if (targetId === 'cloudflare-pages' && !exact) changes.push({ method: 'POST', path, hostname, body: { type: 'CNAME', name: hostname, content: targetHost, proxied: true, ttl: 1, comment: dnsMarker({ resourceName, targetHost }) }, reason: `Point ${hostname} to ${targetHost}; Cloudflare flattens the apex CNAME.` });
|
|
31
|
+
return { records: relevant, changes, dnsOwnership: exact?.comment === dnsMarker({ resourceName, targetHost }) ? 'marked' : 'unmarked' };
|
|
32
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { segment, refusal, PAGES_PROJECTS_PAGING } from './api.mjs';
|
|
2
|
+
import { matchesHostname } from './dns.mjs';
|
|
3
|
+
|
|
4
|
+
/** Full account inventory, including disabled/no-script bindings. Never filter by destination first.
|
|
5
|
+
* @complexity At most 100 pages/list and 100 Pages projects; refuse beyond the cap, never truncate. */
|
|
6
|
+
export async function readDomainInventory({ api, zone }, _options = {}) {
|
|
7
|
+
const records = await api.list({ path: `/zones/${segment({ value: zone.id })}/dns_records` });
|
|
8
|
+
const workers = await api.list({ path: `${api.accountPath}/workers/domains` });
|
|
9
|
+
const routes = await api.list({ path: `/zones/${segment({ value: zone.id })}/workers/routes` });
|
|
10
|
+
const projects = await api.list({ path: `${api.accountPath}/pages/projects` }, PAGES_PROJECTS_PAGING);
|
|
11
|
+
requireFields({ api, rows: records, fields: ['id', 'name', 'type', 'content'], label: 'DNS records' });
|
|
12
|
+
requireFields({ api, rows: workers, fields: ['id', 'hostname', 'service', 'environment'], label: 'Worker domains' });
|
|
13
|
+
requireFields({ api, rows: routes, fields: ['id', 'pattern'], label: 'Worker routes' });
|
|
14
|
+
requireFields({ api, rows: projects, fields: ['name'], label: 'Pages projects' });
|
|
15
|
+
if (projects.length > 100) throw refusal({ kit: api.kit, code: 'CLOUDFLARE_INVENTORY_UNKNOWN', message: 'More than 100 Pages projects need conflict inspection. Narrow the account or contact support before changing domains.' });
|
|
16
|
+
const pages = [];
|
|
17
|
+
for (const project of projects) {
|
|
18
|
+
const domains = await api.list({ path: `${api.accountPath}/pages/projects/${segment({ value: project.name })}/domains` }, PAGES_PROJECTS_PAGING);
|
|
19
|
+
requireFields({ api, rows: domains, fields: ['name', 'status'], label: `Pages project ${project.name} domains` });
|
|
20
|
+
pages.push(...domains.map(domain => ({ ...domain, projectName: project.name })));
|
|
21
|
+
}
|
|
22
|
+
return { records, workers, routes, pages };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requireFields({ api, rows, fields, label }, _options = {}) {
|
|
26
|
+
const complete = rows.every(row => fields.every(field => typeof row?.[field] === 'string' && row[field].length > 0));
|
|
27
|
+
if (!complete) throw refusal({ kit: api.kit, code: 'CLOUDFLARE_INVENTORY_UNKNOWN', message: `${label} returned incomplete identities. Check read permissions and preview again before changing domains.`, status: 409 });
|
|
28
|
+
if (new Set(rows.map(row => row[fields[0]])).size !== rows.length) throw refusal({ kit: api.kit, code: 'CLOUDFLARE_INVENTORY_UNKNOWN', message: `${label} changed while paginating (repeated identities). Preview again before changing domains.`, status: 409 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Current state and exact binding/route effects for one hostname; a route can affect BOTH names. */
|
|
32
|
+
export function planBindings({ inventory, hostname, targetId, resourceName, zone, accountPath }, _options = {}) {
|
|
33
|
+
const workerBindings = inventory.workers.filter(binding => binding.hostname === hostname);
|
|
34
|
+
const pagesBindings = inventory.pages.filter(binding => binding.name === hostname);
|
|
35
|
+
const routes = inventory.routes.filter(route => matchesHostname({ pattern: route.pattern, hostname }));
|
|
36
|
+
const workers = workerBindings.filter(binding => targetId !== 'cloudflare-workers' || binding.service !== resourceName || binding.environment !== 'production' || binding.enabled === false);
|
|
37
|
+
const pages = pagesBindings.filter(binding => targetId !== 'cloudflare-pages' || binding.projectName !== resourceName);
|
|
38
|
+
const changes = [
|
|
39
|
+
...workers.map(binding => ({ method: 'DELETE', path: `${accountPath}/workers/domains/${segment({ value: binding.id })}`, hostname, before: binding, reason: `Detach ${hostname} from Worker ${binding.service}.` })),
|
|
40
|
+
...pages.map(binding => ({ method: 'DELETE', path: `${accountPath}/pages/projects/${segment({ value: binding.projectName })}/domains/${segment({ value: hostname })}`, hostname, before: binding, reason: `Detach ${hostname} from Pages project ${binding.projectName}.` })),
|
|
41
|
+
...routes.map(route => ({ method: 'DELETE', path: `/zones/${segment({ value: zone.id })}/workers/routes/${segment({ value: route.id })}`, hostname, before: route, widerImpact: true, reason: `Remove entire overlapping route ${route.pattern} (${route.id}); this can affect other hostnames and paths. No automatic splitting.` })),
|
|
42
|
+
];
|
|
43
|
+
const owned = targetId === 'cloudflare-workers'
|
|
44
|
+
? workerBindings.some(binding => binding.service === resourceName && binding.environment === 'production' && binding.enabled !== false)
|
|
45
|
+
: pagesBindings.some(binding => binding.projectName === resourceName);
|
|
46
|
+
if (!owned) changes.push(attachment({ targetId, resourceName, zone, accountPath, hostname }));
|
|
47
|
+
if (owned && targetId === 'cloudflare-pages') changes.push(...pagesValidationRetry({ pagesBindings, resourceName, hostname, accountPath }));
|
|
48
|
+
return { workerBindings, pagesBindings, routes, changes, owned };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function pagesValidationRetry({ pagesBindings, resourceName, hostname, accountPath }, _options = {}) {
|
|
52
|
+
const current = pagesBindings.find(binding => binding.projectName === resourceName);
|
|
53
|
+
if (current.status === 'active') return [];
|
|
54
|
+
return [{ method: 'PATCH', path: `${accountPath}/pages/projects/${segment({ value: resourceName })}/domains/${segment({ value: hostname })}`, hostname, before: current,
|
|
55
|
+
reason: `Retry ${current.status} domain validation for ${hostname} on Pages project ${resourceName}.` }];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function attachment({ targetId, resourceName, zone, accountPath, hostname }, _options = {}) {
|
|
59
|
+
if (targetId === 'cloudflare-workers') return { method: 'PUT', path: `${accountPath}/workers/domains`, hostname, body: { hostname, service: resourceName, environment: 'production', zone_id: zone.id }, reason: `Attach ${hostname} to Worker ${resourceName}; Cloudflare manages DNS and certificates.` };
|
|
60
|
+
return { method: 'POST', path: `${accountPath}/pages/projects/${segment({ value: resourceName })}/domains`, hostname, body: { name: hostname }, reason: `Associate ${hostname} with Pages project ${resourceName} before creating its CNAME.` };
|
|
61
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { segment } from './api.mjs';
|
|
2
|
+
|
|
3
|
+
/** Bounded polling with injected clock/sleep and policy-enforcing HTTPS content transport.
|
|
4
|
+
* At most 6 rounds; unfinished TLS/content is partial even if the hosting URL is ready. */
|
|
5
|
+
export async function pollDomains({ api, kit, plan, proof }, { attempts = 6 } = {}) {
|
|
6
|
+
let states = [];
|
|
7
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
8
|
+
states = [];
|
|
9
|
+
const workers = plan.targetId === 'cloudflare-workers' ? await workerState({ api, plan }) : null;
|
|
10
|
+
for (const { hostname } of plan.hostnames) states.push(await safeProbeHostname({ api, kit, plan, proof, hostname, workers }));
|
|
11
|
+
if (states.every(state => state.status === 'ready')) return states;
|
|
12
|
+
if (attempt + 1 < attempts) await kit.sleep(Math.min(1000 * 2 ** attempt, 8000));
|
|
13
|
+
}
|
|
14
|
+
return states;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function safeProbeHostname(input, _options = {}) {
|
|
18
|
+
try { return await probeHostname(input); }
|
|
19
|
+
catch (error) { return { hostname: input.hostname, status: 'unknown', certificateStatus: 'unknown', contentVerified: false,
|
|
20
|
+
errorCode: error.code ?? 'CLOUDFLARE_DOMAIN_STATUS_UNKNOWN', message: `${input.hostname}: ${error.message} Check domain validation and read permissions in Cloudflare, then preview again.` }; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function workerState({ api, plan }, _options = {}) {
|
|
24
|
+
const bindings = await api.list({ path: `${api.accountPath}/workers/domains` });
|
|
25
|
+
const certificates = await api.request({ path: `/zones/${segment({ value: plan.zone.id })}/ssl/certificate_packs` });
|
|
26
|
+
// The certificate-packs list result is the array of packs; anything else proves no coverage.
|
|
27
|
+
return { bindings, certificates: Array.isArray(certificates) ? certificates : [] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function probeHostname({ api, kit, plan, proof, hostname, workers }, _options = {}) {
|
|
31
|
+
const state = workers ? workerHostnameState({ workers, plan, hostname }) : await pagesHostnameState({ api, plan, hostname });
|
|
32
|
+
const verified = state.active && typeof kit.probeContent === 'function' && await kit.probeContent({ url: `https://${hostname}${proof.path}`, expected: proof.expected });
|
|
33
|
+
return { hostname, url: `https://${hostname}`, status: verified ? 'ready' : 'pending', certificateStatus: state.certificateStatus, domainStatus: state.domainStatus ?? 'pending', contentVerified: Boolean(verified), message: verified ? `${hostname} serves the deployed content over HTTPS.` : `${hostname} is awaiting domain validation, certificate or deployed content. Recheck in Cloudflare, then preview and publish again to resume.` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function workerHostnameState({ workers, plan, hostname }, _options = {}) {
|
|
37
|
+
const active = workers.bindings.some(binding => binding.hostname === hostname && binding.service === plan.resourceName && binding.environment === 'production' && binding.enabled !== false);
|
|
38
|
+
const certificate = workers.certificates.find(pack => pack.status === 'active' && pack.hosts?.some(host => certificateMatches({ host, hostname })));
|
|
39
|
+
return { active: active && Boolean(certificate), domainStatus: active ? 'active' : 'pending', certificateStatus: certificate ? 'active' : 'pending' };
|
|
40
|
+
}
|
|
41
|
+
function certificateMatches({ host, hostname }, _options = {}) {
|
|
42
|
+
if (host === hostname) return true;
|
|
43
|
+
return host.startsWith('*.') && hostname.split('.').length === host.split('.').length && hostname.endsWith(host.slice(1));
|
|
44
|
+
}
|
|
45
|
+
async function pagesHostnameState({ api, plan, hostname }, _options = {}) {
|
|
46
|
+
const domain = await api.request({ path: `${api.accountPath}/pages/projects/${segment({ value: plan.resourceName })}/domains/${segment({ value: hostname })}` });
|
|
47
|
+
return { active: domain.status === 'active', domainStatus: domain.status, certificateStatus: domain.status === 'active' ? 'active' : 'pending' };
|
|
48
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { refusal } from './api.mjs';
|
|
3
|
+
import { normalizeDomain, resolveZone } from './zones.mjs';
|
|
4
|
+
import { planDns } from './dns.mjs';
|
|
5
|
+
import { readDomainInventory, planBindings } from './domain-inventory.mjs';
|
|
6
|
+
import { pollDomains } from './domain-status.mjs';
|
|
7
|
+
|
|
8
|
+
/** Authenticated reads only. Unknown inventory always produces an empty executable change list.
|
|
9
|
+
* Both hostnames retain current records, bindings, routes and individual states for the later UI. */
|
|
10
|
+
export async function previewDomains({ api, kit, targetId, resourceName, domain, zoneId }, _options = {}) {
|
|
11
|
+
const apex = normalizeDomain({ domain, kit });
|
|
12
|
+
const hostnames = [apex, `www.${apex}`];
|
|
13
|
+
try {
|
|
14
|
+
const zone = await resolveZone({ api, domain: apex, zoneId: zoneId ?? api.zoneId });
|
|
15
|
+
const inventory = await readDomainInventory({ api, zone });
|
|
16
|
+
const hosts = hostnames.map(hostname => planHostname({ inventory, hostname, targetId, resourceName, zone, accountPath: api.accountPath }));
|
|
17
|
+
// A wildcard route can occur in both host views. Remove it ONCE, with the full before-image.
|
|
18
|
+
const changes = [...new Map(hosts.flatMap(host => host.changes).map(change => [`${change.method}:${change.path}:${JSON.stringify(change.body)}`, change])).values()];
|
|
19
|
+
const details = { targetId, resourceName, accountId: api.accountId, zone, hostnames: hosts, changes };
|
|
20
|
+
return { ...details, inventoryStatus: 'complete', fingerprint: fingerprint({ details }), message: 'Review every change. DNS, binding and certificate propagation can interrupt the existing site. Mail, TXT and CAA records are preserved.' };
|
|
21
|
+
} catch (error) {
|
|
22
|
+
return { inventoryStatus: 'unknown', targetId, resourceName, hostnames: hostnames.map(hostname => ({ hostname, status: 'unknown' })), changes: [], errorCode: 'CLOUDFLARE_INVENTORY_UNKNOWN', message: `Cannot safely inspect ${apex}. ${error.message} Complete account/zone read permissions and preview again.` };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function planHostname(input, _options = {}) {
|
|
27
|
+
const bindings = planBindings(input);
|
|
28
|
+
const dns = planDns({ ...input, records: input.inventory.records, zoneId: input.zone.id, preserveWorkerDns: input.targetId === 'cloudflare-workers' && bindings.owned });
|
|
29
|
+
// Pages must be associated before DNS points at it. Conflicting records/bindings go first.
|
|
30
|
+
const all = [...bindings.changes, ...dns.changes];
|
|
31
|
+
const removals = all.filter(change => change.method === 'DELETE');
|
|
32
|
+
const additions = [...bindings.changes, ...dns.changes].filter(change => change.method !== 'DELETE');
|
|
33
|
+
return { hostname: input.hostname, status: all.length ? 'changes-required' : 'configured', certificateStatus: 'unknown', contentVerified: false,
|
|
34
|
+
records: dns.records, dnsOwnership: dns.dnsOwnership, workerBindings: bindings.workerBindings, pagesBindings: bindings.pagesBindings, routes: bindings.routes, changes: [...removals, ...additions] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Sort keys and inventory arrays so API response ordering alone cannot invalidate consent. */
|
|
38
|
+
function canonical({ value }, _options = {}) {
|
|
39
|
+
if (Array.isArray(value)) return value.map(item => canonical({ value: item })).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
|
40
|
+
if (value !== null && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical({ value: value[key] })]));
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
function fingerprint({ details }, _options = {}) {
|
|
44
|
+
return createHash('sha256').update(JSON.stringify(canonical({ value: details }))).digest('hex');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** An approval is a server-held capability, never a serializable confirmed:true input.
|
|
48
|
+
* Reinspection happens BEFORE uploads/provisioning, so stale/unknown plans cause zero mutations. */
|
|
49
|
+
export async function assertDomainApproval(input, _options = {}) {
|
|
50
|
+
const { api, kit, approval } = input;
|
|
51
|
+
if (typeof approval?.authorize !== 'function') throw refusal({ kit, code: 'CLOUDFLARE_DOMAIN_CONFIRMATION_REQUIRED', message: `Review the exact domain preview for ${input.domain} and confirm it through deployment_execute_static_publish in chat. A request flag cannot approve DNS or binding changes.`, status: 409 });
|
|
52
|
+
const plan = await previewDomains(input);
|
|
53
|
+
if (plan.inventoryStatus !== 'complete') throw refusal({ kit, code: 'CLOUDFLARE_INVENTORY_UNKNOWN', message: plan.message, status: 409 });
|
|
54
|
+
if (approval.plan.fingerprint !== plan.fingerprint) throw refusal({ kit, code: 'CLOUDFLARE_DOMAIN_PLAN_STALE', message: `${input.domain} changed since review. Preview and confirm the new records, bindings and routes.`, status: 409 });
|
|
55
|
+
if (!await approval.authorize({ fingerprint: plan.fingerprint, accountId: api.accountId })) throw refusal({ kit, code: 'CLOUDFLARE_DOMAIN_CONFIRMATION_REQUIRED', message: `Confirmation expired or does not match ${input.domain}. Preview and confirm again.`, status: 409 });
|
|
56
|
+
return { ...plan, authorize: approval.authorize };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Apply only the verified, human-approved change list. No rollback or step journal by owner scope.
|
|
60
|
+
* A failure remains partial and names all unfinished hostnames; rerun a fresh preview to resume. */
|
|
61
|
+
export async function applyDomains({ api, kit, plan, proof }, _options = {}) {
|
|
62
|
+
try {
|
|
63
|
+
if (typeof plan.authorize !== 'function' || !await plan.authorize({ fingerprint: plan.fingerprint, accountId: api.accountId })) {
|
|
64
|
+
throw refusal({ kit, code: 'CLOUDFLARE_DOMAIN_CONFIRMATION_REQUIRED', message: `${plan.zone.name} needs a current human-confirmed preview. Preview and confirm again; no domain changes were applied.`, status: 409 });
|
|
65
|
+
}
|
|
66
|
+
// Uploads may take time. Verify the same approved write set again immediately before cutover.
|
|
67
|
+
const current = await previewDomains({ api, kit, targetId: plan.targetId, resourceName: plan.resourceName, domain: plan.zone.name, zoneId: plan.zone.id });
|
|
68
|
+
if (current.fingerprint !== plan.fingerprint) throw refusal({ kit, code: 'CLOUDFLARE_DOMAIN_PLAN_STALE', message: `${plan.zone.name} changed while hosting uploaded. Preview and confirm the new state; no domain changes were applied.`, status: 409 });
|
|
69
|
+
for (const change of plan.changes) await applyChange({ api, kit, change });
|
|
70
|
+
return await pollDomains({ api, kit, plan, proof });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return failedCutover({ api, kit, plan, proof, error });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function applyChange({ api, kit, change }, _options = {}) {
|
|
77
|
+
if (change.method === 'DELETE' && change.path.includes('/dns_records/')) {
|
|
78
|
+
// Detaching a Worker Custom Domain can remove its DNS record for us. Re-read the collection
|
|
79
|
+
// instead of blindly deleting a now-missing record, or deleting an ID changed concurrently.
|
|
80
|
+
const path = change.path.slice(0, change.path.lastIndexOf('/'));
|
|
81
|
+
const records = await api.list({ path });
|
|
82
|
+
const current = records.find(record => record.id === change.before.id);
|
|
83
|
+
if (!current) return;
|
|
84
|
+
if (fingerprint({ details: current }) !== fingerprint({ details: change.before })) throw refusal({ kit, code: 'CLOUDFLARE_DOMAIN_PLAN_STALE', message: `${change.hostname} DNS record ${change.before.id} changed during cutover. Preview and confirm again.`, status: 409 });
|
|
85
|
+
}
|
|
86
|
+
await api.request({ path: change.path, method: change.method, ...(change.body ? { body: change.body } : {}) });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function failedCutover({ api, kit, plan, proof, error }, _options = {}) {
|
|
90
|
+
let states;
|
|
91
|
+
try { states = await pollDomains({ api, kit, plan, proof }, { attempts: 1 }); }
|
|
92
|
+
catch { states = plan.hostnames.map(({ hostname }) => ({ hostname, certificateStatus: 'unknown', contentVerified: false })); }
|
|
93
|
+
return states.map(state => ({ ...state, status: state.status === 'ready' ? 'ready' : 'failed', operationFailed: true, errorCode: error.code ?? 'CLOUDFLARE_DOMAIN_FAILED',
|
|
94
|
+
message: `${state.hostname}: ${error.message} Hosting is retained. Preview again to inspect and resume the unfinished domain changes.` }));
|
|
95
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { refusal, segment } from './api.mjs';
|
|
3
|
+
|
|
4
|
+
/** Form submissions destination: one D1 database per site Worker plus the bundled receiver.
|
|
5
|
+
* Forms, fields and full validation stay in the host; this file only provisions storage and transport.
|
|
6
|
+
* The `TOVU_*` bindings, `tovu-forms-*` database names and `/_tovu/*` routes are frozen wire names:
|
|
7
|
+
* live Workers and D1 databases already use them. */
|
|
8
|
+
export const FORMS_BINDING = 'TOVU_FORMS';
|
|
9
|
+
export const FORMS_SECRET_BINDING = 'TOVU_FORMS_SECRET';
|
|
10
|
+
/** Routed to the Worker before static assets, so a missing asset can never answer a form POST. */
|
|
11
|
+
export const FORMS_WORKER_ROUTES = ['/forms/*', '/_tovu/*'];
|
|
12
|
+
export const FREE_PLAN_NOTE = 'Free plan limits: 100,000 Worker requests/day, D1 100,000 row writes/day and 500 MB per database (shared with the rest of this Cloudflare account).';
|
|
13
|
+
const D1_PAGING = { perPage: 100, maxPages: 10 };
|
|
14
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,99}$/;
|
|
15
|
+
const FIELD_NAME = /^[A-Za-z0-9_.[\]-]{1,100}$/;
|
|
16
|
+
const MAX_FORMS = 50;
|
|
17
|
+
const MAX_FIELDS = 100;
|
|
18
|
+
|
|
19
|
+
/** Forward-only, plugin-owned. Append a new version; never edit or reorder an applied one. */
|
|
20
|
+
export const MIGRATIONS = Object.freeze([
|
|
21
|
+
{ version: 1, sql: [
|
|
22
|
+
'CREATE TABLE IF NOT EXISTS submissions (seq INTEGER PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL UNIQUE, form_slug TEXT NOT NULL, answers_json TEXT NOT NULL, submitted_at TEXT NOT NULL, ip TEXT NULL, pulled_at TEXT NULL)',
|
|
23
|
+
'CREATE INDEX IF NOT EXISTS submissions_pending ON submissions (pulled_at, seq)',
|
|
24
|
+
'CREATE TABLE IF NOT EXISTS rate_windows (key TEXT PRIMARY KEY, window_start INTEGER NOT NULL, count INTEGER NOT NULL)',
|
|
25
|
+
'CREATE INDEX IF NOT EXISTS rate_windows_start ON rate_windows (window_start)',
|
|
26
|
+
] },
|
|
27
|
+
]);
|
|
28
|
+
const MIGRATIONS_TABLE = 'CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)';
|
|
29
|
+
|
|
30
|
+
/** Validates the host's generic `formDestination` input. `null` means no active forms: assets-only Worker.
|
|
31
|
+
* @complexity O(forms * fields), both capped. */
|
|
32
|
+
export function normalizeForms({ formDestination, kit }, { requireSecret = true } = {}) {
|
|
33
|
+
const forms = formDestination?.forms ?? [];
|
|
34
|
+
if (!Array.isArray(forms) || forms.length > MAX_FORMS) throw formsRefusal({ kit, message: `Expected at most ${MAX_FORMS} forms in the form manifest. Disable unused forms in the site admin, then publish again.` });
|
|
35
|
+
if (forms.length === 0) return null;
|
|
36
|
+
const secret = formDestination.secret;
|
|
37
|
+
if (requireSecret) checkReceiverSecret({ secret, kit });
|
|
38
|
+
return { forms: forms.map(form => normalizeForm({ form, kit })), secret };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function checkReceiverSecret({ secret, kit }, _options = {}) {
|
|
42
|
+
if (typeof secret !== 'string' || secret.length < 32) throw formsRefusal({ kit, message: 'The form receiver key is missing. Publish again from the site admin so it can create the key.' });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeForm({ form, kit }, _options = {}) {
|
|
46
|
+
if (!SLUG.test(form?.slug ?? '')) throw formsRefusal({ kit, message: `Form slug "${String(form?.slug).slice(0, 100)}" cannot be served here. Use lowercase letters, digits and hyphens, then publish again.` });
|
|
47
|
+
checkFieldCount({ form, kit });
|
|
48
|
+
const successPath = form.successPath;
|
|
49
|
+
checkSuccessPath({ form, successPath, kit });
|
|
50
|
+
return { slug: form.slug, fields: form.fields.map(field => normalizeField({ field, kit, slug: form.slug })), ...(successPath ? { successPath } : {}) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function checkFieldCount({ form, kit }, _options = {}) {
|
|
54
|
+
if (!Array.isArray(form.fields) || form.fields.length === 0 || form.fields.length > MAX_FIELDS) throw formsRefusal({ kit, message: `Form ${form.slug} needs 1-${MAX_FIELDS} fields. Fix the form in the site admin, then publish again.` });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function checkSuccessPath({ form, successPath, kit }, _options = {}) {
|
|
58
|
+
if (successPath === undefined) return;
|
|
59
|
+
if (typeof successPath !== 'string' || !/^\/(?![/\\])[^\\\s]{0,499}$/.test(successPath)) throw formsRefusal({ kit, message: `Form ${form.slug} has an unusable success page. Use a path on this site starting with "/".` });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeField({ field, kit, slug }, _options = {}) {
|
|
63
|
+
if (!FIELD_NAME.test(field?.name ?? '')) throw formsRefusal({ kit, message: `Form ${slug} has a field name that cannot be served here. Rename the field in the site admin, then publish again.` });
|
|
64
|
+
const max = field.maxLength;
|
|
65
|
+
checkMaxLength({ max, kit, slug, name: field.name });
|
|
66
|
+
return { name: field.name, required: field.required === true, ...(Number.isInteger(max) ? { maxLength: max } : {}), ...(field.multiple === true ? { multiple: true } : {}) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function checkMaxLength({ max, kit, slug, name }, _options = {}) {
|
|
70
|
+
if (max === undefined || max === null) return;
|
|
71
|
+
if (!(Number.isInteger(max) && max >= 1 && max <= 5000)) throw formsRefusal({ kit, message: `Form ${slug} field ${name} has an invalid maximum length. Use 1-5000.` });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formsRefusal({ kit, message, status = 400, code = 'CLOUDFLARE_FORMS_INVALID' }, _options = {}) {
|
|
75
|
+
return refusal({ kit, code, message, status });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Stable per-site identity derived from the Worker name; never random, so republish reuses it. */
|
|
79
|
+
export function databaseName({ resourceName }, _options = {}) {
|
|
80
|
+
return `tovu-forms-${resourceName}`.slice(0, 63);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const PERMISSION_STEP = 'Edit the saved Cloudflare API token: add Account > D1 > Edit (dashboard: My Profile > API Tokens > the token > Edit), keep its existing Workers permissions, then preview again.';
|
|
84
|
+
|
|
85
|
+
/** Exact-name lookup. A 401/403 is reported as the missing D1 permission, never as "absent". */
|
|
86
|
+
export async function findDatabase({ api, kit, name }, _options = {}) {
|
|
87
|
+
try {
|
|
88
|
+
const items = await api.list({ path: `${api.accountPath}/d1/database?name=${segment({ value: name })}` }, D1_PAGING);
|
|
89
|
+
return items.find(item => item.name === name) ?? null;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error.status === 401 || error.status === 403) throw formsRefusal({ kit, code: 'CLOUDFLARE_D1_PERMISSION_REQUIRED', status: 403, message: `Form submissions need D1 access. ${PERMISSION_STEP}` });
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Read-only preview lines for the publish confirmation. Pages has no receiver in v1.
|
|
97
|
+
* @complexity One bounded D1 inventory read. */
|
|
98
|
+
export async function previewForms({ api, kit, resourceName, targetId, formDestination }, _options = {}) {
|
|
99
|
+
const normalized = normalizeForms({ formDestination, kit }, { requireSecret: false });
|
|
100
|
+
if (!normalized) return undefined;
|
|
101
|
+
const count = normalized.forms.length;
|
|
102
|
+
if (targetId !== 'cloudflare-workers') {
|
|
103
|
+
return { status: 'unsupported', forms: count, changes: [], message: `This site has ${count} active form(s), but Cloudflare Pages publishing cannot receive submissions yet. Publish to Cloudflare Workers to accept form submissions; on Pages the forms will not submit.` };
|
|
104
|
+
}
|
|
105
|
+
const name = databaseName({ resourceName });
|
|
106
|
+
let existing;
|
|
107
|
+
try { existing = await findDatabase({ api, kit, name }); }
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error.code !== 'CLOUDFLARE_D1_PERMISSION_REQUIRED') throw error;
|
|
110
|
+
return { status: 'permission-required', forms: count, changes: [], message: error.message.replace(/^CLOUDFLARE_D1_PERMISSION_REQUIRED: /, '') };
|
|
111
|
+
}
|
|
112
|
+
return { status: 'on', forms: count,
|
|
113
|
+
changes: [{ action: existing ? 'reuse' : 'create', resource: 'd1-database', name, ...(existing ? { databaseId: existing.uuid } : {}) },
|
|
114
|
+
{ action: 'bind', resource: 'worker', resourceName, routes: FORMS_WORKER_ROUTES }],
|
|
115
|
+
message: `Form submissions: on (${count} form${count === 1 ? '' : 's'}). Submissions are stored in D1 database ${name} (${existing ? 'reused' : 'created'}) in this Cloudflare account and pulled into the site admin on demand. ${FREE_PLAN_NOTE}` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Find-or-create the database, then apply pending migrations. Never drops or recreates anything.
|
|
119
|
+
* @complexity One inventory read, at most one create, 2 + pending-migration queries. */
|
|
120
|
+
export async function provisionForms({ api, kit, resourceName }, _options = {}) {
|
|
121
|
+
const name = databaseName({ resourceName });
|
|
122
|
+
const database = await findDatabase({ api, kit, name }) ?? await createDatabase({ api, kit, name });
|
|
123
|
+
if (typeof database?.uuid !== 'string' || !database.uuid) throw formsRefusal({ kit, code: 'CLOUDFLARE_D1_INVALID', status: 502, message: `D1 database ${name} returned no id. Check it in the Cloudflare dashboard, then publish again.` });
|
|
124
|
+
const applied = await migrate({ api, kit, databaseId: database.uuid });
|
|
125
|
+
return { databaseId: database.uuid, databaseName: name, applied };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function createDatabase({ api, kit, name }, _options = {}) {
|
|
129
|
+
try { return await api.request({ path: `${api.accountPath}/d1/database`, method: 'POST', body: { name } }); }
|
|
130
|
+
catch (error) {
|
|
131
|
+
// A concurrent publish may have created it first; the exact-name lookup is authoritative.
|
|
132
|
+
if (!error.details?.alreadyExists) throw error;
|
|
133
|
+
return findDatabase({ api, kit, name });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function query({ api, databaseId, sql, params = [] }, _options = {}) {
|
|
138
|
+
const result = await api.request({ path: `${api.accountPath}/d1/database/${segment({ value: databaseId })}/query`, method: 'POST', body: { sql, params } });
|
|
139
|
+
return Array.isArray(result) ? result.flatMap(item => item?.results ?? []) : [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Applies each missing version in order; returns the versions applied by this call. */
|
|
143
|
+
export async function migrate({ api, kit, databaseId }, _options = {}) {
|
|
144
|
+
await query({ api, databaseId, sql: MIGRATIONS_TABLE });
|
|
145
|
+
const rows = await query({ api, databaseId, sql: 'SELECT version FROM schema_migrations' });
|
|
146
|
+
const done = new Set(rows.map(row => Number(row.version)));
|
|
147
|
+
const applied = [];
|
|
148
|
+
for (const migration of MIGRATIONS.filter(item => !done.has(item.version))) {
|
|
149
|
+
// Every statement is idempotent (IF NOT EXISTS), so a version interrupted before its marker reruns safely.
|
|
150
|
+
// One statement per request: the REST endpoint binds `params` to a single statement.
|
|
151
|
+
for (const sql of migration.sql) await query({ api, databaseId, sql });
|
|
152
|
+
await query({ api, databaseId, sql: 'INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (?, ?)', params: [migration.version, new Date(kit.now?.() ?? Date.now()).toISOString()] });
|
|
153
|
+
applied.push(migration.version);
|
|
154
|
+
}
|
|
155
|
+
return applied;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let receiverSource;
|
|
159
|
+
/** The receiver module text, read once from this plugin's own folder (path-separator safe). */
|
|
160
|
+
export async function receiverModule(_required = {}, _options = {}) {
|
|
161
|
+
receiverSource ??= await readFile(new URL('../worker/forms-receiver.mjs', import.meta.url), 'utf8');
|
|
162
|
+
return receiverSource;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const MAX_PAGE_ROUTES = 90;
|
|
166
|
+
const FORM_MARKER = /<form\b[^>]*\bdata-form-slug="/i;
|
|
167
|
+
/** Exact routes of exported pages that contain one of the site's forms, so the Worker runs first on
|
|
168
|
+
* them and can show the "sent" state after a submission. Other pages and every asset stay on the free
|
|
169
|
+
* static path. Over the cap only the first pages are listed; the submission itself works everywhere.
|
|
170
|
+
* @complexity O(HTML bytes). */
|
|
171
|
+
export function formPageRoutes({ files }, { max = MAX_PAGE_ROUTES } = {}) {
|
|
172
|
+
const routes = [];
|
|
173
|
+
for (const file of files ?? []) {
|
|
174
|
+
const path = String(file.file ?? '').replaceAll('\\', '/').replace(/^\.?\//, '');
|
|
175
|
+
if (path.endsWith('.html') && FORM_MARKER.test(Buffer.from(file.data ?? '').toString('utf8'))) {
|
|
176
|
+
const page = routesForPage({ path });
|
|
177
|
+
if (routes.length + page.length > max) break;
|
|
178
|
+
routes.push(...page);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return routes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `a/index.html` answers at `/a/` and `/a`; `a.html` at `/a.html` and `/a` (auto-trailing-slash). */
|
|
185
|
+
function routesForPage({ path }, _options = {}) {
|
|
186
|
+
if (path === 'index.html') return ['/'];
|
|
187
|
+
if (path.endsWith('/index.html')) { const dir = `/${path.slice(0, -'index.html'.length)}`; return [dir, dir.slice(0, -1)]; }
|
|
188
|
+
return [`/${path}`, `/${path.slice(0, -'.html'.length)}`];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Worker modules and bindings for a site with forms. The manifest is compiled in; visitors never supply it. */
|
|
192
|
+
export async function formsWorkerParts({ manifest, secret, databaseId }, { pageRoutes = [] } = {}) {
|
|
193
|
+
const main = "import { handleRequest } from './receiver.mjs';\nimport manifest from './forms-manifest.mjs';\nexport default { fetch: (request, env) => handleRequest({ request, env, manifest }) };\n";
|
|
194
|
+
return {
|
|
195
|
+
modules: [
|
|
196
|
+
{ name: 'index.mjs', source: main },
|
|
197
|
+
{ name: 'receiver.mjs', source: await receiverModule() },
|
|
198
|
+
{ name: 'forms-manifest.mjs', source: `export default ${JSON.stringify({ forms: manifest.forms })};\n` },
|
|
199
|
+
],
|
|
200
|
+
bindings: [{ type: 'd1', name: FORMS_BINDING, id: databaseId }, { type: 'secret_text', name: FORMS_SECRET_BINDING, text: secret }],
|
|
201
|
+
runWorkerFirst: [...FORMS_WORKER_ROUTES, ...pageRoutes],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Private pull API client. The receiver key travels only in the Authorization header. */
|
|
206
|
+
async function receiverCall({ kit, destination, path, init = {} }, _options = {}) {
|
|
207
|
+
const origin = receiverOrigin({ kit, url: destination?.url });
|
|
208
|
+
if (typeof destination.secret !== 'string' || destination.secret.length < 32) throw formsRefusal({ kit, code: 'CLOUDFLARE_FORMS_KEY_REQUIRED', message: 'The form receiver key is missing. Republish the site so the host can reinstall it, then pull again.' });
|
|
209
|
+
const resp = await receiverFetch({ kit, origin, path, init, secret: destination.secret });
|
|
210
|
+
checkReceiverStatus({ kit, origin, resp });
|
|
211
|
+
try { return await resp.json(); }
|
|
212
|
+
catch { throw formsRefusal({ kit, code: 'CLOUDFLARE_FORMS_INVALID_RESPONSE', status: 502, message: `${origin} returned unreadable data. Republish the site, then pull again.` }); }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function receiverFetch({ kit, origin, path, init, secret }, _options = {}) {
|
|
216
|
+
try { return await kit.fetch(`${origin}${path}`, { ...init, headers: { ...init.headers, Authorization: `Bearer ${secret}` }, redirect: 'error' }, { timeoutMs: kit.timeouts.QUICK }); }
|
|
217
|
+
catch { throw formsRefusal({ kit, code: 'CLOUDFLARE_FORMS_UNREACHABLE', status: 502, message: `${origin} could not be reached. Check the site is published, then pull again.` }); }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function checkReceiverStatus({ kit, origin, resp }, _options = {}) {
|
|
221
|
+
if (resp.status === 401) throw formsRefusal({ kit, code: 'CLOUDFLARE_FORMS_UNAUTHORIZED', status: 401, message: `${origin} refused the form receiver key. Republish the site to reinstall the key, then pull again.` });
|
|
222
|
+
if (resp.status === 404 || resp.status === 405) throw formsRefusal({ kit, code: 'CLOUDFLARE_FORMS_RECEIVER_MISSING', status: 404, message: `${origin} has no form receiver. Republish the site with at least one active form, then pull again.` });
|
|
223
|
+
if (!resp.ok) throw formsRefusal({ kit, code: 'CLOUDFLARE_FORMS_UNAVAILABLE', status: 502, message: `${origin} answered HTTP ${resp.status}. Retry the pull in a few minutes.` });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function receiverOrigin({ kit, url }, _options = {}) {
|
|
227
|
+
let parsed;
|
|
228
|
+
try { parsed = new URL(url); } catch { parsed = null; }
|
|
229
|
+
if (parsed?.protocol !== 'https:') throw formsRefusal({ kit, code: 'CLOUDFLARE_FORMS_ADDRESS', message: 'The published site address is missing or not https. Republish the site, then pull again.' });
|
|
230
|
+
return parsed.origin;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const hasRowIdentity = item => Number.isSafeInteger(item?.seq) && typeof item.id === 'string' && item.id.length > 0 && item.id.length <= 64;
|
|
234
|
+
const hasRowBody = item => typeof item.formSlug === 'string' && item.answers !== null && typeof item.answers === 'object' && !Array.isArray(item.answers);
|
|
235
|
+
const hasRowMeta = item => typeof item.submittedAt === 'string' && (item.ip === null || typeof item.ip === 'string');
|
|
236
|
+
const isPending = item => hasRowIdentity(item) && hasRowBody(item) && hasRowMeta(item);
|
|
237
|
+
|
|
238
|
+
function pendingPath({ after, limit }, _options = {}) {
|
|
239
|
+
return `/_tovu/forms/pending?after=${Number(after) || 0}&limit=${Math.min(100, Math.max(1, Number(limit) || 50))}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** One page of unpulled submissions, oldest first. */
|
|
243
|
+
export async function pullFormSubmissions({ kit, destination }, { after = 0, limit = 50 } = {}) {
|
|
244
|
+
const page = await receiverCall({ kit, destination, path: pendingPath({ after, limit }) });
|
|
245
|
+
const items = page?.items;
|
|
246
|
+
if (!Array.isArray(items) || items.length > 100 || !items.every(isPending)) throw formsRefusal({ kit, code: 'CLOUDFLARE_FORMS_INVALID_RESPONSE', status: 502, message: 'The form receiver returned an unexpected page. Republish the site, then pull again.' });
|
|
247
|
+
return { items, nextAfter: Number(page.nextAfter) || 0, hasMore: page.hasMore === true };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Marks rows the host has committed; only then are they no longer pending. */
|
|
251
|
+
export async function acknowledgeFormSubmissions({ kit, destination, ids }, _options = {}) {
|
|
252
|
+
const result = await receiverCall({ kit, destination, path: '/_tovu/forms/ack', init: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) } });
|
|
253
|
+
return { acknowledged: Number(result?.acknowledged) || 0 };
|
|
254
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { refusal, segment } from './api.mjs';
|
|
2
|
+
import { chunkUploads } from './assets.mjs';
|
|
3
|
+
|
|
4
|
+
/** Pages' stable project address, shared by render preparation and upload results. O(name bytes). */
|
|
5
|
+
export function pagesAddress({ resourceName }, _options = {}) { return `https://${resourceName}.pages.dev`; }
|
|
6
|
+
|
|
7
|
+
/** Retains project names, upload tokens, hash dedup/upsert and config-file form fields.
|
|
8
|
+
* Root `_headers`/`_redirects` are their own deployment form fields, not assets (wrangler's
|
|
9
|
+
* `pages deploy` does the same and keeps them out of the manifest): uploaded as an asset, `_headers`
|
|
10
|
+
* would be served as a public file and its rules never applied. Only the root copy is config; a
|
|
11
|
+
* nested `_headers` stays an ordinary asset.
|
|
12
|
+
* @complexity O(unique assets + bytes) and bounded batches (100 files / 75 MiB encoded). */
|
|
13
|
+
export async function uploadPages({ api, kit, resourceName, prepared }, _options = {}) {
|
|
14
|
+
const path = `${api.accountPath}/pages/projects/${segment({ value: resourceName })}`;
|
|
15
|
+
await ensureProject({ api, path, resourceName });
|
|
16
|
+
const token = await api.request({ path: `${path}/upload-token` });
|
|
17
|
+
if (!token.jwt) throw refusal({ kit, code: 'CLOUDFLARE_UPLOAD_TOKEN_REQUIRED', message: `Pages project ${resourceName} returned no upload token. Check Pages Edit permission and retry.`, status: 502 });
|
|
18
|
+
await uploadAssets({ api, kit, prepared, jwt: token.jwt });
|
|
19
|
+
const form = new FormData();
|
|
20
|
+
form.append('manifest', JSON.stringify(Object.fromEntries(Object.entries(prepared.manifest).map(([path, file]) => [path, file.hash]))));
|
|
21
|
+
form.append('branch', 'main');
|
|
22
|
+
for (const [name, data] of Object.entries(prepared.config)) form.append(name, new File([data], name));
|
|
23
|
+
const deployment = await api.request({ path: `${path}/deployments`, method: 'POST', body: form }, { timeoutMs: kit.timeouts.DEPLOY });
|
|
24
|
+
return { url: pagesAddress({ resourceName }), deploymentId: deployment.id };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function ensureProject({ api, path, resourceName }, _options = {}) {
|
|
28
|
+
if (await api.request({ path }, { allowMissing: true })) return;
|
|
29
|
+
try { await api.request({ path: `${api.accountPath}/pages/projects`, method: 'POST', body: { name: resourceName, production_branch: 'main' } }, { timeoutMs: api.kit.timeouts.DEPLOY }); }
|
|
30
|
+
catch (error) {
|
|
31
|
+
// A concurrent publisher can create the same stable project between GET and POST.
|
|
32
|
+
if (error.status !== 409 && !error.details?.alreadyExists) throw error;
|
|
33
|
+
await api.request({ path });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function uploadAssets({ api, kit, prepared, jwt }, _options = {}) {
|
|
38
|
+
const hashes = [...prepared.byHash.keys()];
|
|
39
|
+
const result = await api.request({ path: '/pages/assets/check-missing', method: 'POST', jwt, body: { hashes } });
|
|
40
|
+
const missing = Array.isArray(result) ? result : result.hashes;
|
|
41
|
+
if (!Array.isArray(missing)) throw refusal({ kit, code: 'CLOUDFLARE_ASSET_LOOKUP_INVALID', message: 'Pages returned an incomplete missing-assets list. Retry publishing.', status: 502 });
|
|
42
|
+
const files = missing.map(hash => {
|
|
43
|
+
const file = prepared.byHash.get(hash);
|
|
44
|
+
if (!file) throw refusal({ kit, code: 'CLOUDFLARE_ASSET_BUCKET_INVALID', message: `Pages requested unknown asset hash ${hash}. Retry publishing.`, status: 502 });
|
|
45
|
+
return file;
|
|
46
|
+
});
|
|
47
|
+
for (const batch of chunkUploads({ files })) {
|
|
48
|
+
await api.request({ path: '/pages/assets/upload', method: 'POST', jwt, body: batch.map(file => ({ key: file.hash, value: file.data.toString('base64'), metadata: { contentType: file.contentType }, base64: true })) }, { timeoutMs: kit.timeouts.UPLOAD });
|
|
49
|
+
}
|
|
50
|
+
await api.request({ path: '/pages/assets/upsert-hashes', method: 'POST', jwt, body: { hashes } });
|
|
51
|
+
}
|