@tokenoftrust/storefront-runner 1.3.4-rc.4 → 1.4.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 (224) 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/meta/0000_snapshot.json +843 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
  10. package/apps/storefront/package.json +13 -1
  11. package/apps/storefront/perf/README.md +64 -0
  12. package/apps/storefront/perf/assert-budgets.ts +159 -0
  13. package/apps/storefront/playwright.config.ts +23 -0
  14. package/apps/storefront/public/js/dashboard-apps.js +173 -0
  15. package/apps/storefront/public/shared/commerce-marketing.css +221 -0
  16. package/apps/storefront/src/components/CollectionCard.astro +1 -0
  17. package/apps/storefront/src/components/ProductCard.astro +1 -0
  18. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
  19. package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
  20. package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
  21. package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
  22. package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
  23. package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
  24. package/apps/storefront/src/components/content/Callout.astro +75 -0
  25. package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
  26. package/apps/storefront/src/components/content/ProseSections.astro +121 -0
  27. package/apps/storefront/src/components/content/ProseToc.astro +34 -0
  28. package/apps/storefront/src/components/content/RichText.astro +44 -0
  29. package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
  30. package/apps/storefront/src/components/home/Hero.astro +14 -0
  31. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
  32. package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
  33. package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
  34. package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
  35. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
  36. package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
  37. package/apps/storefront/src/config/storeName.ts +34 -0
  38. package/apps/storefront/src/layouts/Layout.astro +97 -23
  39. package/apps/storefront/src/lib/analytics/budgets.json +69 -0
  40. package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
  41. package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
  42. package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
  43. package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
  44. package/apps/storefront/src/lib/apps/adminService.ts +106 -0
  45. package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
  46. package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
  47. package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
  48. package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
  49. package/apps/storefront/src/lib/apps/credentials.ts +153 -0
  50. package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
  51. package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
  52. package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
  53. package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
  54. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
  55. package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
  56. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
  57. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
  58. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
  59. package/apps/storefront/src/lib/apps/registryService.ts +579 -0
  60. package/apps/storefront/src/lib/apps/scopes.ts +79 -0
  61. package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
  62. package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
  63. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
  64. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
  65. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
  66. package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
  67. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
  68. package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
  69. package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
  70. package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
  71. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
  72. package/apps/storefront/src/lib/auth/route.ts +16 -1
  73. package/apps/storefront/src/lib/auth/session.ts +8 -0
  74. package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
  75. package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
  76. package/apps/storefront/src/lib/blog/provider.ts +39 -0
  77. package/apps/storefront/src/lib/blog/types.ts +26 -0
  78. package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
  79. package/apps/storefront/src/lib/chrome/model.ts +11 -0
  80. package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
  81. package/apps/storefront/src/lib/content/callout.ts +78 -0
  82. package/apps/storefront/src/lib/content/index.ts +27 -0
  83. package/apps/storefront/src/lib/content/needsReview.ts +58 -0
  84. package/apps/storefront/src/lib/content/prose.ts +186 -0
  85. package/apps/storefront/src/lib/content/richtext.ts +76 -0
  86. package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
  87. package/apps/storefront/src/lib/content-edit/client.ts +70 -14
  88. package/apps/storefront/src/lib/d1/catalog.ts +12 -0
  89. package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
  90. package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
  91. package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
  92. package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
  93. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  94. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  95. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  96. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  97. package/apps/storefront/src/lib/env.ts +12 -0
  98. package/apps/storefront/src/lib/jsonld.ts +12 -17
  99. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  100. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  101. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  102. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  103. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  104. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  105. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  106. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  107. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  108. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  109. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  110. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  111. package/apps/storefront/src/lib/tot/query.ts +32 -0
  112. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  113. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  116. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  117. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  118. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  119. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  120. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  121. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  122. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  123. package/apps/storefront/src/middleware/index.ts +28 -13
  124. package/apps/storefront/src/pages/404.astro +21 -9
  125. package/apps/storefront/src/pages/[...slug].astro +36 -2
  126. package/apps/storefront/src/pages/admin.astro +29 -3
  127. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  128. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  129. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  130. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  131. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  132. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  133. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  134. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  135. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  137. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  138. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  139. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  140. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  142. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  143. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  144. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  146. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  147. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  149. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  150. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  151. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  152. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  153. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  154. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  155. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  156. package/apps/storefront/src/pages/auth/login.astro +197 -41
  157. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  158. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  159. package/apps/storefront/src/pages/blog/index.astro +98 -0
  160. package/apps/storefront/src/pages/capabilities.astro +8 -0
  161. package/apps/storefront/src/pages/cockpit.astro +433 -69
  162. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  163. package/apps/storefront/src/pages/collections/index.astro +10 -2
  164. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  166. package/apps/storefront/src/pages/index.astro +57 -0
  167. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  168. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  169. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  170. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  171. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  175. package/apps/storefront/src/styles/fonts.css +54 -0
  176. package/apps/storefront/src/styles/global.css +22 -2
  177. package/apps/storefront/src/themes/schema.ts +3 -25
  178. package/apps/storefront/tsconfig.json +1 -1
  179. package/apps/storefront/vitest.config.ts +4 -1
  180. package/package.json +1 -1
  181. package/packages/public-runtime/src/candidate-index.ts +311 -0
  182. package/packages/public-runtime/src/checkout.ts +94 -2
  183. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  184. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  185. package/packages/public-runtime/src/compliance/index.ts +22 -0
  186. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  187. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  188. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  189. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  190. package/packages/public-runtime/src/csp.ts +27 -3
  191. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  192. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  193. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  194. package/packages/public-runtime/src/extension-contract.ts +2 -1
  195. package/packages/public-runtime/src/hash.ts +25 -0
  196. package/packages/public-runtime/src/index.ts +6 -0
  197. package/packages/public-runtime/src/membership.ts +353 -0
  198. package/packages/public-runtime/src/product.ts +24 -2
  199. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  200. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  201. package/packages/public-runtime/src/tenant.ts +236 -0
  202. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  203. package/scripts/dev/publish.mjs +158 -0
  204. package/scripts/dev/transient-files.mjs +2 -1
  205. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  206. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  223. package/tenants/home/public/pages/storefront.css +23 -0
  224. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Ship operations workspace (unit dg7) — the PURE composer behind the admin
