@tokenoftrust/storefront-runner 1.3.4-rc.4 → 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/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 +433 -69
- 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,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monitoring contract (spec §F16 — monitors, alerts, operator runbooks).
|
|
3
|
+
*
|
|
4
|
+
* This is the CHECKED-IN source of truth for "what do we watch, at what
|
|
5
|
+
* threshold, at what severity, and which runbook fires when it trips." It is
|
|
6
|
+
* deliberately data, not wiring: Phase 1 ships the *contract* so operators can
|
|
7
|
+
* see coverage and reviewers can diff it; the actual alert providers (Cloudflare
|
|
8
|
+
* Health Checks / Workers metrics / Logpush alerts / a RUM sink) are provisioned
|
|
9
|
+
* out-of-band and each monitor records whether it is `in-code` today or still
|
|
10
|
+
* `needs-external` / `needs-secret`.
|
|
11
|
+
*
|
|
12
|
+
* Signal sources are named against real code where it exists in this repo
|
|
13
|
+
* (`/health` → lib/health.ts, CSP → pages/api/csp-report.ts + CSP_REPORT_PATH,
|
|
14
|
+
* commerce events → lib/analytics). Signals owned by sibling units are referenced
|
|
15
|
+
* by CONTRACT NAME, not imported — Core Web Vitals come from F14's RUM beacon,
|
|
16
|
+
* and age-verification / restricted-state denials come from F12's compliance
|
|
17
|
+
* evidence signals. This file does not edit or depend on those units; when they
|
|
18
|
+
* land, point the corresponding monitor's `observe` at the real sink.
|
|
19
|
+
*
|
|
20
|
+
* The companion operator doc renders this for humans and lists the external
|
|
21
|
+
* wiring/secrets checklist: docs/epics/sample-store-migration/monitoring-and-runbooks.md.
|
|
22
|
+
* RUNBOOK.md §6–§8 hold the incident runbooks these monitors dispatch to.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Alert severity, in escalation order.
|
|
27
|
+
* - `page` — wake someone now; launch-critical, customer-blocking.
|
|
28
|
+
* - `critical` — same-business-hours; degraded but not fully down, or a
|
|
29
|
+
* compliance/legal risk that must be resolved before more sales.
|
|
30
|
+
* - `warning` — investigate; a spike or trend that predicts a `critical`.
|
|
31
|
+
* - `info` — record only; useful for post-incident context, no alert.
|
|
32
|
+
*/
|
|
33
|
+
export type Severity = "page" | "critical" | "warning" | "info";
|
|
34
|
+
|
|
35
|
+
/** What each monitor watches, grouped so a dashboard can lane them. */
|
|
36
|
+
export type MonitorCategory =
|
|
37
|
+
| "availability"
|
|
38
|
+
| "commerce"
|
|
39
|
+
| "compliance"
|
|
40
|
+
| "catalog"
|
|
41
|
+
| "performance"
|
|
42
|
+
| "security";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Provisioning status of a monitor — the honest "is this actually alerting?"
|
|
46
|
+
* flag so a green manifest never implies coverage we don't have.
|
|
47
|
+
* - `in-code` — the signal is emitted by code in THIS repo; only a log
|
|
48
|
+
* or metric alert rule needs attaching.
|
|
49
|
+
* - `needs-external` — depends on an external provider/probe/sink that must be
|
|
50
|
+
* configured (Cloudflare Health Check, Logpush alert, RUM).
|
|
51
|
+
* - `needs-secret` — additionally blocked on a credential/token being set.
|
|
52
|
+
*/
|
|
53
|
+
export type WiringStatus = "in-code" | "needs-external" | "needs-secret";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Stable ids for the six incident runbooks (RUNBOOK.md §7). Every monitor names
|
|
57
|
+
* the runbook an operator opens when it fires, so detection → response is a
|
|
58
|
+
* lookup, not archaeology.
|
|
59
|
+
*/
|
|
60
|
+
export type RunbookId =
|
|
61
|
+
| "checkout-down"
|
|
62
|
+
| "catalog-stale"
|
|
63
|
+
| "bad-price"
|
|
64
|
+
| "compliance-provider-down"
|
|
65
|
+
| "tax-shipping-outage"
|
|
66
|
+
| "bad-content-policy";
|
|
67
|
+
|
|
68
|
+
export interface Monitor {
|
|
69
|
+
/** Stable kebab-case id; also the alert name. Never renamed once shipped. */
|
|
70
|
+
id: string;
|
|
71
|
+
/** One-line human title for dashboards/alerts. */
|
|
72
|
+
title: string;
|
|
73
|
+
category: MonitorCategory;
|
|
74
|
+
/** Where the signal comes from and how a monitor reads it. */
|
|
75
|
+
signal: {
|
|
76
|
+
/** The emitter — code symbol/path in this repo, or a named sibling contract. */
|
|
77
|
+
source: string;
|
|
78
|
+
/** How the alert observes it: probe, Worker metric, log filter, RUM, webhook. */
|
|
79
|
+
observe: string;
|
|
80
|
+
};
|
|
81
|
+
/** The alarm condition in plain, checkable terms (thresholds are launch defaults). */
|
|
82
|
+
threshold: string;
|
|
83
|
+
severity: Severity;
|
|
84
|
+
/** Which incident runbook to run when this fires. */
|
|
85
|
+
runbook: RunbookId;
|
|
86
|
+
wiring: WiringStatus;
|
|
87
|
+
/** External dependency, secret name, or sibling-unit signal reference. */
|
|
88
|
+
notes?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The ten launch-critical monitors from spec §F16. Order is presentation order.
|
|
93
|
+
* Thresholds are the go-live defaults; tune against the 24h post-cutover soak
|
|
94
|
+
* (T10) and record any change here — this file is the audit trail.
|
|
95
|
+
*/
|
|
96
|
+
export const MONITORS: readonly Monitor[] = [
|
|
97
|
+
{
|
|
98
|
+
id: "uptime",
|
|
99
|
+
title: "Storefront uptime / liveness",
|
|
100
|
+
category: "availability",
|
|
101
|
+
signal: {
|
|
102
|
+
source: "GET /health (lib/health.ts — answered before tenant/gate/cache)",
|
|
103
|
+
observe: "external synthetic probe hits /health across regions; expects 200 {status:'ok'}",
|
|
104
|
+
},
|
|
105
|
+
threshold: "2 consecutive probe failures (or >1% of probes failing over 5m)",
|
|
106
|
+
severity: "page",
|
|
107
|
+
runbook: "checkout-down",
|
|
108
|
+
wiring: "needs-external",
|
|
109
|
+
notes:
|
|
110
|
+
"Wire a Cloudflare Health Check (or external uptime monitor) per production host. " +
|
|
111
|
+
"/health also returns the live commit .sha — use it to confirm which build is up during an incident.",
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "page-errors",
|
|
115
|
+
title: "Page / Worker errors (5xx + unhandled exceptions)",
|
|
116
|
+
category: "availability",
|
|
117
|
+
signal: {
|
|
118
|
+
source: "Cloudflare Worker exceptions + non-2xx responses from middleware/index.ts render path",
|
|
119
|
+
observe: "Workers 'Errors' metric + Logpush; alert on error-rate over the request baseline",
|
|
120
|
+
},
|
|
121
|
+
threshold: "5xx rate > 1% over 5m, or any sustained unhandled-exception stream",
|
|
122
|
+
severity: "critical",
|
|
123
|
+
runbook: "bad-content-policy",
|
|
124
|
+
wiring: "needs-external",
|
|
125
|
+
notes:
|
|
126
|
+
"A spike isolated to one route usually points at a bad publish/content artifact (see bad-content-policy); " +
|
|
127
|
+
"a broad spike is an availability event (checkout-down).",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: "checkout-failures",
|
|
131
|
+
title: "Checkout failures (add-to-cart / checkout handoff)",
|
|
132
|
+
category: "commerce",
|
|
133
|
+
signal: {
|
|
134
|
+
source:
|
|
135
|
+
"Signed add-to-cart + cross-origin checkout POST (F11; lib/checkoutCommerce.ts, checkoutCspAdditions)",
|
|
136
|
+
observe:
|
|
137
|
+
"Worker logs on the signed add-to-cart path + checkout-host success beacon; alert on failure ratio",
|
|
138
|
+
},
|
|
139
|
+
threshold: "checkout-start error ratio > 2% over 10m, or add-to-cart 5xx > 1% over 5m",
|
|
140
|
+
severity: "page",
|
|
141
|
+
runbook: "checkout-down",
|
|
142
|
+
wiring: "needs-external",
|
|
143
|
+
notes:
|
|
144
|
+
"Checkout completes on the store's cross-origin cart/checkout host, so full funnel coverage needs a beacon " +
|
|
145
|
+
"from that host too. Storefront-side we can only see the signed handoff; a CSP formAction/frameSrc block " +
|
|
146
|
+
"also surfaces here as csp-violations.",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "age-verification-failures",
|
|
150
|
+
title: "Age-verification failures",
|
|
151
|
+
category: "compliance",
|
|
152
|
+
signal: {
|
|
153
|
+
source:
|
|
154
|
+
"F12 compliance 'age-verification' evidence signal (AgeGate / IsolatedAgeGate → ToT verification)",
|
|
155
|
+
observe: "F12 evidence signal + ToT verification provider errors; alert on error/timeout rate, not on user-declines",
|
|
156
|
+
},
|
|
157
|
+
threshold: "verification provider error/timeout rate > 5% over 10m (distinct from legitimate fail-age outcomes)",
|
|
158
|
+
severity: "critical",
|
|
159
|
+
runbook: "compliance-provider-down",
|
|
160
|
+
wiring: "needs-external",
|
|
161
|
+
notes:
|
|
162
|
+
"Reference F12's signal by name; do NOT read the compliance internals here. Distinguish PROVIDER failure " +
|
|
163
|
+
"(page/critical — we can't legally sell) from a shopper genuinely failing age (expected, not an alert). " +
|
|
164
|
+
"Pre-launch config-coherence gate is in-repo: GET /api/evidence.json (assembleEvidenceReport → age_gate).",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "restricted-state-denials",
|
|
168
|
+
title: "Restricted-state / shipping denials (anomaly)",
|
|
169
|
+
category: "compliance",
|
|
170
|
+
signal: {
|
|
171
|
+
source:
|
|
172
|
+
"F12 compliance 'restricted-state denial' signal (StateEligibilityNotice / ShippingRestrictionNotice / complianceFloor)",
|
|
173
|
+
observe: "F12 evidence signal counted per state; alert on ANOMALOUS spike vs baseline, not on absolute count",
|
|
174
|
+
},
|
|
175
|
+
threshold: "denial rate for a state jumps >3x its 7-day baseline over 1h (mis-configured rule, not normal traffic)",
|
|
176
|
+
severity: "warning",
|
|
177
|
+
runbook: "bad-content-policy",
|
|
178
|
+
wiring: "needs-external",
|
|
179
|
+
notes:
|
|
180
|
+
"Denials are EXPECTED (that's the control working). This monitor catches a mis-configured restriction " +
|
|
181
|
+
"rule that suddenly blocks a legal state — a spike, not a level. Reference F12's signal by name. " +
|
|
182
|
+
"Pre-launch config-coherence gate is in-repo: GET /api/evidence.json (jurisdiction/excise signals).",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: "catalog-sync-failures",
|
|
186
|
+
title: "Catalog sync failures",
|
|
187
|
+
category: "catalog",
|
|
188
|
+
signal: {
|
|
189
|
+
source: "Migration incremental sync (packages/migration) + F17 catalog graduation job",
|
|
190
|
+
observe: "sync-job exit status + migration report (errors/skipped); alert on failed or missed run",
|
|
191
|
+
},
|
|
192
|
+
threshold: "any sync job fails, OR no successful sync within the expected cadence window (default 1h)",
|
|
193
|
+
severity: "warning",
|
|
194
|
+
runbook: "catalog-stale",
|
|
195
|
+
wiring: "needs-external",
|
|
196
|
+
notes:
|
|
197
|
+
"Escalate to critical once a store is on a live source (F17): a failed sync means the catalog is drifting " +
|
|
198
|
+
"from the merchant source. Depends on F17 defining the launch cadence.",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "inventory-mismatch",
|
|
202
|
+
title: "Inventory mismatch (oversell risk)",
|
|
203
|
+
category: "catalog",
|
|
204
|
+
signal: {
|
|
205
|
+
source: "F17 inventory-freshness mismatch alarm (storefront-shown vs source-of-truth stock)",
|
|
206
|
+
observe: "reconciliation check comparing served inventory to the merchant/hosted source",
|
|
207
|
+
},
|
|
208
|
+
threshold: "any product served in-stock while source says out-of-stock, or staleness > sync cadence + grace",
|
|
209
|
+
severity: "critical",
|
|
210
|
+
runbook: "catalog-stale",
|
|
211
|
+
wiring: "needs-external",
|
|
212
|
+
notes:
|
|
213
|
+
"Overselling regulated goods is a launch-blocker. This alarm is the one F16 defines but F17 FEEDS with real " +
|
|
214
|
+
"data — until F17 lands it is a contract only.",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: "payment-failures",
|
|
218
|
+
title: "Payment failures",
|
|
219
|
+
category: "commerce",
|
|
220
|
+
signal: {
|
|
221
|
+
source: "Payment provider on the checkout host (external to the storefront Worker)",
|
|
222
|
+
observe: "payment provider dashboard/webhooks; alert on declined/error rate vs baseline",
|
|
223
|
+
},
|
|
224
|
+
threshold: "payment error rate (non-decline gateway errors) > 3% over 15m",
|
|
225
|
+
severity: "page",
|
|
226
|
+
runbook: "checkout-down",
|
|
227
|
+
wiring: "needs-external",
|
|
228
|
+
notes:
|
|
229
|
+
"Owned by the checkout/payment provider, not this Worker. Separate genuine card declines (expected) from " +
|
|
230
|
+
"gateway/integration errors (alert). Coordinate the provider webhook wiring with F11/F17 owners.",
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "csp-violations",
|
|
234
|
+
title: "CSP violations",
|
|
235
|
+
category: "security",
|
|
236
|
+
signal: {
|
|
237
|
+
source: "POST /api/csp-report (CSP_REPORT_PATH / CSP_REPORT_GROUP) — logs '[csp-report]'",
|
|
238
|
+
observe: "log-based alert on '[csp-report]' volume; CSP is ENFORCED in prod (middleware sets Content-Security-Policy)",
|
|
239
|
+
},
|
|
240
|
+
threshold: "sustained new-violation stream (> baseline) — esp. on checkout/widget origins after a publish",
|
|
241
|
+
severity: "warning",
|
|
242
|
+
runbook: "bad-content-policy",
|
|
243
|
+
wiring: "in-code",
|
|
244
|
+
notes:
|
|
245
|
+
"Emitter already ships (pages/api/csp-report.ts). Only a log-volume alert rule needs attaching. A spike after " +
|
|
246
|
+
"a tenant publish means a script/style/frame origin needs adding to the allowlist — or a blocked injection.",
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: "core-web-vitals",
|
|
250
|
+
title: "Core Web Vitals (LCP / INP / CLS)",
|
|
251
|
+
category: "performance",
|
|
252
|
+
signal: {
|
|
253
|
+
source: "F14 RUM beacon (web-vitals: LCP, INP, CLS) — Real User Monitoring",
|
|
254
|
+
observe: "RUM sink p75 per metric per key template (home, PLP, PDP, checkout entry)",
|
|
255
|
+
},
|
|
256
|
+
threshold: "p75 regresses past 'good': LCP > 2.5s, INP > 200ms, CLS > 0.1 (sustained over 1h)",
|
|
257
|
+
severity: "warning",
|
|
258
|
+
runbook: "bad-content-policy",
|
|
259
|
+
wiring: "needs-external",
|
|
260
|
+
notes:
|
|
261
|
+
"Reference F14's RUM signal by name; do NOT edit F14's analytics files. Cloudflare Web Analytics " +
|
|
262
|
+
"(PUBLIC_CF_ANALYTICS_TOKEN, injected in Layout.astro) gives field CWV today; F14's beacon gives per-template detail.",
|
|
263
|
+
},
|
|
264
|
+
] as const;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The monitors §F16 requires by name. The test asserts MONITORS covers exactly
|
|
268
|
+
* these — so dropping or misnaming one fails CI, and adding a new monitor is a
|
|
269
|
+
* deliberate edit here.
|
|
270
|
+
*/
|
|
271
|
+
export const REQUIRED_MONITOR_IDS = [
|
|
272
|
+
"uptime",
|
|
273
|
+
"page-errors",
|
|
274
|
+
"checkout-failures",
|
|
275
|
+
"age-verification-failures",
|
|
276
|
+
"restricted-state-denials",
|
|
277
|
+
"catalog-sync-failures",
|
|
278
|
+
"inventory-mismatch",
|
|
279
|
+
"payment-failures",
|
|
280
|
+
"csp-violations",
|
|
281
|
+
"core-web-vitals",
|
|
282
|
+
] as const;
|
|
283
|
+
|
|
284
|
+
/** The six incident runbooks in RUNBOOK.md §7 that monitors dispatch to. */
|
|
285
|
+
export const RUNBOOK_IDS: readonly RunbookId[] = [
|
|
286
|
+
"checkout-down",
|
|
287
|
+
"catalog-stale",
|
|
288
|
+
"bad-price",
|
|
289
|
+
"compliance-provider-down",
|
|
290
|
+
"tax-shipping-outage",
|
|
291
|
+
"bad-content-policy",
|
|
292
|
+
] as const;
|
|
293
|
+
|
|
294
|
+
/** Lookup a monitor by id (undefined if absent). */
|
|
295
|
+
export function getMonitor(id: string): Monitor | undefined {
|
|
296
|
+
return MONITORS.find((m) => m.id === id);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Monitors still needing external wiring or a secret before they alert. */
|
|
300
|
+
export function monitorsNeedingWiring(): readonly Monitor[] {
|
|
301
|
+
return MONITORS.filter((m) => m.wiring !== "in-code");
|
|
302
|
+
}
|
|
@@ -6,7 +6,16 @@ function maskPart(part: string): string {
|
|
|
6
6
|
return `${part.slice(0, 1)}...${part.slice(-1)}`;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Display-only email hint for URLs/log-safe UX. Never returns the full address:
|
|
11
|
+
* the LOCAL part — the personal identifier — is always masked. The DOMAIN is shown
|
|
12
|
+
* in full for recognition value ("that's my work address"): a corporate domain is
|
|
13
|
+
* org-level, not personal data on its own, and the masked local part keeps this from
|
|
14
|
+
* being a clean, greppable address in a URL/log. Kept within the current GDPR posture
|
|
15
|
+
* (see the mask-level decision in auth/magic + the signed-in cockpit, which shows the
|
|
16
|
+
* FULL address from the validated session — never from a URL). Masks with `.` (not `*`)
|
|
17
|
+
* so the hint round-trips back through normalizeEmailHint's SAFE_HINT_RE.
|
|
18
|
+
*/
|
|
10
19
|
export function redactEmailForHint(email: string): string | null {
|
|
11
20
|
const raw = String(email || "").trim().toLowerCase();
|
|
12
21
|
const at = raw.indexOf("@");
|
|
@@ -15,10 +24,9 @@ export function redactEmailForHint(email: string): string | null {
|
|
|
15
24
|
const domain = raw.slice(at + 1);
|
|
16
25
|
const parts = domain.split(".").filter(Boolean);
|
|
17
26
|
if (parts.length < 2) return null;
|
|
18
|
-
const suffix = parts.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
return `${maskPart(local)}@${maskPart(registrable)}.${suffix}`;
|
|
27
|
+
const suffix = parts[parts.length - 1];
|
|
28
|
+
if (!suffix || !local) return null;
|
|
29
|
+
return `${maskPart(local)}@${domain}`;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
/**
|
|
@@ -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
|
+
}
|