@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
@@ -3,20 +3,12 @@
3
3
  * cockpit page, its /api/dev/* routes, and the CLI sign-in-code mint all share, so
4
4
  * they never disagree about the developer's store.
5
5
  *
6
- * REGRESSION FIXED (8500 <tenant>/cockpit): the page and the mint used to derive
7
- * the store purely from the session's FIRST membership
8
- * (`session.tenants?.[0]?.resource ?? session.resource`), ignoring the store the
9
- * request URL actually named. An invite to `/<tenantB>/cockpit` therefore rendered —
10
- * and minted a `tot login --code … && tot start` for — whatever store happened to be
11
- * listed first in the session, "losing track of the store" for any developer who
12
- * belongs to more than one.
13
- *
14
6
  * Rule: the URL wins WHEN THE VIEWER CAN ACCESS IT. The middleware has already
15
7
  * resolved the request's host/path to `locals.tenant` (the trusted, authenticated
16
8
  * store seam — never client body input), so when that is a SPECIFIC store (not the
17
9
  * public marketing home) and the signed-in developer holds a membership for it (or a
18
10
  * live staff admission), that store is authoritative. Otherwise we fall back to the
19
- * session's own first store (the generic-host `/cockpit` case, unchanged), and for an
11
+ * session's own first store (the generic-host `/cockpit` case), and for an
20
12
  * anonymous viewer to the path store's branding, else the caller-supplied `?tenant=`.
21
13
  *
22
14
  * Access is gated with the SAME primitive (`viewerCanAccess`) the vendor dashboard
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Per-developer DRAFT overlay for the /dev live preview — the signed token + KV key
3
- * contract behind decision `draft-overlay-same-origin` (workstream 8500-dev-live-preview).
3
+ * contract for the same-origin draft overlay.
4
4
  *
5
5
  * WHAT A DRAFT IS: when the /dev page's onscreen AI loop edits the developer's site
6
6
  * (`/api/content-edit/ai-edit`), the edited artifacts land in TENANT_CACHE KV under
@@ -1,41 +1,29 @@
1
1
  /**
2
2
  * Read whether a tenant appDomain's store is SET UP — its forge repo is provisioned —
3
3
  * from the credential-free MCP preview-status seam (tot-mcp
4
- * GET /internal/preview-status; legacy alias /internal/dev-access/preview-status). The
4
+ * GET /internal/preview-status; alias /internal/dev-access/preview-status). The
5
5
  * seam answers `{ tenant, app, repo, provisioned, previewReleased }`.
6
6
  *
7
- * This is the cockpit's own small reader self-contained, mirroring `cliSignInCode.ts`
8
- * (same MCP-origin + service-secret seam) rather than importing another cluster's copy,
9
- * so the two stay decoupled (the same reason `cliSignInCode` duplicates its transport).
10
- * It gates the CLI sign-in-code mint + the /cockpit setup fastpath so a developer whose
7
+ * This is the cockpit's own small reader over the same MCP-origin + service-secret seam
8
+ * `cliSignInCode.ts` uses. It gates the CLI sign-in-code mint + the /cockpit setup
9
+ * fastpath so a developer whose
11
10
  * store isn't provisioned yet gets an honest "not set up" state instead of a
12
11
  * `tot login --code … && tot start` command that would die at checkout on
13
- * "Repo <owner>-main is not provisioned" (James's 2026-07-19 first-run failure).
12
+ * "Repo <owner>-main is not provisioned".
14
13
  *
15
14
  * - MCP host ← the ORIGIN of MCP_APP_BINDING_URL (one source, so this read hits the
16
15
  * SAME MCP tier the sign-in-code mint does).
17
16
  * - auth ← header x-mcp-service-secret: MCP_SERVICE_SHARED_SECRET.
18
17
  *
19
18
  * Fail-closed but non-blocking by design: an UNCONFIGURED seam (no URL/secret) →
20
- * { configured:false } so callers keep today's behavior (no regression); any non-2xx /
19
+ * { configured:false } so callers keep the default behavior; any non-2xx /
21
20
  * transport / parse fault → { configured:true, ok:false } so a caller can distinguish
22
21
  * "definitely not provisioned" (ok + provisioned:false) from "couldn't tell". Never throws.
23
22
  */
