@iann29/rastro 0.4.0 → 0.6.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.
Files changed (112) hide show
  1. package/README.md +146 -24
  2. package/agent/integration.md +66 -29
  3. package/agent/manifest.json +19 -8
  4. package/agent/manifest.schema.json +28 -9
  5. package/dist/client/federation.d.ts +38 -8
  6. package/dist/client/federation.d.ts.map +1 -1
  7. package/dist/client/federation.js +18 -1
  8. package/dist/client/federation.js.map +1 -1
  9. package/dist/client/identity.d.ts +11 -0
  10. package/dist/client/identity.d.ts.map +1 -0
  11. package/dist/client/identity.js +123 -0
  12. package/dist/client/identity.js.map +1 -0
  13. package/dist/client/index.d.ts +501 -11
  14. package/dist/client/index.d.ts.map +1 -1
  15. package/dist/client/index.js +220 -4
  16. package/dist/client/index.js.map +1 -1
  17. package/dist/component/_generated/api.d.ts +8 -0
  18. package/dist/component/_generated/api.d.ts.map +1 -1
  19. package/dist/component/_generated/api.js.map +1 -1
  20. package/dist/component/_generated/component.d.ts +158 -1
  21. package/dist/component/_generated/component.d.ts.map +1 -1
  22. package/dist/component/constants.d.ts +2 -0
  23. package/dist/component/constants.d.ts.map +1 -1
  24. package/dist/component/constants.js +7 -0
  25. package/dist/component/constants.js.map +1 -1
  26. package/dist/component/coverage.d.ts +1 -0
  27. package/dist/component/coverage.d.ts.map +1 -1
  28. package/dist/component/coverage.js +6 -1
  29. package/dist/component/coverage.js.map +1 -1
  30. package/dist/component/eventStore.d.ts +2 -0
  31. package/dist/component/eventStore.d.ts.map +1 -1
  32. package/dist/component/http.d.ts.map +1 -1
  33. package/dist/component/http.js +51 -1
  34. package/dist/component/http.js.map +1 -1
  35. package/dist/component/ingest.d.ts +2 -0
  36. package/dist/component/ingest.d.ts.map +1 -1
  37. package/dist/component/ingest.js +72 -22
  38. package/dist/component/ingest.js.map +1 -1
  39. package/dist/component/origin.d.ts +70 -0
  40. package/dist/component/origin.d.ts.map +1 -0
  41. package/dist/component/origin.js +230 -0
  42. package/dist/component/origin.js.map +1 -0
  43. package/dist/component/people.d.ts +79 -0
  44. package/dist/component/people.d.ts.map +1 -0
  45. package/dist/component/people.js +249 -0
  46. package/dist/component/people.js.map +1 -0
  47. package/dist/component/platforms.d.ts +33 -0
  48. package/dist/component/platforms.d.ts.map +1 -0
  49. package/dist/component/platforms.js +328 -0
  50. package/dist/component/platforms.js.map +1 -0
  51. package/dist/component/reports.d.ts +35 -74
  52. package/dist/component/reports.d.ts.map +1 -1
  53. package/dist/component/reports.js +59 -14
  54. package/dist/component/reports.js.map +1 -1
  55. package/dist/component/sanitize.d.ts +5 -0
  56. package/dist/component/sanitize.d.ts.map +1 -1
  57. package/dist/component/sanitize.js +33 -1
  58. package/dist/component/sanitize.js.map +1 -1
  59. package/dist/component/schema.d.ts +101 -7
  60. package/dist/component/schema.js +45 -1
  61. package/dist/component/schema.js.map +1 -1
  62. package/dist/component/trackedLinks.d.ts +91 -0
  63. package/dist/component/trackedLinks.d.ts.map +1 -0
  64. package/dist/component/trackedLinks.js +314 -0
  65. package/dist/component/trackedLinks.js.map +1 -0
  66. package/dist/component/useragent.d.ts +6 -0
  67. package/dist/component/useragent.d.ts.map +1 -1
  68. package/dist/component/useragent.js +9 -0
  69. package/dist/component/useragent.js.map +1 -1
  70. package/dist/component/validators.d.ts +101 -11
  71. package/dist/component/validators.d.ts.map +1 -1
  72. package/dist/component/validators.js +62 -1
  73. package/dist/component/validators.js.map +1 -1
  74. package/dist/component/visitors.d.ts +38 -2
  75. package/dist/component/visitors.d.ts.map +1 -1
  76. package/dist/component/visitors.js +162 -42
  77. package/dist/component/visitors.js.map +1 -1
  78. package/dist/react/index.d.ts +9 -5
  79. package/dist/react/index.d.ts.map +1 -1
  80. package/dist/react/index.js +36 -5
  81. package/dist/react/index.js.map +1 -1
  82. package/dist/tracker/generated.d.ts +6 -6
  83. package/dist/tracker/generated.d.ts.map +1 -1
  84. package/dist/tracker/generated.js +6 -6
  85. package/dist/tracker/generated.js.map +1 -1
  86. package/dist/tracker/tracker.d.ts +3 -1
  87. package/dist/tracker/tracker.d.ts.map +1 -1
  88. package/dist/tracker/tracker.js +56 -5
  89. package/dist/tracker/tracker.js.map +1 -1
  90. package/dist/tracker.min.js +1 -1
  91. package/docs/federation.md +24 -0
  92. package/docs/identity.md +307 -0
  93. package/docs/upgrading.md +190 -19
  94. package/llms.txt +11 -2
  95. package/package.json +5 -3
  96. package/src/component/_generated/api.ts +8 -0
  97. package/src/component/_generated/component.ts +251 -1
  98. package/src/component/constants.ts +7 -0
  99. package/src/component/coverage.ts +7 -1
  100. package/src/component/http.ts +56 -1
  101. package/src/component/ingest.ts +100 -28
  102. package/src/component/origin.ts +273 -0
  103. package/src/component/people.ts +321 -0
  104. package/src/component/platforms.ts +359 -0
  105. package/src/component/reports.ts +84 -13
  106. package/src/component/sanitize.ts +39 -1
  107. package/src/component/schema.ts +53 -0
  108. package/src/component/trackedLinks.ts +384 -0
  109. package/src/component/useragent.ts +11 -0
  110. package/src/component/validators.ts +120 -0
  111. package/src/component/visitors.ts +232 -55
  112. package/src/tracker/generated.ts +6 -6
