@thorprovider/storefront 0.3.1 → 0.4.0-mvp.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 (179) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +248 -99
  3. package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
  4. package/assets/templates/app/app/layout.tsx +18 -4
  5. package/assets/templates/app/app/robots.ts +13 -0
  6. package/assets/templates/app/app/sitemap.ts +12 -0
  7. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
  8. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  9. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
  10. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  13. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  15. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
  16. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
  17. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
  22. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  27. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  30. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  31. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  32. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  34. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  39. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  40. package/assets/templates/features/checkout/handlers.ts +153 -0
  41. package/assets/templates/features/checkout/state.ts +61 -0
  42. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  43. package/assets/templates/features/customer-auth/state.ts +36 -0
  44. package/assets/templates/features/recommendations/state.ts +11 -0
  45. package/assets/templates/views/account/content.ts +35 -0
  46. package/assets/templates/views/account/index.tsx +114 -0
  47. package/assets/templates/views/account/registry.tsx +378 -0
  48. package/assets/templates/views/account/schema.ts +52 -0
  49. package/assets/templates/views/category/assembly/assembly.json +28 -0
  50. package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
  51. package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
  52. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  53. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  54. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  55. package/assets/templates/views/category/content.ts +30 -0
  56. package/assets/templates/views/category/data.ts +12 -0
  57. package/assets/templates/views/category/index.tsx +230 -0
  58. package/assets/templates/views/category/props.ts +16 -0
  59. package/assets/templates/views/category/registry.tsx +103 -0
  60. package/assets/templates/views/category/schema.ts +78 -0
  61. package/assets/templates/views/checkout/content.ts +105 -0
  62. package/assets/templates/views/checkout/data.ts +23 -0
  63. package/assets/templates/views/checkout/index.tsx +111 -0
  64. package/assets/templates/views/checkout/props.ts +49 -0
  65. package/assets/templates/views/checkout/registry.tsx +217 -0
  66. package/assets/templates/views/checkout/schema.ts +229 -0
  67. package/assets/templates/views/home/assembly/assembly.json +24 -0
  68. package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
  69. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  70. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  71. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  72. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  73. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  74. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  75. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  76. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  77. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  78. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  79. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  80. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  81. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  82. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  83. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  84. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  85. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  86. package/assets/templates/views/home/content.ts +70 -0
  87. package/assets/templates/views/home/data.ts +15 -0
  88. package/assets/templates/views/home/index.tsx +38 -84
  89. package/assets/templates/views/home/props.ts +22 -0
  90. package/assets/templates/views/home/registry.tsx +38 -22
  91. package/assets/templates/views/home/schema.ts +93 -0
  92. package/assets/templates/views/product/content.ts +25 -0
  93. package/assets/templates/views/product/data.ts +48 -0
  94. package/assets/templates/views/product/index.tsx +104 -0
  95. package/assets/templates/views/product/props.ts +20 -0
  96. package/assets/templates/views/product/registry.tsx +78 -0
  97. package/assets/templates/views/product/schema.ts +108 -0
  98. package/assets/templates/views/static/content.ts +99 -0
  99. package/assets/templates/views/static/index.tsx +60 -0
  100. package/assets/templates/views/static/registry.tsx +66 -0
  101. package/assets/templates/views/static/schema.ts +45 -0
  102. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
  103. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
  104. package/assets/templates/views/user-panel/schema.ts +79 -0
  105. package/bin/storefront.js +1278 -79
  106. package/commands/sf-add-view.md +44 -13
  107. package/commands/sf-feature.md +28 -0
  108. package/commands/sf-theme.md +14 -8
  109. package/commands/sf-view.md +54 -12
  110. package/package.json +6 -4
  111. package/recipes/archetypes.json +28 -1
  112. package/recipes/catalog.json +361 -0
  113. package/recipes/catalog.mjs +128 -0
  114. package/recipes/catalog.schema.json +64 -0
  115. package/recipes/feature.schema.json +89 -0
  116. package/recipes/features.json +139 -0
  117. package/recipes/manifest.schema.json +69 -0
  118. package/recipes/operations.schema.json +43 -0
  119. package/recipes/recipe.schema.json +11 -2
  120. package/recipes/recipes.json +60 -5
  121. package/recipes/sections.json +24 -5
  122. package/recipes/validate.mjs +784 -32
  123. package/scripts/candidate.mjs +236 -0
  124. package/scripts/composers.mjs +140 -0
  125. package/scripts/file-classes.mjs +116 -0
  126. package/scripts/logical-controls.mjs +61 -0
  127. package/scripts/manifest.mjs +298 -0
  128. package/scripts/operation-registry.mjs +256 -0
  129. package/scripts/operations.mjs +720 -0
  130. package/scripts/ownership-conflict.mjs +356 -0
  131. package/scripts/pinning.mjs +504 -0
  132. package/scripts/plan-apply.mjs +843 -0
  133. package/scripts/preflight.mjs +51 -0
  134. package/scripts/reconcile.mjs +511 -0
  135. package/scripts/render-templates.mjs +245 -28
  136. package/scripts/schema-validate.mjs +132 -0
  137. package/scripts/section-compat.mjs +300 -0
  138. package/scripts/section-operations.mjs +508 -0
  139. package/scripts/sync-assets.mjs +22 -15
  140. package/scripts/view-assembly.mjs +291 -0
  141. package/scripts/view-selection.mjs +211 -0
  142. package/skills/sf-feature/SKILL.md +67 -0
  143. package/skills/sf-theme-gen/SKILL.md +12 -5
  144. package/skills/sf-view-gen/SKILL.md +123 -93
  145. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
  146. package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
  147. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  148. package/skills/sf-view-gen/assets/recipes/features.json +139 -0
  149. package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
  150. package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
  151. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  152. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  153. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  154. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  155. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  156. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  157. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  158. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  159. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  160. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  161. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  162. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  163. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  164. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  165. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  166. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  167. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  168. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  169. package/skills/sf-view-gen/references/views.md +44 -10
  170. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  171. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  172. package/skills/sf-view-gen/scripts/validate.mjs +784 -32
  173. package/skills/storefront-workflow/SKILL.md +116 -0
  174. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  175. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  176. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  177. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  178. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  179. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
