@nextcommerce/campaigns-os 1.33.0
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/AGENTS.md +204 -0
- package/CHANGELOG.md +5002 -0
- package/CONTEXT.md +685 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +368 -0
- package/agents/claude/CLAUDE.md +32 -0
- package/agents/codex/AGENTS.md +27 -0
- package/agents/copilot/copilot-instructions.md +14 -0
- package/agents/cursor/campaigns-os.mdc +13 -0
- package/bin/campaigns-os.mjs +38 -0
- package/campaign-spec/README.md +138 -0
- package/campaign-spec/dist/analytics-vocabulary.d.ts +47 -0
- package/campaign-spec/dist/analytics-vocabulary.js +74 -0
- package/campaign-spec/dist/index.d.ts +40 -0
- package/campaign-spec/dist/index.js +77 -0
- package/campaign-spec/dist/normalize.d.ts +22 -0
- package/campaign-spec/dist/normalize.js +41 -0
- package/campaign-spec/dist/routing.d.ts +190 -0
- package/campaign-spec/dist/routing.js +263 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +303 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.d.ts +42 -0
- package/campaign-spec/dist/rules/assembly-hints-shape.js +191 -0
- package/campaign-spec/dist/rules/campaign-metadata.d.ts +14 -0
- package/campaign-spec/dist/rules/campaign-metadata.js +40 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-has-success-url.js +66 -0
- package/campaign-spec/dist/rules/cycle-detection.d.ts +12 -0
- package/campaign-spec/dist/rules/cycle-detection.js +141 -0
- package/campaign-spec/dist/rules/design-source-shape.d.ts +29 -0
- package/campaign-spec/dist/rules/design-source-shape.js +142 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.d.ts +11 -0
- package/campaign-spec/dist/rules/downsell-without-upsell.js +40 -0
- package/campaign-spec/dist/rules/exit-intent-validation.d.ts +23 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +147 -0
- package/campaign-spec/dist/rules/funnel-count.d.ts +9 -0
- package/campaign-spec/dist/rules/funnel-count.js +37 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.d.ts +22 -0
- package/campaign-spec/dist/rules/funnel-hypothesis-length.js +60 -0
- package/campaign-spec/dist/rules/funnel-identity.d.ts +15 -0
- package/campaign-spec/dist/rules/funnel-identity.js +57 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.d.ts +20 -0
- package/campaign-spec/dist/rules/funnel-weight-sum.js +66 -0
- package/campaign-spec/dist/rules/index.d.ts +69 -0
- package/campaign-spec/dist/rules/index.js +131 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.d.ts +13 -0
- package/campaign-spec/dist/rules/offer-ref-integrity.js +59 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.d.ts +12 -0
- package/campaign-spec/dist/rules/package-pricing-sanity.js +42 -0
- package/campaign-spec/dist/rules/page-count.d.ts +11 -0
- package/campaign-spec/dist/rules/page-count.js +31 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.d.ts +14 -0
- package/campaign-spec/dist/rules/page-id-uniqueness.js +47 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.d.ts +8 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +126 -0
- package/campaign-spec/dist/rules/promo-codes-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/promo-codes-shape.js +187 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.d.ts +33 -0
- package/campaign-spec/dist/rules/route-field-ignored-for-page-type.js +81 -0
- package/campaign-spec/dist/rules/route-target-resolves.d.ts +32 -0
- package/campaign-spec/dist/rules/route-target-resolves.js +112 -0
- package/campaign-spec/dist/rules/schema-version.d.ts +21 -0
- package/campaign-spec/dist/rules/schema-version.js +53 -0
- package/campaign-spec/dist/rules/sdk-version.d.ts +26 -0
- package/campaign-spec/dist/rules/sdk-version.js +96 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-countries-shape.js +30 -0
- package/campaign-spec/dist/rules/shipping-methods-present.d.ts +10 -0
- package/campaign-spec/dist/rules/shipping-methods-present.js +26 -0
- package/campaign-spec/dist/rules/store-profile-shape.d.ts +30 -0
- package/campaign-spec/dist/rules/store-profile-shape.js +127 -0
- package/campaign-spec/dist/rules/thank-you-requirement.d.ts +16 -0
- package/campaign-spec/dist/rules/thank-you-requirement.js +50 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.d.ts +28 -0
- package/campaign-spec/dist/rules/unknown-top-level-fields.js +114 -0
- package/campaign-spec/dist/rules/upsell-has-packages.d.ts +9 -0
- package/campaign-spec/dist/rules/upsell-has-packages.js +35 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.d.ts +10 -0
- package/campaign-spec/dist/rules/upsell-routing-complete.js +45 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.d.ts +11 -0
- package/campaign-spec/dist/rules/upsell-without-checkout.js +44 -0
- package/campaign-spec/dist/rules/variant-labels-shape.d.ts +28 -0
- package/campaign-spec/dist/rules/variant-labels-shape.js +86 -0
- package/campaign-spec/dist/sdk-version-parse.d.ts +43 -0
- package/campaign-spec/dist/sdk-version-parse.js +62 -0
- package/campaign-spec/dist/types.d.ts +674 -0
- package/campaign-spec/dist/types.js +40 -0
- package/campaign-spec/package.json +12 -0
- package/compatibility.json +25 -0
- package/contracts/agent-relevant-change-policy.v1.json +111 -0
- package/contracts/brand-theme-source-defaults.figma-sections-export.v0.json +32 -0
- package/contracts/brand-theme-target-tokens.next-core.v0.json +65 -0
- package/contracts/campaign-cart-checkout-field-contract.v0.json +45 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +11 -0
- package/contracts/commerce-surface-catalog.json +2452 -0
- package/contracts/fixtures/orientation/canonicalization/v1.json +34 -0
- package/contracts/fixtures/orientation/envelope/current.json +95 -0
- package/contracts/fixtures/orientation/envelope/freshness_unknown.json +97 -0
- package/contracts/fixtures/orientation/envelope/legacy_baseline.json +95 -0
- package/contracts/fixtures/orientation/envelope/orientation_available.json +136 -0
- package/contracts/fixtures/orientation/envelope/recovered_interrupted_update.json +136 -0
- package/contracts/fixtures/orientation/envelope/refused.json +100 -0
- package/contracts/fixtures/orientation/envelope/restart_required.json +137 -0
- package/contracts/fixtures/orientation/envelope/updated.json +135 -0
- package/contracts/fixtures/orientation/hostile-target/README.md +61 -0
- package/contracts/fixtures/orientation/hostile-target/manifest.json +82 -0
- package/contracts/fixtures/orientation/hostile-target/repo/CHANGELOG.md +18 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/intended.mjs +12 -0
- package/contracts/fixtures/orientation/hostile-target/repo/bin/tripwire.mjs +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/release-ledger.json +48 -0
- package/contracts/fixtures/orientation/hostile-target/repo/contracts/supported-surface.json +17 -0
- package/contracts/fixtures/orientation/hostile-target/repo/docs/example-contract.md +13 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-checkout +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/post-merge +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hooks/pre-commit +5 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/package.json +15 -0
- package/contracts/fixtures/orientation/hostile-target/repo/hostile-dependency-tripwire/tripwire.mjs +9 -0
- package/contracts/fixtures/orientation/hostile-target/repo/package.json +20 -0
- package/contracts/fixtures/orientation/hostile-target/repo/schemas/example.v0.schema.json +15 -0
- package/contracts/fixtures/orientation/release-gate/cases.json +1073 -0
- package/contracts/fixtures/runtime-recipe/accept/current.json +299 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +294 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +51 -0
- package/contracts/fixtures/runtime-recipe/manifest.json +85 -0
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +297 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +251 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +310 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +299 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +299 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/assembly-report.json +180 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/build-context.json +115 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/doctor-output.json +29 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/.campaign-runtime/qa-verdict.json +27 -0
- package/contracts/fixtures/sidecar-bundle/production-shaped/campaign-runtime.build.json +141 -0
- package/contracts/migration-sidecar-bundle.v0.json +149 -0
- package/contracts/orientation-limits.v1.json +41 -0
- package/contracts/orientation-reason-codes.v1.json +196 -0
- package/contracts/private-template-sources.json +8 -0
- package/contracts/release-ledger.json +4598 -0
- package/contracts/reserved-skill-names.json +13 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +299 -0
- package/contracts/supported-surface.json +180 -0
- package/contracts/template-brand-contract.apollo-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.apollo.v0.json +27 -0
- package/contracts/template-brand-contract.demeter.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.olympus-mv-two-step.v0.json +28 -0
- package/contracts/template-brand-contract.olympus.v0.json +27 -0
- package/contracts/template-brand-contract.shared-commerce.v0.json +190 -0
- package/contracts/template-brand-contract.shop-single-step.v0.json +27 -0
- package/contracts/template-brand-contract.shop-three-step.v0.json +29 -0
- package/contracts/template-slot-manifest.apollo-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.apollo.v0.json +12 -0
- package/contracts/template-slot-manifest.demeter.v0.json +30 -0
- package/contracts/template-slot-manifest.olympus-mv-single-step.v0.json +14 -0
- package/contracts/template-slot-manifest.olympus-mv-two-step.v0.json +15 -0
- package/contracts/template-slot-manifest.olympus.v0.json +12 -0
- package/contracts/template-slot-manifest.shared-content-core.v0.json +4254 -0
- package/contracts/template-slot-manifest.shop-single-step.v0.json +47 -0
- package/contracts/template-slot-manifest.shop-three-step.v0.json +33 -0
- package/docs/brand-theme-bridge.md +159 -0
- package/docs/build-packet.md +1300 -0
- package/docs/campaign-build-brief.md +145 -0
- package/docs/campaign-standardization-report.md +329 -0
- package/docs/campaigns-os-build-flow.md +117 -0
- package/docs/design-source-package.md +784 -0
- package/docs/legacy-migration.md +58 -0
- package/docs/migration-sidecar-bundle.md +139 -0
- package/docs/orientation-contract-reference.md +1220 -0
- package/docs/polish-evidence.md +502 -0
- package/docs/qa-and-test-orders.md +1691 -0
- package/docs/release-ledger-authoring-guide.md +274 -0
- package/docs/runtime-readiness.md +211 -0
- package/docs/supported-surface.md +83 -0
- package/docs/versioning.md +55 -0
- package/docs/workflow-findings-sidecar.md +588 -0
- package/package.json +135 -0
- package/prompts/first-build.md +27 -0
- package/prompts/friction-log.md +30 -0
- package/schemas/campaign-build-brief.v1.schema.json +149 -0
- package/schemas/campaign-design-source-package.v0.schema.json +697 -0
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +390 -0
- package/schemas/campaign-runtime-build-context.v0.schema.json +339 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +452 -0
- package/schemas/campaign-spec.v4.schema.json +582 -0
- package/schemas/campaigns-os-doctor-output.v0.schema.json +43 -0
- package/schemas/campaigns-os-legacy-migration-inventory.v0.schema.json +112 -0
- package/schemas/campaigns-os-legacy-provisioning-plan.v0.schema.json +38 -0
- package/schemas/campaigns-os-legacy-provisioning-receipt.v0.schema.json +57 -0
- package/schemas/campaigns-os-qa-verdict-sidecar.v0.schema.json +150 -0
- package/schemas/campaigns-os-qa-verdict.v0.schema.json +438 -0
- package/schemas/campaigns-os-release-ledger.v1.schema.json +162 -0
- package/schemas/campaigns-os-run-record.v0.schema.json +343 -0
- package/schemas/campaigns-os-runtime-recipe.v1.schema.json +313 -0
- package/schemas/campaigns-os-sidecar-bundle-conformance.v0.schema.json +78 -0
- package/schemas/campaigns-os-tooling-orientation.v1.schema.json +381 -0
- package/schemas/campaigns-os-workflow-finding.v0.schema.json +134 -0
- package/schemas/source-html-manifest.v0.schema.json +252 -0
- package/skills/next-campaigns-build/SKILL.md +73 -0
- package/skills/next-campaigns-os/SKILL.md +101 -0
- package/skills/next-campaigns-os/references/session-intake.md +160 -0
- package/skills/next-campaigns-os-setup/SKILL.md +22 -0
- package/skills/next-campaigns-polish/SKILL.md +145 -0
- package/skills/next-campaigns-qa/SKILL.md +92 -0
- package/skills.json +56 -0
- package/skills.sh +64 -0
- package/src/adapter-decision-contract.mjs +333 -0
- package/src/brand-theme.mjs +1151 -0
- package/src/browser-launch.mjs +79 -0
- package/src/build-brief.mjs +781 -0
- package/src/built-site-scope.mjs +312 -0
- package/src/campaign-ecosystem.mjs +734 -0
- package/src/campaign-identity.mjs +405 -0
- package/src/campaign-workspace.mjs +127 -0
- package/src/checkpoint-waiver.mjs +302 -0
- package/src/cli.mjs +13442 -0
- package/src/commercial-journey.mjs +1119 -0
- package/src/commercial-parity.mjs +965 -0
- package/src/consent.mjs +347 -0
- package/src/content-residue.mjs +322 -0
- package/src/deadline.mjs +81 -0
- package/src/design-source-package.mjs +2604 -0
- package/src/deviation.mjs +107 -0
- package/src/doctor-check-registry.mjs +49 -0
- package/src/doctor-sidecar.mjs +106 -0
- package/src/finding-cause.mjs +557 -0
- package/src/findings.mjs +326 -0
- package/src/fs-identity.mjs +68 -0
- package/src/gate-actions.mjs +105 -0
- package/src/html-scan.mjs +53 -0
- package/src/install-mode.mjs +272 -0
- package/src/legacy-migration.d.ts +128 -0
- package/src/legacy-migration.mjs +510 -0
- package/src/lifecycle.mjs +338 -0
- package/src/local-proof.mjs +401 -0
- package/src/map-pin-writeback.mjs +210 -0
- package/src/orchestration-stage-contract.mjs +81 -0
- package/src/package-install-fixture.mjs +33 -0
- package/src/page-kit-build-summary.mjs +175 -0
- package/src/page-kit-campaign-config.mjs +57 -0
- package/src/page-kit-sdk-version.mjs +392 -0
- package/src/page-kit-store-profile.mjs +369 -0
- package/src/page-kit-sync.mjs +162 -0
- package/src/polish-browser.mjs +867 -0
- package/src/polish-capture.mjs +1094 -0
- package/src/polish-deadline.mjs +51 -0
- package/src/polish-gate.mjs +739 -0
- package/src/polish-node.mjs +639 -0
- package/src/polish-page-load.mjs +1100 -0
- package/src/private-template-source.mjs +237 -0
- package/src/proof-policy.mjs +82 -0
- package/src/qa-analytics-correctness.mjs +307 -0
- package/src/qa-analytics-errors.mjs +38 -0
- package/src/qa-analytics-parity.mjs +699 -0
- package/src/qa-binding-evidence.mjs +140 -0
- package/src/qa-browser.mjs +6608 -0
- package/src/qa-cart-entry.mjs +406 -0
- package/src/qa-commercial-parity.mjs +641 -0
- package/src/qa-node.mjs +3620 -0
- package/src/qa-order-bump.mjs +381 -0
- package/src/qa-parity-capture.mjs +428 -0
- package/src/qa-parity-fixture.mjs +359 -0
- package/src/qa-publish.mjs +362 -0
- package/src/qa-purchase-data-layer.mjs +263 -0
- package/src/qa-route-probe.mjs +272 -0
- package/src/qa-sidecar.mjs +188 -0
- package/src/qa-test-order-topology.mjs +207 -0
- package/src/qa-url-privacy.mjs +13 -0
- package/src/qa-verdict-discovery.mjs +192 -0
- package/src/qa-verdict-publish.mjs +105 -0
- package/src/qa-verdict.mjs +287 -0
- package/src/remit.mjs +388 -0
- package/src/repo-scan.mjs +83 -0
- package/src/route-identity.mjs +133 -0
- package/src/run-record-closeout.mjs +229 -0
- package/src/run-record.mjs +839 -0
- package/src/run-session.mjs +226 -0
- package/src/runtime-state-ignore.mjs +113 -0
- package/src/sdk-attribute-index.mjs +212 -0
- package/src/sdk-markup.mjs +358 -0
- package/src/sdk-meta-tags.mjs +52 -0
- package/src/shell-token.mjs +7 -0
- package/src/sidecar-bundle.mjs +399 -0
- package/src/source-asset-crawl.mjs +469 -0
- package/src/source-html-intake.mjs +627 -0
- package/src/source-html-manifest.mjs +276 -0
- package/src/source-prep.mjs +284 -0
- package/src/spec-derive-store.mjs +431 -0
- package/src/spec-derive.mjs +514 -0
- package/src/spec-fetch.mjs +66 -0
- package/src/spec-hash.mjs +48 -0
- package/src/spec-identity.mjs +27 -0
- package/src/stage-ledger.mjs +509 -0
- package/src/standardization-report.mjs +1297 -0
- package/src/template-brand-contract.mjs +473 -0
- package/src/template-freshness.mjs +196 -0
- package/src/template-reference.mjs +81 -0
- package/src/template-slot-manifest.mjs +150 -0
- package/src/text-safety.mjs +62 -0
- package/src/theme-gate.mjs +185 -0
- package/src/upsell-selector-scope.mjs +299 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// `campaigns-os spec derive --from-store <subdomain>`: the store-derived half
|
|
2
|
+
// of the CampaignSpec (#432 slice 2).
|
|
3
|
+
//
|
|
4
|
+
// The nine `campaign.store_*` Store Profile fields are class "derived": the
|
|
5
|
+
// store already states them, so they are generated from it, never typed. The
|
|
6
|
+
// authority is the store's Admin API (`https://<subdomain>.29next.store/api/admin/`):
|
|
7
|
+
// `GET /store/` (name, primary domain, contact phone) and `GET /pages/` (the
|
|
8
|
+
// storefront's own pages, served at `https://<primary_domain>/<slug>/`, which
|
|
9
|
+
// is where the legal links point). Reading the store takes a credential and
|
|
10
|
+
// the network, both of which slice 1 promised never to need, so the store
|
|
11
|
+
// source is opt-in behind `--from-store`, and the default run stays offline.
|
|
12
|
+
//
|
|
13
|
+
// Direction: store -> spec (this module) -> repo (`page-kit sync`). Nothing
|
|
14
|
+
// here writes the repo, and the spec keeps its value for any field the store
|
|
15
|
+
// cannot state (an empty phone, no page that reads as the returns policy):
|
|
16
|
+
// the miss is reported as not derived, the field is never emptied.
|
|
17
|
+
//
|
|
18
|
+
// This module is pure apart from `readStoreProfile`, the one transport, which
|
|
19
|
+
// takes its fetch as an argument so tests never touch the network. The token
|
|
20
|
+
// travels in and never out: no result, reason or error carries it.
|
|
21
|
+
import { PAGE_KIT_STORE_PROFILE_FIELDS, normalizeStoreProfileValue, storeProfileSpecValueProblem } from "./page-kit-store-profile.mjs";
|
|
22
|
+
import { hasControlCharacters, quoteValue } from "./spec-derive.mjs";
|
|
23
|
+
|
|
24
|
+
export const ADMIN_API_STORE_VERSION = "2024-04-01";
|
|
25
|
+
// `/pages/` exists only in the unstable Admin API version today; the header
|
|
26
|
+
// is pinned to it and named in the docs, so a move is one constant.
|
|
27
|
+
export const ADMIN_API_PAGES_VERSION = "unstable";
|
|
28
|
+
export const STORE_READ_TIMEOUT_MS = 15_000;
|
|
29
|
+
// The whole read (store plus every pages cursor) shares one budget, so a
|
|
30
|
+
// store that answers each request slowly cannot hold the command for
|
|
31
|
+
// eleven timeouts in a row.
|
|
32
|
+
export const STORE_READ_BUDGET_MS = 45_000;
|
|
33
|
+
// Pages are read cursor by cursor; a storefront with more pages than this
|
|
34
|
+
// many requests return is reported rather than partially matched.
|
|
35
|
+
export const STORE_PAGES_MAX_REQUESTS = 10;
|
|
36
|
+
// And more rows than any storefront carries is a platform fault, reported
|
|
37
|
+
// as truncated rather than held in memory.
|
|
38
|
+
export const STORE_PAGES_MAX_ROWS = 2_000;
|
|
39
|
+
|
|
40
|
+
// The spec fields this source writes, as `applySpecDerive` path patterns.
|
|
41
|
+
export const SPEC_DERIVE_STORE_FIELDS = Object.freeze(PAGE_KIT_STORE_PROFILE_FIELDS.map((field) => `campaign.${field}`));
|
|
42
|
+
const URL_FIELDS = new Set(["store_url", "store_terms", "store_privacy", "store_contact", "store_returns", "store_shipping"]);
|
|
43
|
+
|
|
44
|
+
// The legal-link fields and the words (in a page's slug or title) that name
|
|
45
|
+
// each one. A page may carry more than one policy (`shipping-returns`) and
|
|
46
|
+
// then binds to each; a field with no page, or several, is not derived.
|
|
47
|
+
export const STORE_PAGE_MATCHERS = Object.freeze({
|
|
48
|
+
store_terms: Object.freeze({ slugs: ["terms", "tos", "terms-of-service", "terms-and-conditions", "terms-conditions", "terms-of-use"], words: ["terms", "tos", "conditions"], describe: "terms" }),
|
|
49
|
+
store_privacy: Object.freeze({ slugs: ["privacy", "privacy-policy"], words: ["privacy"], describe: "privacy" }),
|
|
50
|
+
store_contact: Object.freeze({ slugs: ["contact", "contact-us"], words: ["contact"], describe: "contact" }),
|
|
51
|
+
store_returns: Object.freeze({ slugs: ["returns", "refunds", "return-policy", "returns-policy", "refund-policy", "returns-and-refunds", "shipping-returns", "shipping-and-returns"], words: ["return", "returns", "refund", "refunds"], describe: "returns/refunds" }),
|
|
52
|
+
store_shipping: Object.freeze({ slugs: ["shipping", "delivery", "shipping-policy", "delivery-policy", "shipping-returns", "shipping-and-returns"], words: ["shipping", "delivery"], describe: "shipping/delivery" }),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const SUBDOMAIN_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
56
|
+
const ENV_NAME_REGEX = /^[A-Z_][A-Z0-9_]*$/;
|
|
57
|
+
// Labels as DNS takes them; the last label is a letters-only TLD or a
|
|
58
|
+
// punycode one (`xn--…`), so an internationalized primary domain is a host.
|
|
59
|
+
const HOSTNAME_REGEX = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/i;
|
|
60
|
+
// A bearer token is one line of printable ASCII; anything else is not a
|
|
61
|
+
// token this command will put in a header (the header validator would echo
|
|
62
|
+
// it back in its error).
|
|
63
|
+
const TOKEN_REGEX = /^[\x21-\x7e]+$/;
|
|
64
|
+
|
|
65
|
+
function isPlainObject(value) {
|
|
66
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isNonEmptyString(value) {
|
|
70
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The `<subdomain>` of `<subdomain>.29next.store`, lower-cased, or null when
|
|
74
|
+
// the value is not one (a URL, a path, an empty string).
|
|
75
|
+
export function normalizeStoreSubdomain(value) {
|
|
76
|
+
if (typeof value !== "string") return null;
|
|
77
|
+
const text = value.trim().toLowerCase();
|
|
78
|
+
return SUBDOMAIN_REGEX.test(text) ? text : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function adminApiBaseForStore(subdomain) {
|
|
82
|
+
return `https://${subdomain}.29next.store/api/admin/`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The env var the token is read from when `--store-token-source` is not
|
|
86
|
+
// given: `<SUBDOMAIN>_ADMIN_TOKEN`, dashes as underscores (`my-store` ->
|
|
87
|
+
// `MY_STORE_ADMIN_TOKEN`); a subdomain that starts with a digit gets a
|
|
88
|
+
// leading underscore so the name is one a shell can export.
|
|
89
|
+
export function defaultStoreTokenEnvVar(subdomain) {
|
|
90
|
+
const name = `${String(subdomain).toUpperCase().replace(/-/g, "_")}_ADMIN_TOKEN`;
|
|
91
|
+
return /^[0-9]/.test(name) ? `_${name}` : name;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Only `env:<VAR>` is a token source: a token typed on the command line
|
|
95
|
+
// would land in shell history and process listings. Returns `{ env }` or
|
|
96
|
+
// `{ problem }`.
|
|
97
|
+
export function parseStoreTokenSource(value) {
|
|
98
|
+
if (typeof value !== "string" || !value.trim()) return { problem: "is empty; write env:<VAR>, the environment variable that holds the store's Admin API token." };
|
|
99
|
+
const text = value.trim();
|
|
100
|
+
const match = text.match(/^env:(.+)$/);
|
|
101
|
+
if (!match) return { problem: "must be env:<VAR> (an environment variable name); a token is never written on the command line." };
|
|
102
|
+
const name = match[1];
|
|
103
|
+
if (!ENV_NAME_REGEX.test(name)) return { problem: `names an invalid environment variable ${quoteValue(name)}; use letters, digits and underscores (env:MY_STORE_ADMIN_TOKEN).` };
|
|
104
|
+
return { env: name };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The `https://<domain>` form `campaign.store_url` takes from a primary
|
|
108
|
+
// domain (no trailing slash, the spelling every Map export carries), or null
|
|
109
|
+
// when the domain is not a hostname.
|
|
110
|
+
export function storeUrlFromDomain(domain) {
|
|
111
|
+
if (typeof domain !== "string") return null;
|
|
112
|
+
const host = domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
113
|
+
return HOSTNAME_REGEX.test(host) ? `https://${host}` : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// URL fields compare without a trailing slash: `https://x.example` and
|
|
117
|
+
// `https://x.example/` name one place, and a rewrite between them would
|
|
118
|
+
// only churn the spec and the repo behind it.
|
|
119
|
+
function sameStoreValue(field, before, after) {
|
|
120
|
+
if (typeof before !== "string") return false;
|
|
121
|
+
const a = normalizeStoreProfileValue(before);
|
|
122
|
+
return URL_FIELDS.has(field) ? a.replace(/\/+$/, "") === after.replace(/\/+$/, "") : a === after;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Whether a token can travel in an Authorization header at all.
|
|
126
|
+
export function isUsableStoreToken(token) {
|
|
127
|
+
return typeof token === "string" && TOKEN_REGEX.test(token);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// `store_phone_tel` from the store's display phone: `tel:` plus the digits
|
|
131
|
+
// (a leading + kept, wherever the punctuation put it), the only href form
|
|
132
|
+
// every dialer reads. Only a plain number converts: an extension, a second
|
|
133
|
+
// number, a vanity word or any other letter would fold into the digits and
|
|
134
|
+
// dial something else, so those return null and the field is not derived.
|
|
135
|
+
export function telUriFromPhone(phone) {
|
|
136
|
+
if (typeof phone !== "string") return null;
|
|
137
|
+
const trimmed = phone.trim();
|
|
138
|
+
if (!/^[\s().\-+0-9]+$/.test(trimmed)) return null;
|
|
139
|
+
const plus = /^[\s().\-]*\+/.test(trimmed) ? "+" : "";
|
|
140
|
+
const digits = trimmed.replace(/\D/g, "");
|
|
141
|
+
if (digits.length < 4 || (trimmed.match(/\+/g) || []).length > 1) return null;
|
|
142
|
+
return `tel:${plus}${digits}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function words(text) {
|
|
146
|
+
return String(text ?? "").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The storefront pages that carry the field's policy. A page whose slug IS
|
|
150
|
+
// one of the policy's conventional slugs binds first (exactly one such page
|
|
151
|
+
// settles the field on its own); only when no page has a conventional slug
|
|
152
|
+
// does the wider match by slug or title words apply, so a "free shipping"
|
|
153
|
+
// promo page never outranks `shipping-policy`.
|
|
154
|
+
export function matchStorePages(field, pages) {
|
|
155
|
+
const matcher = STORE_PAGE_MATCHERS[field];
|
|
156
|
+
if (!matcher || !Array.isArray(pages)) return [];
|
|
157
|
+
const usable = pages.filter((page) => isPlainObject(page) && isNonEmptyString(page.slug));
|
|
158
|
+
const canonical = usable.filter((page) => matcher.slugs.includes(page.slug.trim().toLowerCase()));
|
|
159
|
+
if (canonical.length) return canonical;
|
|
160
|
+
return usable.filter((page) => {
|
|
161
|
+
const seen = new Set([...words(page.slug), ...words(page.title)]);
|
|
162
|
+
return matcher.words.some((word) => seen.has(word));
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The field-by-field plan for the store source, in the same shape as
|
|
167
|
+
// `planSpecDerive`: `changes`, `unchanged` and `not_derived` rows over the
|
|
168
|
+
// nine `campaign.store_*` fields, plus `domain_changed` when the store's
|
|
169
|
+
// primary domain is not the host the spec's store_url named (the loud sign
|
|
170
|
+
// of `--from-store` pointing at the wrong store).
|
|
171
|
+
//
|
|
172
|
+
// `store` is the parsed `GET /store/` body; `pages` the accumulated
|
|
173
|
+
// `GET /pages/` results, or null with `pagesStatus` saying why
|
|
174
|
+
// (`unavailable`, `truncated`).
|
|
175
|
+
export function planStoreProfileDerive({ spec, store, pages = null, pagesStatus = "ok", pagesDetail = null, subdomain = "" } = {}) {
|
|
176
|
+
const changes = [];
|
|
177
|
+
const unchanged = [];
|
|
178
|
+
const notDerived = [];
|
|
179
|
+
const body = isPlainObject(store) ? store : {};
|
|
180
|
+
// A missing campaign block is created on write (as applySpecDerive creates
|
|
181
|
+
// any absent container); one of the wrong type is a spec defect.
|
|
182
|
+
const campaign = isPlainObject(spec?.campaign) ? spec.campaign : spec?.campaign == null ? {} : null;
|
|
183
|
+
const base = adminApiBaseForStore(subdomain || "<store>");
|
|
184
|
+
const storeSource = (key) => `${base}store/ ${key}`;
|
|
185
|
+
|
|
186
|
+
const consider = (field, after, source) => {
|
|
187
|
+
const specField = `campaign.${field}`;
|
|
188
|
+
const problem = storeProfileSpecValueProblem(field, after);
|
|
189
|
+
if (problem) {
|
|
190
|
+
const why = problem === "demo_residue"
|
|
191
|
+
? "is the starter demo value; the store pointed at is the demo store, not a merchant's"
|
|
192
|
+
: problem === "not_http_url" ? "is not an http(s) URL"
|
|
193
|
+
: problem === "not_tel_uri" ? "is not a tel: URI"
|
|
194
|
+
: problem === "control_characters" ? "contains control characters" : "is missing";
|
|
195
|
+
notDerived.push({ field: specField, reason: "target_invalid", detail: `the store's ${source} ${quoteValue(after)} ${why}; correct it in the store's settings, then derive again.` });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!campaign) {
|
|
199
|
+
notDerived.push({ field: specField, reason: "spec_container_invalid", detail: `the spec's campaign is not an object, so ${specField} cannot be written; repair the spec, then derive again.` });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const before = Object.hasOwn(campaign, field) ? campaign[field] : undefined;
|
|
203
|
+
const row = { field: specField, path: ["campaign", field], before, after, source };
|
|
204
|
+
if (sameStoreValue(field, before, after)) unchanged.push(row);
|
|
205
|
+
else changes.push(row);
|
|
206
|
+
};
|
|
207
|
+
const missing = (field, source, what) => {
|
|
208
|
+
notDerived.push({ field: `campaign.${field}`, reason: "store_field_missing", detail: `the store's ${source} is empty, so ${what}; set it in the store's settings, then derive again. The spec's value was left as it is.` });
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Name.
|
|
212
|
+
const name = typeof body.name === "string" ? normalizeStoreProfileValue(body.name) : "";
|
|
213
|
+
if (name) consider("store_name", name, storeSource("name"));
|
|
214
|
+
else missing("store_name", storeSource("name"), "store_name cannot be derived");
|
|
215
|
+
|
|
216
|
+
// Primary domain -> store_url, and the host every legal link is served on.
|
|
217
|
+
const domainRaw = typeof body.primary_domain === "string" ? body.primary_domain.trim() : "";
|
|
218
|
+
const storeUrl = domainRaw ? storeUrlFromDomain(domainRaw) : null;
|
|
219
|
+
let domainChanged = null;
|
|
220
|
+
if (!domainRaw) {
|
|
221
|
+
missing("store_url", storeSource("primary_domain"), "store_url cannot be derived");
|
|
222
|
+
} else if (!storeUrl) {
|
|
223
|
+
notDerived.push({ field: "campaign.store_url", reason: "target_invalid", detail: `the store's ${storeSource("primary_domain")} ${quoteValue(domainRaw)} is not a hostname; correct it in the store's settings, then derive again.` });
|
|
224
|
+
} else {
|
|
225
|
+
consider("store_url", storeUrl, storeSource("primary_domain"));
|
|
226
|
+
const before = campaign?.store_url;
|
|
227
|
+
if (typeof before === "string" && before.trim() && !storeProfileSpecValueProblem("store_url", before)) {
|
|
228
|
+
try {
|
|
229
|
+
const beforeHost = new URL(normalizeStoreProfileValue(before)).hostname.toLowerCase();
|
|
230
|
+
const afterHost = new URL(storeUrl).hostname;
|
|
231
|
+
if (beforeHost !== afterHost) domainChanged = { before: beforeHost, after: afterHost };
|
|
232
|
+
} catch {
|
|
233
|
+
// An unparsable spec value is reported by doctor's store-profile
|
|
234
|
+
// rule; it is not this plan's concern.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Phone, display and href forms, from one store field.
|
|
240
|
+
const phoneRaw = typeof body.contact_address?.phone_number === "string" ? normalizeStoreProfileValue(body.contact_address.phone_number) : "";
|
|
241
|
+
const phoneSource = storeSource("contact_address.phone_number");
|
|
242
|
+
if (phoneRaw) {
|
|
243
|
+
consider("store_phone", phoneRaw, phoneSource);
|
|
244
|
+
const tel = telUriFromPhone(phoneRaw);
|
|
245
|
+
if (tel) consider("store_phone_tel", tel, `${phoneSource} (as tel: URI)`);
|
|
246
|
+
else notDerived.push({ field: "campaign.store_phone_tel", reason: "target_invalid", detail: `the store's ${phoneSource} ${quoteValue(phoneRaw)} has too few digits for a tel: URI; correct it in the store's settings, then derive again.` });
|
|
247
|
+
} else {
|
|
248
|
+
missing("store_phone", phoneSource, "store_phone cannot be derived");
|
|
249
|
+
missing("store_phone_tel", phoneSource, "store_phone_tel cannot be derived");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Legal links: each bound to exactly one storefront page.
|
|
253
|
+
const pagesSource = `${base}pages/`;
|
|
254
|
+
for (const [field, matcher] of Object.entries(STORE_PAGE_MATCHERS)) {
|
|
255
|
+
const specField = `campaign.${field}`;
|
|
256
|
+
if (!storeUrl) {
|
|
257
|
+
notDerived.push({ field: specField, reason: "store_domain_missing", detail: `the store has no usable primary domain, so no ${matcher.describe} page URL can be formed for ${field}; set the primary domain in the store's settings, then derive again.` });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (pagesStatus === "truncated") {
|
|
261
|
+
notDerived.push({ field: specField, reason: "store_pages_truncated", detail: `the store lists more pages than ${STORE_PAGES_MAX_REQUESTS} requests to ${pagesSource} return, so a single ${matcher.describe} page cannot be bound for ${field}. The spec's value was left as it is.` });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (pagesStatus === "unavailable" || !Array.isArray(pages)) {
|
|
265
|
+
notDerived.push({ field: specField, reason: "store_pages_unavailable", detail: `the store's pages could not be listed (${pagesSource}, Admin API version ${ADMIN_API_PAGES_VERSION}${pagesDetail ? `: ${pagesDetail}` : ""}), so no ${matcher.describe} page can be bound for ${field}. The spec's value was left as it is.` });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const matches = matchStorePages(field, pages);
|
|
269
|
+
if (matches.length === 0) {
|
|
270
|
+
notDerived.push({ field: specField, reason: "store_page_not_found", detail: `no storefront page's slug or title names ${matcher.describe} (${matcher.words.join(", ")}), so ${field} cannot be derived from ${pagesSource}. Add the page in the store (or keep the spec's value), then derive again.` });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (matches.length > 1) {
|
|
274
|
+
notDerived.push({ field: specField, reason: "store_page_ambiguous", detail: `more than one storefront page reads as ${matcher.describe} (${matches.map((page) => quoteValue(page.slug)).sort().join(", ")}), so no single URL can be derived for ${field}; rename or remove one in the store, then derive again. The spec's value was left as it is.` });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const slug = matches[0].slug.trim();
|
|
278
|
+
// A slug is one path segment: no separators, no dot segments (`..`
|
|
279
|
+
// would serve the homepage as the policy), and well-formed text
|
|
280
|
+
// (encodeURIComponent throws on a lone surrogate).
|
|
281
|
+
let encoded = null;
|
|
282
|
+
try {
|
|
283
|
+
encoded = slug.isWellFormed() ? encodeURIComponent(slug) : null;
|
|
284
|
+
} catch {
|
|
285
|
+
encoded = null;
|
|
286
|
+
}
|
|
287
|
+
if (encoded === null || hasControlCharacters(slug) || /[\/?#\s]/.test(slug) || /^\.{1,2}$/.test(slug)) {
|
|
288
|
+
notDerived.push({ field: specField, reason: "target_invalid", detail: `the storefront page slug ${quoteValue(slug)} is not a URL path segment, so ${field} cannot be derived; correct the page in the store, then derive again.` });
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
consider(field, `${storeUrl}/${encoded}/`, `${pagesSource} slug ${quoteValue(slug)}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Rows in the Store Profile's own field order, the order doctor's gate and
|
|
295
|
+
// page-kit sync print them in, whatever order the sources were read in.
|
|
296
|
+
const order = new Map(PAGE_KIT_STORE_PROFILE_FIELDS.map((field, index) => [`campaign.${field}`, index]));
|
|
297
|
+
const byField = (a, b) => order.get(a.field) - order.get(b.field);
|
|
298
|
+
return { changes: changes.sort(byField), unchanged: unchanged.sort(byField), not_derived: notDerived.sort(byField), domain_changed: domainChanged };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function transportDetail(error) {
|
|
302
|
+
// undici wraps a refused redirect or a TLS failure as "fetch failed" with
|
|
303
|
+
// the reason on `cause`, sometimes as a code, sometimes only as a message.
|
|
304
|
+
const raw = error?.name === "TimeoutError" || error?.name === "AbortError" ? "timed out" : (error?.cause?.code || error?.cause?.message || error?.message || String(error));
|
|
305
|
+
// A header validator quotes the offending header value in its message;
|
|
306
|
+
// whatever follows "Bearer" is never echoed.
|
|
307
|
+
const message = String(raw).replace(/Bearer\s+\S*/g, "Bearer [redacted]").replace(/\s+/g, " ").trim();
|
|
308
|
+
return message.length > 160 ? `${message.slice(0, 157)}…` : message;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// The one place a response is dropped unread: an unconsumed body can hold
|
|
312
|
+
// the connection open past the command's interest in it.
|
|
313
|
+
async function discardBody(response) {
|
|
314
|
+
try {
|
|
315
|
+
await response?.body?.cancel?.();
|
|
316
|
+
} catch {
|
|
317
|
+
// Nothing to do with a body that will not cancel.
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Read the store: `GET /store/` then every `GET /pages/` cursor. Returns
|
|
322
|
+
// `{ status: "ok", store, pages, pages_status }` or a status the CLI turns
|
|
323
|
+
// into a spec.derive.store_* error: `unauthorized` (401/403), `not_found`
|
|
324
|
+
// (404: no such store), `unreachable` (transport, timeout, 5xx), `invalid`
|
|
325
|
+
// (a body that is not the documented object). A pages failure is not fatal:
|
|
326
|
+
// `pages_status` is `unavailable` and the five link fields report it.
|
|
327
|
+
export async function readStoreProfile({ subdomain, token, fetchImpl = globalThis.fetch, timeoutMs = STORE_READ_TIMEOUT_MS, budgetMs = STORE_READ_BUDGET_MS } = {}) {
|
|
328
|
+
if (typeof fetchImpl !== "function") return { status: "unreachable", detail: "global fetch is not available; upgrade to Node 18+." };
|
|
329
|
+
if (!isUsableStoreToken(token)) return { status: "credential_invalid", detail: "the token in the environment is not a single line of printable ASCII (a stray newline, space or non-ASCII character); it was not sent. Re-export it as the value the store issued." };
|
|
330
|
+
const base = adminApiBaseForStore(subdomain);
|
|
331
|
+
const deadline = Date.now() + budgetMs;
|
|
332
|
+
const request = async (url, version) => {
|
|
333
|
+
const remaining = deadline - Date.now();
|
|
334
|
+
if (remaining <= 0) throw Object.assign(new Error("timed out"), { name: "TimeoutError" });
|
|
335
|
+
const response = await fetchImpl(url, {
|
|
336
|
+
method: "GET",
|
|
337
|
+
headers: { Authorization: `Bearer ${token}`, "X-29next-API-Version": version, Accept: "application/json" },
|
|
338
|
+
// A redirect is refused at the HTTP layer, as an off-origin cursor is
|
|
339
|
+
// below: the bearer goes to the store's own origin and nowhere else.
|
|
340
|
+
redirect: "error",
|
|
341
|
+
signal: AbortSignal.timeout(Math.min(timeoutMs, remaining)),
|
|
342
|
+
});
|
|
343
|
+
return response;
|
|
344
|
+
};
|
|
345
|
+
let storeResponse;
|
|
346
|
+
try {
|
|
347
|
+
storeResponse = await request(`${base}store/`, ADMIN_API_STORE_VERSION);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return { status: "unreachable", detail: `${base}store/ could not be reached (${transportDetail(error)}).` };
|
|
350
|
+
}
|
|
351
|
+
if (!storeResponse.ok) await discardBody(storeResponse);
|
|
352
|
+
if (storeResponse.status === 401 || storeResponse.status === 403) return { status: "unauthorized", detail: `${base}store/ answered ${storeResponse.status}; the token is not valid for this store or lacks the store:read scope (the pages read also needs content:read).` };
|
|
353
|
+
if (storeResponse.status === 404) return { status: "not_found", detail: `${base}store/ answered 404; there is no store at that subdomain, or the Admin API does not serve /store/ at version ${ADMIN_API_STORE_VERSION}.` };
|
|
354
|
+
if (!storeResponse.ok) return { status: "unreachable", detail: `${base}store/ answered ${storeResponse.status}.` };
|
|
355
|
+
let store;
|
|
356
|
+
try {
|
|
357
|
+
store = await storeResponse.json();
|
|
358
|
+
} catch {
|
|
359
|
+
// The parser's message quotes the body; a marketing page or an HTML
|
|
360
|
+
// error at an unknown subdomain is not something to echo.
|
|
361
|
+
return { status: "invalid", detail: `${base}store/ did not return JSON; the subdomain may be wrong, or the platform answered with a page instead of the API.` };
|
|
362
|
+
}
|
|
363
|
+
if (!isPlainObject(store)) return { status: "invalid", detail: `${base}store/ returned ${Array.isArray(store) ? "an array" : `a ${store === null ? "null" : typeof store}`}, not the store object.` };
|
|
364
|
+
|
|
365
|
+
const pages = [];
|
|
366
|
+
let pagesStatus = "ok";
|
|
367
|
+
let pagesDetail = null;
|
|
368
|
+
let next = `${base}pages/`;
|
|
369
|
+
let requests = 0;
|
|
370
|
+
const unavailable = (why) => {
|
|
371
|
+
pagesStatus = "unavailable";
|
|
372
|
+
pagesDetail = why;
|
|
373
|
+
};
|
|
374
|
+
while (next) {
|
|
375
|
+
if (requests >= STORE_PAGES_MAX_REQUESTS) {
|
|
376
|
+
pagesStatus = "truncated";
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
requests += 1;
|
|
380
|
+
let response;
|
|
381
|
+
try {
|
|
382
|
+
response = await request(next, ADMIN_API_PAGES_VERSION);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
unavailable(`could not be reached (${transportDetail(error)})`);
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
if (!response.ok) {
|
|
388
|
+
await discardBody(response);
|
|
389
|
+
unavailable(response.status === 401 || response.status === 403
|
|
390
|
+
? `answered ${response.status}; the token lacks the content:read scope`
|
|
391
|
+
: response.status === 404 ? `answered 404; the Admin API does not serve /pages/ at version ${ADMIN_API_PAGES_VERSION}` : `answered ${response.status}`);
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
let body;
|
|
395
|
+
try {
|
|
396
|
+
body = await response.json();
|
|
397
|
+
} catch {
|
|
398
|
+
unavailable("did not return JSON");
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
if (!isPlainObject(body) || !Array.isArray(body.results) || (body.next !== undefined && body.next !== null && typeof body.next !== "string")) {
|
|
402
|
+
unavailable("returned a body that is not the documented page list");
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
for (const page of body.results) {
|
|
406
|
+
if (isPlainObject(page) && isNonEmptyString(page.slug)) pages.push({ slug: page.slug, title: typeof page.title === "string" ? page.title : "" });
|
|
407
|
+
}
|
|
408
|
+
if (pages.length > STORE_PAGES_MAX_ROWS) {
|
|
409
|
+
pagesStatus = "truncated";
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
// The cursor URL is followed only when it resolves under the store's
|
|
413
|
+
// own Admin API pages endpoint: a body that pointed elsewhere (another
|
|
414
|
+
// origin, or `..` back out of the API path) would otherwise carry the
|
|
415
|
+
// token off the endpoint.
|
|
416
|
+
if (isNonEmptyString(body.next)) {
|
|
417
|
+
let resolved = null;
|
|
418
|
+
try {
|
|
419
|
+
resolved = new URL(body.next).href;
|
|
420
|
+
} catch {
|
|
421
|
+
resolved = null;
|
|
422
|
+
}
|
|
423
|
+
if (!resolved || !resolved.startsWith(`${base}pages/`)) {
|
|
424
|
+
unavailable("returned a cursor outside the store's pages endpoint, which was not followed");
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
next = resolved;
|
|
428
|
+
} else next = null;
|
|
429
|
+
}
|
|
430
|
+
return { status: "ok", store, pages: pagesStatus === "ok" ? pages : null, pages_status: pagesStatus, pages_detail: pagesDetail };
|
|
431
|
+
}
|