@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.
- package/CHANGELOG.md +23 -0
- package/README.md +248 -99
- package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
- package/assets/templates/app/app/layout.tsx +18 -4
- package/assets/templates/app/app/robots.ts +13 -0
- package/assets/templates/app/app/sitemap.ts +12 -0
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
- package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
- package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
- package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
- package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
- package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
- package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
- package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
- package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
- package/assets/templates/features/checkout/handlers.ts +153 -0
- package/assets/templates/features/checkout/state.ts +61 -0
- package/assets/templates/features/customer-auth/handlers.ts +59 -0
- package/assets/templates/features/customer-auth/state.ts +36 -0
- package/assets/templates/features/recommendations/state.ts +11 -0
- package/assets/templates/views/account/content.ts +35 -0
- package/assets/templates/views/account/index.tsx +114 -0
- package/assets/templates/views/account/registry.tsx +378 -0
- package/assets/templates/views/account/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +28 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
- package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
- package/assets/templates/views/category/content.ts +30 -0
- package/assets/templates/views/category/data.ts +12 -0
- package/assets/templates/views/category/index.tsx +230 -0
- package/assets/templates/views/category/props.ts +16 -0
- package/assets/templates/views/category/registry.tsx +103 -0
- package/assets/templates/views/category/schema.ts +78 -0
- package/assets/templates/views/checkout/content.ts +105 -0
- package/assets/templates/views/checkout/data.ts +23 -0
- package/assets/templates/views/checkout/index.tsx +111 -0
- package/assets/templates/views/checkout/props.ts +49 -0
- package/assets/templates/views/checkout/registry.tsx +217 -0
- package/assets/templates/views/checkout/schema.ts +229 -0
- package/assets/templates/views/home/assembly/assembly.json +24 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
- package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
- package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
- package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
- package/assets/templates/views/home/content.ts +70 -0
- package/assets/templates/views/home/data.ts +15 -0
- package/assets/templates/views/home/index.tsx +38 -84
- package/assets/templates/views/home/props.ts +22 -0
- package/assets/templates/views/home/registry.tsx +38 -22
- package/assets/templates/views/home/schema.ts +93 -0
- package/assets/templates/views/product/content.ts +25 -0
- package/assets/templates/views/product/data.ts +48 -0
- package/assets/templates/views/product/index.tsx +104 -0
- package/assets/templates/views/product/props.ts +20 -0
- package/assets/templates/views/product/registry.tsx +78 -0
- package/assets/templates/views/product/schema.ts +108 -0
- package/assets/templates/views/static/content.ts +99 -0
- package/assets/templates/views/static/index.tsx +60 -0
- package/assets/templates/views/static/registry.tsx +66 -0
- package/assets/templates/views/static/schema.ts +45 -0
- package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
- package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
- package/assets/templates/views/user-panel/schema.ts +79 -0
- package/bin/storefront.js +1278 -79
- package/commands/sf-add-view.md +44 -13
- package/commands/sf-feature.md +28 -0
- package/commands/sf-theme.md +14 -8
- package/commands/sf-view.md +54 -12
- package/package.json +6 -4
- package/recipes/archetypes.json +28 -1
- package/recipes/catalog.json +361 -0
- package/recipes/catalog.mjs +128 -0
- package/recipes/catalog.schema.json +64 -0
- package/recipes/feature.schema.json +89 -0
- package/recipes/features.json +139 -0
- package/recipes/manifest.schema.json +69 -0
- package/recipes/operations.schema.json +43 -0
- package/recipes/recipe.schema.json +11 -2
- package/recipes/recipes.json +60 -5
- package/recipes/sections.json +24 -5
- package/recipes/validate.mjs +784 -32
- package/scripts/candidate.mjs +236 -0
- package/scripts/composers.mjs +140 -0
- package/scripts/file-classes.mjs +116 -0
- package/scripts/logical-controls.mjs +61 -0
- package/scripts/manifest.mjs +298 -0
- package/scripts/operation-registry.mjs +256 -0
- package/scripts/operations.mjs +720 -0
- package/scripts/ownership-conflict.mjs +356 -0
- package/scripts/pinning.mjs +504 -0
- package/scripts/plan-apply.mjs +843 -0
- package/scripts/preflight.mjs +51 -0
- package/scripts/reconcile.mjs +511 -0
- package/scripts/render-templates.mjs +245 -28
- package/scripts/schema-validate.mjs +132 -0
- package/scripts/section-compat.mjs +300 -0
- package/scripts/section-operations.mjs +508 -0
- package/scripts/sync-assets.mjs +22 -15
- package/scripts/view-assembly.mjs +291 -0
- package/scripts/view-selection.mjs +211 -0
- package/skills/sf-feature/SKILL.md +67 -0
- package/skills/sf-theme-gen/SKILL.md +12 -5
- package/skills/sf-view-gen/SKILL.md +123 -93
- package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
- package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
- package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
- package/skills/sf-view-gen/assets/recipes/features.json +139 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
- package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
- package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
- package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
- package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
- package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
- package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
- package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
- package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
- package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
- package/skills/sf-view-gen/references/views.md +44 -10
- package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
- package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
- package/skills/sf-view-gen/scripts/validate.mjs +784 -32
- package/skills/storefront-workflow/SKILL.md +116 -0
- package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
- package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
- package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
- package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
- package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
- 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
|
+
}
|