@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.
Files changed (225) hide show
  1. package/apps/storefront/astro.config.mjs +15 -0
  2. package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
  3. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
  4. package/apps/storefront/drizzle.config.apps.ts +13 -0
  5. package/apps/storefront/env.d.ts +10 -0
  6. package/apps/storefront/migrations/README.md +13 -7
  7. package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
  8. package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
  10. package/apps/storefront/package.json +13 -1
  11. package/apps/storefront/perf/README.md +64 -0
  12. package/apps/storefront/perf/assert-budgets.ts +159 -0
  13. package/apps/storefront/playwright.config.ts +23 -0
  14. package/apps/storefront/public/js/dashboard-apps.js +173 -0
  15. package/apps/storefront/public/shared/commerce-marketing.css +221 -0
  16. package/apps/storefront/src/components/CollectionCard.astro +1 -0
  17. package/apps/storefront/src/components/ProductCard.astro +1 -0
  18. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
  19. package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
  20. package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
  21. package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
  22. package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
  23. package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
  24. package/apps/storefront/src/components/content/Callout.astro +75 -0
  25. package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
  26. package/apps/storefront/src/components/content/ProseSections.astro +121 -0
  27. package/apps/storefront/src/components/content/ProseToc.astro +34 -0
  28. package/apps/storefront/src/components/content/RichText.astro +44 -0
  29. package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
  30. package/apps/storefront/src/components/home/Hero.astro +14 -0
  31. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
  32. package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
  33. package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
  34. package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
  35. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
  36. package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
  37. package/apps/storefront/src/config/storeName.ts +34 -0
  38. package/apps/storefront/src/layouts/Layout.astro +97 -23
  39. package/apps/storefront/src/lib/analytics/budgets.json +69 -0
  40. package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
  41. package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
  42. package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
  43. package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
  44. package/apps/storefront/src/lib/apps/adminService.ts +106 -0
  45. package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
  46. package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
  47. package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
  48. package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
  49. package/apps/storefront/src/lib/apps/credentials.ts +153 -0
  50. package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
  51. package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
  52. package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
  53. package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
  54. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
  55. package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
  56. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
  57. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
  58. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
  59. package/apps/storefront/src/lib/apps/registryService.ts +579 -0
  60. package/apps/storefront/src/lib/apps/scopes.ts +79 -0
  61. package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
  62. package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
  63. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
  64. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
  65. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
  66. package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
  67. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
  68. package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
  69. package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
  70. package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
  71. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
  72. package/apps/storefront/src/lib/auth/route.ts +16 -1
  73. package/apps/storefront/src/lib/auth/session.ts +8 -0
  74. package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
  75. package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
  76. package/apps/storefront/src/lib/blog/provider.ts +39 -0
  77. package/apps/storefront/src/lib/blog/types.ts +26 -0
  78. package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
  79. package/apps/storefront/src/lib/chrome/model.ts +11 -0
  80. package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
  81. package/apps/storefront/src/lib/content/callout.ts +78 -0
  82. package/apps/storefront/src/lib/content/index.ts +27 -0
  83. package/apps/storefront/src/lib/content/needsReview.ts +58 -0
  84. package/apps/storefront/src/lib/content/prose.ts +186 -0
  85. package/apps/storefront/src/lib/content/richtext.ts +76 -0
  86. package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
  87. package/apps/storefront/src/lib/content-edit/client.ts +70 -14
  88. package/apps/storefront/src/lib/d1/catalog.ts +12 -0
  89. package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
  90. package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
  91. package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
  92. package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
  93. package/apps/storefront/src/lib/dev/obstacleStore.ts +214 -0
  94. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  95. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  96. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  97. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  98. package/apps/storefront/src/lib/env.ts +12 -0
  99. package/apps/storefront/src/lib/jsonld.ts +12 -17
  100. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  101. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  102. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  103. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  104. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  105. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  106. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  107. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  108. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  109. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  110. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  111. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  112. package/apps/storefront/src/lib/tot/query.ts +32 -0
  113. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  116. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  117. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  118. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  119. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  120. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  121. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  122. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  123. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  124. package/apps/storefront/src/middleware/index.ts +28 -13
  125. package/apps/storefront/src/pages/404.astro +21 -9
  126. package/apps/storefront/src/pages/[...slug].astro +36 -2
  127. package/apps/storefront/src/pages/admin.astro +29 -3
  128. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  129. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  130. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  131. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  132. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  133. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  134. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  135. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  137. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  138. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  139. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  142. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  143. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  144. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  146. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  147. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  149. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  150. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  151. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  152. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  153. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  154. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  155. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  156. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  157. package/apps/storefront/src/pages/auth/login.astro +197 -41
  158. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  159. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  160. package/apps/storefront/src/pages/blog/index.astro +98 -0
  161. package/apps/storefront/src/pages/capabilities.astro +8 -0
  162. package/apps/storefront/src/pages/cockpit.astro +496 -74
  163. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  164. package/apps/storefront/src/pages/collections/index.astro +10 -2
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  166. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  167. package/apps/storefront/src/pages/index.astro +57 -0
  168. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  169. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  170. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  171. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  175. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  176. package/apps/storefront/src/styles/fonts.css +54 -0
  177. package/apps/storefront/src/styles/global.css +22 -2
  178. package/apps/storefront/src/themes/schema.ts +3 -25
  179. package/apps/storefront/tsconfig.json +1 -1
  180. package/apps/storefront/vitest.config.ts +4 -1
  181. package/package.json +1 -1
  182. package/packages/public-runtime/src/candidate-index.ts +311 -0
  183. package/packages/public-runtime/src/checkout.ts +94 -2
  184. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  185. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  186. package/packages/public-runtime/src/compliance/index.ts +22 -0
  187. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  188. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  189. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  190. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  191. package/packages/public-runtime/src/csp.ts +27 -3
  192. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  193. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  194. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  195. package/packages/public-runtime/src/extension-contract.ts +2 -1
  196. package/packages/public-runtime/src/hash.ts +25 -0
  197. package/packages/public-runtime/src/index.ts +6 -0
  198. package/packages/public-runtime/src/membership.ts +353 -0
  199. package/packages/public-runtime/src/product.ts +24 -2
  200. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  201. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  202. package/packages/public-runtime/src/tenant.ts +236 -0
  203. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  204. package/scripts/dev/publish.mjs +158 -0
  205. package/scripts/dev/transient-files.mjs +2 -1
  206. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  223. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  224. package/tenants/home/public/pages/storefront.css +23 -0
  225. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -0,0 +1,452 @@