@@ -0,0 +1,121 @@
1
+ // Generated by Thor Commerce storefront (template: assets/templates/app/lib/__STOREFRONT__/schema.ts)
2
+ // Zod contracts for the shell-level base components. Single source shared by
3
+ // the server-side catalog (catalog.ts) and the client-side registry
4
+ // (registry.tsx) — json-render does not validate props against the catalog at
5
+ // render time, so the registry parses here.
6
+
7
+ import { z } from 'zod'
8
+
9
+ export const ctaSchema = z.object({
10
+ label: z.string(),
11
+ href: z.string().optional(),
12
+ })
13
+
14
+ export const navLinkSchema = z.object({
15
+ label: z.string(),
16
+ href: z.string().optional(),
17
+ })
18
+
19
+ const footerLinkSchema = z.object({
20
+ label: z.string(),
21
+ href: z.string().optional(),
22
+ })
23
+
24
+ const footerLinkColumnSchema = z.object({
25
+ title: z.string(),
26
+ links: z.array(footerLinkSchema),
27
+ })
28
+
29
+ // Declared locally so the scaffold's zod version is the only one in play.
30
+ // Cross-major schema assignment (@thorprovider/blocks ships zod v3) breaks the
31
+ // defineCatalog contract, so the block contract is mirrored here instead.
32
+ export const shellProductSchema = z.object({
33
+ id: z.union([z.string(), z.number()]),
34
+ title: z.string(),
35
+ price: z.number(),
36
+ originalPrice: z.number().optional(),
37
+ // Required: the ProductCard1 block contract demands `image: string`, and the
38
+ // runtime's card projection always fills it (PRODUCT_IMAGE_FALLBACK).
39
+ image: z.string(),
40
+ badge: z.string().optional(),
41
+ })
42
+
43
+ export const navbarPropsSchema = z.object({
44
+ navLinks: z.array(navLinkSchema),
45
+ logoText: z.string(),
46
+ logoHref: z.string().optional(),
47
+ currentPath: z.string().optional(),
48
+ signIn: ctaSchema.optional(),
49
+ cta: ctaSchema.optional(),
50
+ cartCount: z.number().optional(),
51
+ })
52
+
53
+ // Mirrors the StorefrontNavbar block contract in this scaffold. `storeCategories`
54
+ // is bound to /navCategories and `items` is derived from /cart in the registry,
55
+ // so only the static chrome is declared here.
56
+ export const navCategorySchema = z.object({
57
+ label: z.string(),
58
+ href: z.string(),
59
+ children: z.array(z.object({ label: z.string(), href: z.string() })).optional(),
60
+ emphasis: z.boolean().optional(),
61
+ })
62
+
63
+ // Optional brand seam mirroring the Navbar4 / FooterSection1 accent props and
64
+ // the StorefrontHero1 editorial image. Declared locally (see shellProductSchema)
65
+ // so the scaffold's zod version is the only one in play.
66
+ export const shellAccentSchema = z.object({
67
+ text: z.string(),
68
+ className: z.string().optional(),
69
+ })
70
+
71
+ export const storefrontNavbarPropsSchema = z.object({
72
+ storeCategories: z.array(navCategorySchema),
73
+ shippingNote: z.string(),
74
+ utilityLinks: z.array(z.object({ label: z.string(), href: z.string() })),
75
+ logoText: z.string(),
76
+ logoHref: z.string().optional(),
77
+ logoAccent: shellAccentSchema.optional(),
78
+ className: z.string().optional(),
79
+ accountHref: z.string().optional(),
80
+ // Held global search (E2 task 5.4): the generated shell opts out of the
81
+ // navbar search control while no verified global search contract exists.
82
+ showSearch: z.boolean().optional(),
83
+ })
84
+
85
+ // Mirrors ShoppingCart4 (block) minus the runtime-bound `items`; the registry
86
+ // fills items from /cart. promotions carry icon NAMES resolved via icon-map.
87
+ export const cartPromotionSchema = z.object({
88
+ threshold: z.number(),
89
+ reward: z.string(),
90
+ icon: z.string(),
91
+ })
92
+
93
+ export const cartDrawerPropsSchema = z.object({
94
+ promotions: z.array(cartPromotionSchema),
95
+ taxRate: z.number().optional(),
96
+ })
97
+
98
+ export const bannerPropsSchema = z.object({
99
+ badge: z.string().optional(),
100
+ message: z.string(),
101
+ href: z.string(),
102
+ linkLabel: z.string(),
103
+ avatars: z.array(z.string()).optional(),
104
+ })
105
+
106
+ export const productCardPropsSchema = z.object({
107
+ products: z.array(shellProductSchema),
108
+ heading: z.string().optional(),
109
+ maxItems: z.number().optional(),
110
+ })
111
+
112
+ export const footerPropsSchema = z.object({
113
+ brandName: z.string(),
114
+ brandAccent: shellAccentSchema.optional(),
115
+ brandDescription: z.string().optional(),
116
+ linkColumns: z.array(footerLinkColumnSchema),
117
+ className: z.string().optional(),
118
+ newsletter: z
119
+ .object({ placeholder: z.string().optional(), buttonLabel: z.string().optional() })
120
+ .optional(),
121
+ })
@@ -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
+ }