@forgecart/cli 2.202610052310.0 → 2.202610071755.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,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "src/app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "aliases": {
14
+ "components": "@/components",
15
+ "ui": "@/components/ui",
16
+ "utils": "@/components/ui/utils",
17
+ "lib": "@/lib",
18
+ "hooks": "@/hooks"
19
+ },
20
+ "iconLibrary": "lucide"
21
+ }
@@ -0,0 +1,105 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ // Produce a self-contained build so a channel workspace can run the
4
+ // storefront with `next start` without a full node_modules tree.
5
+ output: 'standalone',
6
+ reactStrictMode: true,
7
+ // The embedded NestJS backend (src/server/) must be required at runtime
8
+ // from node_modules, not bundled: Nest's core carries optional
9
+ // peer-dependency requires (microservices, platform adapters) that the
10
+ // bundler would otherwise chase into "module not found" errors.
11
+ // NOTE: externalizing Nest keeps it out of the NORMAL server bundle, but
12
+ // `src/instrumentation.ts` compiles under its own rules and statically
13
+ // resolves Nest's lazy `require('class-transformer')` /
14
+ // `require('class-validator')` (class-serializer + ValidationPipe). Those
15
+ // optional peers are therefore REAL dependencies in package.json — remove
16
+ // them and `next build` dies with Module-not-found on the Epinio staging
17
+ // path (CI run 31764049991, both attempts) while dev mode keeps working.
18
+ serverExternalPackages: ['@nestjs/core', '@nestjs/common'],
19
+ // The visual-editor preview runs THIS dev server inside a workspace pod,
20
+ // embedded cross-origin in the dashboard's editor iframe. Next 16 ENFORCES
21
+ // `allowedDevOrigins`: it 403s any cross-site request to an internal dev
22
+ // resource (`/_next`, `/__nextjs`, the HMR websocket) whose Origin/Referer
23
+ // host is not localhost/127.0.0.1 and not listed here — silently killing the
24
+ // dashboard iframe's dev channel with no page error. So this list must name
25
+ // every ORIGIN THAT EMBEDS the editor — i.e. each env's DASHBOARD host — not
26
+ // just the preview subdomain. GOTCHA: `*` matches exactly ONE label (Next's
27
+ // `matchWildcardDomain`), so `*.forgecart.dev` does NOT cover the two-label
28
+ // prod preview host `<code>.preview.forgecart.dev` — it must be listed
29
+ // explicitly. Dev-only — `next start` (deployed storefronts) ignores it.
30
+ allowedDevOrigins: [
31
+ '*.127.0.0.1.nip.io', // LOCAL preview ingress (k3s Traefik :8081) — the host every local pod serves under; without it Next dev 403s the store's own /_next chunks locally (2026-08-13; the wm real-next-app fixture pins the same contract)
32
+ '*.vm.forgecart.com', // `pnpm vm` Cloudflare-tunnel dashboards
33
+ '*.dev.forgecart.dev', // dev dashboard + dev preview subdomains
34
+ '*.forgecart.dev', // single-label deployed preview hosts
35
+ '*.preview.forgecart.dev', // two-label prod preview host (`*.forgecart.dev` can't match it)
36
+ '*.preview.test.forgecart.com', // test-env preview subdomains (plain-http zone; .dev is HSTS-preloaded)
37
+ 'dashboard.test.forgecart.com', // test dashboard origin that embeds the editor
38
+ 'dashboard.forgecart.com', // prod dashboard origin that embeds the editor
39
+ ],
40
+ // CACHE COMPONENTS: OFF, explicitly — and the explicitness is the point.
41
+ //
42
+ // The root layout reads the request locale (`x-forgecart-locale`) to set
43
+ // `<html lang>`, which is an attribute on the document root and therefore
44
+ // structurally outside every Suspense boundary. Under Cache Components that
45
+ // combination is not buildable: `headers()` returns a hanging promise, and a
46
+ // component aborted with no Suspense frame in its stack raises `blocking-route`
47
+ // → `StaticGenBailoutError`. The only escape Next offers is a `<Suspense>`
48
+ // ABOVE `<body>` — in its own words, "an explicit signal from the user that
49
+ // they acknowledge the empty shell".
50
+ //
51
+ // Taking that escape would cost the locale contract entirely. A real 308/404
52
+ // is assigned only when the render promise REJECTS (`renderToStream`'s catch);
53
+ // with a boundary above, React resolves as CLIENT_RENDERED instead, so
54
+ // `notFound()` / `permanentRedirect()` from the layout would emit a 200
55
+ // carrying a 404 page — a soft-404, indexed rather than dropped, which is the
56
+ // exact failure this storefront's locale routing exists to prevent.
57
+ //
58
+ // Nothing is given up by turning it off: the escape hatch yields an empty
59
+ // prelude on every route anyway (identical TTFB), and the template uses no
60
+ // `use cache`. The per-route Suspense boundaries below still stream their
61
+ // holes — that is core SSR, not a Cache Components feature.
62
+ //
63
+ // Written as an explicit `false` rather than omitted: `enforceExperimentalFeatures`
64
+ // re-enables an UNDEFINED value ("we do respect an explicit value in the user
65
+ // config"), so on a future 16.x under this package's caret range a deleted key
66
+ // would silently restore the failure above — in the merchant's scaffold, far
67
+ // from this repo's CI. An explicit false is immune.
68
+ cacheComponents: false,
69
+ experimental: {
70
+ // Persist Turbopack's dev compile artifacts to `.next` so a pod can serve
71
+ // a PRE-WARMED `.next` baked at build time instead of paying a cold first
72
+ // compile. The cache is path-keyed, so it is only valid when warmed at the
73
+ // SAME absolute path the pod serves from (/workspace) — see the workspace-pod
74
+ // Dockerfile `storefront-prewarm` stage and the STOREFRONT materialization
75
+ // initContainer (serve-in-place; never the /opt→/workspace copy). Default-on
76
+ // in Next 16.1+; set explicitly to document the contract.
77
+ turbopackFileSystemCacheForDev: true,
78
+ },
79
+ // Dev-only `data-fc-source` stamping for the ForgeCart visual editor. The
80
+ // loader marks host JSX elements with their source file:line:col so the
81
+ // dashboard can map a sprayed region back to code. It runs under Turbopack
82
+ // before SWC — with no `as` field, Turbopack chains loader -> built-in SWC,
83
+ // so the stamped TSX is still compiled normally.
84
+ //
85
+ // GLOB GOTCHA (Next 15.5): Turbopack matches a rule key by FILENAME unless it
86
+ // contains `/` (then by full project-relative path), and it does NOT expand
87
+ // braces. The earlier `src/**/*.{tsx,jsx}` therefore matched nothing and the
88
+ // loader silently never ran (`data-fc-source` count: 0). Per-extension
89
+ // filename globs are what Turbopack honors. The loader self-gates on
90
+ // NODE_ENV === 'development', so `next build` (webpack, production) is
91
+ // untouched.
92
+ turbopack: {
93
+ rules: {
94
+ '*.tsx': { loaders: ['@forgecart/designer-runtime/turbo-loader'] },
95
+ '*.jsx': { loaders: ['@forgecart/designer-runtime/turbo-loader'] },
96
+ },
97
+ },
98
+ images: {
99
+ // The shop API serves product/asset previews from arbitrary hosts, so
100
+ // allow any remote image. Tighten this to your asset host in production.
101
+ remotePatterns: [{ protocol: 'https', hostname: '**' }],
102
+ },
103
+ };
104
+
105
+ module.exports = nextConfig;
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "storefront",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev --turbopack",
7
+ "build": "next build",
8
+ "start": "next start"
9
+ },
10
+ "dependencies": {
11
+ "@forgecart/designer-runtime": "^1.202607161748.0",
12
+ "@forgecart/sdk": "^1.202609282317.0",
13
+ "@nestjs/common": "^11.1.14",
14
+ "@nestjs/core": "^11.1.14",
15
+ "@radix-ui/react-label": "^2.1.7",
16
+ "@radix-ui/react-slot": "^1.2.4",
17
+ "class-transformer": "^0.5.1",
18
+ "class-validator": "^0.14.2",
19
+ "class-variance-authority": "^0.7.1",
20
+ "clsx": "^2.1.1",
21
+ "next": "^16.1.0",
22
+ "react": "^19.0.0",
23
+ "react-dom": "^19.0.0",
24
+ "reflect-metadata": "^0.2.2",
25
+ "rxjs": "^7.8.2",
26
+ "server-only": "^0.0.1",
27
+ "tailwind-merge": "^3.5.0",
28
+ "uuidv7": "^1.1.0"
29
+ },
30
+ "devDependencies": {
31
+ "@tailwindcss/postcss": "^4.2.1",
32
+ "@types/node": "^22.10.0",
33
+ "@types/react": "^19.0.0",
34
+ "@types/react-dom": "^19.0.0",
35
+ "postcss": "^8.4.49",
36
+ "tailwindcss": "^4.2.1",
37
+ "typescript": "^5.7.0"
38
+ }
39
+ }
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ plugins: {
3
+ '@tailwindcss/postcss': {},
4
+ },
5
+ };
@@ -0,0 +1,205 @@
1
+ import { NextResponse } from 'next/server';
2
+ import type { NextRequest } from 'next/server';
3
+
4
+ import {
5
+ forwardMarketingIdentifiers,
6
+ type MarketingIdentifierInput,
7
+ } from '../../../lib/identify-forward';
8
+ import { CLICK_ID_VALUE_MAX_LENGTH } from '../../../lib/marketing-params';
9
+ import { SESSION_COOKIE, establishSessionCookies } from '../../../lib/session-cookies';
10
+ import { getUpstreamConfig, isObviousBot } from '../../../lib/track-forward';
11
+
12
+ /**
13
+ * Same-origin marketing-identity relay (#1596).
14
+ *
15
+ * A shopper arriving from an ad carries the platform's CLICK ID in the landing
16
+ * URL and nowhere else — no cookie, no header, and gone the moment they reach a
17
+ * second page. `lib/marketing-params.ts` reads it on the client, the landing
18
+ * page POSTs what it found here, and this handler forwards the batch to the
19
+ * ForgeCart shop API as one `setMarketingIdentifiers` mutation.
20
+ *
21
+ * The upstream hop itself (raw HTTP GraphQL with the channel token, the shopper
22
+ * session as Bearer, and every failure resolving to an empty outcome instead of
23
+ * a throw) lives in `src/lib/identify-forward.ts`. This route owns everything
24
+ * request-shaped: parsing untrusted JSON, per-identifier validation, the bot
25
+ * gate, session threading and cookie persistence.
26
+ *
27
+ * Sibling by construction: `../track/route.ts` relays EVENTS over the same
28
+ * transport, through the same gates, onto the same session cookie PAIR (both
29
+ * routes write it through `src/lib/session-cookies.ts`) — read the two
30
+ * together, and change them together. They stay separate routes because a
31
+ * click ID is not an event: it names the IDENTITY the events belong to, it is
32
+ * sent once per landing rather than once per batch, and the ad platforms read
33
+ * it back out of that identity months later when a conversion is reported.
34
+ *
35
+ * Inert guard: before `forgecart init` writes `.env` (the image-build prewarm)
36
+ * there is no shop to talk to, so every submitted key answers `rejected` with
37
+ * zero upstream calls and the server never crashes.
38
+ *
39
+ * Session capture: the shop API surfaces a freshly minted session in the
40
+ * response `extensions` exactly once. The relay may ESTABLISH the shopper
41
+ * identity — persisting that mint into BOTH homes of the session pair, the
42
+ * httpOnly `forgecart-session` cookie and its JS-readable mirror
43
+ * `forgecart-session-client` (`src/lib/session-cookies.ts`) — when the request
44
+ * arrived cookie-less, but it never REPLACES an existing cookie. The asymmetry
45
+ * is the sibling's, for the sibling's reason: an incoming cookie is either a
46
+ * live session owned by the cart path (clobbering it would vanish a
47
+ * just-created cart) or a stale one, whose replacement is the cart path's job.
48
+ *
49
+ * Writing both homes matters most HERE (#1733). On an ad landing this route
50
+ * runs before anything else — `ForgeTracker` awaits it before enqueuing the
51
+ * first page_view — so it is what establishes the session the click ID has just
52
+ * been attached to. Write the httpOnly half alone and the shopper's own socket
53
+ * boots unauthenticated, its first cart operation mints a RIVAL session, and
54
+ * the click ID stays on an identity the settled order never resolves to: the
55
+ * attribution this route exists to make possible, lost behind a 200. Presence
56
+ * is still read from the httpOnly copy alone — it is the authoritative one, and
57
+ * the mirror is client-writable.
58
+ *
59
+ * Folder name: `%5F%5Ffc` is URL-encoded `__fc` — the App Router treats
60
+ * `_`-prefixed folders as PRIVATE (excluded from routing), and the `%5F` escape
61
+ * is Next's documented way to serve a literal-underscore URL segment. A folder
62
+ * literally named `__fc` would silently 404.
63
+ */
64
+
65
+ /**
66
+ * The largest identifier list this route will look at.
67
+ *
68
+ * A landing page sends at most one entry per click ID it recognises: three
69
+ * today (`lib/marketing-params.ts`), six registered backend-side. Sixteen is
70
+ * headroom for every registration that could plausibly be added without
71
+ * admitting a list no client of ours would ever send — past it the caller is
72
+ * not our page, and the request is refused whole rather than trimmed, because
73
+ * silently keeping a prefix of somebody else's batch is the worse answer.
74
+ */
75
+ const MAX_IDENTIFIERS = 16;
76
+
77
+ interface IdentifyResponseBody {
78
+ /** Identifier keys the backend recognised and stored. */
79
+ accepted: readonly string[];
80
+ /**
81
+ * Keys the backend does not know — a NORMAL outcome, never an error. The
82
+ * upstream partitions the submitted keys against its identifier registry,
83
+ * stores the ones it knows and reports the rest; nothing was rolled back and
84
+ * nothing may be retried. A key landing here means only that this storefront
85
+ * and the backend registry disagree about a spelling, which is a deployment
86
+ * fact worth being able to read.
87
+ */
88
+ rejected: readonly string[];
89
+ }
90
+
91
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
92
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
93
+ }
94
+
95
+ /** Every answer this route gives is the same pair of key lists. */
96
+ function respond(accepted: readonly string[], rejected: readonly string[]): NextResponse {
97
+ const body: IdentifyResponseBody = { accepted, rejected };
98
+ return NextResponse.json(body);
99
+ }
100
+
101
+ /** Parse the request body into a bounded identifier array, or `null` when malformed. */
102
+ async function parseIdentifiers(request: NextRequest): Promise<unknown[] | null> {
103
+ let body: unknown;
104
+ try {
105
+ body = await request.json();
106
+ } catch {
107
+ return null;
108
+ }
109
+ if (!isPlainObject(body) || !Array.isArray(body.identifiers)) return null;
110
+ if (body.identifiers.length === 0 || body.identifiers.length > MAX_IDENTIFIERS) return null;
111
+ return body.identifiers;
112
+ }
113
+
114
+ /**
115
+ * Validate one raw entry into a `MarketingIdentifierInput`, or `null` when it
116
+ * is not a usable pair. The route rebuilds the pair from untrusted JSON rather
117
+ * than trusting anything the client typed, so only these two fields cross.
118
+ *
119
+ * Both strings share the click-ID bound. The key is looked up in a registry
120
+ * whose entries are short words, so its cap is a garbage fence rather than a
121
+ * limit anyone can reach honestly — and a second number for the same purpose
122
+ * would be one more thing to keep in step, for no gain. The value's bound is
123
+ * the one that matters and is deliberately generous: Meta's `fbclid` routinely
124
+ * runs past 128 characters, and a click ID short by one character matches
125
+ * nothing at the platform that issued it.
126
+ */
127
+ function validateIdentifier(raw: unknown): MarketingIdentifierInput | null {
128
+ if (!isPlainObject(raw)) return null;
129
+ const { key, value } = raw;
130
+ if (typeof key !== 'string' || key.length === 0 || key.length > CLICK_ID_VALUE_MAX_LENGTH) {
131
+ return null;
132
+ }
133
+ if (typeof value !== 'string' || value.length === 0 || value.length > CLICK_ID_VALUE_MAX_LENGTH) {
134
+ return null;
135
+ }
136
+ return { key, value };
137
+ }
138
+
139
+ export async function POST(request: NextRequest): Promise<NextResponse> {
140
+ const submitted = await parseIdentifiers(request);
141
+ if (!submitted) {
142
+ return NextResponse.json({ error: 'malformed identifiers' }, { status: 400 });
143
+ }
144
+
145
+ // Validation runs BEFORE the gates here, unlike the event sibling: its reject
146
+ // shape is positional (one result per submitted item) and can be built from
147
+ // the raw batch, while this route answers with KEYS — which exist only once
148
+ // an entry has been proven to carry one.
149
+ const identifiers: MarketingIdentifierInput[] = [];
150
+ for (const entry of submitted) {
151
+ const identifier = validateIdentifier(entry);
152
+ // A malformed entry is DROPPED, never a 400: one bad pair must not cost a
153
+ // shopper the attribution the others carry. It is then absent from both
154
+ // answer lists rather than reported as rejected, so `rejected` keeps its
155
+ // single meaning — "the upstream does not know this key" — and can never be
156
+ // read as "you sent junk".
157
+ if (identifier) identifiers.push(identifier);
158
+ }
159
+ // Nothing survived validation: answer the empty verdict without an upstream
160
+ // call. Forwarding an empty list would mint a session for a request that has
161
+ // nothing to store against it.
162
+ if (identifiers.length === 0) return respond([], []);
163
+
164
+ const rejectAll = (): NextResponse => respond([], identifiers.map((identifier) => identifier.key));
165
+
166
+ // Inert before `forgecart init` writes `.env` (image-prewarm contract).
167
+ if (!getUpstreamConfig()) return rejectAll();
168
+
169
+ const userAgent = request.headers.get('user-agent') ?? '';
170
+ if (isObviousBot(userAgent)) return rejectAll();
171
+
172
+ const incomingSession = request.cookies.get(SESSION_COOKIE)?.value ?? null;
173
+ const forwardedFor = request.headers.get('x-forwarded-for');
174
+ // #1014: mirror the browser's low-entropy Client Hints trio upstream —
175
+ // Chromium sends them on every request; the shop API's device identification
176
+ // prefers them over the frozen UA.
177
+ const secChUa = request.headers.get('sec-ch-ua') ?? undefined;
178
+ const secChUaMobile = request.headers.get('sec-ch-ua-mobile') ?? undefined;
179
+ const secChUaPlatform = request.headers.get('sec-ch-ua-platform') ?? undefined;
180
+
181
+ // One call for the whole list: the mutation takes an array and the backend
182
+ // writes it against a single identity, so there is no per-item sequencing to
183
+ // get wrong here (the event relay loops only because `trackEvent` takes one
184
+ // event at a time).
185
+ const outcome = await forwardMarketingIdentifiers(identifiers, {
186
+ sessionToken: incomingSession,
187
+ userAgent,
188
+ forwardedFor,
189
+ secChUa,
190
+ secChUaMobile,
191
+ secChUaPlatform,
192
+ });
193
+
194
+ const response = respond(outcome.accepted, outcome.rejected);
195
+ // Establish-only persist: write the session ONLY when the request arrived
196
+ // without one, and then into BOTH of its homes — the httpOnly original the
197
+ // server reads and the mirror the shopper's own socket boots from. A mint
198
+ // against an EXISTING cookie is never persisted here — replacing a live
199
+ // session would vanish the cart it holds, and replacing a stale one belongs
200
+ // to the cart path (see the module docstring).
201
+ if (!incomingSession && outcome.sessionToken) {
202
+ establishSessionCookies(response, outcome.sessionToken);
203
+ }
204
+ return response;
205
+ }
@@ -0,0 +1,189 @@
1
+ import { NextResponse } from 'next/server';
2
+ import type { NextRequest } from 'next/server';
3
+
4
+ import { SESSION_COOKIE, establishSessionCookies } from '../../../lib/session-cookies';
5
+ import {
6
+ forwardTrackEvent,
7
+ getUpstreamConfig,
8
+ isObviousBot,
9
+ type TrackEventInput,
10
+ } from '../../../lib/track-forward';
11
+
12
+ /**
13
+ * Same-origin marketing-event relay (design doc S7).
14
+ *
15
+ * `ForgeTracker` batches client events (page_view, cta_click, heartbeat, …)
16
+ * and POSTs them here; this handler forwards each item to the ForgeCart shop
17
+ * API as a `trackEvent` GraphQL mutation. Unlike `__forge_beacon` this route
18
+ * is a PRODUCTION feature — tracking runs on deployed storefronts — with one
19
+ * inert guard: before `forgecart init` writes `.env` (the image-build
20
+ * prewarm) there is no shop to talk to, so every item answers `accepted:false`
21
+ * without any upstream call and the server never crashes.
22
+ *
23
+ * The upstream hop itself (raw HTTP GraphQL POST with the channel token, the
24
+ * shopper session as Bearer, UA + `x-forwarded-for` enrichment, and the
25
+ * why-not-the-SDK rationale) lives in `src/lib/track-forward.ts` — shared with
26
+ * the F-B `experiment_exposure` emission so both server-side emitters speak
27
+ * one contract. This route owns everything batch-shaped: parsing, per-item
28
+ * validation, the bot gate, session threading, and cookie persistence.
29
+ *
30
+ * Session capture: the shop API surfaces a freshly minted session in the
31
+ * response `extensions` (`forgecart-auth-token`) exactly once; the first
32
+ * touched item captures it and the remaining items of the batch ride it as
33
+ * Bearer (one identity per batch). The relay may ESTABLISH the shopper
34
+ * identity — persisting that mint into BOTH homes of the session pair, the
35
+ * httpOnly `forgecart-session` cookie and its JS-readable mirror
36
+ * `forgecart-session-client`, through the contract `src/lib/session-cookies.ts`
37
+ * owns and `src/lib/session-actions.ts#syncShopSession` writes too — but it
38
+ * never REPLACES an existing cookie. The asymmetry is deliberate: an incoming
39
+ * cookie that the upstream re-mints against is either a live session owned by
40
+ * the cart path (clobbering it would vanish a just-created cart — the
41
+ * first-touch double-mint race) or a stale one, whose replacement is the cart
42
+ * path's job (the SDK adopts re-mints and `syncShopSession` persists them; the
43
+ * relay replacing it here would race those writers).
44
+ *
45
+ * Both homes or neither (#1733): the browser's shop socket boots its auth from
46
+ * the MIRROR (`lib/shop-session.ts`), so a session established into the httpOnly
47
+ * half alone never reaches the client at all — the first cart operation mints a
48
+ * RIVAL session, `syncShopSession` overwrites the pair with it, and the events
49
+ * this batch just recorded stay on a session no attribution read resolves to.
50
+ * Presence, in contrast, is read from the httpOnly copy ALONE: it is the
51
+ * authoritative one, the mirror is derived from it, and a client-writable cookie
52
+ * must not be able to decide whether this relay establishes at all.
53
+ *
54
+ * Residual window: two writers that BOTH start cookie-less inside the same
55
+ * in-flight window remain last-writer-wins. With the tracker's deferred first
56
+ * flush (`ForgeTracker`'s establishment grace) that requires a click-to-
57
+ * response race under ~100ms — accepted.
58
+ *
59
+ * Contract: per-item isolation (one upstream failure never kills the batch),
60
+ * `accepted:false` is TERMINAL for that item (the tracker never retries), and
61
+ * obviously non-human user agents short-circuit the whole batch with zero
62
+ * upstream calls.
63
+ *
64
+ * Folder name: `%5F%5Ffc` is URL-encoded `__fc` — the App Router treats
65
+ * `_`-prefixed folders as PRIVATE (excluded from routing), and the `%5F`
66
+ * escape is Next's documented way to serve a literal-underscore URL segment.
67
+ * A folder literally named `__fc` would silently 404.
68
+ */
69
+
70
+ /** The tracker flushes at ≤10 events; anything past this is not our client. */
71
+ const MAX_BATCH_SIZE = 50;
72
+
73
+ /** Canonical textual UUID — the only client `eventId` shape the API accepts. */
74
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
75
+
76
+ interface TrackItemResult {
77
+ accepted: boolean;
78
+ eventId: string | null;
79
+ }
80
+
81
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
82
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
83
+ }
84
+
85
+ /** Parse the request body into a bounded batch array, or `null` when malformed. */
86
+ async function parseBatch(request: NextRequest): Promise<unknown[] | null> {
87
+ let body: unknown;
88
+ try {
89
+ body = await request.json();
90
+ } catch {
91
+ return null;
92
+ }
93
+ if (!isPlainObject(body) || !Array.isArray(body.events)) return null;
94
+ if (body.events.length === 0 || body.events.length > MAX_BATCH_SIZE) return null;
95
+ return body.events;
96
+ }
97
+
98
+ /** Copy an optional string field from the raw item, rejecting non-string junk. */
99
+ function readOptionalString(value: unknown): string | undefined {
100
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
101
+ }
102
+
103
+ /**
104
+ * Validate one raw batch item into a `TrackEventInput`, or `null` when the
105
+ * item is malformed (missing/empty `eventType`, non-UUID `eventId`, non-object
106
+ * `properties`). Only allowlisted fields cross to the upstream call.
107
+ */
108
+ function validateItem(raw: unknown): TrackEventInput | null {
109
+ if (!isPlainObject(raw)) return null;
110
+ const { eventType, eventId, properties } = raw;
111
+ if (typeof eventType !== 'string' || eventType.length === 0) return null;
112
+ if (eventId !== undefined && (typeof eventId !== 'string' || !UUID_PATTERN.test(eventId))) {
113
+ return null;
114
+ }
115
+ if (properties !== undefined && !isPlainObject(properties)) return null;
116
+
117
+ const input: TrackEventInput = { eventType };
118
+ if (typeof eventId === 'string') input.eventId = eventId;
119
+ const occurredAt = readOptionalString(raw.occurredAt);
120
+ if (occurredAt) input.occurredAt = occurredAt;
121
+ if (isPlainObject(properties)) input.properties = properties;
122
+ const utmSource = readOptionalString(raw.utmSource);
123
+ if (utmSource) input.utmSource = utmSource;
124
+ const utmMedium = readOptionalString(raw.utmMedium);
125
+ if (utmMedium) input.utmMedium = utmMedium;
126
+ const utmCampaign = readOptionalString(raw.utmCampaign);
127
+ if (utmCampaign) input.utmCampaign = utmCampaign;
128
+ const utmTerm = readOptionalString(raw.utmTerm);
129
+ if (utmTerm) input.utmTerm = utmTerm;
130
+ const utmContent = readOptionalString(raw.utmContent);
131
+ if (utmContent) input.utmContent = utmContent;
132
+ const currencyCode = readOptionalString(raw.currencyCode);
133
+ if (currencyCode) input.currencyCode = currencyCode;
134
+ return input;
135
+ }
136
+
137
+ export async function POST(request: NextRequest): Promise<NextResponse> {
138
+ const batch = await parseBatch(request);
139
+ if (!batch) {
140
+ return NextResponse.json({ error: 'malformed batch' }, { status: 400 });
141
+ }
142
+
143
+ const rejectAll = (): NextResponse =>
144
+ NextResponse.json({
145
+ results: batch.map((): TrackItemResult => ({ accepted: false, eventId: null })),
146
+ });
147
+
148
+ // Inert before `forgecart init` writes `.env` (image-prewarm contract).
149
+ if (!getUpstreamConfig()) return rejectAll();
150
+
151
+ const userAgent = request.headers.get('user-agent') ?? '';
152
+ if (isObviousBot(userAgent)) return rejectAll();
153
+
154
+ const incomingSession = request.cookies.get(SESSION_COOKIE)?.value ?? null;
155
+ const forwardedFor = request.headers.get('x-forwarded-for');
156
+ // #1014: mirror the browser's low-entropy Client Hints trio upstream —
157
+ // Chromium sends them on every request; the shop API's device
158
+ // identification prefers them over the frozen UA.
159
+ const secChUa = request.headers.get('sec-ch-ua') ?? undefined;
160
+ const secChUaMobile = request.headers.get('sec-ch-ua-mobile') ?? undefined;
161
+ const secChUaPlatform = request.headers.get('sec-ch-ua-platform') ?? undefined;
162
+
163
+ let sessionToken = incomingSession;
164
+ const results: TrackItemResult[] = [];
165
+ // Sequential on purpose: the first touched item mints the session and every
166
+ // later item of the batch must ride it — parallel sends would mint one
167
+ // identity per item and shatter attribution.
168
+ for (const raw of batch) {
169
+ const input = validateItem(raw);
170
+ if (!input) {
171
+ results.push({ accepted: false, eventId: null });
172
+ continue;
173
+ }
174
+ const outcome = await forwardTrackEvent(input, { sessionToken, userAgent, forwardedFor, secChUa, secChUaMobile, secChUaPlatform });
175
+ results.push({ accepted: outcome.accepted, eventId: outcome.eventId });
176
+ if (outcome.sessionToken) sessionToken = outcome.sessionToken;
177
+ }
178
+
179
+ const response = NextResponse.json({ results });
180
+ // Establish-only persist: write the session ONLY when the request arrived
181
+ // without one — and then into BOTH of its homes, which is all
182
+ // `establishSessionCookies` does. A mint against an EXISTING cookie is never
183
+ // persisted here — replacing a live session would vanish the cart it holds
184
+ // (first-touch double-mint race), and replacing a stale one belongs to the
185
+ // cart path, whose SDK capture + persist owns re-mints (see the module
186
+ // docstring).
187
+ if (!incomingSession && sessionToken) establishSessionCookies(response, sessionToken);
188
+ return response;
189
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * DEV-ONLY same-origin error-beacon forwarder.
3
+ *
4
+ * The storefront's client beacon (`ForgeErrorBeacon`) POSTs a browser-surfaced
5
+ * runtime error here — same-origin, so the browser never learns the pod-internal
6
+ * receiver address. This handler forwards the body SERVER-side to the in-pod
7
+ * workspace-manager receiver at `http://127.0.0.1:<FORGE_BEACON_PORT>/__forge_beacon`,
8
+ * which folds it into the supervisor's runtime state so the shop's recovery brain
9
+ * can originate a fix for a crash the dev-server's stderr scan cannot see.
10
+ *
11
+ * Gated entirely on `NODE_ENV === 'development'`: a deployed `next start` storefront
12
+ * answers 404 here and forwards nothing — the whole beacon path ships only in the
13
+ * in-pod dev-server. Request access keeps it out of any static prerender so it
14
+ * behaves identically under `next dev` and a dev build.
15
+ *
16
+ * Folder name: `%5F%5Fforge_beacon` is URL-encoded `__forge_beacon`. The App Router
17
+ * treats a folder whose name starts with `_` as PRIVATE and leaves it out of routing
18
+ * entirely, so the literal spelling this route shipped with answered the not-found
19
+ * page to every POST — silently, because the client swallows delivery failures by
20
+ * design (#2103). The `%5F` escape is Next's documented way to serve a URL segment
21
+ * that really starts with an underscore, and only the FOLDER may change: the URL is
22
+ * a contract with the in-pod receiver (`StorefrontBeaconService`'s `BEACON_PATH`),
23
+ * which this template's server half (`src/instrumentation.ts`) posts to as well.
24
+ */
25
+
26
+ const BEACON_PORT = process.env.FORGE_BEACON_PORT ?? '3002';
27
+
28
+ /**
29
+ * The revision the reporting page was SERVED at, lifted out of the forwarded
30
+ * body for the log line below, or `null` when the report is untagged.
31
+ *
32
+ * Reads the body it is ALREADY forwarding verbatim rather than re-deriving
33
+ * anything: the value was stamped into the document at render time, and this
34
+ * handler is a forwarder, not a second opinion. The parse is guarded because a
35
+ * malformed body must not change what this route does — the forward still
36
+ * happens and the client still gets its 204, exactly as before. `JSON.parse` is
37
+ * the one throw on this path and it carries no code to classify, which is why it
38
+ * is caught here and nowhere else (`instrumentation.ts` swallows its own boot
39
+ * failure in the same template for the same reason); judging the payload stays
40
+ * the receiver's job.
41
+ */
42
+ function servedRevision(body: string): string | null {
43
+ try {
44
+ const parsed: unknown = JSON.parse(body);
45
+ if (typeof parsed !== 'object' || parsed === null) return null;
46
+ const value = (parsed as { atRevision?: unknown }).atRevision;
47
+ if (typeof value !== 'string' || value.length === 0) return null;
48
+ return value;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ export async function POST(request: Request): Promise<Response> {
55
+ if (process.env.NODE_ENV !== 'development') {
56
+ return new Response(null, { status: 404 });
57
+ }
58
+
59
+ const body = await request.text();
60
+ // Make the dev-server's output ring revision-attributable (#847): this line is
61
+ // written to the ring the supervisor retains and `TailServerLogs` serves, so a
62
+ // human or the recovery brain reading the ring can tell WHICH tree a
63
+ // browser-surfaced crash belongs to instead of guessing from timing.
64
+ //
65
+ // It deliberately carries the revision and NOTHING ELSE — no message, no stack,
66
+ // no route. The ambient scanner matches dev-server output case-insensitively
67
+ // against RUNTIME_ERROR_PATTERNS ('error:', 'typeerror', 'unhandled', …;
68
+ // `runtime-error-scanner.types.ts`), so echoing the reported message here would
69
+ // put the crash text INTO the stream that scanner reads and fold a second,
70
+ // independent ambient error for the very report already on its way to the
71
+ // receiver — a self-inflicted double-fire. An untagged report stays silent
72
+ // rather than logging a placeholder, which keeps today's output byte-identical
73
+ // for every producer that does not stamp (the server `onRequestError` path
74
+ // never reaches this route at all — it POSTs the receiver directly).
75
+ const revision = servedRevision(body);
76
+ if (revision !== null) console.log(`[forge-beacon browser rev=${revision}]`);
77
+ // Forward server-side to the loopback receiver. A delivery failure is swallowed —
78
+ // the beacon is a best-effort backstop, never a hard dependency of the page — but
79
+ // the client still gets a clean 204 so it never retries against a flapping pod.
80
+ await fetch(`http://127.0.0.1:${BEACON_PORT}/__forge_beacon`, {
81
+ method: 'POST',
82
+ headers: { 'content-type': 'application/json' },
83
+ body,
84
+ }).catch(() => undefined);
85
+
86
+ return new Response(null, { status: 204 });
87
+ }