@iann29/rastro 0.1.0-alpha.4 → 0.1.0-alpha.6

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 (135) hide show
  1. package/README.md +203 -56
  2. package/agent/integration.md +17 -15
  3. package/agent/manifest.json +6 -4
  4. package/agent/manifest.schema.json +8 -5
  5. package/dist/client/federation.d.ts +34 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +17 -3
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/index.d.ts +486 -31
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +209 -41
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/component/_generated/api.d.ts +8 -0
  14. package/dist/component/_generated/api.d.ts.map +1 -1
  15. package/dist/component/_generated/api.js.map +1 -1
  16. package/dist/component/_generated/component.d.ts +82 -8
  17. package/dist/component/_generated/component.d.ts.map +1 -1
  18. package/dist/component/affiliates.d.ts.map +1 -1
  19. package/dist/component/affiliates.js +6 -2
  20. package/dist/component/affiliates.js.map +1 -1
  21. package/dist/component/cardinality.d.ts.map +1 -1
  22. package/dist/component/cardinality.js.map +1 -1
  23. package/dist/component/constants.d.ts +6 -0
  24. package/dist/component/constants.d.ts.map +1 -1
  25. package/dist/component/constants.js +12 -0
  26. package/dist/component/constants.js.map +1 -1
  27. package/dist/component/diagnostics.d.ts.map +1 -1
  28. package/dist/component/diagnostics.js.map +1 -1
  29. package/dist/component/eventStore.d.ts +3 -1
  30. package/dist/component/eventStore.d.ts.map +1 -1
  31. package/dist/component/eventStore.js +13 -10
  32. package/dist/component/eventStore.js.map +1 -1
  33. package/dist/component/funnels.d.ts.map +1 -1
  34. package/dist/component/funnels.js +5 -3
  35. package/dist/component/funnels.js.map +1 -1
  36. package/dist/component/geo.d.ts.map +1 -1
  37. package/dist/component/geo.js +3 -2
  38. package/dist/component/geo.js.map +1 -1
  39. package/dist/component/goals.d.ts.map +1 -1
  40. package/dist/component/goals.js +8 -5
  41. package/dist/component/goals.js.map +1 -1
  42. package/dist/component/guards.d.ts.map +1 -1
  43. package/dist/component/guards.js.map +1 -1
  44. package/dist/component/http.d.ts.map +1 -1
  45. package/dist/component/http.js +71 -37
  46. package/dist/component/http.js.map +1 -1
  47. package/dist/component/identity.d.ts +13 -0
  48. package/dist/component/identity.d.ts.map +1 -0
  49. package/dist/component/identity.js +58 -0
  50. package/dist/component/identity.js.map +1 -0
  51. package/dist/component/ingest.d.ts +3 -1
  52. package/dist/component/ingest.d.ts.map +1 -1
  53. package/dist/component/ingest.js +168 -29
  54. package/dist/component/ingest.js.map +1 -1
  55. package/dist/component/live.d.ts.map +1 -1
  56. package/dist/component/live.js +1 -3
  57. package/dist/component/live.js.map +1 -1
  58. package/dist/component/reports.d.ts +70 -4
  59. package/dist/component/reports.d.ts.map +1 -1
  60. package/dist/component/reports.js +232 -59
  61. package/dist/component/reports.js.map +1 -1
  62. package/dist/component/retention.d.ts +4 -4
  63. package/dist/component/retention.d.ts.map +1 -1
  64. package/dist/component/retention.js +54 -22
  65. package/dist/component/retention.js.map +1 -1
  66. package/dist/component/sanitize.d.ts.map +1 -1
  67. package/dist/component/sanitize.js +42 -11
  68. package/dist/component/sanitize.js.map +1 -1
  69. package/dist/component/schema.d.ts +83 -10
  70. package/dist/component/schema.js +34 -1
  71. package/dist/component/schema.js.map +1 -1
  72. package/dist/component/sites.d.ts.map +1 -1
  73. package/dist/component/sites.js +6 -6
  74. package/dist/component/sites.js.map +1 -1
  75. package/dist/component/useragent.d.ts +9 -0
  76. package/dist/component/useragent.d.ts.map +1 -0
  77. package/dist/component/useragent.js +152 -0
  78. package/dist/component/useragent.js.map +1 -0
  79. package/dist/component/validators.d.ts +29 -18
  80. package/dist/component/validators.d.ts.map +1 -1
  81. package/dist/component/validators.js +8 -2
  82. package/dist/component/validators.js.map +1 -1
  83. package/dist/component/visitors.d.ts +19 -0
  84. package/dist/component/visitors.d.ts.map +1 -0
  85. package/dist/component/visitors.js +86 -0
  86. package/dist/component/visitors.js.map +1 -0
  87. package/dist/component/vitals.d.ts +41 -0
  88. package/dist/component/vitals.d.ts.map +1 -0
  89. package/dist/component/vitals.js +115 -0
  90. package/dist/component/vitals.js.map +1 -0
  91. package/dist/react/index.d.ts.map +1 -1
  92. package/dist/react/index.js.map +1 -1
  93. package/dist/tracker/generated.d.ts +8 -4
  94. package/dist/tracker/generated.d.ts.map +1 -1
  95. package/dist/tracker/generated.js +8 -4
  96. package/dist/tracker/generated.js.map +1 -1
  97. package/dist/tracker/tracker.d.ts.map +1 -1
  98. package/dist/tracker/tracker.js +23 -11
  99. package/dist/tracker/tracker.js.map +1 -1
  100. package/dist/tracker/vitals.d.ts +10 -0
  101. package/dist/tracker/vitals.d.ts.map +1 -0
  102. package/dist/tracker/vitals.js +137 -0
  103. package/dist/tracker/vitals.js.map +1 -0
  104. package/dist/tracker.min.js +1 -1
  105. package/dist/vitals.min.js +1 -0
  106. package/docs/benchmarks/2026-08-20-realistic.md +71 -71
  107. package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
  108. package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
  109. package/docs/federation.md +1 -1
  110. package/package.json +27 -9
  111. package/scripts/benchmark-ingest.mjs +101 -48
  112. package/src/component/_generated/api.ts +8 -0
  113. package/src/component/_generated/component.ts +87 -5
  114. package/src/component/affiliates.ts +20 -5
  115. package/src/component/cardinality.ts +8 -7
  116. package/src/component/constants.ts +12 -0
  117. package/src/component/diagnostics.ts +3 -2
  118. package/src/component/eventStore.ts +53 -46
  119. package/src/component/funnels.ts +19 -16
  120. package/src/component/geo.ts +16 -14
  121. package/src/component/goals.ts +29 -21
  122. package/src/component/guards.ts +3 -1
  123. package/src/component/http.ts +126 -78
  124. package/src/component/identity.ts +74 -0
  125. package/src/component/ingest.ts +349 -130
  126. package/src/component/live.ts +5 -4
  127. package/src/component/reports.ts +572 -221
  128. package/src/component/retention.ts +221 -98
  129. package/src/component/sanitize.ts +77 -27
  130. package/src/component/schema.ts +46 -5
  131. package/src/component/sites.ts +22 -11
  132. package/src/component/useragent.ts +171 -0
  133. package/src/component/validators.ts +19 -7
  134. package/src/component/visitors.ts +116 -0
  135. package/src/component/vitals.ts +146 -0
