@forgecart/cli 2.202608221935.0 → 2.202609190800.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 (90) hide show
  1. package/dist/src/cli.js +3 -21
  2. package/dist/src/cli.js.map +1 -1
  3. package/dist/src/commands/__test__/cli-harness.d.ts +21 -0
  4. package/dist/src/commands/__test__/cli-harness.js +29 -0
  5. package/dist/src/commands/__test__/cli-harness.js.map +1 -0
  6. package/dist/src/commands/init.d.ts +28 -2
  7. package/dist/src/commands/init.js +100 -15
  8. package/dist/src/commands/init.js.map +1 -1
  9. package/dist/src/commands/refresh.d.ts +40 -0
  10. package/dist/src/commands/refresh.js +147 -0
  11. package/dist/src/commands/refresh.js.map +1 -0
  12. package/dist/src/commands/template-manifest.d.ts +38 -0
  13. package/dist/src/commands/template-manifest.js +116 -0
  14. package/dist/src/commands/template-manifest.js.map +1 -0
  15. package/dist/src/version.d.ts +10 -0
  16. package/dist/src/version.js +25 -0
  17. package/dist/src/version.js.map +1 -0
  18. package/package.json +1 -1
  19. package/templates/storefront/README.md +42 -4
  20. package/templates/storefront/next.config.js +29 -7
  21. package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
  22. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
  23. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
  24. package/templates/storefront/src/app/cart/page.tsx +14 -2
  25. package/templates/storefront/src/app/checkout/page.tsx +14 -2
  26. package/templates/storefront/src/app/layout.tsx +85 -22
  27. package/templates/storefront/src/app/page.tsx +63 -20
  28. package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
  29. package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
  30. package/templates/storefront/src/app/ping/route.ts +1 -1
  31. package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
  32. package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
  33. package/templates/storefront/src/app/products/page.tsx +41 -6
  34. package/templates/storefront/src/app/register/page.tsx +54 -0
  35. package/templates/storefront/src/app/reset-password/page.tsx +60 -0
  36. package/templates/storefront/src/app/robots.ts +69 -0
  37. package/templates/storefront/src/app/sitemap.ts +106 -0
  38. package/templates/storefront/src/app/verify/page.tsx +155 -0
  39. package/templates/storefront/src/components/CartView.tsx +26 -7
  40. package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
  41. package/templates/storefront/src/components/Header.tsx +30 -10
  42. package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
  43. package/templates/storefront/src/components/LocaleLink.tsx +49 -0
  44. package/templates/storefront/src/components/ProductCard.tsx +10 -4
  45. package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
  46. package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
  47. package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
  48. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
  49. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
  50. package/templates/storefront/src/lib/account/account-link.ts +76 -0
  51. package/templates/storefront/src/lib/account/register-state.ts +133 -0
  52. package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
  53. package/templates/storefront/src/lib/account/verify-state.ts +56 -0
  54. package/templates/storefront/src/lib/account-actions.ts +76 -0
  55. package/templates/storefront/src/lib/account-session.ts +47 -0
  56. package/templates/storefront/src/lib/asset-alt.ts +34 -0
  57. package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
  58. package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
  59. package/templates/storefront/src/lib/experiments.ts +1 -1
  60. package/templates/storefront/src/lib/forgecart.ts +300 -27
  61. package/templates/storefront/src/lib/format.ts +12 -14
  62. package/templates/storefront/src/lib/identify-forward.ts +152 -0
  63. package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
  64. package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
  65. package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
  66. package/templates/storefront/src/lib/locale/grammar.ts +194 -0
  67. package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
  68. package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
  69. package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
  70. package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
  71. package/templates/storefront/src/lib/marketing-params.ts +213 -0
  72. package/templates/storefront/src/lib/money.ts +50 -0
  73. package/templates/storefront/src/lib/seo/alternates.ts +120 -0
  74. package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
  75. package/templates/storefront/src/lib/seo/metadata.ts +323 -0
  76. package/templates/storefront/src/lib/seo/noindex.ts +218 -0
  77. package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
  78. package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
  79. package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
  80. package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
  81. package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
  82. package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
  83. package/templates/storefront/src/lib/session-actions.ts +15 -8
  84. package/templates/storefront/src/lib/session-cookies.ts +98 -0
  85. package/templates/storefront/src/lib/shop-config.ts +9 -2
  86. package/templates/storefront/src/lib/shop-session.ts +42 -5
  87. package/templates/storefront/src/lib/track-forward.ts +43 -14
  88. package/templates/storefront/src/middleware.ts +196 -16
  89. package/templates/storefront/src/seo/redirects.ts +44 -0
  90. package/templates/storefront/src/server/runner.ts +1 -2
