@thorprovider/storefront 0.3.1 → 0.4.0-mvp.2

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 (263) hide show
  1. package/README.md +321 -152
  2. package/VENDOR.md +69 -0
  3. package/assets/openspec/config.yaml +38 -0
  4. package/assets/templates/AGENTS.md +76 -0
  5. package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
  6. package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
  7. package/assets/templates/app/app/globals.css +5 -8
  8. package/assets/templates/app/app/layout.tsx +17 -7
  9. package/assets/templates/app/app/robots.ts +19 -0
  10. package/assets/templates/app/app/sitemap.ts +71 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  13. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
  14. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  16. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
  17. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  18. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  20. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
  21. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
  22. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  24. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  27. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
  30. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  31. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
  32. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  34. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  39. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
  40. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  41. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  42. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  43. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  44. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  45. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  46. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  47. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  48. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  49. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  50. package/assets/templates/features/checkout/handlers.ts +159 -0
  51. package/assets/templates/features/checkout/state.ts +61 -0
  52. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  53. package/assets/templates/features/customer-auth/state.ts +45 -0
  54. package/assets/templates/features/recommendations/state.ts +11 -0
  55. package/assets/templates/views/account/content.ts +35 -0
  56. package/assets/templates/views/account/index.tsx +114 -0
  57. package/assets/templates/views/account/registry.tsx +439 -0
  58. package/assets/templates/views/account/schema.ts +53 -0
  59. package/assets/templates/views/cart/content.ts +11 -0
  60. package/assets/templates/views/cart/data.ts +23 -0
  61. package/assets/templates/views/cart/index.tsx +53 -0
  62. package/assets/templates/views/cart/props.ts +12 -0
  63. package/assets/templates/views/cart/registry.tsx +81 -0
  64. package/assets/templates/views/cart/schema.ts +52 -0
  65. package/assets/templates/views/category/assembly/assembly.json +29 -0
  66. package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
  67. package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
  68. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  69. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  70. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  71. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  72. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  73. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  74. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  75. package/assets/templates/views/category/content.ts +30 -0
  76. package/assets/templates/views/category/data.ts +12 -0
  77. package/assets/templates/views/category/index.tsx +299 -0
  78. package/assets/templates/views/category/props.ts +23 -0
  79. package/assets/templates/views/category/registry.tsx +122 -0
  80. package/assets/templates/views/category/schema.ts +91 -0
  81. package/assets/templates/views/checkout/content.ts +105 -0
  82. package/assets/templates/views/checkout/data.ts +23 -0
  83. package/assets/templates/views/checkout/index.tsx +111 -0
  84. package/assets/templates/views/checkout/props.ts +49 -0
  85. package/assets/templates/views/checkout/registry.tsx +217 -0
  86. package/assets/templates/views/checkout/schema.ts +229 -0
  87. package/assets/templates/views/home/assembly/assembly.json +33 -0
  88. package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
  89. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  90. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  91. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  92. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  93. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  94. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  95. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  96. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  97. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  98. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  99. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  100. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  101. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  102. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  103. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  104. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  105. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  106. package/assets/templates/views/home/content.ts +70 -0
  107. package/assets/templates/views/home/data.ts +24 -0
  108. package/assets/templates/views/home/index.tsx +57 -85
  109. package/assets/templates/views/home/props.ts +22 -0
  110. package/assets/templates/views/home/registry.tsx +38 -22
  111. package/assets/templates/views/home/schema.ts +93 -0
  112. package/assets/templates/views/product/content.ts +25 -0
  113. package/assets/templates/views/product/data.ts +50 -0
  114. package/assets/templates/views/product/index.tsx +148 -0
  115. package/assets/templates/views/product/props.ts +27 -0
  116. package/assets/templates/views/product/registry.tsx +103 -0
  117. package/assets/templates/views/product/schema.ts +121 -0
  118. package/assets/templates/views/static/content.ts +99 -0
  119. package/assets/templates/views/static/index.tsx +60 -0
  120. package/assets/templates/views/static/registry.tsx +66 -0
  121. package/assets/templates/views/static/schema.ts +45 -0
  122. package/bin/storefront.js +2424 -148
  123. package/examples/merchandising/category-led.json +37 -0
  124. package/examples/merchandising/collection-led.json +37 -0
  125. package/package.json +15 -9
  126. package/recipes/archetypes.json +28 -14
  127. package/recipes/catalog.json +400 -0
  128. package/recipes/catalog.mjs +128 -0
  129. package/recipes/catalog.schema.json +64 -0
  130. package/recipes/feature.schema.json +89 -0
  131. package/recipes/features.json +162 -0
  132. package/recipes/manifest.schema.json +69 -0
  133. package/recipes/operations.schema.json +43 -0
  134. package/recipes/recipe.schema.json +11 -2
  135. package/recipes/recipes.json +76 -19
  136. package/recipes/sections.json +34 -5
  137. package/recipes/validate.mjs +838 -32
  138. package/scripts/candidate.mjs +236 -0
  139. package/scripts/composers.mjs +160 -0
  140. package/scripts/env-file.mjs +86 -0
  141. package/scripts/file-classes.mjs +121 -0
  142. package/scripts/logical-controls.mjs +61 -0
  143. package/scripts/manifest.mjs +298 -0
  144. package/scripts/migrate-english-routes.mjs +276 -0
  145. package/scripts/operation-registry.mjs +256 -0
  146. package/scripts/operations.mjs +720 -0
  147. package/scripts/ownership-conflict.mjs +356 -0
  148. package/scripts/pinning.mjs +573 -0
  149. package/scripts/plan-apply.mjs +987 -0
  150. package/scripts/preflight.mjs +51 -0
  151. package/scripts/reconcile.mjs +549 -0
  152. package/scripts/render-templates.mjs +338 -68
  153. package/scripts/schema-validate.mjs +132 -0
  154. package/scripts/section-compat.mjs +300 -0
  155. package/scripts/section-config.mjs +253 -0
  156. package/scripts/section-operations.mjs +508 -0
  157. package/scripts/starter.mjs +17 -0
  158. package/scripts/sync-assets.mjs +22 -15
  159. package/scripts/theme/color.mjs +185 -0
  160. package/scripts/theme/css-theme.mjs +233 -0
  161. package/scripts/theme/fonts.mjs +123 -0
  162. package/scripts/theme/import-export.mjs +125 -0
  163. package/scripts/theme/preset.mjs +93 -0
  164. package/scripts/theme/theme-plan.mjs +292 -0
  165. package/scripts/theme/tokens.mjs +137 -0
  166. package/scripts/view-assembly.mjs +337 -0
  167. package/scripts/view-selection.mjs +211 -0
  168. package/skills/sf-feature/SKILL.md +80 -0
  169. package/skills/sf-setup/SKILL.md +79 -0
  170. package/skills/sf-theme-gen/SKILL.md +100 -30
  171. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  172. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  173. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  174. package/skills/sf-view-gen/SKILL.md +146 -93
  175. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
  176. package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
  177. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  178. package/skills/sf-view-gen/assets/recipes/features.json +162 -0
  179. package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
  180. package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
  181. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  182. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  183. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  184. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  185. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  186. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  187. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  188. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  189. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  190. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  191. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  192. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  193. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  194. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  195. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  196. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  197. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  198. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  199. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  200. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  201. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  202. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  203. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  204. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  205. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  206. package/skills/sf-view-gen/references/views.md +58 -33
  207. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  208. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  209. package/skills/sf-view-gen/scripts/validate.mjs +838 -32
  210. package/skills/storefront-workflow/SKILL.md +179 -0
  211. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  212. package/CHANGELOG.md +0 -61
  213. package/commands/sf-add-view.md +0 -26
  214. package/commands/sf-theme.md +0 -20
  215. package/commands/sf-view.md +0 -25
  216. package/skills/building-storefronts/SKILL.md +0 -178
  217. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  218. package/skills/json-render-remotion/SKILL.md +0 -111
  219. package/skills/json-render-solid/SKILL.md +0 -204
  220. package/skills/nextjs-shadcn/SKILL.md +0 -367
  221. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  222. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  223. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  224. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  225. package/skills/nextjs-shadcn/references/styling.md +0 -555
  226. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  227. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  228. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  229. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  230. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  231. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
  232. package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
  233. package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
  234. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  235. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  236. package/skills/shadcn-theming/SKILL.md +0 -104
  237. package/skills/storefront-best-practices/SKILL.md +0 -421
  238. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  239. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  240. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  241. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  242. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  243. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  244. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  245. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  246. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  247. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  248. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  249. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  250. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  251. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  252. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  253. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  254. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  255. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  256. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  257. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  258. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  259. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  260. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  261. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  262. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  263. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -0,0 +1,470 @@
