@tokenoftrust/storefront-runner 1.4.0 → 1.4.2-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/apps/storefront/astro.config.mjs +17 -1
  2. package/apps/storefront/integrations/materialize-guard.mjs +56 -0
  3. package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
  4. package/apps/storefront/migrations-apps/0002_good_switch.sql +26 -0
  5. package/apps/storefront/migrations-apps/0003_mute_marauders.sql +12 -0
  6. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  7. package/apps/storefront/migrations-apps/meta/0002_snapshot.json +1160 -0
  8. package/apps/storefront/migrations-apps/meta/0003_snapshot.json +1235 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +21 -0
  10. package/apps/storefront/package.json +0 -1
  11. package/apps/storefront/public/js/dashboard-team.js +38 -2
  12. package/apps/storefront/src/components/Seo.astro +65 -1
  13. package/apps/storefront/src/components/admin/AdminBlogDetailRail.astro +878 -0
  14. package/apps/storefront/src/components/admin/AdminBlogTab.astro +1432 -0
  15. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +3 -7
  16. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1756 -1382
  17. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +1 -3
  18. package/apps/storefront/src/components/blog/BlogPaywall.astro +87 -0
  19. package/apps/storefront/src/components/blog/PostAccessBadge.astro +43 -0
  20. package/apps/storefront/src/components/blog/PostGrid.astro +57 -0
  21. package/apps/storefront/src/components/blog/ReactionBar.astro +214 -0
  22. package/apps/storefront/src/layouts/Layout.astro +48 -3
  23. package/apps/storefront/src/lib/activity/alerts.ts +426 -0
  24. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  25. package/apps/storefront/src/lib/activity/deployVersion.ts +120 -0
  26. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  27. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  28. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  29. package/apps/storefront/src/lib/activity/query.ts +403 -0
  30. package/apps/storefront/src/lib/activity/recordActivity.ts +101 -0
  31. package/apps/storefront/src/lib/activity/store.ts +120 -0
  32. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  33. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  34. package/apps/storefront/src/lib/admin/adminShell.ts +290 -0
  35. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +2 -3
  36. package/apps/storefront/src/lib/admin/ai/audit.ts +3 -5
  37. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +7 -7
  38. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +3 -3
  39. package/apps/storefront/src/lib/admin/orderClient.ts +3 -5
  40. package/apps/storefront/src/lib/admin/services/orders.ts +17 -19
  41. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +4 -6
  42. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +1 -1
  43. package/apps/storefront/src/lib/apps/adminService.ts +4 -4
  44. package/apps/storefront/src/lib/apps/adminSession.ts +53 -44
  45. package/apps/storefront/src/lib/apps/apiAuth.ts +2 -2
  46. package/apps/storefront/src/lib/apps/apiRoute.ts +2 -2
  47. package/apps/storefront/src/lib/apps/catalogMapper.ts +1 -1
  48. package/apps/storefront/src/lib/apps/credentials.ts +2 -2
  49. package/apps/storefront/src/lib/apps/gatewayKeys.ts +4 -4
  50. package/apps/storefront/src/lib/apps/healthAggregate.ts +5 -5
  51. package/apps/storefront/src/lib/apps/orders/attributionService.ts +2 -2
  52. package/apps/storefront/src/lib/apps/orders/customerHash.ts +1 -1
  53. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +12 -12
  54. package/apps/storefront/src/lib/apps/orders/idempotency.ts +7 -7
  55. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +1 -1
  56. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +2 -2
  57. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +6 -6
  58. package/apps/storefront/src/lib/apps/registryService.ts +6 -6
  59. package/apps/storefront/src/lib/apps/scopes.ts +2 -2
  60. package/apps/storefront/src/lib/apps/tokenIssuer.ts +4 -4
  61. package/apps/storefront/src/lib/apps/tokenVerifier.ts +6 -6
  62. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +2 -2
  63. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +3 -3
  64. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +4 -4
  65. package/apps/storefront/src/lib/apps/widgets/placements.ts +2 -2
  66. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +1 -1
  67. package/apps/storefront/src/lib/auth/adminEntry.ts +9 -9
  68. package/apps/storefront/src/lib/auth/identityToken.ts +6 -14
  69. package/apps/storefront/src/lib/auth/loginGate.ts +75 -13
  70. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +6 -4
  71. package/apps/storefront/src/lib/auth/operatorSession.ts +113 -0
  72. package/apps/storefront/src/lib/auth/route.ts +44 -4
  73. package/apps/storefront/src/lib/auth/session.ts +7 -8
  74. package/apps/storefront/src/lib/auth/totAccessClient.ts +8 -18
  75. package/apps/storefront/src/lib/basePath.ts +9 -2
  76. package/apps/storefront/src/lib/blog/access.ts +29 -0
  77. package/apps/storefront/src/lib/blog/collection.ts +313 -0
  78. package/apps/storefront/src/lib/blog/markdown.ts +174 -0
  79. package/apps/storefront/src/lib/blog/provider.ts +5 -0
  80. package/apps/storefront/src/lib/blog/reactions.ts +87 -0
  81. package/apps/storefront/src/lib/blog/rss.ts +92 -0
  82. package/apps/storefront/src/lib/blog/teaser.ts +83 -0
  83. package/apps/storefront/src/lib/blog/types.ts +34 -0
  84. package/apps/storefront/src/lib/blog/viewCounts.ts +243 -0
  85. package/apps/storefront/src/lib/d1/catalog.ts +2 -2
  86. package/apps/storefront/src/lib/d1/schema-apps.ts +147 -14
  87. package/apps/storefront/src/lib/dev/activityIngestToken.ts +2 -4
  88. package/apps/storefront/src/lib/dev/activityStore.ts +3 -5
  89. package/apps/storefront/src/lib/dev/apiBase.ts +1 -4
  90. package/apps/storefront/src/lib/dev/cockpitStore.ts +1 -9
  91. package/apps/storefront/src/lib/dev/devDraft.ts +1 -1
  92. package/apps/storefront/src/lib/dev/previewStatus.ts +9 -21
  93. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +9 -24
  94. package/apps/storefront/src/lib/edgeCache.ts +17 -0
  95. package/apps/storefront/src/lib/env.ts +51 -0
  96. package/apps/storefront/src/lib/http/fetchTransport.ts +63 -0
  97. package/apps/storefront/src/lib/i18n.ts +25 -0
  98. package/apps/storefront/src/lib/jsonld.ts +118 -0
  99. package/apps/storefront/src/lib/monitoring/manifest.ts +11 -11
  100. package/apps/storefront/src/lib/newsletter/confirmationEmail.ts +30 -0
  101. package/apps/storefront/src/lib/newsletter/consent.ts +23 -0
  102. package/apps/storefront/src/lib/newsletter/digest.ts +117 -0
  103. package/apps/storefront/src/lib/newsletter/resendClient.ts +79 -0
  104. package/apps/storefront/src/lib/newsletter/send.ts +265 -0
  105. package/apps/storefront/src/lib/newsletter/subscribers.ts +156 -0
  106. package/apps/storefront/src/lib/publish/adminPublishTab.ts +3519 -0
  107. package/apps/storefront/src/lib/publish/apex-readiness.ts +41 -18
  108. package/apps/storefront/src/lib/publish/dispatchHealth.ts +5 -7
  109. package/apps/storefront/src/lib/publish/domainState.ts +22 -13
  110. package/apps/storefront/src/lib/publish/panelPolling.ts +78 -0
  111. package/apps/storefront/src/lib/publish/shipWorkspace.ts +100 -42
  112. package/apps/storefront/src/lib/publish/stageBundleUpload.ts +215 -0
  113. package/apps/storefront/src/lib/rate/kvWindow.ts +53 -0
  114. package/apps/storefront/src/lib/search/index.ts +2 -2
  115. package/apps/storefront/src/lib/seo/alternates.ts +42 -0
  116. package/apps/storefront/src/lib/seo/meta.ts +65 -0
  117. package/apps/storefront/src/lib/social/golive.ts +212 -0
  118. package/apps/storefront/src/lib/social/notify.ts +32 -0
  119. package/apps/storefront/src/lib/social/tokenStore.ts +171 -0
  120. package/apps/storefront/src/lib/the-build/provider.ts +27 -0
  121. package/apps/storefront/src/lib/the-build/readingTime.ts +35 -0
  122. package/apps/storefront/src/lib/the-build/toc.ts +95 -0
  123. package/apps/storefront/src/lib/tot/ToTClient.ts +1 -1
  124. package/apps/storefront/src/lib/tot/d1Client.ts +2 -2
  125. package/apps/storefront/src/lib/tot/totClientInterface.ts +3 -4
  126. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +16 -17
  127. package/apps/storefront/src/middleware/index.ts +130 -2
  128. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  129. package/apps/storefront/src/pages/admin.astro +52 -1206
  130. package/apps/storefront/src/pages/api/activity.ts +137 -0
  131. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  132. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
  133. package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
  134. package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
  135. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
  136. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
  137. package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
  138. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
  139. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
  141. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
  142. package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +104 -0
  143. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  144. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  145. package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +146 -0
  146. package/apps/storefront/src/pages/api/newsletter/confirm.ts +75 -0
  147. package/apps/storefront/src/pages/api/newsletter/unsubscribe.ts +90 -0
  148. package/apps/storefront/src/pages/api/publish/stage-bundle.ts +70 -0
  149. package/apps/storefront/src/pages/api/rum/vitals.ts +21 -2
  150. package/apps/storefront/src/pages/auth/login.astro +5 -4
  151. package/apps/storefront/src/pages/blog/[slug].astro +96 -16
  152. package/apps/storefront/src/pages/blog/author/[author].astro +140 -0
  153. package/apps/storefront/src/pages/blog/category/[category].astro +67 -0
  154. package/apps/storefront/src/pages/blog/index.astro +2 -0
  155. package/apps/storefront/src/pages/blog/tag/[tag].astro +65 -0
  156. package/apps/storefront/src/pages/cockpit.astro +74 -12
  157. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +15 -11
  158. package/apps/storefront/src/pages/search.astro +1 -1
  159. package/apps/storefront/src/pages/sitemap.xml.ts +20 -0
  160. package/apps/storefront/src/pages/the-build/[slug].astro +129 -0
  161. package/apps/storefront/src/pages/the-build/index.astro +146 -0
  162. package/apps/storefront/src/pages/the-build/rss.xml.ts +33 -0
  163. package/apps/storefront/src/styles/admin.css +1080 -0
  164. package/apps/storefront/tsconfig.json +5 -0
  165. package/package.json +1 -1
  166. package/packages/public-runtime/package.json +1 -0
  167. package/packages/public-runtime/src/activity/README.md +146 -0
  168. package/packages/public-runtime/src/activity/catalog.ts +553 -0
  169. package/packages/public-runtime/src/activity/event.ts +168 -0
  170. package/packages/public-runtime/src/activity/index.ts +16 -0
  171. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  172. package/packages/public-runtime/src/candidate-index.ts +13 -0
  173. package/packages/public-runtime/src/catalog-d1.ts +1 -1
  174. package/packages/public-runtime/src/customization-preview.ts +25 -2
  175. package/packages/public-runtime/src/customization-runtime.ts +125 -3
  176. package/packages/public-runtime/src/customization-versioning.ts +10 -0
  177. package/packages/public-runtime/src/index.ts +7 -0
  178. package/packages/public-runtime/src/static-bundle.ts +357 -0
  179. package/packages/public-runtime/src/tenant.ts +36 -8
  180. package/scripts/build/copy-tenant-assets.mjs +14 -4
  181. package/scripts/dev/ai-edit.mjs +1 -1
  182. package/scripts/dev/checkout-watch.mjs +2 -2
  183. package/scripts/dev/file-browser.mjs +2 -4
  184. package/scripts/dev/file-writer.mjs +1 -1
  185. package/scripts/dev/git-status.mjs +2 -2
  186. package/scripts/dev/locate-handler.mjs +3 -3
  187. package/scripts/dev/port-check.mjs +3 -3
  188. package/scripts/dev/publish.mjs +5 -9
  189. package/scripts/dev/shot.mjs +1 -1
  190. package/scripts/dev/unified-diff.mjs +8 -3
  191. package/scripts/tot-dev.mjs +6 -9
  192. package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
  193. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Ingest validation + server-side redaction for `POST /api/activity`.
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 (the CLI and UI
6
+ * cohorts — 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. 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 alerting reads. */
65
+ canaryTripped: boolean;
66
+ /** The redaction hits (fact-of, never the value) for 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 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. 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` — 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.
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()` (the server emitter) — and therefore `recordUiActivity()`
7
+ * (the UI cohort), which is a thin wrapper over it;
8
+ * • `observeDeployVersion()` (the deploy-provenance observer);
9
+ * • `POST /api/activity` (ingest) — accepts-and-drops so emitters never error.
10
+ * The out-of-process CLI (`emitActivity`) 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
+ }