@@ -0,0 +1,384 @@
1
+ import { v, type Infer } from "convex/values";
2
+ import { internal } from "./_generated/api.js";
3
+ import { internalMutation, mutation, query } from "./_generated/server.js";
4
+ import {
5
+ DAY_MS,
6
+ MAX_TRACKED_LINKS_PER_SITE,
7
+ TRACKED_LINK_CLICK_SHARDS,
8
+ } from "./constants.js";
9
+ import { fail } from "./errors.js";
10
+ import { cleanString, originAllowed, sanitizeOpaqueId } from "./sanitize.js";
11
+ import {
12
+ trackedLinkChannelValidator,
13
+ trackedLinkFieldsValidator,
14
+ } from "./validators.js";
15
+
16
+ const SLUG = /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/;
17
+ const PLATFORM = /^[a-z0-9_]{2,24}$/;
18
+ const MAX_DESTINATION_LENGTH = 2048;
19
+ const MAX_CAMPAIGN_LENGTH = 64;
20
+ const RECENT_DAYS = 30;
21
+ // Each shard's all-time counter sits beside the daily ones under this day, so
22
+ // a link's total is TRACKED_LINK_CLICK_SHARDS rows however long it has lived.
23
+ const ALL_TIME = 0;
24
+ const PURGE_BATCH = 500;
25
+
26
+ type Channel = Infer<typeof trackedLinkChannelValidator>;
27
+
28
+ const trackedLinkDocumentValidator = trackedLinkFieldsValidator.extend({
29
+ _id: v.id("trackedLinks"),
30
+ _creationTime: v.number(),
31
+ });
32
+
33
+ const redirectValidator = trackedLinkFieldsValidator.pick(
34
+ "slug",
35
+ "destination",
36
+ "platform",
37
+ "channel",
38
+ "campaign",
39
+ );
40
+
41
+ export function isTrackedLinkSlug(value: string): boolean {
42
+ return SLUG.test(value);
43
+ }
44
+
45
+ /**
46
+ * Where a click lands: the destination with the link's utm_* appended. A key
47
+ * the destination already carries wins, and its own query keeps its exact
48
+ * encoding because the tags are appended as text.
49
+ */
50
+ export function trackedLinkLocation(
51
+ link: Infer<typeof redirectValidator>,
52
+ ): string {
53
+ const url = new URL(link.destination);
54
+ const utm: Array<[string, string]> = [
55
+ ["utm_source", link.platform],
56
+ ["utm_medium", link.channel],
57
+ ["utm_campaign", link.campaign ?? link.slug],
58
+ ["utm_content", link.slug],
59
+ ];
60
+ const tags = new URLSearchParams(
61
+ utm.filter(([key]) => !url.searchParams.has(key)),
62
+ ).toString();
63
+ if (tags) url.search = url.search ? `${url.search}&${tags}` : `?${tags}`;
64
+ return url.href;
65
+ }
66
+
67
+ function sanitizeLinkSlug(value: string): string {
68
+ const slug = value.trim().toLowerCase();
69
+ if (!isTrackedLinkSlug(slug)) {
70
+ fail(
71
+ "INVALID_ARGUMENT",
72
+ "slug must be 3 to 32 lowercase letters, digits or hyphens, starting and ending with a letter or digit",
73
+ );
74
+ }
75
+ return slug;
76
+ }
77
+
78
+ function sanitizePlatform(value: string): string {
79
+ const platform = value.trim().toLowerCase();
80
+ if (!PLATFORM.test(platform)) {
81
+ fail(
82
+ "INVALID_ARGUMENT",
83
+ "platform must be 2 to 24 lowercase letters, digits or underscores",
84
+ );
85
+ }
86
+ return platform;
87
+ }
88
+
89
+ function sanitizeCampaign(value: string | undefined): string | undefined {
90
+ return value === undefined
91
+ ? undefined
92
+ : cleanString(value, MAX_CAMPAIGN_LENGTH) || undefined;
93
+ }
94
+
95
+ // No open redirect: the destination is an absolute http(s) URL on one of the
96
+ // site's own domains, the same rule the ingestion origin check applies.
97
+ function sanitizeDestination(value: string, domains: string[]): string {
98
+ let url: URL | undefined;
99
+ try {
100
+ if (value.length <= MAX_DESTINATION_LENGTH) {
101
+ url = new URL(cleanString(value, MAX_DESTINATION_LENGTH));
102
+ }
103
+ } catch {
104
+ url = undefined;
105
+ }
106
+ if (
107
+ !url ||
108
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
109
+ url.username ||
110
+ url.password
111
+ ) {
112
+ fail(
113
+ "INVALID_ARGUMENT",
114
+ "destination must be an absolute http(s) URL without credentials",
115
+ );
116
+ }
117
+ if (!originAllowed(url.href, domains)) {
118
+ fail(
119
+ "INVALID_ARGUMENT",
120
+ "destination must be on one of the site's domains",
121
+ );
122
+ }
123
+ return url.href;
124
+ }
125
+
126
+ export const create = mutation({
127
+ args: {
128
+ siteId: v.id("sites"),
129
+ slug: v.string(),
130
+ destination: v.string(),
131
+ platform: v.string(),
132
+ channel: trackedLinkChannelValidator,
133
+ campaign: v.optional(v.string()),
134
+ createdBy: v.string(),
135
+ },
136
+ returns: v.id("trackedLinks"),
137
+ handler: async (ctx, args) => {
138
+ const site = await ctx.db.get("sites", args.siteId);
139
+ if (!site) fail("NOT_FOUND", "site not found");
140
+ const slug = sanitizeLinkSlug(args.slug);
141
+ const destination = sanitizeDestination(args.destination, site.domains);
142
+ const platform = sanitizePlatform(args.platform);
143
+ let createdBy: string;
144
+ try {
145
+ createdBy = sanitizeOpaqueId(args.createdBy, "createdBy");
146
+ } catch (error) {
147
+ fail(
148
+ "INVALID_ARGUMENT",
149
+ error instanceof Error ? error.message : "invalid createdBy",
150
+ );
151
+ }
152
+ const taken = await ctx.db
153
+ .query("trackedLinks")
154
+ .withIndex("by_slug", (range) => range.eq("slug", slug))
155
+ .unique();
156
+ if (taken) fail("CONFLICT", "another tracked link already uses this slug");
157
+ const links = await ctx.db
158
+ .query("trackedLinks")
159
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
160
+ .take(MAX_TRACKED_LINKS_PER_SITE);
161
+ if (links.length >= MAX_TRACKED_LINKS_PER_SITE) {
162
+ fail("LIMIT_EXCEEDED", "tracked link limit reached", {
163
+ limit: MAX_TRACKED_LINKS_PER_SITE,
164
+ });
165
+ }
166
+ return await ctx.db.insert("trackedLinks", {
167
+ siteId: args.siteId,
168
+ slug,
169
+ destination,
170
+ platform,
171
+ channel: args.channel,
172
+ campaign: sanitizeCampaign(args.campaign),
173
+ createdAt: Date.now(),
174
+ createdBy,
175
+ });
176
+ },
177
+ });
178
+
179
+ /** Everything but the slug, which every link already printed carries. */
180
+ export const update = mutation({
181
+ args: {
182
+ siteId: v.id("sites"),
183
+ linkId: v.id("trackedLinks"),
184
+ destination: v.optional(v.string()),
185
+ platform: v.optional(v.string()),
186
+ channel: v.optional(trackedLinkChannelValidator),
187
+ campaign: v.optional(v.union(v.string(), v.null())),
188
+ disabled: v.optional(v.boolean()),
189
+ },
190
+ returns: v.null(),
191
+ handler: async (ctx, args) => {
192
+ const link = await ctx.db.get("trackedLinks", args.linkId);
193
+ if (!link || link.siteId !== args.siteId) {
194
+ fail("NOT_FOUND", "tracked link not found");
195
+ }
196
+ const patch: {
197
+ destination?: string;
198
+ platform?: string;
199
+ channel?: Channel;
200
+ campaign?: string;
201
+ disabledAt?: number;
202
+ } = {};
203
+ if (args.destination !== undefined) {
204
+ const site = await ctx.db.get("sites", args.siteId);
205
+ if (!site) fail("NOT_FOUND", "site not found");
206
+ patch.destination = sanitizeDestination(args.destination, site.domains);
207
+ }
208
+ if (args.platform !== undefined) {
209
+ patch.platform = sanitizePlatform(args.platform);
210
+ }
211
+ if (args.channel !== undefined) patch.channel = args.channel;
212
+ if (args.campaign !== undefined) {
213
+ patch.campaign = sanitizeCampaign(args.campaign ?? undefined);
214
+ }
215
+ if (args.disabled !== undefined) {
216
+ patch.disabledAt = args.disabled
217
+ ? (link.disabledAt ?? Date.now())
218
+ : undefined;
219
+ }
220
+ await ctx.db.patch("trackedLinks", args.linkId, patch);
221
+ return null;
222
+ },
223
+ });
224
+
225
+ export const remove = mutation({
226
+ args: { siteId: v.id("sites"), linkId: v.id("trackedLinks") },
227
+ returns: v.null(),
228
+ handler: async (ctx, args): Promise<null> => {
229
+ const link = await ctx.db.get("trackedLinks", args.linkId);
230
+ if (!link || link.siteId !== args.siteId) {
231
+ fail("NOT_FOUND", "tracked link not found");
232
+ }
233
+ await ctx.db.delete("trackedLinks", args.linkId);
234
+ await ctx.scheduler.runAfter(0, internal.trackedLinks.purgeClicks, {
235
+ linkId: args.linkId,
236
+ });
237
+ return null;
238
+ },
239
+ });
240
+
241
+ /**
242
+ * The site's links, newest first, with their clicks and bot hits: all time,
243
+ * and over the 30 UTC days ending on the day of `now`. The caller passes the
244
+ * clock so the subscription does not go stale.
245
+ */
246
+ export const list = query({
247
+ args: { siteId: v.id("sites"), now: v.number() },
248
+ returns: v.array(
249
+ trackedLinkDocumentValidator.extend({
250
+ clicks: v.number(),
251
+ bots: v.number(),
252
+ clicksLast30Days: v.number(),
253
+ botsLast30Days: v.number(),
254
+ }),
255
+ ),
256
+ handler: async (ctx, args) => {
257
+ if (!Number.isSafeInteger(args.now) || args.now < RECENT_DAYS * DAY_MS) {
258
+ fail("INVALID_ARGUMENT", "now must be a Unix time in milliseconds");
259
+ }
260
+ const since =
261
+ Math.floor(args.now / DAY_MS) * DAY_MS - (RECENT_DAYS - 1) * DAY_MS;
262
+ const links = await ctx.db
263
+ .query("trackedLinks")
264
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
265
+ .order("desc")
266
+ .take(MAX_TRACKED_LINKS_PER_SITE);
267
+ const sum = (rows: Array<{ clicks: number; bots: number }>) =>
268
+ rows.reduce(
269
+ (total, row) => ({
270
+ clicks: total.clicks + row.clicks,
271
+ bots: total.bots + row.bots,
272
+ }),
273
+ { clicks: 0, bots: 0 },
274
+ );
275
+ // Up to 4 + 120 counter rows per link, so a full site of 200 links whose
276
+ // every link fills every shard every day reads about 24,800 rows, inside
277
+ // the 32,000 documents a query may read. Raising either cap needs a
278
+ // finished day's shards folded into one row first.
279
+ return await Promise.all(
280
+ links.map(async (link) => {
281
+ const total = sum(
282
+ await ctx.db
283
+ .query("trackedLinkClicks")
284
+ .withIndex("by_linkId_and_dayStart_and_shard", (range) =>
285
+ range.eq("linkId", link._id).eq("dayStart", ALL_TIME),
286
+ )
287
+ .take(TRACKED_LINK_CLICK_SHARDS),
288
+ );
289
+ const recent = sum(
290
+ await ctx.db
291
+ .query("trackedLinkClicks")
292
+ .withIndex("by_linkId_and_dayStart_and_shard", (range) =>
293
+ range
294
+ .eq("linkId", link._id)
295
+ .gte("dayStart", since)
296
+ .lt("dayStart", since + RECENT_DAYS * DAY_MS),
297
+ )
298
+ .take(RECENT_DAYS * TRACKED_LINK_CLICK_SHARDS),
299
+ );
300
+ return {
301
+ ...link,
302
+ clicks: total.clicks,
303
+ bots: total.bots,
304
+ clicksLast30Days: recent.clicks,
305
+ botsLast30Days: recent.bots,
306
+ };
307
+ }),
308
+ );
309
+ },
310
+ });
311
+
312
+ /**
313
+ * Counts one click on a live link and returns where it goes, or null for an
314
+ * unknown or disabled slug. Bots are counted apart and never as clicks. The
315
+ * destination is checked against the site's domains again, so a domain the
316
+ * site has since dropped stops redirecting.
317
+ */
318
+ export const recordClick = internalMutation({
319
+ args: { slug: v.string(), bot: v.boolean() },
320
+ returns: v.union(redirectValidator, v.null()),
321
+ handler: async (ctx, args) => {
322
+ const link = await ctx.db
323
+ .query("trackedLinks")
324
+ .withIndex("by_slug", (range) => range.eq("slug", args.slug))
325
+ .unique();
326
+ if (!link || link.disabledAt !== undefined) return null;
327
+ const site = await ctx.db.get("sites", link.siteId);
328
+ if (!site || !originAllowed(link.destination, site.domains)) return null;
329
+ const now = Date.now();
330
+ const shard = Math.floor(Math.random() * TRACKED_LINK_CLICK_SHARDS);
331
+ for (const dayStart of [Math.floor(now / DAY_MS) * DAY_MS, ALL_TIME]) {
332
+ const row = await ctx.db
333
+ .query("trackedLinkClicks")
334
+ .withIndex("by_linkId_and_dayStart_and_shard", (range) =>
335
+ range
336
+ .eq("linkId", link._id)
337
+ .eq("dayStart", dayStart)
338
+ .eq("shard", shard),
339
+ )
340
+ .unique();
341
+ if (row) {
342
+ await ctx.db.patch(
343
+ "trackedLinkClicks",
344
+ row._id,
345
+ args.bot ? { bots: row.bots + 1 } : { clicks: row.clicks + 1 },
346
+ );
347
+ } else {
348
+ await ctx.db.insert("trackedLinkClicks", {
349
+ linkId: link._id,
350
+ dayStart,
351
+ shard,
352
+ clicks: args.bot ? 0 : 1,
353
+ bots: args.bot ? 1 : 0,
354
+ });
355
+ }
356
+ }
357
+ return {
358
+ slug: link.slug,
359
+ destination: link.destination,
360
+ platform: link.platform,
361
+ channel: link.channel,
362
+ ...(link.campaign === undefined ? {} : { campaign: link.campaign }),
363
+ };
364
+ },
365
+ });
366
+
367
+ /** Deletes a removed link's counters a batch at a time. */
368
+ export const purgeClicks = internalMutation({
369
+ args: { linkId: v.id("trackedLinks") },
370
+ returns: v.null(),
371
+ handler: async (ctx, args): Promise<null> => {
372
+ const rows = await ctx.db
373
+ .query("trackedLinkClicks")
374
+ .withIndex("by_linkId_and_dayStart_and_shard", (range) =>
375
+ range.eq("linkId", args.linkId),
376
+ )
377
+ .take(PURGE_BATCH);
378
+ for (const row of rows) await ctx.db.delete("trackedLinkClicks", row._id);
379
+ if (rows.length === PURGE_BATCH) {
380
+ await ctx.scheduler.runAfter(0, internal.trackedLinks.purgeClicks, args);
381
+ }
382
+ return null;
383
+ },
384
+ });
@@ -1,3 +1,5 @@
1
+ import { PLATFORMS } from "./platforms.js";
2
+
1
3
  export type ClientClassification = {
2
4
  browser: string;
3
5
  os: string;
@@ -100,6 +102,15 @@ export function classifyUserAgent(userAgent: string): ClientClassification {
100
102
  };
101
103
  }
