@tokenoftrust/storefront-runner 1.3.4-rc.3 → 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 (225) 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/obstacleStore.ts +214 -0
  94. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  95. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  96. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  97. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  98. package/apps/storefront/src/lib/env.ts +12 -0
  99. package/apps/storefront/src/lib/jsonld.ts +12 -17
  100. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  101. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  102. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  103. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  104. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  105. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  106. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  107. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  108. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  109. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  110. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  111. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  112. package/apps/storefront/src/lib/tot/query.ts +32 -0
  113. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  116. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  117. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  118. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  119. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  120. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  121. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  122. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  123. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  124. package/apps/storefront/src/middleware/index.ts +28 -13
  125. package/apps/storefront/src/pages/404.astro +21 -9
  126. package/apps/storefront/src/pages/[...slug].astro +36 -2
  127. package/apps/storefront/src/pages/admin.astro +29 -3
  128. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  129. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  130. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  131. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  132. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  133. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  134. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  135. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  137. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  138. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  139. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  142. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  143. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  144. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  146. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  147. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  149. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  150. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  151. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  152. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  153. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  154. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  155. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  156. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  157. package/apps/storefront/src/pages/auth/login.astro +197 -41
  158. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  159. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  160. package/apps/storefront/src/pages/blog/index.astro +98 -0
  161. package/apps/storefront/src/pages/capabilities.astro +8 -0
  162. package/apps/storefront/src/pages/cockpit.astro +496 -74
  163. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  164. package/apps/storefront/src/pages/collections/index.astro +10 -2
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  166. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  167. package/apps/storefront/src/pages/index.astro +57 -0
  168. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  169. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  170. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  171. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  175. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  176. package/apps/storefront/src/styles/fonts.css +54 -0
  177. package/apps/storefront/src/styles/global.css +22 -2
  178. package/apps/storefront/src/themes/schema.ts +3 -25
  179. package/apps/storefront/tsconfig.json +1 -1
  180. package/apps/storefront/vitest.config.ts +4 -1
  181. package/package.json +1 -1
  182. package/packages/public-runtime/src/candidate-index.ts +311 -0
  183. package/packages/public-runtime/src/checkout.ts +94 -2
  184. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  185. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  186. package/packages/public-runtime/src/compliance/index.ts +22 -0
  187. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  188. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  189. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  190. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  191. package/packages/public-runtime/src/csp.ts +27 -3
  192. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  193. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  194. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  195. package/packages/public-runtime/src/extension-contract.ts +2 -1
  196. package/packages/public-runtime/src/hash.ts +25 -0
  197. package/packages/public-runtime/src/index.ts +6 -0
  198. package/packages/public-runtime/src/membership.ts +353 -0
  199. package/packages/public-runtime/src/product.ts +24 -2
  200. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  201. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  202. package/packages/public-runtime/src/tenant.ts +236 -0
  203. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  204. package/scripts/dev/publish.mjs +158 -0
  205. package/scripts/dev/transient-files.mjs +2 -1
  206. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  223. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  224. package/tenants/home/public/pages/storefront.css +23 -0
  225. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -0,0 +1,107 @@
