@tokenoftrust/storefront-runner 1.3.4-rc.3 → 1.4.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/apps/storefront/astro.config.mjs +15 -0
- package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
- package/apps/storefront/drizzle.config.apps.ts +13 -0
- package/apps/storefront/env.d.ts +10 -0
- package/apps/storefront/migrations/README.md +13 -7
- package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
- package/apps/storefront/package.json +13 -1
- package/apps/storefront/perf/README.md +64 -0
- package/apps/storefront/perf/assert-budgets.ts +159 -0
- package/apps/storefront/playwright.config.ts +23 -0
- package/apps/storefront/public/js/dashboard-apps.js +173 -0
- package/apps/storefront/public/shared/commerce-marketing.css +221 -0
- package/apps/storefront/src/components/CollectionCard.astro +1 -0
- package/apps/storefront/src/components/ProductCard.astro +1 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
- package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
- package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
- package/apps/storefront/src/components/content/Callout.astro +75 -0
- package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
- package/apps/storefront/src/components/content/ProseSections.astro +121 -0
- package/apps/storefront/src/components/content/ProseToc.astro +34 -0
- package/apps/storefront/src/components/content/RichText.astro +44 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
- package/apps/storefront/src/components/home/Hero.astro +14 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
- package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
- package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
- package/apps/storefront/src/config/storeName.ts +34 -0
- package/apps/storefront/src/layouts/Layout.astro +97 -23
- package/apps/storefront/src/lib/analytics/budgets.json +69 -0
- package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
- package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
- package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
- package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
- package/apps/storefront/src/lib/apps/adminService.ts +106 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
- package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
- package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
- package/apps/storefront/src/lib/apps/credentials.ts +153 -0
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
- package/apps/storefront/src/lib/apps/registryService.ts +579 -0
- package/apps/storefront/src/lib/apps/scopes.ts +79 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
- package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
- package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
- package/apps/storefront/src/lib/auth/route.ts +16 -1
- package/apps/storefront/src/lib/auth/session.ts +8 -0
- package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
- package/apps/storefront/src/lib/blog/provider.ts +39 -0
- package/apps/storefront/src/lib/blog/types.ts +26 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
- package/apps/storefront/src/lib/chrome/model.ts +11 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
- package/apps/storefront/src/lib/content/callout.ts +78 -0
- package/apps/storefront/src/lib/content/index.ts +27 -0
- package/apps/storefront/src/lib/content/needsReview.ts +58 -0
- package/apps/storefront/src/lib/content/prose.ts +186 -0
- package/apps/storefront/src/lib/content/richtext.ts +76 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
- package/apps/storefront/src/lib/content-edit/client.ts +70 -14
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
- package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
- package/apps/storefront/src/lib/dev/obstacleStore.ts +214 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
- package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +12 -0
- package/apps/storefront/src/lib/jsonld.ts +12 -17
- package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
- package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
- package/apps/storefront/src/lib/publish/domainState.ts +351 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
- package/apps/storefront/src/lib/rawChrome.ts +34 -3
- package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
- package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
- package/apps/storefront/src/lib/subscription/model.ts +114 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
- package/apps/storefront/src/lib/tot/query.ts +32 -0
- package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
- package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
- package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
- package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
- package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
- package/apps/storefront/src/lib/webhooks/events.ts +98 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
- package/apps/storefront/src/middleware/index.ts +28 -13
- package/apps/storefront/src/pages/404.astro +21 -9
- package/apps/storefront/src/pages/[...slug].astro +36 -2
- package/apps/storefront/src/pages/admin.astro +29 -3
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
- package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
- package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
- package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
- package/apps/storefront/src/pages/auth/login.astro +197 -41
- package/apps/storefront/src/pages/auth/magic.astro +75 -43
- package/apps/storefront/src/pages/blog/[slug].astro +107 -0
- package/apps/storefront/src/pages/blog/index.astro +98 -0
- package/apps/storefront/src/pages/capabilities.astro +8 -0
- package/apps/storefront/src/pages/cockpit.astro +496 -74
- package/apps/storefront/src/pages/collections/[handle].astro +8 -0
- package/apps/storefront/src/pages/collections/index.astro +10 -2
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
- package/apps/storefront/src/pages/index.astro +57 -0
- package/apps/storefront/src/pages/llms.txt.ts +31 -10
- package/apps/storefront/src/pages/products/[handle].astro +100 -9
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
- package/apps/storefront/src/pages/style-guide/index.astro +10 -0
- package/apps/storefront/src/styles/fonts.css +54 -0
- package/apps/storefront/src/styles/global.css +22 -2
- package/apps/storefront/src/themes/schema.ts +3 -25
- package/apps/storefront/tsconfig.json +1 -1
- package/apps/storefront/vitest.config.ts +4 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/candidate-index.ts +311 -0
- package/packages/public-runtime/src/checkout.ts +94 -2
- package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
- package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
- package/packages/public-runtime/src/compliance/index.ts +22 -0
- package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
- package/packages/public-runtime/src/compliance/profile.ts +198 -0
- package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
- package/packages/public-runtime/src/compliance/verification.ts +81 -0
- package/packages/public-runtime/src/csp.ts +27 -3
- package/packages/public-runtime/src/customization-reconcile.ts +35 -0
- package/packages/public-runtime/src/customization-runtime.ts +8 -0
- package/packages/public-runtime/src/customization-versioning.ts +17 -0
- package/packages/public-runtime/src/extension-contract.ts +2 -1
- package/packages/public-runtime/src/hash.ts +25 -0
- package/packages/public-runtime/src/index.ts +6 -0
- package/packages/public-runtime/src/membership.ts +353 -0
- package/packages/public-runtime/src/product.ts +24 -2
- package/packages/public-runtime/src/review-trust-proof.ts +194 -0
- package/packages/public-runtime/src/tenant-assets.ts +40 -5
- package/packages/public-runtime/src/tenant.ts +236 -0
- package/packages/public-runtime/src/widget-postmessage.ts +205 -0
- package/scripts/dev/publish.mjs +158 -0
- package/scripts/dev/transient-files.mjs +2 -1
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/pages/storefront.css +23 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-verifiable apex-cutover READINESS gate (unit u9) — the green
|
|
3
|
+
* preconditions that MUST all pass before an owner apex (DNS) cutover is
|
|
4
|
+
* permitted. This is the DELTA on top of the go-live session's apex machinery:
|
|
5
|
+
* go-live owns the admin Domain Connect/Rollback button, the DNS cutover/rollback
|
|
6
|
+
* CI (`www-domain.yml` → `cutover-dns.mjs`, prior-records persisted to
|
|
7
|
+
* `s3://.../meta/`), and the HMAC completion callback; u9 CONSUMES those and
|
|
8
|
+
* GATES them. Nothing here mutates DNS — it decides whether a cutover is ALLOWED.
|
|
9
|
+
*
|
|
10
|
+
* The gate is a PURE function over a signals bundle so the exact policy is
|
|
11
|
+
* unit-testable independent of the runtime (mirrors `shipReadiness` in
|
|
12
|
+
* `change/ship`'s CLI and the `acceptGate`/`apexCutoverGate` pure decisions). The
|
|
13
|
+
* server endpoint (`GET /api/domain/readiness`) assembles the signals from the
|
|
14
|
+
* live sources via {@link assembleApexReadinessSignals} — the owner capability
|
|
15
|
+
* (u10 `resolveShipCapabilities`), the per-tenant `DomainState` (in-flight run +
|
|
16
|
+
* the CAPTURED revert target), and the consumed dual-run parity / target-health /
|
|
17
|
+
* live-digest evidence record — and the admin Domain display + the `tot go-live`
|
|
18
|
+
* CLI both read the SAME endpoint, so the enforcement gate and the surfaces never
|
|
19
|
+
* diverge.
|
|
20
|
+
*
|
|
21
|
+
* EVIDENCE CONTRACT (the seam the go-live dual-run/health CI feeds): the parity,
|
|
22
|
+
* target-health and live-digest signals are CONSUMED from a per-tenant evidence
|
|
23
|
+
* record persisted at {@link readinessEvidenceKey} (KV). u9 does not RE-RUN
|
|
24
|
+
* dual-run — it reads the record `scripts/publish/dual-run.mjs` + the health
|
|
25
|
+
* probe produce. An absent/garbage record normalises fail-closed (no parity, no
|
|
26
|
+
* digest, unhealthy), so readiness BLOCKS until the go-live CI writes real
|
|
27
|
+
* evidence — never a false "ready" from a missing feed.
|
|
28
|
+
*
|
|
29
|
+
* FAIL-CLOSED throughout (decision `static-publish-fails-closed-reviewed-sha`):
|
|
30
|
+
* a missing, stale, or failed signal BLOCKS the cutover with an honest,
|
|
31
|
+
* itemized reason — never a false "ready". Absence denies. The gate is
|
|
32
|
+
* ADVISORY-vs-authoritative honest: it reports readiness, but the actual cutover
|
|
33
|
+
* is still owner-gated at `apexCutoverGate`/`guardApexCutover` (u10) and executed
|
|
34
|
+
* only by the go-live CI — a green readiness verdict never bypasses those.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The consumed dual-run parity evidence (produced by `scripts/publish/dual-run.mjs`
|
|
39
|
+
* — the T7 CloudFront-vs-baseline structural diff the pending-dns-cutover gate
|
|
40
|
+
* wants). u9 CONSUMES this shape; it does not re-run dual-run. Green = the hard
|
|
41
|
+
* gate dual-run itself enforces: zero CloudFront route failures. `statusMismatches`
|
|
42
|
+
* are surfaced for review (content legitimately differs during a migration) but do
|
|
43
|
+
* NOT block, exactly as `dual-run.mjs` treats them. `digest` ties the evidence to
|
|
44
|
+
* the published bundle it validated, so freshness can be checked against the live
|
|
45
|
+
* digest; `capturedAt` dates it.
|
|
46
|
+
*/
|
|
47
|
+
export interface DualRunEvidence {
|
|
48
|
+
/** Number of routes that FAILED on CloudFront (the hard gate — must be 0). */
|
|
49
|
+
cfFailures: number;
|
|
50
|
+
/** Status mismatches vs the baseline — surfaced for review, non-blocking. */
|
|
51
|
+
statusMismatches: number;
|
|
52
|
+
/** Total routes probed (context for the itemization; 0 ⇒ no real run). */
|
|
53
|
+
routes: number;
|
|
54
|
+
/** The published-bundle digest this evidence was produced against, or null. */
|
|
55
|
+
digest: string | null;
|
|
56
|
+
/** When the dual-run evidence was produced (ISO), or null. */
|
|
57
|
+
capturedAt: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The captured revert target — prior DNS records persisted by a connect/rehearsal run. */
|
|
61
|
+
export interface RevertTarget {
|
|
62
|
+
/** S3 key of the persisted prior DNS records (`meta/dns-prior-records.*.json`), or null. */
|
|
63
|
+
key: string | null;
|
|
64
|
+
/** When they were captured (ISO), or null if never. */
|
|
65
|
+
capturedAt: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The concrete signals the readiness gate decides over. Assembled server-side
|
|
70
|
+
* from the live sources; every field is fail-closed by construction (a null /
|
|
71
|
+
* false / absent value denies its check).
|
|
72
|
+
*/
|
|
73
|
+
export interface ApexReadinessSignals {
|
|
74
|
+
/**
|
|
75
|
+
* The acting principal holds the OWNER-ONLY apex-cutover capability
|
|
76
|
+
* (`resolveShipCapabilities(principal).apexCutover`, u10). A `ship-on-behalf`
|
|
77
|
+
* developer or an unauthenticated caller is false → denied.
|
|
78
|
+
*/
|
|
79
|
+
ownerCapable: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* A domain connect/rollback run is currently in flight (`runInFlight(state)`).
|
|
82
|
+
* A cutover cannot start while one is pending its CI callback — the exact race
|
|
83
|
+
* `DomainState` exists to prevent.
|
|
84
|
+
*/
|
|
85
|
+
runInFlight: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* The consumed dual-run parity evidence, or null when none has been produced
|
|
88
|
+
* (fail-closed: no evidence ⇒ not green).
|
|
89
|
+
*/
|
|
90
|
+
dualRun: DualRunEvidence | null;
|
|
91
|
+
/**
|
|
92
|
+
* The apex target's health probe passed (the CloudFront/static origin is
|
|
93
|
+
* serving). Distinct from parity; a false/failed probe blocks. Fail-closed
|
|
94
|
+
* default false when it could not be established.
|
|
95
|
+
*/
|
|
96
|
+
targetHealthy: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* The digest currently published live (the reviewed tree the cutover would
|
|
99
|
+
* point traffic at). Evidence must be tied to THIS digest to count as fresh
|
|
100
|
+
* (`static-publish-fails-closed-reviewed-sha`). Null ⇒ freshness fails closed.
|
|
101
|
+
*/
|
|
102
|
+
liveDigest: string | null;
|
|
103
|
+
/** The captured revert target — a rollback must have somewhere to restore FROM. */
|
|
104
|
+
revert: RevertTarget;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A stable id per readiness check, so surfaces can label/order them consistently. */
|
|
108
|
+
export type ApexReadinessCheckId =
|
|
109
|
+
| "owner-capability"
|
|
110
|
+
| "no-run-in-flight"
|
|
111
|
+
| "dual-run-parity"
|
|
112
|
+
| "target-health"
|
|
113
|
+
| "evidence-fresh"
|
|
114
|
+
| "revert-captured";
|
|
115
|
+
|
|
116
|
+
/** One itemized readiness check — ok/blocked plus an honest human-legible detail. */
|
|
117
|
+
export interface ApexReadinessCheck {
|
|
118
|
+
id: ApexReadinessCheckId;
|
|
119
|
+
/** Short label for the surface (admin chip / CLI line). */
|
|
120
|
+
label: string;
|
|
121
|
+
/** True when this precondition is satisfied. */
|
|
122
|
+
ok: boolean;
|
|
123
|
+
/** Why it passed or, more importantly, why it's blocking — always actionable. */
|
|
124
|
+
detail: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The full readiness verdict: ready iff EVERY check passed; itemized either way. */
|
|
128
|
+
export interface ApexReadiness {
|
|
129
|
+
/** True only when all checks pass — fail-closed on any missing/failed signal. */
|
|
130
|
+
ready: boolean;
|
|
131
|
+
/** Every check, in a stable order, whether it passed or blocked. */
|
|
132
|
+
checks: ApexReadinessCheck[];
|
|
133
|
+
/** The details of the blocking checks only (empty ⇒ ready). Convenience for surfaces. */
|
|
134
|
+
blockedReasons: string[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Compute apex-cutover readiness from the signals — PURE, total, fail-closed. The
|
|
139
|
+
* order is deliberate (owner → in-flight → parity → health → freshness → revert)
|
|
140
|
+
* so the most fundamental blocker surfaces first, mirroring `shipReadiness`.
|
|
141
|
+
*
|
|
142
|
+
* The cutover is READY only when ALL checks pass. Any missing signal (null
|
|
143
|
+
* evidence, null digest, absent revert key) is a BLOCK with an honest reason,
|
|
144
|
+
* never a silent pass.
|
|
145
|
+
*/
|
|
146
|
+
export function computeApexReadiness(signals: ApexReadinessSignals): ApexReadiness {
|
|
147
|
+
const checks: ApexReadinessCheck[] = [];
|
|
148
|
+
|
|
149
|
+
// 1. Owner-only capability (u10). A non-owner is denied outright — this is the
|
|
150
|
+
// same floor `apexCutoverGate` enforces, surfaced as a readiness item.
|
|
151
|
+
checks.push({
|
|
152
|
+
id: "owner-capability",
|
|
153
|
+
label: "Owner capability",
|
|
154
|
+
ok: signals.ownerCapable,
|
|
155
|
+
detail: signals.ownerCapable
|
|
156
|
+
? "you hold the owner-only apex-cutover capability"
|
|
157
|
+
: "apex cutover is owner-only — a ship-on-behalf developer cannot cut over the apex",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// 2. No run in flight — two concurrent DNS mutations is the race DomainState guards.
|
|
161
|
+
checks.push({
|
|
162
|
+
id: "no-run-in-flight",
|
|
163
|
+
label: "No run in flight",
|
|
164
|
+
ok: !signals.runInFlight,
|
|
165
|
+
detail: signals.runInFlight
|
|
166
|
+
? "a domain connect/rollback run is already in flight — wait for it to complete"
|
|
167
|
+
: "no domain run is in flight",
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// 3. Dual-run parity — zero CloudFront route failures (the hard gate dual-run.mjs
|
|
171
|
+
// itself enforces). Missing evidence fails closed. Status mismatches are
|
|
172
|
+
// surfaced but do NOT block (content legitimately differs mid-migration).
|
|
173
|
+
const dr = signals.dualRun;
|
|
174
|
+
const parityOk = !!dr && dr.routes > 0 && dr.cfFailures === 0;
|
|
175
|
+
checks.push({
|
|
176
|
+
id: "dual-run-parity",
|
|
177
|
+
label: "Dual-run parity",
|
|
178
|
+
ok: parityOk,
|
|
179
|
+
detail: !dr
|
|
180
|
+
? "no dual-run parity evidence — run the CloudFront-vs-baseline dual-run first"
|
|
181
|
+
: dr.routes === 0
|
|
182
|
+
? "dual-run evidence covered 0 routes — not a real parity run"
|
|
183
|
+
: dr.cfFailures > 0
|
|
184
|
+
? `dual-run parity failing: ${dr.cfFailures} CloudFront route failure(s) of ${dr.routes}`
|
|
185
|
+
: `dual-run green: 0 CloudFront failures across ${dr.routes} route(s)` +
|
|
186
|
+
(dr.statusMismatches > 0
|
|
187
|
+
? ` (${dr.statusMismatches} status mismatch(es) surfaced for review — non-blocking)`
|
|
188
|
+
: ""),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// 4. Target health — the apex target (CloudFront/static origin) is serving.
|
|
192
|
+
checks.push({
|
|
193
|
+
id: "target-health",
|
|
194
|
+
label: "Target health",
|
|
195
|
+
ok: signals.targetHealthy,
|
|
196
|
+
detail: signals.targetHealthy
|
|
197
|
+
? "apex target health check passed"
|
|
198
|
+
: "apex target health check failing or unestablished",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// 5. Evidence freshness — the parity evidence is tied to the CURRENT live digest
|
|
202
|
+
// (`static-publish-fails-closed-reviewed-sha`). Stale evidence (a different
|
|
203
|
+
// digest) or a missing digest on either side fails closed.
|
|
204
|
+
const evidenceDigest = dr?.digest ?? null;
|
|
205
|
+
const freshOk =
|
|
206
|
+
!!evidenceDigest && !!signals.liveDigest && evidenceDigest === signals.liveDigest;
|
|
207
|
+
checks.push({
|
|
208
|
+
id: "evidence-fresh",
|
|
209
|
+
label: "Evidence fresh",
|
|
210
|
+
ok: freshOk,
|
|
211
|
+
detail: freshOk
|
|
212
|
+
? `evidence is tied to the live digest (${short(signals.liveDigest)})`
|
|
213
|
+
: !signals.liveDigest
|
|
214
|
+
? "no live digest to tie the evidence to"
|
|
215
|
+
: !evidenceDigest
|
|
216
|
+
? "parity evidence carries no digest — cannot confirm it matches the live tree"
|
|
217
|
+
: `parity evidence is stale: it was produced against ${short(evidenceDigest)}, ` +
|
|
218
|
+
`not the live digest ${short(signals.liveDigest)}`,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// 6. Captured revert target — a rollback must have persisted prior DNS records to
|
|
222
|
+
// restore FROM. No captured target ⇒ the cutover has no tested one-line revert.
|
|
223
|
+
const revertOk = !!signals.revert.key;
|
|
224
|
+
checks.push({
|
|
225
|
+
id: "revert-captured",
|
|
226
|
+
label: "Revert captured",
|
|
227
|
+
ok: revertOk,
|
|
228
|
+
detail: revertOk
|
|
229
|
+
? `captured revert target present (prior DNS records${
|
|
230
|
+
signals.revert.capturedAt ? ` @ ${signals.revert.capturedAt}` : ""
|
|
231
|
+
})`
|
|
232
|
+
: "no captured revert target — prior DNS records are not persisted, so there is no tested rollback",
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const blockedReasons = checks.filter((c) => !c.ok).map((c) => c.detail);
|
|
236
|
+
return { ready: blockedReasons.length === 0, checks, blockedReasons };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Normalise a raw `dual-run.mjs --json` summary into {@link DualRunEvidence}. Pure.
|
|
240
|
+
* Tolerant of the shape the script prints (`{ summary: { routes, cfFailures,
|
|
241
|
+
* statusMismatches } }`) as well as a flattened record; returns null for anything
|
|
242
|
+
* unrecognisable so the gate fails closed on garbage. `digest`/`capturedAt` are
|
|
243
|
+
* read from the wrapping evidence record (the script itself doesn't stamp them). */
|
|
244
|
+
export function normalizeDualRunEvidence(raw: unknown): DualRunEvidence | null {
|
|
245
|
+
if (!raw || typeof raw !== "object") return null;
|
|
246
|
+
const o = raw as Record<string, unknown>;
|
|
247
|
+
const summary =
|
|
248
|
+
o.summary && typeof o.summary === "object" ? (o.summary as Record<string, unknown>) : o;
|
|
249
|
+
const cfFailures = num(summary.cfFailures);
|
|
250
|
+
const routes = num(summary.routes);
|
|
251
|
+
const statusMismatches = num(summary.statusMismatches);
|
|
252
|
+
if (cfFailures === null || routes === null) return null;
|
|
253
|
+
return {
|
|
254
|
+
cfFailures,
|
|
255
|
+
routes,
|
|
256
|
+
statusMismatches: statusMismatches ?? 0,
|
|
257
|
+
digest: typeof o.digest === "string" ? o.digest : null,
|
|
258
|
+
capturedAt: typeof o.capturedAt === "string" ? o.capturedAt : null,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function num(v: unknown): number | null {
|
|
263
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── Evidence record + signal assembly (the endpoint's non-pure boundary) ─────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The consumed readiness-evidence record the go-live dual-run/health CI persists
|
|
270
|
+
* per tenant. u9 READS it; it does not produce it. Bundles the three signals u9
|
|
271
|
+
* cannot derive from `DomainState` alone: the dual-run parity result, the apex
|
|
272
|
+
* target's health, and the digest currently live (so freshness can be checked).
|
|
273
|
+
*/
|
|
274
|
+
export interface ReadinessEvidence {
|
|
275
|
+
dualRun: DualRunEvidence | null;
|
|
276
|
+
targetHealthy: boolean;
|
|
277
|
+
liveDigest: string | null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** KV key for a tenant's consumed readiness-evidence record. */
|
|
281
|
+
export function readinessEvidenceKey(tenantId: string): string {
|
|
282
|
+
return `www-domain:readiness-evidence:${tenantId}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Normalise a raw persisted evidence record into {@link ReadinessEvidence},
|
|
287
|
+
* FAIL-CLOSED: anything unrecognisable yields no parity, an unhealthy target, and
|
|
288
|
+
* no live digest — so the gate blocks rather than trusting a malformed feed. Pure.
|
|
289
|
+
*/
|
|
290
|
+
export function normalizeReadinessEvidence(raw: unknown): ReadinessEvidence {
|
|
291
|
+
if (!raw || typeof raw !== "object") {
|
|
292
|
+
return { dualRun: null, targetHealthy: false, liveDigest: null };
|
|
293
|
+
}
|
|
294
|
+
const o = raw as Record<string, unknown>;
|
|
295
|
+
return {
|
|
296
|
+
dualRun: normalizeDualRunEvidence(o.dualRun ?? o),
|
|
297
|
+
targetHealthy: o.targetHealthy === true,
|
|
298
|
+
liveDigest: typeof o.liveDigest === "string" ? o.liveDigest : null,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The state fields the readiness signals draw from `DomainState` (structural, no import). */
|
|
303
|
+
export interface ReadinessDomainState {
|
|
304
|
+
/** True when the last run is still awaiting its CI callback (`runInFlight`). */
|
|
305
|
+
runInFlight: boolean;
|
|
306
|
+
/** The captured prior-DNS-records pointer (`priorRecordsKey`), or null. */
|
|
307
|
+
priorRecordsKey: string | null;
|
|
308
|
+
/** When those prior records were captured (`priorCapturedAt`), or null. */
|
|
309
|
+
priorCapturedAt: string | null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Assemble the {@link ApexReadinessSignals} the gate decides over from the three
|
|
314
|
+
* live sources — the owner capability (u10), the per-tenant domain state (revert +
|
|
315
|
+
* in-flight), and the consumed evidence record — PURE so the endpoint's mapping is
|
|
316
|
+
* unit-testable without KV/session I/O. The endpoint does the reads; this does the
|
|
317
|
+
* mapping.
|
|
318
|
+
*/
|
|
319
|
+
export function assembleApexReadinessSignals(input: {
|
|
320
|
+
ownerCapable: boolean;
|
|
321
|
+
state: ReadinessDomainState;
|
|
322
|
+
evidence: ReadinessEvidence;
|
|
323
|
+
}): ApexReadinessSignals {
|
|
324
|
+
return {
|
|
325
|
+
ownerCapable: input.ownerCapable,
|
|
326
|
+
runInFlight: input.state.runInFlight,
|
|
327
|
+
dualRun: input.evidence.dualRun,
|
|
328
|
+
targetHealthy: input.evidence.targetHealthy,
|
|
329
|
+
liveDigest: input.evidence.liveDigest,
|
|
330
|
+
revert: { key: input.state.priorRecordsKey, capturedAt: input.state.priorCapturedAt },
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Short 12-char digest for legible details (full digest stays in the raw signal). */
|
|
335
|
+
function short(digest: string | null): string {
|
|
336
|
+
return digest ? digest.slice(0, 12) : "(none)";
|
|
337
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch-health assessment (dg5) — is the static-publish `repository_dispatch`
|
|
3
|
+
* path actually WIRED and HEALTHY, or would a "go-live" silently hand the publish
|
|
4
|
+
* to a dead-end? The Worker fires a `repository_dispatch` and gets a 204 back
|
|
5
|
+
* having verified NOTHING about the target (see `createRepositoryDispatchSink` —
|
|
6
|
+
* it is fire-and-forget by construction). This module is the HONEST pre-flight
|
|
7
|
+
* surface that answers, per binding decision `static-publish-fails-closed-
|
|
8
|
+
* reviewed-sha`, whether that path can carry a publish at all — reporting each
|
|
9
|
+
* concrete signal pass/fail/unknown with a reason, never a silent assumption.
|
|
10
|
+
*
|
|
11
|
+
* FOUR SIGNALS:
|
|
12
|
+
* - `dispatch-cred-present` — is WWW_PUBLISH_DISPATCH_TOKEN set?
|
|
13
|
+
* - `target-config-present` — does this tenant declare a static
|
|
14
|
+
* target AND does the dispatch repo resolve?
|
|
15
|
+
* - `workflow-registered-on-default-branch` — is the publish workflow visible to
|
|
16
|
+
* the Actions API? `repository_dispatch`
|
|
17
|
+
* ONLY triggers workflows on the repo's
|
|
18
|
+
* DEFAULT branch (main), so a workflow
|
|
19
|
+
* that lives only on the deploy branch is
|
|
20
|
+
* NOT yet reachable. A 404 here is the
|
|
21
|
+
* honest "not yet promoted to main" state
|
|
22
|
+
* (the HELD main-promotion gate,
|
|
23
|
+
* `pending-main-promotion-and-production-
|
|
24
|
+
* publish-activation`) — surfaced, not papered.
|
|
25
|
+
* - `target-reachable` — does the published `live/manifest.json`
|
|
26
|
+
* answer? DIAGNOSTIC ONLY: a pre-first-
|
|
27
|
+
* publish target legitimately has none, so
|
|
28
|
+
* this never blocks a ship (the post-merge
|
|
29
|
+
* VERIFY in dg4's orchestrator is the real
|
|
30
|
+
* no-false-live gate).
|
|
31
|
+
*
|
|
32
|
+
* FAIL-CLOSED SEMANTICS:
|
|
33
|
+
* - `healthy` is true ONLY when every SHIP-CRITICAL signal (cred, target-config,
|
|
34
|
+
* workflow-registration) is `pass`. An `unknown` is NOT healthy.
|
|
35
|
+
* - `blockShip` is true only for a DEFINITIVELY-bad critical precondition (cred
|
|
36
|
+
* missing, target-config missing, or a workflow 404). An `unknown` (e.g. the
|
|
37
|
+
* Actions API is unreachable, or the token lacks read scope) does NOT set
|
|
38
|
+
* `blockShip` — so GitHub reachability is never a hard dependency of every
|
|
39
|
+
* accept; the orchestrator's post-merge verify still fails closed regardless.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** A single signal's honest state. `unknown` = we genuinely could not determine it. */
|
|
43
|
+
export type DispatchHealthStatus = "pass" | "fail" | "unknown";
|
|
44
|
+
|
|
45
|
+
export type DispatchHealthSignalId =
|
|
46
|
+
| "dispatch-cred-present"
|
|
47
|
+
| "target-config-present"
|
|
48
|
+
| "workflow-registered-on-default-branch"
|
|
49
|
+
| "target-reachable";
|
|
50
|
+
|
|
51
|
+
export interface DispatchHealthSignal {
|
|
52
|
+
id: DispatchHealthSignalId;
|
|
53
|
+
status: DispatchHealthStatus;
|
|
54
|
+
/** Human/audit-legible reason for the status. */
|
|
55
|
+
detail: string;
|
|
56
|
+
/** Whether this signal gates a ship (false = diagnostic only, e.g. target-reachable). */
|
|
57
|
+
critical: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DispatchHealthReport {
|
|
61
|
+
/** True ONLY when every ship-critical signal is `pass` (fail-closed: unknown ⇒ not healthy). */
|
|
62
|
+
healthy: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* True when a ship should be REFUSED before merge because a critical precondition
|
|
65
|
+
* is DEFINITIVELY bad (never for an `unknown`). Consumed by the ship path so a
|
|
66
|
+
* static go-live never merges into a dead-end that could report a false live.
|
|
67
|
+
*/
|
|
68
|
+
blockShip: boolean;
|
|
69
|
+
/** The one-line honest reason `blockShip` is set, else null. */
|
|
70
|
+
blockReason: string | null;
|
|
71
|
+
signals: DispatchHealthSignal[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AssessDispatchHealthInput {
|
|
75
|
+
/** WWW_PUBLISH_DISPATCH_TOKEN (presence only; the value never leaves env). */
|
|
76
|
+
dispatchToken: string | null;
|
|
77
|
+
/** `owner/repo` hosting the publish workflow (WWW_PUBLISH_DISPATCH_REPO). */
|
|
78
|
+
repoSlug: string | null;
|
|
79
|
+
/** The `repository_dispatch` event type (informational — surfaced for audit). */
|
|
80
|
+
eventType: string;
|
|
81
|
+
/** GitHub API base (default https://api.github.com); set for Enterprise. */
|
|
82
|
+
apiBase?: string;
|
|
83
|
+
/** The workflow file name the Actions API is probed for (e.g. "www-publish.yml"). */
|
|
84
|
+
workflowFile: string;
|
|
85
|
+
/** The tenant's static-publish target label when enabled, else null. */
|
|
86
|
+
staticPublishTarget: string | null;
|
|
87
|
+
/** Published-manifest URL for the `target-reachable` probe, or null when unresolved. */
|
|
88
|
+
manifestUrl: string | null;
|
|
89
|
+
/** Injectable fetch (Worker/Node global by default) — so this is unit-tested with no network. */
|
|
90
|
+
fetchImpl?: typeof fetch;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Parse `owner/repo`, returning null when it isn't a clean two-part slug. */
|
|
94
|
+
export function parseRepoSlug(slug: string | null): { owner: string; repo: string } | null {
|
|
95
|
+
if (!slug) return null;
|
|
96
|
+
const [owner, repo] = slug.split("/", 2);
|
|
97
|
+
if (!owner || !repo) return null;
|
|
98
|
+
return { owner, repo };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Probe the Actions API for the publish workflow. A 200 means GitHub has the
|
|
103
|
+
* workflow REGISTERED — which only happens once it is on the repo's DEFAULT
|
|
104
|
+
* branch, so `repository_dispatch` can trigger it. A 404 is the honest "not on the
|
|
105
|
+
* default branch yet" state. A 401/403/other/network error is `unknown` (we can't
|
|
106
|
+
* tell — often the token has write-only scope), NEVER a false pass or a hard block.
|
|
107
|
+
*/
|
|
108
|
+
async function probeWorkflowRegistered(
|
|
109
|
+
input: AssessDispatchHealthInput,
|
|
110
|
+
repo: { owner: string; repo: string },
|
|
111
|
+
): Promise<DispatchHealthSignal> {
|
|
112
|
+
const base = {
|
|
113
|
+
id: "workflow-registered-on-default-branch" as const,
|
|
114
|
+
critical: true,
|
|
115
|
+
};
|
|
116
|
+
if (!input.dispatchToken) {
|
|
117
|
+
return { ...base, status: "unknown", detail: "cannot probe: dispatch credential absent" };
|
|
118
|
+
}
|
|
119
|
+
const apiBase = (input.apiBase ?? "https://api.github.com").replace(/\/+$/, "");
|
|
120
|
+
const url = `${apiBase}/repos/${repo.owner}/${repo.repo}/actions/workflows/${encodeURIComponent(input.workflowFile)}`;
|
|
121
|
+
const doFetch = input.fetchImpl ?? fetch;
|
|
122
|
+
let res: Response;
|
|
123
|
+
try {
|
|
124
|
+
res = await doFetch(url, {
|
|
125
|
+
headers: {
|
|
126
|
+
authorization: `Bearer ${input.dispatchToken}`,
|
|
127
|
+
accept: "application/vnd.github+json",
|
|
128
|
+
"user-agent": "tot-storefront-dispatch-health",
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { ...base, status: "unknown", detail: `Actions API unreachable: ${(e as Error).message}` };
|
|
133
|
+
}
|
|
134
|
+
if (res.status === 200) {
|
|
135
|
+
return {
|
|
136
|
+
...base,
|
|
137
|
+
status: "pass",
|
|
138
|
+
detail: `${input.workflowFile} is registered on the default branch (repository_dispatch can trigger it)`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (res.status === 404) {
|
|
142
|
+
return {
|
|
143
|
+
...base,
|
|
144
|
+
status: "fail",
|
|
145
|
+
detail:
|
|
146
|
+
`${input.workflowFile} is NOT registered on ${repo.owner}/${repo.repo}'s default branch ` +
|
|
147
|
+
`(main) — repository_dispatch cannot trigger it until it is promoted to main ` +
|
|
148
|
+
`(pending main-promotion gate).`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (res.status === 401 || res.status === 403) {
|
|
152
|
+
return {
|
|
153
|
+
...base,
|
|
154
|
+
status: "unknown",
|
|
155
|
+
detail: `Actions API returned HTTP ${res.status} — dispatch token lacks read scope; registration cannot be confirmed`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
return { ...base, status: "unknown", detail: `Actions API returned HTTP ${res.status}` };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Probe the published-manifest URL. DIAGNOSTIC ONLY (`critical: false`): a
|
|
163
|
+
* pre-first-publish target has no manifest yet, which is a legitimate bootstrap
|
|
164
|
+
* state, so this never gates a ship.
|
|
165
|
+
*/
|
|
166
|
+
async function probeTargetReachable(input: AssessDispatchHealthInput): Promise<DispatchHealthSignal> {
|
|
167
|
+
const base = { id: "target-reachable" as const, critical: false };
|
|
168
|
+
if (!input.manifestUrl) {
|
|
169
|
+
return { ...base, status: "unknown", detail: "no published-manifest URL resolved for this target" };
|
|
170
|
+
}
|
|
171
|
+
const doFetch = input.fetchImpl ?? fetch;
|
|
172
|
+
let res: Response;
|
|
173
|
+
try {
|
|
174
|
+
res = await doFetch(input.manifestUrl, { headers: { accept: "application/json" } });
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return { ...base, status: "unknown", detail: `manifest fetch failed: ${(e as Error).message}` };
|
|
177
|
+
}
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
return {
|
|
180
|
+
...base,
|
|
181
|
+
status: res.status === 404 ? "fail" : "unknown",
|
|
182
|
+
detail: `manifest HTTP ${res.status}${res.status === 404 ? " (target not yet published — bootstrap)" : ""}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
let hasDigest = false;
|
|
186
|
+
try {
|
|
187
|
+
const manifest = (await res.json()) as { treeDigest?: unknown };
|
|
188
|
+
hasDigest = typeof manifest.treeDigest === "string" && manifest.treeDigest.length > 0;
|
|
189
|
+
} catch {
|
|
190
|
+
return { ...base, status: "fail", detail: "manifest was not valid JSON" };
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
...base,
|
|
194
|
+
status: "pass",
|
|
195
|
+
detail: hasDigest ? "target serves a published manifest with a treeDigest" : "target reachable (manifest carries no treeDigest yet)",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Assess the dispatch path's health. Runs the two network probes concurrently.
|
|
201
|
+
* Pure w.r.t. its inputs — the caller resolves env/config and injects `fetchImpl`.
|
|
202
|
+
*/
|
|
203
|
+
export async function assessDispatchHealth(
|
|
204
|
+
input: AssessDispatchHealthInput,
|
|
205
|
+
): Promise<DispatchHealthReport> {
|
|
206
|
+
const repo = parseRepoSlug(input.repoSlug);
|
|
207
|
+
|
|
208
|
+
const credSignal: DispatchHealthSignal = input.dispatchToken
|
|
209
|
+
? {
|
|
210
|
+
id: "dispatch-cred-present",
|
|
211
|
+
status: "pass",
|
|
212
|
+
detail: "WWW_PUBLISH_DISPATCH_TOKEN is configured",
|
|
213
|
+
critical: true,
|
|
214
|
+
}
|
|
215
|
+
: {
|
|
216
|
+
id: "dispatch-cred-present",
|
|
217
|
+
status: "fail",
|
|
218
|
+
detail: "WWW_PUBLISH_DISPATCH_TOKEN is not set — the Worker cannot fire repository_dispatch",
|
|
219
|
+
critical: true,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const targetConfigSignal: DispatchHealthSignal =
|
|
223
|
+
input.staticPublishTarget && repo
|
|
224
|
+
? {
|
|
225
|
+
id: "target-config-present",
|
|
226
|
+
status: "pass",
|
|
227
|
+
detail: `static target "${input.staticPublishTarget}" → dispatch repo ${repo.owner}/${repo.repo} (event ${input.eventType})`,
|
|
228
|
+
critical: true,
|
|
229
|
+
}
|
|
230
|
+
: {
|
|
231
|
+
id: "target-config-present",
|
|
232
|
+
status: "fail",
|
|
233
|
+
detail: !input.staticPublishTarget
|
|
234
|
+
? "tenant declares no enabled static-publish target"
|
|
235
|
+
: "dispatch repo (WWW_PUBLISH_DISPATCH_REPO) does not resolve to owner/repo",
|
|
236
|
+
critical: true,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// The workflow probe needs a resolved repo; without one it can't be evaluated.
|
|
240
|
+
const workflowSignal: DispatchHealthSignal = repo
|
|
241
|
+
? await probeWorkflowRegistered(input, repo)
|
|
242
|
+
: {
|
|
243
|
+
id: "workflow-registered-on-default-branch",
|
|
244
|
+
status: "unknown",
|
|
245
|
+
detail: "cannot probe: dispatch repo does not resolve",
|
|
246
|
+
critical: true,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const reachableSignal = await probeTargetReachable(input);
|
|
250
|
+
|
|
251
|
+
const signals: DispatchHealthSignal[] = [
|
|
252
|
+
credSignal,
|
|
253
|
+
targetConfigSignal,
|
|
254
|
+
workflowSignal,
|
|
255
|
+
reachableSignal,
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
const critical = signals.filter((s) => s.critical);
|
|
259
|
+
const healthy = critical.every((s) => s.status === "pass");
|
|
260
|
+
// Block a ship only for a DEFINITIVELY-bad critical precondition — never an unknown.
|
|
261
|
+
const blocker = critical.find((s) => s.status === "fail") ?? null;
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
healthy,
|
|
265
|
+
blockShip: blocker !== null,
|
|
266
|
+
blockReason: blocker ? `${blocker.id}: ${blocker.detail}` : null,
|
|
267
|
+
signals,
|
|
268
|
+
};
|
|
269
|
+
}
|