3
+ * Publish tab's operations surface that sits ALONGSIDE the go-live core
4
+ * (AdminPublishTab.astro's change queue + accept/reject + dispatch status).
5
+ *
6
+ * dg7 is DELTA-scoped: the go-live session already delivered the Publish-tab
7
+ * core (the change queue from GET /api/changes, Go-live/Reject, the live-version
8
+ * + dispatch-status panel, and the owner-only Domain section). This module adds
9
+ * the operations workspace BEYOND that core — the three read/act surfaces woven
10
+ * into the same tab:
11
+ *
12
+ * 1. DIFF VIEW — for one pending candidate, the artifact-level diff of what
13
+ * changed (its rendered immutable version vs the current live version),
14
+ * tied to the exact tenant Gitea head SHA, with the u5 immutable
15
+ * `/preview/<tenant>/rev/<sha>/` link and the dg3 evidence AUTHORITY
16
+ * (authoritative / stale / missing) — never a diff a reviewer would mistake
17
+ * for the head when a last-good version actually renders.
18
+ * 2. VERSIONS — the tenant's published-version / promotion history
19
+ * (`listPromotions`, u2/u3 promotion_status), each row carrying the tenant
20
+ * Gitea SHA (`gitea-only-tenant-content-authority`), its promotion time, a
21
+ * `/rev/<sha>/` link, and — marking the current live one.
22
+ * 3. ROLLBACK — pick a prior version and roll back THROUGH the one orchestrator
23
+ * (`/api/changes/rollback` → `rollbackCandidateAtomic` / u8's
24
+ * `staticRollbackOrchestrator`), rendering only the HONEST terminal state
25
+ * (`rolled_back` when VERIFIED; `publish_pending` in flight; `publish_failed`
26
+ * / `blocked` fail-closed) — never a false "rolled back".
27
+ *
28
+ * COMPOSES, never rebuilds:
29
+ * - the u5 immutable link is `reviewDashboardRevisionHref` verbatim;
30
+ * - the dg3 authority is `candidateEvidenceAuthority` /
31
+ * `classifyCandidateForRender` verbatim;
32
+ * - the versions come from the SAME `KVVersionStore.listPromotions` the
33
+ * "one step back" rollback resolution reads;
34
+ * - the rollback ACTION is the existing `/api/changes/rollback` seam — this
35
+ * module only INTERPRETS its honest response, it never re-decides a ship.
36
+ *
37
+ * PURE (no I/O): the endpoints inject already-read KV data, so every honest-state
38
+ * decision is unit-tested without a Worker/KV — mirroring the reviewDashboard.ts /
39
+ * candidatePreviewState.ts split.
40
+ *
41
+ * HONEST BOUNDARY (surfaced, never faked): a FULLY-STATIC tenant serves its public
42
+ * site from an external `versions/<digest>/` archive whose per-version treeDigests
43
+ * live only in S3 — enumerable by the Node-only `listRollbackTargets`
44
+ * (`scripts/publish/lib/static-rollback.mjs`), NOT by the Worker. So the console
45
+ * can list a static tenant's promotion history + `/rev` links + the CURRENT live
46
+ * digest, but it cannot resolve an ARBITRARY prior version's archive digest, which
47
+ * `/api/changes/rollback` fail-closed requires (`targetDigest`). `rollbackMode`
48
+ * names this: a `control-plane` tenant rolls back one-click (targetVersionId only);
49
+ * a `static-archive` tenant's per-version rollback is honestly deferred to the ship
50
+ * CLI / CI that enumerates the archive — never a one-click that would 400.
51
+ */
52
+ import { reviewDashboardRevisionHref } from "@/lib/preview/reviewDashboard";
53
+ import {
54
+ candidateEvidenceAuthority,
55
+ classifyCandidateForRender,
56
+ defaultBlockedReason,
57
+ type CandidateEvidenceAuthority,
58
+ } from "@/lib/preview/candidatePreviewState";
59
+ import type { ReviewEnvironment, PromotionEvent } from "@tot/public-runtime";
60
+
61
+ // ── Versions / promotion history ───────────────────────────────────────────
62
+
63
+ /** One published version in the tenant's promotion history (u2/u3 promotion_status). */
64
+ export interface WorkspaceVersion {
65
+ /** The tenant Gitea SHA the version serves — the content identity
66
+ * (`gitea-only-tenant-content-authority`). */
67
+ versionId: string;
68
+ /** How the version was published (a Gitea commit, or a content hash). */
69
+ source: "commit" | "hash" | null;
70
+ /** ISO time the version was PROMOTED onto `live` (the promotion event). */
71
+ promotedAt: string;
72
+ /** ISO time the version was first published (from its site-version manifest). */
73
+ createdAt: string | null;
74
+ /** Artifact count in the version manifest, or `null` when the manifest is absent. */
75
+ artifactCount: number | null;
76
+ /**
77
+ * The content-addressed `treeDigest`, when the console can honestly know it —
78
+ * i.e. ONLY the current live version of a static tenant (read back from
79
+ * `live/manifest.json`). `null` for every other row: a prior static version's
80
+ * digest lives only in the S3 archive the Worker can't enumerate. Never fabricated.
81
+ */
82
+ digest: string | null;
83
+ /** The u5 immutable `/preview/<tenant>/rev/<sha>/` deep-link, or `null` without a SHA. */
84
+ revHref: string | null;
85
+ /** The version currently serving live (the one you never roll back TO). */
86
+ isCurrent: boolean;
87
+ }
88
+
89
+ /** How a rollback is honestly driven for this tenant (see the module HONEST BOUNDARY note). */
90
+ export type RollbackMode = "control-plane" | "static-archive";
91
+
92
+ /** The versions-panel view model. */
93
+ export interface VersionsView {
94
+ /** The current live version + (static) the digest `live/` actually serves. */
95
+ current: { versionId: string | null; digest: string | null };
96
+ /** Promotion history, newest first, current marked. */
97
+ versions: WorkspaceVersion[];
98
+ /** Whether the tenant publishes its public site to an external static target. */
99
+ staticPublish: boolean;
100
+ /**
101
+ * `control-plane` → a prior version rolls back one-click via `targetVersionId`.
102
+ * `static-archive` → per-version rollback is deferred to the ship CLI / CI that
103
+ * enumerates the `versions/` archive (the Worker can't resolve the archive digest).
104
+ */
105
+ rollbackMode: RollbackMode;
106
+ }
107
+
108
+ /** A single promoted version enriched with the manifest facts the panel shows. */
109
+ export interface VersionManifestFacts {
110
+ source: "commit" | "hash" | null;
111
+ createdAt: string | null;
112
+ artifactCount: number | null;
113
+ }
114
+
115
+ export interface BuildVersionsViewInput {
116
+ tenantId: string;
117
+ /** Live-promotion history, newest first (`KVVersionStore.listPromotions`). */
118
+ promotions: PromotionEvent[];
119
+ /** The current live version id (the live-channel pointer), or `null`. */
120
+ currentVersionId: string | null;
121
+ /** The digest `live/` serves — static tenants only; `null` otherwise. */
122
+ currentDigest: string | null;
123
+ /** Per-version manifest facts, keyed by versionId (absent ⇒ unknown facts). */
124
+ manifests: Map<string, VersionManifestFacts>;
125
+ staticPublish: boolean;
126
+ }
127
+
128
+ /**
129
+ * Assemble the versions panel from the promotion history + per-version manifest
130
+ * facts + the current-live pointer. Pure. The current live row is marked; the
131
+ * digest is populated ONLY for the current live of a static tenant (never faked
132
+ * for priors). `revHref` is the u5 immutable link.
133
+ */
134
+ export function buildVersionsView(input: BuildVersionsViewInput): VersionsView {
135
+ const { tenantId, promotions, currentVersionId, currentDigest, manifests, staticPublish } = input;
136
+
137
+ const versions: WorkspaceVersion[] = promotions.map((p) => {
138
+ const facts = manifests.get(p.versionId);
139
+ const isCurrent = currentVersionId != null && p.versionId === currentVersionId;
140
+ return {
141
+ versionId: p.versionId,
142
+ source: facts?.source ?? null,
143
+ promotedAt: p.at,
144
+ createdAt: facts?.createdAt ?? null,
145
+ artifactCount: facts?.artifactCount ?? null,
146
+ // Honest: a digest is known only for the current live of a static tenant.
147
+ digest: isCurrent && staticPublish ? currentDigest : null,
148
+ revHref: reviewDashboardRevisionHref(tenantId, p.versionId),
149
+ isCurrent,
150
+ };
151
+ });
152
+
153
+ return {
154
+ current: { versionId: currentVersionId, digest: staticPublish ? currentDigest : null },
155
+ versions,
156
+ staticPublish,
157
+ rollbackMode: staticPublish ? "static-archive" : "control-plane",
158
+ };
159
+ }
160
+
161
+ // ── Diff view ──────────────────────────────────────────────────────────────
162
+
163
+ export type DiffChangeKind = "added" | "removed" | "changed";
164
+
165
+ /** One artifact path that differs between the candidate and the current live version. */
166
+ export interface DiffEntry {
167
+ path: string;
168
+ change: DiffChangeKind;
169
+ }
170
+
171
+ /** The diff-view model for one pending candidate. */
172
+ export interface CandidateDiffView {
173
+ changeId: string;
174
+ /** The candidate's exact head SHA — the reconcile TARGET (tenant Gitea sha). */
175
+ headSha: string | null;
176
+ /** The immutable version actually rendered + diffed — the reconcile ACTUAL. */
177
+ versionId: string | null;
178
+ /** The u5 immutable link to the exact reviewed SHA, or `null` without a head SHA. */
179
+ revHref: string | null;
180
+ /** dg3 evidence authority: authoritative (tied to head) / stale (last-good) / missing. */
181
+ authority: CandidateEvidenceAuthority;
182
+ /** The artifact-level diff (candidate rendered version vs current live), sorted by path. */
183
+ files: DiffEntry[];
184
+ /** True when the candidate has a renderable version to diff (dg3 `ready`). */
185
+ renderable: boolean;
186
+ /** When NOT renderable, the honest reason (names why) — never a fake empty diff. */
187
+ blockedReason: string | null;
188
+ }
189
+
190
+ export interface BuildCandidateDiffInput {
191
+ tenantId: string;
192
+ env: ReviewEnvironment;
193
+ /** The candidate rendered version's artifacts (path → contentHash), or `null`. */
194
+ candidateArtifacts: Record<string, string> | null;
195
+ /** The current live version's artifacts (path → contentHash), or `null` (nothing live yet). */
196
+ liveArtifacts: Record<string, string> | null;
197
+ }
198
+
199
+ /**
200
+ * Diff a candidate's rendered version against the current live version, at the
201
+ * artifact level (each path carries a content hash in its site-version manifest).
202
+ * Composes dg3's `classifyCandidateForRender` (only a `ready` candidate has a
203
+ * version to diff — a blocked/queued one yields an HONEST `blockedReason`, never a
204
+ * fabricated empty diff) and `candidateEvidenceAuthority` (so a diff of a last-good
205
+ * version that LAGS the head is flagged `stale`, not passed off as the head).
206
+ * Pure.
207
+ */
208
+ export function buildCandidateDiff(input: BuildCandidateDiffInput): CandidateDiffView {
209
+ const { tenantId, env, candidateArtifacts, liveArtifacts } = input;
210
+ const classification = classifyCandidateForRender(env);
211
+ const authority = candidateEvidenceAuthority(env);
212
+ const revHref = reviewDashboardRevisionHref(tenantId, env.headSha);
213
+
214
+ if (classification.kind !== "ready") {
215
+ const reason =
216
+ classification.kind === "blocked"
217
+ ? classification.reason
218
+ : defaultBlockedReason(env.status);
219
+ return {
220
+ changeId: env.changeId,
221
+ headSha: env.headSha,
222
+ versionId: null,
223
+ revHref,
224
+ authority,
225
+ files: [],
226
+ renderable: false,
227
+ blockedReason: reason,
228
+ };
229
+ }
230
+
231
+ const files = diffArtifacts(candidateArtifacts ?? {}, liveArtifacts ?? {});
232
+ return {
233
+ changeId: env.changeId,
234
+ headSha: env.headSha,
235
+ versionId: classification.versionId,
236
+ revHref,
237
+ authority,
238
+ files,
239
+ renderable: true,
240
+ blockedReason: null,
241
+ };
242
+ }
243
+
244
+ /** Artifact-level diff: paths added / removed / content-changed, sorted by path. */
245
+ export function diffArtifacts(
246
+ candidate: Record<string, string>,
247
+ live: Record<string, string>,
248
+ ): DiffEntry[] {
249
+ const entries: DiffEntry[] = [];
250
+ for (const [path, hash] of Object.entries(candidate)) {
251
+ if (!(path in live)) entries.push({ path, change: "added" });
252
+ else if (live[path] !== hash) entries.push({ path, change: "changed" });
253
+ }
254
+ for (const path of Object.keys(live)) {
255
+ if (!(path in candidate)) entries.push({ path, change: "removed" });
256
+ }
257
+ return entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
258
+ }
259
+
260
+ // ── Rollback response interpretation ─────────────────────────────────────────
261
+
262
+ /** The normalized honest terminal state of a rollback, for the surface to render. */
263
+ export type RollbackState =
264
+ | "rolled_back" // VERIFIED live on the prior version — the ONLY "rolled back" claim
265
+ | "publish_pending" // restored + dispatched, not yet verified — NOT "rolled back"
266
+ | "publish_failed" // fail-closed: unwired / dispatch failed / verification failed
267
+ | "blocked" // a gate refused (capability / no prior / race) — nothing moved
268
+ | "error"; // transport / unexpected shape
269
+
270
+ export interface RollbackOutcome {
271
+ state: RollbackState;
272
+ /** True ONLY for `rolled_back` (the target is verified to serve the prior version). */
273
+ rolledBack: boolean;
274
+ /** The version restored TO, when known. */
275
+ toVersionId: string | null;
276
+ /** A human-legible message for the surface. */
277
+ message: string;
278
+ }
279
+
280
+ /**
281
+ * Interpret the `/api/changes/rollback` response into ONE honest terminal state,
282
+ * for BOTH planes without the surface having to know which it hit:
283
+ * - a HOSTED (control-plane) rollback returns `{ rolledBack: true, toVersionId }`
284
+ * on success (the pointer flip IS the rollback) or `{ error, reason }` on refusal;
285
+ * - a STATIC rollback returns the orchestrator's honest `{ state, ... }` (u8).
286
+ * The rule the surface depends on: `rolledBack` is true ONLY for a verified
287
+ * `rolled_back` — a `publish_pending` is NEVER rendered as rolled back
288
+ * (`storefront-atomic-accept-is-ship-orchestrator` / `static-publish-fails-closed`).
289
+ * Pure.
290
+ */
291
+ export function interpretRollbackResponse(
292
+ httpOk: boolean,
293
+ body: {
294
+ state?: unknown;
295
+ rolledBack?: unknown;
296
+ toVersionId?: unknown;
297
+ message?: unknown;
298
+ error?: unknown;
299
+ reason?: unknown;
300
+ },
301
+ ): RollbackOutcome {
302
+ const toVersionId = typeof body.toVersionId === "string" ? body.toVersionId : null;
303
+
304
+ // Static plane: the orchestrator's explicit honest state wins.
305
+ if (typeof body.state === "string") {
306
+ const state = normalizeRollbackState(body.state);
307
+ const message =
308
+ typeof body.message === "string" && body.message
309
+ ? body.message
310
+ : typeof body.error === "string" && body.error
311
+ ? body.error
312
+ : rollbackStateFallbackMessage(state);
313
+ return { state, rolledBack: state === "rolled_back", toVersionId, message };
314
+ }
315
+
316
+ // Control-plane: a bare success flag, or a typed refusal.
317
+ if (httpOk && body.rolledBack === true) {
318
+ return {
319
+ state: "rolled_back",
320
+ rolledBack: true,
321
+ toVersionId,
322
+ message: toVersionId ? `Rolled back live to ${toVersionId}.` : "Rolled back live.",
323
+ };
324
+ }
325
+
326
+ const reason = typeof body.reason === "string" ? body.reason : null;
327
+ const message =
328
+ typeof body.error === "string" && body.error
329
+ ? body.error + (reason ? ` (${reason})` : "")
330
+ : "Rollback failed.";
331
+ // A control-plane refusal is a gate refusal (capability / no prior / conflict) —
332
+ // nothing moved; treat as blocked (never a false rolled-back).
333
+ return { state: "blocked", rolledBack: false, toVersionId, message };
334
+ }
335
+
336
+ function normalizeRollbackState(raw: string): RollbackState {
337
+ switch (raw) {
338
+ case "rolled_back":
339
+ case "publish_pending":
340
+ case "publish_failed":
341
+ case "blocked":
342
+ return raw;
343
+ default:
344
+ return "error";
345
+ }
346
+ }
347
+
348
+ function rollbackStateFallbackMessage(state: RollbackState): string {
349
+ switch (state) {
350
+ case "rolled_back":
351
+ return "Rolled back live.";
352
+ case "publish_pending":
353
+ return "Restored the pointer and dispatched the re-publish — not yet verified live. Refresh to re-check.";
354
+ case "publish_failed":
355
+ return "Rollback failed — the publish path is unwired or verification failed. Nothing false went live.";
356
+ case "blocked":
357
+ return "Rollback was refused by a gate — nothing changed.";
358
+ case "error":
359
+ default:
360
+ return "Rollback returned an unexpected response.";
361
+ }
362
+ }
@@ -99,7 +99,9 @@ function renderBrand(brand: ChromeBrand, opts: { footer?: boolean } = {}): strin
99
99
  const inner = validLogo
100
100
  ? `<img src="${esc(logo.src)}" alt="${esc(logo.alt)}"${
101
101
  logo.width ? ` width="${logo.width}"` : ""
102
- }${logo.height ? ` height="${logo.height}"` : ""}>`
102
+ }${logo.height ? ` height="${logo.height}"` : ""} loading="${
103
+ opts.footer ? "lazy" : "eager"
104
+ }" decoding="async">`
103
105
  : brand.wordmark?.length