1
+ /**
2
+ * RFC 9470 step-up challenge types + classification — storefront's OWN port.
3
+ *
4
+ * storefront's server calls tot CORE's internal routes directly with machine
5
+ * creds (`totAccessClient.ts`), never through the tot-mcp gateway. That means
6
+ * there is NO `WWW-Authenticate` header on this path (that header only exists
7
+ * on the gateway) — CORE signals a step-up challenge or a pending identity-bind
8
+ * as PLAIN JSON on the response BODY. This module classifies that (already
9
+ * envelope-unwrapped) body into a typed result.
10
+ *
11
+ * This mirrors launchpad's step-up parser/`runWithStepUp` helper in spirit, but
12
+ * is a deliberately independent, storefront-owned module: storefront is a
13
+ * separate repo with disjoint history and cannot import launchpad's kernel.
14
+ *
15
+ * Verified shapes (tot20 @ bb66934f / qa core):
16
+ * STEP-UP challenge (HTTP 401):
17
+ * { ok:false, reason:'step-up-required',
18
+ * obligations:[{ type:'step-up', requiredTier, requiredAcr, maxAuthAgeSeconds, message }] }
19
+ * SUCCESS-PENDING (HTTP 2xx, identity-bind):
20
+ * { ok:true, signinEnabled:false, obligations:[...] }
21
+ */
22
+
23
+ /** A single obligation tot20 attaches to a step-up/pending response. */
24
+ export interface StepUpObligation {
25
+ type: "step-up" | "dpop-required" | "reauthenticate";
26
+ requiredAcr?: string;
27
+ requiredAmr?: string[];
28
+ requiredTier?: string;
29
+ maxAuthAgeSeconds?: string | number;
30
+ message?: string;
31
+ }
32
+
33
+ /** The step-up challenge, flattened from the first `type:'step-up'` obligation. */
34
+ export interface StepUpChallenge {
35
+ obligations: StepUpObligation[];
36
+ requiredAcr?: string;
37
+ requiredTier?: string;
38
+ maxAuthAgeSeconds?: string | number;
39
+ }
40
+
41
+ export type StepUpClassification =
42
+ | { kind: "step-up-required"; challenge: StepUpChallenge }
43
+ | { kind: "success-pending"; obligations: StepUpObligation[] }
44
+ | { kind: "none" };
45
+
46
+ const OBLIGATION_TYPES = new Set(["step-up", "dpop-required", "reauthenticate"]);
47
+
48
+ /** Parse + validate an `obligations` field into typed obligations (fail-open to `[]`). */
49
+ function toObligations(raw: unknown): StepUpObligation[] {
50
+ if (!Array.isArray(raw)) return [];
51
+ const out: StepUpObligation[] = [];
52
+ for (const item of raw) {
53
+ if (!item || typeof item !== "object") continue;
54
+ const o = item as Record<string, unknown>;
55
+ if (typeof o.type !== "string" || !OBLIGATION_TYPES.has(o.type)) continue;
56
+ const obligation: StepUpObligation = { type: o.type as StepUpObligation["type"] };
57
+ if (typeof o.requiredAcr === "string") obligation.requiredAcr = o.requiredAcr;
58
+ if (Array.isArray(o.requiredAmr)) {
59
+ obligation.requiredAmr = o.requiredAmr.filter((a): a is string => typeof a === "string");
60
+ }
61
+ if (typeof o.requiredTier === "string") obligation.requiredTier = o.requiredTier;
62
+ if (typeof o.maxAuthAgeSeconds === "string" || typeof o.maxAuthAgeSeconds === "number") {
63
+ obligation.maxAuthAgeSeconds = o.maxAuthAgeSeconds;
64
+ }
65
+ if (typeof o.message === "string") obligation.message = o.message;
66
+ out.push(obligation);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Classify an UNWRAPPED tot CORE response body (already stripped of the
73
+ * `{ content, metadata }` envelope) into a step-up state. Pure + independently
74
+ * testable — `normalizeFederatedVerify` calls this BEFORE its `b.ok === true`
75
+ * branch so every other body shape falls through unchanged.
76
+ */
77
+ export function classifyStepUp(body: unknown): StepUpClassification {
78
+ if (!body || typeof body !== "object") return { kind: "none" };
79
+ const b = body as Record<string, unknown>;
80
+ const obligations = toObligations(b.obligations);
81
+
82
+ // SUCCESS-PENDING: identity bound (HTTP 2xx, `ok:true`), sign-in awaits
83
+ // step-up completion. Checked FIRST because `ok:true` bodies never fall into
84
+ // the step-up-required (HTTP 401, `ok:false`) branch below, even though both
85
+ // shapes carry the same `type:'step-up'` obligation riding along.
86
+ if (b.ok === true) {
87
+ return b.signinEnabled === false ? { kind: "success-pending", obligations } : { kind: "none" };
88
+ }
89
+
90
+ // STEP-UP challenge (`ok:false`): signalled either by the explicit `reason`,
91
+ // or by an obligation of type `step-up` riding along on the response.
92
+ const stepUpObligation = obligations.find((o) => o.type === "step-up");
93
+ if (b.reason === "step-up-required" || stepUpObligation) {
94
+ const first = stepUpObligation ?? obligations[0];
95
+ return {
96
+ kind: "step-up-required",
97
+ challenge: {
98
+ obligations,
99
+ requiredAcr: first?.requiredAcr,
100
+ requiredTier: first?.requiredTier,
101
+ maxAuthAgeSeconds: first?.maxAuthAgeSeconds,
102
+ },
103
+ };
104
+ }
105
+
106
+ return { kind: "none" };
107
+ }
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { readEnv } from "@/lib/env";
30
30
  import { diagEmail } from "@/lib/diag/redact";
31
+ import { classifyStepUp, type StepUpChallenge, type StepUpObligation } from "./stepUpChallenge.js";
31
32
 
32
33
  /** `send` is deliberately opaque — it never reveals whether the email was eligible. */
33
34
  export interface AccessCodeSendResult {
@@ -92,6 +93,31 @@ export interface TotAccessClient {
92
93
  * authenticated caller server-side, never passed in the body.
93
94
  */
94
95
  exchangeSignInToken(token: string): Promise<FederatedVerifyResult>;
96
+ /**
97
+ * MINT a fresh single-use sign-in token for `email`, bound to (email, aud = THIS
98
+ * caller's appDomain), by calling tot20's access-code `/mint` as the storefront's
99
+ * OWN machine credential. Unlike `developerInvites` this is NOT operator-grant-gated
100
+ * per tenant — tot20 re-resolves the email's memberships fresh at exchange. Used to
101
+ * RE-ISSUE a dev's identity proof at CLI-sign-in approve time (subjectReissue), where
102
+ * the id_token's `aud` (= the storefront appDomain = the MCP client_id) is exactly
103
+ * what the MCP binds the subject_token to. Fails closed.
104
+ */
105
+ mintSignInToken(email: string, ttlSeconds?: number): Promise<SignInTokenMintResult>;
106
+ /**
107
+ * STEP-UP COMPLETION (SMS), leg 1: ask tot CORE to send a one-time SMS code to
108
+ * `identifier` (the identifier bound during a `step-up-required` challenge or a
109
+ * `success-pending` identity-bind). Neutral-on-failure — mirrors
110
+ * {@link sendFederatedCode}: a transport error still resolves `{ ok: true }` so
111
+ * the caller never learns whether the identifier was valid/eligible.
112
+ */
113
+ sendSmsChallenge(identifier: string): Promise<{ ok: true }>;
114
+ /**
115
+ * STEP-UP COMPLETION (SMS), leg 2: verify the code. On success the response
116
+ * carries the FRESHER `id_token` (acr `tot:aal:sms`, fresh `auth_time`) that
117
+ * satisfies the step-up obligation — the caller re-derives the session from it
118
+ * exactly like a normal federated verify. Fail-closed typed on any other shape.
119
+ */
120
+ verifySmsChallenge(identifier: string, code: string): Promise<SmsVerifyResult>;
95
121
  }
96
122
 
97
123
  /**
@@ -99,9 +125,27 @@ export interface TotAccessClient {
99
125
  * claims — email + all `tenants[]` — are the sole authority) and the `audience`
100
126
  * tot set (= the calling app). `id_token` is present-usually, not guaranteed
101
127
  * (signer degradation, contract §6); the consumer fails closed when it's absent.
128
+ *
129
+ * The `step-up-required`/`success-pending` variants carry tot20's RFC 9470
130
+ * obligations (see `@/lib/auth/stepUpChallenge`) instead of a plain failure
131
+ * reason, so the caller can drive the SMS completion loop rather than just
132
+ * showing a generic "invalid code" error.
102
133
  */
103
134
  export type FederatedVerifyResult =
104
135
  | { ok: true; email: string; audience: string; id_token?: string }
136
+ | { ok: false; reason: "step-up-required"; stepUp: StepUpChallenge }
137
+ | { ok: false; reason: "success-pending"; obligations: StepUpObligation[] }
138
+ | { ok: false; reason: VerifyFailureReason };
139
+
140
+ /** Result of {@link TotAccessClient.mintSignInToken}. The raw `token` is a single-use
141
+ * secret — server-to-server only, NEVER logged. */
142
+ export type SignInTokenMintResult =
143
+ | { ok: true; token: string; expiresAt?: string; ttlSeconds?: number }
144
+ | { ok: false; reason: VerifyFailureReason };
145
+
146
+ /** Result of {@link TotAccessClient.verifySmsChallenge}. */
147
+ export type SmsVerifyResult =
148
+ | { ok: true; idToken: string; email?: string }
105
149
  | { ok: false; reason: VerifyFailureReason };
106
150
 
107
151
  // --- HTTP transport seam (injected by tests) --------------------------------
@@ -286,6 +330,82 @@ export class HttpTotAccessClient implements TotAccessClient {
286
330
  // authoritative email (no client-supplied fallback needed).
287
331
  return normalizeFederatedVerify(body, "");
288
332
  }
333
+
334
+ async mintSignInToken(email: string, ttlSeconds?: number): Promise<SignInTokenMintResult> {
335
+ let res: HttpResponseLike;
336
+ try {
337
+ res = await this.transport.post(
338
+ `${this.baseUrl}/api/internal/access-code/mint`,
339
+ {},
340
+ {
341
+ email: email.toLowerCase(),
342
+ ...(ttlSeconds != null ? { ttlSeconds } : {}),
343
+ ...this.credentialFields(),
344
+ },
345
+ );
346
+ } catch (e) {
347
+ try {
348
+ console.warn("[diag:mint] transport error", e instanceof Error ? e.message : String(e));
349
+ } catch {
350
+ /* never break */
351
+ }
352
+ return { ok: false, reason: "error" };
353
+ }
354
+ let body: unknown = "(unread)";
355
+ try {
356
+ body = await res.json();
357
+ } catch {
358
+ body = "(non-JSON)";
359
+ }
360
+ const result = normalizeMint(body);
361
+ // DIAGNOSTIC (secret-safe): on a non-ok mint, log tot20's status + body so a
362
+ // 404 (path) / 401·403 (cred not accepted here) / 5xx is diagnosable. The `token`
363
+ // (only present on success) is never in this branch, so nothing sensitive leaks.
364
+ if (!result.ok) {
365
+ try {
366
+ console.warn("[diag:mint]", JSON.stringify({ status: res.status, body }));
367
+ } catch {
368
+ /* never break */
369
+ }
370
+ }
371
+ return result;
372
+ }
373
+
374
+ async sendSmsChallenge(identifier: string): Promise<{ ok: true }> {
375
+ // Neutral by contract, mirroring sendFederatedCode: a transport error still
376
+ // resolves { ok: true } so the caller never learns eligibility. Best-effort
377
+ // server-side diagnostic only.
378
+ try {
379
+ await this.transport.post(
380
+ `${this.baseUrl}/api/internal/access-code/identity-send`,
381
+ {},
382
+ { identifier, channel: "sms", ...this.credentialFields() },
383
+ );
384
+ } catch {
385
+ /* swallow — neutral response */
386
+ }
387
+ return { ok: true };
388
+ }
389
+
390
+ async verifySmsChallenge(identifier: string, code: string): Promise<SmsVerifyResult> {
391
+ let res: HttpResponseLike;
392
+ try {
393
+ res = await this.transport.post(
394
+ `${this.baseUrl}/api/internal/access-code/identity-verify`,
395
+ {},
396
+ { identifier, channel: "sms", code, ...this.credentialFields() },
397
+ );
398
+ } catch {
399
+ return { ok: false, reason: "error" };
400
+ }
401
+ let body: unknown;
402
+ try {
403
+ body = await res.json();
404
+ } catch {
405
+ return { ok: false, reason: "error" };
406
+ }
407
+ return normalizeSmsVerify(body);
408
+ }
289
409
  }
290
410
 
291
411
  /** Parse tot20's FEDERATED verify response into a typed result (fail closed). */
@@ -296,6 +416,18 @@ export function normalizeFederatedVerify(
296
416
  // Unwrap the `{ content, metadata }` envelope when present (see normalizeVerify).
297
417
  const raw = (body ?? {}) as Record<string, unknown>;
298
418
  const b = (raw.content && typeof raw.content === "object" ? raw.content : raw) as Record<string, unknown>;
419
+
420
+ // Classify BEFORE the `b.ok === true` branch: a step-up challenge is `ok:false`
421
+ // but success-pending is `ok:true` — both must be caught here, ahead of the
422
+ // ordinary success/failure handling below.
423
+ const stepUp = classifyStepUp(b);
424
+ if (stepUp.kind === "step-up-required") {
425
+ return { ok: false, reason: "step-up-required", stepUp: stepUp.challenge };
426
+ }
427
+ if (stepUp.kind === "success-pending") {
428
+ return { ok: false, reason: "success-pending", obligations: stepUp.obligations };
429
+ }
430
+
299
431
  if (b.ok === true) {
300
432
  const idToken =
301
433
  typeof b.id_token === "string"
@@ -319,6 +451,51 @@ export function normalizeFederatedVerify(
319
451
  };
320
452
  }
321
453
 
454
+ /** Parse tot20's access-code `/mint` response into a typed result (fail closed). */
455
+ export function normalizeMint(body: unknown): SignInTokenMintResult {
456
+ const raw = (body ?? {}) as Record<string, unknown>;
457
+ const b = (raw.content && typeof raw.content === "object" ? raw.content : raw) as Record<string, unknown>;
458
+ if (b.ok === true && typeof b.token === "string" && b.token) {
459
+ const result: SignInTokenMintResult = { ok: true, token: b.token };
460
+ if (typeof b.expiresAt === "string") result.expiresAt = b.expiresAt;
461
+ if (typeof b.ttlSeconds === "number") result.ttlSeconds = b.ttlSeconds;
462
+ return result;
463
+ }
464
+ const reason = b.reason;
465
+ const known: VerifyFailureReason[] = ["invalid", "expired", "too_many_attempts", "error"];
466
+ return {
467
+ ok: false,
468
+ reason: known.includes(reason as VerifyFailureReason) ? (reason as VerifyFailureReason) : "error",
469
+ };
470
+ }
471
+
472
+ /** Parse tot20's SMS step-up-completion verify response (fail closed). */
473
+ export function normalizeSmsVerify(body: unknown): SmsVerifyResult {
474
+ // Same envelope-unwrap convention as normalizeVerify/normalizeFederatedVerify.
475
+ const raw = (body ?? {}) as Record<string, unknown>;
476
+ const b = (raw.content && typeof raw.content === "object" ? raw.content : raw) as Record<string, unknown>;
477
+ if (b.ok === true) {
478
+ const idToken =
479
+ typeof b.id_token === "string"
480
+ ? b.id_token
481
+ : typeof b.assertion === "string"
482
+ ? b.assertion
483
+ : undefined;
484
+ // The fresher credential is the SOLE authority for completing step-up — fail
485
+ // closed if it's absent rather than trusting an unsigned "ok".
486
+ if (!idToken) return { ok: false, reason: "error" };
487
+ const result: SmsVerifyResult = { ok: true, idToken };
488
+ if (typeof b.email === "string") result.email = b.email.toLowerCase();
489
+ return result;
490
+ }
491
+ const reason = b.reason;
492
+ const known: VerifyFailureReason[] = ["invalid", "expired", "too_many_attempts", "error"];
493
+ return {
494
+ ok: false,
495
+ reason: known.includes(reason as VerifyFailureReason) ? (reason as VerifyFailureReason) : "invalid",
496
+ };
497
+ }
498
+
322
499
  /** Parse + validate tot20's verify response into our typed result (fail closed). */
323
500
  export function normalizeVerify(
324
501
  body: unknown,
@@ -379,6 +556,8 @@ export class MockTotAccessClient implements TotAccessClient {
379
556
  /** key = `${resource}${emailLower}` → roster entry. */
380
557
  private roster = new Map<string, MockRosterEntry>();
381
558
  sent: Array<{ email: string; resource: string }> = [];
559
+ /** Identifiers the mock was asked to SMS-challenge (step-up completion). */
560
+ smsSent: string[] = [];
382
561
 
383
562
  constructor(
384
563
  private readonly code = "123456",
@@ -472,6 +651,35 @@ export class MockTotAccessClient implements TotAccessClient {
472
651
  }
473
652
  return { ok: false, reason: "invalid" };
474
653
  }
654
+
655
+ async mintSignInToken(email: string): Promise<SignInTokenMintResult> {
656
+ // Mirror the mock exchange's accepted shape ("mock|<tenant>|<email>") so a
657
+ // mint→exchange round-trip resolves back to this email in tests / local dev.
658
+ const token = Buffer.from(`mock|storefront|${email.toLowerCase()}`, "utf8").toString("base64url");
659
+ return { ok: true, token };
660
+ }
661
+
662
+ async sendSmsChallenge(identifier: string): Promise<{ ok: true }> {
663
+ this.smsSent.push(identifier);
664
+ return { ok: true }; // neutral regardless of eligibility
665
+ }
666
+
667
+ async verifySmsChallenge(identifier: string, code: string): Promise<SmsVerifyResult> {
668
+ if (code !== this.code) return { ok: false, reason: "invalid" };
669
+ // The mock has no private key material; it returns a deterministic
670
+ // placeholder token unless a real signer is wired in an integration test.
671
+ const idToken = this.signIdToken
672
+ ? await this.signIdToken({
673
+ email: identifier.toLowerCase(),
674
+ resource: "",
675
+ roles: [],
676
+ capability: "member",
677
+ })
678
+ : `mock-sms-id-token|${identifier.toLowerCase()}`;
679
+ const result: SmsVerifyResult = { ok: true, idToken };
680
+ if (identifier.includes("@")) result.email = identifier.toLowerCase();
681
+ return result;
682
+ }
475
683
  }
476
684
 
477
685
  /** Collapse a role array to one effective capability (mirrors tot20's precedence). */
@@ -0,0 +1,39 @@
1
+ import type { BlogPost, BlogPostSummary } from "./types.js";
2
+
3
+ const indexGlobs = import.meta.glob<BlogPostSummary[]>(
4
+ "../../../../../tenants/*/content/blog/index.json",
5
+ { import: "default" },
6
+ );
7
+ const postGlobs = import.meta.glob<BlogPost>(
8
+ "../../../../../tenants/*/content/blog/posts/*.json",
9
+ { import: "default" },
10
+ );
11
+ const redirectGlobs = import.meta.glob<Record<string, string>>(
12
+ "../../../../../tenants/*/content/blog/redirects.json",
13
+ { import: "default" },
14
+ );
15
+
16
+ function tenantKey(keys: string[], tenantId: string, suffix: string): string | undefined {
17
+ const segment = `/tenants/${tenantId}/content/blog/`;
18
+ return keys.find((key) => key.includes(segment) && key.endsWith(suffix));
19
+ }
20
+
21
+ export async function listBlogPosts(tenantId: string): Promise<BlogPostSummary[]> {
22
+ const key = tenantKey(Object.keys(indexGlobs), tenantId, "/index.json")
23
+ ?? Object.keys(indexGlobs).find((candidate) => candidate.includes(`/tenants/${tenantId}/content/blog/index.json`));
24
+ return key ? indexGlobs[key]!() : [];
25
+ }
26
+
27
+ export async function getBlogPost(tenantId: string, slug: string): Promise<BlogPost | null> {
28
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null;
29
+ const key = tenantKey(Object.keys(postGlobs), tenantId, `/posts/${slug}.json`);
30
+ return key ? postGlobs[key]!() : null;
31
+ }
32
+
33
+ export async function resolveBlogRedirect(tenantId: string, slug: string): Promise<string | null> {
34
+ const key = tenantKey(Object.keys(redirectGlobs), tenantId, "/redirects.json")
35
+ ?? Object.keys(redirectGlobs).find((candidate) => candidate.includes(`/tenants/${tenantId}/content/blog/redirects.json`));
36
+ if (!key) return null;
37
+ const redirects = await redirectGlobs[key]!();
38
+ return redirects[slug] ?? null;
39
+ }
@@ -0,0 +1,26 @@
1
+ export interface BlogImage {
2
+ src: string;
3
+ alt: string;
4
+ width?: number;
5
+ height?: number;
6
+ }
7
+
8
+ export interface BlogPostSummary {
9
+ source: { platform: "wordpress"; id: string };
10
+ slug: string;
11
+ title: string;
12
+ seoTitle: string;
13
+ description: string;
14
+ excerpt: string;
15
+ publishedAt: string;
16
+ modifiedAt: string;
17
+ author: string;
18
+ categories: string[];
19
+ tags: string[];
20
+ featuredImage?: BlogImage;
21
+ }
22
+
23
+ export interface BlogPost extends BlogPostSummary {
24
+ oldSlugs: string[];
25
+ contentHtml: string;
26
+ }
@@ -47,5 +47,43 @@ export async function readCartSecret(
47
47
  commerce: ResolvedManagedCheckout,
48
48
  ): Promise<string | undefined> {
49
49
  if (commerce.mode !== "signed") return undefined;
50
- return readEnv("FOXY_CART_SECRET");
50
+ const secret = await readEnv("FOXY_CART_SECRET");
51
+ return secret && secret.length ? secret : undefined;
52
+ }
53
+
54
+ /**
55
+ * The outcome of resolving whether — and how — a tenant may build add-to-cart
56
+ * forms this request.
57
+ * - `enabled: true, secret: undefined` → `unsigned-demo` (no secret needed).
58
+ * - `enabled: true, secret: "…"` → `signed` with its validation secret.
59
+ * - `enabled: false` → FAIL CLOSED: signed mode is configured
60
+ * but the secret is missing, so NO cart may be built. The caller must render
61
+ * no buy form (never a tamperable / broken one).
62
+ */
63
+ export interface CartSigningResolution {
64
+ enabled: boolean;
65
+ secret?: string;
66
+ }
67
+
68
+ /**
69
+ * Resolve cart signing for a request, failing CLOSED for production signed
70
+ * checkout. Signed mode is the real-merchant configuration, so its
71
+ * cart-validation secret is REQUIRED: without it we refuse to emit any buy form
72
+ * (a signed form we can't sign would be tamperable; an unsigned one against a
73
+ * validation-ON store would be broken). Missing-secret in signed mode is a
74
+ * deployment misconfiguration, so we log it loudly (`console.error`) in every
75
+ * environment — it should never happen on a live store and must be caught fast.
76
+ * `unsigned-demo` needs no secret and is always enabled.
77
+ */
78
+ export async function resolveCartSigning(
79
+ commerce: ResolvedManagedCheckout,
80
+ ): Promise<CartSigningResolution> {
81
+ if (commerce.mode !== "signed") return { enabled: true };
82
+ const secret = await readCartSecret(commerce);
83
+ if (secret) return { enabled: true, secret };
84
+ console.error(
85
+ "[storefront] signed checkout is configured but FOXY_CART_SECRET is missing — " +
86
+ "buy forms disabled (fail-closed). Set the cart-validation secret for this environment.",
87
+ );
88
+ return { enabled: false };
51
89
  }
@@ -220,6 +220,17 @@ export interface TenantChrome {
220
220
  * cutover of a SHIPPED tenant stays a deliberate, gated step.
221
221
  */
222
222
  sharedChrome?: boolean;
223
+ /**
224
+ * OPT-IN: also link the shared commerce-MARKETING stylesheet
225
+ * (/shared/commerce-marketing.css, unit F5) into this tenant's wrapper — the
226
+ * raw-page BODY layer (page-hero / prose / callouts / value & tier cards /
227
+ * contact form / FAQ / CTA band) its content fragments repeat. Linked AFTER the
228
+ * chrome sheet so its page-level variants win. Absent/false ⇒ not linked, so a
229
+ * SHIPPED tenant that still carries its own page-local <style> never
230
+ * double-loads or silently flips until its integration unit strips that CSS and
231
+ * adopts (same gated-cutover discipline as `sharedChrome`).
232
+ */
233
+ sharedMarketingCss?: boolean;
223
234
  /** <html lang> (defaults to "en"). */
224
235
  lang?: string;
225
236
  head?: ChromeHead;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Compliance enforcement ADAPTER — the request-scoped bridge between a resolved
3
+ * tenant and the pure `@tot/public-runtime` enforcement engine. It:
4
+ * - decides whether a tenant is REGULATED (age/nicotine),
5
+ * - resolves its data-driven ruleset (fail-closed: regulated + no ruleset ⇒ null),
6
+ * - wires the LIVE verification provider from operational env
7
+ * (`TOT_VERIFICATION_PROVIDER`) — absent ⇒ the verification gate fails closed,
8
+ * - and produces the enforcement→evidence signals the promote gate consumes.
9
+ *
10
+ * The verification provider being external config is deliberate: this repo owns
11
+ * the GATE and its safe default (BLOCK), not the provider integration. Setting
12
+ * `TOT_VERIFICATION_PROVIDER=tot` (once the live ToT verification flow is wired)
13
+ * is what flips a regulated tenant from "blocked/not-promotable" to enforceable.
14
+ */
15
+ import type {
16
+ TenantConfig,
17
+ ComplianceRuleset,
18
+ EnforcementEvidenceSignals,
19
+ } from "@tot/public-runtime";
20
+ import { enforcementSignals } from "@tot/public-runtime";
21
+ import { readEnv } from "@/lib/env";
22
+ import { COMPLIANCE_RULESETS } from "@/config/compliance/rulesets";
23
+
24
+ /** True when the tenant sells age/nicotine-restricted goods (drives fail-closed). */
25
+ export function isRegulatedTenant(
26
+ tenant: Pick<TenantConfig, "compliance">,
27
+ ): boolean {
28
+ const c = tenant.compliance;
29
+ return !!c && ((c.minAge != null && c.minAge > 0) || !!c.nicotineWarning);
30
+ }
31
+
32
+ /**
33
+ * Provider values that denote a NON-LIVE, test/sandbox verification profile. This
34
+ * is the mode the 8501 demo/preview runs in: there is no live identity provider,
35
+ * so the storefront's front-door 21+ affirmation is allowed to SIMULATE a passed
36
+ * age/identity verification — and ONLY then — so the regulated checkout can be
37
+ * exercised end-to-end for the demo without a real provider or any secret.
38
+ *
39
+ * This does NOT weaken the F12 invariant that a soft affirmation is never real
40
+ * verification: the simulation is gated on an EXPLICIT sandbox provider value set
41
+ * per-environment (test-tier only). Production leaves `TOT_VERIFICATION_PROVIDER`
42
+ * UNSET (→ fail-closed) or points it at the live provider (whose own outcome is
43
+ * then required); in neither case does affirmation substitute for verification.
44
+ */
45
+ export const SANDBOX_VERIFICATION_PROVIDERS: ReadonlySet<string> = new Set([
46
+ "sandbox",
47
+ "test",
48
+ "demo",
49
+ ]);
50
+
51
+ /** True when the resolved verification provider is an explicit sandbox/test profile. */
52
+ export function isSandboxVerificationProvider(
53
+ provider: string | undefined | null,
54
+ ): boolean {
55
+ return !!provider && SANDBOX_VERIFICATION_PROVIDERS.has(provider.toLowerCase());
56
+ }
57
+
58
+ /**
59
+ * The PACT reporting CAPTURE sink status. The record BUILDER
60
+ * (`buildPactReportRecord`) ships in this unit and the capture point is wired at
61
+ * order intake; the durable per-state export/FILING is a documented follow-up. We
62
+ * report the capture path as wired so the promote gate doesn't hard-block on the
63
+ * filing (merchant/counsel-owned) while still requiring the per-order data model.
64
+ */
65
+ const PACT_REPORTING_CAPTURE_WIRED = true;
66
+
67
+ /**
68
+ * Resolve a tenant's compliance ruleset, or `null` when it can't be resolved for a
69
+ * regulated tenant (the caller must then fail closed). Wires the live verification
70
+ * provider from env. An unregulated tenant returns `null` too — callers key on
71
+ * {@link isRegulatedTenant} to distinguish "no ruleset needed" from "fail closed".
72
+ */
73
+ export async function resolveTenantRuleset(
74
+ tenant: Pick<TenantConfig, "tenant_id" | "compliance">,
75
+ ): Promise<ComplianceRuleset | null> {
76
+ const base = COMPLIANCE_RULESETS[tenant.tenant_id];
77
+ if (!base) return null;
78
+ const provider = (await readEnv("TOT_VERIFICATION_PROVIDER")) || undefined;
79
+ return {
80
+ ...base,
81
+ verification: { ...base.verification, provider },
82
+ };
83
+ }
84
+
85
+ /** Build the enforcement→evidence signals for a tenant (for the promote gate). */
86
+ export async function resolveEnforcementEvidenceSignals(
87
+ tenant: Pick<TenantConfig, "tenant_id" | "compliance">,
88
+ ): Promise<EnforcementEvidenceSignals> {
89
+ const regulated = isRegulatedTenant(tenant);
90
+ const ruleset = await resolveTenantRuleset(tenant);
91
+ return enforcementSignals(ruleset, {
92
+ regulated,
93
+ reportingCaptureWired: PACT_REPORTING_CAPTURE_WIRED,
94
+ });
95
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Callout contract (spec §F4). A tone-styled aside — the structured, data-driven
3
+ * form of the repeated `.disclaimer` / `.warn-band` page patterns that the f05
4
+ * shared sheet (public/shared/commerce-marketing.css) defines for the raw-HTML
5
+ * path. A tenant supplies a {@link CalloutModel}; <Callout> paints it from f06
6
+ * tokens with the a11y semantics baked in (see {@link calloutPresentation}), so
7
+ * no tenant hand-authors the callout markup + its role/aria.
8
+ *
9
+ * Tones map to the same token vocabulary the f05 sheet uses:
10
+ * info → `--color-info` (the sheet's `.disclaimer`, e.g. an FYI)
11
+ * warning → `--color-warning` (the sheet's `.warn-band`, a caution)
12
+ * legal → `--color-warning` (compliance/legal caution; distinct label)
13
+ * success → `--color-success` (a positive confirmation)
14
+ * Node-free + presentation-free so the contract tests and the .astro widget
15
+ * share ONE definition.
16
+ */
17
+ import type { RichText } from "./richtext.js";
18
+
19
+ export type CalloutTone = "info" | "warning" | "legal" | "success";
20
+
21
+ export interface CalloutModel {
22
+ tone: CalloutTone;
23
+ /** Optional bold lead line above the body. */
24
+ title?: string;
25
+ /** The callout copy (inline links/emphasis allowed; never HTML). */
26
+ body: RichText;
27
+ }
28
+
29
+ /**
30
+ * The a11y + styling recipe for a tone. Kept here (not in the component) so the
31
+ * semantics are one testable mapping: `role` and `label` drive the assistive
32
+ * announcement, `tokenVar` is the accent/tint token, `iconPath` is the SVG path.
33
+ * `role` stays "note" for all tones — these are STATIC page asides, not live
34
+ * regions, so "alert" would be wrong (it would interrupt a screen reader).
35
+ */
36
+ export interface CalloutPresentation {
37
+ role: "note";
38
+ /** aria-label for the callout region (announces its nature). */
39
+ label: string;
40
+ /** The contract token this tone tints from (accent + border + icon). */
41
+ tokenVar: `--color-${string}`;
42
+ /** SVG path `d` for the tone's leading icon (info "i" / caution "!" / check). */
43
+ iconPath: string;
44
+ }
45
+
46
+ // Simple, self-contained 24-grid icon paths (stroke, currentColor via token).
47
+ const ICON_INFO =
48
+ "M12 16v-5M12 8h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z";
49
+ const ICON_CAUTION =
50
+ "M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z";
51
+ const ICON_CHECK = "M20 6 9 17l-5-5";
52
+
53
+ const PRESENTATION: Record<CalloutTone, CalloutPresentation> = {
54
+ info: { role: "note", label: "Note", tokenVar: "--color-info", iconPath: ICON_INFO },
55
+ warning: {
56
+ role: "note",
57
+ label: "Important",
58
+ tokenVar: "--color-warning",
59
+ iconPath: ICON_CAUTION,
60
+ },
61
+ legal: {
62
+ role: "note",
63
+ label: "Legal notice",
64
+ tokenVar: "--color-warning",
65
+ iconPath: ICON_CAUTION,
66
+ },
67
+ success: {
68
+ role: "note",
69
+ label: "Confirmed",
70
+ tokenVar: "--color-success",
71
+ iconPath: ICON_CHECK,
72
+ },
73
+ };
74
+
75
+ /** The a11y + tone recipe for a callout tone. */
76
+ export function calloutPresentation(tone: CalloutTone): CalloutPresentation {
77
+ return PRESENTATION[tone] ?? PRESENTATION.info;
78
+ }