@web-my-money/studio-consumer 1.0.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 ADDED
@@ -0,0 +1,19 @@
1
+ # @web-my-money/studio-consumer
2
+
3
+ Consumer-side integration for WMM Studio: content, analytics, attribution.
4
+
5
+ This is a source-only workspace package — no build step, no `dist`. Consumers
6
+ are Next.js apps that already transpile workspace and scoped packages, so
7
+ publishing compiled output here would only add a compile-and-publish cycle
8
+ for no gain.
9
+
10
+ ## Entry points
11
+
12
+ - `@web-my-money/studio-consumer/analytics`
13
+ - `@web-my-money/studio-consumer/content`
14
+ - `@web-my-money/studio-consumer/attribution`
15
+
16
+ This package is currently a skeleton: each entry point exports only a
17
+ `PACKAGE_VERSION` constant, used to prove at runtime that a consumer is
18
+ resolving this package rather than a stale copy. Content, analytics, and
19
+ attribution logic land in later tasks.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@web-my-money/studio-consumer",
3
+ "version": "1.0.0",
4
+ "description": "Consumer-side integration for WMM Studio: content, analytics, attribution.",
5
+ "license": "UNLICENSED",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Web-My-Money/wmm-studio.git",
9
+ "directory": "packages/studio-consumer"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "exports": {
17
+ "./analytics": "./src/analytics/index.ts",
18
+ "./content": "./src/content/index.ts",
19
+ "./attribution": "./src/attribution/index.ts",
20
+ "./headers": "./src/content/headers.mjs"
21
+ },
22
+ "peerDependencies": {
23
+ "next": ">=16",
24
+ "react": ">=19"
25
+ },
26
+ "files": ["src"]
27
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * A/B assignment, as pure arithmetic (wmm-studio docs/analytics/06-ab-testing.md §2).
3
+ *
4
+ * Split out of `lib/experiments.ts` — which keeps its `server-only` marker and
5
+ * re-exports all of this — because the analytics collector runs in the browser
6
+ * and has to stamp the visitor's arm on every event. Without that stamp
7
+ * `funnel_events.variant` is empty and the comparison in Studio has nothing to
8
+ * compare, which is exactly the state this file was written to fix.
9
+ *
10
+ * ## The client may RESOLVE an arm. It must never RENDER one.
11
+ *
12
+ * Reading the arm to label an event is safe: the label describes a decision the
13
+ * server already made. Choosing copy from it is not — a client-side choice
14
+ * flashes the wrong variant, and the flash itself changes behaviour, which is
15
+ * the measurement artefact this whole design exists to avoid. Rendering stays
16
+ * behind `lib/content.ts`, which is server-only and stays that way.
17
+ *
18
+ * Four rules, each because the alternative quietly corrupts the result:
19
+ *
20
+ * - **Sticky per visitor.** A visitor who sees both variants poisons both
21
+ * numbers, so the bucket is a pure function of their id and the experiment
22
+ * key — no randomness at request time, and nothing to store.
23
+ * - **Same answer on both sides.** Server and client run this same function
24
+ * over the same cookie and the same published experiment list, so the arm
25
+ * that was rendered and the arm that gets recorded cannot disagree.
26
+ * - **Default is control.** No experiment, unknown path, missing cookie, bad
27
+ * config — all serve control. A variant is never served by accident.
28
+ * - **One experiment per path**, enforced by a unique index in Studio's schema.
29
+ * Overlapping experiments make both results meaningless.
30
+ */
31
+
32
+ export type RunningExperiment = {
33
+ key: string;
34
+ targetPath: string;
35
+ /** Percent of visitors sent to variant B, 1-99. */
36
+ split: number;
37
+ };
38
+
39
+ export const CONTROL = "control";
40
+ export const VARIANT = "b";
41
+ /** The cookie the proxy sets and the collector reads. */
42
+ export const VISITOR_COOKIE = "wmm_vid";
43
+
44
+ /**
45
+ * FNV-1a, 32-bit. A hash, not a PRNG: the same input must bucket the same way on
46
+ * every request, on every server, forever — including after a deploy. Chosen for
47
+ * being short enough to read and verify by hand; the distribution only has to be
48
+ * even, not cryptographic, and nothing here is a secret.
49
+ */
50
+ function hash(input: string): number {
51
+ let h = 0x811c9dc5;
52
+ for (let i = 0; i < input.length; i++) {
53
+ h ^= input.charCodeAt(i);
54
+ // `Math.imul` keeps this a 32-bit multiply; `h * 16777619` loses precision
55
+ // past 2^53 and stops being the same function across inputs.
56
+ h = Math.imul(h, 0x01000193);
57
+ }
58
+ // `>>> 0` because the XOR above makes h signed.
59
+ return h >>> 0;
60
+ }
61
+
62
+ /** A stable 0-99 bucket for a visitor within one experiment. */
63
+ export function bucketFor(visitorId: string, experimentKey: string): number {
64
+ return hash(`${experimentKey}:${visitorId}`) % 100;
65
+ }
66
+
67
+ /**
68
+ * The variant to render for this visitor on this path.
69
+ *
70
+ * `experiments` comes from the published content payload, so this costs no
71
+ * database call. Returns `control` for anything unclear.
72
+ */
73
+ export function resolveVariant(
74
+ path: string,
75
+ visitorId: string | null | undefined,
76
+ experiments: RunningExperiment[] | null | undefined,
77
+ ): { variant: string; experimentKey: string | null } {
78
+ if (!visitorId || !experiments?.length) {
79
+ return { variant: CONTROL, experimentKey: null };
80
+ }
81
+
82
+ const experiment = experiments.find((e) => e.targetPath === path);
83
+ if (!experiment) return { variant: CONTROL, experimentKey: null };
84
+
85
+ // Guard the config too: a split outside 1-99 is not an experiment, and
86
+ // trusting it would send everyone one way while still labelling the data as a
87
+ // test.
88
+ if (
89
+ !Number.isFinite(experiment.split) ||
90
+ experiment.split < 1 ||
91
+ experiment.split > 99
92
+ ) {
93
+ return { variant: CONTROL, experimentKey: null };
94
+ }
95
+
96
+ const bucket = bucketFor(visitorId, experiment.key);
97
+ return {
98
+ variant: bucket < experiment.split ? VARIANT : CONTROL,
99
+ experimentKey: experiment.key,
100
+ };
101
+ }
@@ -0,0 +1,126 @@
1
+ import { NextResponse } from "next/server";
2
+
3
+ /**
4
+ * First-party analytics collector (wmm-studio docs/analytics/01-pipeline.md §5.1).
5
+ *
6
+ * The browser posts same-origin here; this route stamps the site key and
7
+ * forwards the batch server-side to Studio's ingest endpoint with the bearer
8
+ * token. Two reasons it exists rather than the browser calling Studio directly:
9
+ * the token stays server-side, and a same-origin request is not what ad-blockers
10
+ * and third-party cookie policy are looking for.
11
+ *
12
+ * It ALWAYS returns 204, whatever happens. An analytics outage must never
13
+ * surface an error on a marketing page or interfere with a form — so failures
14
+ * are logged here and are invisible to the visitor.
15
+ *
16
+ * Inert with no `FUNNEL_INGEST_URL`: nothing is forwarded and the site behaves
17
+ * exactly as it did before this route existed. That is the correct state before
18
+ * rollout, which is what lets this merge ahead of being switched on.
19
+ */
20
+
21
+ /** Mirrors the ingest endpoint's cap. Refused here so we don't forward a 413. */
22
+ const MAX_BATCH = 50;
23
+
24
+ const noContent = () => new NextResponse(null, { status: 204 });
25
+
26
+ /**
27
+ * Where events are forwarded.
28
+ *
29
+ * Defaulted rather than required, so onboarding a new consumer site is ONE
30
+ * secret instead of two settings — and so a typo in a per-project env var cannot
31
+ * silently send a client's events nowhere. Override it only for a non-production
32
+ * Studio.
33
+ */
34
+ const DEFAULT_INGEST_URL = "https://studio.webmymoney.com/api/ingest/funnel-events";
35
+
36
+ /** The shape every per-site ingest key has. See wmm-studio `lib/analytics/ingest-auth.ts`. */
37
+ const KEY_PREFIX = "wmmk_";
38
+
39
+ /**
40
+ * Warn once, not per request.
41
+ *
42
+ * A key without the `wmmk_` prefix is the legacy GLOBAL token: Studio still
43
+ * accepts it, but it derives nothing from it, so events are filed under whatever
44
+ * `site_key` the payload claims — and the gate that deletes that path is next.
45
+ * The failure mode this catches is the whole point: everything stays green (204s,
46
+ * rows arriving, checklist passing) right up until the legacy path is removed and
47
+ * the site's analytics go dark. This is the collector's hot path, so the flag is
48
+ * module-level and the line is printed once per process, not once per batch.
49
+ */
50
+ let warnedAboutKeyShape = false;
51
+
52
+ function warnOnceAboutKeyShape(key: string): void {
53
+ if (warnedAboutKeyShape || key.startsWith(KEY_PREFIX)) return;
54
+ warnedAboutKeyShape = true;
55
+ console.warn(
56
+ `[analytics] FUNNEL_INGEST_TOKEN does not look like a per-site ingest key ` +
57
+ `(expected a "${KEY_PREFIX}" prefix). This is the legacy global token: Studio ` +
58
+ `will file these events under the site key in the payload rather than deriving ` +
59
+ `it from the key, and that path is being removed. Mint this site's own key at ` +
60
+ `Studio -> /sites/new.`,
61
+ );
62
+ }
63
+
64
+ /** Test-only: forget that the one-time warning has already been printed. */
65
+ export function resetIngestKeyShapeWarning(): void {
66
+ warnedAboutKeyShape = false;
67
+ }
68
+
69
+ export function createCollectHandler(config: {
70
+ siteKey: string;
71
+ ingestUrl: string;
72
+ ingestKey: string;
73
+ }): (request: Request) => Promise<Response> {
74
+ return async function POST(request: Request): Promise<Response> {
75
+ const url = config.ingestUrl || DEFAULT_INGEST_URL;
76
+ const token = config.ingestKey;
77
+ // The token is the only thing that genuinely has to be configured per site.
78
+ if (!token) return noContent();
79
+ warnOnceAboutKeyShape(token);
80
+
81
+ let events: unknown[];
82
+ try {
83
+ const body = (await request.json()) as { events?: unknown };
84
+ if (!Array.isArray(body.events)) return noContent();
85
+ events = body.events;
86
+ } catch {
87
+ return noContent();
88
+ }
89
+
90
+ if (events.length === 0) return noContent();
91
+ if (events.length > MAX_BATCH) {
92
+ console.warn(`[analytics] refusing oversized batch: ${events.length}`);
93
+ return noContent();
94
+ }
95
+
96
+ // The site key is stamped here, never trusted from the client: a browser must
97
+ // not be able to write events into another site's namespace by editing a
98
+ // 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
+ }));
103
+
104
+ try {
105
+ const res = await fetch(url, {
106
+ method: "POST",
107
+ headers: {
108
+ "Content-Type": "application/json",
109
+ Authorization: `Bearer ${token}`,
110
+ },
111
+ body: JSON.stringify({ events: stamped }),
112
+ });
113
+ if (!res.ok) {
114
+ // One line, no payload echo: a rejected batch is a code or config problem
115
+ // and the status is what identifies which.
116
+ console.warn(`[analytics] ingest rejected batch: ${res.status}`);
117
+ }
118
+ } catch (err) {
119
+ console.warn(
120
+ `[analytics] ingest unreachable: ${err instanceof Error ? err.message : "unknown"}`,
121
+ );
122
+ }
123
+
124
+ return noContent();
125
+ };
126
+ }