@getvitops/utils 2.1.0 → 4.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.
@@ -0,0 +1,96 @@
1
+ import { createSign } from "node:crypto";
2
+
3
+ //#region src/google/token.ts
4
+ /**
5
+ * Google OAuth2 token exchange — one request, two grants.
6
+ *
7
+ * Both of this package's Google modules need a bearer token, and both used to
8
+ * mint one themselves. The two copies looked different because the *credentials*
9
+ * differ, but everything after building the form body was identical: same
10
+ * endpoint, same content-type, same "surface the response body as the error"
11
+ * rule, same `access_token` extraction. So the seam is precise —
12
+ * **credential → form params differs, params → token does not.**
13
+ *
14
+ * The two grants are kept because they are not accidental duplication; they track
15
+ * how each command is actually run:
16
+ *
17
+ * - **service account** (RS256 JWT bearer) for `search notify`, which runs on
18
+ * every deploy in CI. It never expires. A refresh token can be revoked, and
19
+ * for an OAuth client still in *Testing* publishing status Google expires it
20
+ * after 7 days — a bad property for a deploy step.
21
+ * - **user OAuth** (refresh-token grant) for `search setup`, which is a
22
+ * one-time human operation. Site verification makes the calling identity an
23
+ * *owner* of the property, and that should be a person, not a project robot.
24
+ *
25
+ * `googleapis` is deliberately not a dependency — this is one REST endpoint in a
26
+ * CLI that installs into every consumer project. See AGENTS.md.
27
+ *
28
+ * Internal: not a published subpath. Both `./indexing` and `./onboarding` import
29
+ * it, so it lands in a shared chunk, which is free for a Node library. (The
30
+ * "don't create a shared chunk" rule in AGENTS.md is about browser bundles and
31
+ * the consent gate.)
32
+ */
33
+ const TOKEN_URL = "https://oauth2.googleapis.com/token";
34
+ const b64url = (input) => Buffer.from(input).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
35
+ /** Sign the RS256 JWT assertion a service-account grant carries. */
36
+ function assertionFor(account, scope, now) {
37
+ const iat = Math.floor(now / 1e3);
38
+ const header = b64url(JSON.stringify({
39
+ alg: "RS256",
40
+ typ: "JWT"
41
+ }));
42
+ const claims = b64url(JSON.stringify({
43
+ iss: account.client_email,
44
+ scope,
45
+ aud: TOKEN_URL,
46
+ iat,
47
+ exp: iat + 3600
48
+ }));
49
+ const signer = createSign("RSA-SHA256");
50
+ signer.update(`${header}.${claims}`);
51
+ const pem = account.private_key.replace(/\\n/g, "\n");
52
+ return `${header}.${claims}.${b64url(signer.sign(pem))}`;
53
+ }
54
+ /** The half that never differs: POST the grant, surface the cause, return the token. */
55
+ async function requestToken(body, fetchImpl) {
56
+ const res = await fetchImpl(TOKEN_URL, {
57
+ method: "POST",
58
+ headers: { "content-type": "application/x-www-form-urlencoded" },
59
+ body: body.toString()
60
+ });
61
+ if (!res.ok) throw new Error(`token exchange failed (${res.status}): ${await res.text()}`);
62
+ const { access_token } = await res.json();
63
+ if (!access_token) throw new Error("token exchange returned no access_token");
64
+ return access_token;
65
+ }
66
+ /**
67
+ * Exchange either credential for a short-lived access token.
68
+ *
69
+ * Throws rather than returning a result: nothing downstream can run without a
70
+ * token, so there is no per-item decision for a caller to make. (The API calls
71
+ * themselves do return structured results — those fail per domain.)
72
+ *
73
+ * `now` is a parameter so the assertion is testable without mocking the clock.
74
+ */
75
+ function googleAccessToken(credential, fetchImpl = fetch, now = Date.now()) {
76
+ return requestToken(credential.kind === "oauth" ? new URLSearchParams({
77
+ grant_type: "refresh_token",
78
+ client_id: credential.oauth.clientId,
79
+ client_secret: credential.oauth.clientSecret,
80
+ refresh_token: credential.oauth.refreshToken
81
+ }) : new URLSearchParams({
82
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
83
+ assertion: assertionFor(credential.account, credential.scope, now)
84
+ }), fetchImpl);
85
+ }
86
+ /** Scopes, named once so a caller never spells one out. */
87
+ const SCOPES = {
88
+ /** Search Console (`sitemaps.submit`, `urlInspection`, `sites`). */
89
+ webmasters: "https://www.googleapis.com/auth/webmasters",
90
+ /** Site Verification API. */
91
+ siteVerification: "https://www.googleapis.com/auth/siteverification"
92
+ };
93
+
94
+ //#endregion
95
+ export { googleAccessToken as n, SCOPES as t };
96
+ //# sourceMappingURL=token-BV7tPHh2.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-BV7tPHh2.mjs","names":[],"sources":["../src/google/token.ts"],"sourcesContent":["/**\n * Google OAuth2 token exchange — one request, two grants.\n *\n * Both of this package's Google modules need a bearer token, and both used to\n * mint one themselves. The two copies looked different because the *credentials*\n * differ, but everything after building the form body was identical: same\n * endpoint, same content-type, same \"surface the response body as the error\"\n * rule, same `access_token` extraction. So the seam is precise —\n * **credential → form params differs, params → token does not.**\n *\n * The two grants are kept because they are not accidental duplication; they track\n * how each command is actually run:\n *\n * - **service account** (RS256 JWT bearer) for `search notify`, which runs on\n * every deploy in CI. It never expires. A refresh token can be revoked, and\n * for an OAuth client still in *Testing* publishing status Google expires it\n * after 7 days — a bad property for a deploy step.\n * - **user OAuth** (refresh-token grant) for `search setup`, which is a\n * one-time human operation. Site verification makes the calling identity an\n * *owner* of the property, and that should be a person, not a project robot.\n *\n * `googleapis` is deliberately not a dependency — this is one REST endpoint in a\n * CLI that installs into every consumer project. See AGENTS.md.\n *\n * Internal: not a published subpath. Both `./indexing` and `./onboarding` import\n * it, so it lands in a shared chunk, which is free for a Node library. (The\n * \"don't create a shared chunk\" rule in AGENTS.md is about browser bundles and\n * the consent gate.)\n */\nimport { createSign } from 'node:crypto';\n\nconst TOKEN_URL = 'https://oauth2.googleapis.com/token';\n\n/** The fields we need from a service-account JSON key. */\nexport interface ServiceAccount {\n client_email: string;\n private_key: string;\n}\n\n/** A user OAuth credential (refresh-token flow — never a service account). */\nexport interface GoogleOAuth {\n clientId: string;\n clientSecret: string;\n refreshToken: string;\n}\n\n/**\n * Which identity to act as.\n *\n * `scope` rides on the service-account variant because that grant carries its\n * scope in the signed assertion. A user OAuth token's scopes are fixed when the\n * refresh token is minted, so there is nothing to pass at exchange time — the\n * asymmetry is the protocol's, not ours.\n */\nexport type GoogleCredential =\n | { kind: 'service-account'; account: ServiceAccount; scope: string }\n | { kind: 'oauth'; oauth: GoogleOAuth };\n\nconst b64url = (input: Buffer | string): string =>\n Buffer.from(input).toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n\n/** Sign the RS256 JWT assertion a service-account grant carries. */\nfunction assertionFor(account: ServiceAccount, scope: string, now: number): string {\n const iat = Math.floor(now / 1000);\n const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));\n const claims = b64url(\n JSON.stringify({ iss: account.client_email, scope, aud: TOKEN_URL, iat, exp: iat + 3600 }),\n );\n const signer = createSign('RSA-SHA256');\n signer.update(`${header}.${claims}`);\n // `\\n` survives most secret stores as a literal; normalise so a pasted key works.\n const pem = account.private_key.replace(/\\\\n/g, '\\n');\n return `${header}.${claims}.${b64url(signer.sign(pem))}`;\n}\n\n/** The half that never differs: POST the grant, surface the cause, return the token. */\nasync function requestToken(body: URLSearchParams, fetchImpl: typeof fetch): Promise<string> {\n const res = await fetchImpl(TOKEN_URL, {\n method: 'POST',\n headers: { 'content-type': 'application/x-www-form-urlencoded' },\n body: body.toString(),\n });\n if (!res.ok) {\n // The body names the cause — clock skew, wrong audience or a revoked key for a\n // service account; `invalid_grant` / `invalid_client` for OAuth — and carries\n // no secret of ours: the assertion travels in the request, not the response.\n throw new Error(`token exchange failed (${res.status}): ${await res.text()}`);\n }\n const { access_token } = (await res.json()) as { access_token?: string };\n if (!access_token) throw new Error('token exchange returned no access_token');\n return access_token;\n}\n\n/**\n * Exchange either credential for a short-lived access token.\n *\n * Throws rather than returning a result: nothing downstream can run without a\n * token, so there is no per-item decision for a caller to make. (The API calls\n * themselves do return structured results — those fail per domain.)\n *\n * `now` is a parameter so the assertion is testable without mocking the clock.\n */\nexport function googleAccessToken(\n credential: GoogleCredential,\n fetchImpl: typeof fetch = fetch,\n now: number = Date.now(),\n): Promise<string> {\n const body =\n credential.kind === 'oauth'\n ? new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: credential.oauth.clientId,\n client_secret: credential.oauth.clientSecret,\n refresh_token: credential.oauth.refreshToken,\n })\n : new URLSearchParams({\n grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',\n assertion: assertionFor(credential.account, credential.scope, now),\n });\n return requestToken(body, fetchImpl);\n}\n\n/** Scopes, named once so a caller never spells one out. */\nexport const SCOPES = {\n /** Search Console (`sitemaps.submit`, `urlInspection`, `sites`). */\n webmasters: 'https://www.googleapis.com/auth/webmasters',\n /** Site Verification API. */\n siteVerification: 'https://www.googleapis.com/auth/siteverification',\n} as const;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,MAAM,YAAY;AA2BlB,MAAM,UAAU,UACd,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,QAAQ,CAAC,CAAC,QAAQ,OAAO,GAAG,CAAC,CAAC,QAAQ,OAAO,GAAG,CAAC,CAAC,QAAQ,OAAO,EAAE;;AAGjG,SAAS,aAAa,SAAyB,OAAe,KAAqB;CACjF,MAAM,MAAM,KAAK,MAAM,MAAM,GAAI;CACjC,MAAM,SAAS,OAAO,KAAK,UAAU;EAAE,KAAK;EAAS,KAAK;CAAM,CAAC,CAAC;CAClE,MAAM,SAAS,OACb,KAAK,UAAU;EAAE,KAAK,QAAQ;EAAc;EAAO,KAAK;EAAW;EAAK,KAAK,MAAM;CAAK,CAAC,CAC3F;CACA,MAAM,SAAS,WAAW,YAAY;CACtC,OAAO,OAAO,GAAG,OAAO,GAAG,QAAQ;CAEnC,MAAM,MAAM,QAAQ,YAAY,QAAQ,QAAQ,IAAI;CACpD,OAAO,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,OAAO,KAAK,GAAG,CAAC;AACvD;;AAGA,eAAe,aAAa,MAAuB,WAA0C;CAC3F,MAAM,MAAM,MAAM,UAAU,WAAW;EACrC,QAAQ;EACR,SAAS,EAAE,gBAAgB,oCAAoC;EAC/D,MAAM,KAAK,SAAS;CACtB,CAAC;CACD,IAAI,CAAC,IAAI,IAIP,MAAM,IAAI,MAAM,0BAA0B,IAAI,OAAO,KAAK,MAAM,IAAI,KAAK,GAAG;CAE9E,MAAM,EAAE,iBAAkB,MAAM,IAAI,KAAK;CACzC,IAAI,CAAC,cAAc,MAAM,IAAI,MAAM,yCAAyC;CAC5E,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,kBACd,YACA,YAA0B,OAC1B,MAAc,KAAK,IAAI,GACN;CAajB,OAAO,aAXL,WAAW,SAAS,UAChB,IAAI,gBAAgB;EAClB,YAAY;EACZ,WAAW,WAAW,MAAM;EAC5B,eAAe,WAAW,MAAM;EAChC,eAAe,WAAW,MAAM;CAClC,CAAC,IACD,IAAI,gBAAgB;EAClB,YAAY;EACZ,WAAW,aAAa,WAAW,SAAS,WAAW,OAAO,GAAG;CACnE,CAAC,GACmB,SAAS;AACrC;;AAGA,MAAa,SAAS;;CAEpB,YAAY;;CAEZ,kBAAkB;AACpB"}
@@ -0,0 +1,88 @@
1
+ import { i as TrackingData, n as TrackingCategory, r as TrackingConfig, t as ConversionEvent } from "../types-C3lHaSrU.mjs";
2
+
3
+ //#region src/tracking/params.d.ts
4
+ /**
5
+ * The ad-platform vocabulary — the one place that says which URL parameters mean
6
+ * "this visit came from an ad", and which platform each belongs to.
7
+ *
8
+ * Single source of truth on purpose: the capture script reads it to know what to
9
+ * pull off the URL, and the notification renderer reads it to name the platform.
10
+ * Two lists would drift into a cookie holding a click ID nothing can attribute.
11
+ */
12
+ /**
13
+ * Click-ID parameters, in identification precedence.
14
+ *
15
+ * Order matters: `identifyPlatform` and `getPrimaryClickId` return the first
16
+ * match, and a URL can legitimately carry more than one (a Google Ads link
17
+ * retargeted through Meta arrives with both `gclid` and `fbclid`). Google first
18
+ * because its click IDs are the ones that survive redirects intact.
19
+ */
20
+ declare const PLATFORM_PARAMS: Readonly<Record<string, string>>;
21
+ /** Just the parameter names, in the same precedence order. */
22
+ declare const CLICK_ID_PARAMS: readonly string[];
23
+ declare const UTM_PARAMS: readonly ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"];
24
+ /** Everything the capture script pulls off the landing URL. */
25
+ declare const TRACKED_PARAMS: readonly string[];
26
+ /**
27
+ * The attribution cookie.
28
+ *
29
+ * First-party and same-site: it is read by this site's own conversion handler and
30
+ * never sent anywhere. It is still a persistent identifier, which is why writing
31
+ * it waits on consent.
32
+ */
33
+ declare const TRACKING_COOKIE = "_ac";
34
+ /** 90 days — an ad click and the conversion it produces are rarely further apart. */
35
+ declare const TRACKING_COOKIE_DAYS = 90;
36
+ /** The A/B variant cookie, assigned by the router when a site runs experiments. */
37
+ declare const AB_COOKIE = "_ab";
38
+ //#endregion
39
+ //#region src/tracking/cookie.d.ts
40
+ /** Read one cookie out of a `document.cookie`-shaped string. */
41
+ declare function readCookie(cookieString: string | null | undefined, name: string): string | null;
42
+ /**
43
+ * Cookie header → attribution, or `null`.
44
+ *
45
+ * Anything unparseable reads as `null` — no attribution — rather than as a
46
+ * partial record. A half-read cookie would attribute a conversion to whichever
47
+ * fields happened to survive, which is worse than reporting it unattributed:
48
+ * "we don't know" is actionable, a wrong campaign is not.
49
+ */
50
+ declare function parseTrackingCookie(cookieHeader: string | null | undefined): TrackingData | null;
51
+ /** Attribution → cookie value (not the full `Set-Cookie` — see `cookieAttributes`). */
52
+ declare function serializeTrackingCookie(data: TrackingData): string;
53
+ /**
54
+ * Attributes for the attribution cookie.
55
+ *
56
+ * `SameSite=Lax` rather than `Strict`: the visitor arrives here by following a
57
+ * link from the ad platform, and `Strict` withholds the cookie on exactly that
58
+ * cross-site navigation — so a returning click would read as a fresh one. No
59
+ * `HttpOnly`, because the capture script has to write it from the page.
60
+ */
61
+ declare function cookieAttributes(now: number, secure: boolean): string;
62
+ /**
63
+ * Fold a fresh capture into whatever was already stored.
64
+ *
65
+ * New values win, but `ts` is the **first** capture's — it dates the visitor's
66
+ * original arrival, which is what a 90-day attribution window is measured from.
67
+ * Taking the latest would restart the window on every visit and make the cookie
68
+ * outlive the click it represents.
69
+ */
70
+ declare function mergeTracking(existing: TrackingData | null, captured: TrackingData, now: number): TrackingData;
71
+ //#endregion
72
+ //#region src/tracking/platform.d.ts
73
+ /**
74
+ * Which platform sent this visitor.
75
+ *
76
+ * Falls back to `utm_source` when no click ID is present: a UTM-tagged link from
77
+ * a newsletter or a partner site is still attribution, just self-declared rather
78
+ * than platform-issued. Returns null when there is nothing to go on.
79
+ */
80
+ declare function identifyPlatform(data: TrackingData): string | null;
81
+ /** The click ID to quote, with the parameter it came from. */
82
+ declare function getPrimaryClickId(data: TrackingData): {
83
+ param: string;
84
+ value: string;
85
+ } | null;
86
+ //#endregion
87
+ export { AB_COOKIE, CLICK_ID_PARAMS, type ConversionEvent, PLATFORM_PARAMS, TRACKED_PARAMS, TRACKING_COOKIE, TRACKING_COOKIE_DAYS, type TrackingCategory, type TrackingConfig, type TrackingData, UTM_PARAMS, cookieAttributes, getPrimaryClickId, identifyPlatform, mergeTracking, parseTrackingCookie, readCookie, serializeTrackingCookie };
88
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/tracking/params.ts","../../src/tracking/cookie.ts","../../src/tracking/platform.ts"],"mappings":";;;;;;AAiBA;;;;AAA6C;AAY7C;;;;AAA8E;AAE9E;;;AAFA,cAZa,eAAA,EAAiB,QAAQ,CAAC,MAAA;AAoB7B;AAAA,cARG,eAAA;AAAA,cAEA,UAAA;;cASA,cAAA;AAAuE;AASpF;;;;AAA4B;AAG5B;AAZoF,cASvE,eAAA;;cAGA,oBAAA;AAAoB;AAAA,cAGpB,SAAA;;;;iBCxCG,UAAA,CAAW,YAAA,6BAAyC,IAAY;ADEhF;;;;AAA6C;AAY7C;;;AAZA,iBCegB,mBAAA,CAAoB,YAAA,8BAA0C,YAAY;ADHZ;AAAA,iBCgB9D,uBAAA,CAAwB,IAAkB,EAAZ,YAAY;;;;ADRhD;AAGV;;;;iBCoBgB,gBAAA,CAAiB,GAAA,UAAa,MAAe;ADX7D;;;;AAA4B;AAG5B;;;AAHA,iBCwBgB,aAAA,CACd,QAAA,EAAU,YAAA,SACV,QAAA,EAAU,YAAA,EACV,GAAA,WACC,YAAA;;;;;AD5DH;;;;AAA6C;iBEC7B,gBAAA,CAAiB,IAAkB,EAAZ,YAAY;;iBASnC,iBAAA,CAAkB,IAAA,EAAM,YAAY;EAAK,KAAA;EAAe,KAAA;AAAA"}
@@ -0,0 +1,77 @@
1
+ import { a as PLATFORM_PARAMS, c as TRACKING_COOKIE_DAYS, i as CLICK_ID_PARAMS, l as UTM_PARAMS, n as identifyPlatform, o as TRACKED_PARAMS, r as AB_COOKIE, s as TRACKING_COOKIE, t as getPrimaryClickId } from "../platform-CIdwwI_Z.mjs";
2
+
3
+ //#region src/tracking/cookie.ts
4
+ /**
5
+ * Reading and writing the `_ac` cookie, as functions over a cookie **string**.
6
+ *
7
+ * String in, string out, so one implementation serves both ends: the browser
8
+ * passes `document.cookie`, the conversion handler passes the request's `Cookie`
9
+ * header. A separate server-side parser would be a second thing to keep in step
10
+ * with the writer, and the failure — attribution silently absent from a
11
+ * notification — looks identical to a visitor who arrived organically.
12
+ *
13
+ * The same shape, for the same reason, as `@getvitops/core`'s consent store.
14
+ */
15
+ /** Read one cookie out of a `document.cookie`-shaped string. */
16
+ function readCookie(cookieString, name) {
17
+ if (!cookieString) return null;
18
+ for (const part of cookieString.split(";")) {
19
+ const raw = part.trim();
20
+ if (raw.slice(0, name.length + 1) === `${name}=`) return raw.slice(name.length + 1);
21
+ }
22
+ return null;
23
+ }
24
+ /**
25
+ * Cookie header → attribution, or `null`.
26
+ *
27
+ * Anything unparseable reads as `null` — no attribution — rather than as a
28
+ * partial record. A half-read cookie would attribute a conversion to whichever
29
+ * fields happened to survive, which is worse than reporting it unattributed:
30
+ * "we don't know" is actionable, a wrong campaign is not.
31
+ */
32
+ function parseTrackingCookie(cookieHeader) {
33
+ const raw = readCookie(cookieHeader, "_ac");
34
+ if (!raw) return null;
35
+ try {
36
+ const data = JSON.parse(decodeURIComponent(raw));
37
+ if (typeof data !== "object" || data === null || Array.isArray(data)) return null;
38
+ return data;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ /** Attribution → cookie value (not the full `Set-Cookie` — see `cookieAttributes`). */
44
+ function serializeTrackingCookie(data) {
45
+ const clean = Object.fromEntries(Object.entries(data).filter(([, v]) => v !== void 0));
46
+ return encodeURIComponent(JSON.stringify(clean));
47
+ }
48
+ /**
49
+ * Attributes for the attribution cookie.
50
+ *
51
+ * `SameSite=Lax` rather than `Strict`: the visitor arrives here by following a
52
+ * link from the ad platform, and `Strict` withholds the cookie on exactly that
53
+ * cross-site navigation — so a returning click would read as a fresh one. No
54
+ * `HttpOnly`, because the capture script has to write it from the page.
55
+ */
56
+ function cookieAttributes(now, secure) {
57
+ return `;expires=${new Date(now + 90 * 864e5).toUTCString()};path=/;SameSite=Lax${secure ? ";Secure" : ""}`;
58
+ }
59
+ /**
60
+ * Fold a fresh capture into whatever was already stored.
61
+ *
62
+ * New values win, but `ts` is the **first** capture's — it dates the visitor's
63
+ * original arrival, which is what a 90-day attribution window is measured from.
64
+ * Taking the latest would restart the window on every visit and make the cookie
65
+ * outlive the click it represents.
66
+ */
67
+ function mergeTracking(existing, captured, now) {
68
+ return {
69
+ ...existing,
70
+ ...captured,
71
+ ts: existing?.ts ?? now
72
+ };
73
+ }
74
+
75
+ //#endregion
76
+ export { AB_COOKIE, CLICK_ID_PARAMS, PLATFORM_PARAMS, TRACKED_PARAMS, TRACKING_COOKIE, TRACKING_COOKIE_DAYS, UTM_PARAMS, cookieAttributes, getPrimaryClickId, identifyPlatform, mergeTracking, parseTrackingCookie, readCookie, serializeTrackingCookie };
77
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/tracking/cookie.ts"],"sourcesContent":["/**\n * Reading and writing the `_ac` cookie, as functions over a cookie **string**.\n *\n * String in, string out, so one implementation serves both ends: the browser\n * passes `document.cookie`, the conversion handler passes the request's `Cookie`\n * header. A separate server-side parser would be a second thing to keep in step\n * with the writer, and the failure — attribution silently absent from a\n * notification — looks identical to a visitor who arrived organically.\n *\n * The same shape, for the same reason, as `@getvitops/core`'s consent store.\n */\nimport { TRACKING_COOKIE, TRACKING_COOKIE_DAYS } from './params.ts';\nimport type { TrackingData } from './types.ts';\n\n/** Read one cookie out of a `document.cookie`-shaped string. */\nexport function readCookie(cookieString: string | null | undefined, name: string): string | null {\n if (!cookieString) return null;\n for (const part of cookieString.split(';')) {\n const raw = part.trim();\n if (raw.slice(0, name.length + 1) === `${name}=`) return raw.slice(name.length + 1);\n }\n return null;\n}\n\n/**\n * Cookie header → attribution, or `null`.\n *\n * Anything unparseable reads as `null` — no attribution — rather than as a\n * partial record. A half-read cookie would attribute a conversion to whichever\n * fields happened to survive, which is worse than reporting it unattributed:\n * \"we don't know\" is actionable, a wrong campaign is not.\n */\nexport function parseTrackingCookie(cookieHeader: string | null | undefined): TrackingData | null {\n const raw = readCookie(cookieHeader, TRACKING_COOKIE);\n if (!raw) return null;\n try {\n const data: unknown = JSON.parse(decodeURIComponent(raw));\n if (typeof data !== 'object' || data === null || Array.isArray(data)) return null;\n return data as TrackingData;\n } catch {\n return null;\n }\n}\n\n/** Attribution → cookie value (not the full `Set-Cookie` — see `cookieAttributes`). */\nexport function serializeTrackingCookie(data: TrackingData): string {\n // Undefined values would serialize as absent keys anyway; dropping them first\n // keeps the cookie small and the round-trip exact.\n const clean = Object.fromEntries(Object.entries(data).filter(([, v]) => v !== undefined));\n return encodeURIComponent(JSON.stringify(clean));\n}\n\n/**\n * Attributes for the attribution cookie.\n *\n * `SameSite=Lax` rather than `Strict`: the visitor arrives here by following a\n * link from the ad platform, and `Strict` withholds the cookie on exactly that\n * cross-site navigation — so a returning click would read as a fresh one. No\n * `HttpOnly`, because the capture script has to write it from the page.\n */\nexport function cookieAttributes(now: number, secure: boolean): string {\n const expires = new Date(now + TRACKING_COOKIE_DAYS * 864e5).toUTCString();\n return `;expires=${expires};path=/;SameSite=Lax${secure ? ';Secure' : ''}`;\n}\n\n/**\n * Fold a fresh capture into whatever was already stored.\n *\n * New values win, but `ts` is the **first** capture's — it dates the visitor's\n * original arrival, which is what a 90-day attribution window is measured from.\n * Taking the latest would restart the window on every visit and make the cookie\n * outlive the click it represents.\n */\nexport function mergeTracking(\n existing: TrackingData | null,\n captured: TrackingData,\n now: number,\n): TrackingData {\n return { ...existing, ...captured, ts: existing?.ts ?? now };\n}\n"],"mappings":";;;;;;;;;;;;;;;AAeA,SAAgB,WAAW,cAAyC,MAA6B;CAC/F,IAAI,CAAC,cAAc,OAAO;CAC1B,KAAK,MAAM,QAAQ,aAAa,MAAM,GAAG,GAAG;EAC1C,MAAM,MAAM,KAAK,KAAK;EACtB,IAAI,IAAI,MAAM,GAAG,KAAK,SAAS,CAAC,MAAM,GAAG,KAAK,IAAI,OAAO,IAAI,MAAM,KAAK,SAAS,CAAC;CACpF;CACA,OAAO;AACT;;;;;;;;;AAUA,SAAgB,oBAAoB,cAA8D;CAChG,MAAM,MAAM,WAAW,mBAA6B;CACpD,IAAI,CAAC,KAAK,OAAO;CACjB,IAAI;EACF,MAAM,OAAgB,KAAK,MAAM,mBAAmB,GAAG,CAAC;EACxD,IAAI,OAAO,SAAS,YAAY,SAAS,QAAQ,MAAM,QAAQ,IAAI,GAAG,OAAO;EAC7E,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;AAGA,SAAgB,wBAAwB,MAA4B;CAGlE,MAAM,QAAQ,OAAO,YAAY,OAAO,QAAQ,IAAI,CAAC,CAAC,QAAQ,GAAG,OAAO,MAAM,MAAS,CAAC;CACxF,OAAO,mBAAmB,KAAK,UAAU,KAAK,CAAC;AACjD;;;;;;;;;AAUA,SAAgB,iBAAiB,KAAa,QAAyB;CAErE,OAAO,YADS,IAAI,KAAK,WAA6B,KAAK,CAAC,CAAC,YACpC,EAAE,sBAAsB,SAAS,YAAY;AACxE;;;;;;;;;AAUA,SAAgB,cACd,UACA,UACA,KACc;CACd,OAAO;EAAE,GAAG;EAAU,GAAG;EAAU,IAAI,UAAU,MAAM;CAAI;AAC7D"}
@@ -0,0 +1,69 @@
1
+ //#region src/tracking/types.d.ts
2
+ /**
3
+ * The tracking module's own option surface.
4
+ *
5
+ * Deliberately structural rather than `Config['site']['tracking']` imported from
6
+ * `@getvitops/generator`: the generator already depends on this package, so
7
+ * importing back would be a cycle. The vocabulary mirrors that block field for
8
+ * field — the same arrangement, and for the same reason, as `IndexingConfig`.
9
+ */
10
+ /** Which consent category the `_ac` cookie waits on. */
11
+ type TrackingCategory = 'marketing' | 'analytics';
12
+ interface TrackingConfig {
13
+ enabled?: boolean | undefined;
14
+ /**
15
+ * Default `marketing`. `_ac` is a 90-day identifier tying a visitor to the ad
16
+ * that brought them, which is what advertising consent covers — but a site
17
+ * using click IDs purely for its own campaign reporting may reasonably file it
18
+ * under `analytics`, so this is stated rather than assumed.
19
+ */
20
+ category?: TrackingCategory | undefined;
21
+ platforms?: string[] | undefined;
22
+ }
23
+ /**
24
+ * What the `_ac` cookie holds: where a visitor came from, captured once on the
25
+ * landing page and carried until it expires.
26
+ *
27
+ * Click-ID and UTM keys are indexed rather than enumerated because the platform
28
+ * table is the source of truth for which are recognised — enumerating them here
29
+ * would be a second list to keep in step.
30
+ */
31
+ interface TrackingData {
32
+ [param: string]: string | number | undefined;
33
+ /** Path of the page the visitor first landed on. */
34
+ landingPage?: string | undefined;
35
+ /** `document.referrer` at capture, when there was one. */
36
+ referrer?: string | undefined;
37
+ /** Epoch ms of first capture. Preserved across later captures. */
38
+ ts?: number | undefined;
39
+ /** A/B variant assigned by the router, when the site runs one. */
40
+ ab_variant?: string | undefined;
41
+ }
42
+ /**
43
+ * A conversion, as a fact — the thing every notification channel renders.
44
+ *
45
+ * This is the module's load-bearing abstraction. The event says *what happened*;
46
+ * how it reads is the channel's business, so an SMS channel can render 160
47
+ * characters from the same event an email renders in full without a second source
48
+ * of truth. It is the rule the legal templates already follow: the config records
49
+ * facts, the template owns prose.
50
+ *
51
+ * `at` is passed in rather than read from a clock, so `plan`/`render` stay pure
52
+ * and their tests need no fake timers.
53
+ */
54
+ interface ConversionEvent {
55
+ type: 'form' | 'call';
56
+ /** Submitted fields, for `type: 'form'`. Honeypots are stripped before this. */
57
+ formData?: Record<string, string> | undefined;
58
+ /** The dialled number, for `type: 'call'`. */
59
+ phone?: string | undefined;
60
+ /** Attribution from the `_ac` cookie, or null when the visitor arrived cold. */
61
+ tracking: TrackingData | null;
62
+ userAgent?: string | undefined;
63
+ ip?: string | undefined;
64
+ /** Epoch ms. */
65
+ at: number;
66
+ }
67
+ //#endregion
68
+ export { TrackingData as i, TrackingCategory as n, TrackingConfig as r, ConversionEvent as t };
69
+ //# sourceMappingURL=types-C3lHaSrU.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types-C3lHaSrU.d.mts","names":[],"sources":["../src/tracking/types.ts"],"mappings":";;AAiBA;;;;AAA4B;AAE5B;;;KAFY,gBAAA;AAAA,UAEK,cAAA;EACf,OAAA;EAOW;;;AACF;AAWX;;EAZE,QAAA,GAAW,gBAAgB;EAC3B,SAAA;AAAA;;;;;;AAoBU;AAeZ;;UAxBiB,YAAA;EAAA,CACd,KAAA;EAwBD;EAtBA,WAAA;EAwBW;EAtBX,QAAA;EA0BA;EAxBA,EAAA;EAyBA;EAvBA,UAAA;AAAA;;AA0BE;;;;;;;;;;;UAXa,eAAA;EACf,IAAA;;EAEA,QAAA,GAAW,MAAA;;EAEX,KAAA;;EAEA,QAAA,EAAU,YAAY;EACtB,SAAA;EACA,EAAA;;EAEA,EAAA;AAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/utils",
3
- "version": "2.1.0",
3
+ "version": "4.0.0",
4
4
  "description": "Shared build-time utilities for the Vitops toolchain — favicon generation and friends.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -26,9 +26,21 @@
26
26
  "types": "./dist/indexing/index.d.mts",
27
27
  "import": "./dist/indexing/index.mjs"
28
28
  },
29
+ "./onboarding": {
30
+ "types": "./dist/onboarding/index.d.mts",
31
+ "import": "./dist/onboarding/index.mjs"
32
+ },
29
33
  "./media": {
30
34
  "types": "./dist/media/index.d.mts",
31
35
  "import": "./dist/media/index.mjs"
36
+ },
37
+ "./notify": {
38
+ "types": "./dist/notify/index.d.mts",
39
+ "import": "./dist/notify/index.mjs"
40
+ },
41
+ "./tracking": {
42
+ "types": "./dist/tracking/index.d.mts",
43
+ "import": "./dist/tracking/index.mjs"
32
44
  }
33
45
  },
34
46
  "publishConfig": {