102
104
 
105
+ /**
106
+ * The platform whose in-app browser sent the request (Instagram, Facebook,
107
+ * TikTok…), from the marker each app appends to the User-Agent. The system
108
+ * browser a messaging app hands a link to carries none.
109
+ */
110
+ export function inAppPlatform(userAgent: string): string | undefined {
111
+ return PLATFORMS.find((row) => row.agent?.test(userAgent))?.id;
112
+ }
113
+
103
114
  function matchAgent(
104
115
  table: readonly (readonly [RegExp, string])[],
105
116
  userAgent: string,
@@ -1,5 +1,54 @@
1
1
  import { v, type Infer } from "convex/values";
2
2
 
3
+ export const personAttributeValueValidator = v.union(
4
+ v.string(),
5
+ v.number(),
6
+ v.boolean(),
7
+ );
8
+ export const personAttributesValidator = v.record(
9
+ v.string(),
10
+ personAttributeValueValidator,
11
+ );
12
+ export type PersonAttributes = Infer<typeof personAttributesValidator>;
13
+ export const personFilterValidator = v.union(
14
+ v.object({
15
+ field: v.union(
16
+ v.literal("name"),
17
+ v.literal("email"),
18
+ v.literal("visitorId"),
19
+ ),
20
+ prefix: v.string(),
21
+ }),
22
+ v.object({
23
+ field: v.literal("attribute"),
24
+ key: v.string(),
25
+ value: personAttributeValueValidator,
26
+ }),
27
+ );
28
+ export type PersonFilter = Infer<typeof personFilterValidator>;
29
+
30
+ /** A profile supplied by the product's trusted backend, shared by its visits. */
31
+ export const visitorProfileValidator = v.object({
32
+ visitorId: v.string(),
33
+ name: v.optional(v.string()),
34
+ email: v.optional(v.string()),
35
+ attributes: v.optional(personAttributesValidator),
36
+ identifiedAt: v.number(),
37
+ updatedAt: v.number(),
38
+ });
39
+ export type VisitorProfile = Infer<typeof visitorProfileValidator>;
40
+
41
+ export const identifyVisitorFields = {
42
+ visitorId: v.string(),
43
+ previousVisitorId: v.optional(v.string()),
44
+ // Omission preserves the value; null explicitly removes it.
45
+ name: v.optional(v.union(v.string(), v.null())),
46
+ email: v.optional(v.union(v.string(), v.null())),
47
+ attributes: v.optional(
48
+ v.record(v.string(), v.union(personAttributeValueValidator, v.null())),
49
+ ),
50
+ };
51
+
3
52
  export const eventTypeValidator = v.union(
4
53
  v.literal("pageview"),
5
54
  v.literal("click"),
@@ -50,6 +99,9 @@ export const trackerEventValidator = v.object({
50
99
  // Web Vitals measurement: milliseconds, or CLS scaled by 1000. Only "vital"
51
100
  // events carry it; sanitization strips it from every other type.
52
101
  value: v.optional(v.number()),
102
+ // The names of the click-ID keys on the landing URL (`fbclid`, `gclid`…),
103
+ // never their values; sanitization keeps only the known ones (origin.ts).
104
+ clid: v.optional(v.array(v.string())),
53
105
  });
54
106
 
55
107
  export const ingestContextValidator = v.object({
@@ -61,8 +113,41 @@ export const ingestContextValidator = v.object({
61
113
  os: v.optional(v.string()),
62
114
  device: v.optional(v.string()),
63
115
  visitorKey: v.optional(v.string()),
116
+ // The platform whose in-app browser sent the batch (useragent.ts), derived
117
+ // by the HTTP action so the raw User-Agent never reaches a mutation.
118
+ inApp: v.optional(v.string()),
64
119
  });
65
120
 
121
+ // How a session arrived (channel) and which rule proved it (evidence); see
122
+ // origin.ts. "legacy" is never stored: reports mark a session recorded before
123
+ // origins existed with it when they classify that session on read.
124
+ export const channelValidator = v.union(
125
+ v.literal("direct"),
126
+ v.literal("organic_search"),
127
+ v.literal("paid_search"),
128
+ v.literal("organic_social"),
129
+ v.literal("paid_social"),
130
+ v.literal("messaging"),
131
+ v.literal("email"),
132
+ v.literal("affiliate"),
133
+ v.literal("ai"),
134
+ v.literal("display"),
135
+ v.literal("referral"),
136
+ // A link printed off the web: a QR code, a flyer, a receipt. Only a tracked
137
+ // link can say so, since the scan itself carries no referrer.
138
+ v.literal("offline"),
139
+ );
140
+
141
+ export const evidenceValidator = v.union(
142
+ v.literal("utm"),
143
+ v.literal("clickId"),
144
+ v.literal("referrer"),
145
+ v.literal("inApp"),
146
+ v.literal("affiliate"),
147
+ v.literal("none"),
148
+ v.literal("legacy"),
149
+ );
150
+
66
151
  export const batchedEventValidator = trackerEventValidator.extend({
67
152
  ...ingestContextValidator.fields,
68
153
  aggregateCountry: v.optional(v.string()),
@@ -94,6 +179,10 @@ export const sessionFieldsValidator = v.object({
94
179
  exitPath: v.string(),
95
180
  referrer: v.optional(v.string()),
96
181
  source: v.string(),
182
+ // The first touch's origin (origin.ts); absent on sessions recorded before.
183
+ platform: v.optional(v.string()),
184
+ channel: v.optional(channelValidator),
185
+ evidence: v.optional(evidenceValidator),
97
186
  utmSource: v.optional(v.string()),
98
187
  utmMedium: v.optional(v.string()),
99
188
  utmCampaign: v.optional(v.string()),
@@ -155,6 +244,10 @@ export const dimensionTypeValidator = v.union(
155
244
  // host of an outbound click (never its path).
156
245
  v.literal("medium"),
157
246
  v.literal("outbound"),
247
+ // The session's origin platform, channel and evidence (origin.ts).
248
+ v.literal("platform"),
249
+ v.literal("channel"),
250
+ v.literal("evidence"),
158
251
  );
159
252
 
160
253
  export const dimensionSlotValidator = v.object({
@@ -254,6 +347,31 @@ export const affiliateFieldsValidator = v.object({
254
347
  updatedAt: v.number(),
255
348
  });
256
349
 
350
+ // Where a tracked link was published: chosen at creation and carried to the
351
+ // landing page as its utm_medium.
352
+ export const trackedLinkChannelValidator = v.union(
353
+ v.literal("organic_social"),
354
+ v.literal("paid_social"),
355
+ v.literal("messaging"),
356
+ v.literal("email"),
357
+ v.literal("affiliate"),
358
+ v.literal("display"),
359
+ v.literal("referral"),
360
+ v.literal("offline"),
361
+ );
362
+
363
+ export const trackedLinkFieldsValidator = v.object({
364
+ siteId: v.id("sites"),
365
+ slug: v.string(),
366
+ destination: v.string(),
367
+ platform: v.string(),
368
+ channel: trackedLinkChannelValidator,
369
+ campaign: v.optional(v.string()),
370
+ createdAt: v.number(),
371
+ createdBy: v.string(),
372
+ disabledAt: v.optional(v.number()),
373
+ });
374
+
257
375
  export const ingestResultValidator = v.object({
258
376
  accepted: v.number(),
259
377
  duplicates: v.number(),
@@ -278,3 +396,5 @@ export type IngestContext = Infer<typeof ingestContextValidator>;
278
396
  export type EventProperties = Infer<typeof eventPropertiesValidator>;
279
397
  export type FunnelStep = Infer<typeof funnelStepValidator>;
280
398
  export type EventType = Infer<typeof eventTypeValidator>;
399
+ export type Channel = Infer<typeof channelValidator>;
400
+ export type Evidence = Infer<typeof evidenceValidator>;