@web-my-money/studio-consumer 2.1.0 → 2.2.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.
package/README.md CHANGED
@@ -68,3 +68,21 @@ import { focalObjectPosition } from "@web-my-money/studio-consumer/image";
68
68
  <img src={value.url} alt={value.alt.en} style={{ objectFit: "cover", objectPosition: focalObjectPosition(value) }} />
69
69
  ```
70
70
 
71
+ ## Analytics collector (2.2.0)
72
+
73
+ `createCollectHandler` only accepts posts from the site's own pages (`Sec-Fetch-Site`,
74
+ falling back to `Origin`), and it decides each event's A/B arm on the server from the
75
+ `wmm_vid` cookie. The browser's own `variant` is always discarded. Pass the content
76
+ client's `getRunningExperiments` so the arm can be resolved:
77
+
78
+ ```ts
79
+ export const POST = createCollectHandler({
80
+ siteKey: SITE_KEY,
81
+ ingestUrl: process.env.FUNNEL_INGEST_URL ?? "",
82
+ ingestKey: process.env.FUNNEL_INGEST_TOKEN ?? "",
83
+ getRunningExperiments: () => content.getRunningExperiments(),
84
+ });
85
+ ```
86
+
87
+ Without it, events carry no arm and the site stays out of any A/B comparison.
88
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-my-money/studio-consumer",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Consumer-side integration for WMM Studio: content, analytics, attribution.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -1,4 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { resolveVariant, VISITOR_COOKIE, type RunningExperiment } from "./bucketing";
2
3
 
3
4
  /**
4
5
  * First-party analytics collector (wmm-studio docs/analytics/01-pipeline.md §5.1).
@@ -13,6 +14,9 @@ import { NextResponse } from "next/server";
13
14
  * surface an error on a marketing page or interfere with a form — so failures
14
15
  * are logged here and are invisible to the visitor.
15
16
  *
17
+ * Same-origin only, and the A/B arm is resolved HERE from the visitor cookie,
18
+ * never taken from the payload (parity with WMM Website's own route, 2026-10-09).
19
+ *
16
20
  * Inert with no `FUNNEL_INGEST_URL`: nothing is forwarded and the site behaves
17
21
  * exactly as it did before this route existed. That is the correct state before
18
22
  * rollout, which is what lets this merge ahead of being switched on.
@@ -61,6 +65,34 @@ function warnOnceAboutKeyShape(key: string): void {
61
65
  );
62
66
  }
63
67
 
68
+ /**
69
+ * Only the site's own pages post here. Browsers send `Sec-Fetch-Site` (or at least
70
+ * an `Origin`) on a same-origin beacon; a bare script or a third-party page does
71
+ * not. Headers can be forged by hand, so this is a speed bump, not a lock; what
72
+ * actually protects the A/B numbers is the server-side arm below.
73
+ */
74
+ function isSameOrigin(req: Request): boolean {
75
+ const fetchSite = req.headers.get("sec-fetch-site");
76
+ if (fetchSite) return fetchSite === "same-origin";
77
+ const origin = req.headers.get("origin");
78
+ if (!origin) return false;
79
+ try {
80
+ return new URL(origin).host === new URL(req.url).host;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function readCookie(req: Request, name: string): string | null {
87
+ const header = req.headers.get("cookie");
88
+ if (!header) return null;
89
+ for (const part of header.split(";")) {
90
+ const [k, ...v] = part.trim().split("=");
91
+ if (k === name) return decodeURIComponent(v.join("="));
92
+ }
93
+ return null;
94
+ }
95
+
64
96
  /** Test-only: forget that the one-time warning has already been printed. */
65
97
  export function resetIngestKeyShapeWarning(): void {
66
98
  warnedAboutKeyShape = false;
@@ -70,6 +102,13 @@ export function createCollectHandler(config: {
70
102
  siteKey: string;
71
103
  ingestUrl: string;
72
104
  ingestKey: string;
105
+ /**
106
+ * The site's running experiments (pass the content client's
107
+ * `getRunningExperiments`). With it, each event's A/B arm is resolved here
108
+ * from the `wmm_vid` cookie; without it, events carry no arm. The browser's
109
+ * own `variant` is discarded either way.
110
+ */
111
+ getRunningExperiments?: () => Promise<RunningExperiment[]>;
73
112
  }): (request: Request) => Promise<Response> {
74
113
  return async function POST(request: Request): Promise<Response> {
75
114
  const url = config.ingestUrl || DEFAULT_INGEST_URL;
@@ -77,6 +116,7 @@ export function createCollectHandler(config: {
77
116
  // The token is the only thing that genuinely has to be configured per site.
78
117
  if (!token) return noContent();
79
118
  warnOnceAboutKeyShape(token);
119
+ if (!isSameOrigin(request)) return noContent();
80
120
 
81
121
  let events: unknown[];
82
122
  try {
@@ -93,13 +133,39 @@ export function createCollectHandler(config: {
93
133
  return noContent();
94
134
  }
95
135
 
136
+ // The A/B arm is decided here, from the visitor cookie and the experiment list
137
+ // the page render used — never taken from the payload. A posted `variant` could
138
+ // otherwise put any visitor into any arm and move an experiment's result. No
139
+ // cookie means the arm is unknowable, so none is sent.
140
+ const visitorId = readCookie(request, VISITOR_COOKIE);
141
+ const experiments: RunningExperiment[] =
142
+ visitorId && config.getRunningExperiments
143
+ ? await config.getRunningExperiments().catch(() => [])
144
+ : [];
145
+
96
146
  // The site key is stamped here, never trusted from the client: a browser must
97
147
  // not be able to write events into another site's namespace by editing a
98
148
  // payload. Anything the client sent under that key is overwritten.
99
- const stamped = events.map((e) => ({
100
- ...(e && typeof e === "object" ? e : {}),
101
- siteKey: config.siteKey,
102
- }));
149
+ const stamped = events
150
+ .filter(
151
+ (e): e is Record<string, unknown> =>
152
+ !!e &&
153
+ typeof e === "object" &&
154
+ typeof (e as { path?: unknown }).path === "string" &&
155
+ (e as { path: string }).path.startsWith("/"),
156
+ )
157
+ .map((e) => {
158
+ const { variant: _clientVariant, ...rest } = e;
159
+ const path = (rest.path as string).split("?")[0];
160
+ const arm = visitorId ? resolveVariant(path, visitorId, experiments) : null;
161
+ return {
162
+ ...rest,
163
+ ...(visitorId ? { visitorId } : {}),
164
+ ...(arm?.experimentKey ? { variant: arm.variant } : {}),
165
+ siteKey: config.siteKey,
166
+ };
167
+ });
168
+ if (stamped.length === 0) return noContent();
103
169
 
104
170
  try {
105
171
  const res = await fetch(url, {
@@ -1,5 +1,5 @@
1
1
  /** Proves at runtime which build a consumer is actually running. */
2
- export const PACKAGE_VERSION = "2.1.0";
2
+ export const PACKAGE_VERSION = "2.2.0";
3
3
 
4
4
  export {
5
5
  resolveVariant,
@@ -1,5 +1,5 @@
1
1
  /** Proves at runtime which build a consumer is actually running. */
2
- export const PACKAGE_VERSION = "2.1.0";
2
+ export const PACKAGE_VERSION = "2.2.0";
3
3
 
4
4
  export {
5
5
  getAttribution,
@@ -16,7 +16,7 @@
16
16
  * Pure on purpose: no `server-only`, no Next imports, safe in any component.
17
17
  */
18
18
 
19
- export const PACKAGE_VERSION = "2.1.0";
19
+ export const PACKAGE_VERSION = "2.2.0";
20
20
 
21
21
  /** Theme ids owned by WMM in the tokens package (themes.json `owner: "wmm"`). */
22
22
  export const WMM_THEMES = ["wmm", "site", "light", "dark"] as const;
@@ -1,5 +1,5 @@
1
1
  /** Proves at runtime which build a consumer is actually running. */
2
- export const PACKAGE_VERSION = "2.1.0";
2
+ export const PACKAGE_VERSION = "2.2.0";
3
3
 
4
4
  export {
5
5
  createContentClient,
@@ -31,6 +31,14 @@ export function createManifestHandler(
31
31
  */
32
32
  brand: SiteBrand,
33
33
  ): () => Promise<Response> {
34
+ // TypeScript requires the brand; a JS caller or a cast can still skip it. Fail
35
+ // where the mistake is (the site's build), not at Studio's sync. Frozen is the
36
+ // mark of defineSiteBrand, which validated it.
37
+ if (!brand || !Object.isFrozen(brand)) {
38
+ throw new Error(
39
+ "createManifestHandler: pass the site's brand from defineSiteBrand(...) as the second argument.",
40
+ );
41
+ }
34
42
  return async function GET() {
35
43
  const manifest = await getManifest();
36
44
  return NextResponse.json(
@@ -1,5 +1,5 @@
1
1
  /** Pure; safe in client components. */
2
- export const PACKAGE_VERSION = "2.1.0";
2
+ export const PACKAGE_VERSION = "2.2.0";
3
3
 
4
4
  /**
5
5
  * CSS `object-position` for a Studio image value's focal point (spec §6.2).