@iann29/rastro 0.1.0-alpha.3 → 0.1.0-alpha.5
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 +374 -62
- package/agent/integration.md +651 -0
- package/agent/manifest.json +183 -0
- package/agent/manifest.schema.json +405 -0
- package/dist/client/federation.d.ts +205 -0
- package/dist/client/federation.d.ts.map +1 -0
- package/dist/client/federation.js +179 -0
- package/dist/client/federation.js.map +1 -0
- package/dist/client/index.d.ts +1658 -8
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +228 -2
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +10 -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 +169 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/cardinality.d.ts +12 -0
- package/dist/component/cardinality.d.ts.map +1 -0
- package/dist/component/cardinality.js +94 -0
- package/dist/component/cardinality.js.map +1 -0
- package/dist/component/constants.d.ts +11 -0
- package/dist/component/constants.d.ts.map +1 -1
- package/dist/component/constants.js +11 -0
- package/dist/component/constants.js.map +1 -1
- package/dist/component/coverage.d.ts +18 -0
- package/dist/component/coverage.d.ts.map +1 -0
- package/dist/component/coverage.js +19 -0
- package/dist/component/coverage.js.map +1 -0
- package/dist/component/errors.d.ts +1 -1
- package/dist/component/errors.d.ts.map +1 -1
- package/dist/component/errors.js.map +1 -1
- package/dist/component/eventStore.d.ts +1 -0
- package/dist/component/eventStore.d.ts.map +1 -1
- package/dist/component/http.d.ts.map +1 -1
- package/dist/component/http.js +85 -36
- 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 +1 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/component/ingest.js +258 -19
- package/dist/component/ingest.js.map +1 -1
- package/dist/component/live.d.ts.map +1 -1
- package/dist/component/live.js +6 -3
- package/dist/component/live.js.map +1 -1
- package/dist/component/reports.d.ts +120 -2
- package/dist/component/reports.d.ts.map +1 -1
- package/dist/component/reports.js +659 -91
- package/dist/component/reports.js.map +1 -1
- package/dist/component/retention.d.ts +75 -1
- package/dist/component/retention.d.ts.map +1 -1
- package/dist/component/retention.js +517 -54
- package/dist/component/retention.js.map +1 -1
- package/dist/component/sanitize.d.ts +4 -1
- package/dist/component/sanitize.d.ts.map +1 -1
- package/dist/component/sanitize.js +13 -5
- package/dist/component/sanitize.js.map +1 -1
- package/dist/component/schema.d.ts +224 -7
- package/dist/component/schema.js +113 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/sites.d.ts.map +1 -1
- package/dist/component/sites.js +5 -1
- 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 +42 -11
- package/dist/component/validators.d.ts.map +1 -1
- package/dist/component/validators.js +10 -1
- 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/tracker/generated.d.ts +4 -4
- package/dist/tracker/generated.d.ts.map +1 -1
- package/dist/tracker/generated.js +4 -4
- package/dist/tracker/generated.js.map +1 -1
- package/dist/tracker/tracker.js +23 -11
- package/dist/tracker/tracker.js.map +1 -1
- package/dist/tracker.min.js +1 -1
- package/docs/benchmarks/2026-08-20-realistic.md +4 -4
- package/docs/benchmarks/2026-08-21-formal-certification.md +333 -0
- package/docs/federation-setup.md +395 -0
- package/docs/federation.md +258 -0
- package/docs/upgrading.md +130 -0
- package/llms.txt +65 -0
- package/package.json +41 -11
- package/scripts/generate-federation-keys.mjs +20 -0
- package/src/component/_generated/api.ts +10 -0
- package/src/component/_generated/component.ts +228 -1
- package/src/component/cardinality.ts +116 -0
- package/src/component/constants.ts +11 -0
- package/src/component/coverage.ts +25 -0
- package/src/component/errors.ts +2 -1
- package/src/component/http.ts +129 -38
- package/src/component/identity.ts +74 -0
- package/src/component/ingest.ts +414 -13
- package/src/component/live.ts +8 -3
- package/src/component/reports.ts +910 -98
- package/src/component/retention.ts +624 -88
- package/src/component/sanitize.ts +19 -5
- package/src/component/schema.ts +125 -0
- package/src/component/sites.ts +5 -1
- package/src/component/useragent.ts +171 -0
- package/src/component/validators.ts +11 -0
- package/src/component/visitors.ts +105 -0
package/src/component/http.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
MAX_BATCH_BYTES,
|
|
8
8
|
MAX_BATCH_EVENT_GROUPS,
|
|
9
9
|
MAX_BATCH_EVENTS,
|
|
10
|
+
RATE_LIMIT_WINDOW_MS,
|
|
10
11
|
} from "./constants.js";
|
|
11
12
|
import {
|
|
12
13
|
classifyIp,
|
|
@@ -19,6 +20,8 @@ import {
|
|
|
19
20
|
} from "./geo.js";
|
|
20
21
|
import { isPlainRecord } from "./guards.js";
|
|
21
22
|
import { sanitizeEvent } from "./sanitize.js";
|
|
23
|
+
import { deriveVisitorKey, randomSecret } from "./identity.js";
|
|
24
|
+
import { classifyClient, isKnownBot } from "./useragent.js";
|
|
22
25
|
import type { TrackerEvent } from "./validators.js";
|
|
23
26
|
import {
|
|
24
27
|
TRACKER_GZIP_BASE64,
|
|
@@ -35,9 +38,16 @@ http.route({
|
|
|
35
38
|
path: "/events",
|
|
36
39
|
method: "POST",
|
|
37
40
|
handler: httpAction(async (ctx, request) => {
|
|
41
|
+
const requestId = crypto.randomUUID();
|
|
38
42
|
const origin = request.headers.get("origin");
|
|
39
|
-
if (!origin)
|
|
40
|
-
|
|
43
|
+
if (!origin) {
|
|
44
|
+
return json(
|
|
45
|
+
{ error: { code: "ORIGIN_REQUIRED" } },
|
|
46
|
+
400,
|
|
47
|
+
requestIdHeaders(requestId),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const headers = corsHeaders(origin, requestId);
|
|
41
51
|
const contentLength = Number(request.headers.get("content-length"));
|
|
42
52
|
if (Number.isFinite(contentLength) && contentLength > MAX_BATCH_BYTES) {
|
|
43
53
|
return json(
|
|
@@ -80,9 +90,34 @@ http.route({
|
|
|
80
90
|
const edgeGeo = proxyRequestTrusted ? geoFromHeaders(request) : {};
|
|
81
91
|
const userAgent =
|
|
82
92
|
metadata.userAgent ?? request.headers.get("user-agent") ?? "";
|
|
93
|
+
// Self-declared bots never reach a transaction; the response keeps the
|
|
94
|
+
// counter shape the tracker expects and names the reason in a header.
|
|
95
|
+
if (isKnownBot(userAgent)) {
|
|
96
|
+
return json(
|
|
97
|
+
{ accepted: 0, duplicates: 0, rejected: payload.events.length },
|
|
98
|
+
200,
|
|
99
|
+
{ ...headers, "X-Rastro-Rejected": "bot" },
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
// Anonymous batches (visitorId === sessionId) get a salted daily key so
|
|
103
|
+
// repeat visits count once; the IP itself stops here.
|
|
104
|
+
const anonymous = payload.events.some(
|
|
105
|
+
(event) => event.visitorId === event.sessionId,
|
|
106
|
+
);
|
|
107
|
+
const visitorKey =
|
|
108
|
+
anonymous && clientIp.ip && userAgent
|
|
109
|
+
? await deriveVisitorKey({
|
|
110
|
+
secret: await visitorSecret(ctx),
|
|
111
|
+
now: Date.now(),
|
|
112
|
+
siteId: payload.siteId,
|
|
113
|
+
ip: clientIp.ip,
|
|
114
|
+
userAgent,
|
|
115
|
+
})
|
|
116
|
+
: undefined;
|
|
83
117
|
const context = {
|
|
84
118
|
...edgeGeo,
|
|
85
|
-
...
|
|
119
|
+
...classifyClient(userAgent, request.headers),
|
|
120
|
+
...(visitorKey ? { visitorKey } : {}),
|
|
86
121
|
};
|
|
87
122
|
|
|
88
123
|
try {
|
|
@@ -100,6 +135,7 @@ http.route({
|
|
|
100
135
|
) {
|
|
101
136
|
const events = enrichmentCandidateEvents(payload.events);
|
|
102
137
|
if (events) {
|
|
138
|
+
let stage: GeoIpFailureStage = "claim";
|
|
103
139
|
try {
|
|
104
140
|
const claim = await ctx.runMutation(
|
|
105
141
|
internal.geo.claimEnrichment,
|
|
@@ -110,22 +146,34 @@ http.route({
|
|
|
110
146
|
},
|
|
111
147
|
);
|
|
112
148
|
if (claim.sessionIds.length > 0) {
|
|
149
|
+
stage = "resolve";
|
|
113
150
|
const providerGeo = await resolveGeoIp(
|
|
114
151
|
clientIp.ip,
|
|
115
152
|
env.RASTRO_GEOIP_PROVIDER!,
|
|
116
153
|
env.RASTRO_GEOIP_TOKEN,
|
|
117
154
|
);
|
|
118
155
|
if (providerGeo) {
|
|
156
|
+
stage = "apply";
|
|
119
157
|
await ctx.runMutation(internal.geo.applyEnrichment, {
|
|
120
158
|
siteId: payload.siteId as Id<"sites">,
|
|
121
159
|
sessionIds: claim.sessionIds,
|
|
122
160
|
events: claim.events,
|
|
123
161
|
context: providerGeo,
|
|
124
162
|
});
|
|
163
|
+
} else {
|
|
164
|
+
logGeoIpFailure(
|
|
165
|
+
requestId,
|
|
166
|
+
stage,
|
|
167
|
+
env.RASTRO_GEOIP_PROVIDER!,
|
|
168
|
+
);
|
|
125
169
|
}
|
|
126
170
|
}
|
|
127
171
|
} catch {
|
|
128
|
-
|
|
172
|
+
logGeoIpFailure(
|
|
173
|
+
requestId,
|
|
174
|
+
stage,
|
|
175
|
+
env.RASTRO_GEOIP_PROVIDER!,
|
|
176
|
+
);
|
|
129
177
|
}
|
|
130
178
|
}
|
|
131
179
|
}
|
|
@@ -139,9 +187,25 @@ http.route({
|
|
|
139
187
|
: data.code === "RATE_LIMITED"
|
|
140
188
|
? 429
|
|
141
189
|
: 400;
|
|
142
|
-
return json(
|
|
190
|
+
return json(
|
|
191
|
+
{ error: data },
|
|
192
|
+
status,
|
|
193
|
+
status === 429
|
|
194
|
+
? {
|
|
195
|
+
...headers,
|
|
196
|
+
"Retry-After": String(
|
|
197
|
+
Math.ceil(RATE_LIMIT_WINDOW_MS / 1_000),
|
|
198
|
+
),
|
|
199
|
+
}
|
|
200
|
+
: headers,
|
|
201
|
+
);
|
|
143
202
|
}
|
|
144
|
-
|
|
203
|
+
logInternalHttpError(requestId);
|
|
204
|
+
return json(
|
|
205
|
+
{ error: { code: "INTERNAL_ERROR", requestId } },
|
|
206
|
+
500,
|
|
207
|
+
headers,
|
|
208
|
+
);
|
|
145
209
|
}
|
|
146
210
|
}),
|
|
147
211
|
});
|
|
@@ -209,6 +273,13 @@ http.route({
|
|
|
209
273
|
edgeHeadersAvailable:
|
|
210
274
|
proxyRequestTrusted && Object.keys(geoFromHeaders(request)).length > 0,
|
|
211
275
|
},
|
|
276
|
+
features: {
|
|
277
|
+
botDetection: "userAgent",
|
|
278
|
+
uniqueVisitors:
|
|
279
|
+
clientIp.ip && (metadata.userAgent ?? request.headers.get("user-agent"))
|
|
280
|
+
? "dailyHash"
|
|
281
|
+
: "session",
|
|
282
|
+
},
|
|
212
283
|
},
|
|
213
284
|
200,
|
|
214
285
|
{
|
|
@@ -458,45 +529,45 @@ function decodeHeader(value: string | undefined): string | undefined {
|
|
|
458
529
|
}
|
|
459
530
|
}
|
|
460
531
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
: /
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
: "desktop";
|
|
491
|
-
return { browser, os, device };
|
|
532
|
+
type GeoIpFailureStage = "claim" | "resolve" | "apply";
|
|
533
|
+
|
|
534
|
+
function logInternalHttpError(requestId: string): void {
|
|
535
|
+
console.error(JSON.stringify({
|
|
536
|
+
level: "error",
|
|
537
|
+
event: "rastro.http.internal_error",
|
|
538
|
+
requestId,
|
|
539
|
+
route: "/events",
|
|
540
|
+
method: "POST",
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function logGeoIpFailure(
|
|
545
|
+
requestId: string,
|
|
546
|
+
stage: GeoIpFailureStage,
|
|
547
|
+
provider: "ipinfo" | "ipwhois",
|
|
548
|
+
): void {
|
|
549
|
+
console.warn(JSON.stringify({
|
|
550
|
+
level: "warn",
|
|
551
|
+
event: "rastro.geoip.failure",
|
|
552
|
+
requestId,
|
|
553
|
+
route: "/events",
|
|
554
|
+
stage,
|
|
555
|
+
provider,
|
|
556
|
+
}));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function requestIdHeaders(requestId: string): Record<string, string> {
|
|
560
|
+
return { "X-Request-ID": requestId };
|
|
492
561
|
}
|
|
493
562
|
|
|
494
|
-
function corsHeaders(origin: string):
|
|
563
|
+
function corsHeaders(origin: string, requestId?: string): Record<string, string> {
|
|
495
564
|
return {
|
|
496
565
|
"Access-Control-Allow-Origin": origin,
|
|
497
566
|
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
498
567
|
"Access-Control-Allow-Headers": "Content-Type",
|
|
568
|
+
"Access-Control-Expose-Headers": "X-Request-ID, Retry-After",
|
|
499
569
|
"Access-Control-Max-Age": "86400",
|
|
570
|
+
...(requestId ? requestIdHeaders(requestId) : {}),
|
|
500
571
|
Vary: "Origin",
|
|
501
572
|
};
|
|
502
573
|
}
|
|
@@ -509,3 +580,23 @@ function json(value: unknown, status: number, headers?: HeadersInit): Response {
|
|
|
509
580
|
}
|
|
510
581
|
|
|
511
582
|
export default http;
|
|
583
|
+
|
|
584
|
+
let cachedVisitorSecret: string | undefined;
|
|
585
|
+
|
|
586
|
+
/** The per-deployment salt behind anonymous visitor keys, created on first use. */
|
|
587
|
+
async function visitorSecret(ctx: {
|
|
588
|
+
runQuery: (reference: typeof internal.identity.secret, args: Record<string, never>) => Promise<string | null>;
|
|
589
|
+
runMutation: (
|
|
590
|
+
reference: typeof internal.identity.ensureSecret,
|
|
591
|
+
args: { candidate: string },
|
|
592
|
+
) => Promise<string>;
|
|
593
|
+
}): Promise<string> {
|
|
594
|
+
if (cachedVisitorSecret) return cachedVisitorSecret;
|
|
595
|
+
const stored = await ctx.runQuery(internal.identity.secret, {});
|
|
596
|
+
cachedVisitorSecret =
|
|
597
|
+
stored ??
|
|
598
|
+
(await ctx.runMutation(internal.identity.ensureSecret, {
|
|
599
|
+
candidate: randomSecret(),
|
|
600
|
+
}));
|
|
601
|
+
return cachedVisitorSecret;
|
|
602
|
+
}
|
|
@@ -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
|
+
}
|