@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.
- package/README.md +203 -56
- package/agent/integration.md +17 -15
- package/agent/manifest.json +6 -4
- package/agent/manifest.schema.json +8 -5
- package/dist/client/federation.d.ts +34 -8
- package/dist/client/federation.d.ts.map +1 -1
- package/dist/client/federation.js +17 -3
- package/dist/client/federation.js.map +1 -1
- package/dist/client/index.d.ts +486 -31
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +209 -41
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +8 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +82 -8
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/affiliates.d.ts.map +1 -1
- package/dist/component/affiliates.js +6 -2
- package/dist/component/affiliates.js.map +1 -1
- package/dist/component/cardinality.d.ts.map +1 -1
- package/dist/component/cardinality.js.map +1 -1
- package/dist/component/constants.d.ts +6 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +12 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/diagnostics.d.ts.map +1 -1
- package/dist/component/diagnostics.js.map +1 -1
- package/dist/component/eventStore.d.ts +3 -1
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/eventStore.js +13 -10
- package/dist/component/eventStore.js.map +1 -1
- package/dist/component/funnels.d.ts.map +1 -1
- package/dist/component/funnels.js +5 -3
- package/dist/component/funnels.js.map +1 -1
- package/dist/component/geo.d.ts.map +1 -1
- package/dist/component/geo.js +3 -2
- package/dist/component/geo.js.map +1 -1
- package/dist/component/goals.d.ts.map +1 -1
- package/dist/component/goals.js +8 -5
- package/dist/component/goals.js.map +1 -1
- package/dist/component/guards.d.ts.map +1 -1
- package/dist/component/guards.js.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +71 -37
- package/dist/component/http.js.map +1 -1
- package/dist/component/identity.d.ts +13 -0
- package/dist/component/identity.d.ts.map +1 -0
- package/dist/component/identity.js +58 -0
- package/dist/component/identity.js.map +1 -0
- package/dist/component/ingest.d.ts +3 -1
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +168 -29
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +1 -3
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +70 -4
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +232 -59
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +4 -4
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +54 -22
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +42 -11
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +83 -10
- package/dist/component/schema.js +34 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +6 -6
- package/dist/component/sites.js.map +1 -1
- package/dist/component/useragent.d.ts +9 -0
- package/dist/component/useragent.d.ts.map +1 -0
- package/dist/component/useragent.js +152 -0
- package/dist/component/useragent.js.map +1 -0
- package/dist/component/validators.d.ts +29 -18
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +8 -2
- package/dist/component/validators.js.map +1 -1
- package/dist/component/visitors.d.ts +19 -0
- package/dist/component/visitors.d.ts.map +1 -0
- package/dist/component/visitors.js +86 -0
- package/dist/component/visitors.js.map +1 -0
- package/dist/component/vitals.d.ts +41 -0
- package/dist/component/vitals.d.ts.map +1 -0
- package/dist/component/vitals.js +115 -0
- package/dist/component/vitals.js.map +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/tracker/generated.d.ts +8 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +8 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.d.ts.map +1 -1
- package/dist/tracker/tracker.js +23 -11
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker/vitals.d.ts +10 -0
- package/dist/tracker/vitals.d.ts.map +1 -0
- package/dist/tracker/vitals.js +137 -0
- package/dist/tracker/vitals.js.map +1 -0
- package/dist/tracker.min.js +1 -1
- package/dist/vitals.min.js +1 -0
- package/docs/benchmarks/2026-08-20-realistic.md +71 -71
- package/docs/benchmarks/2026-08-21-formal-certification.md +50 -30
- package/docs/benchmarks/2026-08-30-alpha6-recertification.md +206 -0
- package/docs/federation.md +1 -1
- package/package.json +27 -9
- package/scripts/benchmark-ingest.mjs +101 -48
- package/src/component/_generated/api.ts +8 -0
- package/src/component/_generated/component.ts +87 -5
- package/src/component/affiliates.ts +20 -5
- package/src/component/cardinality.ts +8 -7
- package/src/component/constants.ts +12 -0
- package/src/component/diagnostics.ts +3 -2
- package/src/component/eventStore.ts +53 -46
- package/src/component/funnels.ts +19 -16
- package/src/component/geo.ts +16 -14
- package/src/component/goals.ts +29 -21
- package/src/component/guards.ts +3 -1
- package/src/component/http.ts +126 -78
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +349 -130
- package/src/component/live.ts +5 -4
- package/src/component/reports.ts +572 -221
- package/src/component/retention.ts +221 -98
- package/src/component/sanitize.ts +77 -27
- package/src/component/schema.ts +46 -5
- package/src/component/sites.ts +22 -11
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +19 -7
- package/src/component/visitors.ts +116 -0
- package/src/component/vitals.ts +146 -0
package/src/component/goals.ts
CHANGED
|
@@ -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)))
|
|
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 =
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
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)
|
|
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(
|
|
76
|
-
candidate
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
99
|
-
candidate
|
|
100
|
-
|
|
101
|
-
|
|
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", {
|
|
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)
|
|
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
|
},
|
package/src/component/guards.ts
CHANGED
package/src/component/http.ts
CHANGED
|
@@ -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
|
-
...
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 &&
|
|
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
|
|
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(
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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(
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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(
|
|
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
|
+
}
|