1
+ /**
2
+ * F14 — Performance budgets (framework-enforceable, not prose).
3
+ *
4
+ * This module is the typed lens over the checked-in manifest (`budgets.json`,
5
+ * the source of truth). It provides three things the go-live framework needs:
6
+ *
7
+ * 1. `parsePerfBudgets` — a runtime validator so a malformed manifest fails
8
+ * loudly (in a test / at harness start) instead of silently under-enforcing.
9
+ * 2. `evaluateManifest` / `evaluateRoute` — the PURE budget-assertion logic the
10
+ * lab harness (`perf/assert-budgets.ts`) runs against a measurement report
11
+ * (Lighthouse or any collector that emits the `RouteMeasurement` shape).
12
+ * 3. `toLighthouseBudgets` — a derived, Lighthouse-native `budget.json` array so
13
+ * a Lighthouse/LHCI run can enforce the same numbers directly.
14
+ *
15
+ * Keeping the comparison pure (no fs / no network) is what makes the gate
16
+ * unit-testable here (perfBudgets.test.ts) without a live preview — the live
17
+ * Lighthouse run only has to PRODUCE the report; this decides pass/fail.
18
+ *
19
+ * `classifyPath` is shared with the field RUM collector (webVitals.ts) so a
20
+ * field sample and a lab measurement bucket into the SAME route id.
21
+ */
22
+ import manifestJson from "./budgets.json";
23
+
24
+ export type RouteId = "home" | "plp" | "pdp" | "search" | "account" | "checkout";
25
+ export const ROUTE_IDS: readonly RouteId[] = [
26
+ "home",
27
+ "plp",
28
+ "pdp",
29
+ "search",
30
+ "account",
31
+ "checkout",
32
+ ];
33
+
34
+ /** A path that matched no budgeted route — collected by RUM, never gated. */
35
+ export type RouteClass = RouteId | "other";
36
+
37
+ export interface WebVitalsBudget {
38
+ /** Largest Contentful Paint, p75, milliseconds. */
39
+ lcpMs: number;
40
+ /** Interaction to Next Paint, p75, milliseconds. */
41
+ inpMs: number;
42
+ /** Cumulative Layout Shift, p75, unitless. */
43
+ cls: number;
44
+ }
45
+
46
+ export interface ResourceBudget {
47
+ /** Script transfer budget, KiB compressed. */
48
+ jsKib: number;
49
+ /** Stylesheet transfer budget, KiB compressed. */
50
+ cssKib: number;
51
+ /** Image transfer budget, KiB compressed. */
52
+ imageKib: number;
53
+ /** Total page transfer budget, KiB compressed. */
54
+ totalKib: number;
55
+ }
56
+
57
+ export interface RouteBudgetDef {
58
+ id: RouteId;
59
+ label: string;
60
+ /** A representative path a lab run can navigate to. */
61
+ samplePath: string;
62
+ /**
63
+ * Path-prefix matchers used by `classifyPath`. The sentinel `"="` means the
64
+ * exact site root (`/`) only — a bare prefix would match every path.
65
+ */
66
+ match: readonly string[];
67
+ /** Optional per-route resource-budget overrides (merged over the defaults). */
68
+ resourceBudgets?: Partial<ResourceBudget>;
69
+ /** Optional per-route Web Vitals overrides (merged over the defaults). */
70
+ webVitals?: Partial<WebVitalsBudget>;
71
+ }
72
+
73
+ export interface PerfBudgetManifest {
74
+ version: number;
75
+ webVitals: WebVitalsBudget;
76
+ resourceBudgets: ResourceBudget;
77
+ thirdPartyScriptAllowlist: readonly string[];
78
+ routes: readonly RouteBudgetDef[];
79
+ }
80
+
81
+ /** The fully-resolved budget for one route (defaults + overrides applied). */
82
+ export interface ResolvedRouteBudget {
83
+ id: RouteId;
84
+ label: string;
85
+ samplePath: string;
86
+ webVitals: WebVitalsBudget;
87
+ resourceBudgets: ResourceBudget;
88
+ }
89
+
90
+ /**
91
+ * What a lab run (Lighthouse or an equivalent collector) reports for one route.
92
+ * All fields optional except `routeId` so a partial run (e.g. resource-only, no
93
+ * lab vitals) still evaluates the dimensions it did measure.
94
+ */
95
+ export interface RouteMeasurement {
96
+ routeId: RouteId;
97
+ url?: string;
98
+ resources?: Partial<{
99
+ jsKib: number;
100
+ cssKib: number;
101
+ imageKib: number;
102
+ totalKib: number;
103
+ }>;
104
+ webVitals?: Partial<WebVitalsBudget>;
105
+ /** Distinct third-party origins that loaded scripts on the page. */
106
+ thirdPartyOrigins?: readonly string[];
107
+ }
108
+
109
+ export interface BudgetViolation {
110
+ routeId: RouteId;
111
+ /** Which budget line failed. */
112
+ metric:
113
+ | "lcpMs"
114
+ | "inpMs"
115
+ | "cls"
116
+ | "jsKib"
117
+ | "cssKib"
118
+ | "imageKib"
119
+ | "totalKib"
120
+ | "thirdParty";
121
+ budget: number;
122
+ actual: number;
123
+ /** For `thirdParty`: the offending origin(s). */
124
+ detail?: string;
125
+ }
126
+
127
+ export interface RouteBudgetResult {
128
+ routeId: RouteId;
129
+ passed: boolean;
130
+ violations: BudgetViolation[];
131
+ }
132
+
133
+ export interface BudgetReport {
134
+ passed: boolean;
135
+ results: RouteBudgetResult[];
136
+ violations: BudgetViolation[];
137
+ /** Budgeted route ids that had no measurement in the report. */
138
+ missingRoutes: RouteId[];
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Validation
143
+ // ---------------------------------------------------------------------------
144
+
145
+ function isRecord(v: unknown): v is Record<string, unknown> {
146
+ return typeof v === "object" && v !== null;
147
+ }
148
+
149
+ function num(obj: Record<string, unknown>, key: string, where: string): number {
150
+ const v = obj[key];
151
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
152
+ throw new Error(`perf budgets: ${where}.${key} must be a non-negative number`);
153
+ }
154
+ return v;
155
+ }
156
+
157
+ function partialWebVitals(v: unknown, where: string): Partial<WebVitalsBudget> {
158
+ if (!isRecord(v)) throw new Error(`perf budgets: ${where}.webVitals must be an object`);
159
+ const out: Partial<WebVitalsBudget> = {};
160
+ for (const k of ["lcpMs", "inpMs", "cls"] as const) {
161
+ if (v[k] !== undefined) out[k] = num(v, k, `${where}.webVitals`);
162
+ }
163
+ return out;
164
+ }
165
+
166
+ function partialResources(v: unknown, where: string): Partial<ResourceBudget> {
167
+ if (!isRecord(v)) throw new Error(`perf budgets: ${where}.resourceBudgets must be an object`);
168
+ const out: Partial<ResourceBudget> = {};
169
+ for (const k of ["jsKib", "cssKib", "imageKib", "totalKib"] as const) {
170
+ if (v[k] !== undefined) out[k] = num(v, k, `${where}.resourceBudgets`);
171
+ }
172
+ return out;
173
+ }
174
+
175
+ /**
176
+ * Validate + narrow a raw manifest. Throws with a precise path on the first
177
+ * problem — so `budgets.json` cannot silently drift into a shape that
178
+ * under-enforces (a test calls this on the checked-in file).
179
+ */
180
+ export function parsePerfBudgets(raw: unknown): PerfBudgetManifest {
181
+ if (!isRecord(raw)) throw new Error("perf budgets: manifest must be an object");
182
+ const version = num(raw, "version", "manifest");
183
+
184
+ if (!isRecord(raw.webVitals)) throw new Error("perf budgets: manifest.webVitals missing");
185
+ const webVitals: WebVitalsBudget = {
186
+ lcpMs: num(raw.webVitals, "lcpMs", "webVitals"),
187
+ inpMs: num(raw.webVitals, "inpMs", "webVitals"),
188
+ cls: num(raw.webVitals, "cls", "webVitals"),
189
+ };
190
+
191
+ if (!isRecord(raw.resourceBudgets)) {
192
+ throw new Error("perf budgets: manifest.resourceBudgets missing");
193
+ }
194
+ const resourceBudgets: ResourceBudget = {
195
+ jsKib: num(raw.resourceBudgets, "jsKib", "resourceBudgets"),
196
+ cssKib: num(raw.resourceBudgets, "cssKib", "resourceBudgets"),
197
+ imageKib: num(raw.resourceBudgets, "imageKib", "resourceBudgets"),
198
+ totalKib: num(raw.resourceBudgets, "totalKib", "resourceBudgets"),
199
+ };
200
+
201
+ const allowlistRaw = raw.thirdPartyScriptAllowlist;
202
+ if (!Array.isArray(allowlistRaw) || !allowlistRaw.every((h) => typeof h === "string")) {
203
+ throw new Error("perf budgets: thirdPartyScriptAllowlist must be a string[]");
204
+ }
205
+ const thirdPartyScriptAllowlist = allowlistRaw.map((h) => h.toLowerCase());
206
+
207
+ if (!Array.isArray(raw.routes) || raw.routes.length === 0) {
208
+ throw new Error("perf budgets: manifest.routes must be a non-empty array");
209
+ }
210
+ const routes: RouteBudgetDef[] = raw.routes.map((r, i) => {
211
+ if (!isRecord(r)) throw new Error(`perf budgets: routes[${i}] must be an object`);
212
+ const id = r.id;
213
+ if (typeof id !== "string" || !(ROUTE_IDS as readonly string[]).includes(id)) {
214
+ throw new Error(`perf budgets: routes[${i}].id "${String(id)}" is not a known RouteId`);
215
+ }
216
+ if (typeof r.label !== "string" || typeof r.samplePath !== "string") {
217
+ throw new Error(`perf budgets: routes[${i}] needs string label + samplePath`);
218
+ }
219
+ if (!Array.isArray(r.match) || !r.match.every((m) => typeof m === "string") || r.match.length === 0) {
220
+ throw new Error(`perf budgets: routes[${i}].match must be a non-empty string[]`);
221
+ }
222
+ return {
223
+ id: id as RouteId,
224
+ label: r.label,
225
+ samplePath: r.samplePath,
226
+ match: r.match as string[],
227
+ ...(r.resourceBudgets !== undefined
228
+ ? { resourceBudgets: partialResources(r.resourceBudgets, `routes[${i}]`) }
229
+ : {}),
230
+ ...(r.webVitals !== undefined
231
+ ? { webVitals: partialWebVitals(r.webVitals, `routes[${i}]`) }
232
+ : {}),
233
+ };
234
+ });
235
+
236
+ const seen = new Set<string>();
237
+ for (const r of routes) {
238
+ if (seen.has(r.id)) throw new Error(`perf budgets: duplicate route id "${r.id}"`);
239
+ seen.add(r.id);
240
+ }
241
+
242
+ return { version, webVitals, resourceBudgets, thirdPartyScriptAllowlist, routes };
243
+ }
244
+
245
+ /** The validated, checked-in manifest. */
246
+ export const PERF_BUDGETS: PerfBudgetManifest = parsePerfBudgets(manifestJson);
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Route classification (shared lab + field)
250
+ // ---------------------------------------------------------------------------
251
+
252
+ /**
253
+ * Bucket a URL pathname into a budgeted route id (or "other"). Matching is by
254
+ * the manifest's `match` prefixes; the `"="` sentinel matches only the exact
255
+ * root. Order follows the manifest, but the root sentinel and longer prefixes
256
+ * are unambiguous so order rarely matters.
257
+ */
258
+ export function classifyPath(
259
+ pathname: string,
260
+ manifest: PerfBudgetManifest = PERF_BUDGETS,
261
+ ): RouteClass {
262
+ // Normalize: strip a trailing slash (except root) and lowercase.
263
+ let p = pathname.toLowerCase();
264
+ if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
265
+ for (const route of manifest.routes) {
266
+ for (const m of route.match) {
267
+ if (m === "=") {
268
+ if (p === "" || p === "/") return route.id;
269
+ } else if (p === m || p.startsWith(m.endsWith("/") ? m : `${m}/`) || p === m.replace(/\/$/, "")) {
270
+ return route.id;
271
+ }
272
+ }
273
+ }
274
+ return "other";
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Budget resolution + evaluation
279
+ // ---------------------------------------------------------------------------
280
+
281
+ /** Resolve a route's effective budget (manifest defaults + per-route overrides). */
282
+ export function resolveRouteBudget(
283
+ routeId: RouteId,
284
+ manifest: PerfBudgetManifest = PERF_BUDGETS,
285
+ ): ResolvedRouteBudget {
286
+ const def = manifest.routes.find((r) => r.id === routeId);
287
+ if (!def) throw new Error(`perf budgets: no route "${routeId}" in manifest`);
288
+ return {
289
+ id: def.id,
290
+ label: def.label,
291
+ samplePath: def.samplePath,
292
+ webVitals: { ...manifest.webVitals, ...def.webVitals },
293
+ resourceBudgets: { ...manifest.resourceBudgets, ...def.resourceBudgets },
294
+ };
295
+ }
296
+
297
+ const RESOURCE_METRICS: readonly (keyof ResourceBudget)[] = [
298
+ "jsKib",
299
+ "cssKib",
300
+ "imageKib",
301
+ "totalKib",
302
+ ];
303
+
304
+ /** Evaluate ONE route's measurement against its resolved budget. Pure. */
305
+ export function evaluateRoute(
306
+ measurement: RouteMeasurement,
307
+ manifest: PerfBudgetManifest = PERF_BUDGETS,
308
+ ): BudgetViolation[] {
309
+ const budget = resolveRouteBudget(measurement.routeId, manifest);
310
+ const violations: BudgetViolation[] = [];
311
+
312
+ const res = measurement.resources ?? {};
313
+ for (const metric of RESOURCE_METRICS) {
314
+ const actual = res[metric];
315
+ if (typeof actual === "number" && actual > budget.resourceBudgets[metric]) {
316
+ violations.push({
317
+ routeId: measurement.routeId,
318
+ metric,
319
+ budget: budget.resourceBudgets[metric],
320
+ actual,
321
+ });
322
+ }
323
+ }
324
+
325
+ const wv = measurement.webVitals ?? {};
326
+ for (const metric of ["lcpMs", "inpMs", "cls"] as const) {
327
+ const actual = wv[metric];
328
+ if (typeof actual === "number" && actual > budget.webVitals[metric]) {
329
+ violations.push({
330
+ routeId: measurement.routeId,
331
+ metric,
332
+ budget: budget.webVitals[metric],
333
+ actual,
334
+ });
335
+ }
336
+ }
337
+
338
+ if (measurement.thirdPartyOrigins?.length) {
339
+ const allow = new Set(manifest.thirdPartyScriptAllowlist);
340
+ const offenders = measurement.thirdPartyOrigins
341
+ .map((o) => o.toLowerCase())
342
+ .filter((o) => !allow.has(o));
343
+ if (offenders.length) {
344
+ violations.push({
345
+ routeId: measurement.routeId,
346
+ metric: "thirdParty",
347
+ budget: 0,
348
+ actual: offenders.length,
349
+ detail: offenders.join(", "),
350
+ });
351
+ }
352
+ }
353
+
354
+ return violations;
355
+ }
356
+
357
+ /**
358
+ * Evaluate a full report (measurements for some/all budgeted routes). `passed`
359
+ * is true only when there are ZERO violations. Missing routes are reported but
360
+ * do NOT fail the gate on their own — a partial lab run (e.g. account/checkout
361
+ * need auth) still enforces the routes it did measure. The harness decides
362
+ * whether missing routes are fatal for a given run (`--require-all`).
363
+ */
364
+ export function evaluateManifest(
365
+ measurements: readonly RouteMeasurement[],
366
+ manifest: PerfBudgetManifest = PERF_BUDGETS,
367
+ ): BudgetReport {
368
+ const byRoute = new Map<RouteId, RouteMeasurement>();
369
+ for (const m of measurements) byRoute.set(m.routeId, m);
370
+
371
+ const results: RouteBudgetResult[] = [];
372
+ const allViolations: BudgetViolation[] = [];
373
+ for (const m of measurements) {
374
+ const violations = evaluateRoute(m, manifest);
375
+ results.push({ routeId: m.routeId, passed: violations.length === 0, violations });
376
+ allViolations.push(...violations);
377
+ }
378
+
379
+ const missingRoutes = manifest.routes.map((r) => r.id).filter((id) => !byRoute.has(id));
380
+
381
+ return {
382
+ passed: allViolations.length === 0,
383
+ results,
384
+ violations: allViolations,
385
+ missingRoutes,
386
+ };
387
+ }
388
+
389
+ /** Human-readable summary for CLI / CI logs. */
390
+ export function formatBudgetReport(report: BudgetReport): string {
391
+ const lines: string[] = [];
392
+ lines.push(report.passed ? "✓ performance budgets: PASS" : "✗ performance budgets: FAIL");
393
+ for (const r of report.results) {
394
+ if (r.passed) {
395
+ lines.push(` ✓ ${r.routeId}`);
396
+ } else {
397
+ lines.push(` ✗ ${r.routeId}`);
398
+ for (const v of r.violations) {
399
+ const detail = v.detail ? ` (${v.detail})` : "";
400
+ lines.push(
401
+ ` ${v.metric}: ${v.actual} > budget ${v.budget}${detail}`,
402
+ );
403
+ }
404
+ }
405
+ }
406
+ if (report.missingRoutes.length) {
407
+ lines.push(` · no measurement for: ${report.missingRoutes.join(", ")}`);
408
+ }
409
+ return lines.join("\n");
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Lighthouse budgets exporter (documented lab seam)
414
+ // ---------------------------------------------------------------------------
415
+
416
+ /** One entry of a Lighthouse `budget.json` (the format `--budget-path` reads). */
417
+ export interface LighthouseBudgetEntry {
418
+ path: string;
419
+ timings: { metric: string; budget: number }[];
420
+ resourceSizes: { resourceType: string; budget: number }[];
421
+ }
422
+
423
+ /**
424
+ * Derive a Lighthouse-native budgets array from the manifest, one entry per
425
+ * budgeted route. Feed it to Lighthouse (`--budget-path`) / LHCI so a lab run
426
+ * enforces the SAME numbers this module does. Lighthouse resource budgets are
427
+ * in KiB and its timing budgets in ms — matching our manifest units.
428
+ *
429
+ * Note: Lighthouse's LCP/CLS assertions are LAB metrics; INP has no lab
430
+ * equivalent (it needs real interaction), so INP is enforced via field RUM
431
+ * (rumAlert.ts), not here. We emit `interactive` as the closest lab proxy.
432
+ */
433
+ export function toLighthouseBudgets(
434
+ manifest: PerfBudgetManifest = PERF_BUDGETS,
435
+ ): LighthouseBudgetEntry[] {
436
+ return manifest.routes.map((r) => {
437
+ const b = resolveRouteBudget(r.id, manifest);
438
+ return {
439
+ path: b.samplePath.split("?")[0] ?? "/",
440
+ timings: [
441
+ { metric: "largest-contentful-paint", budget: b.webVitals.lcpMs },
442
+ { metric: "cumulative-layout-shift", budget: b.webVitals.cls },
443
+ ],
444
+ resourceSizes: [
445
+ { resourceType: "script", budget: b.resourceBudgets.jsKib },
446
+ { resourceType: "stylesheet", budget: b.resourceBudgets.cssKib },
447
+ { resourceType: "image", budget: b.resourceBudgets.imageKib },
448
+ { resourceType: "total", budget: b.resourceBudgets.totalKib },
449
+ ],
450
+ };
451
+ });
452
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * F14 — RUM regression-alert contract.
3
+ *
4
+ * The field beacon (`webVitals.ts`) emits per-route CWV samples; this module is
5
+ * the PURE sink-side logic that turns a window of those samples into regression
6
+ * alerts. It is the code half of F16's `core-web-vitals` monitor: F16's manifest
7
+ * declares WHAT is watched (p75 per metric per key template, threshold "past
8
+ * 'good'", runbook), and names F14's beacon as the source; this decides, from
9
+ * the samples, WHETHER the threshold tripped.
10
+ *
11
+ * Deliberately pure + transport-agnostic: it takes an array of `WebVitalsSample`
12
+ * and returns `RegressionAlert[]`. WHERE the samples come from (a Workers
13
+ * Analytics Engine query, a KV rollup, a Logpush aggregation) and WHERE the
14
+ * alerts go (PagerDuty, Slack, a Logpush alert rule) are the sink's job — this
15
+ * is the contract both sides agree on, and the only part worth unit-testing.
16
+ *
17
+ * The p75 percentile + the 1-hour sustain window match F16's threshold text and
18
+ * Google/CrUX convention. The window itself (which hour of samples) is chosen by
19
+ * the caller; a low-traffic bucket is suppressed by `minSamples` so a single
20
+ * bad sample never pages anyone.
21
+ */
22
+ import {
23
+ PERF_BUDGETS,
24
+ type PerfBudgetManifest,
25
+ type RouteId,
26
+ } from "./perfBudgets.js";
27
+ import {
28
+ rateMetric,
29
+ type WebVitalName,
30
+ type WebVitalRating,
31
+ type WebVitalsSample,
32
+ } from "./webVitals.js";
33
+
34
+ /**
35
+ * The F16 monitor this contract feeds. Referenced by id, NOT imported from
36
+ * `monitoring/manifest.ts` — F14 owns the signal, F16 owns the monitor; keeping
37
+ * the coupling to a string means neither unit imports the other's files.
38
+ */
39
+ export const CWV_MONITOR_ID = "core-web-vitals";
40
+
41
+ /** Alert severity, mirroring F16's `Severity` (kept local to avoid the import). */
42
+ export type AlertSeverity = "page" | "critical" | "warning" | "info";
43
+
44
+ /** The p75 rollup for one route bucket over the caller's window. */
45
+ export interface RouteVitalsP75 {
46
+ route: RouteId;
47
+ /** Number of samples that fed this bucket (across all three metrics). */
48
+ sampleCount: number;
49
+ /** p75 per metric, or undefined when the window had no sample for it. */
50
+ p75: Partial<Record<WebVitalName, number>>;
51
+ }
52
+
53
+ /** One regression: a route's p75 for a metric crossed out of `good`. */
54
+ export interface RegressionAlert {
55
+ /** Always `core-web-vitals` — the F16 monitor an operator opens. */
56
+ monitorId: typeof CWV_MONITOR_ID;
57
+ route: RouteId;
58
+ metric: WebVitalName;
59
+ /** The observed p75 that tripped the alert. */
60
+ p75: number;
61
+ /** The `good` budget line it crossed. */
62
+ budget: number;
63
+ /** `needs-improvement` (warning) or `poor` (critical). */
64
+ rating: Exclude<WebVitalRating, "good">;
65
+ severity: AlertSeverity;
66
+ sampleCount: number;
67
+ }
68
+
69
+ export interface DetectOptions {
70
+ /**
71
+ * Minimum samples in a route bucket before it can alert — guards low-traffic
72
+ * routes from paging on noise. Default 50 (a stable-ish p75).
73
+ */
74
+ minSamples?: number;
75
+ }
76
+
77
+ /**
78
+ * Exact-rank percentile (nearest-rank, p in [0,1]) over a numeric series.
79
+ * Small windows are fine — this is O(n log n) and the series is one route-hour.
80
+ */
81
+ export function percentile(values: readonly number[], p: number): number | undefined {
82
+ if (values.length === 0) return undefined;
83
+ const sorted = [...values].sort((a, b) => a - b);
84
+ const rank = Math.ceil(p * sorted.length);
85
+ const idx = Math.min(Math.max(rank, 1), sorted.length) - 1;
86
+ return sorted[idx];
87
+ }
88
+
89
+ const METRICS: readonly WebVitalName[] = ["lcp", "inp", "cls"];
90
+
91
+ /**
92
+ * Roll a flat sample list up into per-route p75s. Samples whose route is
93
+ * `"other"` (unbudgeted) are dropped — we only alert on budgeted templates.
94
+ */
95
+ export function aggregateP75(samples: readonly WebVitalsSample[]): RouteVitalsP75[] {
96
+ const byRoute = new Map<RouteId, Map<WebVitalName, number[]>>();
97
+ for (const s of samples) {
98
+ if (s.route === "other") continue;
99
+ let metrics = byRoute.get(s.route);
100
+ if (!metrics) {
101
+ metrics = new Map();
102
+ byRoute.set(s.route, metrics);
103
+ }
104
+ const series = metrics.get(s.metric) ?? [];
105
+ series.push(s.value);
106
+ metrics.set(s.metric, series);
107
+ }
108
+
109
+ const out: RouteVitalsP75[] = [];
110
+ for (const [route, metrics] of byRoute) {
111
+ const p75: Partial<Record<WebVitalName, number>> = {};
112
+ let sampleCount = 0;
113
+ for (const metric of METRICS) {
114
+ const series = metrics.get(metric);
115
+ if (!series?.length) continue;
116
+ sampleCount += series.length;
117
+ const v = percentile(series, 0.75);
118
+ if (v !== undefined) p75[metric] = v;
119
+ }
120
+ out.push({ route, sampleCount, p75 });
121
+ }
122
+ return out;
123
+ }
124
+
125
+ function severityFor(rating: Exclude<WebVitalRating, "good">): AlertSeverity {
126
+ // A `poor` p75 is customer-visible degradation → same-hours critical; a
127
+ // `needs-improvement` p75 is the leading-indicator warning F16 describes.
128
+ return rating === "poor" ? "critical" : "warning";
129
+ }
130
+
131
+ /**
132
+ * The contract: given a window of field samples, return the regressions. A
133
+ * regression is a budgeted route whose p75 for some metric rates worse than
134
+ * `good`, in a bucket with enough samples to trust. Zero regressions ⇒ the
135
+ * `core-web-vitals` monitor stays green.
136
+ */
137
+ export function detectRegressions(
138
+ samples: readonly WebVitalsSample[],
139
+ manifest: PerfBudgetManifest = PERF_BUDGETS,
140
+ opts: DetectOptions = {},
141
+ ): RegressionAlert[] {
142
+ const minSamples = opts.minSamples ?? 50;
143
+ const budgets: Record<WebVitalName, number> = {
144
+ lcp: manifest.webVitals.lcpMs,
145
+ inp: manifest.webVitals.inpMs,
146
+ cls: manifest.webVitals.cls,
147
+ };
148
+
149
+ const alerts: RegressionAlert[] = [];
150
+ for (const bucket of aggregateP75(samples)) {
151
+ if (bucket.sampleCount < minSamples) continue;
152
+ for (const metric of METRICS) {
153
+ const p75 = bucket.p75[metric];
154
+ if (p75 === undefined) continue;
155
+ const rating = rateMetric(metric, p75, manifest);
156
+ if (rating === "good") continue;
157
+ alerts.push({
158
+ monitorId: CWV_MONITOR_ID,
159
+ route: bucket.route,
160
+ metric,
161
+ p75,
162
+ budget: budgets[metric],
163
+ rating,
164
+ severity: severityFor(rating),
165
+ sampleCount: bucket.sampleCount,
166
+ });
167
+ }
168
+ }
169
+ return alerts;
170
+ }
171
+
172
+ /** One-line alert summary for a log line / notification body. */
173
+ export function formatRegressionAlert(a: RegressionAlert): string {
174
+ const unit = a.metric === "cls" ? "" : "ms";
175
+ return (
176
+ `[${a.severity}] ${a.monitorId}: ${a.route} ${a.metric.toUpperCase()} ` +
177
+ `p75 ${a.p75}${unit} > budget ${a.budget}${unit} (${a.rating}, n=${a.sampleCount})`
178
+ );
179
+ }