Skip to content

Commit 552b9e0

Browse files
committed
fix: contain anonymous analytics ingestion
1 parent dc25da4 commit 552b9e0

8 files changed

Lines changed: 190 additions & 42 deletions

File tree

apps/web/__tests__/unit/product-analytics-browser-token.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
PRODUCT_ANALYTICS_BROWSER_TOKEN_COOKIE,
55
PRODUCT_ANALYTICS_BROWSER_TOKEN_TTL_SECONDS,
66
readProductAnalyticsBrowserToken,
7+
readProductAnalyticsBrowserTokenClaims,
78
verifyProductAnalyticsBrowserToken,
89
} from "@/lib/analytics/browser-token";
910

@@ -12,8 +13,15 @@ const now = Date.parse("2026-07-12T12:00:00.000Z");
1213

1314
describe("product analytics browser token", () => {
1415
it("accepts an untampered token inside its bounded lifetime", () => {
15-
const token = createProductAnalyticsBrowserToken(secret, now, "nonce");
16+
const token = createProductAnalyticsBrowserToken(
17+
secret,
18+
"anonymous-1",
19+
now,
20+
);
1621
expect(verifyProductAnalyticsBrowserToken(token, secret, now)).toBe(true);
22+
expect(readProductAnalyticsBrowserTokenClaims(token, secret, now)).toEqual({
23+
anonymousId: "anonymous-1",
24+
});
1725
expect(
1826
verifyProductAnalyticsBrowserToken(
1927
token,
@@ -24,7 +32,11 @@ describe("product analytics browser token", () => {
2432
});
2533

2634
it("rejects expired, future, tampered, and malformed tokens", () => {
27-
const token = createProductAnalyticsBrowserToken(secret, now, "nonce");
35+
const token = createProductAnalyticsBrowserToken(
36+
secret,
37+
"anonymous-1",
38+
now,
39+
);
2840
expect(
2941
verifyProductAnalyticsBrowserToken(
3042
token,
@@ -34,7 +46,7 @@ describe("product analytics browser token", () => {
3446
).toBe(false);
3547
expect(
3648
verifyProductAnalyticsBrowserToken(
37-
createProductAnalyticsBrowserToken(secret, now + 61_000, "nonce"),
49+
createProductAnalyticsBrowserToken(secret, "anonymous-1", now + 61_000),
3850
secret,
3951
now,
4052
),
@@ -48,7 +60,11 @@ describe("product analytics browser token", () => {
4860
});
4961

5062
it("reads only the analytics token cookie", () => {
51-
const token = createProductAnalyticsBrowserToken(secret, now, "nonce");
63+
const token = createProductAnalyticsBrowserToken(
64+
secret,
65+
"anonymous-1",
66+
now,
67+
);
5268
expect(
5369
readProductAnalyticsBrowserToken(
5470
`other=value; ${PRODUCT_ANALYTICS_BROWSER_TOKEN_COOKIE}=${token}`,

apps/web/__tests__/unit/product-analytics-queue.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import {
77
createProductEventId,
8+
getOrCreateBrowserAnonymousId,
89
getOrCreateStorageId,
910
ProductAnalyticsQueue,
1011
type ProductAnalyticsTransport,
@@ -232,6 +233,17 @@ describe("browser analytics identity", () => {
232233
expect(storage.setItem).toHaveBeenCalledWith("key", "new-id");
233234
});
234235

236+
it("uses the server-issued cookie identity", () => {
237+
const storage = { getItem: vi.fn(() => "stale-id"), setItem: vi.fn() };
238+
expect(
239+
getOrCreateBrowserAnonymousId(storage, "signed-id", () => "new-id"),
240+
).toBe("signed-id");
241+
expect(storage.setItem).toHaveBeenCalledWith(
242+
"cap_analytics_anonymous_id_v1",
243+
"signed-id",
244+
);
245+
});
246+
235247
it("falls back when storage is unavailable", () => {
236248
const storage = {
237249
getItem: vi.fn(() => {

apps/web/__tests__/unit/product-analytics-request.test.ts

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
1-
import { PRODUCT_ANALYTICS_LIMITS } from "@cap/analytics";
1+
import {
2+
PRODUCT_ANALYTICS_LIMITS,
3+
type ProductEventInput,
4+
} from "@cap/analytics";
25
import { describe, expect, it } from "vitest";
36
import {
47
getProductAnalyticsRateLimitKey,
8+
hasExpectedBrowserAnalyticsMetadata,
9+
isAllowedAnonymousBrowserProductEvent,
510
isAuthenticatedAnalyticsRequestCandidate,
6-
isTrustedAnalyticsRequest,
711
normalizeGeoHeader,
812
normalizeProductEventBatch,
913
ProductAnalyticsRateLimiter,
1014
} from "@/lib/analytics/request";
1115

1216
const allowedOrigins = ["https://cap.so", "tauri://localhost"];
13-
const event = {
17+
const event: ProductEventInput = {
1418
eventId: "event-1",
1519
eventName: "page_view",
1620
occurredAt: "2026-07-12T12:00:00.000Z",
@@ -20,7 +24,7 @@ const event = {
2024
};
2125
const now = Date.parse("2026-07-12T12:00:01.000Z");
2226

23-
describe("isTrustedAnalyticsRequest", () => {
27+
describe("hasExpectedBrowserAnalyticsMetadata", () => {
2428
it.each([
2529
[
2630
"same-origin browser",
@@ -31,25 +35,30 @@ describe("isTrustedAnalyticsRequest", () => {
3135
{ origin: "https://cap.so", secFetchSite: "same-site" },
3236
],
3337
])("accepts %s", (_label, headers) => {
34-
expect(isTrustedAnalyticsRequest(headers, allowedOrigins)).toBe(true);
38+
expect(hasExpectedBrowserAnalyticsMetadata(headers, allowedOrigins)).toBe(
39+
true,
40+
);
3541
});
3642

3743
it("rejects cross-site browser requests", () => {
3844
expect(
39-
isTrustedAnalyticsRequest(
45+
hasExpectedBrowserAnalyticsMetadata(
4046
{ origin: "https://attacker.example", secFetchSite: "cross-site" },
4147
allowedOrigins,
4248
),
4349
).toBe(false);
4450
});
4551

4652
it("rejects requests without browser metadata", () => {
47-
expect(isTrustedAnalyticsRequest({}, allowedOrigins)).toBe(false);
53+
expect(hasExpectedBrowserAnalyticsMetadata({}, allowedOrigins)).toBe(false);
4854
expect(
49-
isTrustedAnalyticsRequest({ origin: "https://cap.so" }, allowedOrigins),
55+
hasExpectedBrowserAnalyticsMetadata(
56+
{ origin: "https://cap.so" },
57+
allowedOrigins,
58+
),
5059
).toBe(false);
5160
expect(
52-
isTrustedAnalyticsRequest(
61+
hasExpectedBrowserAnalyticsMetadata(
5362
{ origin: "https://cap.so", secFetchSite: "none" },
5463
allowedOrigins,
5564
),
@@ -67,6 +76,11 @@ describe("isTrustedAnalyticsRequest", () => {
6776
authorization: "Bearer invalid",
6877
}),
6978
).toBe(false);
79+
expect(
80+
isAuthenticatedAnalyticsRequestCandidate({
81+
authorization: `Bearer ${"a".repeat(36)} extra`,
82+
}),
83+
).toBe(false);
7084
expect(
7185
isAuthenticatedAnalyticsRequestCandidate({
7286
authorization: `Bearer ${"a".repeat(36)}`,
@@ -75,9 +89,33 @@ describe("isTrustedAnalyticsRequest", () => {
7589
).toBe(true);
7690
});
7791

92+
it("allows only bounded top-of-funnel web events without an actor", () => {
93+
expect(isAllowedAnonymousBrowserProductEvent(event, "anonymous-1")).toBe(
94+
true,
95+
);
96+
expect(
97+
isAllowedAnonymousBrowserProductEvent(
98+
{ ...event, eventName: "recording_started" },
99+
"anonymous-1",
100+
),
101+
).toBe(false);
102+
expect(
103+
isAllowedAnonymousBrowserProductEvent(
104+
{ ...event, anonymousId: "attacker-chosen" },
105+
"anonymous-1",
106+
),
107+
).toBe(false);
108+
expect(
109+
isAllowedAnonymousBrowserProductEvent(
110+
{ ...event, platform: "desktop" },
111+
"anonymous-1",
112+
),
113+
).toBe(false);
114+
});
115+
78116
it("rejects oversized declared bodies", () => {
79117
expect(
80-
isTrustedAnalyticsRequest(
118+
hasExpectedBrowserAnalyticsMetadata(
81119
{ contentLength: String(PRODUCT_ANALYTICS_LIMITS.requestBytes + 1) },
82120
allowedOrigins,
83121
),
@@ -87,9 +125,9 @@ describe("isTrustedAnalyticsRequest", () => {
87125
it.each(["invalid", "-1", "1.5"])(
88126
"rejects malformed content length %s",
89127
(contentLength) => {
90-
expect(isTrustedAnalyticsRequest({ contentLength }, allowedOrigins)).toBe(
91-
false,
92-
);
128+
expect(
129+
hasExpectedBrowserAnalyticsMetadata({ contentLength }, allowedOrigins),
130+
).toBe(false);
93131
},
94132
);
95133
});

apps/web/app/api/events/route.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ import {
1919
import { Effect, Layer, Schema } from "effect";
2020
import {
2121
readProductAnalyticsBrowserToken,
22-
verifyProductAnalyticsBrowserToken,
22+
readProductAnalyticsBrowserTokenClaims,
2323
} from "@/lib/analytics/browser-token";
2424
import {
2525
getProductAnalyticsRateLimitKey,
26+
hasExpectedBrowserAnalyticsMetadata,
27+
isAllowedAnonymousBrowserProductEvent,
2628
isAuthenticatedAnalyticsRequestCandidate,
27-
isTrustedAnalyticsRequest,
2829
normalizeGeoHeader,
2930
normalizeProductEventBatch,
3031
ProductAnalyticsRateLimiter,
@@ -88,14 +89,17 @@ const ApiLive = HttpApiBuilder.api(Api).pipe(
8889
origin: headers.origin,
8990
secFetchSite: headers["sec-fetch-site"],
9091
};
91-
const isBrowserRequest =
92-
isTrustedAnalyticsRequest(requestMetadata, allowedOrigins) &&
93-
verifyProductAnalyticsBrowserToken(
92+
const browserClaims =
93+
hasExpectedBrowserAnalyticsMetadata(
94+
requestMetadata,
95+
allowedOrigins,
96+
) &&
97+
readProductAnalyticsBrowserTokenClaims(
9498
readProductAnalyticsBrowserToken(headers.cookie),
9599
serverEnv().NEXTAUTH_SECRET,
96100
);
97101
if (
98-
!isBrowserRequest &&
102+
!browserClaims &&
99103
!isAuthenticatedAnalyticsRequestCandidate(requestMetadata)
100104
) {
101105
return yield* Effect.fail(new HttpApiError.BadRequest());
@@ -126,7 +130,16 @@ const ApiLive = HttpApiBuilder.api(Api).pipe(
126130
}
127131

128132
const actor = yield* resolveProductAnalyticsActor;
129-
if (!isBrowserRequest && !actor) {
133+
if (
134+
!actor &&
135+
(!browserClaims ||
136+
!events.every((event) =>
137+
isAllowedAnonymousBrowserProductEvent(
138+
event,
139+
browserClaims.anonymousId,
140+
),
141+
))
142+
) {
130143
return yield* Effect.fail(new HttpApiError.BadRequest());
131144
}
132145
const rows = createProductEventRows(events, {

apps/web/app/utils/product-analytics.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,19 @@ export function getOrCreateStorageId(
222222
return created;
223223
}
224224

225+
export function getOrCreateBrowserAnonymousId(
226+
storage: Pick<Storage, "getItem" | "setItem"> | undefined,
227+
cookieId: string | undefined,
228+
createId: () => string,
229+
) {
230+
if (!cookieId)
231+
return getOrCreateStorageId(storage, ANONYMOUS_ID_KEY, createId);
232+
try {
233+
storage?.setItem(ANONYMOUS_ID_KEY, cookieId);
234+
} catch {}
235+
return cookieId;
236+
}
237+
225238
export function createProductEventId(
226239
randomUUID: (() => string) | null = getRandomUUID() ?? null,
227240
now = Date.now(),
@@ -278,10 +291,12 @@ function getBrowserQueue() {
278291

279292
function getAnonymousId() {
280293
if (!anonymousId) {
281-
anonymousId = getOrCreateStorageId(
282-
getBrowserStorage("localStorage"),
283-
ANONYMOUS_ID_KEY,
284-
() => createProductEventId(),
294+
const storage = getBrowserStorage("localStorage");
295+
const cookieId = Cookies.get(PRODUCT_ANALYTICS_ANONYMOUS_ID_COOKIE);
296+
anonymousId = getOrCreateBrowserAnonymousId(
297+
storage,
298+
cookieId,
299+
createProductEventId,
285300
);
286301
persistAnonymousIdCookie(anonymousId);
287302
}

apps/web/lib/analytics/browser-token.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,55 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
22

33
export const PRODUCT_ANALYTICS_BROWSER_TOKEN_COOKIE =
44
"cap_analytics_browser_token";
5-
export const PRODUCT_ANALYTICS_BROWSER_TOKEN_TTL_SECONDS = 24 * 60 * 60;
5+
export const PRODUCT_ANALYTICS_BROWSER_TOKEN_TTL_SECONDS = 60 * 60;
6+
7+
export function createProductAnalyticsAnonymousId() {
8+
return randomBytes(16).toString("base64url");
9+
}
610

711
export function createProductAnalyticsBrowserToken(
812
secret: string,
13+
anonymousId = createProductAnalyticsAnonymousId(),
914
now = Date.now(),
10-
nonce = randomBytes(16).toString("base64url"),
1115
) {
12-
const payload = `v1.${Math.floor(now / 1000)}.${nonce}`;
16+
const payload = `v1.${Math.floor(now / 1000)}.${anonymousId}`;
1317
return `${payload}.${sign(payload, secret)}`;
1418
}
1519

16-
export function verifyProductAnalyticsBrowserToken(
20+
export function readProductAnalyticsBrowserTokenClaims(
1721
token: string | undefined,
1822
secret: string,
1923
now = Date.now(),
2024
) {
21-
if (!token) return false;
25+
if (!token) return undefined;
2226
const parts = token.split(".");
23-
if (parts.length !== 4 || parts[0] !== "v1") return false;
27+
if (parts.length !== 4 || parts[0] !== "v1") return undefined;
2428
const issuedAt = Number(parts[1]);
25-
if (!Number.isSafeInteger(issuedAt)) return false;
29+
if (!Number.isSafeInteger(issuedAt)) return undefined;
30+
const anonymousId = parts[2];
31+
if (!anonymousId || anonymousId.length > 128) return undefined;
2632
const nowSeconds = Math.floor(now / 1000);
2733
if (
2834
issuedAt > nowSeconds + 60 ||
2935
nowSeconds - issuedAt > PRODUCT_ANALYTICS_BROWSER_TOKEN_TTL_SECONDS
3036
) {
31-
return false;
37+
return undefined;
3238
}
3339
const payload = parts.slice(0, 3).join(".");
3440
const expected = Buffer.from(sign(payload, secret));
3541
const actual = Buffer.from(parts[3] ?? "");
36-
return expected.length === actual.length && timingSafeEqual(expected, actual);
42+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
43+
return undefined;
44+
}
45+
return { anonymousId };
46+
}
47+
48+
export function verifyProductAnalyticsBrowserToken(
49+
token: string | undefined,
50+
secret: string,
51+
now = Date.now(),
52+
) {
53+
return Boolean(readProductAnalyticsBrowserTokenClaims(token, secret, now));
3754
}
3855

3956
export function readProductAnalyticsBrowserToken(cookieHeader?: string) {

0 commit comments

Comments
 (0)