@forgecart/cli 2.202608121449.0 → 2.202608200713.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 (63) hide show
  1. package/dist/src/commands/init.d.ts +16 -0
  2. package/dist/src/commands/init.js +18 -7
  3. package/dist/src/commands/init.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/storefront/README.md +145 -61
  6. package/templates/storefront/next.config.js +20 -0
  7. package/templates/storefront/package.json +10 -2
  8. package/templates/storefront/postcss.config.js +1 -2
  9. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +30 -12
  10. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
  11. package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  12. package/templates/storefront/src/app/cart/page.tsx +33 -6
  13. package/templates/storefront/src/app/checkout/page.tsx +40 -0
  14. package/templates/storefront/src/app/error.tsx +21 -0
  15. package/templates/storefront/src/app/global-error.tsx +23 -0
  16. package/templates/storefront/src/app/globals.css +105 -8
  17. package/templates/storefront/src/app/layout.tsx +45 -17
  18. package/templates/storefront/src/app/page.tsx +153 -43
  19. package/templates/storefront/src/app/ping/route.ts +1 -2
  20. package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
  21. package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
  22. package/templates/storefront/src/app/products/page.tsx +52 -9
  23. package/templates/storefront/src/components/CartView.tsx +244 -117
  24. package/templates/storefront/src/components/ForgeTracker.tsx +131 -26
  25. package/templates/storefront/src/components/Header.tsx +8 -10
  26. package/templates/storefront/src/components/ProductCard.tsx +25 -13
  27. package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
  28. package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
  29. package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
  30. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
  31. package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
  32. package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  33. package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
  34. package/templates/storefront/src/instrumentation.ts +34 -0
  35. package/templates/storefront/src/lib/action-result.ts +30 -0
  36. package/templates/storefront/src/lib/backend-actions.ts +20 -0
  37. package/templates/storefront/src/lib/backend-client.ts +47 -0
  38. package/templates/storefront/src/lib/cart-context.tsx +157 -22
  39. package/templates/storefront/src/lib/checkout-session.ts +185 -0
  40. package/templates/storefront/src/lib/error-messages.ts +24 -0
  41. package/templates/storefront/src/lib/experiments.ts +42 -44
  42. package/templates/storefront/src/lib/forgecart.ts +61 -78
  43. package/templates/storefront/src/lib/format.ts +91 -0
  44. package/templates/storefront/src/lib/session-actions.ts +54 -0
  45. package/templates/storefront/src/lib/shop-config.ts +44 -0
  46. package/templates/storefront/src/lib/shop-session.ts +114 -0
  47. package/templates/storefront/src/lib/track-forward.ts +14 -1
  48. package/templates/storefront/src/lib/uuid.ts +19 -0
  49. package/templates/storefront/src/server/app.module.ts +18 -0
  50. package/templates/storefront/src/server/backend-api.ts +26 -0
  51. package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
  52. package/templates/storefront/src/server/bootstrap.ts +122 -0
  53. package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
  54. package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  55. package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  56. package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
  57. package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
  58. package/templates/storefront/src/server/runner.ts +91 -0
  59. package/templates/storefront/src/server/types.ts +36 -0
  60. package/templates/storefront/tsconfig.json +2 -0
  61. package/templates/storefront/.env.example +0 -12
  62. package/templates/storefront/src/lib/cart-actions.ts +0 -139
  63. package/templates/storefront/tailwind.config.js +0 -8
@@ -1,7 +1,8 @@
1
- import { ForgeCartShopClient } from '@forgecart/sdk';
1
+ import 'server-only';
2
+
3
+ import { ForgeCartShopClient } from '@forgecart/sdk/shop';
2
4
  import type {
3
5
  ShopProductFieldFragment as Product,
4
- ShopSellingPlanFieldFragment as SellingPlan,
5
6
  ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
6
7
  } from '@forgecart/sdk/shop';
7
8
 