@@ -8,10 +8,7 @@ import {
8
8
  sanitizePath,
9
9
  sanitizeSlug,
10
10
  } from "./sanitize.js";
11
- import {
12
- goalFieldsValidator,
13
- goalMatchTypeValidator,
14
- } from "./validators.js";
11
+ import { goalFieldsValidator, goalMatchTypeValidator } from "./validators.js";
15
12
 
16
13
  const goalDocumentValidator = goalFieldsValidator.extend({
17
14
  _id: v.id("goals"),
@@ -31,14 +28,16 @@ export const upsert = mutation({
31
28
  },
32
29
  returns: v.id("goals"),
33
30
  handler: async (ctx, args) => {
34
- if (!(await ctx.db.get("sites", args.siteId))) fail("NOT_FOUND", "site not found");
31
+ if (!(await ctx.db.get("sites", args.siteId)))
32
+ fail("NOT_FOUND", "site not found");
35
33
  let key: string;
36
34
  let matchValue: string;
37
35
  try {
38
36
  key = sanitizeSlug(args.key, "key");
39
- matchValue = args.matchType === "pageview"
40
- ? sanitizePath(args.matchValue)
41
- : cleanString(args.matchValue, 80);
37
+ matchValue =
38
+ args.matchType === "pageview"
39
+ ? sanitizePath(args.matchValue)
40
+ : cleanString(args.matchValue, 80);
42
41
  } catch (error) {
43
42
  fail(
44
43
  "INVALID_ARGUMENT",
@@ -49,7 +48,10 @@ export const upsert = mutation({
49
48
  if (!name) fail("INVALID_ARGUMENT", "goal name is required");
50
49
  if (!matchValue) fail("INVALID_ARGUMENT", "goal match value is required");
51
50
  if (args.valueCents !== undefined && !isValidMoney(args.valueCents)) {
52
- fail("INVALID_ARGUMENT", "valueCents must be an integer between 0 and 1000000000");
51
+ fail(
52
+ "INVALID_ARGUMENT",
53
+ "valueCents must be an integer between 0 and 1000000000",
54
+ );
53
55
  }
54
56
 
55
57
  const existingByKey = await ctx.db
@@ -69,14 +71,16 @@ export const upsert = mutation({
69
71
  const now = Date.now();
70
72
  if (goalId) {
71
73
  const goal = await ctx.db.get("goals", goalId);
72
- if (!goal || goal.siteId !== args.siteId) fail("NOT_FOUND", "goal not found");
74
+ if (!goal || goal.siteId !== args.siteId)
75
+ fail("NOT_FOUND", "goal not found");
73
76
  if (
74
77
  (args.active ?? goal.active) &&
75
- goals.some((candidate) =>
76
- candidate._id !== goalId &&
77
- candidate.active &&
78
- candidate.matchType === args.matchType &&
79
- candidate.matchValue === matchValue
78
+ goals.some(
79
+ (candidate) =>
80
+ candidate._id !== goalId &&
81
+ candidate.active &&
82
+ candidate.matchType === args.matchType &&
83
+ candidate.matchValue === matchValue,
80
84
  )
81
85
  ) {
82
86
  fail("CONFLICT", "another active goal already uses this matcher");
@@ -95,16 +99,19 @@ export const upsert = mutation({
95
99
 
96
100
  if (
97
101
  (args.active ?? true) &&
98
- goals.some((candidate) =>
99
- candidate.active &&
100
- candidate.matchType === args.matchType &&
101
- candidate.matchValue === matchValue
102
+ goals.some(
103
+ (candidate) =>
104
+ candidate.active &&
105
+ candidate.matchType === args.matchType &&
106
+ candidate.matchValue === matchValue,
102
107
  )
103
108
  ) {
104
109
  fail("CONFLICT", "another active goal already uses this matcher");
105
110
  }
106
111
  if (goals.length >= MAX_GOALS_PER_SITE) {
107
- fail("LIMIT_EXCEEDED", "goal limit reached", { limit: MAX_GOALS_PER_SITE });
112
+ fail("LIMIT_EXCEEDED", "goal limit reached", {
113
+ limit: MAX_GOALS_PER_SITE,
114
+ });
108
115
  }
109
116
  return await ctx.db.insert("goals", {
110
117
  siteId: args.siteId,
@@ -136,7 +143,8 @@ export const remove = mutation({
136
143
  returns: v.null(),
137
144
  handler: async (ctx, args) => {
138
145
  const goal = await ctx.db.get("goals", args.goalId);
139
- if (!goal || goal.siteId !== args.siteId) fail("NOT_FOUND", "goal not found");
146
+ if (!goal || goal.siteId !== args.siteId)
147
+ fail("NOT_FOUND", "goal not found");
140
148
  await ctx.db.delete("goals", args.goalId);
141
149
  return null;
142
150
  },
@@ -1,3 +1,5 @@
1
- export function isPlainRecord(value: unknown): value is Record<string, unknown> {
1
+ export function isPlainRecord(
2
+ value: unknown,
3
+ ): value is Record<string, unknown> {
2
4
  return typeof value === "object" && value !== null && !Array.isArray(value);
3
5
  }
@@ -20,15 +20,22 @@ import {
20
20
  } from "./geo.js";
21
21
  import { isPlainRecord } from "./guards.js";
22
22
  import { sanitizeEvent } from "./sanitize.js";
23
+ import { deriveVisitorKey, randomSecret } from "./identity.js";
24
+ import { classifyClient, isKnownBot } from "./useragent.js";
23
25
  import type { TrackerEvent } from "./validators.js";
24
26
  import {
25
27
  TRACKER_GZIP_BASE64,
26
28
  TRACKER_GZIP_BYTES,
29
+ VITALS_GZIP_BASE64,
30
+ VITALS_GZIP_BYTES,
27
31
  } from "../tracker/generated.js";
28
32
 
29
33
  const TRACKER_GZIP = Uint8Array.from(atob(TRACKER_GZIP_BASE64), (character) =>
30
34
  character.charCodeAt(0),
31
35
  );
36
+ const VITALS_GZIP = Uint8Array.from(atob(VITALS_GZIP_BASE64), (character) =>
37
+ character.charCodeAt(0),
38
+ );
32
39
 
33
40
  const http = httpRouter();
34
41
 
@@ -88,9 +95,34 @@ http.route({
88
95
  const edgeGeo = proxyRequestTrusted ? geoFromHeaders(request) : {};
89
96
  const userAgent =
90
97
  metadata.userAgent ?? request.headers.get("user-agent") ?? "";
98
+ // Self-declared bots never reach a transaction; the response keeps the
99
+ // counter shape the tracker expects and names the reason in a header.
100
+ if (isKnownBot(userAgent)) {
101
+ return json(
102
+ { accepted: 0, duplicates: 0, rejected: payload.events.length },
103
+ 200,
104
+ { ...headers, "X-Rastro-Rejected": "bot" },
105
+ );
106
+ }
107
+ // Anonymous batches (visitorId === sessionId) get a salted daily key so
108
+ // repeat visits count once; the IP itself stops here.
109
+ const anonymous = payload.events.some(
110
+ (event) => event.visitorId === event.sessionId,
111
+ );
112
+ const visitorKey =
113
+ anonymous && clientIp.ip && userAgent
114
+ ? await deriveVisitorKey({
115
+ secret: await visitorSecret(ctx),
116
+ now: Date.now(),
117
+ siteId: payload.siteId,
118
+ ip: clientIp.ip,
119
+ userAgent,
120
+ })
121
+ : undefined;
91
122
  const context = {
92
123
  ...edgeGeo,
93
- ...classifyUserAgent(userAgent),
124
+ ...classifyClient(userAgent, request.headers),
125
+ ...(visitorKey ? { visitorKey } : {}),
94
126
  };
95
127
 
96
128
  try {
@@ -110,14 +142,11 @@ http.route({
110
142
  if (events) {
111
143
  let stage: GeoIpFailureStage = "claim";
112
144
  try {
113
- const claim = await ctx.runMutation(
114
- internal.geo.claimEnrichment,
115
- {
116
- siteId: payload.siteId as Id<"sites">,
117
- events,
118
- dailyLimit: geoIpDailyLimit(env.RASTRO_GEOIP_DAILY_LIMIT),
119
- },
120
- );
145
+ const claim = await ctx.runMutation(internal.geo.claimEnrichment, {
146
+ siteId: payload.siteId as Id<"sites">,
147
+ events,
148
+ dailyLimit: geoIpDailyLimit(env.RASTRO_GEOIP_DAILY_LIMIT),
149
+ });
121
150
  if (claim.sessionIds.length > 0) {
122
151
  stage = "resolve";
123
152
  const providerGeo = await resolveGeoIp(
@@ -134,19 +163,11 @@ http.route({
134
163
  context: providerGeo,
135
164
  });
136
165
  } else {
137
- logGeoIpFailure(
138
- requestId,
139
- stage,
140
- env.RASTRO_GEOIP_PROVIDER!,
141
- );
166
+ logGeoIpFailure(requestId, stage, env.RASTRO_GEOIP_PROVIDER!);
142
167
  }
143
168
  }
144
169
  } catch {
145
- logGeoIpFailure(
146
- requestId,
147
- stage,
148
- env.RASTRO_GEOIP_PROVIDER!,
149
- );
170
+ logGeoIpFailure(requestId, stage, env.RASTRO_GEOIP_PROVIDER!);
150
171
  }
151
172
  }
152
173
  }
@@ -166,9 +187,7 @@ http.route({
166
187
  status === 429
167
188
  ? {
168
189
  ...headers,
169
- "Retry-After": String(
170
- Math.ceil(RATE_LIMIT_WINDOW_MS / 1_000),
171
- ),
190
+ "Retry-After": String(Math.ceil(RATE_LIMIT_WINDOW_MS / 1_000)),
172
191
  }
173
192
  : headers,
174
193
  );
@@ -218,6 +237,24 @@ http.route({
218
237
  }),
219
238
  });
220
239
 
240
+ http.route({
241
+ path: "/vitals.js",
242
+ method: "GET",
243
+ handler: httpAction(async () => {
244
+ return new Response(VITALS_GZIP, {
245
+ status: 200,
246
+ headers: {
247
+ "Content-Type": "text/javascript; charset=utf-8",
248
+ "Content-Encoding": "gzip",
249
+ "Content-Length": String(VITALS_GZIP_BYTES),
250
+ "Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
251
+ "Access-Control-Allow-Origin": "*",
252
+ Vary: "Accept-Encoding",
253
+ },
254
+ });
255
+ }),
256
+ });
257
+
221
258
  http.route({
222
259
  path: "/health",
223
260
  method: "GET",
@@ -244,7 +281,17 @@ http.route({
244
281
  trustedProxy: trustProxy,
245
282
  proxyRequestTrusted,
246
283
  edgeHeadersAvailable:
247
- proxyRequestTrusted && Object.keys(geoFromHeaders(request)).length > 0,
284
+ proxyRequestTrusted &&
285
+ Object.keys(geoFromHeaders(request)).length > 0,
286
+ },
287
+ features: {
288
+ botDetection: "userAgent",
289
+ uniqueVisitors:
290
+ clientIp.ip &&
291
+ (metadata.userAgent ?? request.headers.get("user-agent"))
292
+ ? "dailyHash"
293
+ : "session",
294
+ webVitals: "optIn",
248
295
  },
249
296
  },
250
297
  200,
@@ -308,7 +355,8 @@ function parseEvent(value: unknown): TrackerEvent {
308
355
  type !== "custom" &&
309
356
  type !== "conversion" &&
310
357
  type !== "heartbeat" &&
311
- type !== "outbound"
358
+ type !== "outbound" &&
359
+ type !== "vital"
312
360
  ) {
313
361
  throw new Error("event type is invalid");
314
362
  }
@@ -353,6 +401,9 @@ function parseEvent(value: unknown): TrackerEvent {
353
401
  ...(optionalString(value.affiliateSlug, "affiliateSlug") !== undefined
354
402
  ? { affiliateSlug: optionalString(value.affiliateSlug, "affiliateSlug") }
355
403
  : {}),
404
+ ...(optionalNumber(value.value, "value") !== undefined
405
+ ? { value: optionalNumber(value.value, "value") }
406
+ : {}),
356
407
  };
357
408
  }
358
409
 
@@ -446,9 +497,7 @@ function geoFromHeaders(request: Request): {
446
497
  return {
447
498
  ...(country ? { country } : {}),
448
499
  ...(city ? { city } : {}),
449
- ...(validCoordinates(latitude, longitude)
450
- ? { latitude, longitude }
451
- : {}),
500
+ ...(validCoordinates(latitude, longitude) ? { latitude, longitude } : {}),
452
501
  };
453
502
  }
454
503
 
@@ -463,12 +512,14 @@ function validCoordinates(
463
512
  latitude: number | undefined,
464
513
  longitude: number | undefined,
465
514
  ): latitude is number {
466
- return latitude !== undefined &&
515
+ return (
516
+ latitude !== undefined &&
467
517
  longitude !== undefined &&
468
518
  latitude >= -90 &&
469
519
  latitude <= 90 &&
470
520
  longitude >= -180 &&
471
- longitude <= 180;
521
+ longitude <= 180
522
+ );
472
523
  }
473
524
 
474
525
  function firstHeader(request: Request, names: string[]): string | undefined {
@@ -495,49 +546,18 @@ function decodeHeader(value: string | undefined): string | undefined {
495
546
  }
496
547
  }
497
548
 
498
- function classifyUserAgent(userAgent: string): {
499
- browser: string;
500
- os: string;
501
- device: string;
502
- } {
503
- const browser = /Edg\//.test(userAgent)
504
- ? "Edge"
505
- : /Firefox\//.test(userAgent)
506
- ? "Firefox"
507
- : /Chrome\//.test(userAgent)
508
- ? "Chrome"
509
- : /Safari\//.test(userAgent)
510
- ? "Safari"
511
- : "Other";
512
- const os = /Android/.test(userAgent)
513
- ? "Android"
514
- : /iPhone|iPad/.test(userAgent)
515
- ? "iOS"
516
- : /Windows/.test(userAgent)
517
- ? "Windows"
518
- : /Mac OS X/.test(userAgent)
519
- ? "macOS"
520
- : /Linux/.test(userAgent)
521
- ? "Linux"
522
- : "Other";
523
- const device = /iPad|Tablet/.test(userAgent)
524
- ? "tablet"
525
- : /Mobile|Android|iPhone/.test(userAgent)
526
- ? "mobile"
527
- : "desktop";
528
- return { browser, os, device };
529
- }
530
-
531
549
  type GeoIpFailureStage = "claim" | "resolve" | "apply";
532
550
 
533
551
  function logInternalHttpError(requestId: string): void {
534
- console.error(JSON.stringify({
535
- level: "error",
536
- event: "rastro.http.internal_error",
537
- requestId,
538
- route: "/events",
539
- method: "POST",
540
- }));
552
+ console.error(
553
+ JSON.stringify({
554
+ level: "error",
555
+ event: "rastro.http.internal_error",
556
+ requestId,
557
+ route: "/events",
558
+ method: "POST",
559
+ }),
560
+ );
541
561
  }
542
562
 
543
563
  function logGeoIpFailure(
@@ -545,21 +565,26 @@ function logGeoIpFailure(
545
565
  stage: GeoIpFailureStage,
546
566
  provider: "ipinfo" | "ipwhois",
547
567
  ): void {
548
- console.warn(JSON.stringify({
549
- level: "warn",
550
- event: "rastro.geoip.failure",
551
- requestId,
552
- route: "/events",
553
- stage,
554
- provider,
555
- }));
568
+ console.warn(
569
+ JSON.stringify({
570
+ level: "warn",
571
+ event: "rastro.geoip.failure",
572
+ requestId,
573
+ route: "/events",
574
+ stage,
575
+ provider,
576
+ }),
577
+ );
556
578
  }
557
579
 
558
580
  function requestIdHeaders(requestId: string): Record<string, string> {
559
581
  return { "X-Request-ID": requestId };
560
582
  }
561
583
 
562
- function corsHeaders(origin: string, requestId?: string): Record<string, string> {
584
+ function corsHeaders(
585
+ origin: string,
586
+ requestId?: string,
587
+ ): Record<string, string> {
563
588
  return {
564
589
  "Access-Control-Allow-Origin": origin,
565
590
  "Access-Control-Allow-Methods": "POST, OPTIONS",
@@ -579,3 +604,26 @@ function json(value: unknown, status: number, headers?: HeadersInit): Response {
579
604
  }
580
605
 
581
606
  export default http;
607
+
608
+ let cachedVisitorSecret: string | undefined;
609
+
610
+ /** The per-deployment salt behind anonymous visitor keys, created on first use. */
611
+ async function visitorSecret(ctx: {
612
+ runQuery: (
613
+ reference: typeof internal.identity.secret,
614
+ args: Record<string, never>,
615
+ ) => Promise<string | null>;
616
+ runMutation: (
617
+ reference: typeof internal.identity.ensureSecret,
618
+ args: { candidate: string },
619
+ ) => Promise<string>;
620
+ }): Promise<string> {
621
+ if (cachedVisitorSecret) return cachedVisitorSecret;
622
+ const stored = await ctx.runQuery(internal.identity.secret, {});
623
+ cachedVisitorSecret =
624
+ stored ??
625
+ (await ctx.runMutation(internal.identity.ensureSecret, {
626
+ candidate: randomSecret(),
627
+ }));
628
+ return cachedVisitorSecret;
629
+ }
@@ -0,0 +1,74 @@
1
+ import { v } from "convex/values";
2
+ import { DAY_MS } from "./constants.js";
3
+ import { internalMutation, internalQuery } from "./_generated/server.js";
4
+
5
+ /**
6
+ * Anonymous visitors (no host-supplied id) are counted through a salted hash of
7
+ * the request IP and User-Agent that rotates every UTC day. The hash is derived
8
+ * in the HTTP action, so the raw IP never reaches a mutation or a table, and the
9
+ * salt is a per-deployment secret, so nobody holding a key can walk the IPv4
10
+ * space back to an address. Same device and network on the same day count once;
11
+ * the next day they are a new visitor by design.
12
+ */
13
+ const KEY_HEX_LENGTH = 24;
14
+ const SECRET_PATTERN = /^[0-9a-f]{64}$/;
15
+
16
+ export const secret = internalQuery({
17
+ args: {},
18
+ returns: v.union(v.string(), v.null()),
19
+ handler: async (ctx) => {
20
+ const row = await ctx.db
21
+ .query("visitorSecrets")
22
+ .withIndex("by_key", (range) => range.eq("key", "current"))
23
+ .unique();
24
+ return row?.value ?? null;
25
+ },
26
+ });
27
+
28
+ export const ensureSecret = internalMutation({
29
+ args: { candidate: v.string() },
30
+ returns: v.string(),
31
+ handler: async (ctx, args) => {
32
+ const existing = await ctx.db
33
+ .query("visitorSecrets")
34
+ .withIndex("by_key", (range) => range.eq("key", "current"))
35
+ .unique();
36
+ if (existing) return existing.value;
37
+ if (!SECRET_PATTERN.test(args.candidate)) {
38
+ throw new Error("visitor secret candidate must be 64 hex characters");
39
+ }
40
+ await ctx.db.insert("visitorSecrets", {
41
+ key: "current",
42
+ value: args.candidate,
43
+ createdAt: Date.now(),
44
+ });
45
+ return args.candidate;
46
+ },
47
+ });
48
+
49
+ export function randomSecret(): string {
50
+ return hex(crypto.getRandomValues(new Uint8Array(32)));
51
+ }
52
+
53
+ export async function deriveVisitorKey(input: {
54
+ secret: string;
55
+ now: number;
56
+ siteId: string;
57
+ ip: string;
58
+ userAgent: string;
59
+ }): Promise<string> {
60
+ const dayStart = Math.floor(input.now / DAY_MS) * DAY_MS;
61
+ const material = new TextEncoder().encode(
62
+ [input.secret, dayStart, input.siteId, input.ip, input.userAgent].join(" "),
63
+ );
64
+ const digest = new Uint8Array(
65
+ await crypto.subtle.digest("SHA-256", material),
66
+ );
67
+ return hex(digest).slice(0, KEY_HEX_LENGTH);
68
+ }
69
+
70
+ function hex(bytes: Uint8Array): string {
71
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
72
+ "",
73
+ );
74
+ }