@forgecart/cli 2.202610052143.0 → 2.202610060357.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 (125) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront-shadcn/.forgecartignore +2 -0
  3. package/templates/storefront-shadcn/Procfile +1 -0
  4. package/templates/storefront-shadcn/README.md +229 -0
  5. package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
  6. package/templates/storefront-shadcn/components.json +21 -0
  7. package/templates/storefront-shadcn/next.config.js +105 -0
  8. package/templates/storefront-shadcn/package.json +39 -0
  9. package/templates/storefront-shadcn/postcss.config.js +5 -0
  10. package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
  11. package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
  12. package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  13. package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  14. package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
  15. package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
  16. package/templates/storefront-shadcn/src/app/error.tsx +23 -0
  17. package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
  18. package/templates/storefront-shadcn/src/app/globals.css +156 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
  20. package/templates/storefront-shadcn/src/app/page.tsx +217 -0
  21. package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
  22. package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
  23. package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
  24. package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
  25. package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
  26. package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
  27. package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
  28. package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
  29. package/templates/storefront-shadcn/src/app/robots.ts +69 -0
  30. package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
  31. package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
  32. package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
  33. package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
  34. package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
  35. package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
  36. package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
  37. package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
  38. package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
  39. package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
  40. package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
  41. package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
  42. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
  43. package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
  44. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
  45. package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
  46. package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
  47. package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
  48. package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
  49. package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  50. package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
  51. package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
  52. package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
  53. package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
  54. package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
  55. package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
  56. package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
  57. package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
  58. package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
  59. package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
  60. package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
  61. package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
  62. package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
  63. package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
  64. package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
  65. package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
  66. package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
  67. package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
  68. package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
  69. package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
  70. package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
  71. package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
  72. package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
  73. package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
  74. package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
  75. package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
  76. package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
  77. package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
  78. package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
  79. package/templates/storefront-shadcn/src/lib/format.ts +89 -0
  80. package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
  81. package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
  82. package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
  83. package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
  84. package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
  85. package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
  86. package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
  87. package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
  88. package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
  89. package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
  90. package/templates/storefront-shadcn/src/lib/money.ts +50 -0
  91. package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
  92. package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
  93. package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
  94. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
  95. package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
  96. package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
  97. package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
  98. package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
  99. package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
  100. package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
  101. package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
  102. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
  103. package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
  104. package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
  105. package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
  106. package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
  107. package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
  108. package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
  109. package/templates/storefront-shadcn/src/middleware.ts +379 -0
  110. package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
  111. package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
  112. package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
  113. package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
  114. package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
  115. package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
  116. package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  117. package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  118. package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
  119. package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
  120. package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
  121. package/templates/storefront-shadcn/src/server/runner.ts +90 -0
  122. package/templates/storefront-shadcn/src/server/types.ts +36 -0
  123. package/templates/storefront-shadcn/tsconfig.json +25 -0
  124. package/templates/storefront-shadcn-sdk-floor.json +1174 -0
  125. package/templates/template-set.json +10 -0
