@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.
Files changed (246) 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/0001_woozy_lyja.sql +22 -0
  9. package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
  10. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  11. package/apps/storefront/migrations-apps/meta/_journal.json +20 -0
  12. package/apps/storefront/package.json +12 -1
  13. package/apps/storefront/perf/README.md +64 -0
  14. package/apps/storefront/perf/assert-budgets.ts +159 -0
  15. package/apps/storefront/playwright.config.ts +23 -0
  16. package/apps/storefront/public/js/dashboard-apps.js +173 -0
  17. package/apps/storefront/public/shared/commerce-marketing.css +221 -0
  18. package/apps/storefront/src/components/CollectionCard.astro +1 -0
  19. package/apps/storefront/src/components/ProductCard.astro +1 -0
  20. package/apps/storefront/src/components/admin/AdminPublishTab.astro +3562 -0
  21. package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
  22. package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
  23. package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
  24. package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
  25. package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
  26. package/apps/storefront/src/components/content/Callout.astro +75 -0
  27. package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
  28. package/apps/storefront/src/components/content/ProseSections.astro +121 -0
  29. package/apps/storefront/src/components/content/ProseToc.astro +34 -0
  30. package/apps/storefront/src/components/content/RichText.astro +44 -0
  31. package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
  32. package/apps/storefront/src/components/home/Hero.astro +14 -0
  33. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
  34. package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
  35. package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
  36. package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
  37. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
  38. package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
  39. package/apps/storefront/src/config/storeName.ts +34 -0
  40. package/apps/storefront/src/layouts/Layout.astro +97 -23
  41. package/apps/storefront/src/lib/activity/alerts.ts +428 -0
  42. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  43. package/apps/storefront/src/lib/activity/deployVersion.ts +127 -0
  44. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  45. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  46. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  47. package/apps/storefront/src/lib/activity/query.ts +403 -0
  48. package/apps/storefront/src/lib/activity/recordActivity.ts +105 -0
  49. package/apps/storefront/src/lib/activity/store.ts +122 -0
  50. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  51. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  52. package/apps/storefront/src/lib/analytics/budgets.json +69 -0
  53. package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
  54. package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
  55. package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
  56. package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
  57. package/apps/storefront/src/lib/apps/adminService.ts +106 -0
  58. package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
  59. package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
  60. package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
  61. package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
  62. package/apps/storefront/src/lib/apps/credentials.ts +153 -0
  63. package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
  64. package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
  65. package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
  66. package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
  67. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
  68. package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
  69. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
  70. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
  71. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
  72. package/apps/storefront/src/lib/apps/registryService.ts +579 -0
  73. package/apps/storefront/src/lib/apps/scopes.ts +79 -0
  74. package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
  75. package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
  76. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
  77. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
  78. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
  79. package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
  80. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
  81. package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
  82. package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
  83. package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
  84. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +219 -0
  85. package/apps/storefront/src/lib/auth/route.ts +60 -5
  86. package/apps/storefront/src/lib/auth/session.ts +8 -0
  87. package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
  88. package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
  89. package/apps/storefront/src/lib/blog/provider.ts +39 -0
  90. package/apps/storefront/src/lib/blog/types.ts +26 -0
  91. package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
  92. package/apps/storefront/src/lib/chrome/model.ts +11 -0
  93. package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
  94. package/apps/storefront/src/lib/content/callout.ts +78 -0
  95. package/apps/storefront/src/lib/content/index.ts +27 -0
  96. package/apps/storefront/src/lib/content/needsReview.ts +58 -0
  97. package/apps/storefront/src/lib/content/prose.ts +186 -0
  98. package/apps/storefront/src/lib/content/richtext.ts +76 -0
  99. package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
  100. package/apps/storefront/src/lib/content-edit/client.ts +70 -14
  101. package/apps/storefront/src/lib/d1/catalog.ts +12 -0
  102. package/apps/storefront/src/lib/d1/schema-apps.ts +267 -0
  103. package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
  104. package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
  105. package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
  106. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  107. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  108. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  109. package/apps/storefront/src/lib/dev/vcBinding.ts +80 -0
  110. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  111. package/apps/storefront/src/lib/env.ts +63 -0
  112. package/apps/storefront/src/lib/jsonld.ts +12 -17
  113. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  114. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  115. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  116. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  117. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  118. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  119. package/apps/storefront/src/lib/publish/shipWorkspace.ts +369 -0
  120. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  121. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  122. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  123. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  124. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  125. package/apps/storefront/src/lib/tot/query.ts +32 -0
  126. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  127. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  128. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  129. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  130. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  131. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  132. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  133. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  134. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  135. package/apps/storefront/src/middleware/index.ts +45 -13
  136. package/apps/storefront/src/pages/404.astro +21 -9
  137. package/apps/storefront/src/pages/[...slug].astro +36 -2
  138. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  139. package/apps/storefront/src/pages/admin.astro +183 -9
  140. package/apps/storefront/src/pages/api/activity.ts +137 -0
  141. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  142. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +86 -0
  143. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  144. package/apps/storefront/src/pages/api/apps/admin/install.ts +125 -0
  145. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  146. package/apps/storefront/src/pages/api/apps/admin/resume.ts +79 -0
  147. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +80 -0
  148. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +98 -0
  149. package/apps/storefront/src/pages/api/apps/admin/update.ts +126 -0
  150. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +70 -0
  151. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  152. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +183 -0
  153. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  154. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +162 -0
  155. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  156. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  157. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  158. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  159. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  160. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  161. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  162. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +74 -0
  163. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  164. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  165. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  166. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  167. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  168. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  169. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  170. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  171. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  172. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  173. package/apps/storefront/src/pages/auth/login.astro +202 -45
  174. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  175. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  176. package/apps/storefront/src/pages/blog/index.astro +98 -0
  177. package/apps/storefront/src/pages/capabilities.astro +8 -0
  178. package/apps/storefront/src/pages/cockpit.astro +470 -69
  179. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  180. package/apps/storefront/src/pages/collections/index.astro +10 -2
  181. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  182. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  183. package/apps/storefront/src/pages/index.astro +57 -0
  184. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  185. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  186. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  187. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  188. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  189. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  190. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  191. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  192. package/apps/storefront/src/styles/fonts.css +54 -0
  193. package/apps/storefront/src/styles/global.css +22 -2
  194. package/apps/storefront/src/themes/schema.ts +3 -25
  195. package/apps/storefront/tsconfig.json +1 -1
  196. package/apps/storefront/vitest.config.ts +4 -1
  197. package/package.json +1 -1
  198. package/packages/public-runtime/src/activity/README.md +146 -0
  199. package/packages/public-runtime/src/activity/catalog.ts +501 -0
  200. package/packages/public-runtime/src/activity/event.ts +168 -0
  201. package/packages/public-runtime/src/activity/index.ts +16 -0
  202. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  203. package/packages/public-runtime/src/candidate-index.ts +324 -0
  204. package/packages/public-runtime/src/checkout.ts +94 -2
  205. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  206. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  207. package/packages/public-runtime/src/compliance/index.ts +22 -0
  208. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  209. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  210. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  211. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  212. package/packages/public-runtime/src/csp.ts +27 -3
  213. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  214. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  215. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  216. package/packages/public-runtime/src/extension-contract.ts +2 -1
  217. package/packages/public-runtime/src/hash.ts +25 -0
  218. package/packages/public-runtime/src/index.ts +12 -0
  219. package/packages/public-runtime/src/membership.ts +353 -0
  220. package/packages/public-runtime/src/product.ts +24 -2
  221. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  222. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  223. package/packages/public-runtime/src/tenant.ts +236 -0
  224. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  225. package/scripts/dev/publish.mjs +158 -0
  226. package/scripts/dev/transient-files.mjs +2 -1
  227. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  228. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  229. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  230. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  231. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  232. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  233. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  234. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  235. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  236. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  237. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  238. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  239. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  240. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  241. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  242. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  243. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  244. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  245. package/tenants/home/public/pages/storefront.css +23 -0
  246. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -30,6 +30,7 @@ import {
30
30
  buildCsp,
31
31
  STOREFRONT_CSP_ALLOWLIST,
32
32
  checkoutCspAdditions,
33
+ appWidgetCspAdditions,
33
34
  CSP_REPORT_GROUP,
34
35
  CSP_REPORT_PATH,
35
36
  readPublishedArtifact,
@@ -46,13 +47,15 @@ import {
46
47
  parseGateMode,
47
48
  isAuthPath,
48
49
  sessionAdmitsHost,
50
+ sessionHostCapability,
49
51
  PUBLIC_HOME_TENANT_ID,
50
52
  } from "@/lib/auth/loginGate";
51
53
  import { gatedHoldingResponse } from "@/lib/auth/gatePage";
52
54
  import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
53
55
  import { isMaintenanceOn, maintenanceResponse } from "@/lib/maintenance";
54
56
  import { HEALTH_PATH, healthResponse } from "@/lib/health";
55
- import { membershipFor } from "@/lib/dashboard/tenantSelection";
57
+ import { observeDeployVersion } from "@/lib/activity/deployVersion";
58
+ import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
56
59
  import type { DeepPartial, ThemeTokens } from "@tot/public-runtime";
57
60
 
58
61
  export const onRequest = defineMiddleware(async (context, next) => {
@@ -61,6 +64,21 @@ export const onRequest = defineMiddleware(async (context, next) => {
61
64
  const host =
62
65
  request.headers.get("host") ?? url.host ?? "localhost";
63
66
 
67
+ // ---- Activity telemetry kill switch (card D8) -----------------------------
68
+ // Prime the ONE `ACTIVITY_TELEMETRY_DISABLED` flag from the (async) Worker env
69
+ // once per isolate, BEFORE the first emit, so every telemetry chokepoint in this
70
+ // isolate (deploy-provenance, recordActivity, ingest) observes it. Fail-open,
71
+ // never throws. Off by default (unset โ‡’ telemetry on).
72
+ await primeActivityKillSwitch();
73
+
74
+ // ---- Deploy provenance (card D2) โ€” worker_commit / deploy.version_observed -
75
+ // Once per worker isolate (cold start), emit `deploy.version_observed` carrying
76
+ // this build's commit SHA so D5 can derive deploy skew (running build vs umbrella
77
+ // tip). Idempotent per isolate, best-effort, never throws โ€” so it's safe here at
78
+ // the very front door, ahead of /health, and fires regardless of Host/tenant/gate.
79
+ // A no-op when the D8 kill switch above is set.
80
+ observeDeployVersion();
81
+
64
82
  // ---- /health โ€” liveness + deploy-identity probe ---------------------------
65
83
  // Answered FIRST: before tenant resolution, the access gate, the maintenance
66
84
  // kill-switch, and the edge cache โ€” so it reports the running build's commit SHA
@@ -160,17 +178,18 @@ export const onRequest = defineMiddleware(async (context, next) => {
160
178
  return gatedHoldingResponse(decision.location);
161
179
  }
162
180
  if (admitted && viewerRecord) {
163
- // Surface the viewer's role FOR THIS host's tenant as chrome. A member sees
164
- // their tenants[] role; a ToT-staff viewer admitted via an explicit vendor
165
- // selection sees the staffRoles-scoped capability (distinct path).
166
- const role = membershipFor(viewerRecord.tenants, tenant.appDomain, {
167
- selection: viewerRecord.staffSelection,
168
- nowSeconds,
169
- })?.role;
181
+ // Surface the viewer's standing FOR THIS host's tenant as chrome, BOUND to
182
+ // the host so a capability the session holds for another tenant never leaks
183
+ // in: a member sees their tenants[] role; the owner of their own tenant sees
184
+ // the resource-scoped session's capability; a ToT-staff viewer admitted via
185
+ // an explicit vendor selection sees the staffRoles-scoped capability. The
186
+ // ship gates (u10 decideIsOwner/resolveShipPrincipal) read this capability,
187
+ // so binding it here keeps owner-resolution server-side + tenant-correct.
188
+ const hostCapability = sessionHostCapability(viewerRecord, tenant.appDomain, nowSeconds);
170
189
  locals.viewer = {
171
190
  email: viewerRecord.email,
172
- roles: role ? [role] : viewerRecord.roles,
173
- capability: role ?? viewerRecord.capability,
191
+ roles: hostCapability ? [hostCapability] : viewerRecord.roles,
192
+ capability: hostCapability ?? viewerRecord.capability,
174
193
  };
175
194
  }
176
195
  }
@@ -210,8 +229,14 @@ export const onRequest = defineMiddleware(async (context, next) => {
210
229
  if (hit) return hit;
211
230
  }
212
231
 
213
- // Edge read replica: opt-in via USE_D1_CATALOG=1 (+ a CATALOG_DB binding). Off
214
- // by default, so the bundled fixture snapshot serves reads.
232
+ // Catalog source selection (F17 graduation path โ€” docs/architecture/product-catalog.md).
233
+ // A store's product source graduates over its life: JSON fixtures (dev default) ->
234
+ // D1 edge replica (staging) -> hosted ToT catalog (go-live). Here we pick the read
235
+ // backend: USE_D1_CATALOG=1 (+ a CATALOG_DB binding) reads the D1 edge replica;
236
+ // otherwise createToTClient falls back to the bundled JSON fixture snapshot. The
237
+ // hosted catalog graduates the SAME canonical records into the D1 read model
238
+ // (emitCatalogSql), so JSON/D1/hosted render identically โ€” pinned by
239
+ // lib/tot/sourceSelection.test.ts.
215
240
  const d1 =
216
241
  (await readEnv("USE_D1_CATALOG")) === "1" ? await readD1("CATALOG_DB") : undefined;
217
242
  const tot = await createToTClient(tenant, d1);
@@ -360,6 +385,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
360
385
  locals.canonicalBase = canonicalBase;
361
386
  locals.customizationEnv = customization.env;
362
387
  locals.cspNonce = cspNonce;
388
+ // Seeded here, filled in by whichever page/layout renders a widget slot
389
+ // (renderSlot.ts) as the page renders inside next() below โ€” read back out
390
+ // after next() resolves so the CSP frame-src reflects exactly what rendered.
391
+ locals.activeWidgetPlacements = [];
363
392
  // Prefix that path-routed pages live under ("" for host-routed). Pages stay
364
393
  // tenant-agnostic; the Layout uses this to keep root-absolute links inside
365
394
  // the tenant prefix on the client.
@@ -427,6 +456,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
427
456
  // strict policy only when this tenant has a store โ€” so add-to-cart, the
428
457
  // sidecart iframe/XHR, and the cross-origin form POST all pass.
429
458
  const checkoutCsp = commerce ? checkoutCspAdditions(commerce.origin) : null;
459
+ // Private-app widget origins (D5) โ€” read back AFTER next() so this reflects
460
+ // exactly the placements that rendered on THIS page, never a wildcard.
461
+ const widgetCsp = appWidgetCspAdditions(locals.activeWidgetPlacements ?? []);
430
462
  response.headers.set(
431
463
  cspHeader,
432
464
  buildCsp({
@@ -450,7 +482,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
450
482
  ...(checkoutCsp?.connectSrc ?? []),
451
483
  ],
452
484
  imgSrc: [...STOREFRONT_CSP_ALLOWLIST.imgSrc, ...(checkoutCsp?.imgSrc ?? [])],
453
- frameSrc: checkoutCsp?.frameSrc ?? [],
485
+ frameSrc: [...(checkoutCsp?.frameSrc ?? []), ...widgetCsp.frameSrc],
454
486
  formAction: checkoutCsp?.formAction ?? [],
455
487
  reportGroup: CSP_REPORT_GROUP,
456
488
  reportUri: CSP_REPORT_PATH,
@@ -2,15 +2,25 @@
2
2
  /**
3
3
  * 404 โ€” written in the Northwind voice, not a stub. Offers a way home and a
4
4
  * few collections to wander into so a dead link still lands somewhere useful.
5
+ *
6
+ * DELIBERATELY on-demand โ€” do NOT prerender (audit finding "selective-prerender").
7
+ * Fully tenant-varying: it reads Astro.locals.tenant + tot.listCollections() and
8
+ * renders under locals.themeStyle, all set per-request by the middleware from the
9
+ * resolved Host. Prerendering would freeze a single build-time tenant AND strip the
10
+ * middleware-applied CSP + security headers (no static _headers fallback exists).
5
11
  */
12
+ export const prerender = false;
13
+
6
14
  import Layout from "@/layouts/Layout.astro";
7
15
  import CollectionCard from "@/components/CollectionCard.astro";
8
16
  import { withBase } from "@/lib/basePath";
9
17
 
10
- const { tenant, tot, basePath } = Astro.locals;
18
+ const { tenant, tot, basePath, features } = Astro.locals;
11
19
 
12
- // A handful of collections to redirect lost visitors toward.
13
- const collections = (await tot.listCollections()).slice(0, 3);
20
+ // A handful of collections to redirect lost visitors toward. Marketing tenants
21
+ // (features.catalog === false) have no /collections route at all โ€” skip the
22
+ // fetch entirely rather than trust it to come back empty.
23
+ const collections = features.catalog ? (await tot.listCollections()).slice(0, 3) : [];
14
24
  ---
15
25
 
16
26
  <Layout
@@ -36,12 +46,14 @@ const collections = (await tot.listCollections()).slice(0, 3);
36
46
  >
37
47
  Back home
38
48
  </a>
39
- <a
40
- href={withBase(basePath, "/collections")}
41
- class="inline-flex items-center rounded-full border border-[var(--color-border)] px-6 py-3 font-display text-sm font-medium text-[var(--color-text)] transition-colors hover:border-[var(--color-primary)]"
42
- >
43
- Browse collections
44
- </a>
49
+ {features.catalog && (
50
+ <a
51
+ href={withBase(basePath, "/collections")}
52
+ class="inline-flex items-center rounded-full border border-[var(--color-border)] px-6 py-3 font-display text-sm font-medium text-[var(--color-text)] transition-colors hover:border-[var(--color-primary)]"
53
+ >
54
+ Browse collections
55
+ </a>
56
+ )}
45
57
  </div>
46
58
  </div>
47
59
  </section>
@@ -7,6 +7,8 @@
7
7
  */
8
8
  import Layout from "@/layouts/Layout.astro";
9
9
  import Breadcrumbs from "@/components/Breadcrumbs.astro";
10
+ import ProseSections from "@/components/content/ProseSections.astro";
11
+ import ProseToc from "@/components/content/ProseToc.astro";
10
12
  import { breadcrumbLd } from "@/lib/jsonld";
11
13
  import { truncate } from "@/lib/format";
12
14
  import { rawHtmlPolicy, rawHtmlResponse } from "@/lib/rawMarketingHtml";
@@ -56,6 +58,27 @@ const crumbs = [
56
58
  const description =
57
59
  page.seoDescription ||
58
60
  (page.intro ? truncate(page.intro, 160) : undefined);
61
+
62
+ // Structured pages (F4) drive the body from `sections`; legacy pages keep the
63
+ // sanitized `body_html`. Prefer structured whenever a page ships sections.
64
+ const structured = (page.sections?.length ?? 0) > 0;
65
+
66
+ // Reviewer-only needs-review flags render in preview/dev only โ€” mirrors the
67
+ // middleware's "production read" definition (no preview host / token / cookie),
68
+ // so a real shopper never sees scaffolding but a reviewer does.
69
+ const isPreview =
70
+ import.meta.env.DEV ||
71
+ Astro.url.hostname.toLowerCase().startsWith("preview.") ||
72
+ Astro.url.searchParams.has("__preview") ||
73
+ Astro.cookies.has("tot_preview");
74
+
75
+ const updatedLabel = page.updated
76
+ ? new Date(page.updated).toLocaleDateString("en-US", {
77
+ year: "numeric",
78
+ month: "long",
79
+ day: "numeric",
80
+ })
81
+ : null;
59
82
  ---
60
83
 
61
84
  <Layout
@@ -80,10 +103,21 @@ const description =
80
103
  {page.intro}
81
104
  </p>
82
105
  )}
106
+ {updatedLabel && (
107
+ <p class="mt-3 text-sm text-[var(--color-muted)]">Last updated {updatedLabel}</p>
108
+ )}
109
+ {structured && <ProseToc sections={page.sections!} />}
83
110
  </header>
84
111
 
85
- {/* Body โ€” constrained to a readable measure. */}
112
+ {/* Body โ€” constrained to a readable measure. Structured pages (F4) render
113
+ framework widgets from `sections`; legacy pages keep the sanitized HTML. */}
86
114
  <div class="container-prose pb-[clamp(3rem,7vw,6rem)] pt-8">
87
- <div class="rte mx-auto max-w-[68ch] text-[var(--color-text)]" set:html={page.body_html} />
115
+ {structured ? (
116
+ <div class="mx-auto max-w-[68ch]">
117
+ <ProseSections sections={page.sections!} includeNeedsReview={isPreview} />
118
+ </div>
119
+ ) : (
120
+ <div class="rte mx-auto max-w-[68ch] text-[var(--color-text)]" set:html={page.body_html} />
121
+ )}
88
122
  </div>
89
123
  </Layout>
@@ -0,0 +1,420 @@
1
+ ---
2
+ /**
3
+ * `/admin/ops-timeline` โ€” the operational-activity timeline VIEW (card D5).
4
+ *
5
+ * The READ surface of the actorร—action telemetry spine: a chronological,
6
+ * tenant-scoped list of lifecycle activity answering "who shipped/rejected what,
7
+ * when, why โ€” and what happened to my change/deploy". It reads the D1
8
+ * `activity_events` timeline cohort (DECISIONS.md ยง8) through the pure,
9
+ * tenant-scoped query module (`lib/activity/query.ts`).
10
+ *
11
+ * ROUTING / AUTH โ€” mirrors `admin.astro`: middleware resolves the tenant, injects
12
+ * CSP/security headers, and already held anonymous/non-member visitors at the
13
+ * sign-in gate. This surface exposes operational detail, so it renders the data
14
+ * ONLY for a viewer with owner/admin standing for the routed tenant, or a ToT-staff
15
+ * session (`resolveAdminEntry` / `resolveAdminTenantMode`, the same machinery the
16
+ * admin shell uses). A signed-in non-owner sees the chrome + a "no standing" notice,
17
+ * never another tenant's rows.
18
+ *
19
+ * TENANT ISOLATION โ€” the query is ALWAYS scoped to `Astro.locals.tenant.tenant_id`
20
+ * (the served tenant, never a URL value); `parseTimelineQuery` pins that tenant and
21
+ * ignores any `tenant`/`tenantId` query param. See `query.isolation.test.ts` for the
22
+ * hostile read-side proof.
23
+ *
24
+ * DEPLOY SKEW (card D2) โ€” the page surfaces `deriveDeploySkew()`: a banner when the
25
+ * build serving this page (or the timeline) shows more than one live version, plus a
26
+ * per-row "stale build" chip on superseded `deploy.version_observed` rows.
27
+ *
28
+ * ALERTS (card D7) โ€” the page also runs `checkActivityAlerts()` (silent-terminal,
29
+ * recurring preview.serve miss_no_env, reconcile-hang watchdog, and โ€” promoting the
30
+ * skew signal above โ€” a persistent deploy-skew alert) and renders an alerts banner
31
+ * above the deploy-skew visibility banner. Same on-demand posture: computed fresh on
32
+ * every page load, no cron. `GET /api/admin/activity-alerts` exposes the identical
33
+ * check for an external poller.
34
+ */
35
+ export const prerender = false;
36
+
37
+ import { readViewerSession } from "@/lib/auth/route";
38
+ import { resolveAdminEntry } from "@/lib/auth/adminEntry";
39
+ import { resolveAdminTenantMode } from "@/lib/adminTenantEnvelope";
40
+ import { readD1 } from "@/lib/env";
41
+ import { fromD1 } from "@/lib/d1/catalog";
42
+ import {
43
+ parseTimelineQuery,
44
+ loadOpsTimeline,
45
+ type OpsTimelineData,
46
+ type ActivityTimelineRow,
47
+ } from "@/lib/activity/query";
48
+ import { checkActivityAlerts, type ActivityAlert } from "@/lib/activity/alerts";
49
+
50
+ const { tenant, basePath, cspNonce } = Astro.locals;
51
+ const adminPath = `${basePath || ""}/admin`;
52
+ const timelinePath = `${adminPath}/ops-timeline`;
53
+ const tenantLabel = tenant.displayName || tenant.appDomain || tenant.tenant_id;
54
+ const nowSeconds = Math.floor(Date.now() / 1000);
55
+
56
+ const viewerSession = await readViewerSession(Astro);
57
+ const viewerEmail = (Astro.locals.viewer?.email ?? viewerSession?.email ?? "").toLowerCase();
58
+
59
+ const adminEntry = resolveAdminEntry({
60
+ record: viewerSession,
61
+ hostResource: tenant.appDomain,
62
+ nowSeconds,
63
+ });
64
+ const { isStaff } = resolveAdminTenantMode({
65
+ session: viewerSession,
66
+ adminAppTenant: tenant.appDomain,
67
+ requestedTarget: null,
68
+ nowSeconds,
69
+ });
70
+
71
+ // Gate: a signed-in owner/admin of THIS tenant, or ToT staff. Everyone else gets
72
+ // chrome + a notice, and the query never runs.
73
+ const authorized = Boolean(viewerSession) && (adminEntry.isOwnerViewer || isStaff);
74
+
75
+ // The query is scoped to the SERVED tenant's id โ€” never a URL value.
76
+ const query = parseTimelineQuery(Astro.url.searchParams, tenant.tenant_id);
77
+
78
+ let data: OpsTimelineData | null = null;
79
+ let alerts: ActivityAlert[] = [];
80
+ let dbAvailable = true;
81
+ if (authorized) {
82
+ const d1 = await readD1("STOREFRONT_APPS_DB");
83
+ if (d1) {
84
+ const db = fromD1(d1);
85
+ [data, alerts] = await Promise.all([
86
+ loadOpsTimeline(db, query),
87
+ checkActivityAlerts(db, tenant.tenant_id),
88
+ ]);
89
+ } else {
90
+ // Dev/Node or an env without the binding โ€” the timeline lives only in D1.
91
+ dbAvailable = false;
92
+ }
93
+ }
94
+
95
+ const rows = data?.rows ?? [];
96
+ const skew = data?.deploySkew ?? null;
97
+ // Versions superseded by a newer observation โ†’ a "stale build" chip on those rows.
98
+ const staleVersions = new Set(
99
+ (skew?.observedVersions ?? [])
100
+ .filter((v) => v.version !== skew?.latestObservedVersion)
101
+ .map((v) => v.version),
102
+ );
103
+
104
+ /** Short, non-PII actor label (the id is already an opaque pseudonym). */
105
+ function shortActor(row: ActivityTimelineRow): string {
106
+ const id = row.actorId.length > 14 ? `${row.actorId.slice(0, 14)}โ€ฆ` : row.actorId;
107
+ return `${row.actorKind}:${id}`;
108
+ }
109
+
110
+ /** Map a status to a chip color class. */
111
+ function statusClass(status: string): string {
112
+ switch (status) {
113
+ case "succeeded":
114
+ return "green";
115
+ case "failed":
116
+ return "red";
117
+ case "refused":
118
+ return "amber";
119
+ default:
120
+ return "blue"; // invoked
121
+ }
122
+ }
123
+
124
+ /** Compact, human detail line from the (already redacted) payload. */
125
+ function detailFor(row: ActivityTimelineRow): string {
126
+ const parts: string[] = [];
127
+ const rendered = row.payload.rendered ?? {};
128
+ for (const [k, v] of Object.entries(rendered)) parts.push(`${k}: ${v}`);
129
+ const args = row.payload.args ?? {};
130
+ for (const [k, v] of Object.entries(args)) {
131
+ if (k === "changeId") continue; // shown in its own column
132
+ parts.push(`${k}=${String(v)}`);
133
+ }
134
+ return parts.join(" ยท ");
135
+ }
136
+
137
+ /** The version an event refers to (deploy.version_observed rows carry it). */
138
+ function versionOf(row: ActivityTimelineRow): string | undefined {
139
+ const v = row.payload.args?.version;
140
+ return typeof v === "string" ? v : undefined;
141
+ }
142
+
143
+ const statusOptions = ["", "invoked", "succeeded", "failed", "refused"];
144
+ ---
145
+
146
+ <!doctype html>
147
+ <html lang="en">
148
+ <head>
149
+ <meta charset="utf-8" />
150
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
151
+ <meta name="robots" content="noindex, nofollow" />
152
+ <title>{tenantLabel} ยท Ops Timeline</title>
153
+ <style is:global nonce={cspNonce}>
154
+ :root {
155
+ color-scheme: light;
156
+ --bg: #f5f7f6;
157
+ --surface: #ffffff;
158
+ --surface-soft: #f9fbfa;
159
+ --ink: #16211f;
160
+ --muted: #64716d;
161
+ --line: #dce5e2;
162
+ --teal: #007f79;
163
+ --green-soft: #e3f5eb;
164
+ --green: #137a55;
165
+ --red-soft: #fde7e4;
166
+ --red: #b42318;
167
+ --amber-soft: #fff0d5;
168
+ --amber: #a45d00;
169
+ --blue-soft: #e7efff;
170
+ --blue: #2458c7;
171
+ --radius: 8px;
172
+ --font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
173
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
174
+ }
175
+ * { box-sizing: border-box; }
176
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 var(--font); }
177
+ a { color: var(--teal); }
178
+ .wrap { max-width: 1180px; margin: 0 auto; padding: 24px 20px 64px; }
179
+ header.page { margin-bottom: 18px; }
180
+ header.page h1 { margin: 0; font-size: 22px; font-weight: 760; }
181
+ header.page p { margin: 4px 0 0; color: var(--muted); font-size: 13px; }
182
+ header.page code { font-family: var(--mono); }
183
+ .notice {
184
+ padding: 14px 16px; border: 1px solid var(--amber); border-radius: var(--radius);
185
+ background: var(--amber-soft); color: #5c3600; margin-bottom: 18px; font-size: 13px;
186
+ }
187
+ .skew {
188
+ display: flex; align-items: flex-start; gap: 12px;
189
+ padding: 12px 14px; border: 1px solid #f1c883; border-radius: var(--radius);
190
+ background: #fff6e7; margin-bottom: 18px; font-size: 13px;
191
+ }
192
+ .skew strong { display: block; }
193
+ .skew .versions { color: var(--muted); font-size: 12px; margin-top: 3px; }
194
+ .skew code { font-family: var(--mono); }
195
+ .alerts { display: grid; gap: 8px; margin-bottom: 18px; }
196
+ .alert {
197
+ display: flex; align-items: flex-start; gap: 12px;
198
+ padding: 12px 14px; border-radius: var(--radius); font-size: 13px;
199
+ }
200
+ .alert strong { display: block; }
201
+ .alert .meta { color: inherit; opacity: 0.75; font-size: 12px; margin-top: 3px; }
202
+ .alert.page, .alert.critical {
203
+ border: 1px solid var(--red); background: var(--red-soft); color: var(--red);
204
+ }
205
+ .alert.warning {
206
+ border: 1px solid #f1c883; background: #fff6e7; color: #7a4a00;
207
+ }
208
+ .alert.info {
209
+ border: 1px solid var(--line); background: var(--surface-soft); color: var(--muted);
210
+ }
211
+ form.filters {
212
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
213
+ gap: 10px; align-items: end;
214
+ padding: 14px; border: 1px solid var(--line); border-radius: var(--radius);
215
+ background: var(--surface); margin-bottom: 18px;
216
+ }
217
+ form.filters .field { display: grid; gap: 4px; min-width: 0; }
218
+ form.filters label { color: var(--muted); font-size: 11px; font-weight: 760; text-transform: uppercase; }
219
+ form.filters input, form.filters select {
220
+ min-height: 36px; padding: 0 9px; font: inherit;
221
+ border: 1px solid var(--line); border-radius: 6px; background: #fcfdfc; color: var(--ink);
222
+ }
223
+ form.filters .actions { display: flex; gap: 8px; }
224
+ .btn {
225
+ min-height: 36px; display: inline-flex; align-items: center; padding: 0 14px;
226
+ border: 1px solid var(--teal); border-radius: 6px; background: var(--teal); color: #fff;
227
+ font-weight: 680; text-decoration: none; cursor: pointer;
228
+ }
229
+ .btn.ghost { background: var(--surface); color: var(--ink); border-color: var(--line); }
230
+ .table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
231
+ table { width: 100%; min-width: 920px; border-collapse: collapse; font-size: 13px; }
232
+ th {
233
+ height: 38px; padding: 0 12px; border-bottom: 1px solid var(--line);
234
+ background: var(--surface-soft); color: var(--muted); font-size: 11px;
235
+ font-weight: 760; text-align: left; text-transform: uppercase; white-space: nowrap;
236
+ }
237
+ td { padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
238
+ tr:last-child td { border-bottom: 0; }
239
+ td.at { white-space: nowrap; font-family: var(--mono); font-size: 12px; color: var(--muted); }
240
+ td.action code, td.change code, td.actor code { font-family: var(--mono); font-size: 12px; }
241
+ td.action small { display: block; color: var(--muted); font-size: 11px; margin-top: 2px; }
242
+ td.detail { color: var(--muted); overflow-wrap: anywhere; }
243
+ .chip {
244
+ display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px;
245
+ border-radius: 999px; background: var(--surface-soft); color: #3f4d49;
246
+ font-size: 11px; font-weight: 740; white-space: nowrap;
247
+ }
248
+ .chip.green { background: var(--green-soft); color: var(--green); }
249
+ .chip.red { background: var(--red-soft); color: var(--red); }
250
+ .chip.amber { background: var(--amber-soft); color: var(--amber); }
251
+ .chip.blue { background: var(--blue-soft); color: var(--blue); }
252
+ .chip.stale { background: #ffe0d6; color: #a5330a; margin-left: 6px; }
253
+ .empty { padding: 40px 16px; text-align: center; color: var(--muted); }
254
+ @media (max-width: 760px) {
255
+ form.filters { grid-template-columns: 1fr 1fr; }
256
+ }
257
+ </style>
258
+ </head>
259
+ <body>
260
+ <div class="wrap">
261
+ <header class="page">
262
+ <h1>Operational activity timeline</h1>
263
+ <p>
264
+ {tenantLabel} ยท <code>{tenant.tenant_id}</code> ยท lifecycle events, newest first ยท
265
+ <a href={adminPath}>โ† Admin</a>
266
+ </p>
267
+ </header>
268
+
269
+ {!viewerSession && (
270
+ <div class="notice">
271
+ Sign in as an owner/admin of {tenantLabel} to view the operational activity timeline.
272
+ <a href="/auth/login">Sign in</a>.
273
+ </div>
274
+ )}
275
+
276
+ {viewerSession && !authorized && (
277
+ <div class="notice">
278
+ {viewerEmail} is signed in but has no owner/admin standing for {tenantLabel}. The ops
279
+ timeline is limited to owners/admins of this store and ToT staff.
280
+ </div>
281
+ )}
282
+
283
+ {authorized && !dbAvailable && (
284
+ <div class="notice">
285
+ The activity timeline is stored in D1 (<code>STOREFRONT_APPS_DB</code>), which isn't
286
+ bound in this environment โ€” no rows to show here.
287
+ </div>
288
+ )}
289
+
290
+ {authorized && alerts.length > 0 && (
291
+ <div class="alerts" role="alert">
292
+ {alerts.map((a) => (
293
+ <div class={`alert ${a.severity}`}>
294
+ <span aria-hidden="true">{a.severity === "page" || a.severity === "critical" ? "๐Ÿšจ" : "โš ๏ธ"}</span>
295
+ <div>
296
+ <strong>{a.title}</strong>
297
+ <div>{a.summary}</div>
298
+ <div class="meta">
299
+ {a.changeId && <>Change <code>{a.changeId}</code> ยท </>}
300
+ since {a.since}
301
+ </div>
302
+ </div>
303
+ </div>
304
+ ))}
305
+ </div>
306
+ )}
307
+
308
+ {authorized && skew?.skewDetected && (
309
+ <div class="skew" role="status">
310
+ <span aria-hidden="true">โš ๏ธ</span>
311
+ <div>
312
+ <strong>Deploy skew detected</strong>
313
+ <div class="versions">
314
+ {skew.runningVersion
315
+ ? <>Serving build <code>{skew.runningVersion}</code>. </>
316
+ : <>Serving build unknown. </>}
317
+ Observed builds:{" "}
318
+ {skew.observedVersions.map((v, i) => (
319
+ <>
320
+ {i > 0 ? ", " : ""}
321
+ <code>{v.version}</code>{v.env ? ` (${v.env})` : ""}
322
+ {v.version === skew.latestObservedVersion ? " โ€” latest" : " โ€” stale"}
323
+ </>
324
+ ))}.
325
+ This is a visibility signal only (alerting is a separate concern).
326
+ </div>
327
+ </div>
328
+ </div>
329
+ )}
330
+
331
+ {authorized && (
332
+ <form class="filters" method="get" action={timelinePath}>
333
+ <div class="field">
334
+ <label for="f-changeId">Change ID</label>
335
+ <input id="f-changeId" type="text" name="changeId" value={query.changeId ?? ""} placeholder="chg-โ€ฆ" />
336
+ </div>
337
+ <div class="field">
338
+ <label for="f-action">Action</label>
339
+ <input id="f-action" type="text" name="action" value={query.action ?? ""} placeholder="change.ship" />
340
+ </div>
341
+ <div class="field">
342
+ <label for="f-actionPrefix">Action family</label>
343
+ <input id="f-actionPrefix" type="text" name="actionPrefix" value={query.actionPrefix ?? ""} placeholder="change." />
344
+ </div>
345
+ <div class="field">
346
+ <label for="f-actorId">Actor ID</label>
347
+ <input id="f-actorId" type="text" name="actorId" value={query.actorId ?? ""} placeholder="h_โ€ฆ" />
348
+ </div>
349
+ <div class="field">
350
+ <label for="f-status">Status</label>
351
+ <select id="f-status" name="status">
352
+ {statusOptions.map((s) => (
353
+ <option value={s} selected={query.status === s || (s === "" && !query.status)}>
354
+ {s === "" ? "any" : s}
355
+ </option>
356
+ ))}
357
+ </select>
358
+ </div>
359
+ <div class="field">
360
+ <label for="f-since">Since (ISO)</label>
361
+ <input id="f-since" type="text" name="since" value={query.since ?? ""} placeholder="2026-08-01" />
362
+ </div>
363
+ <div class="field">
364
+ <label for="f-until">Until (ISO)</label>
365
+ <input id="f-until" type="text" name="until" value={query.until ?? ""} placeholder="2026-08-31" />
366
+ </div>
367
+ <div class="field actions">
368
+ <button class="btn" type="submit">Filter</button>
369
+ <a class="btn ghost" href={timelinePath}>Reset</a>
370
+ </div>
371
+ </form>
372
+ )}
373
+
374
+ {authorized && dbAvailable && (
375
+ <div class="table-wrap">
376
+ <table>
377
+ <thead>
378
+ <tr>
379
+ <th>When</th>
380
+ <th>Actor</th>
381
+ <th>Action</th>
382
+ <th>Status</th>
383
+ <th>Change</th>
384
+ <th>Detail</th>
385
+ </tr>
386
+ </thead>
387
+ <tbody>
388
+ {rows.length === 0 && (
389
+ <tr>
390
+ <td class="empty" colspan="6">No activity matches these filters yet.</td>
391
+ </tr>
392
+ )}
393
+ {rows.map((row) => {
394
+ const version = versionOf(row);
395
+ const isStale = version != null && staleVersions.has(version);
396
+ return (
397
+ <tr>
398
+ <td class="at">{row.at}</td>
399
+ <td class="actor"><code>{shortActor(row)}</code></td>
400
+ <td class="action">
401
+ <code>{row.action}</code>
402
+ {row.actionDesc && <small>{row.actionDesc}</small>}
403
+ </td>
404
+ <td>
405
+ <span class={`chip ${statusClass(row.status)}`}>{row.status}</span>
406
+ {row.errorClass && <span class="chip amber">{row.errorClass}</span>}
407
+ {isStale && <span class="chip stale">stale build</span>}
408
+ </td>
409
+ <td class="change">{row.changeId ? <code>{row.changeId}</code> : "โ€”"}</td>
410
+ <td class="detail">{detailFor(row) || "โ€”"}</td>
411
+ </tr>
412
+ );
413
+ })}
414
+ </tbody>
415
+ </table>
416
+ </div>
417
+ )}
418
+ </div>
419
+ </body>
420
+ </html>