@tokenoftrust/storefront-runner 1.3.4-rc.4 → 1.4.1
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/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +20 -0
- package/apps/storefront/package.json +12 -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 +3562 -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/activity/alerts.ts +428 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +127 -0
- package/apps/storefront/src/lib/activity/ingest.ts +188 -0
- package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
- package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
- package/apps/storefront/src/lib/activity/query.ts +403 -0
- package/apps/storefront/src/lib/activity/recordActivity.ts +105 -0
- package/apps/storefront/src/lib/activity/store.ts +122 -0
- package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
- package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
- 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 +219 -0
- package/apps/storefront/src/lib/auth/route.ts +60 -5
- 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 +267 -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/dev/vcBinding.ts +80 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +63 -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 +369 -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/middleware/index.ts +45 -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/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +183 -9
- package/apps/storefront/src/pages/api/activity.ts +137 -0
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +86 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +125 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +79 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +80 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +98 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +126 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +70 -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 +183 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +162 -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 +74 -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/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -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 +202 -45
- 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 +470 -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/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +501 -0
- package/packages/public-runtime/src/activity/event.ts +168 -0
- package/packages/public-runtime/src/activity/index.ts +16 -0
- package/packages/public-runtime/src/activity/redaction.ts +263 -0
- package/packages/public-runtime/src/candidate-index.ts +324 -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 +12 -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
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* Every page renders inside this.
|
|
6
6
|
*/
|
|
7
7
|
import "@/styles/global.css";
|
|
8
|
+
// Self-hosted web fonts (audit finding 6) — replaces the render-blocking
|
|
9
|
+
// fonts.googleapis.com stylesheet. Bundled by Vite, served same-origin.
|
|
10
|
+
import "@/styles/fonts.css";
|
|
8
11
|
import Seo from "@/components/Seo.astro";
|
|
9
12
|
import AnnouncementBar from "@/components/chrome/AnnouncementBar.astro";
|
|
10
13
|
import SiteHeader from "@/components/chrome/SiteHeader.astro";
|
|
@@ -17,10 +20,13 @@ import ShippingRestrictionNotice from "@/components/compliance/ShippingRestricti
|
|
|
17
20
|
import IsolatedAgeGate from "@/components/islands/IsolatedAgeGate.tsx";
|
|
18
21
|
import AuthBadge from "@/components/auth/AuthBadge.astro";
|
|
19
22
|
import { referenceTheme } from "@/themes/reference";
|
|
20
|
-
import { mergeTheme
|
|
23
|
+
import { mergeTheme } from "@/themes/schema";
|
|
21
24
|
import { organizationLd, websiteLd } from "@/lib/jsonld";
|
|
22
25
|
import { nonceInlineTags, CHROME_BODY_MARKER } from "@/lib/rawMarketingHtml";
|
|
23
26
|
import { readViewerSession } from "@/lib/auth/route";
|
|
27
|
+
import AppWidgetFrame from "@/components/apps/AppWidgetFrame.astro";
|
|
28
|
+
import { getRegistryService } from "@/lib/apps/adminService.js";
|
|
29
|
+
import { renderWidgetSlot } from "@/lib/apps/widgets/renderSlot.js";
|
|
24
30
|
|
|
25
31
|
interface Props {
|
|
26
32
|
title: string;
|
|
@@ -49,7 +55,6 @@ const allJsonLd = [
|
|
|
49
55
|
];
|
|
50
56
|
|
|
51
57
|
const theme = mergeTheme(referenceTheme, tenant.theme_tokens);
|
|
52
|
-
const fontHref = themeFontHref(theme);
|
|
53
58
|
|
|
54
59
|
const chrome = await Astro.locals.content.getChrome();
|
|
55
60
|
|
|
@@ -92,12 +97,22 @@ const compliance = tenant.compliance;
|
|
|
92
97
|
// open hosts where the gate never ran. Signed-out "Sign in" carries the current
|
|
93
98
|
// path as `next`, so after the email-code round-trip the visitor lands back
|
|
94
99
|
// where they were instead of the site root.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
//
|
|
101
|
+
// Marketing tenants (siteType:"marketing" → features.catalog === false) have no
|
|
102
|
+
// shopper/vendor account surface to link to: /auth/login + /dashboard are also
|
|
103
|
+
// excluded outright from the static-publish route set (routeClassifier's
|
|
104
|
+
// auth/+dashboard/ prefixes), so a marketing-only site can never resolve either
|
|
105
|
+
// URL. Skip the affordance rather than render a dangling link — mirrors the
|
|
106
|
+
// same commerce/marketing split index.astro already keys off `!features.catalog`.
|
|
107
|
+
let authLink: { label: string; href: string } | undefined;
|
|
108
|
+
if (features.catalog) {
|
|
109
|
+
const viewer = Astro.locals.viewer ?? (await readViewerSession(Astro));
|
|
110
|
+
const nextParam = encodeURIComponent(Astro.url.pathname + Astro.url.search);
|
|
111
|
+
authLink = {
|
|
112
|
+
label: viewer ? "Dashboard" : "Sign in",
|
|
113
|
+
href: viewer ? "/dashboard" : `/auth/login?next=${nextParam}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
101
116
|
|
|
102
117
|
// Per-vendor header treatment from the theme file: a "dark" header renders the
|
|
103
118
|
// bar in the primary color carrying the light logo (e.g. Giant Vapes' navy +
|
|
@@ -121,6 +136,23 @@ const legalLine = `© ${new Date().getFullYear()} ${tenant.displayName}${
|
|
|
121
136
|
const analyticsToken = import.meta.env.PUBLIC_CF_ANALYTICS_TOKEN;
|
|
122
137
|
const analyticsEnabled =
|
|
123
138
|
analyticsToken && !/REPLACE|PLACEHOLDER/i.test(analyticsToken);
|
|
139
|
+
|
|
140
|
+
// Private-app widgets (D5) — global.footer slot. Resolved once here so it
|
|
141
|
+
// renders for BOTH footer paths below (the tenant-owned chrome.html splice
|
|
142
|
+
// and the generic <Footer>) instead of being owned by either. No dark-mode
|
|
143
|
+
// preference infra exists yet — "light" is the only theme hint until it does.
|
|
144
|
+
const footerWidgets = await renderWidgetSlot(
|
|
145
|
+
{
|
|
146
|
+
registry: await getRegistryService(),
|
|
147
|
+
tenantId: tenant.tenant_id,
|
|
148
|
+
customizationEnv: Astro.locals.customizationEnv,
|
|
149
|
+
slot: "global.footer",
|
|
150
|
+
route: path,
|
|
151
|
+
locale: tenant.locale,
|
|
152
|
+
theme: "light",
|
|
153
|
+
},
|
|
154
|
+
Astro.locals.activeWidgetPlacements,
|
|
155
|
+
);
|
|
124
156
|
---
|
|
125
157
|
|
|
126
158
|
<!doctype html>
|
|
@@ -130,27 +162,20 @@ const analyticsEnabled =
|
|
|
130
162
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
131
163
|
<link rel="icon" type="image/svg+xml" href={theme.brand.favicon} />
|
|
132
164
|
|
|
133
|
-
{/* Per-tenant theme tokens injected at the edge (brief §7).
|
|
134
|
-
|
|
165
|
+
{/* Per-tenant theme tokens injected at the edge (brief §7). The id is the
|
|
166
|
+
dev-loop hot-patch target: tenant-hot-reload.mjs recomputes this same
|
|
167
|
+
`:root{...}` block on a theme.json save and the script below swaps its
|
|
168
|
+
textContent in place — no full reload. Inert in production (no listener
|
|
169
|
+
attaches there). */}
|
|
170
|
+
<style id="tot-theme-vars" set:html={themeStyle} nonce={cspNonce}></style>
|
|
135
171
|
|
|
136
172
|
{/* Shared chrome's own stylesheet(s) — the tenant theme (/tenants/...) plus
|
|
137
173
|
either the tenant's own mkt.css or the platform /shared/commerce-chrome.css
|
|
138
174
|
— so the ported chrome renders in-brand on Layout (commerce) pages too. */}
|
|
139
175
|
{sharedChrome?.css.map((href) => <link rel="stylesheet" href={href} />)}
|
|
140
176
|
|
|
141
|
-
{
|
|
142
|
-
|
|
143
|
-
<>
|
|
144
|
-
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
145
|
-
<link
|
|
146
|
-
rel="preconnect"
|
|
147
|
-
href="https://fonts.gstatic.com"
|
|
148
|
-
crossorigin
|
|
149
|
-
/>
|
|
150
|
-
<link rel="stylesheet" href={fontHref} />
|
|
151
|
-
</>
|
|
152
|
-
)
|
|
153
|
-
}
|
|
177
|
+
{/* Web fonts are self-hosted (audit finding 6) via `@/styles/fonts.css`,
|
|
178
|
+
imported above — no third-party font stylesheet / preconnects. */}
|
|
154
179
|
|
|
155
180
|
<Seo
|
|
156
181
|
title={title}
|
|
@@ -230,6 +255,20 @@ const analyticsEnabled =
|
|
|
230
255
|
</>
|
|
231
256
|
)}
|
|
232
257
|
|
|
258
|
+
{/* Private-app widgets (D5, global.footer) — sandboxed per install; one
|
|
259
|
+
resolution above covers both footer paths. */}
|
|
260
|
+
{footerWidgets.map((w) => (
|
|
261
|
+
<div class="container-prose py-6">
|
|
262
|
+
<AppWidgetFrame
|
|
263
|
+
endpoint={w.endpoint}
|
|
264
|
+
launchToken={w.launchToken}
|
|
265
|
+
appOrigin={w.appOrigin}
|
|
266
|
+
installId={w.installId}
|
|
267
|
+
title={w.title ?? "App widget"}
|
|
268
|
+
/>
|
|
269
|
+
</div>
|
|
270
|
+
))}
|
|
271
|
+
|
|
233
272
|
{capabilities.ageVerification.enabled && compliance?.minAge && (
|
|
234
273
|
<IsolatedAgeGate client:load minAge={compliance.minAge} brand={tenant.displayName} />
|
|
235
274
|
)}
|
|
@@ -342,6 +381,31 @@ const analyticsEnabled =
|
|
|
342
381
|
</script>
|
|
343
382
|
)}
|
|
344
383
|
|
|
384
|
+
{/* Dev-loop theme hot-patch (D1): tenant-hot-reload.mjs broadcasts a
|
|
385
|
+
`tot:theme-vars` HMR custom event on every theme.json save instead of
|
|
386
|
+
forcing a full reload; swap the CSS var block in place so edits repaint
|
|
387
|
+
instantly with no lost scroll position. import.meta.hot is undefined in
|
|
388
|
+
production builds, so this is a no-op there. */}
|
|
389
|
+
{import.meta.env.DEV && (
|
|
390
|
+
<script>
|
|
391
|
+
if (import.meta.hot) {
|
|
392
|
+
import.meta.hot.on("tot:theme-vars", (data: { css?: string }) => {
|
|
393
|
+
const el = document.getElementById("tot-theme-vars");
|
|
394
|
+
if (el && typeof data?.css === "string") el.textContent = data.css;
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
</script>
|
|
398
|
+
)}
|
|
399
|
+
|
|
400
|
+
{/* Private-app widget postMessage bridge (D5) — mounted unconditionally
|
|
401
|
+
(cheap no-op with zero tracked iframes); scans for
|
|
402
|
+
`[data-tot-widget-origin]` frames itself, so it doesn't need to know
|
|
403
|
+
which slots rendered above. */}
|
|
404
|
+
<script>
|
|
405
|
+
import { mountWidgetPostMessageBridge } from "@tot/public-runtime";
|
|
406
|
+
mountWidgetPostMessageBridge();
|
|
407
|
+
</script>
|
|
408
|
+
|
|
345
409
|
{/* Scroll-reveal + analytics pageview hook. ~0.5KB, no framework. */}
|
|
346
410
|
<script>
|
|
347
411
|
// Reveal-on-scroll (respects reduced motion via CSS).
|
|
@@ -366,5 +430,15 @@ const analyticsEnabled =
|
|
|
366
430
|
els.forEach((el) => el.classList.add("is-in"));
|
|
367
431
|
}
|
|
368
432
|
</script>
|
|
433
|
+
|
|
434
|
+
{/* Field Web Vitals (F14 RUM) — measures LCP/INP/CLS for real visitors and
|
|
435
|
+
beacons per-route samples to the RUM sink. ~1KB, no framework; no-ops
|
|
436
|
+
where PerformanceObserver is absent. This is the per-route-template
|
|
437
|
+
signal F16's `core-web-vitals` monitor consumes (the CF Web Analytics
|
|
438
|
+
beacon above only gives aggregate field CWV). */}
|
|
439
|
+
<script>
|
|
440
|
+
import { startWebVitals } from "@/lib/analytics/webVitals";
|
|
441
|
+
startWebVitals();
|
|
442
|
+
</script>
|
|
369
443
|
</body>
|
|
370
444
|
</html>
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operational-activity ALERT conditions (card D7) — the detection layer on top of
|
|
3
|
+
* the D5 query surface.
|
|
4
|
+
*
|
|
5
|
+
* D1–D5 make operational activity queryable; D7 turns four specific, high-value
|
|
6
|
+
* conditions in that stream into ALERTS. It deliberately follows the SAME shape as
|
|
7
|
+
* this repo's only other alert contract, F14's `analytics/rumAlert.ts`: a set of
|
|
8
|
+
* PURE, transport-agnostic detectors that take a window of activity and return a
|
|
9
|
+
* structured `ActivityAlert[]`. WHERE the alerts go (a log line, a future
|
|
10
|
+
* PagerDuty/Slack sink, a polled endpoint) is the sink's job — there is NO outbound
|
|
11
|
+
* notification transport in this repo today (rumAlert is likewise detection-only),
|
|
12
|
+
* so D7 does not invent one. It also does NOT add a cron: the checker is on-demand
|
|
13
|
+
* (computed when `/admin/ops-timeline` loads, or when the read-only alerts endpoint
|
|
14
|
+
* is polled), exactly as the card scopes it.
|
|
15
|
+
*
|
|
16
|
+
* THE FOUR CONDITIONS (and how each avoids false-positive noise):
|
|
17
|
+
*
|
|
18
|
+
* 1. silent-terminal — a `reconcile.terminal` with status `failed` (errorClass
|
|
19
|
+
* `reconcile_fault` = an unexpected crash → critical, or `reconcile_blocked` =
|
|
20
|
+
* a safety gate refused the candidate → warning). A single failure is worth an
|
|
21
|
+
* alert (a reviewer's PR silently didn't materialize), so there is no count
|
|
22
|
+
* threshold; noise is bounded by (a) status = `failed` only, never `succeeded`,
|
|
23
|
+
* and (b) collapsing repeat failures for one changeId into one alert.
|
|
24
|
+
*
|
|
25
|
+
* 2. recurring-preview-miss — a REPEATED `preview.serve`/`failed`/`miss_no_env`
|
|
26
|
+
* for the SAME changeId. One miss is an expected race (a reviewer opened the
|
|
27
|
+
* preview before its ReviewEnvironment finished materializing), so a single
|
|
28
|
+
* miss NEVER alerts; only `threshold`+ misses (default 3) for one changeId
|
|
29
|
+
* inside a short `window` (default 15m) do — that pattern means a LIVE PR whose
|
|
30
|
+
* preview never came up, not a transient.
|
|
31
|
+
*
|
|
32
|
+
* 3. reconcile-hang — a `webhook.reconcile.received` for a changeId with NO
|
|
33
|
+
* `reconcile.terminal` (any status) at-or-after it, once a `grace` period
|
|
34
|
+
* (default 10m) has elapsed. This is an ABSENCE detector (architecturally
|
|
35
|
+
* unlike 1/2, which fire on a bad event's PRESENCE): it correlates two action
|
|
36
|
+
* types by changeId + time. Noise is bounded by the grace window — a reconcile
|
|
37
|
+
* still legitimately in flight (received < grace ago) never fires; a terminal
|
|
38
|
+
* after the received (any status) clears it.
|
|
39
|
+
*
|
|
40
|
+
* 4. deploy-skew — promotes D5's visibility-only `deriveDeploySkew()` into an
|
|
41
|
+
* alert. `skewDetected` is true on EVERY normal rollout (two isolates coexist
|
|
42
|
+
* briefly), so alerting on it directly would page on every deploy. Instead this
|
|
43
|
+
* fires ONLY when the build serving this request differs from the newest
|
|
44
|
+
* observed build AND that newest build has been latest for longer than a `grace`
|
|
45
|
+
* period (default 15m) — i.e. THIS isolate failed to roll to a deploy that
|
|
46
|
+
* finished long ago (a persistent stale isolate), not a rollout in progress.
|
|
47
|
+
*
|
|
48
|
+
* DATA DEPENDENCY (honest wiring status, mirroring F16's `WiringStatus`). The
|
|
49
|
+
* detectors read the D1 `activity_events` timeline through the D5 `queryActivity`
|
|
50
|
+
* seam, so they alert on whatever is in that timeline. Getting SERVER-emitted
|
|
51
|
+
* lifecycle events INTO D1 is the D1/D4 fan-out wiring (today `recordActivity()` is
|
|
52
|
+
* logs-only; `deployVersion.ts` notes the swap to a timeline-fanning emitter is
|
|
53
|
+
* pending) — NOT D7's job. `preview.serve` stays `analytics`-sink by design (too
|
|
54
|
+
* high-volume to timeline wholesale); detector 2 is source-agnostic (the rumAlert
|
|
55
|
+
* precedent) and fires against an Analytics-Engine-sourced or seeded feed. See
|
|
56
|
+
* `ALERT_CONDITIONS` for the per-condition source + wiring note.
|
|
57
|
+
*/
|
|
58
|
+
import {
|
|
59
|
+
queryActivity,
|
|
60
|
+
deriveDeploySkew,
|
|
61
|
+
type ActivityTimelineRow,
|
|
62
|
+
type DeploySkewSummary,
|
|
63
|
+
} from "./query.js";
|
|
64
|
+
import type { Queryable } from "../d1/catalog.js";
|
|
65
|
+
|
|
66
|
+
/** Severity, mirroring F14's `rumAlert.AlertSeverity` / F16's `manifest.Severity`. */
|
|
67
|
+
export type AlertSeverity = "page" | "critical" | "warning" | "info";
|
|
68
|
+
|
|
69
|
+
/** The four D7 conditions. Stable ids — a downstream sink/dashboard keys on them. */
|
|
70
|
+
export type ActivityAlertType =
|
|
71
|
+
| "silent-terminal"
|
|
72
|
+
| "recurring-preview-miss"
|
|
73
|
+
| "reconcile-hang"
|
|
74
|
+
| "deploy-skew";
|
|
75
|
+
|
|
76
|
+
/** One active alert. `details` is low-cardinality + already-redacted-safe (it is
|
|
77
|
+
* built only from CORE fields — action/status/errorClass/changeId/at/version —
|
|
78
|
+
* never from the payload zone), so it is safe to log or serialize verbatim. */
|
|
79
|
+
export interface ActivityAlert {
|
|
80
|
+
type: ActivityAlertType;
|
|
81
|
+
severity: AlertSeverity;
|
|
82
|
+
/** Stable human title for the condition. */
|
|
83
|
+
title: string;
|
|
84
|
+
/** One-line human summary for a banner / log line / notification body. */
|
|
85
|
+
summary: string;
|
|
86
|
+
/** The change this alert concerns, when it is change-scoped. */
|
|
87
|
+
changeId?: string;
|
|
88
|
+
/** Structured, low-cardinality specifics (safe to serialize). */
|
|
89
|
+
details: Record<string, string | number>;
|
|
90
|
+
/** ISO-8601 instant the condition has been active SINCE (earliest evidence). */
|
|
91
|
+
since: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Thresholds (the launch defaults; every one is overridable per call) ──────
|
|
95
|
+
export const DEFAULTS = {
|
|
96
|
+
/** How far back the on-demand checker scans the timeline. */
|
|
97
|
+
lookbackMs: 24 * 60 * 60 * 1000,
|
|
98
|
+
/** recurring-preview-miss: misses of the SAME changeId before it alerts. */
|
|
99
|
+
previewMissThreshold: 3,
|
|
100
|
+
/** recurring-preview-miss: the window those misses must fall inside. */
|
|
101
|
+
previewMissWindowMs: 15 * 60 * 1000,
|
|
102
|
+
/** reconcile-hang: how long a `received` may sit un-terminated before alerting. */
|
|
103
|
+
reconcileHangGraceMs: 10 * 60 * 1000,
|
|
104
|
+
/** deploy-skew: how long the newest build must have been latest before a
|
|
105
|
+
* still-stale serving isolate is a persistent (not rollout-transient) skew. */
|
|
106
|
+
deploySkewGraceMs: 15 * 60 * 1000,
|
|
107
|
+
} as const;
|
|
108
|
+
|
|
109
|
+
const SEVERITY_RANK: Record<AlertSeverity, number> = { page: 0, critical: 1, warning: 2, info: 3 };
|
|
110
|
+
|
|
111
|
+
/** Deterministic ordering: most-severe first, then most-recently-active first. */
|
|
112
|
+
export function sortAlerts(alerts: ActivityAlert[]): ActivityAlert[] {
|
|
113
|
+
return [...alerts].sort(
|
|
114
|
+
(a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || (a.since < b.since ? 1 : a.since > b.since ? -1 : 0),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ─── Detector 1: silent-terminal ─────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Alert on `reconcile.terminal`/`failed` rows. Pass ONLY failed terminals (the
|
|
122
|
+
* orchestrator queries `status: "failed"`). Repeat failures for one changeId
|
|
123
|
+
* collapse into a single alert carrying the count, so a flapping PR does not spam.
|
|
124
|
+
*/
|
|
125
|
+
export function detectSilentTerminals(failedTerminals: readonly ActivityTimelineRow[]): ActivityAlert[] {
|
|
126
|
+
const byChange = new Map<string, ActivityTimelineRow[]>();
|
|
127
|
+
for (const r of failedTerminals) {
|
|
128
|
+
if (r.status !== "failed") continue; // defensive: only failures
|
|
129
|
+
const key = r.changeId ?? r.id;
|
|
130
|
+
(byChange.get(key) ?? byChange.set(key, []).get(key)!).push(r);
|
|
131
|
+
}
|
|
132
|
+
const out: ActivityAlert[] = [];
|
|
133
|
+
for (const [key, group] of byChange) {
|
|
134
|
+
const sorted = [...group].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
|
135
|
+
const latest = sorted[sorted.length - 1]!;
|
|
136
|
+
const errorClass = latest.errorClass ?? "unknown";
|
|
137
|
+
// reconcile_fault = an UNEXPECTED crash of the reconcile → critical; a
|
|
138
|
+
// reconcile_blocked = a safety gate correctly refused the candidate, an
|
|
139
|
+
// operator should look but nothing crashed → warning.
|
|
140
|
+
const severity: AlertSeverity = errorClass === "reconcile_fault" ? "critical" : "warning";
|
|
141
|
+
out.push({
|
|
142
|
+
type: "silent-terminal",
|
|
143
|
+
severity,
|
|
144
|
+
title: "Reconcile terminated in failure",
|
|
145
|
+
summary:
|
|
146
|
+
`Candidate ${latest.changeId ?? key} reconcile ${errorClass}` +
|
|
147
|
+
(group.length > 1 ? ` (${group.length}× in window)` : "") +
|
|
148
|
+
` — its preview did not materialize.`,
|
|
149
|
+
...(latest.changeId ? { changeId: latest.changeId } : {}),
|
|
150
|
+
details: { errorClass, failures: group.length, lastAt: latest.at },
|
|
151
|
+
since: sorted[0]!.at,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Detector 2: recurring preview.serve miss_no_env ─────────────────────────
|
|
158
|
+
|
|
159
|
+
export interface PreviewMissOptions {
|
|
160
|
+
/** "now" as epoch ms (injected for testing). */
|
|
161
|
+
now: number;
|
|
162
|
+
threshold?: number;
|
|
163
|
+
windowMs?: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Alert when the SAME changeId has `threshold`+ `preview.serve`/`failed`/
|
|
168
|
+
* `miss_no_env` events inside `windowMs`. Pass `preview.serve`/`failed` rows (the
|
|
169
|
+
* orchestrator queries them); this filters to `miss_no_env` and groups by changeId.
|
|
170
|
+
* A single miss (an expected pre-materialization race) is below threshold → silent.
|
|
171
|
+
*/
|
|
172
|
+
export function detectRecurringPreviewMisses(
|
|
173
|
+
serveFailures: readonly ActivityTimelineRow[],
|
|
174
|
+
opts: PreviewMissOptions,
|
|
175
|
+
): ActivityAlert[] {
|
|
176
|
+
const threshold = opts.threshold ?? DEFAULTS.previewMissThreshold;
|
|
177
|
+
const windowMs = opts.windowMs ?? DEFAULTS.previewMissWindowMs;
|
|
178
|
+
const floor = opts.now - windowMs;
|
|
179
|
+
|
|
180
|
+
const byChange = new Map<string, ActivityTimelineRow[]>();
|
|
181
|
+
for (const r of serveFailures) {
|
|
182
|
+
if (r.errorClass !== "miss_no_env" || !r.changeId) continue;
|
|
183
|
+
const t = Date.parse(r.at);
|
|
184
|
+
if (Number.isNaN(t) || t < floor || t > opts.now) continue; // only misses inside the window
|
|
185
|
+
(byChange.get(r.changeId) ?? byChange.set(r.changeId, []).get(r.changeId)!).push(r);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const out: ActivityAlert[] = [];
|
|
189
|
+
for (const [changeId, misses] of byChange) {
|
|
190
|
+
if (misses.length < threshold) continue; // below threshold ⇒ transient, not an alert
|
|
191
|
+
const sorted = [...misses].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
|
192
|
+
out.push({
|
|
193
|
+
type: "recurring-preview-miss",
|
|
194
|
+
severity: "warning",
|
|
195
|
+
title: "Preview repeatedly missing for a live change",
|
|
196
|
+
summary:
|
|
197
|
+
`Change ${changeId} has ${misses.length} preview.serve misses (miss_no_env) in ` +
|
|
198
|
+
`${Math.round(windowMs / 60000)}m — reviewers are hitting a preview that never came up.`,
|
|
199
|
+
changeId,
|
|
200
|
+
details: { misses: misses.length, windowMinutes: Math.round(windowMs / 60000), lastAt: sorted[sorted.length - 1]!.at },
|
|
201
|
+
since: sorted[0]!.at,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Detector 3: reconcile-hang watchdog (ABSENCE correlation) ───────────────
|
|
208
|
+
|
|
209
|
+
export interface ReconcileHangOptions {
|
|
210
|
+
/** "now" as epoch ms (injected for testing). */
|
|
211
|
+
now: number;
|
|
212
|
+
graceMs?: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Alert on a `webhook.reconcile.received` for a changeId that has NO
|
|
217
|
+
* `reconcile.terminal` at-or-after it, once `graceMs` has elapsed. Correlates the
|
|
218
|
+
* two action streams by changeId + time and reports the ABSENCE of a terminal.
|
|
219
|
+
*
|
|
220
|
+
* Per changeId we take the LATEST received (a PR pushed N times → only the newest
|
|
221
|
+
* cycle matters) and look for ANY terminal (succeeded/failed/blocked) with
|
|
222
|
+
* `at >= received.at`. None + past-grace ⇒ hang. A terminal after the received, OR
|
|
223
|
+
* a received still inside the grace window, clears it (the true-negative cases).
|
|
224
|
+
*/
|
|
225
|
+
export function detectReconcileHangs(
|
|
226
|
+
received: readonly ActivityTimelineRow[],
|
|
227
|
+
terminals: readonly ActivityTimelineRow[],
|
|
228
|
+
opts: ReconcileHangOptions,
|
|
229
|
+
): ActivityAlert[] {
|
|
230
|
+
const graceMs = opts.graceMs ?? DEFAULTS.reconcileHangGraceMs;
|
|
231
|
+
|
|
232
|
+
// Latest `received` per changeId.
|
|
233
|
+
const latestReceived = new Map<string, ActivityTimelineRow>();
|
|
234
|
+
for (const r of received) {
|
|
235
|
+
if (!r.changeId) continue;
|
|
236
|
+
const prev = latestReceived.get(r.changeId);
|
|
237
|
+
if (!prev || r.at > prev.at) latestReceived.set(r.changeId, r);
|
|
238
|
+
}
|
|
239
|
+
// Latest terminal `at` per changeId (any status).
|
|
240
|
+
const latestTerminalAt = new Map<string, string>();
|
|
241
|
+
for (const t of terminals) {
|
|
242
|
+
if (!t.changeId) continue;
|
|
243
|
+
const prev = latestTerminalAt.get(t.changeId);
|
|
244
|
+
if (!prev || t.at > prev) latestTerminalAt.set(t.changeId, t.at);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const out: ActivityAlert[] = [];
|
|
248
|
+
for (const [changeId, r] of latestReceived) {
|
|
249
|
+
const terminalAt = latestTerminalAt.get(changeId);
|
|
250
|
+
if (terminalAt && terminalAt >= r.at) continue; // terminated after this receive ⇒ not hung
|
|
251
|
+
const receivedMs = Date.parse(r.at);
|
|
252
|
+
if (Number.isNaN(receivedMs)) continue;
|
|
253
|
+
const elapsedMs = opts.now - receivedMs;
|
|
254
|
+
if (elapsedMs <= graceMs) continue; // still legitimately in flight ⇒ no alert
|
|
255
|
+
out.push({
|
|
256
|
+
type: "reconcile-hang",
|
|
257
|
+
severity: "critical",
|
|
258
|
+
title: "Reconcile received but never terminated",
|
|
259
|
+
summary:
|
|
260
|
+
`Change ${changeId} got a reconcile webhook ${Math.round(elapsedMs / 60000)}m ago with no ` +
|
|
261
|
+
`terminal state — the review loop is stuck.`,
|
|
262
|
+
changeId,
|
|
263
|
+
details: { receivedAt: r.at, minutesElapsed: Math.round(elapsedMs / 60000), graceMinutes: Math.round(graceMs / 60000) },
|
|
264
|
+
since: r.at,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ─── Detector 4: deploy-skew (persistent, not rollout-transient) ─────────────
|
|
271
|
+
|
|
272
|
+
export interface DeploySkewAlertOptions {
|
|
273
|
+
/** "now" as epoch ms (injected for testing). */
|
|
274
|
+
now: number;
|
|
275
|
+
graceMs?: number;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Promote D5's visibility-only `DeploySkewSummary` into an alert, WITHOUT firing on
|
|
280
|
+
* every normal rollout. Fires only when the build serving this request differs from
|
|
281
|
+
* the newest observed build AND that newest build has been latest for longer than
|
|
282
|
+
* `graceMs` — i.e. THIS isolate never rolled to a deploy that finished long ago.
|
|
283
|
+
* A fresh rollout (newest observed < grace old) is the expected transient → silent.
|
|
284
|
+
*/
|
|
285
|
+
export function detectDeploySkew(skew: DeploySkewSummary, opts: DeploySkewAlertOptions): ActivityAlert[] {
|
|
286
|
+
const graceMs = opts.graceMs ?? DEFAULTS.deploySkewGraceMs;
|
|
287
|
+
const { runningVersion, latestObservedVersion } = skew;
|
|
288
|
+
// Need to know BOTH the serving build and the newest observed build to judge skew.
|
|
289
|
+
if (!runningVersion || !latestObservedVersion) return [];
|
|
290
|
+
if (runningVersion === latestObservedVersion) return []; // this isolate is current ⇒ fine
|
|
291
|
+
const latest = skew.observedVersions[0];
|
|
292
|
+
if (!latest) return [];
|
|
293
|
+
const latestAgeMs = opts.now - Date.parse(latest.lastObservedAt);
|
|
294
|
+
if (Number.isNaN(latestAgeMs) || latestAgeMs <= graceMs) return []; // rollout still in its grace window
|
|
295
|
+
return [
|
|
296
|
+
{
|
|
297
|
+
type: "deploy-skew",
|
|
298
|
+
severity: "warning",
|
|
299
|
+
title: "Stale build still serving after rollout",
|
|
300
|
+
summary:
|
|
301
|
+
`This isolate serves build ${runningVersion} but ${latestObservedVersion} has been the ` +
|
|
302
|
+
`latest for ${Math.round(latestAgeMs / 60000)}m — a stale isolate never rolled forward.`,
|
|
303
|
+
details: {
|
|
304
|
+
runningVersion,
|
|
305
|
+
latestObservedVersion,
|
|
306
|
+
minutesStale: Math.round(latestAgeMs / 60000),
|
|
307
|
+
graceMinutes: Math.round(graceMs / 60000),
|
|
308
|
+
},
|
|
309
|
+
since: latest.lastObservedAt,
|
|
310
|
+
},
|
|
311
|
+
];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─── The on-demand orchestrator ──────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
export interface CheckAlertsOptions {
|
|
317
|
+
/** Injected clock (tests). Defaults to `new Date()`. */
|
|
318
|
+
now?: Date;
|
|
319
|
+
lookbackMs?: number;
|
|
320
|
+
previewMissThreshold?: number;
|
|
321
|
+
previewMissWindowMs?: number;
|
|
322
|
+
reconcileHangGraceMs?: number;
|
|
323
|
+
deploySkewGraceMs?: number;
|
|
324
|
+
/** Pin the serving build for deploy-skew (tests); real callers omit it. */
|
|
325
|
+
runningOverride?: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Run all four detectors for a tenant against the D1 timeline and return the active
|
|
330
|
+
* alerts, most-severe-first. Tenant-scoped through `queryActivity`/`deriveDeploySkew`
|
|
331
|
+
* (the same isolation invariant covers the whole check). On-demand: called when the
|
|
332
|
+
* ops-timeline page loads and by the read-only alerts endpoint — no cron.
|
|
333
|
+
*/
|
|
334
|
+
export async function checkActivityAlerts(
|
|
335
|
+
db: Queryable,
|
|
336
|
+
tenantId: string,
|
|
337
|
+
opts: CheckAlertsOptions = {},
|
|
338
|
+
): Promise<ActivityAlert[]> {
|
|
339
|
+
const now = opts.now ?? new Date();
|
|
340
|
+
const nowMs = now.getTime();
|
|
341
|
+
const lookbackMs = opts.lookbackMs ?? DEFAULTS.lookbackMs;
|
|
342
|
+
const since = new Date(nowMs - lookbackMs).toISOString();
|
|
343
|
+
|
|
344
|
+
const [failedTerminals, allTerminals, received, serveFailures, skew] = await Promise.all([
|
|
345
|
+
queryActivity(db, { tenantId, action: "reconcile.terminal", status: "failed", since, order: "desc" }),
|
|
346
|
+
queryActivity(db, { tenantId, action: "reconcile.terminal", since, order: "desc" }),
|
|
347
|
+
queryActivity(db, { tenantId, action: "webhook.reconcile.received", since, order: "desc" }),
|
|
348
|
+
queryActivity(db, { tenantId, action: "preview.serve", status: "failed", since, order: "desc" }),
|
|
349
|
+
deriveDeploySkew(db, tenantId, { since, ...(opts.runningOverride ? { runningOverride: opts.runningOverride } : {}) }),
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
return sortAlerts([
|
|
353
|
+
...detectSilentTerminals(failedTerminals),
|
|
354
|
+
...detectRecurringPreviewMisses(serveFailures, {
|
|
355
|
+
now: nowMs,
|
|
356
|
+
...(opts.previewMissThreshold != null ? { threshold: opts.previewMissThreshold } : {}),
|
|
357
|
+
...(opts.previewMissWindowMs != null ? { windowMs: opts.previewMissWindowMs } : {}),
|
|
358
|
+
}),
|
|
359
|
+
...detectReconcileHangs(received, allTerminals, {
|
|
360
|
+
now: nowMs,
|
|
361
|
+
...(opts.reconcileHangGraceMs != null ? { graceMs: opts.reconcileHangGraceMs } : {}),
|
|
362
|
+
}),
|
|
363
|
+
...detectDeploySkew(skew, {
|
|
364
|
+
now: nowMs,
|
|
365
|
+
...(opts.deploySkewGraceMs != null ? { graceMs: opts.deploySkewGraceMs } : {}),
|
|
366
|
+
}),
|
|
367
|
+
]);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** One-line alert summary for a log line / notification body (rumAlert parity). */
|
|
371
|
+
export function formatActivityAlert(a: ActivityAlert): string {
|
|
372
|
+
return `[${a.severity}] ${a.type}: ${a.summary} (since ${a.since})`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ─── Coherent surface descriptor (F16 `MONITORS` parity) ─────────────────────
|
|
376
|
+
|
|
377
|
+
/** Honest per-condition source + wiring note — the audit trail for "is this
|
|
378
|
+
* actually alerting?", mirroring F16's `WiringStatus`. */
|
|
379
|
+
export interface AlertCondition {
|
|
380
|
+
type: ActivityAlertType;
|
|
381
|
+
title: string;
|
|
382
|
+
defaultSeverity: AlertSeverity;
|
|
383
|
+
threshold: string;
|
|
384
|
+
/** Which timeline signal(s) it reads. */
|
|
385
|
+
source: string;
|
|
386
|
+
/** Honest wiring status. */
|
|
387
|
+
wiring: "in-code" | "needs-timeline-fanout" | "needs-external";
|
|
388
|
+
notes?: string;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export const ALERT_CONDITIONS: readonly AlertCondition[] = [
|
|
392
|
+
{
|
|
393
|
+
type: "silent-terminal",
|
|
394
|
+
title: "Reconcile terminated in failure",
|
|
395
|
+
defaultSeverity: "critical",
|
|
396
|
+
threshold: "any reconcile.terminal/failed (reconcile_fault ⇒ critical, reconcile_blocked ⇒ warning)",
|
|
397
|
+
source: "reconcile.terminal (timeline sink)",
|
|
398
|
+
wiring: "needs-timeline-fanout",
|
|
399
|
+
notes: "Emitted by candidate-reconcile.ts via recordActivity() (logs-only today); reaches D1 once the D1/D4 server→timeline fan-out lands.",
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
type: "recurring-preview-miss",
|
|
403
|
+
title: "Preview repeatedly missing for a live change",
|
|
404
|
+
defaultSeverity: "warning",
|
|
405
|
+
threshold: "≥3 preview.serve/failed/miss_no_env for one changeId within 15m",
|
|
406
|
+
source: "preview.serve (analytics sink — high volume)",
|
|
407
|
+
wiring: "needs-external",
|
|
408
|
+
notes: "preview.serve stays analytics-sink by design; detector is source-agnostic (rumAlert precedent) and fires against an Analytics-Engine-sourced or seeded feed.",
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
type: "reconcile-hang",
|
|
412
|
+
title: "Reconcile received but never terminated",
|
|
413
|
+
defaultSeverity: "critical",
|
|
414
|
+
threshold: "webhook.reconcile.received with no reconcile.terminal at-or-after it, past a 10m grace",
|
|
415
|
+
source: "webhook.reconcile.received + reconcile.terminal (both timeline sink)",
|
|
416
|
+
wiring: "needs-timeline-fanout",
|
|
417
|
+
notes: "D7 promoted webhook.reconcile.received to the timeline sink and emits it at reconcile start; reaches D1 once server→timeline fan-out lands.",
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
type: "deploy-skew",
|
|
421
|
+
title: "Stale build still serving after rollout",
|
|
422
|
+
defaultSeverity: "warning",
|
|
423
|
+
threshold: "serving build ≠ newest observed AND newest has been latest > 15m",
|
|
424
|
+
source: "deploy.version_observed (timeline sink) + this build's __GIT_SHA__",
|
|
425
|
+
wiring: "needs-timeline-fanout",
|
|
426
|
+
notes: "Promotes D5's deriveDeploySkew from visibility to an alert; grace period suppresses normal-rollout noise.",
|
|
427
|
+
},
|
|
428
|
+
] as const;
|