1
+ // Generated by Thor Commerce storefront (composition core: session).
2
+ //
3
+ // Host session core — pure signing and validation for the storefront session
4
+ // cookie. No Next.js, no React, no I/O: this module turns a validated session
5
+ // payload into a signed, tamper-evident cookie value and back, so it is unit
6
+ // testable in isolation. The Next host (`./session`) owns the actual cookie.
7
+ //
8
+ // Shapes are owned once by `@thorprovider/types`; their validation is owned by
9
+ // `@thorprovider/runtime/contracts`. This file adds no commerce logic and calls
10
+ // no backend and no external SDK.
11
+ //
12
+ // Early guest receipt gate (change B, task 2.1) — recorded here because change
13
+ // B edits no backend source:
14
+ // - The host issues this signed, httpOnly, store-scoped capability cookie. The
15
+ // signing secret is host-only (`THOR_SESSION_SECRET`), independent of the
16
+ // backend, and is never embedded, generated as a constant, or printed.
17
+ // - Receipt recovery reads `receiptCartId` from THIS verified cookie only. A
18
+ // bare client-supplied order id or cart id never authorizes a receipt.
19
+ // - Recovery (integration, not part of this foundation) MUST GET the backend
20
+ // cart and require `cart.completed_at`, then issue a single-shot native
21
+ // complete to reconcile and return the SAME persisted order.
22
+ // - Owned backend follow-up (debt, not fixed by change B): the native public
23
+ // order/cart lookup that reconcile needs is not yet verified; the runtime
24
+ // commerce provider (adapters/runtime only, no direct SDK call) must expose
25
+ // it before the UI promises recovery.
26
+
27
+ import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'
28
+ import type { StoreContext } from '@thorprovider/types'
29
+ import { storeContextSchema } from '@thorprovider/runtime/contracts'
30
+
31
+ /** Cookie value format version; bump on any incompatible payload change. */
32
+ export const SESSION_PAYLOAD_VERSION = 1
33
+
34
+ /** Default session lifetime: 30 days, mirrored by the cookie `maxAge`. */
35
+ export const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30
36
+
37
+ /** Cookie-name prefix; the full name is store-scoped via `sessionCookieName`. */
38
+ export const SESSION_COOKIE_PREFIX = 'thor_session_'
39
+
40
+ /** Domain separator so a session HMAC can never be replayed as another token. */
41
+ const SIGNING_DOMAIN = 'thor-session.v1'
42
+
43
+ /** A short secret is a weak HMAC key; fail loud instead of silently weakening. */
44
+ const MIN_SECRET_LENGTH = 16
45
+
46
+ export type SessionStatus = 'active' | 'completed'
47
+
48
+ /**
49
+ * The signed capability carried by the session cookie.
50
+ *
51
+ * `cartId` is the active backend guest cart; `receiptCartId` is the completed
52
+ * cart kept for receipt recovery after the active cart is cleared. Both are
53
+ * backend ids the host persists — the client never supplies either one to
54
+ * authorize a receipt.
55
+ */
56
+ export interface HostSessionPayload {
57
+ version: typeof SESSION_PAYLOAD_VERSION
58
+ /** Validated single-store context; binds the cookie to store/channel/region. */
59
+ context: StoreContext
60
+ /** Opaque guest shopper identity (a UUID issued by the host). */
61
+ shopperId: string
62
+ /** Active backend cart id, or null when no cart is active. */
63
+ cartId: string | null
64
+ /** Completed backend cart id kept for recovery, or null. */
65
+ receiptCartId: string | null
66
+ /** `active` while shopping; `completed` after a confirmed order. */
67
+ status: SessionStatus
68
+ /** Issued-at epoch seconds. */
69
+ issuedAt: number
70
+ /** Expiry epoch seconds; enforced in addition to the cookie `maxAge`. */
71
+ expiresAt: number
72
+ }
73
+
74
+ export interface CreateSessionOptions {
75
+ /** Injectable clock (epoch seconds) for deterministic tests. */
76
+ now?: number
77
+ /** Override the default lifetime. */
78
+ ttlSeconds?: number
79
+ /** Reuse an identity instead of issuing a fresh UUID. */
80
+ shopperId?: string
81
+ }
82
+
83
+ export interface DecodeSessionOptions {
84
+ /** Injectable clock (epoch seconds) for deterministic tests. */
85
+ now?: number
86
+ /** Reject a cookie whose store/channel/region/currency does not match. */
87
+ expectedContext?: StoreContext
88
+ }
89
+
90
+ function assertSecret(secret: string): void {
91
+ if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH) {
92
+ throw new Error(
93
+ `session signing secret must be a string of at least ${MIN_SECRET_LENGTH} characters`,
94
+ )
95
+ }
96
+ }
97
+
98
+ function sign(body: string, secret: string): string {
99
+ return createHmac('sha256', secret)
100
+ .update(`${SIGNING_DOMAIN}.${body}`)
101
+ .digest('base64url')
102
+ }
103
+
104
+ function nowSeconds(): number {
105
+ return Math.floor(Date.now() / 1000)
106
+ }
107
+
108
+ function isNonEmptyString(value: unknown): value is string {
109
+ return typeof value === 'string' && value.length > 0
110
+ }
111
+
112
+ function isNullableString(value: unknown): value is string | null {
113
+ return value === null || isNonEmptyString(value)
114
+ }
115
+
116
+ function isFiniteInteger(value: unknown): value is number {
117
+ return typeof value === 'number' && Number.isInteger(value) && Number.isFinite(value)
118
+ }
119
+
120
+ /**
121
+ * Store-scoped, cookie-name-safe session cookie name.
122
+ *
123
+ * Two stores on the same host therefore get distinct cookie names, and an
124
+ * unexpected store id cannot collide with another store's cookie.
125
+ *
126
+ * Sanitization can make distinct ids collide (`a.b` and `a_b` both map to
127
+ * `a_b`); because the cookie is one per app, a collision would let one store
128
+ * overwrite another's session. When sanitization changed the id, a short digest
129
+ * of the original id is appended so distinct stores always get distinct names.
130
+ */
131
+ export function sessionCookieName(storeId: string): string {
132
+ const safe = storeId.replace(/[^A-Za-z0-9_-]/g, '_')
133
+ if (safe.length === 0) {
134
+ throw new Error('sessionCookieName: storeId must contain a cookie-safe character')
135
+ }
136
+ if (safe === storeId) return `${SESSION_COOKIE_PREFIX}${safe}`
137
+ const digest = createHash('sha256').update(storeId).digest('base64url').slice(0, 10)
138
+ return `${SESSION_COOKIE_PREFIX}${safe}_${digest}`
139
+ }
140
+
141
+ /** Issue a fresh opaque guest identity. Two calls never collide. */
142
+ export function newShopperId(): string {
143
+ return randomUUID()
144
+ }
145
+
146
+ /**
147
+ * Build a new active session for a store context.
148
+ *
149
+ * The context is validated before the cookie is ever issued, so a cookie is
150
+ * only created for a valid store/channel/region/currency.
151
+ */
152
+ export function createHostSession(
153
+ context: StoreContext,
154
+ options: CreateSessionOptions = {},
155
+ ): HostSessionPayload {
156
+ const validated = storeContextSchema.parse(context)
157
+ const issuedAt = options.now ?? nowSeconds()
158
+ const ttlSeconds = options.ttlSeconds ?? SESSION_TTL_SECONDS
159
+ return {
160
+ version: SESSION_PAYLOAD_VERSION,
161
+ context: validated,
162
+ shopperId: options.shopperId ?? newShopperId(),
163
+ cartId: null,
164
+ receiptCartId: null,
165
+ status: 'active',
166
+ issuedAt,
167
+ expiresAt: issuedAt + ttlSeconds,
168
+ }
169
+ }
170
+
171
+ /** Sign a session payload into a `<body>.<signature>` cookie value. */
172
+ export function encodeHostSession(payload: HostSessionPayload, secret: string): string {
173
+ assertSecret(secret)
174
+ const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
175
+ return `${body}.${sign(body, secret)}`
176
+ }
177
+
178
+ /**
179
+ * Verify and decode a session cookie value.
180
+ *
181
+ * Returns null for a missing, malformed, tampered, expired, or wrong-store
182
+ * cookie — the caller then clears/regenerates rather than trusting client ids.
183
+ * The signature is compared in constant time.
184
+ */
185
+ export function decodeHostSession(
186
+ raw: string | undefined | null,
187
+ secret: string,
188
+ options: DecodeSessionOptions = {},
189
+ ): HostSessionPayload | null {
190
+ assertSecret(secret)
191
+ if (typeof raw !== 'string' || raw.length === 0) return null
192
+
193
+ const parts = raw.split('.')
194
+ if (parts.length !== 2) return null
195
+ const [body, provided] = parts
196
+ if (body.length === 0 || provided.length === 0) return null
197
+
198
+ const expected = sign(body, secret)
199
+ const providedBuf = Buffer.from(provided, 'base64url')
200
+ const expectedBuf = Buffer.from(expected, 'base64url')
201
+ if (providedBuf.length !== expectedBuf.length) return null
202
+ if (!timingSafeEqual(providedBuf, expectedBuf)) return null
203
+
204
+ let parsed: unknown
205
+ try {
206
+ parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
207
+ } catch {
208
+ return null
209
+ }
210
+
211
+ const payload = parseSessionPayload(parsed)
212
+ if (!payload) return null
213
+
214
+ const now = options.now ?? nowSeconds()
215
+ if (payload.expiresAt <= now) return null
216
+ if (options.expectedContext && !sessionMatchesContext(payload, options.expectedContext)) {
217
+ return null
218
+ }
219
+ return payload
220
+ }
221
+
222
+ /**
223
+ * Validate a decoded payload against the contract.
224
+ *
225
+ * The store context is re-validated with the shared runtime schema; a completed
226
+ * session must carry a receipt cart so recovery has an authorized handle.
227
+ */
228
+ function parseSessionPayload(input: unknown): HostSessionPayload | null {
229
+ if (typeof input !== 'object' || input === null) return null
230
+ const candidate = input as Record<string, unknown>
231
+ if (candidate.version !== SESSION_PAYLOAD_VERSION) return null
232
+
233
+ const context = storeContextSchema.safeParse(candidate.context)
234
+ if (!context.success) return null
235
+
236
+ if (!isNonEmptyString(candidate.shopperId)) return null
237
+ if (!isNullableString(candidate.cartId)) return null
238
+ if (!isNullableString(candidate.receiptCartId)) return null
239
+ if (candidate.status !== 'active' && candidate.status !== 'completed') return null
240
+ if (candidate.status === 'completed' && !isNonEmptyString(candidate.receiptCartId)) return null
241
+ if (!isFiniteInteger(candidate.issuedAt) || !isFiniteInteger(candidate.expiresAt)) return null
242
+
243
+ return {
244
+ version: SESSION_PAYLOAD_VERSION,
245
+ context: context.data,
246
+ shopperId: candidate.shopperId,
247
+ cartId: candidate.cartId,
248
+ receiptCartId: candidate.receiptCartId,
249
+ status: candidate.status,
250
+ issuedAt: candidate.issuedAt,
251
+ expiresAt: candidate.expiresAt,
252
+ }
253
+ }
254
+
255
+ /** True when the payload is bound to exactly this store/channel/region/currency. */
256
+ export function sessionMatchesContext(
257
+ payload: HostSessionPayload,
258
+ context: StoreContext,
259
+ ): boolean {
260
+ return (
261
+ payload.context.storeId === context.storeId &&
262
+ payload.context.salesChannelId === context.salesChannelId &&
263
+ payload.context.regionId === context.regionId &&
264
+ payload.context.currencyCode === context.currencyCode
265
+ )
266
+ }
267
+
268
+ /** Attach the active backend cart id to the session. */
269
+ export function bindSessionCart(
270
+ payload: HostSessionPayload,
271
+ cartId: string,
272
+ ): HostSessionPayload {
273
+ if (!isNonEmptyString(cartId)) throw new Error('bindSessionCart: cartId is required')
274
+ return { ...payload, cartId, status: 'active' }
275
+ }
276
+
277
+ /**
278
+ * Record a confirmed order for `receiptCartId`.
279
+ *
280
+ * The active cart is cleared ONLY when it is the cart that was completed. If
281
+ * the session has a DIFFERENT active cart (a new purchase started after an
282
+ * earlier order), that cart is preserved and the session stays `active` while
283
+ * the completed receipt is retained for recovery. When there is no active cart,
284
+ * the session becomes `completed`.
285
+ *
286
+ * Call this ONLY on a confirmed order — on any error the active cart must be
287
+ * retained and no receipt recorded, so never call it in an error path.
288
+ */
289
+ export function completeSessionCart(
290
+ payload: HostSessionPayload,
291
+ receiptCartId: string,
292
+ ): HostSessionPayload {
293
+ if (!isNonEmptyString(receiptCartId)) {
294
+ throw new Error('completeSessionCart: receiptCartId is required')
295
+ }
296
+ const hasActiveCart = isNonEmptyString(payload.cartId)
297
+ const isSameCart = hasActiveCart && payload.cartId === receiptCartId
298
+ return {
299
+ ...payload,
300
+ cartId: isSameCart ? null : payload.cartId,
301
+ receiptCartId,
302
+ status: isSameCart || !hasActiveCart ? 'completed' : 'active',
303
+ }
304
+ }
305
+
306
+ /**
307
+ * True when the verified session carries a completed cart safe to recover.
308
+ *
309
+ * Recovery is keyed on the presence of `receiptCartId`, not on the `status`
310
+ * field: a shopper who starts a NEW cart after a completed order keeps the
311
+ * prior receipt cart id on the same cookie (status returns to `active`), so the
312
+ * proof cookie is never lost while the new cart is active.
313
+ */
314
+ export function canRecoverReceipt(
315
+ payload: HostSessionPayload | null,
316
+ ): payload is HostSessionPayload & { receiptCartId: string } {
317
+ return payload !== null && isNonEmptyString(payload.receiptCartId)
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Customer auth codec (change: storefront-customer-auth, task 2.1)
322
+ // ---------------------------------------------------------------------------
323
+ //
324
+ // The auth cookie is a DEDICATED, store-scoped, httpOnly, HMAC-signed cookie
325
+ // that carries the customer JWT for the duration of a request. It is separate
326
+ // from the guest session cookie so the accepted guest payload/version is never
327
+ // touched (see design D1). This module stays pure: no Next.js, no React, no I/O,
328
+ // and no commerce logic. The Next host (`./session`) owns the actual cookie.
329
+
330
+ /** Cookie value format version; bump on any incompatible auth payload change. */
331
+ export const AUTH_PAYLOAD_VERSION = 1
332
+
333
+ /** Default auth token lifetime: 30 days, mirrored by the cookie `maxAge`. */
334
+ export const AUTH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30
335
+
336
+ /** Cookie-name prefix; the full name is store-scoped via `authCookieName`. */
337
+ export const AUTH_COOKIE_PREFIX = 'thor_auth_'
338
+
339
+ /** Domain separator so an auth HMAC can never be replayed as another token. */
340
+ export const AUTH_SIGNING_DOMAIN = 'thor-auth.v1'
341
+
342
+ /**
343
+ * The signed capability carried by the auth cookie.
344
+ *
345
+ * `token` is the customer JWT the backend issued; it lives only in this
346
+ * httpOnly cookie and in server-side request scope and is never sent to the
347
+ * client. The cookie is bound to `storeId` so a session issued for one store
348
+ * cannot authenticate another.
349
+ */
350
+ export interface HostAuthPayload {
351
+ version: number
352
+ storeId: string
353
+ token: string
354
+ /** Issued-at epoch seconds. */
355
+ issuedAt: number
356
+ /** Expiry epoch seconds; enforced in addition to the cookie `maxAge`. */
357
+ expiresAt: number
358
+ }
359
+
360
+ export interface CreateHostAuthOptions {
361
+ /** Injectable clock (epoch seconds) for deterministic tests. */
362
+ now?: number
363
+ /** Override the default lifetime. */
364
+ ttlSeconds?: number
365
+ }
366
+
367
+ export interface DecodeHostAuthOptions {
368
+ /** Injectable clock (epoch seconds) for deterministic tests. */
369
+ now?: number
370
+ /** Reject a cookie issued for a different store. */
371
+ expectedStoreId?: string
372
+ }
373
+
374
+ /** Store-scoped auth cookie name. Distinct from the guest session cookie. */
375
+ export function authCookieName(storeId: string): string {
376
+ return `${AUTH_COOKIE_PREFIX}${storeId}`
377
+ }
378
+
379
+ function signAuth(body: string, secret: string): string {
380
+ return createHmac('sha256', secret)
381
+ .update(`${AUTH_SIGNING_DOMAIN}.${body}`)
382
+ .digest('base64url')
383
+ }
384
+
385
+ /** Build a new auth payload for a store and customer token. */
386
+ export function createHostAuth(
387
+ storeId: string,
388
+ token: string,
389
+ options: CreateHostAuthOptions = {},
390
+ ): HostAuthPayload {
391
+ if (!isNonEmptyString(storeId)) throw new Error('createHostAuth: storeId is required')
392
+ if (!isNonEmptyString(token)) throw new Error('createHostAuth: token is required')
393
+ const issuedAt = options.now ?? nowSeconds()
394
+ const ttlSeconds = options.ttlSeconds ?? AUTH_TOKEN_TTL_SECONDS
395
+ return {
396
+ version: AUTH_PAYLOAD_VERSION,
397
+ storeId,
398
+ token,
399
+ issuedAt,
400
+ expiresAt: issuedAt + ttlSeconds,
401
+ }
402
+ }
403
+
404
+ /** Sign an auth payload into a `<body>.<signature>` cookie value. */
405
+ export function encodeHostAuth(payload: HostAuthPayload, secret: string): string {
406
+ assertSecret(secret)
407
+ const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
408
+ return `${body}.${signAuth(body, secret)}`
409
+ }
410
+
411
+ /**
412
+ * Verify and decode an auth cookie value.
413
+ *
414
+ * Returns null for a missing, malformed, tampered, expired, or wrong-store
415
+ * cookie. The signature is compared in constant time.
416
+ */
417
+ export function decodeHostAuth(
418
+ raw: string | undefined | null,
419
+ secret: string,
420
+ options: DecodeHostAuthOptions = {},
421
+ ): HostAuthPayload | null {
422
+ assertSecret(secret)
423
+ if (typeof raw !== 'string' || raw.length === 0) return null
424
+
425
+ const parts = raw.split('.')
426
+ if (parts.length !== 2) return null
427
+ const [body, provided] = parts
428
+ if (body.length === 0 || provided.length === 0) return null
429
+
430
+ const expected = signAuth(body, secret)
431
+ const providedBuf = Buffer.from(provided, 'base64url')
432
+ const expectedBuf = Buffer.from(expected, 'base64url')
433
+ if (providedBuf.length !== expectedBuf.length) return null
434
+ if (!timingSafeEqual(providedBuf, expectedBuf)) return null
435
+
436
+ let parsed: unknown
437
+ try {
438
+ parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
439
+ } catch {
440
+ return null
441
+ }
442
+
443
+ const payload = parseAuthPayload(parsed)
444
+ if (!payload) return null
445
+
446
+ const now = options.now ?? nowSeconds()
447
+ if (payload.expiresAt <= now) return null
448
+ if (options.expectedStoreId !== undefined && payload.storeId !== options.expectedStoreId) {
449
+ return null
450
+ }
451
+ return payload
452
+ }
453
+
454
+ /** Validate a decoded auth payload against the contract. */
455
+ function parseAuthPayload(input: unknown): HostAuthPayload | null {
456
+ if (typeof input !== 'object' || input === null) return null
457
+ const candidate = input as Record<string, unknown>
458
+ if (candidate.version !== AUTH_PAYLOAD_VERSION) return null
459
+ if (!isNonEmptyString(candidate.storeId)) return null
460
+ if (!isNonEmptyString(candidate.token)) return null
461
+ if (!isFiniteInteger(candidate.issuedAt) || !isFiniteInteger(candidate.expiresAt)) return null
462
+
463
+ return {
464
+ version: AUTH_PAYLOAD_VERSION,
465
+ storeId: candidate.storeId,
466
+ token: candidate.token,
467
+ issuedAt: candidate.issuedAt,
468
+ expiresAt: candidate.expiresAt,
469
+ }
470
+ }
@@ -0,0 +1,169 @@
1
+ // Generated by Thor Commerce storefront (composition core: session host).
2
+ //
3
+ // Host session cookie — the Next-bound half of the guest session. `./session.core`
4
+ // owns the pure signing and validation; this module owns the cookie.
5
+ //
6
+ // Next.js 16 `cookies()` is async and may only be MUTATED from a Server Action
7
+ // or a Route Handler — never during a Server Component render. `readHostSession`
8
+ // is therefore the only render-safe export; every other export writes or clears
9
+ // the cookie and MUST be called from an action or route. This is why a guest
10
+ // identity is created in an action/route bootstrap, never in a read-only render.
11
+ //
12
+ // The signing secret is required host configuration (`THOR_SESSION_SECRET`),
13
+ // independent of the backend. It is never embedded, generated as a constant, or
14
+ // printed; a missing or too-short secret throws before any cookie is written.
15
+ // No external SDK is called here; commerce data stays behind adapters/runtime.
16
+
17
+ import { cookies } from 'next/headers'
18
+ import type { StoreContext } from '@thorprovider/types'
19
+ import {
20
+ AUTH_TOKEN_TTL_SECONDS,
21
+ SESSION_TTL_SECONDS,
22
+ authCookieName,
23
+ bindSessionCart,
24
+ completeSessionCart,
25
+ createHostAuth,
26
+ createHostSession,
27
+ decodeHostAuth,
28
+ decodeHostSession,
29
+ encodeHostAuth,
30
+ encodeHostSession,
31
+ sessionCookieName,
32
+ type HostSessionPayload,
33
+ } from './session.core'
34
+
35
+ /** Host environment variable that carries the cookie signing secret. */
36
+ export const SESSION_SECRET_ENV = 'THOR_SESSION_SECRET'
37
+
38
+ /** Read the required host signing secret; throws (loud) when unset. */
39
+ export function getSessionSecret(): string {
40
+ const secret = process.env[SESSION_SECRET_ENV]
41
+ if (!secret || secret.trim() === '') {
42
+ throw new Error(
43
+ `${SESSION_SECRET_ENV} is required to sign the storefront session cookie; set it in the host environment (never commit or log it).`,
44
+ )
45
+ }
46
+ return secret
47
+ }
48
+
49
+ /** Hardened cookie flags: httpOnly, SameSite=Lax, secure in production, TTL. */
50
+ function cookieOptions() {
51
+ return {
52
+ httpOnly: true,
53
+ sameSite: 'lax' as const,
54
+ secure: process.env.NODE_ENV === 'production',
55
+ path: '/',
56
+ maxAge: SESSION_TTL_SECONDS,
57
+ }
58
+ }
59
+
60
+ /**
61
+ * READ-ONLY — safe during a Server Component render.
62
+ *
63
+ * Returns the verified session, or null for a missing/tampered/expired/wrong-store
64
+ * cookie. A null result means "no trusted identity"; the caller must obtain one
65
+ * from a Server Action or Route Handler, never from client-supplied ids.
66
+ */
67
+ export async function readHostSession(
68
+ context: StoreContext,
69
+ ): Promise<HostSessionPayload | null> {
70
+ const store = await cookies()
71
+ const raw = store.get(sessionCookieName(context.storeId))?.value
72
+ return decodeHostSession(raw, getSessionSecret(), { expectedContext: context })
73
+ }
74
+
75
+ /** MUTATION — Server Action or Route Handler only. Issues a fresh identity. */
76
+ export async function issueHostSession(context: StoreContext): Promise<HostSessionPayload> {
77
+ const payload = createHostSession(context)
78
+ await writeHostSession(context, payload)
79
+ return payload
80
+ }
81
+
82
+ /**
83
+ * MUTATION — Server Action or Route Handler only. Attaches the active backend
84
+ * cart to the current identity, issuing one first if the cookie is absent or
85
+ * untrusted (regenerate instead of trusting a client id).
86
+ */
87
+ export async function bindHostSessionCart(
88
+ context: StoreContext,
89
+ cartId: string,
90
+ ): Promise<HostSessionPayload> {
91
+ const current = await readHostSession(context)
92
+ const next = bindSessionCart(current ?? createHostSession(context), cartId)
93
+ await writeHostSession(context, next)
94
+ return next
95
+ }
96
+
97
+ /**
98
+ * MUTATION — Server Action or Route Handler only.
99
+ *
100
+ * Clears the active cart ONLY on a confirmed order and preserves `receiptCartId`
101
+ * for recovery. `receiptCartId` must come from the host's own completed cart,
102
+ * never from client input. On any error or unresolved completion the caller
103
+ * must NOT call this, so the active cart is retained and no receipt is recorded.
104
+ */
105
+ export async function completeHostSessionCart(
106
+ context: StoreContext,
107
+ receiptCartId: string,
108
+ ): Promise<HostSessionPayload> {
109
+ const current = await readHostSession(context)
110
+ const next = completeSessionCart(current ?? createHostSession(context), receiptCartId)
111
+ await writeHostSession(context, next)
112
+ return next
113
+ }
114
+
115
+ /** MUTATION — Server Action or Route Handler only. Clears the session cookie. */
116
+ export async function clearHostSession(context: StoreContext): Promise<void> {
117
+ const store = await cookies()
118
+ store.delete(sessionCookieName(context.storeId))
119
+ }
120
+
121
+ async function writeHostSession(
122
+ context: StoreContext,
123
+ payload: HostSessionPayload,
124
+ ): Promise<void> {
125
+ const store = await cookies()
126
+ store.set(
127
+ sessionCookieName(context.storeId),
128
+ encodeHostSession(payload, getSessionSecret()),
129
+ cookieOptions(),
130
+ )
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Customer auth cookie (change: storefront-customer-auth, task 2.1)
135
+ // ---------------------------------------------------------------------------
136
+ //
137
+ // The auth cookie is separate from the guest session cookie and reuses the same
138
+ // host-only secret and hardened flags. It carries only the customer JWT; the
139
+ // guest session payload/version and the guest cart are never read or written
140
+ // here. As with the guest session, only `readAuthToken` is render-safe.
141
+
142
+ /**
143
+ * READ-ONLY — safe during a Server Component render.
144
+ *
145
+ * Returns the verified customer token, or null for a missing/tampered/expired/
146
+ * wrong-store cookie. A null result means "not authenticated".
147
+ */
148
+ export async function readAuthToken(context: StoreContext): Promise<string | null> {
149
+ const store = await cookies()
150
+ const raw = store.get(authCookieName(context.storeId))?.value
151
+ const payload = decodeHostAuth(raw, getSessionSecret(), { expectedStoreId: context.storeId })
152
+ return payload?.token ?? null
153
+ }
154
+
155
+ /** MUTATION — Server Action or Route Handler only. Persists the customer token. */
156
+ export async function writeAuthToken(context: StoreContext, token: string): Promise<void> {
157
+ const store = await cookies()
158
+ store.set(
159
+ authCookieName(context.storeId),
160
+ encodeHostAuth(createHostAuth(context.storeId, token), getSessionSecret()),
161
+ { ...cookieOptions(), maxAge: AUTH_TOKEN_TTL_SECONDS },
162
+ )
163
+ }
164
+
165
+ /** MUTATION — Server Action or Route Handler only. Clears the auth cookie. */
166
+ export async function clearAuthToken(context: StoreContext): Promise<void> {
167
+ const store = await cookies()
168
+ store.delete(authCookieName(context.storeId))
169
+ }