@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.
- package/README.md +321 -152
- package/VENDOR.md +69 -0
- package/assets/openspec/config.yaml +38 -0
- package/assets/templates/AGENTS.md +76 -0
- package/assets/templates/app/app/[[...slug]]/page.tsx +54 -2
- package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
- package/assets/templates/app/app/globals.css +5 -8
- package/assets/templates/app/app/layout.tsx +17 -7
- package/assets/templates/app/app/robots.ts +19 -0
- package/assets/templates/app/app/sitemap.ts +71 -0
- package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
- package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
- package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
- package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
- package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
- package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -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 +264 -51
- package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
- package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
- package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
- package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -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 +120 -0
- package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
- package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
- package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
- package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -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 +161 -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 +18 -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/products.client.tsx +100 -0
- package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
- 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/schema.products.ts +39 -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 +159 -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 +45 -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 +439 -0
- package/assets/templates/views/account/schema.ts +53 -0
- package/assets/templates/views/cart/content.ts +11 -0
- package/assets/templates/views/cart/data.ts +23 -0
- package/assets/templates/views/cart/index.tsx +53 -0
- package/assets/templates/views/cart/props.ts +12 -0
- package/assets/templates/views/cart/registry.tsx +81 -0
- package/assets/templates/views/cart/schema.ts +52 -0
- package/assets/templates/views/category/assembly/assembly.json +29 -0
- package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
- package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
- package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -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 +299 -0
- package/assets/templates/views/category/props.ts +23 -0
- package/assets/templates/views/category/registry.tsx +122 -0
- package/assets/templates/views/category/schema.ts +91 -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 +33 -0
- package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -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 +24 -0
- package/assets/templates/views/home/index.tsx +57 -85
- 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 +50 -0
- package/assets/templates/views/product/index.tsx +148 -0
- package/assets/templates/views/product/props.ts +27 -0
- package/assets/templates/views/product/registry.tsx +103 -0
- package/assets/templates/views/product/schema.ts +121 -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/bin/storefront.js +2424 -148
- package/examples/merchandising/category-led.json +37 -0
- package/examples/merchandising/collection-led.json +37 -0
- package/package.json +15 -9
- package/recipes/archetypes.json +28 -14
- package/recipes/catalog.json +400 -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 +162 -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 +76 -19
- package/recipes/sections.json +34 -5
- package/recipes/validate.mjs +838 -32
- package/scripts/candidate.mjs +236 -0
- package/scripts/composers.mjs +160 -0
- package/scripts/env-file.mjs +86 -0
- package/scripts/file-classes.mjs +121 -0
- package/scripts/logical-controls.mjs +61 -0
- package/scripts/manifest.mjs +298 -0
- package/scripts/migrate-english-routes.mjs +276 -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 +573 -0
- package/scripts/plan-apply.mjs +987 -0
- package/scripts/preflight.mjs +51 -0
- package/scripts/reconcile.mjs +549 -0
- package/scripts/render-templates.mjs +338 -68
- package/scripts/schema-validate.mjs +132 -0
- package/scripts/section-compat.mjs +300 -0
- package/scripts/section-config.mjs +253 -0
- package/scripts/section-operations.mjs +508 -0
- package/scripts/starter.mjs +17 -0
- package/scripts/sync-assets.mjs +22 -15
- package/scripts/theme/color.mjs +185 -0
- package/scripts/theme/css-theme.mjs +233 -0
- package/scripts/theme/fonts.mjs +123 -0
- package/scripts/theme/import-export.mjs +125 -0
- package/scripts/theme/preset.mjs +93 -0
- package/scripts/theme/theme-plan.mjs +292 -0
- package/scripts/theme/tokens.mjs +137 -0
- package/scripts/view-assembly.mjs +337 -0
- package/scripts/view-selection.mjs +211 -0
- package/skills/sf-feature/SKILL.md +80 -0
- package/skills/sf-setup/SKILL.md +79 -0
- package/skills/sf-theme-gen/SKILL.md +100 -30
- package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
- package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
- package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
- package/skills/sf-view-gen/SKILL.md +146 -93
- package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
- package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
- package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
- package/skills/sf-view-gen/assets/recipes/features.json +162 -0
- package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
- package/skills/sf-view-gen/assets/recipes/sections.json +34 -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/products.client.tsx +100 -0
- package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
- 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.products.ts +39 -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/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
- package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
- package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
- package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
- package/skills/sf-view-gen/references/views.md +58 -33
- 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 +838 -32
- package/skills/storefront-workflow/SKILL.md +179 -0
- package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
- package/CHANGELOG.md +0 -61
- package/commands/sf-add-view.md +0 -26
- package/commands/sf-theme.md +0 -20
- package/commands/sf-view.md +0 -25
- package/skills/building-storefronts/SKILL.md +0 -178
- package/skills/building-storefronts/references/frontend-integration.md +0 -229
- package/skills/json-render-remotion/SKILL.md +0 -111
- package/skills/json-render-solid/SKILL.md +0 -204
- package/skills/nextjs-shadcn/SKILL.md +0 -367
- package/skills/nextjs-shadcn/references/architecture.md +0 -499
- package/skills/nextjs-shadcn/references/project-setup.md +0 -162
- package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
- package/skills/nextjs-shadcn/references/sidebar.md +0 -274
- package/skills/nextjs-shadcn/references/styling.md +0 -555
- 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
- package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
- package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
- package/skills/shadcn-component-discovery/SKILL.md +0 -273
- package/skills/shadcn-component-discovery/references/registries.md +0 -226
- package/skills/shadcn-theming/SKILL.md +0 -104
- package/skills/storefront-best-practices/SKILL.md +0 -421
- package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
- package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
- package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
- package/skills/storefront-best-practices/reference/components/footer.md +0 -112
- package/skills/storefront-best-practices/reference/components/hero.md +0 -241
- package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
- package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
- package/skills/storefront-best-practices/reference/components/popups.md +0 -221
- package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
- package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
- package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
- package/skills/storefront-best-practices/reference/components/search.md +0 -101
- package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
- package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
- package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
- package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
- package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
- package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
- package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
- package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
- package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
- package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
- package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
- package/skills/storefront-best-practices/reference/medusa.md +0 -307
- /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
- /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
|
+
}
|