@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
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Owner-facing "who did this" attribution for a candidate change or aggregate —
3
+ * DELIBERATELY separate from the anonymized activity spine. `ActivityActor.id`
4
+ * is a salted, non-reversible hash by design (see `recordActivity.ts`'s header:
5
+ * a raw PII id in the core zone would defeat the D0 contract), so it can never
6
+ * answer "who submitted/accepted/rejected/shipped this" for an owner reviewing
7
+ * `/admin#publish`. This persists the real email (never joined into the general
8
+ * `queryActivity()` surface, never cross-tenant) alongside the existing hashed
9
+ * event, so the two concerns stay separate: analytics stays anonymized, and
10
+ * this stays a narrow, owner-only-readable accountability record.
11
+ *
12
+ * `sub` (the tot20-issued, stable user id) would be a better key than email —
13
+ * email is a mutable account attribute, not the identity itself — but `sub`
14
+ * is dropped before `SessionRecord` today (see `auth/session.ts`). Threading
15
+ * it through is a real follow-up, not done here; email is what every call site
16
+ * already has.
17
+ */
18
+ import { displayNameFromEmail } from "./recordActivity";
19
+
20
+ export type ChangeActorAction = "submit" | "reject" | "accept" | "ship" | "build";
21
+
22
+ export interface ChangeActorRecord {
23
+ email: string;
24
+ /** Best-effort display name — no real name is captured anywhere today, so
25
+ * this is always {@link displayNameFromEmail}'s local-part guess. Swap the
26
+ * source here (not the callers) if a real name ever becomes available. */
27
+ name: string;
28
+ at: string;
29
+ }
30
+
31
+ export interface ChangeActorKv {
32
+ get(key: string): Promise<string | null>;
33
+ put(key: string, value: string): Promise<void>;
34
+ }
35
+
36
+ function attributionKey(tenantId: string, subjectId: string, action: ChangeActorAction): string {
37
+ return `cust:change-actor:${tenantId}:${subjectId}:${action}`;
38
+ }
39
+
40
+ /**
41
+ * Persist who took `action` on `subjectId` (a `changeId` for reject/accept/build,
42
+ * or the aggregate id for ship). Best-effort — never blocks or fails the
43
+ * mutation it describes; a write fault is swallowed, mirroring `recordActivity`.
44
+ */
45
+ export async function recordChangeActor(
46
+ kv: ChangeActorKv,
47
+ tenantId: string,
48
+ subjectId: string,
49
+ action: ChangeActorAction,
50
+ email: string,
51
+ ): Promise<void> {
52
+ try {
53
+ const record: ChangeActorRecord = {
54
+ email,
55
+ name: displayNameFromEmail(email),
56
+ at: new Date().toISOString(),
57
+ };
58
+ await kv.put(attributionKey(tenantId, subjectId, action), JSON.stringify(record));
59
+ } catch {
60
+ /* best-effort — the mutation it describes already succeeded */
61
+ }
62
+ }
63
+
64
+ /** Read every recorded action's attribution for one subject. Best-effort per
65
+ * action: a missing/corrupt record is simply absent from the result, never
66
+ * a thrown error. */
67
+ export async function readChangeActors(
68
+ kv: ChangeActorKv,
69
+ tenantId: string,
70
+ subjectId: string,
71
+ ): Promise<Partial<Record<ChangeActorAction, ChangeActorRecord>>> {
72
+ const actions: ChangeActorAction[] = ["submit", "reject", "accept", "ship", "build"];
73
+ const out: Partial<Record<ChangeActorAction, ChangeActorRecord>> = {};
74
+ await Promise.all(
75
+ actions.map(async (action) => {
76
+ try {
77
+ const raw = await kv.get(attributionKey(tenantId, subjectId, action));
78
+ if (raw) out[action] = JSON.parse(raw) as ChangeActorRecord;
79
+ } catch {
80
+ /* a corrupt/missing record just isn't shown */
81
+ }
82
+ }),
83
+ );
84
+ return out;
85
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * deploy.version_observed — the deploy-provenance / deploy-skew signal (card D2).
3
+ *
4
+ * "A deployed worker version/commit was observed." This is the DERIVED signal D5's
5
+ * query surface reads to answer *deploy skew* — is the build serving traffic the
6
+ * pushed umbrella tip, or is a stale isolate still live? D2's job is just to make
7
+ * `worker_commit` reliably present on server events and to EMIT this observation at
8
+ * least once per running build; the actual "== umbrella tip?" comparison and the
9
+ * timeline query are D5's (this module intentionally does no comparison — it has no
10
+ * notion of what the tip is, only what THIS build is).
11
+ *
12
+ * WHAT IT PRODUCES. A catalog `deploy.version_observed` event (ACTION_CATALOG:
13
+ * actors [system|dev], source server, sinks logs+analytics+timeline). The FULL
14
+ * 40-char commit rides `scope.workerCommit` — the CORE zone, which createActivityEvent
15
+ * never redacts — because that is where the authoritative, unscrubbed sha belongs.
16
+ *
17
+ * DELIBERATELY the full sha does NOT go in `payload`. The action's argsAllow names
18
+ * `commit`, but a 40-char hex sha trips D0's `high_entropy` VALUE canary (redaction.ts:
19
+ * ≥28 chars of base64url) — it would be scrubbed to «redacted» AND set
20
+ * `report.canaryTripped`, which D6/D7 alert on, firing a FALSE PII alarm on every cold
21
+ * start. So payload carries only the SHORT `version` (7 chars, safe) plus `env` +
22
+ * `observedFrom`; a reader wanting the full sha reads scope.workerCommit (a git sha is
23
+ * public, so the core zone is its correct, alert-free home). See the D2 report note for
24
+ * D5/D8: treat scope.workerCommit as the authoritative provenance, payload.version as a
25
+ * human-eyeball convenience.
26
+ *
27
+ * WHERE IT'S EMITTED. observeDeployVersion() fires ONCE PER WORKER ISOLATE (guarded
28
+ * by a module-level flag) from the middleware's front door — a cold start emits one
29
+ * observation, not one per request, so the stream is low-volume and honest. Until
30
+ * D1's recordActivity()/D4's ingest+store land, the emission goes to the universal
31
+ * `logs` sink (structured `[activity]` JSON to Workers Logs — the same pipeline the
32
+ * interim recordActivity.ts uses, already provisioned via `[observability]`). When
33
+ * D1's recordActivity() exists, swap `emitToLogs` for it (it will fan out to the
34
+ * analytics+timeline sinks the action declares); the EVENT this module builds is
35
+ * unchanged.
36
+ */
37
+ import { createActivityEvent } from "@tot/public-runtime";
38
+ import type { ActivityEvent, RedactionReport } from "@tot/public-runtime";
39
+ import { workerCommit, workerCommitShort, UNKNOWN_COMMIT } from "./workerCommit.js";
40
+ import { isActivityDisabled } from "./killSwitch.js";
41
+
42
+ export interface DeployVersionObservedInput {
43
+ /** Deploy env/target name (e.g. "preview", "production"), when known. Optional. */
44
+ env?: string;
45
+ /** What surfaced the observation (e.g. "startup", "health"). Defaults "startup". */
46
+ observedFrom?: string;
47
+ /** Opaque actor id. Defaults "system" (the platform observed its own build). */
48
+ actorId?: string;
49
+ /** Override the build commit — tests only. Real callers omit (reads __GIT_SHA__). */
50
+ commit?: string;
51
+ }
52
+
53
+ /**
54
+ * Build (do not emit) the `deploy.version_observed` ActivityEvent for the running
55
+ * build. Pure + side-effect-free — the piece D5 ultimately consumes and the piece
56
+ * tests assert against. Returns the safe-constructed event plus its redaction report.
57
+ */
58
+ export function buildDeployVersionObservedEvent(
59
+ input: DeployVersionObservedInput = {},
60
+ ): { event: ActivityEvent; report: RedactionReport } {
61
+ const commit = workerCommit(input.commit);
62
+ const version = workerCommitShort(input.commit);
63
+ return createActivityEvent({
64
+ action: "deploy.version_observed",
65
+ actor: { kind: "system", id: input.actorId ?? "system" },
66
+ source: "server",
67
+ outcome: { status: "succeeded" },
68
+ // worker_commit (full sha) in the CORE zone — never redacted; the authoritative
69
+ // provenance every server event carries and D5 derives skew from.
70
+ scope: commit !== UNKNOWN_COMMIT ? { workerCommit: commit } : {},
71
+ // Payload carries the SHORT version (safe from the high_entropy canary that would
72
+ // scrub a full sha) + env + observedFrom. Unknown builds omit `version` rather than
73
+ // store the sentinel, but still emit the observation.
74
+ payload: {
75
+ args: {
76
+ ...(commit !== UNKNOWN_COMMIT ? { version } : {}),
77
+ ...(input.env ? { env: input.env } : {}),
78
+ observedFrom: input.observedFrom ?? "startup",
79
+ },
80
+ },
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Serialize an ActivityEvent to the universal `logs` sink (Workers Logs). Interim
86
+ * transport until D1's recordActivity() fans out to all of the action's sinks;
87
+ * mirrors the `[activity]` tag the existing recordActivity.ts slice already emits so
88
+ * one grep finds both. Best-effort, never throws.
89
+ */
90
+ function emitToLogs(event: ActivityEvent): void {
91
+ try {
92
+ console.log(`[activity] ${JSON.stringify(event)}`);
93
+ } catch {
94
+ // best-effort only — telemetry must never break a request
95
+ }
96
+ }
97
+
98
+ /** Per-isolate guard: emit the observation once per cold start, not per request. */
99
+ let observedThisIsolate = false;
100
+
101
+ /**
102
+ * Emit `deploy.version_observed` ONCE for this worker isolate. Idempotent within an
103
+ * isolate (subsequent calls are no-ops), best-effort, never throws — safe to call on
104
+ * every request from the middleware front door. Returns the event it emitted (or
105
+ * `undefined` if it already fired this isolate) so callers/tests can inspect it.
106
+ */
107
+ export function observeDeployVersion(
108
+ input: DeployVersionObservedInput = {},
109
+ ): ActivityEvent | undefined {
110
+ if (observedThisIsolate) return undefined;
111
+ // Kill switch (D8): telemetry off ⇒ skip the observation entirely (fail-open). Do
112
+ // NOT set the per-isolate guard, so re-enabling emits the observation on the next call.
113
+ if (isActivityDisabled()) return undefined;
114
+ observedThisIsolate = true;
115
+ try {
116
+ const { event } = buildDeployVersionObservedEvent(input);
117
+ emitToLogs(event);
118
+ return event;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ /** Test-only: reset the per-isolate guard so a test can re-exercise the first-fire path. */
125
+ export function __resetDeployVersionObservedForTest(): void {
126
+ observedThisIsolate = false;
127
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Ingest validation + server-side redaction for `POST /api/activity` (card D4).
3
+ *
4
+ * PURE (no I/O) so it is unit-testable and the route stays a thin auth/parse/
5
+ * respond shell. Takes the raw JSON events an emitter posted (CLI cohort D3, UI
6
+ * cohort D6 — both send JSON matching the `ActivityEvent` shape) plus the
7
+ * AUTHENTICATED tenant id, and returns the accepted events (already rebuilt
8
+ * through `createActivityEvent`, so re-redacted server-side) and the rejections.
9
+ *
10
+ * DEFENSE IN DEPTH (D8's future attack surface). Even though every emitter is
11
+ * expected to have run the redaction contract itself, ingest NEVER trusts that:
12
+ * it rebuilds each event through `createActivityEvent()`, which re-applies the
13
+ * default-deny allowlist + canary scan. A malicious/careless client that posts a
14
+ * secret in an un-allowlisted (or even allowlisted) payload field has it dropped/
15
+ * redacted here regardless of what it claimed to send.
16
+ *
17
+ * TENANT ISOLATION. `scope.tenantId` on the stored event is ALWAYS the
18
+ * authenticated tenant (from the HMAC-bound header), never the client-supplied
19
+ * body value — a client cannot write into another tenant's scope.
20
+ */
21
+ import {
22
+ createActivityEvent,
23
+ isActionKey,
24
+ isActorAllowed,
25
+ isIsoTimestamp,
26
+ getActionSpec,
27
+ scanCoreIdentifier,
28
+ type ActivityEvent,
29
+ type ActorKind,
30
+ type ActivitySource,
31
+ type ActivityStatus,
32
+ type ActivityPayload,
33
+ type RedactionFieldHit,
34
+ } from "@tot/public-runtime";
35
+
36
+ const ACTOR_KINDS = new Set<ActorKind>(["dev", "merchant", "admin", "agent", "system"]);
37
+ const SOURCES = new Set<ActivitySource>(["cli", "server", "ui"]);
38
+ const STATUSES = new Set<ActivityStatus>(["invoked", "succeeded", "failed", "refused"]);
39
+
40
+ export interface IngestRejection {
41
+ /** Index of the offending event in the posted batch. */
42
+ index: number;
43
+ /** Coarse machine reason — mirrors the `system.ingest.rejected` catalog args. */
44
+ reason:
45
+ | "not_object"
46
+ | "unknown_action"
47
+ | "actor_invalid"
48
+ | "actor_not_allowed"
49
+ | "source_invalid"
50
+ | "source_mismatch"
51
+ | "status_invalid"
52
+ | "bad_timestamp"
53
+ /** A client-supplied CORE-zone id (actor.id/ref, scope.changeId/traceId) carried a
54
+ * raw PII/secret SHAPE. The core zone is unredacted, so this is refused outright
55
+ * (never stored) rather than scrubbed — see `scanCoreIdentifier`. */
56
+ | "core_pii";
57
+ /** The action string the client attempted (for self-telemetry), when present. */
58
+ attemptedAction?: string;
59
+ }
60
+
61
+ export interface AcceptedActivity {
62
+ event: ActivityEvent;
63
+ /** True when a value-canary fired during server-side redaction — the signal
64
+ * the route emits self-telemetry for and D6/D7 alert on. */
65
+ canaryTripped: boolean;
66
+ /** The redaction hits (fact-of, never the value) for D8 audit / logging. */
67
+ redactionHits: RedactionFieldHit[];
68
+ }
69
+
70
+ export interface IngestOutcome {
71
+ accepted: AcceptedActivity[];
72
+ rejected: IngestRejection[];
73
+ }
74
+
75
+ function asString(v: unknown): string | undefined {
76
+ return typeof v === "string" && v.length > 0 ? v : undefined;
77
+ }
78
+
79
+ /**
80
+ * Validate + redact one posted event under the authenticated tenant. Returns the
81
+ * accepted (rebuilt) event or a rejection. The rebuild forces the tenant scope
82
+ * and re-runs redaction; it preserves the client's `id` (idempotency key) and
83
+ * `at` (emit clock) when they are well-formed.
84
+ */
85
+ export function ingestOne(index: number, raw: unknown, tenantId: string): AcceptedActivity | IngestRejection {
86
+ if (!raw || typeof raw !== "object") return { index, reason: "not_object" };
87
+ const e = raw as Record<string, unknown>;
88
+
89
+ const action = e.action;
90
+ if (typeof action !== "string" || !isActionKey(action)) {
91
+ return { index, reason: "unknown_action", attemptedAction: typeof action === "string" ? action : undefined };
92
+ }
93
+ const spec = getActionSpec(action)!;
94
+
95
+ const rawActor = e.actor;
96
+ if (!rawActor || typeof rawActor !== "object") return { index, reason: "actor_invalid", attemptedAction: action };
97
+ const actorKind = (rawActor as Record<string, unknown>).kind;
98
+ const actorId = (rawActor as Record<string, unknown>).id;
99
+ const actorRef = (rawActor as Record<string, unknown>).ref;
100
+ if (typeof actorKind !== "string" || !ACTOR_KINDS.has(actorKind as ActorKind) || typeof actorId !== "string" || !actorId) {
101
+ return { index, reason: "actor_invalid", attemptedAction: action };
102
+ }
103
+ // Spoof guard: a `dev`-cohort caller cannot claim an `admin` lifecycle action.
104
+ if (!isActorAllowed(action, actorKind as ActorKind)) {
105
+ return { index, reason: "actor_not_allowed", attemptedAction: action };
106
+ }
107
+
108
+ const source = e.source;
109
+ if (typeof source !== "string" || !SOURCES.has(source as ActivitySource)) {
110
+ return { index, reason: "source_invalid", attemptedAction: action };
111
+ }
112
+ // The catalog declares exactly one legitimate source per action (1:1 grounding).
113
+ if (source !== spec.source) return { index, reason: "source_mismatch", attemptedAction: action };
114
+
115
+ const rawOutcome = e.outcome;
116
+ const status = rawOutcome && typeof rawOutcome === "object" ? (rawOutcome as Record<string, unknown>).status : undefined;
117
+ if (typeof status !== "string" || !STATUSES.has(status as ActivityStatus)) {
118
+ return { index, reason: "status_invalid", attemptedAction: action };
119
+ }
120
+ const errorClass = asString((rawOutcome as Record<string, unknown>).errorClass);
121
+ const durationRaw = (rawOutcome as Record<string, unknown>).durationMs;
122
+ const durationMs = typeof durationRaw === "number" && Number.isFinite(durationRaw) ? durationRaw : undefined;
123
+
124
+ // `at`: keep the emit clock when well-formed; a PRESENT-but-malformed `at` is a
125
+ // reject (a broken clock corrupts the D5 timeline ordering), a MISSING one is
126
+ // stamped server-side by createActivityEvent.
127
+ const at = e.at;
128
+ if (at !== undefined && !isIsoTimestamp(at)) return { index, reason: "bad_timestamp", attemptedAction: action };
129
+
130
+ const rawScope = (e.scope && typeof e.scope === "object" ? e.scope : {}) as Record<string, unknown>;
131
+
132
+ // CORE-ZONE PII GUARD (D8 hardening). actor.id / actor.ref and the client-supplied
133
+ // scope ids land UNREDACTED in the core zone, whose contract is "opaque, never raw
134
+ // PII/secret". The payload contract is enforced by re-redaction below, but the core
135
+ // ids were NOT checked — a buggy/malicious (though authenticated) emitter could
136
+ // smuggle a raw email/token into actor.id and have it stored in D1/Analytics/logs
137
+ // verbatim. Refuse any core id with a raw PII/secret SHAPE (opaque hashes/UUIDs pass;
138
+ // see `scanCoreIdentifier`). Rejected → self-telemetried as `system.ingest.rejected`.
139
+ const changeId = asString(rawScope.changeId);
140
+ const traceId = asString(rawScope.traceId);
141
+ if (
142
+ scanCoreIdentifier(actorId) ||
143
+ (typeof actorRef === "string" && actorRef && scanCoreIdentifier(actorRef)) ||
144
+ (changeId && scanCoreIdentifier(changeId)) ||
145
+ (traceId && scanCoreIdentifier(traceId))
146
+ ) {
147
+ return { index, reason: "core_pii", attemptedAction: action };
148
+ }
149
+
150
+ const built = createActivityEvent({
151
+ action,
152
+ actor: {
153
+ kind: actorKind as ActorKind,
154
+ id: actorId,
155
+ ...(typeof actorRef === "string" && actorRef ? { ref: actorRef } : {}),
156
+ },
157
+ source: source as ActivitySource,
158
+ outcome: {
159
+ status: status as ActivityStatus,
160
+ ...(errorClass ? { errorClass } : {}),
161
+ ...(durationMs !== undefined ? { durationMs } : {}),
162
+ },
163
+ scope: {
164
+ // AUTHENTICATED tenant only — the client's scope.tenantId is DISCARDED.
165
+ tenantId,
166
+ ...(changeId ? { changeId } : {}),
167
+ ...(traceId ? { traceId } : {}),
168
+ },
169
+ ...(typeof at === "string" ? { at } : {}),
170
+ ...(asString(e.id) ? { id: e.id as string } : {}),
171
+ // RAW payload — createActivityEvent re-redacts it (defense in depth).
172
+ ...(e.payload && typeof e.payload === "object" ? { payload: e.payload as ActivityPayload } : {}),
173
+ });
174
+
175
+ return { event: built.event, canaryTripped: built.report.canaryTripped, redactionHits: built.report.hits };
176
+ }
177
+
178
+ /** Validate + redact a whole posted batch under the authenticated tenant. */
179
+ export function ingestEvents(events: unknown[], tenantId: string): IngestOutcome {
180
+ const accepted: AcceptedActivity[] = [];
181
+ const rejected: IngestRejection[] = [];
182
+ events.forEach((raw, i) => {
183
+ const r = ingestOne(i, raw, tenantId);
184
+ if ("event" in r) accepted.push(r);
185
+ else rejected.push(r);
186
+ });
187
+ return { accepted, rejected };
188
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Service-auth for `POST /api/activity` (card D4) — HMAC-SHA256 over the raw
3
+ * body, with the authenticated TENANT bound into the signed material.
4
+ *
5
+ * WHY HMAC (not the bare `x-mcp-service-secret` shared-secret seam the
6
+ * webhook receivers use). Those receivers authenticate ONE trusted forwarder
7
+ * (tot-mcp) and read the tenant from a body the forwarder itself constructed —
8
+ * fine there. Activity ingest is different in two ways that make a plain shared
9
+ * secret insufficient:
10
+ * 1. It is fed by MANY emitters (the CLI dev cohort, the UI cohort) whose
11
+ * payloads must not be tamperable in flight — the redaction canaries key
12
+ * off byte-exact values, so body INTEGRITY is load-bearing.
13
+ * 2. Every row is TENANT-SCOPED and the scope must come from the authenticated
14
+ * identity, not a client-set field. So the tenant is part of the SIGNED
15
+ * material (`<tenant>\n<body>`): a caller cannot assert a tenant it was not
16
+ * issued a signature for without holding the ingest secret, and cannot swap
17
+ * the tenant without invalidating the signature. The route then writes
18
+ * `scope.tenantId` from THIS verified header and discards any body value.
19
+ *
20
+ * The signing key is the `ACTIVITY_INGEST_SECRET` Worker secret, shared with the
21
+ * emitters' gateway (the CLI/UI post through a signer that holds it — the
22
+ * per-emitter key-distribution/rotation story is a downstream concern, noted as
23
+ * a TODO). Everything here is WebCrypto (`crypto.subtle`) so the edge bundle
24
+ * never imports `node:crypto`, matching the node-free posture of the contract.
25
+ */
26
+
27
+ /** Header carrying the authenticated tenant assertion (bound into the HMAC). */
28
+ export const TENANT_HEADER = "x-tot-activity-tenant";
29
+ /** Header carrying the hex HMAC-SHA256 signature over `<tenant>\n<rawBody>`. */
30
+ export const SIGNATURE_HEADER = "x-tot-activity-signature";
31
+
32
+ export type IngestAuthResult =
33
+ | { ok: true; tenantId: string }
34
+ | { ok: false; status: 401 | 503; reason: string };
35
+
36
+ /** Length-independent constant-time string compare — mirrors the shared-secret
37
+ * seam in `api/changes/candidate-reconcile.ts`; avoids leaking the signature by
38
+ * timing. */
39
+ export function timingSafeEqual(a: string, b: string): boolean {
40
+ const len = Math.max(a.length, b.length);
41
+ let diff = a.length ^ b.length;
42
+ for (let i = 0; i < len; i++) {
43
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
44
+ }
45
+ return diff === 0;
46
+ }
47
+
48
+ const encoder = new TextEncoder();
49
+
50
+ function toHex(buf: ArrayBuffer): string {
51
+ const b = new Uint8Array(buf);
52
+ let out = "";
53
+ for (let i = 0; i < b.length; i++) out += (b[i] as number).toString(16).padStart(2, "0");
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Hex HMAC-SHA256 of `message` under `secret`. Exported so the tests (and any
59
+ * server-side signer/gateway) sign with the exact same construction the verifier
60
+ * checks — a single source of truth for the wire format.
61
+ */
62
+ export async function hmacSha256Hex(secret: string, message: string): Promise<string> {
63
+ const key = await crypto.subtle.importKey(
64
+ "raw",
65
+ encoder.encode(secret),
66
+ { name: "HMAC", hash: "SHA-256" },
67
+ false,
68
+ ["sign"],
69
+ );
70
+ const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
71
+ return toHex(sig);
72
+ }
73
+
74
+ /** The exact bytes signed: the authenticated tenant, then the raw request body,
75
+ * separated by a newline the tenant can never contain (it is validated as a
76
+ * single-line id upstream). Binding both means neither can be swapped
77
+ * independently of the signature. */
78
+ export function signingMaterial(tenantId: string, rawBody: string): string {
79
+ return `${tenantId}\n${rawBody}`;
80
+ }
81
+
82
+ /**
83
+ * Verify an inbound ingest request. `secret` is the resolved
84
+ * `ACTIVITY_INGEST_SECRET` (the route returns 503 BEFORE calling this when it is
85
+ * unset — an unconfigured seam is not an auth failure). Returns the authenticated
86
+ * `tenantId` on success; a 401 with a coarse reason otherwise (never echoes the
87
+ * expected signature or the secret).
88
+ */
89
+ export async function verifyIngestAuth(
90
+ headers: Headers,
91
+ rawBody: string,
92
+ secret: string,
93
+ ): Promise<IngestAuthResult> {
94
+ const tenantId = (headers.get(TENANT_HEADER) ?? "").trim();
95
+ const presented = (headers.get(SIGNATURE_HEADER) ?? "").trim();
96
+ if (!tenantId) return { ok: false, status: 401, reason: "missing tenant" };
97
+ // A newline in the tenant id would let a caller forge the material framing.
98
+ if (/[\r\n]/.test(tenantId)) return { ok: false, status: 401, reason: "invalid tenant" };
99
+ if (!presented) return { ok: false, status: 401, reason: "missing signature" };
100
+ const expected = await hmacSha256Hex(secret, signingMaterial(tenantId, rawBody));
101
+ if (!timingSafeEqual(presented.toLowerCase(), expected)) {
102
+ return { ok: false, status: 401, reason: "bad signature" };
103
+ }
104
+ return { ok: true, tenantId };
105
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The single operational KILL SWITCH for the whole activity-telemetry pipeline (D8).
3
+ *
4
+ * ONE flag — the `ACTIVITY_TELEMETRY_DISABLED` env var / Worker secret — switches
5
+ * EVERY server-side emit path to a no-op in one place:
6
+ * • `recordActivity()` (D1 server emitter) — and therefore `recordUiActivity()`
7
+ * (D6 UI cohort), which is a thin wrapper over it;
8
+ * • `observeDeployVersion()` (D2 deploy-provenance observer);
9
+ * • `POST /api/activity` (D4 ingest) — accepts-and-drops so emitters never error.
10
+ * The out-of-process CLI (`emitActivity`, D3) honours the SAME flag name in its own
11
+ * JS mirror. Wiring it at these SHARED chokepoints means no call site checks it.
12
+ *
13
+ * FAIL-OPEN — this gates ONLY the observability layer. The underlying operation
14
+ * (reconcile, preview-serve, ship, invite, a CLI command) never depends on telemetry
15
+ * and is never blocked by it; flipping the switch turns emits into no-ops while
16
+ * everything else runs unchanged. An unset/unknown flag ⇒ telemetry ENABLED (the
17
+ * normal state). Reading the flag can never throw.
18
+ *
19
+ * SYNC + PRIMED — `recordActivity()` is a synchronous, fire-and-forget best-effort
20
+ * call, so the switch must be checkable WITHOUT awaiting. `isActivityDisabled()`
21
+ * consults (a) a value PRIMED once per isolate from the async Worker env
22
+ * (`primeActivityKillSwitch()`, called at the middleware front door) and (b) the
23
+ * synchronously-available build/process env — so a build-time var takes effect in
24
+ * dev/tests immediately, and a Worker secret takes effect from the first primed
25
+ * request of each isolate.
26
+ */
27
+ import { readEnv } from "../env.js";
28
+
29
+ /** The one canonical flag name — shared verbatim by the server and the CLI mirror. */
30
+ export const ACTIVITY_KILL_SWITCH_FLAG = "ACTIVITY_TELEMETRY_DISABLED";
31
+
32
+ const TRUTHY = new Set(["1", "true", "yes", "on"]);
33
+
34
+ function truthy(v: string | null | undefined): boolean {
35
+ return typeof v === "string" && TRUTHY.has(v.trim().toLowerCase());
36
+ }
37
+
38
+ /** Primed once per isolate from the async Worker env. `undefined` until primed. */
39
+ let primedDisabled: boolean | undefined;
40
+
41
+ /** Synchronously-available env only (build-time `import.meta.env` + `process.env`) —
42
+ * does NOT see Worker-only secrets, which arrive via {@link primeActivityKillSwitch}. */
43
+ function fromSyncEnv(): boolean {
44
+ try {
45
+ const meta = (import.meta as unknown as { env?: Record<string, string | undefined> }).env;
46
+ if (meta && truthy(meta[ACTIVITY_KILL_SWITCH_FLAG])) return true;
47
+ } catch {
48
+ /* import.meta.env unavailable — ignore */
49
+ }
50
+ if (typeof process !== "undefined" && process.env && truthy(process.env[ACTIVITY_KILL_SWITCH_FLAG])) {
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+
56
+ /**
57
+ * True when the entire activity pipeline is switched off. Sync + never throws.
58
+ * Fail-open: an unknown/unset flag ⇒ `false` (telemetry enabled).
59
+ */
60
+ export function isActivityDisabled(): boolean {
61
+ return primedDisabled === true || fromSyncEnv();
62
+ }
63
+
64
+ /**
65
+ * Prime the kill switch from the (async) Worker env, once per isolate. Best-effort:
66
+ * a read fault leaves telemetry ENABLED (fail-open). Call at the middleware front door
67
+ * so every subsequent route emit in the isolate observes a Worker-secret flag.
68
+ */
69
+ export async function primeActivityKillSwitch(): Promise<void> {
70
+ try {
71
+ primedDisabled = truthy(await readEnv(ACTIVITY_KILL_SWITCH_FLAG));
72
+ } catch {
73
+ primedDisabled = false;
74
+ }
75
+ }
76
+
77
+ /** Test-only: clear the primed cache so a test can re-exercise the prime path. */
78
+ export function __resetActivityKillSwitchForTest(): void {
79
+ primedDisabled = undefined;
80
+ }