@@ -20,9 +21,14 @@ import type {
20
21
  * - FORGECART_SHOP_API_URL -> endpoint
21
22
  * - FORGECART_CHANNEL_TOKEN -> channelToken
22
23
  *
23
- * This module is server-only: it is imported exclusively from Server
24
- * Components, so the channel token never reaches the browser. The construction
25
- * mirrors the dashboard's `SdkClientService` (see
24
+ * This module is server-only the `import 'server-only'` above makes a client
25
+ * component's value-import of it a BUILD error, so the channel token and the
26
+ * SDK's websocket transport can never reach the browser. The same guard sits
27
+ * on every SDK-bearing server module (`cart-actions.ts` today; any future
28
+ * action module talking to the shop API adopts it the same way). Client
29
+ * components may still `import type` from here — type imports erase. Pure
30
+ * display helpers live in `./format`, which client components import freely.
31
+ * The construction mirrors the dashboard's `SdkClientService` (see
26
32
  * app/dashboard/src/service/sdk-client.service.ts), adapted to read from env.
27
33
  */
28
34
 
@@ -77,6 +83,48 @@ export type {
77
83
  ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
78
84
  } from '@forgecart/sdk/shop';
79
85
 
86
+ // Checkout-flow result types, aliased off the generated operation results so
87
+ // the components and Server Actions speak the SDK's own shapes (indexed
88
+ // access keeps them exactly as selected — no parallel hand-written types).
89
+ import type {
90
+ AvailableCountriesQuery,
91
+ ConfirmPaymentSessionMutation,
92
+ CreatePaymentSessionMutation,
93
+ GetSessionTemplateQuery,
94
+ GetSessionVariablesQuery,
95
+ RefreshShippingRateGroupsMutation,
96
+ ShopEligiblePaymentProvidersQuery,
97
+ ShopOrderByCodeQuery,
98
+ } from '@forgecart/sdk/shop';
99
+
100
+ export type Country = AvailableCountriesQuery['availableCountries'][number];
101
+ export type ShippingRateGroup =
102
+ RefreshShippingRateGroupsMutation['refreshShippingRateGroups'][number];
103
+ export type ShippingRate = ShippingRateGroup['rates'][number];
104
+ export type EligiblePaymentProvider =
105
+ ShopEligiblePaymentProvidersQuery['eligiblePaymentProviders'][number];
106
+ export type PaymentSession = CreatePaymentSessionMutation['createPaymentSession'];
107
+ export type PaymentTemplate = GetSessionTemplateQuery['getSessionTemplate'];
108
+ export type PaymentVariables = GetSessionVariablesQuery['getSessionVariables'];
109
+ export type ConfirmedPaymentSession = ConfirmPaymentSessionMutation['confirmPaymentSession'];
110
+ export type CheckoutOrder = NonNullable<ShopOrderByCodeQuery['orderByCode']>;
111
+
112
+ /**
113
+ * The order fields the checkout flow renders and gates on — the structural
114
+ * common denominator of every checkout mutation's Order selection (each
115
+ * operation selects a slightly different super-set of these, so no single
116
+ * generated type fits them all; this view is the one deliberate non-SDK
117
+ * shape, and every SDK result assigns to it).
118
+ */
119
+ export interface CheckoutOrderView {
120
+ id: string;
121
+ code: string;
122
+ state: string;
123
+ currencyCode: string;
124
+ totalWithTax: number;
125
+ totalQuantity: number;
126
+ }
127
+
80
128
  /**
81
129
  * Fetch a page of products for the channel.
82
130
  *
@@ -114,7 +162,8 @@ export async function getFeaturedProducts(count = 4): Promise<Product[]> {
114
162
  export async function getSellingPlanGroupsForVariant(
115
163
  variantId: string,
116
164
  ): Promise<SellingPlanGroup[]> {
117
- const { sellingPlanGroupsForVariant } = await getShopClient().sellingPlan.sellingPlanGroupsForVariant({ variantId });
165
+ const { sellingPlanGroupsForVariant } =
166
+ await getShopClient().sellingPlan.sellingPlanGroupsForVariant({ variantId });
118
167
  return sellingPlanGroupsForVariant;
119
168
  }
120
169
 
@@ -125,77 +174,11 @@ export async function getChannelSellingPlanGroups(): Promise<SellingPlanGroup[]>
125
174
  }
126
175
 
127
176
  /**
128
- * Preview the per-unit price a plan yields from the one-time `priceWithTax`.
129
- *
130
- * Mirrors the server's pricing policy: `none` keeps the price, `percentage`
131
- * applies the percent discount (rounded to whole minor units), `fixed_amount`
132
- * subtracts the minor-unit adjustment (floored at zero). A `null`
133
- * `adjustmentValue` (only valid for `none`) is treated as no adjustment.
134
- */
135
- export function getPlanPreviewPrice(basePrice: number, plan: SellingPlan): number {
136
- if (plan.pricingPolicy === 'percentage') {
137
- const adjustment = plan.adjustmentValue ?? 0;
138
- // `adjustmentValue` is a whole-number percent (e.g. 15 -> 15% off).
139
- const PERCENT_BASE = 100;
140
- return Math.round(basePrice * (1 - adjustment / PERCENT_BASE));
141
- }
142
- if (plan.pricingPolicy === 'fixed_amount') {
143
- return Math.max(0, basePrice - (plan.adjustmentValue ?? 0));
144
- }
145
- return basePrice;
146
- }
147
-
148
- /**
149
- * A short human-readable savings hint for a plan, or `null` when it offers no
150
- * discount (policy `none`, a zero adjustment, or a non-positive percentage).
151
- */
152
- export function getPlanSavingsLabel(plan: SellingPlan, currency = 'USD'): string | null {
153
- if (plan.pricingPolicy === 'percentage' && (plan.adjustmentValue ?? 0) > 0) {
154
- return `Save ${plan.adjustmentValue}%`;
155
- }
156
- if (plan.pricingPolicy === 'fixed_amount' && (plan.adjustmentValue ?? 0) > 0) {
157
- return `Save ${formatPrice(plan.adjustmentValue ?? 0, currency)}`;
158
- }
159
- return null;
160
- }
161
-
162
- /**
163
- * The billing cadence as a phrase, e.g. "every 1 monthly" or "every 2 weekly",
164
- * built from the plan's interval count and the (lower-cased) billing interval.
165
- */
166
- export function getPlanCadenceLabel(plan: SellingPlan): string {
167
- return `every ${plan.intervalCount} ${plan.billingInterval.toLowerCase()}`;
168
- }
169
-
170
- /** Lowest variant price for a product, in minor units, or `null` if none. */
171
- export function getStartingPrice(product: Product): number | null {
172
- if (product.variants.length === 0) {
173
- return null;
174
- }
175
- return product.variants.reduce(
176
- (min, v) => (v.priceWithTax < min ? v.priceWithTax : min),
177
- product.variants[0].priceWithTax,
178
- );
179
- }
180
-
181
- /**
182
- * Format a minor-unit price as a currency string. Defaults to USD; pass a
183
- * different ISO currency code as needed.
184
- *
185
- * ForgeCart prices are integers in the currency's smallest denomination, so
186
- * the divisor depends on the currency's ISO-4217 exponent: 2500 is $25.00
187
- * (two-decimal cents) but ¥2,500 (zero-decimal yen) and BHD 2.500
188
- * (three-decimal fils). The exponent is derived from the same
189
- * `Intl.NumberFormat` instance that renders the price — its
190
- * `resolvedOptions().maximumFractionDigits` carries the CLDR fraction digits
191
- * for the currency — so the divisor always agrees with the digits shown,
192
- * with a defensive fallback of 2 should the formatter resolve none.
177
+ * Countries the channel ships to — a PUBLIC channel read (no session), so it
178
+ * belongs on this server singleton: the checkout page prefetches it and hands
179
+ * the list to the client flow.
193
180
  */