@@ -122,21 +122,21 @@ export function isObviousBot(userAgent: string): boolean {
122
122
  }
123
123
 
124
124
  /**
125
- * Forward one event as a raw HTTP GraphQL POST. Any failure — missing config,
126
- * network, HTTP status, GraphQL errors — resolves to `accepted:false`; the
127
- * forwarder is best-effort by design (analytics must never break the
128
- * storefront) and `accepted:false` is terminal (callers never retry).
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.
129
132
  */
130
- export async function forwardTrackEvent(
131
- input: TrackEventInput,
133
+ export function buildUpstreamHeaders(
134
+ channelToken: string,
132
135
  { sessionToken, userAgent, forwardedFor, secChUa, secChUaMobile, secChUaPlatform }: ForwardHeaders,
133
- ): Promise<UpstreamOutcome> {
134
- const upstream = getUpstreamConfig();
135
- if (!upstream) return { accepted: false, eventId: null, sessionToken: null };
136
-
136
+ ): Record<string, string> {
137
137
  const headers: Record<string, string> = {
138
138
  'content-type': 'application/json',
139
- 'forgecart-token': upstream.channelToken,
139
+ 'forgecart-token': channelToken,
140
140
  };
141
141
  if (sessionToken) headers['Authorization'] = `Bearer ${sessionToken}`;
142
142
  if (userAgent) headers['user-agent'] = userAgent;
@@ -144,18 +144,47 @@ export async function forwardTrackEvent(
144
144
  if (secChUa) headers['sec-ch-ua'] = secChUa;
145
145
  if (secChUaMobile) headers['sec-ch-ua-mobile'] = secChUaMobile;
146
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 };
147
177
 
148
178
  try {
149
179
  const response = await fetch(upstream.shopApiUrl, {
150
180
  method: 'POST',
151
- headers,
181
+ headers: buildUpstreamHeaders(upstream.channelToken, forward),
152
182
  body: JSON.stringify({ query: TRACK_EVENT_MUTATION, variables: { input } }),
153
183
  });
154
184
  if (!response.ok) return { accepted: false, eventId: null, sessionToken: null };
155
185
 
156
186
  const payload = (await response.json()) as GraphQLTrackResponse;
157
- const minted = payload.extensions?.['forgecart-auth-token'];
158
- const capturedToken = typeof minted === 'string' && minted.length > 0 ? minted : null;
187
+ const capturedToken = readMintedSessionToken(payload.extensions);
159
188
  const trackEvent = payload.data?.trackEvent;
160
189
  if (!trackEvent) return { accepted: false, eventId: null, sessionToken: capturedToken };
161
190
  return {
@@ -2,8 +2,18 @@ import { NextResponse } from 'next/server';
2
2
  import type { NextRequest } from 'next/server';
3
3
  import { uuidv7 } from 'uuidv7';
4
4
 
5
+ import {
6
+ hasServerAuthoredHeaders,
7
+ localeRequestHeaders,
8
+ planLocaleRewrite,
9
+ } from './lib/locale/middleware-plan';
10
+ import type { LocaleRewritePlan } from './lib/locale/middleware-plan';
11
+ import { planRedirect } from './lib/seo/redirect-plan';
12
+ import { REDIRECTS } from './seo/redirects';
13
+
5
14
  /**
6
- * Visitor-identity + forced-experiment-entry middleware (design doc S6 + F-B).
15
+ * Visitor-identity + forced-experiment-entry + locale-rewrite middleware
16
+ * (design doc S6 + F-B; locale stage = epic launch#54 W1-8 / S1).
7
17
  *
8
18
  * S6 — visitor identity. Mints the durable anonymous visitor key — the
9
19
  * `forgecart-visitor` cookie (UUIDv7, so ids sort by first-touch time) —
@@ -15,6 +25,16 @@ import { uuidv7 } from 'uuidv7';
15
25
  * - Mint ONLY when the cookie is absent. An identified visitor's response
16
26
  * carries NO `Set-Cookie` at all — re-sending it on every response would
17
27
  * make every page uncacheable for shared caches.
28
+ * - The key is DUAL-HOMED (#1079 P1, deliberate posture change): the
29
+ * canonical `forgecart-visitor` cookie stays httpOnly (server reads:
30
+ * `getVariant()`'s assignment, header forwarding), and a byte-identical
31
+ * NON-httpOnly mirror `forgecart-visitor-client` (the
32
+ * `forgecart-session-client` pattern) exposes it to browser JS so the
33
+ * SDK can send it in WS connection params — visitor-subject goal events
34
+ * are unattributable without it. The id is a UUIDv7, not a secret, and
35
+ * the httpOnly original stays authoritative: a missing or tampered
36
+ * mirror is re-synced FROM it (one extra `Set-Cookie` response per
37
+ * legacy visitor, then the steady state is Set-Cookie-free again).
18
38
  * - Zero fetches, zero environment reads: the workspace-pod prewarm boots
19
39
  * this dev server with no `.env`, and the middleware must be inert
20
40
  * to that (it never crashes a request, env or not).
@@ -44,10 +64,68 @@ import { uuidv7 } from 'uuidv7';
44
64
  * propagation for the VISITOR id (it reads-else-mints within a
45
65
  * request-scoped cache); the forced map is different: it either rode in on
46
66
  * the request cookie or is deterministically rewritten here.
67
+ *
68
+ * S1 — locale rewrite. `/<xx>/<rest>` is rewritten to `/<rest>`, with the
69
+ * locale carried forward on the request as `x-forgecart-locale` and announced
70
+ * on the response as `Content-Language`. The route tree therefore stays FLAT:
71
+ * no `[locale]` segment, no root catch-all (#1041 law).
72
+ *
73
+ * The locale DECISION is not here — it is `planLocaleRewrite`, a pure module
74
+ * the template spec project pins. This file imports `next/server`, so nothing
75
+ * in it can be reached by that project; the locale stage therefore keeps no
76
+ * decision of its own and only maps the two plan cases onto Next responses.
77
+ * (The F-B parsing and merging below predate that split and are still
78
+ * unpinned — they are covered, like the rest of this file's runtime
79
+ * behaviour, only by the storefront-verify phases.)
80
+ *
81
+ * Three properties of this stage are load-bearing:
82
+ *
83
+ * - It obeys the same zero-fetch / zero-env contract as the rest of this
84
+ * file. A two-letter first segment is only a CANDIDATE here; whether the
85
+ * channel offers it is decided in the layout's request-time hole, the only
86
+ * place that can ask. Validating here would require a fetch, and a fetch in
87
+ * middleware is exactly what the prewarm boot cannot afford.
88
+ * - It runs BEFORE, and independently of, the cookie fast path below. A
89
+ * rewrite is not cookie work: an already-identified visitor carrying no
90
+ * forced entry does no cookie work at all, so a locale stage placed inside
91
+ * that branch would render the DEFAULT language for every prefixed URL a
92
+ * returning visitor opens.
93
+ * - It is the only place `Content-Language` can be set at all. No RSC API
94
+ * lets a layout or page write a response header, so this is the single
95
+ * emitter by necessity rather than by policy — and nothing downstream may
96
+ * add a second, which would produce two header lines.
97
+ *
98
+ * Two consequences are accepted deliberately. It labels the CANDIDATE,
99
+ * because validity is not knowable here: `/zz/x` carries
100
+ * `Content-Language: zz` on the 404 the layout then raises, and
101
+ * `/en/x` carries `Content-Language: en` on its 308. Both are inert — a
102
+ * crawler drops a 404 rather than indexing its language, and a 308 has no
103
+ * body to describe. And the UNPREFIXED default-language response carries no
104
+ * header at all, since this stage cannot know the channel's default without
105
+ * a fetch. The layout announces that one as `<html lang>`, which is the
106
+ * signal search engines actually consume for language; the header is the
107
+ * weaker of the two and is required here only for the prefixed case.
108
+ *
109
+ * Store-wide routes are protected from the locale stage by `planLocaleRewrite`,
110
+ * not by the matcher. The matcher below excludes only `_next/static`,
111
+ * `_next/image`, `favicon.ico`, `ping` and `__forge_beacon`, and only in their
112
+ * UNPREFIXED spelling — its negative lookahead is anchored at the start of the
113
+ * path, so `/de/ping` reaches this file even though `/ping` does not.
114
+ * `sitemap.xml`, `robots.txt`, `api` and `__fc` are not excluded at all and run
115
+ * through this middleware in every spelling. What keeps every one of them
116
+ * unrewritten is the planner's `pass`, whose list is the grammar's.
47
117
  */
48
118
 
49
119
  const VISITOR_COOKIE_NAME = 'forgecart-visitor';
50
120
 
121
+ /**
122
+ * Browser-readable mirror of {@link VISITOR_COOKIE_NAME} (#1079 P1) — same
123
+ * value, same lifetime, NOT httpOnly. Read by the shop-session SDK
124
+ * bootstrap and sent as the `forgecart-visitor` WS connection-params
125
+ * header. Never read server-side: the httpOnly original is authoritative.
126
+ */
127
+ const VISITOR_MIRROR_COOKIE_NAME = 'forgecart-visitor-client';
128
+
51
129
  /** 400 days — Chrome's upper bound on cookie lifetime (RFC 6265bis). */
52
130
  const VISITOR_COOKIE_MAX_AGE_SECONDS = 400 * 24 * 60 * 60;
53
131
 
@@ -147,21 +225,112 @@ function mergeForcedEntries(request: NextRequest): string | null {
147
225
  return JSON.stringify(Object.fromEntries(merged));
148
226
  }
149
227
 
228
+ /**
229
+ * The response the cookie steps decorate — one per plan case.
230
+ *
231
+ * A `pass` keeps the untouched `NextResponse.next()` whenever it can, and that
232
+ * is a deliberate budget decision rather than a leftover: passing
233
+ * `{ request: { headers } }` makes Next copy the ENTIRE inbound header block
234
+ * into `x-middleware-request-*` on the middleware→server hop, against Node's
235
+ * 16 KB default. This matcher covers `/api/*`, `_next/data`, `sitemap.xml` and
236
+ * every RSC prefetch, so paying that on all of them — to sanitize the few
237
+ * requests that actually arrive forged — would be a poor trade. The mutated
238
+ * path is taken only when there is something to say or something to strip.
239
+ *
240
+ * Headers are cloned AFTER any forced-entry cookie rewrite, so a rewritten
241
+ * request carries the merged cookie header too — the landing render in a
242
+ * prefixed locale must still see its forced variant. The query string survives
243
+ * because `nextUrl.clone()` carries it and only `pathname` is reassigned.
244
+ */
245
+ function buildBaseResponse(
246
+ request: NextRequest,
247
+ plan: LocaleRewritePlan,
248
+ requestHeadersMutated: boolean,
249
+ ): NextResponse {
250
+ if (plan.kind === 'pass') {
251
+ if (!requestHeadersMutated && !hasServerAuthoredHeaders(request.headers)) {
252
+ return NextResponse.next();
253
+ }
254
+ const headers = localeRequestHeaders(request.headers, plan, request.nextUrl.search);
255
+ return NextResponse.next({ request: { headers } });
256
+ }
257
+
258
+ const url = request.nextUrl.clone();
259
+ url.pathname = plan.routePath;
260
+ const headers = localeRequestHeaders(request.headers, plan, request.nextUrl.search);
261
+ const response = NextResponse.rewrite(url, { request: { headers } });
262
+ response.headers.set('Content-Language', plan.locale);
263
+ return response;
264
+ }
265
+
150
266
  export function middleware(request: NextRequest): NextResponse {
267
+ // W1-9 — rename redirects, BEFORE everything else. A retired URL is not a
268
+ // page of this site any more, so nothing downstream should spend work on it:
269
+ // not the locale rewrite (the destination gets its own pass), not the cookie
270
+ // stages (the browser follows the 308 and is identified on the hop that
271
+ // actually renders). The query string rides along, so a campaign link's
272
+ // `?fc-exp=` survives a rename.
273
+ //
274
+ // The table is a SOURCE file (`src/seo/redirects.ts`) rather than
275
+ // `next.config.js`'s `redirects()` because that one is read once at boot: an
276
+ // entry added there does nothing until the server restarts, which in a
277
+ // workspace pod means the merchant watches their rename not work.
278
+ const redirectTo = planRedirect(request.nextUrl.pathname, REDIRECTS);
279
+ if (redirectTo !== null) {
280
+ const destination = request.nextUrl.clone();
281
+ destination.pathname = redirectTo;
282
+ return NextResponse.redirect(destination, 308);
283
+ }
284
+
285
+ // Then the locale plan — outside the cookie fast path below, because it is
286
+ // not cookie work. An identified visitor with no forced entry does no cookie
287
+ // work at all, so a locale stage inside that branch would serve the default
288
+ // language for every prefixed URL a returning visitor opens.
289
+ const plan = planLocaleRewrite(request.nextUrl.pathname);
151
290
  const forcedEntryValue = mergeForcedEntries(request);
152
- const mintVisitor = !request.cookies.has(VISITOR_COOKIE_NAME);
153
- if (!forcedEntryValue && !mintVisitor) {
291
+ const storedVisitorId = request.cookies.get(VISITOR_COOKIE_NAME)?.value;
292
+ const mintVisitor = !storedVisitorId;
293
+ // Mirror sync (#1079 P1): absent on every pre-mirror visitor's first
294
+ // request back, and re-synced whenever it disagrees with the httpOnly
295
+ // original (client JS can rewrite it — the original is authoritative).
296
+ // `mintVisitor` implies a sync: a fresh id is written to BOTH homes.
297
+ const syncMirror =
298
+ mintVisitor || request.cookies.get(VISITOR_MIRROR_COOKIE_NAME)?.value !== storedVisitorId;
299
+ // The steady-state fast path: an identified visitor on an unprefixed URL with
300
+ // no campaign parameter needs no request-header override at all, and skipping
301
+ // it avoids Next copying the entire inbound header block into
302
+ // `x-middleware-request-*` on every such request.
303
+ //
304
+ // `hasServerAuthoredHeaders` MUST be part of this condition, not only of
305
+ // `buildBaseResponse`. A bare `NextResponse.next()` emits no
306
+ // `x-middleware-override-headers`, and Next deletes non-overridden inbound
307
+ // request headers ONLY under that header — so returning early here would let a
308
+ // forged `x-forgecart-locale` survive into the render on the exact path most
309
+ // real traffic takes (every returning browser: visitor cookie present and its
310
+ // mirror in agreement). A forged request instead falls through to
311
+ // `buildBaseResponse`, which strips the server-authored names and re-emits.
312
+ if (
313
+ plan.kind === 'pass' &&
314
+ !forcedEntryValue &&
315
+ !syncMirror &&
316
+ !hasServerAuthoredHeaders(request.headers)
317
+ ) {
154
318
  return NextResponse.next();
155
319
  }
156
320
 
157
- let response: NextResponse;
158
321
  if (forcedEntryValue) {
159
322
  // Rewrite this request's own cookie header so the landing render already
160
323
  // resolves the forced variant (Set-Cookie alone reaches only the NEXT
161
- // request), then persist for subsequent navigations. No redirect — the
162
- // campaign URL stays shareable.
324
+ // request), then persist for subsequent navigations. The forced-entry
325
+ // stage itself never redirects — the campaign URL stays shareable — and
326
+ // when the locale stage does canonicalize one, the query string rides
327
+ // along, so the entry survives the hop either way.
163
328
  request.cookies.set(FORCED_ENTRY_COOKIE_NAME, forcedEntryValue);
164
- response = NextResponse.next({ request: { headers: request.headers } });
329
+ }
330
+
331
+ const response = buildBaseResponse(request, plan, forcedEntryValue !== null);
332
+
333
+ if (forcedEntryValue) {
165
334
  response.cookies.set({
166
335
  name: FORCED_ENTRY_COOKIE_NAME,
167
336
  value: forcedEntryValue,
@@ -171,18 +340,29 @@ export function middleware(request: NextRequest): NextResponse {
171
340
  maxAge: FORCED_ENTRY_COOKIE_MAX_AGE_SECONDS,
172
341
  path: '/',
173
342
  });
174
- } else {
175
- response = NextResponse.next();
176
343
  }
177
344
 
178
- if (mintVisitor) {
179
- // `secure` is honored on https (deployed storefronts) and on localhost;
180
- // browsers may drop it on other plain-http dev hosts — acceptable, since a
181
- // stable visitor identity is a production concern, not a preview one.
345
+ if (syncMirror) {
346
+ const visitorId = storedVisitorId ?? uuidv7();
347
+ if (mintVisitor) {
348
+ // `secure` is honored on https (deployed storefronts) and on localhost;
349
+ // browsers may drop it on other plain-http dev hosts — acceptable, since a
350
+ // stable visitor identity is a production concern, not a preview one.
351
+ response.cookies.set({
352
+ name: VISITOR_COOKIE_NAME,
353
+ value: visitorId,
354
+ httpOnly: true,
355
+ secure: true,
356
+ sameSite: 'lax',
357
+ maxAge: VISITOR_COOKIE_MAX_AGE_SECONDS,
358
+ path: '/',
359
+ });
360
+ }
361
+ // Deliberately NOT httpOnly — see VISITOR_MIRROR_COOKIE_NAME.
182
362
  response.cookies.set({
183
- name: VISITOR_COOKIE_NAME,
184
- value: uuidv7(),
185
- httpOnly: true,
363
+ name: VISITOR_MIRROR_COOKIE_NAME,
364
+ value: visitorId,
365
+ httpOnly: false,
186
366
  secure: true,
187
367
  sameSite: 'lax',
188
368
  maxAge: VISITOR_COOKIE_MAX_AGE_SECONDS,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Rename redirects — the one file you edit when a page moves.
3
+ *
4
+ * When a page's URL changes, the old address does not stop existing: it is in
5
+ * search results, in links other people wrote, and in browser history. Left to
6
+ * 404 it loses everything the old URL had earned. A 308 hands that across to
7
+ * the new address instead.
8
+ *
9
+ * Add an entry and save. The dev server picks it up immediately, so the
10
+ * redirect is live in the same session — which is exactly why this is a source
11
+ * file and not `next.config.js`'s `redirects()`: that one is read once at boot,
12
+ * so an entry added there does nothing until the server restarts.
13
+ *
14
+ * export const REDIRECTS: readonly RedirectRule[] = [
15
+ * { from: '/summer-sale', to: '/promotions/summer' },
16
+ * ];
17
+ *
18
+ * Three things to know, all enforced in `lib/seo/redirect-plan.ts`:
19
+ *
20
+ * - **Paths are written WITHOUT a language prefix.** `/summer-sale` covers
21
+ * `/de/summer-sale` too, and each redirects inside its own language. Writing
22
+ * `/de/summer-sale` here would cover only German — and the language you
23
+ * forgot would keep 404ing, silently.
24
+ * - **Matching is exact.** `/summer-sale` does not match `/summer-sale/tents`
25
+ * or `/summer-sale-2024`. Each moved page gets its own entry, because a
26
+ * prefix rule would drag URLs you never meant to move.
27
+ * - **Chains collapse and cycles are refused.** `/a → /b → /c` answers ONE
28
+ * 308 straight to `/c`, since every extra hop weakens the signal a crawler
29
+ * carries across. A cycle redirects nowhere at all rather than trapping a
30
+ * browser in a loop.
31
+ */
32
+
33
+ /** One moved page: an old address and the address that replaced it. */
34
+ export interface RedirectRule {
35
+ /** The retired path, unprefixed and rooted — e.g. `/summer-sale`. */
36
+ from: string;
37
+ /** Where it lives now, unprefixed and rooted — e.g. `/promotions/summer`. */
38
+ to: string;
39
+ }
40
+
41
+ /**
42
+ * The table. Empty in a fresh scaffold — nothing has been renamed yet.
43
+ */
44
+ export const REDIRECTS: readonly RedirectRule[] = [];
@@ -4,13 +4,12 @@ import { extractError, type ExtractedError } from '@forgecart/sdk';
4
4
  import { cookies } from 'next/headers';
5
5
 
6
6
  import { UNREACHABLE_ERROR, type ActionResult } from '../lib/action-result';
7
+ import { SESSION_COOKIE } from '../lib/session-cookies';
7
8
  import { getBackend } from './bootstrap';
8
9
  import { ForgeCartClientFactory } from './forgecart/forgecart-client.factory';
9
10
  import { BackendError } from './types';
10
11
  import type { BackendSession } from './types';
11
12
 
12
- const SESSION_COOKIE = 'forgecart-session';
13
-
14
13
  /** The gate has no env yet (image pre-warm / before `forgecart init`). */
15
14
  const NOT_CONFIGURED: ExtractedError = {
16
15
  code: 'BACKEND_NOT_CONFIGURED',