104
106
  ? renderWordmark(brand.wordmark)
105
107
  : esc(brand.label);
@@ -279,7 +281,7 @@ function renderFooter(footer: ChromeFooter): string {
279
281
  const logo = brand?.logo && /^(https?:|\/)/.test(brand.logo.src)
280
282
  ? `<img src="${esc(brand.logo.src)}" alt="${esc(brand.logo.alt)}"${
281
283
  brand.logo.width ? ` width="${brand.logo.width}"` : ""
282
- }${brand.logo.height ? ` height="${brand.logo.height}"` : ""}>`
284
+ }${brand.logo.height ? ` height="${brand.logo.height}"` : ""} loading="lazy" decoding="async">`
283
285
  : brand?.wordmark?.length
284
286
  ? `<span class="brand">${renderWordmark(brand.wordmark)}</span>`
285
287
  : "";
@@ -445,6 +447,31 @@ export const CHROME_BEHAVIOR_JS = `(function () {
445
447
  /** The platform-shipped chrome stylesheet every rendered wrapper links (unit F2). */
446
448
  export const SHARED_CHROME_STYLESHEET = "/shared/commerce-chrome.css";
447
449
 
450
+ /**
451
+ * The platform-shipped RAW-PAGE-BODY marketing stylesheet (unit F5). Linked only
452
+ * when a tenant opts in (chrome.sharedMarketingCss) and AFTER the chrome sheet, so
453
+ * its page-level variants (e.g. .sec-head) win over chrome's home-section ones.
454
+ */
455
+ export const SHARED_MARKETING_STYLESHEET = "/shared/commerce-marketing.css";
456
+
457
+ /** MIME type for a favicon href, derived from its extension (SVG marks are common now). */
458
+ function faviconMime(href: string): string {
459
+ const ext = href.split(/[?#]/)[0]?.split(".").pop()?.toLowerCase();
460
+ switch (ext) {
461
+ case "svg":
462
+ return "image/svg+xml";
463
+ case "ico":
464
+ return "image/x-icon";
465
+ case "jpg":
466
+ case "jpeg":
467
+ return "image/jpeg";
468
+ case "webp":
469
+ return "image/webp";
470
+ default:
471
+ return "image/png";
472
+ }
473
+ }
474
+
448
475
  /**
449
476
  * Render the canonical chrome wrapper document for a tenant, from its config.
450
477
  *
@@ -459,9 +486,13 @@ export function renderRawChrome(chrome: TenantChrome): string {
459
486
  const head = chrome.head ?? {};
460
487
  const headLinks = [
461
488
  head.themeColor ? `<meta name="theme-color" content="${esc(head.themeColor)}">` : "",
462
- head.favicon ? `<link rel="icon" type="image/png" href="${esc(head.favicon)}">` : "",
489
+ head.favicon
490
+ ? `<link rel="icon" type="${faviconMime(head.favicon)}" href="${esc(head.favicon)}">`
491
+ : "",
463
492
  ...(head.themeStylesheets ?? []).map((h) => `<link rel="stylesheet" href="${esc(h)}">`),
464
493
  `<link rel="stylesheet" href="${SHARED_CHROME_STYLESHEET}">`,
494
+ // Opt-in raw-page-body layer, after the chrome sheet so its variants win (F5).
495
+ chrome.sharedMarketingCss ? `<link rel="stylesheet" href="${SHARED_MARKETING_STYLESHEET}">` : "",
465
496
  ]
466
497
  .filter(Boolean)
467
498
  .join("\n");
@@ -9,12 +9,15 @@
9
9
  * parent/child + Shared Components plan.
10
10
  */
11
11
  import type { ProductSectionSource } from "@tot/public-runtime";
12
+ import type { ProseSection } from "@/lib/content/prose";
13
+ import type { Tier } from "@/lib/membership/model";
12
14
 
13
15
  export type HomeBlock =
14
16
  | HeroBlock
15
17
  | PromoTilesBlock
16
18
  | FeaturedCollectionsBlock
17
19
  | FeaturedProductsBlock
20
+ | MembershipBandBlock
18
21
  | EditorialBlock
19
22
  | NewsletterBlock;
20
23
 
@@ -59,6 +62,22 @@ export interface FeaturedProductsBlock {
59
62
  limit?: number;
60
63
  }
61
64
 
65
+ /**
66
+ * Membership "band" on the commerce home — the Guest / Member / Autopilot tier
67
+ * comparison, driven by the platform <TierCards> (F4) widget from tenant data.
68
+ * The widget owns the illustrative-figures affordance (a tier is an EXAMPLE
69
+ * unless it asserts `real: true`), so a tenant surfaces its membership model on
70
+ * the home page without copying tier-card HTML. The block carries the same
71
+ * {@link Tier} shape the standalone membership page uses.
72
+ */
73
+ export interface MembershipBandBlock {
74
+ component: "membership_band";
75
+ eyebrow?: string;
76
+ title?: string;
77
+ subhead?: string;
78
+ tiers: Tier[];
79
+ }
80
+
62
81
  export interface EditorialBlock {
63
82
  component: "editorial";
64
83
  eyebrow?: string;
@@ -249,11 +268,24 @@ export interface EditorialPage {
249
268
  title: string;
250
269
  seoTitle?: string;
251
270
  seoDescription?: string;
252
- /** Sanitized HTML body. */
253
- body_html: string;
271
+ /**
272
+ * Sanitized HTML body (legacy / raw path). Optional: a page authored with
273
+ * structured {@link ProseSection}s omits it and drives the body from `sections`
274
+ * instead (spec §F4). The renderer prefers `sections` when present.
275
+ */
276
+ body_html?: string;
277
+ /**
278
+ * Structured policy/prose body (spec §F4) — headings, paragraphs, lists,
279
+ * callouts, a contact block, needs-review flags. When present, the page route
280
+ * renders <ProseSections> from this instead of `set:html(body_html)`, so a
281
+ * tenant drives a full policy page from JSON with no HTML/CSS to copy.
282
+ */
283
+ sections?: ProseSection[];
254
284
  /** Optional hero eyebrow/intro. */
255
285
  eyebrow?: string;
256
286
  intro?: string;
287
+ /** ISO date (YYYY-MM-DD) rendered as a "Last updated" line (structured pages). */
288
+ updated?: string;
257
289
  /** When true, listed in the footer policy column. */
258
290
  isPolicy?: boolean;
259
291
  }
@@ -353,18 +353,25 @@ function mapHome(content: Record<string, any>): HomeContent {
353
353
  }
354
354
 
355
355
  function mapPage(content: Record<string, any>, slug: string): EditorialPage {
356
- // Expect a rendered-HTML field (text/markdown component). A raw richtext object
357
- // would need a resolver we throw so the local fallback serves it instead.
356
+ // A page is either structured (F4 `sections`) OR raw HTML (`body_html`/`body`).
357
+ // Structured pages drive the body from `sections`; otherwise we expect a
358
+ // rendered-HTML field (a raw richtext object would need a resolver — we throw
359
+ // so the local fallback serves it instead).
360
+ const sections = Array.isArray(content.sections) ? content.sections : undefined;
358
361
  const body_html = content.body_html ?? content.body;
359
- if (typeof body_html !== "string") throw new Error(`storyblok page ${slug}: body not HTML`);
362
+ if (!sections && typeof body_html !== "string") {
363
+ throw new Error(`storyblok page ${slug}: no sections and body not HTML`);
364
+ }
360
365
  return {
361
366
  slug: slug.replace(/^pages\//, ""),
362
367
  title: content.title ?? slug,
363
368
  seoTitle: content.seoTitle ?? content.seo_title,
364
369
  seoDescription: content.seoDescription ?? content.seo_description,
365
- body_html,
370
+ body_html: typeof body_html === "string" ? body_html : undefined,
371
+ sections,
366
372
  eyebrow: content.eyebrow,
367
373
  intro: content.intro,
374
+ updated: content.updated,
368
375
  isPolicy: !!content.isPolicy,
369
376
  };
370
377
  }
@@ -43,6 +43,74 @@ export interface SubscriptionFigure {
43
43
  real?: boolean;
44
44
  }
45
45
 
46
+ /**
47
+ * The account-management actions a subscriber can take on an Autopilot order
48
+ * (F10). The storefront never EXECUTES these — each is a handoff into the
49
+ * tenant's product-owned manage-subscription portal (see {@link
50
+ * SubscriptionManagePortal}); this enum just gives the entry points stable,
51
+ * typed identities so the copy, ordering, and analytics hooks are consistent.
52
+ */
53
+ export type SubscriptionManageAction =
54
+ | "skip"
55
+ | "pause"
56
+ | "resume"
57
+ | "cancel"
58
+ | "reschedule"
59
+ | "quantity"
60
+ | "swap"
61
+ | "address";
62
+
63
+ /** Stable order for listing the manage actions on a surface. */
64
+ export const SUBSCRIPTION_MANAGE_ACTIONS: SubscriptionManageAction[] = [
65
+ "skip",
66
+ "pause",
67
+ "resume",
68
+ "cancel",
69
+ "reschedule",
70
+ "quantity",
71
+ "swap",
72
+ "address",
73
+ ];
74
+
75
+ /** Default label + description copy per manage action. Display only. */
76
+ export const MANAGE_ACTION_META: Record<
77
+ SubscriptionManageAction,
78
+ { label: string; description: string }
79
+ > = {
80
+ skip: {
81
+ label: "Skip a delivery",
82
+ description: "Skip your next shipment and pick back up after.",
83
+ },
84
+ pause: {
85
+ label: "Pause your subscription",
86
+ description: "Take a break — pause billing and shipments, resume anytime.",
87
+ },
88
+ resume: {
89
+ label: "Resume your subscription",
90
+ description: "Start deliveries again from a paused subscription.",
91
+ },
92
+ cancel: {
93
+ label: "Cancel anytime",
94
+ description: "Cancel your subscription with no penalty.",
95
+ },
96
+ reschedule: {
97
+ label: "Reschedule delivery",
98
+ description: "Move your next delivery date earlier or later.",
99
+ },
100
+ quantity: {
101
+ label: "Change quantity",
102
+ description: "Update how much ships each cycle.",
103
+ },
104
+ swap: {
105
+ label: "Swap products",
106
+ description: "Change which products are in your subscription.",
107
+ },
108
+ address: {
109
+ label: "Update address or payment",
110
+ description: "Change where it ships or how you pay.",
111
+ },
112
+ };
113
+
46
114
  /**
47
115
  * One manage-subscription entry point (e.g. "Update delivery date", "Pause or
48
116
  * cancel"). This is an EDUCATION/HANDOFF link only — it points at the
@@ -56,6 +124,52 @@ export interface SubscriptionManageEntry {
56
124
  description?: string;
57
125
  /** Handoff URL into the tenant's product-owned manage-subscription flow. */
58
126
  href: string;
127
+ /** The typed action this entry hands off to (analytics/audit hook). */
128
+ action?: SubscriptionManageAction;
129
+ }
130
+
131
+ /**
132
+ * The provider-handoff CONTRACT for subscription management. The storefront owns
133
+ * only the entry points; the tenant's managed-checkout provider (Phase 2) owns
134
+ * the actual skip/pause/cancel/reschedule/quantity execution behind `baseHref`.
135
+ * A tenant supplies its portal URL and (optionally) the subset of actions it
136
+ * offers; `hrefForAction` overrides the default deep-link shape when the provider
137
+ * uses path- rather than query-based deep links.
138
+ */
139
+ export interface SubscriptionManagePortal {
140
+ /** Base URL of the tenant's manage-subscription portal. */
141
+ baseHref: string;
142
+ /** Actions offered (in listing order). Defaults to every action. */
143
+ actions?: SubscriptionManageAction[];
144
+ /** Deep-link builder; defaults to `?action=<action>` on `baseHref`. */
145
+ hrefForAction?: (action: SubscriptionManageAction, baseHref: string) => string;
146
+ }
147
+
148
+ /** Default deep link into the manage portal for an action. */
149
+ function defaultActionHref(action: SubscriptionManageAction, baseHref: string): string {
150
+ return `${baseHref}${baseHref.includes("?") ? "&" : "?"}action=${action}`;
151
+ }
152
+
153
+ /**
154
+ * Build the manage-subscription entry points for a tenant's portal — the F10
155
+ * skip / pause / cancel / reschedule / quantity (+ resume / swap / address)
156
+ * handoffs. Each entry is a plain link into `portal.baseHref`; the storefront
157
+ * hands off, the provider executes.
158
+ */
159
+ export function buildManageEntries(
160
+ portal: SubscriptionManagePortal,
161
+ ): SubscriptionManageEntry[] {
162
+ const actions = portal.actions ?? SUBSCRIPTION_MANAGE_ACTIONS;
163
+ const hrefFor = portal.hrefForAction ?? defaultActionHref;
164
+ return actions.map((action) => {
165
+ const meta = MANAGE_ACTION_META[action];
166
+ return {
167
+ label: meta.label,
168
+ description: meta.description,
169
+ href: hrefFor(action, portal.baseHref),
170
+ action,
171
+ };
172
+ });
59
173
  }
60
174
 
61
175
  /**
@@ -51,9 +51,9 @@ export class FixtureToTClient implements ToTClient {
51
51
  }
52
52
 
53
53
  private scoped(): CatalogProduct[] {
54
- // Fixtures are single-tenant ("pilot"); scope-filter anyway so the seam is
55
- // honest for many-tenant.
56
- return this.products.filter((p) => p.tenant_id === this.scope || true);
54
+ // Fixtures are now multi-tenant: every read is scoped to this client's
55
+ // tenant so one tenant can never see another's products.
56
+ return this.products.filter((p) => p.tenant_id === this.scope);
57
57
  }
58
58
 
59
59
  async listProducts(query: ListProductsQuery = {}): Promise<ListProductsResponse> {