194
- export function formatPrice(minorUnits: number, currency = 'USD'): string {
195
- const formatter = new Intl.NumberFormat('en-US', {
196
- style: 'currency',
197
- currency,
198
- });
199
- const exponent = formatter.resolvedOptions().maximumFractionDigits ?? 2;
200
- return formatter.format(minorUnits / 10 ** exponent);
181
+ export async function getAvailableCountries(): Promise<Country[]> {
182
+ const { availableCountries } = await getShopClient().country.availableCountries();
183
+ return availableCountries;
201
184
  }
@@ -0,0 +1,91 @@
1
+ import type {
2
+ ShopProductFieldFragment as Product,
3
+ ShopSellingPlanFieldFragment as SellingPlan,
4
+ } from '@forgecart/sdk/shop';
5
+
6
+ /**
7
+ * Pure display helpers over SDK types — safe to import from ANY component.
8
+ *
9
+ * This module carries no runtime SDK dependency: the `@forgecart/sdk/shop`
10
+ * import above is type-only, so it erases at compile time and none of the
11
+ * client (`ForgeCartShopClient`, graphql-ws) ever reaches a browser bundle.
12
+ * Client components import their formatting from here; the SDK-bearing
13
+ * modules (`forgecart.ts`, `cart-actions.ts`) are `server-only` and reject a
14
+ * client value-import at build time.
15
+ */
16
+
17
+ /**
18
+ * Format a minor-unit price as a currency string. Defaults to USD; pass a
19
+ * different ISO currency code as needed.
20
+ *
21
+ * ForgeCart prices are integers in the currency's smallest denomination, so
22
+ * the divisor depends on the currency's ISO-4217 exponent: 2500 is $25.00
23
+ * (two-decimal cents) but ¥2,500 (zero-decimal yen) and BHD 2.500
24
+ * (three-decimal fils). The exponent is derived from the same
25
+ * `Intl.NumberFormat` instance that renders the price — its
26
+ * `resolvedOptions().maximumFractionDigits` carries the CLDR fraction digits
27
+ * for the currency — so the divisor always agrees with the digits shown,
28
+ * with a defensive fallback of 2 should the formatter resolve none.
29
+ */
30
+ export function formatPrice(minorUnits: number, currency = 'USD'): string {
31
+ const formatter = new Intl.NumberFormat('en-US', {
32
+ style: 'currency',
33
+ currency,
34
+ });
35
+ const exponent = formatter.resolvedOptions().maximumFractionDigits ?? 2;
36
+ return formatter.format(minorUnits / 10 ** exponent);
37
+ }
38
+
39
+ /** Lowest variant price for a product, in minor units, or `null` if none. */
40
+ export function getStartingPrice(product: Product): number | null {
41
+ if (product.variants.length === 0) {
42
+ return null;
43
+ }
44
+ return product.variants.reduce(
45
+ (min, v) => (v.priceWithTax < min ? v.priceWithTax : min),
46
+ product.variants[0].priceWithTax,
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Preview the per-unit price a plan yields from the one-time `priceWithTax`.
52
+ *
53
+ * Mirrors the server's pricing policy: `none` keeps the price, `percentage`
54
+ * applies the percent discount (rounded to whole minor units), `fixed_amount`
55
+ * subtracts the minor-unit adjustment (floored at zero). A `null`
56
+ * `adjustmentValue` (only valid for `none`) is treated as no adjustment.
57
+ */
58
+ export function getPlanPreviewPrice(basePrice: number, plan: SellingPlan): number {
59
+ if (plan.pricingPolicy === 'percentage') {
60
+ const adjustment = plan.adjustmentValue ?? 0;
61
+ // `adjustmentValue` is a whole-number percent (e.g. 15 -> 15% off).
62
+ const PERCENT_BASE = 100;
63
+ return Math.round(basePrice * (1 - adjustment / PERCENT_BASE));
64
+ }
65
+ if (plan.pricingPolicy === 'fixed_amount') {
66
+ return Math.max(0, basePrice - (plan.adjustmentValue ?? 0));
67
+ }
68
+ return basePrice;
69
+ }
70
+
71
+ /**
72
+ * A short human-readable savings hint for a plan, or `null` when it offers no
73
+ * discount (policy `none`, a zero adjustment, or a non-positive percentage).
74
+ */
75
+ export function getPlanSavingsLabel(plan: SellingPlan, currency = 'USD'): string | null {
76
+ if (plan.pricingPolicy === 'percentage' && (plan.adjustmentValue ?? 0) > 0) {
77
+ return `Save ${plan.adjustmentValue}%`;
78
+ }
79
+ if (plan.pricingPolicy === 'fixed_amount' && (plan.adjustmentValue ?? 0) > 0) {
80
+ return `Save ${formatPrice(plan.adjustmentValue ?? 0, currency)}`;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * The billing cadence as a phrase, e.g. "every 1 monthly" or "every 2 weekly",
87
+ * built from the plan's interval count and the (lower-cased) billing interval.
88
+ */
89
+ export function getPlanCadenceLabel(plan: SellingPlan): string {
90
+ return `every ${plan.intervalCount} ${plan.billingInterval.toLowerCase()}`;
91
+ }
@@ -0,0 +1,54 @@
1
+ 'use server';
2
+
3
+ import 'server-only';
4
+
5
+ import { cookies } from 'next/headers';
6
+
7
+ /**
8
+ * Session custody glue for the PROGRESSIVE stack.
9
+ *
10
+ * The shopper's session lives CLIENT-SIDE (the browser's own shop websocket
11
+ * mints and carries it — see `shop-session.ts`); the server only needs to
12
+ * SEE it so SSR (checkout gating, any future server read) stays coherent
13
+ * with the cart the shopper built over their socket. Two cookies, one value:
14
+ *
15
+ * - `forgecart-session` httpOnly — the server-readable copy.
16
+ * - `forgecart-session-client` JS-readable mirror — what the browser
17
+ * socket boots from on the next visit.
18
+ *
19
+ * The mirror is deliberately not httpOnly: the session token is the
20
+ *shopper's own low-privilege shop session (channel-scoped; the admin secret
21
+ * never reaches the browser), and the client socket cannot exist without
22
+ * reading it. Both cookies always carry the same value — this action is the
23
+ * single writer.
24
+ */
25
+
26
+ const SESSION_COOKIE = 'forgecart-session';
27
+ const SESSION_MIRROR_COOKIE = 'forgecart-session-client';
28
+ const SESSION_MAX_AGE = 60 * 60 * 24 * 30;
29
+ /** Session tokens are opaque but bounded — reject junk before it hits a header. */
30
+ const MAX_TOKEN_LENGTH = 512;
31
+
32
+ /**
33
+ * Persist (or clear, with `null`) the client-minted session token into both
34
+ * cookies. Called by the browser session layer whenever its socket captures
35
+ * a new token; idempotent when the value already matches.
36
+ */
37
+ export async function syncShopSession(token: string | null): Promise<void> {
38
+ const store = await cookies();
39
+ if (token === null) {
40
+ store.delete(SESSION_COOKIE);
41
+ store.delete(SESSION_MIRROR_COOKIE);
42
+ return;
43
+ }
44
+ if (token.length === 0 || token.length > MAX_TOKEN_LENGTH) return;
45
+ if (
46
+ store.get(SESSION_COOKIE)?.value === token &&
47
+ store.get(SESSION_MIRROR_COOKIE)?.value === token
48
+ ) {
49
+ return;
50
+ }
51
+ const shared = { sameSite: 'lax' as const, path: '/', maxAge: SESSION_MAX_AGE };
52
+ store.set(SESSION_COOKIE, token, { ...shared, httpOnly: true });
53
+ store.set(SESSION_MIRROR_COOKIE, token, { ...shared, httpOnly: false });
54
+ }
@@ -0,0 +1,44 @@
1
+ import 'server-only';
2
+
3
+ /**
4
+ * The channel's shop-API coordinates, read once from the environment
5
+ * (`forgecart init` writes them into `.env`).
6
+ *
7
+ * Two consumers, two exposure levels — and potentially TWO URLS (split
8
+ * horizon):
9
+ * - Server modules (`forgecart.ts`, the embedded backend) read
10
+ * `FORGECART_SHOP_API_URL` — in a workspace pod that is an INTERNAL
11
+ * address (docker bridge / cluster network) only the pod can reach.
12
+ * - The BROWSER receives its config via {@link getBrowserShopConfig} — the
13
+ * layout serializes it into the client `CartProvider`, which boots the
14
+ * shopper's own session websocket. The browser needs the PUBLIC
15
+ * `FORGECART_SHOP_API_BROWSER_URL` (HTTPS, so the socket runs over WSS —
16
+ * an internal ws:// endpoint is mixed content on an HTTPS page and
17
+ * unreachable from a shopper's machine anyway). When the browser URL is
18
+ * absent, the server URL serves both sides (deployed storefronts and
19
+ * loopback dev, where one origin is reachable from everywhere).
20
+ *
21
+ * The channel token is the shop API's PUBLIC storefront token (it scopes
22
+ * reads/writes to the channel and a shopper session); the admin secret never
23
+ * takes this path.
24
+ */
25
+
26
+ const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
27
+ const SHOP_API_BROWSER_URL = process.env.FORGECART_SHOP_API_BROWSER_URL ?? SHOP_API_URL;
28
+ const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
29
+
30
+ export interface BrowserShopConfig {
31
+ apiUrl: string;
32
+ channelToken: string;
33
+ }
34
+
35
+ /** Whether `forgecart init` has written the shop env yet (pre-warm renders without it). */
36
+ export function isShopConfigured(): boolean {
37
+ return Boolean(SHOP_API_URL && CHANNEL_TOKEN);
38
+ }
39
+
40
+ /** The client-socket coordinates, or null on an unconfigured scaffold. */
41
+ export function getBrowserShopConfig(): BrowserShopConfig | null {
42
+ if (!isShopConfigured()) return null;
43
+ return { apiUrl: SHOP_API_BROWSER_URL, channelToken: CHANNEL_TOKEN };
44
+ }
@@ -0,0 +1,114 @@
1
+ 'use client';
2
+
3
+ // The /shop subpath is the ONLY SDK entry browser code may import: the SDK
4
+ // compiles to CommonJS (never tree-shaken), and only the /shop module graph
5
+ // is guaranteed admin-free — the root barrel would bundle the admin client
6
+ // into every shopper's browser (the pod image build greps chunks for exactly
7
+ // that and fails).
8
+ import { ForgeCartShopClient, extractError } from '@forgecart/sdk/shop';
9
+
10
+ import { UNREACHABLE_ERROR, type ActionResult } from './action-result';
11
+ import { syncShopSession } from './session-actions';
12
+
13
+ /**
14
+ * The shopper's OWN shop websocket — the client half of the progressive
15
+ * stack. Sockets are session-based, so the session socket lives where the
16
+ * session lives: in the browser, one long-lived client per tab. The server
17
+ * never builds session-bearing sockets (its two clients are the anonymous
18
+ * channel singleton in `forgecart.ts` and the embedded backend's admin
19
+ * singleton); it only prefetches public data and mirrors the session cookie.
20
+ *
21
+ * Lifecycle:
22
+ * - `initShopSession(config)` (the CartProvider mount) arms the singleton
23
+ * with the channel coordinates; the socket itself opens lazily on the
24
+ * first operation and the SDK's `lazyCloseTimeout` parks it when idle.
25
+ * - The client boots its auth from the JS-readable mirror cookie
26
+ * (`forgecart-session-client`), so a returning shopper's socket adopts
27
+ * the same session SSR saw.
28
+ * - After every operation, a newly-captured session token (the SDK reads
29
+ * it from result extensions on the first mutation) is synced back into
30
+ * BOTH cookies via the `syncShopSession` server action — the next SSR
31
+ * pass sees the cart this socket built.
32
+ *
33
+ * Every operation returns the same `ActionResult` envelope the old Server
34
+ * Actions used, so calling components keep their error contract: typed
35
+ * in-band errors, never a throw.
36
+ */
37
+
38
+ export interface ShopSessionConfig {
39
+ apiUrl: string;
40
+ channelToken: string;
41
+ }
42
+
43
+ const SESSION_MIRROR_COOKIE = 'forgecart-session-client';
44
+
45
+ let config: ShopSessionConfig | null = null;
46
+ let client: ForgeCartShopClient | null = null;
47
+ let syncedToken: string | null = null;
48
+
49
+ /** Read the session mirror cookie (null outside the browser or when absent). */
50
+ export function readMirrorSession(): string | null {
51
+ if (typeof document === 'undefined') return null;
52
+ const entry = document.cookie
53
+ .split('; ')
54
+ .find((candidate) => candidate.startsWith(`${SESSION_MIRROR_COOKIE}=`));
55
+ if (!entry) return null;
56
+ const value = decodeURIComponent(entry.slice(SESSION_MIRROR_COOKIE.length + 1));
57
+ return value.length > 0 ? value : null;
58
+ }
59
+
60
+ /** Arm the singleton with the channel coordinates (idempotent; provider mount). */
61
+ export function initShopSession(next: ShopSessionConfig): void {
62
+ config = next;
63
+ }
64
+
65
+ /** Whether the session socket can exist (configured scaffold + browser). */
66
+ export function isSessionReady(): boolean {
67
+ return config !== null && typeof window !== 'undefined';
68
+ }
69
+
70
+ function ensureClient(): ForgeCartShopClient {
71
+ if (!config) {
72
+ throw new Error(
73
+ 'Shop session not initialized — CartProvider must mount with shopConfig first.',
74
+ );
75
+ }
76
+ if (!client) {
77
+ client = new ForgeCartShopClient({
78
+ endpoint: config.apiUrl,
79
+ channelToken: config.channelToken,
80
+ });
81
+ const existing = readMirrorSession();
82
+ if (existing) {
83
+ client.setAuthToken(existing);
84
+ syncedToken = existing;
85
+ }
86
+ }
87
+ return client;
88
+ }
89
+
90
+ /**
91
+ * Run one session operation on the shopper's socket and return the typed
92
+ * envelope. Session capture: when the operation minted or rotated the
93
+ * session, the new token is synced into the cookies fire-and-forget —
94
+ * a sync failure never breaks the shopper's interaction.
95
+ */
96
+ export async function runSessionOp<T>(
97
+ op: (client: ForgeCartShopClient) => Promise<T>,
98
+ ): Promise<ActionResult<T>> {
99
+ if (!isSessionReady()) {
100
+ return { ok: false, error: UNREACHABLE_ERROR };
101
+ }
102
+ const live = ensureClient();
103
+ try {
104
+ const data = await op(live);
105
+ const token = live.getAuthToken();
106
+ if (token && token !== syncedToken) {
107
+ syncedToken = token;
108
+ void syncShopSession(token).catch(() => undefined);
109
+ }
110
+ return { ok: true, data };
111
+ } catch (error) {
112
+ return { ok: false, error: extractError(error) ?? UNREACHABLE_ERROR };
113
+ }
114
+ }
@@ -74,6 +74,16 @@ export interface ForwardHeaders {
74
74
  sessionToken: string | null;
75
75
  /** The browser's own User-Agent, forwarded for device enrichment. */
76
76
  userAgent: string;
77
+ /**
78
+ * The browser's low-entropy Client Hints (`sec-ch-ua`, `sec-ch-ua-mobile`,
79
+ * `sec-ch-ua-platform`), mirrored verbatim so device identification can
80
+ * outrank the frozen UA per-field (#1014). Chromium sends the trio on
81
+ * every request — no handshake, no `Accept-CH` — so this is a pure
82
+ * pass-through; absent on non-Chromium browsers.
83
+ */
84
+ secChUa?: string;
85
+ secChUaMobile?: string;
86
+ secChUaPlatform?: string;
77
87
  /**
78
88
  * Client IP as `x-forwarded-for` (the proxy-standard contract the shop API
79
89
  * trusts) — NEVER ForgeCart's edge-secret-gated client-IP override header
@@ -119,7 +129,7 @@ export function isObviousBot(userAgent: string): boolean {
119
129
  */
120
130
  export async function forwardTrackEvent(
121
131
  input: TrackEventInput,
122
- { sessionToken, userAgent, forwardedFor }: ForwardHeaders,
132
+ { sessionToken, userAgent, forwardedFor, secChUa, secChUaMobile, secChUaPlatform }: ForwardHeaders,
123
133
  ): Promise<UpstreamOutcome> {
124
134
  const upstream = getUpstreamConfig();
125
135
  if (!upstream) return { accepted: false, eventId: null, sessionToken: null };
@@ -131,6 +141,9 @@ export async function forwardTrackEvent(
131
141
  if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
132
142
  if (userAgent) headers['user-agent'] = userAgent;
133
143
  if (forwardedFor) headers['x-forwarded-for'] = forwardedFor;
144
+ if (secChUa) headers['sec-ch-ua'] = secChUa;
145
+ if (secChUaMobile) headers['sec-ch-ua-mobile'] = secChUaMobile;
146
+ if (secChUaPlatform) headers['sec-ch-ua-platform'] = secChUaPlatform;
134
147
 
135
148
  try {
136
149
  const response = await fetch(upstream.shopApiUrl, {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * v4 UUID that works in EVERY browsing context this storefront serves from.
3
+ *
4
+ * `crypto.randomUUID` is secure-context gated: it exists on https origins and
5
+ * on localhost, but NOT on plain-http LAN/preview hosts — exactly the local
6
+ * k3s preview origin (`http://<code>.127.0.0.1.nip.io:8081`) the dev recipe
7
+ * and the dashboard-e2e forge suite drive. A bare call there is a TypeError
8
+ * mid-render (the checkout AddressStep crash, 2026-08-09). `getRandomValues`
9
+ * carries no such gate, so mint the same v4 shape by hand when the fast path
10
+ * is missing.
11
+ */
12
+ export function mintUuid(): string {
13
+ if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
14
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
15
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
16
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
17
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
18
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
19
+ }
@@ -0,0 +1,18 @@
1
+ import 'server-only';
2
+
3
+ import { Module } from '@nestjs/common';
4
+
5
+ import { CustomerExtrasModule } from './customer-extras/customer-extras.module';
6
+ import { ForgeCartModule } from './forgecart/forgecart.module';
7
+
8
+ /**
9
+ * Root of the storefront's embedded NestJS backend.
10
+ *
11
+ * `bootstrap.ts` boots this module as an APPLICATION CONTEXT — the module
12
+ * system, DI container, and lifecycle only; Next.js keeps owning HTTP — and
13
+ * scans it for `@BackendMethod()` methods, which become the `sdk.backend`
14
+ * surface. Every domain module registers here; a module that is not
15
+ * imported does not exist to the scan.
16
+ */
17
+ @Module({ imports: [ForgeCartModule, CustomerExtrasModule] })
18
+ export class AppModule {}
@@ -0,0 +1,26 @@
1
+ import 'server-only';
2
+
3
+ import type {
4
+ AssignCustomerHashInput,
5
+ AssignCustomerHashResult,
6
+ } from './customer-extras/type/customer-extras.types';
7
+
8
+ /**
9
+ * The compile-time surface of the backend gate — the typed twin of the
10
+ * runtime `@BackendMethod()` scan.
11
+ *
12
+ * `lib/backend-client.ts` types the `sdk.backend` proxy from this interface
13
+ * (a type-only import, so nothing here reaches the client bundle), and
14
+ * `bootstrap.ts` validates at boot that the decorator scan discovered
15
+ * EXACTLY the names below — a mismatch in either direction fails the boot
16
+ * with a message naming the drifted method.
17
+ *
18
+ * Adding a method = the decorated service method + one signature here (+
19
+ * the name in the list below). See the `backend-feature-authoring` recipe.
20
+ */
21
+ export interface BackendApi {
22
+ assignCustomerHash: (input: AssignCustomerHashInput) => Promise<AssignCustomerHashResult>;
23
+ }
24
+
25
+ /** The declared names, for the boot-time drift check against the scan. */
26
+ export const BACKEND_API_METHOD_NAMES: readonly (keyof BackendApi)[] = ['assignCustomerHash'];
@@ -0,0 +1,23 @@
1
+ import 'server-only';
2
+
3
+ import { SetMetadata, type CustomDecorator } from '@nestjs/common';
4
+
5
+ /** Metadata key marking a service method as browser-invocable via `sdk.backend`. */
6
+ export const BACKEND_METHOD_KEY = 'forgecart:backend-method';
7
+
8
+ /**
9
+ * Opt a service method into the `sdk.backend` gate.
10
+ *
11
+ * The decorator IS the allowlist: the bootstrap scan exposes decorated
12
+ * methods and nothing else, so a method becomes browser-callable only by
13
+ * carrying this marker (plus its line in `backend-api.ts` — the boot-time
14
+ * drift check keeps the two in lock-step). Method names must be unique
15
+ * across ALL services; a duplicate fails the boot loudly.
16
+ *
17
+ * A decorated method runs shopper-invocable with the ADMIN client available
18
+ * in its session — the method body owns the decision of what that authority
19
+ * may be used for, and must treat its `input` as untrusted data.
20
+ */
21
+ export function BackendMethod(): CustomDecorator<string> {
22
+ return SetMetadata(BACKEND_METHOD_KEY, true);
23
+ }