24
23
  import { readEnv } from "@/lib/env";
24
+ import { FetchHttpTransport, type HttpGetTransport } from "@/lib/http/fetchTransport";
25
25
 
26
- export interface HttpGetTransport {
27
- get(
28
- url: string,
29
- headers: Record<string, string>,
30
- ): Promise<{ status: number; json(): Promise<unknown> }>;
31
- }
32
-
33
- export class FetchGetTransport implements HttpGetTransport {
34
- async get(url: string, headers: Record<string, string>) {
35
- const res = await fetch(url, { method: "GET", headers });
36
- return { status: res.status, json: () => res.json() };
37
- }
38
- }
26
+ export type { HttpGetTransport };
39
27
 
40
28
  export interface TenantPreviewStatus {
41
29
  /** False when the seam isn't wired on this deploy (no URL/secret) — gate inactive. */
@@ -68,7 +56,7 @@ export async function readTenantPreviewStatus(
68
56
  return { configured: false, ok: false, reason: "bad-mcp-url" };
69
57
  }
70
58
 
71
- const transport = deps.transport ?? new FetchGetTransport();
59
+ const transport = deps.transport ?? new FetchHttpTransport();
72
60
  const full = `${endpoint}?tenant=${encodeURIComponent(tenant.toLowerCase())}`;
73
61
  let res: { status: number; json(): Promise<unknown> };
74
62
  try {
@@ -21,20 +21,14 @@
21
21
  */
22
22
  import { readEnv } from "@/lib/env";
23
23
  import { mintMcpClientAssertion, McpClientAssertionError } from "@/lib/auth/mcpClientAssertion";
24
+ import { FetchHttpTransport, type HttpTransport } from "@/lib/http/fetchTransport";
25
+
26
+ export type { HttpTransport };
24
27
 
25
28
  const JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
26
29
  /** The path whose audience the MCP binds EVERY rendezvous assertion to. */
27
30
  const RENDEZVOUS_AUDIENCE_PATH = "/oauth/device/rendezvous";
28
31
 
29
- /** Injectable transport so the broker calls unit-test with no network. */
30
- export interface HttpTransport {
31
- post(
32
- url: string,
33
- headers: Record<string, string>,
34
- body: unknown,
35
- ): Promise<{ status: number; json(): Promise<unknown> }>;
36
- }
37
-
38
32
  // Bound every rendezvous call to the MCP. The MCP runs on AppRunner (scale-to-zero),
39
33
  // so the FIRST call after idle hits a cold instance that can take many seconds to
40
34
  // answer. Without a bound the storefront Worker's subrequest hangs and the browser's
@@ -44,20 +38,6 @@ export interface HttpTransport {
44
38
  // 502 ("having a moment — try again"); the retry lands on a now-warm MCP and succeeds.
45
39
  const RENDEZVOUS_FETCH_TIMEOUT_MS = 12_000;
46
40
 
47
- export class FetchHttpTransport implements HttpTransport {
48
- async post(url: string, headers: Record<string, string>, body: unknown) {
49
- const res = await fetch(url, {
50
- method: "POST",
51
- headers: { "Content-Type": "application/json", ...headers },
52
- body: JSON.stringify(body),
53
- // A timeout/abort rejects the fetch → callRendezvous's catch → a 502 the cockpit
54
- // renders as a retryable error, instead of a hung request the browser gives up on.
55
- signal: AbortSignal.timeout(RENDEZVOUS_FETCH_TIMEOUT_MS),
56
- });
57
- return { status: res.status, json: () => res.json() };
58
- }
59
- }
60
-
61
41
  export interface RendezvousDeps {
62
42
  transport?: HttpTransport;
63
43
  /** Override the assertion minter in tests (defaults to the real private-key signer). */
@@ -97,7 +77,12 @@ async function callRendezvous(
97
77
  ): Promise<{ status: number; body: Record<string, unknown> }> {
98
78
  const mint = deps.mintAssertion ?? ((aud: string) => mintMcpClientAssertion(aud));
99
79
  const clientAssertion = await mint(target.audience);
100
- const transport = deps.transport ?? new FetchHttpTransport();
80
+ const transport =
81
+ deps.transport ??
82
+ new FetchHttpTransport({
83
+ defaultHeaders: { "Content-Type": "application/json" },
84
+ timeoutMs: RENDEZVOUS_FETCH_TIMEOUT_MS,
85
+ });
101
86
  const res = await transport.post(
102
87
  `${target.origin}${path}`,
103
88
  {},
@@ -66,6 +66,23 @@ export function pageCacheKey(
66
66
  return new Request(`${CACHE_ORIGIN}/${tenantId}/v${version}${pathname}${search}`);
67
67
  }
68
68
 
69
+ /**
70
+ * Cache key for a digest-addressed static-bundle response (T2, ADR 0012).
71
+ * Keyed by `treeDigest` instead of the integer `cachever` `pageCacheKey` uses —
72
+ * a pointer flip to a NEW digest is a different key, so it is a clean cache
73
+ * miss (never a stale serve of the old digest); an unchanged digest keeps
74
+ * serving its warm entry across requests. Shares `getCachedPage`/
75
+ * `putCachedPage` with the page cache (both just take a Request key + Cache).
76
+ */
77
+ export function staticBundleCacheKey(
78
+ tenantId: string,
79
+ digest: string,
80
+ pathname: string,
81
+ search: string,
82
+ ): Request {
83
+ return new Request(`${CACHE_ORIGIN}/${tenantId}/digest-${digest}${pathname}${search}`);
84
+ }
85
+
69
86
  /** Current cache version for a tenant (defaults to 1). KV read is edge-cached. */
70
87
  export async function readCacheVersion(
71
88
  kv: KVNamespace,
@@ -40,6 +40,44 @@ export async function readEnv(key: string): Promise<string | undefined> {
40
40
  return fromBuild(key);
41
41
  }
42
42
 
43
+ /** The slice of the Workers ExecutionContext runAfterResponse needs — the
44
+ * @astrojs/cloudflare adapter exposes it to routes as `locals.cfContext`. */
45
+ export interface CfExecutionContextLike {
46
+ waitUntil(promise: Promise<unknown>): void;
47
+ }
48
+
49
+ /**
50
+ * Run `work` PAST the response — the request's `ExecutionContext.waitUntil`
51
+ * when the caller passes it, else a detached floating promise (dev/Node). For
52
+ * webhook RECEIVERS that must ACK fast: the upstream forwarder (tot-mcp
53
+ * gitea-webhook proxy) aborts its forward at 15s and Gitea bounds its delivery
54
+ * wait too, while a real reconcile takes tens of seconds — running it inside
55
+ * the request meant every healthy reconcile was killed mid-flight and recorded
56
+ * as `forward_failed` (a layer of Trello 13075, behind the auth fix).
57
+ *
58
+ * CALLERS MUST PASS `locals.cfContext` (the @astrojs/cloudflare adapter's
59
+ * per-request ExecutionContext, `createLocals: { cfContext: ctx }`): in the
60
+ * Workers runtime a merely-floating promise is CANCELLED the moment the
61
+ * response returns — an earlier revision here imported `waitUntil` from
62
+ * `cloudflare:workers` (not exported by this adapter/runtime version), silently
63
+ * degraded to the floating promise, and the async reconcile died unobserved
64
+ * right after its 202. The floating-promise branch is a DEV-ONLY fallback.
65
+ * Errors are the caller's to handle inside `work` (this never throws).
66
+ */
67
+ export function runAfterResponse(
68
+ work: Promise<unknown>,
69
+ cfContext?: CfExecutionContextLike | null,
70
+ ): void {
71
+ const swallowed = work.catch(() => {});
72
+ if (cfContext && typeof cfContext.waitUntil === "function") {
73
+ cfContext.waitUntil(swallowed);
74
+ return;
75
+ }
76
+ // dev/Node only — in the Workers runtime this promise would be cancelled at
77
+ // response end, which is exactly the bug the parameter above exists to avoid.
78
+ void swallowed;
79
+ }
80
+
43
81
  /** KV binding for the newsletter stub / tenant cache, if present in the runtime. */
44
82
  export async function readKv(binding: string): Promise<KVNamespace | undefined> {
45
83
  const w = await getWorkerEnv();
@@ -59,6 +97,19 @@ export async function readR2(binding: string): Promise<R2Bucket | undefined> {
59
97
  return (w?.[binding] as R2Bucket | undefined) ?? undefined;
60
98
  }
61
99
 
100
+ /**
101
+ * Cloudflare Analytics Engine dataset binding (e.g. ACTIVITY_ANALYTICS), if
102
+ * present. Untyped (`unknown`) deliberately — the caller casts to its own
103
+ * minimal `writeDataPoint` shape (see `activity/store.ts`'s
104
+ * `AnalyticsEngineLike`) rather than this module taking a types dep. Absent in
105
+ * dev/Node and until the binding is provisioned; callers treat undefined as
106
+ * "analytics sink unavailable, skip it" (never an error).
107
+ */
108
+ export async function readAnalytics(binding: string): Promise<unknown | undefined> {
109
+ const w = await getWorkerEnv();
110
+ return w?.[binding] ?? undefined;
111
+ }
112
+
62
113
  /**
63
114
  * Cloudflare Queue producer binding (e.g. WEBHOOK_DELIVERY_QUEUE), if present.
64
115
  * Untyped (`unknown`) deliberately — callers cast to their own minimal
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The one shared `fetch`-backed HTTP transport seam used by the storefront's
3
+ * server-to-server clients. A transport is injectable so those clients unit-test
4
+ * with no network; this is the production implementation they default to.
5
+ *
6
+ * Both `post` and `get` return the minimal `{ status, json }` shape the callers
7
+ * read — never the raw `Response` — so a test stub and the real transport are
8
+ * interchangeable. `defaultHeaders` are merged under each call's own headers
9
+ * (per-call wins); `timeoutMs`, when set, aborts each request via
10
+ * `AbortSignal.timeout` so a hung subrequest becomes a fast, retryable failure.
11
+ */
12
+ export interface HttpResponseLike {
13
+ status: number;
14
+ json(): Promise<unknown>;
15
+ }
16
+
17
+ export interface HttpTransport {
18
+ post(url: string, headers: Record<string, string>, body: unknown): Promise<HttpResponseLike>;
19
+ }
20
+
21
+ export interface HttpGetTransport {
22
+ get(url: string, headers: Record<string, string>): Promise<HttpResponseLike>;
23
+ }
24
+
25
+ export interface FetchHttpTransportOptions {
26
+ /** Merged UNDER each call's headers (a per-call header of the same name wins). */
27
+ defaultHeaders?: Record<string, string>;
28
+ /** When set, every request aborts after this many ms via `AbortSignal.timeout`. */
29
+ timeoutMs?: number;
30
+ }
31
+
32
+ export class FetchHttpTransport implements HttpTransport, HttpGetTransport {
33
+ private readonly defaultHeaders: Record<string, string>;
34
+ private readonly timeoutMs?: number;
35
+
36
+ constructor(options: FetchHttpTransportOptions = {}) {
37
+ this.defaultHeaders = options.defaultHeaders ?? {};
38
+ this.timeoutMs = options.timeoutMs;
39
+ }
40
+
41
+ private signal(): AbortSignal | undefined {
42
+ return this.timeoutMs != null ? AbortSignal.timeout(this.timeoutMs) : undefined;
43
+ }
44
+
45
+ async post(url: string, headers: Record<string, string>, body: unknown): Promise<HttpResponseLike> {
46
+ const res = await fetch(url, {
47
+ method: "POST",
48
+ headers: { ...this.defaultHeaders, ...headers },
49
+ body: JSON.stringify(body),
50
+ signal: this.signal(),
51
+ });
52
+ return { status: res.status, json: () => res.json() };
53
+ }
54
+
55
+ async get(url: string, headers: Record<string, string>): Promise<HttpResponseLike> {
56
+ const res = await fetch(url, {
57
+ method: "GET",
58
+ headers: { ...this.defaultHeaders, ...headers },
59
+ signal: this.signal(),
60
+ });
61
+ return { status: res.status, json: () => res.json() };
62
+ }
63
+ }
@@ -0,0 +1,25 @@
1
+ // Human-readable, in-language display names for the reader language switcher,
2
+ // keyed by BCP-47 primary subtag. Unmapped codes fall back to the uppercased code.
3
+ const LANGUAGE_LABELS: Record<string, string> = {
4
+ en: "English",
5
+ es: "Español",
6
+ fr: "Français",
7
+ de: "Deutsch",
8
+ it: "Italiano",
9
+ pt: "Português",
10
+ nl: "Nederlands",
11
+ sv: "Svenska",
12
+ pl: "Polski",
13
+ ru: "Русский",
14
+ ja: "日本語",
15
+ ko: "한국어",
16
+ zh: "中文",
17
+ ar: "العربية",
18
+ hi: "हिन्दी",
19
+ tr: "Türkçe",
20
+ };
21
+
22
+ export function languageLabel(code: string): string {
23
+ const primary = code.toLowerCase().split(/[-_]/)[0] ?? code;
24
+ return LANGUAGE_LABELS[primary] ?? code.toUpperCase();
25
+ }
@@ -81,6 +81,124 @@ export function breadcrumbLd(
81
81
  };
82
82
  }
83
83
 
84
+ /** Words in rendered HTML — tags/entities stripped. Feeds `BlogPosting.wordCount`. */
85
+ export function wordCount(html: string): number {
86
+ const text = html
87
+ .replace(/<[^>]+>/g, " ")
88
+ .replace(/&[a-z#0-9]+;/gi, " ");
89
+ return text.match(/\S+/g)?.length ?? 0;
90
+ }
91
+
92
+ /** The subset of a blog post `blogPostingLd` reads — kept structural so the
93
+ * builder doesn't couple `jsonld.ts` to the blog module. */
94
+ export interface BlogPostingInput {
95
+ title: string;
96
+ description: string;
97
+ publishedAt: string;
98
+ modifiedAt: string;
99
+ author: string;
100
+ categories: string[];
101
+ tags: string[];
102
+ contentHtml: string;
103
+ inLanguage?: string;
104
+ featuredImage?: { src: string; alt?: string; width?: number; height?: number };
105
+ }
106
+
107
+ /**
108
+ * Enriched `BlogPosting` graph (B1.6): adds `wordCount`, `articleSection`,
109
+ * `keywords`, `inLanguage` (default "en"), an `author.url`, and an `ImageObject`
110
+ * `image` with dimensions — on top of the baseline
111
+ * headline/description/dates/publisher.
112
+ */
113
+ export function blogPostingLd(
114
+ post: BlogPostingInput,
115
+ opts: { url: string; canonicalBase: string; publisherName: string; authorUrl?: string },
116
+ ) {
117
+ const { url, canonicalBase, publisherName, authorUrl } = opts;
118
+ const image = post.featuredImage
119
+ ? {
120
+ "@type": "ImageObject",
121
+ url: new URL(post.featuredImage.src, canonicalBase + "/").toString(),
122
+ ...(post.featuredImage.alt ? { caption: post.featuredImage.alt } : {}),
123
+ ...(post.featuredImage.width ? { width: post.featuredImage.width } : {}),
124
+ ...(post.featuredImage.height ? { height: post.featuredImage.height } : {}),
125
+ }
126
+ : undefined;
127
+ return {
128
+ "@context": "https://schema.org",
129
+ "@type": "BlogPosting",
130
+ headline: post.title,
131
+ description: post.description,
132
+ url,
133
+ mainEntityOfPage: url,
134
+ datePublished: post.publishedAt,
135
+ dateModified: post.modifiedAt,
136
+ author: {
137
+ "@type": "Person",
138
+ name: post.author,
139
+ ...(authorUrl ? { url: authorUrl } : {}),
140
+ },
141
+ publisher: { "@type": "Organization", name: publisherName, url: canonicalBase },
142
+ wordCount: wordCount(post.contentHtml),
143
+ ...(post.categories[0] ? { articleSection: post.categories[0] } : {}),
144
+ ...(post.tags.length ? { keywords: post.tags.join(", ") } : {}),
145
+ inLanguage: post.inLanguage ?? "en",
146
+ ...(image ? { image } : {}),
147
+ };
148
+ }
149
+
150
+ /**
151
+ * `CollectionPage` + embedded `ItemList` for a category/tag/author archive
152
+ * (B1.2/B1.3 opt in — this composes with them without requiring their routes
153
+ * exist yet). `items` are already absolute URLs.
154
+ */
155
+ export function collectionPageLd(opts: {
156
+ name: string;
157
+ url: string;
158
+ description?: string;
159
+ items: { title: string; url: string }[];
160
+ }) {
161
+ return {
162
+ "@context": "https://schema.org",
163
+ "@type": "CollectionPage",
164
+ name: opts.name,
165
+ url: opts.url,
166
+ ...(opts.description ? { description: opts.description } : {}),
167
+ mainEntity: {
168
+ "@type": "ItemList",
169
+ numberOfItems: opts.items.length,
170
+ itemListElement: opts.items.map((it, i) => ({
171
+ "@type": "ListItem",
172
+ position: i + 1,
173
+ name: it.title,
174
+ url: it.url,
175
+ })),
176
+ },
177
+ };
178
+ }
179
+
180
+ /**
181
+ * `Person` graph for an author page (B1.3) — the E-E-A-T signal Google weights
182
+ * for author authority. `sameAs` carries the author's social-profile URLs.
183
+ */
184
+ export function personLd(opts: {
185
+ name: string;
186
+ url?: string;
187
+ description?: string;
188
+ sameAs?: string[];
189
+ image?: string;
190
+ }) {
191
+ return {
192
+ "@context": "https://schema.org",
193
+ "@type": "Person",
194
+ name: opts.name,
195
+ ...(opts.url ? { url: opts.url } : {}),
196
+ ...(opts.description ? { description: opts.description } : {}),
197
+ ...(opts.sameAs && opts.sameAs.length ? { sameAs: opts.sameAs } : {}),
198
+ ...(opts.image ? { image: opts.image } : {}),
199
+ };
200
+ }
201
+
84
202
  export function productLd(
85
203
  p: CatalogProduct,
86
204
  tenant: TenantConfig,
@@ -1,21 +1,21 @@
1
1
  /**
2
- * Monitoring contract (spec §F16 — monitors, alerts, operator runbooks).
2
+ * Monitoring contract (monitors, alerts, operator runbooks).
3
3
  *
4
4
  * This is the CHECKED-IN source of truth for "what do we watch, at what
5
5
  * threshold, at what severity, and which runbook fires when it trips." It is
6
- * deliberately data, not wiring: Phase 1 ships the *contract* so operators can
6
+ * deliberately data, not wiring: this file ships the *contract* so operators can
7
7
  * see coverage and reviewers can diff it; the actual alert providers (Cloudflare
8
8
  * Health Checks / Workers metrics / Logpush alerts / a RUM sink) are provisioned
9
- * out-of-band and each monitor records whether it is `in-code` today or still
9
+ * out-of-band and each monitor records whether it is `in-code` or still
10
10
  * `needs-external` / `needs-secret`.
11
11
  *
12
12
  * Signal sources are named against real code where it exists in this repo
13
13
  * (`/health` → lib/health.ts, CSP → pages/api/csp-report.ts + CSP_REPORT_PATH,
14
14
  * commerce events → lib/analytics). Signals owned by sibling units are referenced
15
- * by CONTRACT NAME, not imported — Core Web Vitals come from F14's RUM beacon,
16
- * and age-verification / restricted-state denials come from F12's compliance
17
- * evidence signals. This file does not edit or depend on those units; when they
18
- * land, point the corresponding monitor's `observe` at the real sink.
15
+ * by CONTRACT NAME, not imported — Core Web Vitals come from the RUM beacon,
16
+ * and age-verification / restricted-state denials come from the compliance
17
+ * evidence signals. This file does not edit or depend on those units; point the
18
+ * corresponding monitor's `observe` at the real sink where one exists.
19
19
  *
20
20
  * The companion operator doc renders this for humans and lists the external
21
21
  * wiring/secrets checklist: docs/epics/sample-store-migration/monitoring-and-runbooks.md.
@@ -89,9 +89,9 @@ export interface Monitor {
89
89
  }
90
90
 
91
91
  /**
92
- * The ten launch-critical monitors from spec §F16. Order is presentation order.
93
- * Thresholds are the go-live defaults; tune against the 24h post-cutover soak
94
- * (T10) and record any change here — this file is the audit trail.
92
+ * The ten launch-critical monitors. Order is presentation order.
93
+ * Thresholds are defaults; tune against the 24h post-cutover soak
94
+ * and record any change here — this file is the audit trail.
95
95
  */
96
96
  export const MONITORS: readonly Monitor[] = [
97
97
  {
@@ -264,7 +264,7 @@ export const MONITORS: readonly Monitor[] = [
264
264
  ] as const;
265
265
 
266
266
  /**
267
- * The monitors §F16 requires by name. The test asserts MONITORS covers exactly
267
+ * The monitors this contract requires by name. The test asserts MONITORS covers exactly
268
268
  * these — so dropping or misnaming one fails CI, and adding a new monitor is a
269
269
  * deliberate edit here.
270
270
  */
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Double-opt-in confirmation mail — STUB (N2).
3
+ *
4
+ * The storefront Worker sends no email itself: every existing "email" flow is
5
+ * delegated over HTTP to tot20, and there is no bulk/marketing sender in-repo
6
+ * (docs/plans/2026-08-22-newsletter-send-spike.md §1). Wiring the real send is
7
+ * the ESP-pipeline unit N4's job — it replaces the body of this function with a
8
+ * transactional send (confirm link in the body). Until then we only LOG intent,
9
+ * and never let a send failure fail the subscribe (the pending row + returned
10
+ * token are the durable state; the mail is a follow-up).
11
+ */
12
+
13
+ export interface ConfirmationEmailRequest {
14
+ email: string;
15
+ collection: string;
16
+ /** Absolute confirm URL the recipient clicks: `…/api/newsletter/confirm?token=…`. */
17
+ confirmUrl: string;
18
+ }
19
+
20
+ /**
21
+ * N4 REPLACES THIS with a real ESP send. Today: log the intent and return.
22
+ * Best-effort by contract — callers do not await deliverability, and this never
23
+ * throws.
24
+ */
25
+ export async function sendConfirmationEmail(req: ConfirmationEmailRequest): Promise<void> {
26
+ console.log(
27
+ `[newsletter] confirmation email STUB — would send to ${req.email} ` +
28
+ `(collection: ${req.collection}) confirm: ${req.confirmUrl}`,
29
+ );
30
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Consent-record primitives for the newsletter double-opt-in flow (N2).
3
+ *
4
+ * `randomToken` mints the opaque per-subscriber secret that both the confirm and
5
+ * (N3) unsubscribe links carry — Web Crypto only (no `node:*`) so it runs
6
+ * identically in the Worker and in tests. `hashConsentIp` is the GDPR-safe IP
7
+ * record: we store `sha256(ip + ":" + salt)`, NEVER the raw IP (repo convention:
8
+ * opaque hashes only, cf. `apps/orders/customerHash.ts`).
9
+ */
10
+ import { sha256Hex } from "@tot/public-runtime";
11
+
12
+ /** 32 bytes of CSPRNG entropy, hex-encoded — an opaque, unguessable link token. */
13
+ export function randomToken(): string {
14
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
15
+ let hex = "";
16
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
17
+ return hex;
18
+ }
19
+
20
+ /** Salted one-way hash of the client IP — the consent record's IP evidence. */
21
+ export function hashConsentIp(ip: string, salt: string): Promise<string> {
22
+ return sha256Hex(`${ip}:${salt}`);
23
+ }