@@ -0,0 +1,151 @@
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
+ /** The request's resolved locale — the session speaks the page's language. */
42
+ languageCode: string;
43
+ }
44
+
45
+ const SESSION_MIRROR_COOKIE = 'forgecart-session-client';
46
+
47
+ /**
48
+ * Browser-readable mirror of the httpOnly `forgecart-visitor` cookie,
49
+ * minted by the middleware (#1079 P1). Its value rides the SDK's
50
+ * `forgecart-visitor` WS connection-params header so visitor-subject
51
+ * experiment goals stamp server-side.
52
+ */
53
+ const VISITOR_MIRROR_COOKIE = 'forgecart-visitor-client';
54
+
55
+ let config: ShopSessionConfig | null = null;
56
+ let client: ForgeCartShopClient | null = null;
57
+ let syncedToken: string | null = null;
58
+
59
+ /** Read a JS-visible mirror cookie (null outside the browser or when absent). */
60
+ function readMirrorCookie(name: string): string | null {
61
+ if (typeof document === 'undefined') return null;
62
+ const entry = document.cookie
63
+ .split('; ')
64
+ .find((candidate) => candidate.startsWith(`${name}=`));
65
+ if (!entry) return null;
66
+ const value = decodeURIComponent(entry.slice(name.length + 1));
67
+ return value.length > 0 ? value : null;
68
+ }
69
+
70
+ /** Read the session mirror cookie (null outside the browser or when absent). */
71
+ export function readMirrorSession(): string | null {
72
+ return readMirrorCookie(SESSION_MIRROR_COOKIE);
73
+ }
74
+
75
+ /**
76
+ * Arm the singleton with the channel coordinates (idempotent; provider mount).
77
+ *
78
+ * A locale switch is a full document load — the switcher uses plain anchors
79
+ * precisely so the language cannot go stale — which resets this module and
80
+ * rebuilds the client at the new language. The re-sync below covers the
81
+ * remaining case: a client-side navigation that somehow changes the resolved
82
+ * locale would otherwise leave a live socket speaking the previous language
83
+ * for the rest of the session. `setLanguageCode` disposes and re-handshakes
84
+ * the socket, so it is called ONLY on an actual change.
85
+ */
86
+ export function initShopSession(next: ShopSessionConfig): void {
87
+ const previous = config;
88
+ config = next;
89
+ if (client && previous && previous.languageCode !== next.languageCode) {
90
+ client.setLanguageCode(next.languageCode);
91
+ }
92
+ }
93
+
94
+ /** Whether the session socket can exist (configured scaffold + browser). */
95
+ export function isSessionReady(): boolean {
96
+ return config !== null && typeof window !== 'undefined';
97
+ }
98
+
99
+ function ensureClient(): ForgeCartShopClient {
100
+ if (!config) {
101
+ throw new Error(
102
+ 'Shop session not initialized — CartProvider must mount with shopConfig first.',
103
+ );
104
+ }
105
+ if (!client) {
106
+ client = new ForgeCartShopClient({
107
+ endpoint: config.apiUrl,
108
+ channelToken: config.channelToken,
109
+ // Fixed at handshake — the cart, checkout and every error message on
110
+ // this socket answer in the language the URL resolved to.
111
+ languageCode: config.languageCode,
112
+ // The visitor mirror is minted by the middleware before any page can
113
+ // mount the provider, so it is present on every real navigation; when
114
+ // it is somehow absent the client simply sends no visitor header and
115
+ // visitor-subject goals stay unstamped (never guess — #1079).
116
+ visitorId: readMirrorCookie(VISITOR_MIRROR_COOKIE) ?? undefined,
117
+ });
118
+ const existing = readMirrorSession();
119
+ if (existing) {
120
+ client.setAuthToken(existing);
121
+ syncedToken = existing;
122
+ }
123
+ }
124
+ return client;
125
+ }
126
+
127
+ /**
128
+ * Run one session operation on the shopper's socket and return the typed
129
+ * envelope. Session capture: when the operation minted or rotated the
130
+ * session, the new token is synced into the cookies fire-and-forget —
131
+ * a sync failure never breaks the shopper's interaction.
132
+ */
133
+ export async function runSessionOp<T>(
134
+ op: (client: ForgeCartShopClient) => Promise<T>,
135
+ ): Promise<ActionResult<T>> {
136
+ if (!isSessionReady()) {
137
+ return { ok: false, error: UNREACHABLE_ERROR };
138
+ }
139
+ const live = ensureClient();
140
+ try {
141
+ const data = await op(live);
142
+ const token = live.getAuthToken();
143
+ if (token && token !== syncedToken) {
144
+ syncedToken = token;
145
+ void syncShopSession(token).catch(() => undefined);
146
+ }
147
+ return { ok: true, data };
148
+ } catch (error) {
149
+ return { ok: false, error: extractError(error) ?? UNREACHABLE_ERROR };
150
+ }
151
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Shared marketing-event upstream forwarder (design doc S7 + F-B).
3
+ *
4
+ * ONE implementation of "send one trackEvent to the ForgeCart shop API over
5
+ * plain HTTP GraphQL", used by both server-side emitters:
6
+ *
7
+ * - the `/__fc/track` relay route (client batches — page_view, cta_click,
8
+ * heartbeat, …), which loops this per item;
9
+ * - `experiments.ts` (F-B), which emits `experiment_exposure` inside
10
+ * `next/server` `after()` — the exposure MUST NOT self-call the relay
11
+ * over HTTP (an extra localhost round-trip that breaks the moment the
12
+ * server is bound behind a proxy) and MUST NOT use the SDK's WebSocket
13
+ * singleton (its connection pins one identity; design doc F-B blocker).
14
+ *
15
+ * Why raw HTTP GraphQL: the generated SDK transports operations over one
16
+ * WebSocket whose connection pins ONE identity — the relay/exposure path
17
+ * instead sends each request with the CURRENT shopper session as
18
+ * `Authorization: Bearer`, so attribution follows the shopper. The generated
19
+ * SDK also has no plain-HTTP query path, hence the hand-rolled POST (the shop
20
+ * API serves HTTP POST at the same `/shop-api` URL).
21
+ *
22
+ * Config is env-backed (`forgecart init` writes `.env`); before that
23
+ * exists (the workspace-pod image prewarm) `getUpstreamConfig()` answers
24
+ * `null` and every caller stays inert — the storefront never crashes over
25
+ * missing analytics config.
26
+ */
27
+
28
+ const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
29
+ const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
30
+
31
+ /**
32
+ * Conservative bot gate shared by every server-side emitter: only UA
33
+ * substrings that are unambiguously non-human (crawlers and CLI fetchers).
34
+ * Real browsers never match; an absent UA does NOT match (absence is not
35
+ * obvious, and losing real events costs more than relaying a stray
36
+ * programmatic call the backend's reputation flags catch).
37
+ */
38
+ const BOT_UA_SUBSTRINGS = ['bot', 'spider', 'crawl', 'curl', 'wget'];
39
+
40
+ /**
41
+ * The SDK's own `ShopTrackEvent` document, inlined: the generated client is
42
+ * WebSocket-only, so this module speaks plain HTTP GraphQL with the same shape.
43
+ */
44
+ const TRACK_EVENT_MUTATION = `mutation ShopTrackEvent($input: TrackEventInput!) {
45
+ trackEvent(input: $input) {
46
+ eventId
47
+ accepted
48
+ }
49
+ }`;
50
+
51
+ /** The channel-scoped upstream the storefront server talks to. */
52
+ export interface UpstreamConfig {
53
+ shopApiUrl: string;
54
+ channelToken: string;
55
+ }
56
+
57
+ /** The shop API's `TrackEventInput` — validated allowlist, never raw client JSON. */
58
+ export interface TrackEventInput {
59
+ eventType: string;
60
+ eventId?: string;
61
+ occurredAt?: string;
62
+ properties?: Record<string, unknown>;
63
+ utmSource?: string;
64
+ utmMedium?: string;
65
+ utmCampaign?: string;
66
+ utmTerm?: string;
67
+ utmContent?: string;
68
+ currencyCode?: string;
69
+ }
70
+
71
+ /** Per-request identity/enrichment headers forwarded upstream with one event. */
72
+ export interface ForwardHeaders {
73
+ /** Current shopper session riding as `Authorization: Bearer`, if any. */
74
+ sessionToken: string | null;
75
+ /** The browser's own User-Agent, forwarded for device enrichment. */
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;
87
+ /**
88
+ * Client IP as `x-forwarded-for` (the proxy-standard contract the shop API
89
+ * trusts) — NEVER ForgeCart's edge-secret-gated client-IP override header
90
+ * (design doc S7): unsigned use would collapse per-IP rate limits onto the
91
+ * storefront server's IP and geo-seed wrong market/currency defaults.
92
+ */
93
+ forwardedFor: string | null;
94
+ }
95
+
96
+ export interface UpstreamOutcome {
97
+ accepted: boolean;
98
+ eventId: string | null;
99
+ /** Session token surfaced in the response extensions, if any. */
100
+ sessionToken: string | null;
101
+ }
102
+
103
+ interface GraphQLTrackResponse {
104
+ data?: { trackEvent?: { eventId?: string; accepted?: boolean } | null } | null;
105
+ errors?: unknown[];
106
+ extensions?: Record<string, unknown>;
107
+ }
108
+
109
+ /**
110
+ * The configured upstream, or `null` before `forgecart init` writes
111
+ * `.env` (the image-prewarm contract — callers must stay inert).
112
+ */
113
+ export function getUpstreamConfig(): UpstreamConfig | null {
114
+ if (!SHOP_API_URL || !CHANNEL_TOKEN) return null;
115
+ return { shopApiUrl: SHOP_API_URL, channelToken: CHANNEL_TOKEN };
116
+ }
117
+
118
+ /** True when the UA is unambiguously a crawler/CLI fetcher — skip emission. */
119
+ export function isObviousBot(userAgent: string): boolean {
120
+ const normalized = userAgent.toLowerCase();
121
+ return BOT_UA_SUBSTRINGS.some((substring) => normalized.includes(substring));
122
+ }
123
+
124
+ /**
125
+ * The header set every upstream call carries.
126
+ *
127
+ * ONE builder, shared with the marketing-identity forwarder
128
+ * (`identify-forward.ts`), because both speak about the SAME shopper: the
129
+ * session riding as `Authorization` decides which identity a row is written to,
130
+ * so a header set that drifted between them would split one visitor in two and
131
+ * nothing downstream could put them back together.
132
+ */
133
+ export function buildUpstreamHeaders(
134
+ channelToken: string,
135
+ { sessionToken, userAgent, forwardedFor, secChUa, secChUaMobile, secChUaPlatform }: ForwardHeaders,
136
+ ): Record<string, string> {
137
+ const headers: Record<string, string> = {
138
+ 'content-type': 'application/json',
139
+ 'forgecart-token': channelToken,
140
+ };
141
+ if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
142
+ if (userAgent) headers['user-agent'] = userAgent;
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;
147
+ return headers;
148
+ }
149
+
150
+ /**
151
+ * The session the shop API minted for a cookie-less request, if it minted one.
152
+ *
153
+ * Surfaced in the response `extensions` exactly once. Shared with the identity
154
+ * forwarder for the same reason the headers are: both relays may ESTABLISH the
155
+ * shopper's `forgecart-session` cookie from this value, and two readings of
156
+ * "did the upstream mint a session" would eventually mean two cookies.
157
+ */
158
+ export function readMintedSessionToken(
159
+ extensions: Record<string, unknown> | undefined,
160
+ ): string | null {
161
+ const minted = extensions?.['forgecart-auth-token'];
162
+ return typeof minted === 'string' && minted.length > 0 ? minted : null;
163
+ }
164
+
165
+ /**
166
+ * Forward one event as a raw HTTP GraphQL POST. Any failure — missing config,
167
+ * network, HTTP status, GraphQL errors — resolves to `accepted:false`; the
168
+ * forwarder is best-effort by design (analytics must never break the
169
+ * storefront) and `accepted:false` is terminal (callers never retry).
170
+ */
171
+ export async function forwardTrackEvent(
172
+ input: TrackEventInput,
173
+ forward: ForwardHeaders,
174
+ ): Promise<UpstreamOutcome> {
175
+ const upstream = getUpstreamConfig();
176
+ if (!upstream) return { accepted: false, eventId: null, sessionToken: null };
177
+
178
+ try {
179
+ const response = await fetch(upstream.shopApiUrl, {
180
+ method: 'POST',
181
+ headers: buildUpstreamHeaders(upstream.channelToken, forward),
182
+ body: JSON.stringify({ query: TRACK_EVENT_MUTATION, variables: { input } }),
183
+ });
184
+ if (!response.ok) return { accepted: false, eventId: null, sessionToken: null };
185
+
186
+ const payload = (await response.json()) as GraphQLTrackResponse;
187
+ const capturedToken = readMintedSessionToken(payload.extensions);
188
+ const trackEvent = payload.data?.trackEvent;
189
+ if (!trackEvent) return { accepted: false, eventId: null, sessionToken: capturedToken };
190
+ return {
191
+ accepted: trackEvent.accepted === true,
192
+ eventId: trackEvent.eventId ?? null,
193
+ sessionToken: capturedToken,
194
+ };
195
+ } catch {
196
+ // Best-effort by design: an unreachable/booting backend must never break
197
+ // the storefront, and `accepted:false` is terminal — no caller retries.
198
+ return { accepted: false, eventId: null, sessionToken: null };
199
+ }
200
+ }
@@ -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
+ }