mcp-scraper 0.72.7 → 0.73.1

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 (35) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +16 -2
  3. package/dist/{analytics-repository-6YLRSWYO.js → analytics-repository-7XYPQ2YJ.js} +15 -1
  4. package/dist/bin/api-server.js +3 -3
  5. package/dist/bin/mcp-scraper-cli.js +1 -1
  6. package/dist/bin/mcp-scraper-install.js +2 -2
  7. package/dist/bin/mcp-stdio-server.js +4 -4
  8. package/dist/{chunk-FVL4GUTP.js → chunk-EQJRPDUS.js} +2 -2
  9. package/dist/{chunk-ZSVXNLLM.js → chunk-G75QXTBE.js} +188 -14
  10. package/dist/{chunk-SR7GSLEA.js → chunk-M6XDENOO.js} +953 -122
  11. package/dist/{chunk-RRZAYUEX.js → chunk-NLPFY2IF.js} +1 -1
  12. package/dist/{chunk-RAFQEPJ4.js → chunk-Q2J23DHY.js} +1 -1
  13. package/dist/{chunk-I45JV4EU.js → chunk-RV2D27R4.js} +1 -1
  14. package/dist/{chunk-K2SXQGKM.js → chunk-S45DGELW.js} +1 -1
  15. package/dist/{extract-bundle-337ZDIIQ.js → extract-bundle-2V22S7EU.js} +2 -2
  16. package/dist/{gmail-service-QXDGNWUJ.js → gmail-service-JHL4PQFN.js} +2 -2
  17. package/dist/{server-M5FLUWYD.js → server-663CRTGY.js} +14433 -12846
  18. package/dist/{site-extract-repository-ACNAVNDN.js → site-extract-repository-DUAEAU6A.js} +2 -2
  19. package/dist/{worker-JQORPCFQ.js → worker-CBK5DWEE.js} +1 -1
  20. package/package.json +16 -4
  21. package/dist/bin/api-server.cjs +0 -108656
  22. package/dist/bin/api-server.d.cts +0 -1
  23. package/dist/bin/api-server.d.ts +0 -1
  24. package/dist/bin/mcp-scraper-cli.cjs +0 -3219
  25. package/dist/bin/mcp-scraper-cli.d.cts +0 -1
  26. package/dist/bin/mcp-scraper-cli.d.ts +0 -1
  27. package/dist/bin/mcp-scraper-install.cjs +0 -134
  28. package/dist/bin/mcp-scraper-install.d.cts +0 -1
  29. package/dist/bin/mcp-scraper-install.d.ts +0 -1
  30. package/dist/bin/mcp-stdio-server.cjs +0 -22333
  31. package/dist/bin/mcp-stdio-server.d.cts +0 -1
  32. package/dist/bin/mcp-stdio-server.d.ts +0 -1
  33. package/dist/bin/paa-harvest.cjs +0 -4745
  34. package/dist/bin/paa-harvest.d.cts +0 -1
  35. package/dist/bin/paa-harvest.d.ts +0 -1
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-PJEEKOUM.js";
5
5
 
6
6
  // src/api/analytics-repository.ts
7
- import { createHash, createHmac, randomBytes, randomUUID } from "crypto";
7
+ import { createHash as createHash2, createHmac as createHmac2, randomBytes as randomBytes2, randomUUID as randomUUID2 } from "crypto";
8
8
  import { Pool } from "pg";
9
9
 
10
10
  // src/api/analytics-attribution.ts
@@ -82,6 +82,261 @@ function resolveAnalyticsAttribution(input) {
82
82
  };
83
83
  }
84
84
 
85
+ // src/api/analytics-identity-profile.ts
86
+ import { createHash, randomUUID } from "crypto";
87
+
88
+ // src/api/analytics-crypto.ts
89
+ import {
90
+ createCipheriv,
91
+ createDecipheriv,
92
+ createHmac,
93
+ randomBytes,
94
+ scryptSync,
95
+ timingSafeEqual
96
+ } from "crypto";
97
+ var VERSION = "xray1";
98
+ function rootSecret() {
99
+ return process.env.ANALYTICS_RESTRICTED_DATA_SECRET?.trim() || getSessionSecret();
100
+ }
101
+ function keyFor(purpose) {
102
+ return scryptSync(rootSecret(), `xray-restricted:${purpose}:v1`, 32);
103
+ }
104
+ function encryptAnalyticsRestricted(plaintext, purpose) {
105
+ const normalizedPurpose = purpose.trim().slice(0, 80);
106
+ if (!normalizedPurpose) throw new Error("analytics encryption purpose required");
107
+ const iv = randomBytes(12);
108
+ const cipher = createCipheriv("aes-256-gcm", keyFor(normalizedPurpose), iv);
109
+ cipher.setAAD(Buffer.from(`${VERSION}:${normalizedPurpose}`, "utf8"));
110
+ const ciphertext = Buffer.concat([
111
+ cipher.update(plaintext, "utf8"),
112
+ cipher.final()
113
+ ]);
114
+ const tag = cipher.getAuthTag();
115
+ return [
116
+ VERSION,
117
+ Buffer.from(normalizedPurpose, "utf8").toString("base64url"),
118
+ iv.toString("base64url"),
119
+ tag.toString("base64url"),
120
+ ciphertext.toString("base64url")
121
+ ].join(".");
122
+ }
123
+ function decryptAnalyticsRestricted(stored, expectedPurpose) {
124
+ try {
125
+ const [version, encodedPurpose, iv, tag, ciphertext] = stored.split(".");
126
+ if (version !== VERSION || !encodedPurpose || !iv || !tag || !ciphertext)
127
+ return null;
128
+ const purpose = Buffer.from(encodedPurpose, "base64url").toString("utf8");
129
+ if (purpose !== expectedPurpose) return null;
130
+ const decipher = createDecipheriv(
131
+ "aes-256-gcm",
132
+ keyFor(purpose),
133
+ Buffer.from(iv, "base64url")
134
+ );
135
+ decipher.setAAD(Buffer.from(`${VERSION}:${purpose}`, "utf8"));
136
+ decipher.setAuthTag(Buffer.from(tag, "base64url"));
137
+ return Buffer.concat([
138
+ decipher.update(Buffer.from(ciphertext, "base64url")),
139
+ decipher.final()
140
+ ]).toString("utf8");
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+ function analyticsIdentityHmac(kind, value) {
146
+ return createHmac("sha256", keyFor(`identity:${kind}`)).update(value.trim().toLowerCase()).digest("hex");
147
+ }
148
+ function signAnalyticsWebhook(provider, timestamp, rawBody, secret) {
149
+ return createHmac("sha256", secret).update(`${provider}.${timestamp}.${rawBody}`).digest("hex");
150
+ }
151
+ function verifyAnalyticsWebhookSignature(actual, expected) {
152
+ if (!actual || actual.length !== expected.length) return false;
153
+ try {
154
+ return timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ // src/api/analytics-identity-profile.ts
161
+ var ANALYTICS_IDENTITY_PROFILE_PURPOSE = "enhanced_matching";
162
+ var ANALYTICS_IDENTITY_PROFILE_RETENTION_DAYS = 90;
163
+ var ANALYTICS_IDENTITY_PROFILE_RATE_LIMIT = 10;
164
+ var AnalyticsIdentityProfileError = class extends Error {
165
+ constructor(code, message, status) {
166
+ super(message);
167
+ this.code = code;
168
+ this.status = status;
169
+ this.name = "AnalyticsIdentityProfileError";
170
+ }
171
+ code;
172
+ status;
173
+ };
174
+ function normalizedText(value, max) {
175
+ const normalized = value?.trim().replace(/\s+/g, " ").slice(0, max);
176
+ return normalized || null;
177
+ }
178
+ function maskedEmail(value) {
179
+ if (!value) return null;
180
+ const [local, domain] = value.split("@");
181
+ if (!local || !domain) return null;
182
+ return `${local.slice(0, 1)}${"*".repeat(Math.min(6, Math.max(2, local.length - 1)))}@${domain}`;
183
+ }
184
+ function maskedPhone(value) {
185
+ if (!value) return null;
186
+ const digits = value.replace(/\D/g, "");
187
+ if (digits.length < 4) return null;
188
+ return `***-***-${digits.slice(-4)}`;
189
+ }
190
+ function auditSubject(reference) {
191
+ return createHash("sha256").update(reference).digest("hex");
192
+ }
193
+ async function assertAnalyticsIdentitySignalsNotTombstoned(db, input) {
194
+ if (!input.signals.length) return;
195
+ const kinds = input.signals.map((signal) => signal.kind);
196
+ const hashes = input.signals.map((signal) => signal.valueHmac);
197
+ const result = await db.query(
198
+ `SELECT 1 FROM analytics_identity_tombstones
199
+ WHERE site_id=$1 AND (kind,value_hmac) IN (
200
+ SELECT * FROM unnest($2::text[],$3::text[])
201
+ ) LIMIT 1`,
202
+ [input.siteId, kinds, hashes]
203
+ );
204
+ if (result.rowCount) {
205
+ throw new AnalyticsIdentityProfileError(
206
+ "analytics_identity_deleted",
207
+ "This identity cannot be automatically recreated.",
208
+ 410
209
+ );
210
+ }
211
+ }
212
+ async function writeAnalyticsConfirmedIdentityProfile(db, input) {
213
+ const displayName = normalizedText(input.displayName, 160);
214
+ const email = normalizedText(input.email, 320)?.toLowerCase() ?? null;
215
+ const phone = normalizedText(input.phone, 40);
216
+ if (!displayName && !email && !phone) return false;
217
+ const person = await db.query(
218
+ `SELECT p.public_ref FROM analytics_people p
219
+ JOIN analytics_identity_namespaces n ON n.id=p.namespace_id AND n.site_id=p.site_id AND n.status='approved'
220
+ WHERE p.id=$1 AND p.site_id=$2`,
221
+ [input.personId, input.siteId]
222
+ );
223
+ const publicRef = person.rows[0]?.public_ref;
224
+ if (!publicRef) return false;
225
+ const refs = [...new Set([publicRef, ...input.consentSubjectRefs].filter(Boolean))];
226
+ const consent = await db.query(
227
+ `SELECT s.latest_receipt_id::text FROM analytics_consent_current_state s
228
+ JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id AND r.site_id=s.site_id
229
+ WHERE s.site_id=$1 AND s.subject_ref=ANY($2::text[])
230
+ AND s.site_analytics=true AND s.enhanced_matching=true AND s.gpc=false
231
+ AND r.action NOT IN ('withdraw','gpc')
232
+ AND (s.expires_at IS NULL OR s.expires_at > now())
233
+ ORDER BY s.effective_at DESC LIMIT 1`,
234
+ [input.siteId, refs]
235
+ );
236
+ const consentReceiptId = consent.rows[0]?.latest_receipt_id;
237
+ if (!consentReceiptId) return false;
238
+ const verifiedAt = input.verifiedAt && Number.isFinite(Date.parse(input.verifiedAt)) ? new Date(input.verifiedAt).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
239
+ const ciphertext = encryptAnalyticsRestricted(JSON.stringify({
240
+ schemaVersion: 1,
241
+ displayName,
242
+ email,
243
+ phone,
244
+ evidenceType: input.evidenceType,
245
+ verifiedAt
246
+ }), "identity-profile");
247
+ await db.query(
248
+ `INSERT INTO analytics_restricted_data(id,site_id,person_id,purpose,subject_ref,payload_ciphertext,expires_at,consent_receipt_id)
249
+ VALUES($1,$2,$3,$4,$5,$6,now()+($7::int * interval '1 day'),$8)
250
+ ON CONFLICT(site_id,purpose,subject_ref) DO UPDATE SET
251
+ person_id=EXCLUDED.person_id,payload_ciphertext=EXCLUDED.payload_ciphertext,
252
+ expires_at=EXCLUDED.expires_at,consent_receipt_id=EXCLUDED.consent_receipt_id,created_at=now()`,
253
+ [
254
+ randomUUID(),
255
+ input.siteId,
256
+ input.personId,
257
+ ANALYTICS_IDENTITY_PROFILE_PURPOSE,
258
+ publicRef,
259
+ ciphertext,
260
+ ANALYTICS_IDENTITY_PROFILE_RETENTION_DAYS,
261
+ consentReceiptId
262
+ ]
263
+ );
264
+ return true;
265
+ }
266
+ async function resolveAnalyticsMaskedIdentityProfile(db, input) {
267
+ const now = input.now ?? /* @__PURE__ */ new Date();
268
+ const role = await db.query(
269
+ "SELECT role FROM analytics_site_grants WHERE site_id=$1 AND user_id=$2",
270
+ [input.siteId, input.actorUserId]
271
+ );
272
+ if (!role.rowCount) throw new AnalyticsIdentityProfileError("analytics_site_not_found", "Site not found.", 404);
273
+ if (!["owner", "editor"].includes(role.rows[0].role))
274
+ throw new AnalyticsIdentityProfileError("analytics_forbidden", "Editor access is required.", 403);
275
+ const recent = await db.query(
276
+ `SELECT count(*)::text AS count FROM analytics_identity_profile_access_audits
277
+ WHERE site_id=$1 AND actor_user_id=$2 AND created_at > $3::timestamptz - interval '1 minute'`,
278
+ [input.siteId, input.actorUserId, now.toISOString()]
279
+ );
280
+ if (Number(recent.rows[0]?.count ?? 0) >= ANALYTICS_IDENTITY_PROFILE_RATE_LIMIT) {
281
+ await db.query(
282
+ `INSERT INTO analytics_identity_profile_access_audits(id,site_id,actor_user_id,subject_ref_hmac,purpose,result,created_at)
283
+ VALUES($1,$2,$3,$4,'journey_identity_display','rate_limited',$5)`,
284
+ [randomUUID(), input.siteId, input.actorUserId, auditSubject(input.requestedPersonRef), now.toISOString()]
285
+ );
286
+ throw new AnalyticsIdentityProfileError("analytics_identity_profile_rate_limited", "Identity profile rate limit exceeded.", 429);
287
+ }
288
+ const unavailable = async (result) => {
289
+ await db.query(
290
+ `INSERT INTO analytics_identity_profile_access_audits(id,site_id,actor_user_id,subject_ref_hmac,purpose,result,created_at)
291
+ VALUES($1,$2,$3,$4,'journey_identity_display',$5,$6)`,
292
+ [randomUUID(), input.siteId, input.actorUserId, auditSubject(input.requestedPersonRef), result, now.toISOString()]
293
+ );
294
+ throw new AnalyticsIdentityProfileError("analytics_identity_profile_not_found", "Identity profile not found.", 404);
295
+ };
296
+ if (!input.personId) return unavailable("not_found");
297
+ const row = await db.query(
298
+ `SELECT p.public_ref,r.payload_ciphertext,r.expires_at
299
+ FROM analytics_people p
300
+ JOIN analytics_restricted_data r ON r.person_id=p.id AND r.site_id=p.site_id
301
+ AND r.purpose=$3 AND r.subject_ref=p.public_ref AND r.expires_at>$4
302
+ JOIN analytics_consent_current_state s ON s.site_id=p.site_id AND s.latest_receipt_id=r.consent_receipt_id
303
+ AND s.site_analytics=true AND s.enhanced_matching=true AND s.gpc=false
304
+ AND (s.expires_at IS NULL OR s.expires_at>$4)
305
+ JOIN analytics_consent_receipts c ON c.id=s.latest_receipt_id AND c.site_id=s.site_id
306
+ AND c.action NOT IN ('withdraw','gpc')
307
+ WHERE p.id=$1 AND p.site_id=$2
308
+ AND NOT EXISTS (SELECT 1 FROM analytics_identity_tombstones t WHERE t.site_id=p.site_id AND t.person_id=p.id)
309
+ LIMIT 1`,
310
+ [input.personId, input.siteId, ANALYTICS_IDENTITY_PROFILE_PURPOSE, now.toISOString()]
311
+ );
312
+ if (!row.rowCount) return unavailable("not_found");
313
+ const plaintext = decryptAnalyticsRestricted(row.rows[0].payload_ciphertext, "identity-profile");
314
+ if (!plaintext) return unavailable("unavailable");
315
+ let parsed;
316
+ try {
317
+ parsed = JSON.parse(plaintext);
318
+ } catch {
319
+ return unavailable("unavailable");
320
+ }
321
+ const evidenceType = parsed.evidenceType;
322
+ if (!["first_party_form", "authenticated_user", "crm_contact"].includes(String(evidenceType)))
323
+ return unavailable("unavailable");
324
+ const profile = {
325
+ personRef: input.requestedPersonRef,
326
+ displayName: normalizedText(typeof parsed.displayName === "string" ? parsed.displayName : void 0, 160),
327
+ maskedEmail: maskedEmail(typeof parsed.email === "string" ? parsed.email : null),
328
+ maskedPhone: maskedPhone(typeof parsed.phone === "string" ? parsed.phone : null),
329
+ evidenceType,
330
+ verifiedAt: typeof parsed.verifiedAt === "string" && Number.isFinite(Date.parse(parsed.verifiedAt)) ? new Date(parsed.verifiedAt).toISOString() : now.toISOString()
331
+ };
332
+ await db.query(
333
+ `INSERT INTO analytics_identity_profile_access_audits(id,site_id,person_id,actor_user_id,subject_ref_hmac,purpose,result,created_at)
334
+ VALUES($1,$2,$3,$4,$5,'journey_identity_display','returned_masked',$6)`,
335
+ [randomUUID(), input.siteId, input.personId, input.actorUserId, auditSubject(input.requestedPersonRef), now.toISOString()]
336
+ );
337
+ return profile;
338
+ }
339
+
85
340
  // src/api/analytics-provider-registry.ts
86
341
  var META_ACTION_SOURCES = /* @__PURE__ */ new Set([
87
342
  "email",
@@ -605,7 +860,6 @@ async function closeAnalyticsPool() {
605
860
  async function migrateAnalytics() {
606
861
  const db = getAnalyticsPool();
607
862
  const client = await db.connect();
608
- await client.query("BEGIN");
609
863
  try {
610
864
  await client.query(`
611
865
  CREATE TABLE IF NOT EXISTS analytics_schema_migrations (
@@ -1289,6 +1543,33 @@ async function migrateAnalytics() {
1289
1543
  UNIQUE(site_id, purpose, subject_ref)
1290
1544
  )
1291
1545
  `);
1546
+ await client.query(`
1547
+ CREATE TABLE IF NOT EXISTS analytics_identity_tombstones (
1548
+ id uuid PRIMARY KEY,
1549
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1550
+ person_id uuid,
1551
+ kind text NOT NULL,
1552
+ value_hmac text NOT NULL,
1553
+ reason text NOT NULL CHECK (reason IN ('subject_deletion')),
1554
+ created_at timestamptz NOT NULL DEFAULT now(),
1555
+ UNIQUE(site_id,kind,value_hmac)
1556
+ )
1557
+ `);
1558
+ await client.query(`
1559
+ CREATE TABLE IF NOT EXISTS analytics_identity_profile_access_audits (
1560
+ id uuid PRIMARY KEY,
1561
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1562
+ person_id uuid,
1563
+ actor_user_id bigint NOT NULL,
1564
+ subject_ref_hmac text NOT NULL,
1565
+ purpose text NOT NULL CHECK (purpose IN ('journey_identity_display')),
1566
+ result text NOT NULL CHECK (result IN ('returned_masked','not_found','unavailable','rate_limited')),
1567
+ created_at timestamptz NOT NULL DEFAULT now()
1568
+ )
1569
+ `);
1570
+ await client.query(
1571
+ "CREATE INDEX IF NOT EXISTS analytics_identity_profile_access_rate ON analytics_identity_profile_access_audits(site_id,actor_user_id,created_at DESC)"
1572
+ );
1292
1573
  await client.query(
1293
1574
  `ALTER TABLE analytics_activation_destinations ADD COLUMN IF NOT EXISTS readiness text NOT NULL DEFAULT 'configured_unverified' CHECK (readiness IN ('not_configured','configured_unverified','verified','live','degraded','disabled'))`
1294
1575
  );
@@ -1390,6 +1671,11 @@ async function migrateAnalytics() {
1390
1671
  await client.query(
1391
1672
  `ALTER TABLE analytics_people ADD COLUMN IF NOT EXISTS public_ref text`
1392
1673
  );
1674
+ await client.query(
1675
+ `UPDATE analytics_people
1676
+ SET public_ref = 'xrp_' || substr(md5(random()::text || clock_timestamp()::text || id::text || site_id::text), 1, 24)
1677
+ WHERE public_ref IS NULL`
1678
+ );
1393
1679
  await client.query(
1394
1680
  `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS event_definition_id uuid REFERENCES analytics_event_definitions(id) ON DELETE SET NULL`
1395
1681
  );
@@ -1554,6 +1840,9 @@ async function migrateAnalytics() {
1554
1840
  PRIMARY KEY(site_id, subject_ref)
1555
1841
  )
1556
1842
  `);
1843
+ await client.query(
1844
+ "ALTER TABLE analytics_restricted_data ADD COLUMN IF NOT EXISTS consent_receipt_id uuid REFERENCES analytics_consent_receipts(id) ON DELETE CASCADE"
1845
+ );
1557
1846
  await client.query(`
1558
1847
  CREATE TABLE IF NOT EXISTS analytics_identity_namespaces (
1559
1848
  id uuid PRIMARY KEY,
@@ -1586,13 +1875,19 @@ async function migrateAnalytics() {
1586
1875
  )
1587
1876
  `);
1588
1877
  await client.query(
1589
- `ALTER TABLE analytics_people ADD COLUMN IF NOT EXISTS namespace_id uuid REFERENCES analytics_identity_namespaces(id) ON DELETE RESTRICT`
1878
+ `ALTER TABLE analytics_people ADD COLUMN IF NOT EXISTS namespace_id uuid`
1879
+ );
1880
+ await client.query(
1881
+ `ALTER TABLE analytics_identity_nodes ADD COLUMN IF NOT EXISTS namespace_id uuid`
1590
1882
  );
1591
1883
  await client.query(
1592
- `ALTER TABLE analytics_identity_nodes ADD COLUMN IF NOT EXISTS namespace_id uuid REFERENCES analytics_identity_namespaces(id) ON DELETE RESTRICT`
1884
+ `ALTER TABLE analytics_identity_edges ADD COLUMN IF NOT EXISTS namespace_id uuid`
1593
1885
  );
1594
1886
  await client.query(
1595
- `ALTER TABLE analytics_identity_edges ADD COLUMN IF NOT EXISTS namespace_id uuid REFERENCES analytics_identity_namespaces(id) ON DELETE RESTRICT`
1887
+ "CREATE UNIQUE INDEX IF NOT EXISTS analytics_people_site_ref ON analytics_people(id, site_id)"
1888
+ );
1889
+ await client.query(
1890
+ "CREATE UNIQUE INDEX IF NOT EXISTS analytics_identity_nodes_site_ref ON analytics_identity_nodes(id, site_id)"
1596
1891
  );
1597
1892
  await client.query(
1598
1893
  "CREATE UNIQUE INDEX IF NOT EXISTS analytics_identity_nodes_namespace_ref ON analytics_identity_nodes(id, site_id, namespace_id)"
@@ -1600,6 +1895,62 @@ async function migrateAnalytics() {
1600
1895
  await client.query(
1601
1896
  "CREATE UNIQUE INDEX IF NOT EXISTS analytics_people_namespace_ref ON analytics_people(id, site_id, namespace_id)"
1602
1897
  );
1898
+ await client.query(`
1899
+ DO $$
1900
+ BEGIN
1901
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_people_namespace_site_fkey') THEN
1902
+ ALTER TABLE analytics_people ADD CONSTRAINT analytics_people_namespace_site_fkey
1903
+ FOREIGN KEY(namespace_id, site_id) REFERENCES analytics_identity_namespaces(id, site_id)
1904
+ ON DELETE RESTRICT NOT VALID;
1905
+ END IF;
1906
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_nodes_namespace_site_fkey') THEN
1907
+ ALTER TABLE analytics_identity_nodes ADD CONSTRAINT analytics_identity_nodes_namespace_site_fkey
1908
+ FOREIGN KEY(namespace_id, site_id) REFERENCES analytics_identity_namespaces(id, site_id)
1909
+ ON DELETE RESTRICT NOT VALID;
1910
+ END IF;
1911
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_person_site_fkey') THEN
1912
+ ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_person_site_fkey
1913
+ FOREIGN KEY(person_id, site_id) REFERENCES analytics_people(id, site_id)
1914
+ ON DELETE CASCADE NOT VALID;
1915
+ END IF;
1916
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_node_site_fkey') THEN
1917
+ ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_node_site_fkey
1918
+ FOREIGN KEY(identity_node_id, site_id) REFERENCES analytics_identity_nodes(id, site_id)
1919
+ ON DELETE CASCADE NOT VALID;
1920
+ END IF;
1921
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_namespace_site_fkey') THEN
1922
+ ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_namespace_site_fkey
1923
+ FOREIGN KEY(namespace_id, site_id) REFERENCES analytics_identity_namespaces(id, site_id)
1924
+ ON DELETE RESTRICT NOT VALID;
1925
+ END IF;
1926
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_person_namespace_fkey') THEN
1927
+ ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_person_namespace_fkey
1928
+ FOREIGN KEY(person_id, site_id, namespace_id) REFERENCES analytics_people(id, site_id, namespace_id)
1929
+ ON DELETE CASCADE NOT VALID;
1930
+ END IF;
1931
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_node_namespace_fkey') THEN
1932
+ ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_node_namespace_fkey
1933
+ FOREIGN KEY(identity_node_id, site_id, namespace_id) REFERENCES analytics_identity_nodes(id, site_id, namespace_id)
1934
+ ON DELETE CASCADE NOT VALID;
1935
+ END IF;
1936
+ END $$
1937
+ `);
1938
+ for (const constraint of [
1939
+ ["analytics_people", "analytics_people_namespace_site_fkey"],
1940
+ ["analytics_identity_nodes", "analytics_identity_nodes_namespace_site_fkey"],
1941
+ ["analytics_identity_edges", "analytics_identity_edges_person_site_fkey"],
1942
+ ["analytics_identity_edges", "analytics_identity_edges_node_site_fkey"],
1943
+ ["analytics_identity_edges", "analytics_identity_edges_namespace_site_fkey"],
1944
+ ["analytics_identity_edges", "analytics_identity_edges_person_namespace_fkey"],
1945
+ ["analytics_identity_edges", "analytics_identity_edges_node_namespace_fkey"]
1946
+ ]) {
1947
+ await client.query(`ALTER TABLE ${constraint[0]} VALIDATE CONSTRAINT ${constraint[1]}`);
1948
+ }
1949
+ await client.query("ALTER TABLE analytics_people DROP CONSTRAINT IF EXISTS analytics_people_namespace_id_fkey");
1950
+ await client.query("ALTER TABLE analytics_identity_nodes DROP CONSTRAINT IF EXISTS analytics_identity_nodes_namespace_id_fkey");
1951
+ await client.query("ALTER TABLE analytics_identity_edges DROP CONSTRAINT IF EXISTS analytics_identity_edges_namespace_id_fkey");
1952
+ await client.query("ALTER TABLE analytics_identity_edges DROP CONSTRAINT IF EXISTS analytics_identity_edges_person_id_fkey");
1953
+ await client.query("ALTER TABLE analytics_identity_edges DROP CONSTRAINT IF EXISTS analytics_identity_edges_identity_node_id_fkey");
1603
1954
  await client.query(`
1604
1955
  CREATE TABLE IF NOT EXISTS analytics_candidate_associations (
1605
1956
  id uuid PRIMARY KEY,
@@ -2255,9 +2606,7 @@ async function migrateAnalytics() {
2255
2606
  VALUES ('2026-08-05.1'), ('2026-08-05.2'), ('2026-08-05.3'), ('2026-08-05.4'), ('2026-08-05.5'), ('2026-08-05.6'), ('2026-08-05.7'), ('2026-08-05.8'), ('2026-08-05.9'), ('2026-08-05.10'), ('2026-08-05.11'), ('2026-08-05.12'), ('2026-08-05.13'), ('2026-08-05.14'), ('2026-08-26.1'), ('2026-08-26.2'), ('2026-08-26.3'), ('2026-08-26.4'), ('2026-08-27.1')
2256
2607
  ON CONFLICT (version) DO NOTHING
2257
2608
  `);
2258
- await client.query("COMMIT");
2259
2609
  } catch (error) {
2260
- await client.query("ROLLBACK");
2261
2610
  throw error;
2262
2611
  } finally {
2263
2612
  client.release();
@@ -2265,7 +2614,7 @@ async function migrateAnalytics() {
2265
2614
  }
2266
2615
  function normalizeSlug(value) {
2267
2616
  const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
2268
- return slug || `site-${randomBytes(4).toString("hex")}`;
2617
+ return slug || `site-${randomBytes2(4).toString("hex")}`;
2269
2618
  }
2270
2619
  function normalizeObservedHostname(origin) {
2271
2620
  if (!origin || origin === "null") return null;
@@ -2278,7 +2627,7 @@ function normalizeObservedHostname(origin) {
2278
2627
  }
2279
2628
  }
2280
2629
  function publicPixelId() {
2281
- return `px_${randomBytes(18).toString("base64url")}`;
2630
+ return `px_${randomBytes2(18).toString("base64url")}`;
2282
2631
  }
2283
2632
  function mapRows(rows) {
2284
2633
  return rows;
@@ -2316,7 +2665,7 @@ async function requireAnalyticsEditor(client, siteId, userId) {
2316
2665
  async function createAnalyticsSite(input) {
2317
2666
  const db = getAnalyticsPool();
2318
2667
  const client = await db.connect();
2319
- const id = randomUUID();
2668
+ const id = randomUUID2();
2320
2669
  const baseSlug = normalizeSlug(input.slug || input.name);
2321
2670
  try {
2322
2671
  await client.query("BEGIN");
@@ -2332,7 +2681,7 @@ async function createAnalyticsSite(input) {
2332
2681
  } catch (error) {
2333
2682
  const code = error.code;
2334
2683
  if (code !== "23505" || attempt === 2) throw error;
2335
- slug = `${baseSlug}-${randomBytes(2).toString("hex")}`;
2684
+ slug = `${baseSlug}-${randomBytes2(2).toString("hex")}`;
2336
2685
  }
2337
2686
  }
2338
2687
  await client.query(
@@ -2547,7 +2896,7 @@ async function createAnalyticsPixel(input) {
2547
2896
  VALUES ($1, $2, $3, $4, $5)
2548
2897
  RETURNING id, site_id, public_id, name, environment, status, created_at::text, NULL::text AS last_event_at`,
2549
2898
  [
2550
- randomUUID(),
2899
+ randomUUID2(),
2551
2900
  input.siteId,
2552
2901
  publicPixelId(),
2553
2902
  input.name.trim(),
@@ -2624,7 +2973,7 @@ async function setAnalyticsPixelDomainState(input) {
2624
2973
  SELECT $1, p.id, $4, $5 FROM analytics_pixels p WHERE p.id = $2 AND p.site_id = $3
2625
2974
  ON CONFLICT(pixel_id, hostname) DO UPDATE SET state = EXCLUDED.state, updated_at = now()
2626
2975
  RETURNING id`,
2627
- [randomUUID(), input.pixelId, input.siteId, hostname, input.state]
2976
+ [randomUUID2(), input.pixelId, input.siteId, hostname, input.state]
2628
2977
  );
2629
2978
  if (!result.rowCount)
2630
2979
  throw new AnalyticsRepositoryError(
@@ -2666,7 +3015,7 @@ async function upsertAnalyticsHostGroup(input) {
2666
3015
  ON CONFLICT(pixel_id, name) DO UPDATE SET
2667
3016
  auto_decorate=EXCLUDED.auto_decorate, updated_at=now()
2668
3017
  RETURNING id`,
2669
- [randomUUID(), input.siteId, input.pixelId, input.name.trim().slice(0, 120), Boolean(input.autoDecorate)]
3018
+ [randomUUID2(), input.siteId, input.pixelId, input.name.trim().slice(0, 120), Boolean(input.autoDecorate)]
2670
3019
  );
2671
3020
  await client.query("DELETE FROM analytics_host_group_members WHERE group_id=$1", [group.rows[0].id]);
2672
3021
  for (const hostname of hostnames)
@@ -2749,7 +3098,7 @@ async function prepareAnalyticsLinkerIssue(input) {
2749
3098
  VALUES ($1,$2,'visitor_id',$3)
2750
3099
  ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at=now()
2751
3100
  RETURNING id`,
2752
- [randomUUID(), siteId, identityHmac("visitor_id", input.visitorId)]
3101
+ [randomUUID2(), siteId, identityHmac("visitor_id", input.visitorId)]
2753
3102
  );
2754
3103
  const existingPerson = await client.query(
2755
3104
  `SELECT person_id FROM analytics_identity_edges
@@ -2760,19 +3109,20 @@ async function prepareAnalyticsLinkerIssue(input) {
2760
3109
  let personId = existingPerson.rows[0]?.person_id;
2761
3110
  if (!personId) {
2762
3111
  const person = await client.query(
2763
- `INSERT INTO analytics_people(id, site_id, crm_person_ref) VALUES ($1,$2,$3)
2764
- ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at=now() RETURNING id`,
2765
- [randomUUID(), siteId, `XRay::anonymous::${identityHmac("visitor_id", input.visitorId).slice(0, 24)}`]
3112
+ `INSERT INTO analytics_people(id, site_id, crm_person_ref, public_ref) VALUES ($1,$2,$3,$4)
3113
+ ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at=now(),
3114
+ public_ref=COALESCE(analytics_people.public_ref,EXCLUDED.public_ref) RETURNING id`,
3115
+ [randomUUID2(), siteId, `XRay::anonymous::${identityHmac("visitor_id", input.visitorId).slice(0, 24)}`, newAnalyticsPersonPublicRef()]
2766
3116
  );
2767
3117
  personId = person.rows[0].id;
2768
3118
  await client.query(
2769
3119
  `INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
2770
3120
  VALUES ($1,$2,$3,$4,'first_party_linker',0.980)
2771
3121
  ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at=now()`,
2772
- [randomUUID(), siteId, personId, node.rows[0].id]
3122
+ [randomUUID2(), siteId, personId, node.rows[0].id]
2773
3123
  );
2774
3124
  }
2775
- const tokenId = randomUUID();
3125
+ const tokenId = randomUUID2();
2776
3126
  await client.query(
2777
3127
  `INSERT INTO analytics_linker_tokens(
2778
3128
  id, site_id, pixel_id, host_group_id, identity_node_id,
@@ -2828,13 +3178,13 @@ async function redeemAnalyticsLinkerRecord(input) {
2828
3178
  VALUES ($1,$2,'visitor_id',$3)
2829
3179
  ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at=now()
2830
3180
  RETURNING id`,
2831
- [randomUUID(), input.siteId, identityHmac("visitor_id", input.visitorId)]
3181
+ [randomUUID2(), input.siteId, identityHmac("visitor_id", input.visitorId)]
2832
3182
  );
2833
3183
  await client.query(
2834
3184
  `INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
2835
3185
  VALUES ($1,$2,$3,$4,'cross_domain_linker',0.980)
2836
3186
  ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at=now()`,
2837
- [randomUUID(), input.siteId, personId, targetNode.rows[0].id]
3187
+ [randomUUID2(), input.siteId, personId, targetNode.rows[0].id]
2838
3188
  );
2839
3189
  await client.query(
2840
3190
  "UPDATE analytics_events SET person_id=$1 WHERE site_id=$2 AND visitor_id=$3 AND person_id IS NULL",
@@ -2858,6 +3208,133 @@ async function redeemAnalyticsLinkerRecord(input) {
2858
3208
  client.release();
2859
3209
  }
2860
3210
  }
3211
+ function analyticsIdentityResolutionAllowed(event) {
3212
+ return Boolean(
3213
+ event.consent?.analytics === "granted" && (event.consent.purposes?.enhanced_matching === "granted" || event.consent.adUserData === "granted") && event.consent.globalPrivacyControl !== true
3214
+ );
3215
+ }
3216
+ function analyticsIdentityPromotionAllowed(event) {
3217
+ return Boolean(event.identity && analyticsIdentityResolutionAllowed(event));
3218
+ }
3219
+ async function projectAnalyticsPixelEventConsent(client, input) {
3220
+ const visitorId = input.event.visitorId?.trim().slice(0, 240);
3221
+ const consent = input.event.consent;
3222
+ if (!visitorId || !consent) return null;
3223
+ const occurredAtMs = Date.parse(input.event.occurredAt);
3224
+ const serverNow = Date.now();
3225
+ if (!Number.isFinite(occurredAtMs) || occurredAtMs > serverNow + 5 * 6e4 || occurredAtMs < serverNow - 24 * 60 * 6e4)
3226
+ return null;
3227
+ const explicit = consent.purposes;
3228
+ const purposes = {
3229
+ siteAnalytics: explicit ? explicit.site_analytics === "granted" : consent.analytics === "granted",
3230
+ enhancedMatching: explicit ? explicit.enhanced_matching === "granted" : consent.adUserData === "granted",
3231
+ advertisingMeasurement: explicit ? explicit.advertising_measurement === "granted" : consent.adPersonalization === "granted"
3232
+ };
3233
+ const gpc = consent.globalPrivacyControl === true;
3234
+ if (gpc) {
3235
+ purposes.siteAnalytics = false;
3236
+ purposes.enhancedMatching = false;
3237
+ purposes.advertisingMeasurement = false;
3238
+ }
3239
+ const action = gpc ? "gpc" : Object.values(purposes).every(Boolean) ? "accept_all" : Object.values(purposes).every((value) => !value) ? "reject_all" : "save_choices";
3240
+ const occurredAt = new Date(occurredAtMs).toISOString();
3241
+ const previous = await client.query(
3242
+ `SELECT s.latest_receipt_id::text,s.site_analytics,s.enhanced_matching,s.advertising_measurement,s.gpc,r.action
3243
+ FROM analytics_consent_current_state s JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id
3244
+ WHERE s.site_id=$1 AND s.subject_ref=$2 FOR UPDATE`,
3245
+ [input.siteId, visitorId]
3246
+ );
3247
+ const prior = previous.rows[0];
3248
+ if (prior && prior.site_analytics === purposes.siteAnalytics && prior.enhanced_matching === purposes.enhancedMatching && prior.advertising_measurement === purposes.advertisingMeasurement && prior.gpc === gpc && prior.action === action) return prior.latest_receipt_id;
3249
+ const receiptId = randomUUID2();
3250
+ await client.query(
3251
+ `INSERT INTO analytics_consent_receipts(id,site_id,pixel_id,subject_ref,policy_version,jurisdiction_profile,
3252
+ necessary,site_analytics,enhanced_matching,advertising_measurement,gpc,action,source,presented_at,occurred_at,supersedes_id)
3253
+ VALUES($1,$2,$3,$4,1,'pixel',true,$5,$6,$7,$8,$9,'gateway',$10,$10,$11)`,
3254
+ [
3255
+ receiptId,
3256
+ input.siteId,
3257
+ input.pixelId,
3258
+ visitorId,
3259
+ purposes.siteAnalytics,
3260
+ purposes.enhancedMatching,
3261
+ purposes.advertisingMeasurement,
3262
+ gpc,
3263
+ action,
3264
+ occurredAt,
3265
+ prior?.latest_receipt_id ?? null
3266
+ ]
3267
+ );
3268
+ await client.query(
3269
+ `INSERT INTO analytics_consent_current_state(site_id,subject_ref,latest_receipt_id,policy_version,jurisdiction_profile,
3270
+ necessary,site_analytics,enhanced_matching,advertising_measurement,gpc,effective_at)
3271
+ VALUES($1,$2,$3,1,'pixel',true,$4,$5,$6,$7,$8)
3272
+ ON CONFLICT(site_id,subject_ref) DO UPDATE SET latest_receipt_id=EXCLUDED.latest_receipt_id,
3273
+ policy_version=EXCLUDED.policy_version,jurisdiction_profile=EXCLUDED.jurisdiction_profile,
3274
+ site_analytics=EXCLUDED.site_analytics,enhanced_matching=EXCLUDED.enhanced_matching,
3275
+ advertising_measurement=EXCLUDED.advertising_measurement,gpc=EXCLUDED.gpc,
3276
+ effective_at=EXCLUDED.effective_at,updated_at=now()
3277
+ WHERE EXCLUDED.effective_at>=analytics_consent_current_state.effective_at`,
3278
+ [
3279
+ input.siteId,
3280
+ visitorId,
3281
+ receiptId,
3282
+ purposes.siteAnalytics,
3283
+ purposes.enhancedMatching,
3284
+ purposes.advertisingMeasurement,
3285
+ gpc,
3286
+ occurredAt
3287
+ ]
3288
+ );
3289
+ return receiptId;
3290
+ }
3291
+ async function resolveAnalyticsConfirmedActivationIdentity(db, input) {
3292
+ if (!input.subjectRef) return { personId: null, activationEligible: false };
3293
+ const result = await db.query(
3294
+ `SELECT p.id::text AS person_id,s.advertising_measurement
3295
+ FROM analytics_people p
3296
+ JOIN analytics_consent_current_state s ON s.site_id=p.site_id AND s.subject_ref=$2
3297
+ JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id AND r.site_id=s.site_id
3298
+ WHERE p.site_id=$1
3299
+ AND (($3::uuid IS NOT NULL AND p.id=$3) OR ($4::text IS NOT NULL AND EXISTS (
3300
+ SELECT 1 FROM analytics_events e WHERE e.site_id=p.site_id AND e.event_id=$4
3301
+ AND e.person_id=p.id AND e.visitor_id=$2
3302
+ )))
3303
+ AND s.site_analytics=true AND s.gpc=false AND r.action NOT IN ('withdraw','gpc')
3304
+ AND (s.expires_at IS NULL OR s.expires_at>now()) LIMIT 1`,
3305
+ [input.siteId, input.subjectRef, input.personId ?? null, input.eventId ?? null]
3306
+ );
3307
+ const row = result.rows[0];
3308
+ return row ? { personId: row.person_id, activationEligible: row.advertising_measurement === true } : { personId: null, activationEligible: false };
3309
+ }
3310
+ async function hasCurrentAnalyticsIdentityConsent(client, siteId, visitorId, sessionId) {
3311
+ if (!visitorId?.trim() || !sessionId?.trim()) return false;
3312
+ const result = await client.query(
3313
+ `SELECT 1 FROM analytics_consent_current_state s
3314
+ JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id AND r.site_id=s.site_id
3315
+ WHERE s.site_id=$1 AND s.subject_ref=$2
3316
+ AND s.site_analytics=true AND s.enhanced_matching=true AND s.gpc=false
3317
+ AND r.action NOT IN ('withdraw','gpc')
3318
+ AND (s.expires_at IS NULL OR s.expires_at>now())
3319
+ AND EXISTS (SELECT 1 FROM analytics_sessions x
3320
+ WHERE x.site_id=s.site_id AND x.visitor_hmac=$3 AND x.session_hmac=$4)
3321
+ ORDER BY s.effective_at DESC LIMIT 1`,
3322
+ [siteId, visitorId, identityHmac("visitor_id", visitorId), identityHmac("session_id", sessionId)]
3323
+ );
3324
+ return Boolean(result.rowCount);
3325
+ }
3326
+ async function hasCurrentPersonIdentityConsent(client, siteId, personPublicRef) {
3327
+ const result = await client.query(
3328
+ `SELECT 1 FROM analytics_consent_current_state s
3329
+ JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id AND r.site_id=s.site_id
3330
+ WHERE s.site_id=$1 AND s.subject_ref=$2
3331
+ AND s.site_analytics=true AND s.enhanced_matching=true AND s.gpc=false
3332
+ AND r.action NOT IN ('withdraw','gpc')
3333
+ AND (s.expires_at IS NULL OR s.expires_at>now()) LIMIT 1`,
3334
+ [siteId, personPublicRef]
3335
+ );
3336
+ return Boolean(result.rowCount);
3337
+ }
2861
3338
  var MAX_ENGAGED_MS = 30 * 60 * 1e3;
2862
3339
  var ENGAGED_SESSION_MS = 1e4;
2863
3340
  function boundedEngagedMs(value) {
@@ -2926,7 +3403,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
2926
3403
  updated_at = now()
2927
3404
  RETURNING id`,
2928
3405
  [
2929
- randomUUID(),
3406
+ randomUUID2(),
2930
3407
  input.siteId,
2931
3408
  input.pixelId,
2932
3409
  visitorHmac,
@@ -2942,7 +3419,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
2942
3419
  input.event.source || input.event.medium || input.event.campaign || input.event.referrer || Object.keys(clickIds).length
2943
3420
  );
2944
3421
  if (!hasTouch) return;
2945
- const touchHash = createHash("sha256").update(
3422
+ const touchHash = createHash2("sha256").update(
2946
3423
  JSON.stringify({
2947
3424
  siteId: input.siteId,
2948
3425
  sessionHmac,
@@ -2963,7 +3440,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
2963
3440
  person_id = COALESCE(analytics_attribution_touches.person_id, EXCLUDED.person_id)
2964
3441
  RETURNING id`,
2965
3442
  [
2966
- randomUUID(),
3443
+ randomUUID2(),
2967
3444
  input.siteId,
2968
3445
  session.rows[0].id,
2969
3446
  input.personId ?? null,
@@ -2987,7 +3464,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
2987
3464
  }
2988
3465
  async function ingestAnalyticsEvents(input) {
2989
3466
  const db = getAnalyticsPool();
2990
- const requestId = `air_${randomUUID()}`;
3467
+ const requestId = `air_${randomUUID2()}`;
2991
3468
  const hostname = normalizeObservedHostname(input.origin);
2992
3469
  const pixelResult = await db.query(
2993
3470
  `SELECT p.id, p.site_id, p.status,
@@ -3008,7 +3485,7 @@ async function ingestAnalyticsEvents(input) {
3008
3485
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
3009
3486
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
3010
3487
  [
3011
- randomUUID(),
3488
+ randomUUID2(),
3012
3489
  requestId,
3013
3490
  pixel?.site_id ?? null,
3014
3491
  pixel?.id ?? null,
@@ -3033,7 +3510,7 @@ async function ingestAnalyticsEvents(input) {
3033
3510
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
3034
3511
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
3035
3512
  [
3036
- randomUUID(),
3513
+ randomUUID2(),
3037
3514
  requestId,
3038
3515
  pixel.site_id,
3039
3516
  pixel.id,
@@ -3058,7 +3535,7 @@ async function ingestAnalyticsEvents(input) {
3058
3535
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, rejected_count, reason_codes)
3059
3536
  VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
3060
3537
  [
3061
- randomUUID(),
3538
+ randomUUID2(),
3062
3539
  requestId,
3063
3540
  pixel.site_id,
3064
3541
  pixel.id,
@@ -3082,7 +3559,7 @@ async function ingestAnalyticsEvents(input) {
3082
3559
  ON CONFLICT(pixel_id, hostname) DO UPDATE
3083
3560
  SET last_seen_at = now(), updated_at = now()
3084
3561
  RETURNING state`,
3085
- [randomUUID(), pixel.id, hostname]
3562
+ [randomUUID2(), pixel.id, hostname]
3086
3563
  );
3087
3564
  if (domainResult.rows[0]?.state !== "approved") {
3088
3565
  await db.query(
@@ -3096,7 +3573,7 @@ async function ingestAnalyticsEvents(input) {
3096
3573
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
3097
3574
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
3098
3575
  [
3099
- randomUUID(),
3576
+ randomUUID2(),
3100
3577
  requestId,
3101
3578
  pixel.site_id,
3102
3579
  pixel.id,
@@ -3123,18 +3600,14 @@ async function ingestAnalyticsEvents(input) {
3123
3600
  await client.query("BEGIN");
3124
3601
  for (const event of input.events) {
3125
3602
  try {
3126
- const attribution = resolveAnalyticsAttribution(event);
3127
- const identifiedPersonId = event.identity && event.consent?.analytics === "granted" ? await linkAnalyticsIdentityInTransaction(client, {
3603
+ await projectAnalyticsPixelEventConsent(client, {
3128
3604
  siteId: pixel.site_id,
3129
- visitorId: event.visitorId,
3130
- sessionId: event.sessionId,
3131
- email: event.identity.email,
3132
- phone: event.identity.phone,
3133
- customerId: event.identity.customerId,
3134
- orderId: event.identity.orderId,
3135
- clickIds: event.clickIds
3136
- }) : null;
3137
- const resolvedPerson = identifiedPersonId ? { rows: [{ person_id: identifiedPersonId }] } : event.visitorId || event.sessionId ? await client.query(
3605
+ pixelId: pixel.id,
3606
+ event
3607
+ });
3608
+ const attribution = resolveAnalyticsAttribution(event);
3609
+ const eventIdentity = event.identity;
3610
+ const resolvedPerson = analyticsIdentityResolutionAllowed(event) && (event.visitorId || event.sessionId) ? await client.query(
3138
3611
  `SELECT e.person_id FROM analytics_identity_nodes n JOIN analytics_identity_edges e ON e.identity_node_id = n.id
3139
3612
  WHERE n.site_id = $1 AND ((n.kind = 'visitor_id' AND n.value_hmac = $2) OR (n.kind = 'session_id' AND n.value_hmac = $3))
3140
3613
  ORDER BY e.confidence DESC, e.last_seen_at DESC LIMIT 1`,
@@ -3156,7 +3629,7 @@ async function ingestAnalyticsEvents(input) {
3156
3629
  $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33
3157
3630
  ) ON CONFLICT(site_id, event_id) DO NOTHING`,
3158
3631
  [
3159
- randomUUID(),
3632
+ randomUUID2(),
3160
3633
  event.eventId,
3161
3634
  pixel.site_id,
3162
3635
  pixel.id,
@@ -3193,13 +3666,34 @@ async function ingestAnalyticsEvents(input) {
3193
3666
  );
3194
3667
  if (inserted.rowCount) {
3195
3668
  accepted += 1;
3669
+ const resolvedPersonId = resolvedPerson.rows[0]?.person_id;
3196
3670
  await materializeAnalyticsSessionAndTouch({
3197
3671
  client,
3198
3672
  siteId: pixel.site_id,
3199
3673
  pixelId: pixel.id,
3200
- personId: resolvedPerson.rows[0]?.person_id,
3674
+ personId: resolvedPersonId,
3201
3675
  event
3202
3676
  });
3677
+ if (resolvedPersonId && event.eventName === "identify") {
3678
+ await backfillAnalyticsConfirmedHistory(client, {
3679
+ siteId: pixel.site_id,
3680
+ personId: resolvedPersonId,
3681
+ visitorId: event.visitorId,
3682
+ sessionId: event.sessionId
3683
+ });
3684
+ }
3685
+ if (eventIdentity && analyticsIdentityPromotionAllowed(event)) {
3686
+ await linkAnalyticsIdentityInTransaction(client, {
3687
+ siteId: pixel.site_id,
3688
+ visitorId: event.visitorId,
3689
+ sessionId: event.sessionId,
3690
+ email: eventIdentity.email,
3691
+ phone: eventIdentity.phone,
3692
+ customerId: eventIdentity.customerId,
3693
+ orderId: eventIdentity.orderId,
3694
+ clickIds: event.clickIds
3695
+ });
3696
+ }
3203
3697
  } else {
3204
3698
  rejected += 1;
3205
3699
  reasonCodes.add("duplicate_event");
@@ -3222,7 +3716,7 @@ async function ingestAnalyticsEvents(input) {
3222
3716
  id, request_id, site_id, pixel_id, hostname, accepted_count, rejected_count, reason_codes
3223
3717
  ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
3224
3718
  [
3225
- randomUUID(),
3719
+ randomUUID2(),
3226
3720
  requestId,
3227
3721
  pixel.site_id,
3228
3722
  pixel.id,
@@ -3254,7 +3748,7 @@ function normalizeGeographyCode(value, max) {
3254
3748
  return normalized && /^[A-Z0-9-]+$/.test(normalized) ? normalized.slice(0, max) : null;
3255
3749
  }
3256
3750
  function pageFingerprint(scope, siteId, filters) {
3257
- return createHash("sha256").update(JSON.stringify({ scope, siteId, filters })).digest("base64url").slice(0, 18);
3751
+ return createHash2("sha256").update(JSON.stringify({ scope, siteId, filters })).digest("base64url").slice(0, 18);
3258
3752
  }
3259
3753
  function decodePageOffset(cursor, fingerprint) {
3260
3754
  if (!cursor) return 0;
@@ -3313,7 +3807,7 @@ async function createAnalyticsConversion(input) {
3313
3807
  ORDER BY e.confidence DESC LIMIT 1`,
3314
3808
  [input.siteId, identityHmac("session_id", input.sessionId)]
3315
3809
  ) : { rows: [] };
3316
- const proposedId = randomUUID();
3810
+ const proposedId = randomUUID2();
3317
3811
  const inserted = await client.query(
3318
3812
  `INSERT INTO analytics_conversions(
3319
3813
  id, source_event_id, site_id, pixel_id, session_id, conversion_kind,
@@ -3353,7 +3847,7 @@ async function createAnalyticsConversion(input) {
3353
3847
  ) VALUES ($1,$2,$3,$4,$5)
3354
3848
  ON CONFLICT(destination_id, conversion_id) DO NOTHING`,
3355
3849
  [
3356
- randomUUID(),
3850
+ randomUUID2(),
3357
3851
  destination.id,
3358
3852
  conversionId,
3359
3853
  resolvedPerson.rows[0]?.person_id ?? null,
@@ -4117,7 +4611,7 @@ async function createAnalyticsCampaignLink(input) {
4117
4611
  404
4118
4612
  );
4119
4613
  }
4120
- const shortCode = (input.shortCode?.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-") || randomBytes(5).toString("base64url").toLowerCase()).slice(0, 48);
4614
+ const shortCode = (input.shortCode?.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-") || randomBytes2(5).toString("base64url").toLowerCase()).slice(0, 48);
4121
4615
  try {
4122
4616
  const result = await db.query(
4123
4617
  `INSERT INTO analytics_campaign_links(
@@ -4126,7 +4620,7 @@ async function createAnalyticsCampaignLink(input) {
4126
4620
  ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
4127
4621
  RETURNING *, 0::int AS click_count`,
4128
4622
  [
4129
- randomUUID(),
4623
+ randomUUID2(),
4130
4624
  input.siteId,
4131
4625
  input.pixelId ?? null,
4132
4626
  input.name.trim(),
@@ -4197,7 +4691,7 @@ async function resolveAnalyticsCampaignLink(shortCode, referrer) {
4197
4691
  if (!row) return null;
4198
4692
  await db.query(
4199
4693
  `INSERT INTO analytics_campaign_clicks(id, link_id, referrer) VALUES ($1, $2, $3)`,
4200
- [randomUUID(), row.id, normalizeAnalyticsUrl(referrer || void 0)]
4694
+ [randomUUID2(), row.id, normalizeAnalyticsUrl(referrer || void 0)]
4201
4695
  );
4202
4696
  return buildTaggedCampaignUrl(row);
4203
4697
  }
@@ -4215,14 +4709,14 @@ async function createAnalyticsForm(input) {
4215
4709
  404
4216
4710
  );
4217
4711
  const baseSlug = normalizeSlug(input.name);
4218
- const slug = `${baseSlug}-${randomBytes(3).toString("hex")}`;
4219
- const publicId = `form_${randomBytes(18).toString("base64url")}`;
4712
+ const slug = `${baseSlug}-${randomBytes2(3).toString("hex")}`;
4713
+ const publicId = `form_${randomBytes2(18).toString("base64url")}`;
4220
4714
  const result = await db.query(
4221
4715
  `INSERT INTO analytics_forms(id, public_id, site_id, pixel_id, name, slug, fields, brand, submit_label, success_message, redirect_url, consent_text, status, created_by_user_id)
4222
4716
  VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14)
4223
4717
  RETURNING *, 0::int AS submission_count`,
4224
4718
  [
4225
- randomUUID(),
4719
+ randomUUID2(),
4226
4720
  publicId,
4227
4721
  input.siteId,
4228
4722
  input.pixelId,
@@ -4267,7 +4761,7 @@ async function getPublicAnalyticsForm(publicId) {
4267
4761
  return result.rows[0] ?? null;
4268
4762
  }
4269
4763
  async function recordAnalyticsFormSubmission(input) {
4270
- const id = randomUUID();
4764
+ const id = randomUUID2();
4271
4765
  await getAnalyticsPool().query(
4272
4766
  `INSERT INTO analytics_form_submissions(id, form_id, site_id, pixel_id, visitor_id, session_id, crm_person_ref, source, medium, campaign, crm_delivery_status, click_ids)
4273
4767
  VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb)`,
@@ -4311,18 +4805,262 @@ function sanitizeClickIds(value) {
4311
4805
  return output;
4312
4806
  }
4313
4807
  function identityHmac(kind, value) {
4314
- return createHmac("sha256", getSessionSecret()).update(`${kind}:${value.trim().toLowerCase()}`).digest("hex");
4808
+ return createHmac2("sha256", getSessionSecret()).update(`${kind}:${value.trim().toLowerCase()}`).digest("hex");
4809
+ }
4810
+ function newAnalyticsPersonPublicRef() {
4811
+ return `xrp_${randomBytes2(18).toString("base64url")}`;
4812
+ }
4813
+ async function ensurePrimaryAnalyticsIdentityNamespace(client, siteId, source) {
4814
+ let namespace = await client.query(
4815
+ "SELECT id FROM analytics_identity_namespaces WHERE site_id=$1 AND status='approved' ORDER BY version DESC LIMIT 1",
4816
+ [siteId]
4817
+ );
4818
+ if (!namespace.rows[0]) {
4819
+ await client.query(
4820
+ `INSERT INTO analytics_identity_namespaces(
4821
+ id,site_id,name,status,approved_hosts,approved_sources,version,approved_by_user_id,approved_at
4822
+ )
4823
+ SELECT $1,s.id,'X-Ray primary','approved',
4824
+ COALESCE((SELECT jsonb_agg(d.hostname ORDER BY d.hostname)
4825
+ FROM analytics_pixels p
4826
+ JOIN analytics_pixel_domains d ON d.pixel_id=p.id AND d.state='approved'
4827
+ WHERE p.site_id=s.id),'[]'::jsonb),
4828
+ $3::jsonb,1,s.owner_user_id,now()
4829
+ FROM analytics_sites s WHERE s.id=$2
4830
+ ON CONFLICT(site_id,name,version) DO NOTHING`,
4831
+ [randomUUID2(), siteId, JSON.stringify([source])]
4832
+ );
4833
+ namespace = await client.query(
4834
+ "SELECT id FROM analytics_identity_namespaces WHERE site_id=$1 AND status='approved' ORDER BY version DESC LIMIT 1",
4835
+ [siteId]
4836
+ );
4837
+ }
4838
+ const namespaceId = namespace.rows[0]?.id;
4839
+ if (!namespaceId)
4840
+ throw new AnalyticsRepositoryError(
4841
+ "identity_namespace_unavailable",
4842
+ "The Site identity graph could not be initialized.",
4843
+ 409
4844
+ );
4845
+ await client.query(
4846
+ `UPDATE analytics_identity_namespaces
4847
+ SET approved_sources=(SELECT jsonb_agg(value ORDER BY value)
4848
+ FROM (SELECT DISTINCT value
4849
+ FROM jsonb_array_elements_text(approved_sources || $3::jsonb)) merged_sources),
4850
+ approved_hosts=(SELECT COALESCE(jsonb_agg(hostname ORDER BY hostname),'[]'::jsonb)
4851
+ FROM (SELECT DISTINCT hostname
4852
+ FROM (SELECT jsonb_array_elements_text(approved_hosts) AS hostname
4853
+ UNION ALL
4854
+ SELECT d.hostname
4855
+ FROM analytics_pixels p
4856
+ JOIN analytics_pixel_domains d ON d.pixel_id=p.id AND d.state='approved'
4857
+ WHERE p.site_id=$2) hosts) distinct_hosts),
4858
+ updated_at=now()
4859
+ WHERE id=$1 AND site_id=$2`,
4860
+ [namespaceId, siteId, JSON.stringify([source])]
4861
+ );
4862
+ return namespaceId;
4863
+ }
4864
+ async function backfillAnalyticsConfirmedHistory(client, input) {
4865
+ if (!input.visitorId && !input.sessionId)
4866
+ return { events: 0, sessions: 0, touches: 0, conversions: 0 };
4867
+ const visitorHmac = input.visitorId ? identityHmac("visitor_id", input.visitorId) : null;
4868
+ const sessionHmac = input.sessionId ? identityHmac("session_id", input.sessionId) : null;
4869
+ const events = await client.query(
4870
+ `UPDATE analytics_events SET person_id = $1 WHERE site_id = $2 AND person_id IS NULL
4871
+ AND (($3::text IS NOT NULL AND visitor_id = $3) OR ($4::text IS NOT NULL AND session_id = $4))`,
4872
+ [input.personId, input.siteId, input.visitorId ?? null, input.sessionId ?? null]
4873
+ );
4874
+ const conversions = await client.query(
4875
+ `UPDATE analytics_conversions SET person_id = $1 WHERE site_id = $2 AND person_id IS NULL
4876
+ AND $3::text IS NOT NULL AND session_id = $3`,
4877
+ [input.personId, input.siteId, input.sessionId ?? null]
4878
+ );
4879
+ const sessions = await client.query(
4880
+ `UPDATE analytics_sessions SET person_id = $1
4881
+ WHERE site_id = $2 AND person_id IS NULL
4882
+ AND (($3::text IS NOT NULL AND visitor_hmac = $3)
4883
+ OR ($4::text IS NOT NULL AND session_hmac = $4))`,
4884
+ [input.personId, input.siteId, visitorHmac, sessionHmac]
4885
+ );
4886
+ const touches = await client.query(
4887
+ `UPDATE analytics_attribution_touches SET person_id = $1
4888
+ WHERE site_id = $2 AND person_id IS NULL
4889
+ AND (($3::text IS NOT NULL AND visitor_hmac = $3)
4890
+ OR ($4::text IS NOT NULL AND session_id IN (
4891
+ SELECT id FROM analytics_sessions
4892
+ WHERE site_id = $2 AND session_hmac = $4
4893
+ )))`,
4894
+ [input.personId, input.siteId, visitorHmac, sessionHmac]
4895
+ );
4896
+ return {
4897
+ events: events.rowCount ?? 0,
4898
+ sessions: sessions.rowCount ?? 0,
4899
+ touches: touches.rowCount ?? 0,
4900
+ conversions: conversions.rowCount ?? 0
4901
+ };
4902
+ }
4903
+ async function resolveAndMergeConfirmedAnalyticsPerson(client, input) {
4904
+ const matches = await client.query(
4905
+ `SELECT p.id
4906
+ FROM analytics_people p
4907
+ JOIN (
4908
+ SELECT DISTINCT e.person_id
4909
+ FROM analytics_identity_nodes n
4910
+ JOIN analytics_identity_edges e ON e.identity_node_id=n.id AND e.site_id=n.site_id
4911
+ WHERE n.site_id=$1 AND EXISTS (
4912
+ SELECT 1 FROM jsonb_to_recordset($2::jsonb) signal(kind text,value_hmac text)
4913
+ WHERE signal.kind=n.kind AND signal.value_hmac=n.value_hmac
4914
+ )
4915
+ ) matched ON matched.person_id=p.id
4916
+ WHERE p.site_id=$1 AND (p.namespace_id=$3 OR p.namespace_id IS NULL)
4917
+ ORDER BY p.first_seen_at ASC,p.id ASC
4918
+ FOR UPDATE OF p`,
4919
+ [
4920
+ input.siteId,
4921
+ JSON.stringify(input.signals.map((signal) => ({ kind: signal.kind, value_hmac: signal.valueHmac }))),
4922
+ input.namespaceId
4923
+ ]
4924
+ );
4925
+ const canonicalId = matches.rows[0]?.id;
4926
+ if (!canonicalId) {
4927
+ const inserted = await client.query(
4928
+ `INSERT INTO analytics_people(id, site_id, namespace_id, crm_person_ref, public_ref) VALUES ($1,$2,$3,$4,$5)
4929
+ ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at = now(),
4930
+ namespace_id=COALESCE(analytics_people.namespace_id,EXCLUDED.namespace_id),
4931
+ public_ref=COALESCE(analytics_people.public_ref,EXCLUDED.public_ref) RETURNING id`,
4932
+ [randomUUID2(), input.siteId, input.namespaceId, input.crmPersonRef, newAnalyticsPersonPublicRef()]
4933
+ );
4934
+ return inserted.rows[0].id;
4935
+ }
4936
+ await client.query(
4937
+ `UPDATE analytics_people SET last_seen_at=now(),
4938
+ namespace_id=COALESCE(namespace_id,$2),public_ref=COALESCE(public_ref,$3)
4939
+ WHERE id=$1 AND site_id=$4`,
4940
+ [canonicalId, input.namespaceId, newAnalyticsPersonPublicRef(), input.siteId]
4941
+ );
4942
+ const duplicateIds = matches.rows.slice(1).map((row) => row.id);
4943
+ if (duplicateIds.length === 0) return canonicalId;
4944
+ await client.query(
4945
+ `DELETE FROM analytics_identity_edges duplicate
4946
+ USING analytics_identity_edges canonical
4947
+ WHERE duplicate.site_id=$1 AND duplicate.person_id=ANY($2::uuid[])
4948
+ AND canonical.site_id=duplicate.site_id AND canonical.person_id=$3
4949
+ AND canonical.identity_node_id=duplicate.identity_node_id
4950
+ AND canonical.evidence_kind=duplicate.evidence_kind`,
4951
+ [input.siteId, duplicateIds, canonicalId]
4952
+ );
4953
+ await client.query(
4954
+ `UPDATE analytics_identity_edges SET person_id=$3,namespace_id=$4
4955
+ WHERE site_id=$1 AND person_id=ANY($2::uuid[])`,
4956
+ [input.siteId, duplicateIds, canonicalId, input.namespaceId]
4957
+ );
4958
+ for (const table of [
4959
+ "analytics_form_submissions",
4960
+ "analytics_events",
4961
+ "analytics_conversions",
4962
+ "analytics_sessions",
4963
+ "analytics_attribution_touches",
4964
+ "analytics_external_events",
4965
+ "analytics_restricted_data",
4966
+ "analytics_crm_outbound_jobs"
4967
+ ]) {
4968
+ await client.query(
4969
+ `UPDATE ${table} SET person_id=$3 WHERE site_id=$1 AND person_id=ANY($2::uuid[])`,
4970
+ [input.siteId, duplicateIds, canonicalId]
4971
+ );
4972
+ }
4973
+ await client.query(
4974
+ "UPDATE analytics_activation_jobs SET person_id=$2 WHERE person_id=ANY($1::uuid[])",
4975
+ [duplicateIds, canonicalId]
4976
+ );
4977
+ await client.query(
4978
+ `UPDATE analytics_candidate_associations SET promoted_person_id=$3,namespace_id=$4
4979
+ WHERE site_id=$1 AND promoted_person_id=ANY($2::uuid[])`,
4980
+ [input.siteId, duplicateIds, canonicalId, input.namespaceId]
4981
+ );
4982
+ await client.query(
4983
+ `DELETE FROM analytics_candidate_promotions duplicate
4984
+ USING analytics_candidate_promotions canonical
4985
+ WHERE duplicate.site_id=$1 AND duplicate.person_id=ANY($2::uuid[])
4986
+ AND canonical.site_id=duplicate.site_id AND canonical.person_id=$3
4987
+ AND canonical.association_id=duplicate.association_id`,
4988
+ [input.siteId, duplicateIds, canonicalId]
4989
+ );
4990
+ await client.query(
4991
+ `UPDATE analytics_candidate_promotions SET person_id=$3,namespace_id=$4
4992
+ WHERE site_id=$1 AND person_id=ANY($2::uuid[])`,
4993
+ [input.siteId, duplicateIds, canonicalId, input.namespaceId]
4994
+ );
4995
+ await client.query(
4996
+ `DELETE FROM analytics_crm_person_mappings duplicate
4997
+ USING analytics_crm_person_mappings canonical
4998
+ WHERE duplicate.site_id=$1 AND duplicate.person_id=ANY($2::uuid[])
4999
+ AND canonical.site_id=duplicate.site_id AND canonical.person_id=$3
5000
+ AND canonical.connection_id=duplicate.connection_id`,
5001
+ [input.siteId, duplicateIds, canonicalId]
5002
+ );
5003
+ await client.query(
5004
+ `UPDATE analytics_crm_person_mappings SET person_id=$3,namespace_id=$4
5005
+ WHERE site_id=$1 AND person_id=ANY($2::uuid[])`,
5006
+ [input.siteId, duplicateIds, canonicalId, input.namespaceId]
5007
+ );
5008
+ await client.query(
5009
+ `DELETE FROM analytics_prediction_results duplicate
5010
+ USING analytics_prediction_results canonical
5011
+ WHERE duplicate.site_id=$1 AND duplicate.person_id=ANY($2::uuid[])
5012
+ AND canonical.site_id=duplicate.site_id AND canonical.person_id=$3
5013
+ AND canonical.manifest_id=duplicate.manifest_id`,
5014
+ [input.siteId, duplicateIds, canonicalId]
5015
+ );
5016
+ for (const table of ["analytics_score_evaluations", "analytics_prediction_results"]) {
5017
+ await client.query(
5018
+ `UPDATE ${table} SET person_id=$3,namespace_id=$4
5019
+ WHERE site_id=$1 AND person_id=ANY($2::uuid[])`,
5020
+ [input.siteId, duplicateIds, canonicalId, input.namespaceId]
5021
+ );
5022
+ }
5023
+ await client.query(
5024
+ "UPDATE analytics_identity_profile_access_audits SET person_id=$3 WHERE site_id=$1 AND person_id=ANY($2::uuid[])",
5025
+ [input.siteId, duplicateIds, canonicalId]
5026
+ );
5027
+ await client.query(
5028
+ "UPDATE analytics_identity_tombstones SET person_id=$3 WHERE site_id=$1 AND person_id=ANY($2::uuid[])",
5029
+ [input.siteId, duplicateIds, canonicalId]
5030
+ );
5031
+ await client.query(
5032
+ "DELETE FROM analytics_people WHERE site_id=$1 AND id=ANY($2::uuid[])",
5033
+ [input.siteId, duplicateIds]
5034
+ );
5035
+ return canonicalId;
4315
5036
  }
4316
5037
  async function linkAnalyticsIdentityInTransaction(client, input) {
4317
5038
  const stable = input.customerId || input.orderId || input.email || input.phone;
4318
5039
  if (!stable) return null;
5040
+ if (!await hasCurrentAnalyticsIdentityConsent(
5041
+ client,
5042
+ input.siteId,
5043
+ input.visitorId,
5044
+ input.sessionId
5045
+ )) return null;
5046
+ const namespaceId = await ensurePrimaryAnalyticsIdentityNamespace(client, input.siteId, "pixel");
5047
+ const tombstoneSignals = [
5048
+ ...input.email ? [{ kind: "email", valueHmac: identityHmac("email", input.email) }] : [],
5049
+ ...input.phone ? [{ kind: "phone", valueHmac: identityHmac("phone", input.phone) }] : [],
5050
+ ...input.customerId ? [{ kind: "customer_id", valueHmac: identityHmac("customer_id", input.customerId) }] : [],
5051
+ ...input.orderId ? [{ kind: "customer_id", valueHmac: identityHmac("customer_id", `order:${input.orderId}`) }] : []
5052
+ ];
5053
+ await assertAnalyticsIdentitySignalsNotTombstoned(client, {
5054
+ siteId: input.siteId,
5055
+ signals: tombstoneSignals
5056
+ });
4319
5057
  const crmPersonRef = `XRay::${identityHmac("person_ref", stable).slice(0, 24)}`;
4320
- const person = await client.query(
4321
- `INSERT INTO analytics_people(id, site_id, crm_person_ref) VALUES ($1,$2,$3)
4322
- ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at = now() RETURNING id`,
4323
- [randomUUID(), input.siteId, crmPersonRef]
4324
- );
4325
- const personId = person.rows[0].id;
5058
+ const personId = await resolveAndMergeConfirmedAnalyticsPerson(client, {
5059
+ siteId: input.siteId,
5060
+ namespaceId,
5061
+ crmPersonRef,
5062
+ signals: tombstoneSignals
5063
+ });
4326
5064
  const signals = [];
4327
5065
  if (input.visitorId)
4328
5066
  signals.push({ kind: "visitor_id", value: input.visitorId, evidence: "pixel_identify", confidence: 0.98 });
@@ -4340,30 +5078,66 @@ async function linkAnalyticsIdentityInTransaction(client, input) {
4340
5078
  signals.push({ kind: "click_id", value: `${platform}:${clickId}`, evidence: platform, confidence: 0.9 });
4341
5079
  for (const signal of signals) {
4342
5080
  const node = await client.query(
4343
- `INSERT INTO analytics_identity_nodes(id, site_id, kind, value_hmac) VALUES ($1,$2,$3,$4)
4344
- ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now() RETURNING id`,
4345
- [randomUUID(), input.siteId, signal.kind, identityHmac(signal.kind, signal.value)]
5081
+ `INSERT INTO analytics_identity_nodes(id, site_id, namespace_id, kind, value_hmac) VALUES ($1,$2,$3,$4,$5)
5082
+ ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now(),
5083
+ namespace_id=COALESCE(analytics_identity_nodes.namespace_id,EXCLUDED.namespace_id) RETURNING id`,
5084
+ [randomUUID2(), input.siteId, namespaceId, signal.kind, identityHmac(signal.kind, signal.value)]
4346
5085
  );
4347
5086
  await client.query(
4348
- `INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
4349
- VALUES ($1,$2,$3,$4,$5,$6)
5087
+ `INSERT INTO analytics_identity_edges(id, site_id, namespace_id, person_id, identity_node_id, evidence_kind, confidence)
5088
+ VALUES ($1,$2,$3,$4,$5,$6,$7)
4350
5089
  ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE
4351
- SET last_seen_at = now(), confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence)`,
4352
- [randomUUID(), input.siteId, personId, node.rows[0].id, signal.evidence, signal.confidence]
5090
+ SET last_seen_at = now(), confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence),
5091
+ namespace_id=COALESCE(analytics_identity_edges.namespace_id,EXCLUDED.namespace_id)`,
5092
+ [randomUUID2(), input.siteId, namespaceId, personId, node.rows[0].id, signal.evidence, signal.confidence]
4353
5093
  );
4354
5094
  }
5095
+ await backfillAnalyticsConfirmedHistory(client, {
5096
+ siteId: input.siteId,
5097
+ personId,
5098
+ visitorId: input.visitorId,
5099
+ sessionId: input.sessionId
5100
+ });
5101
+ if (input.customerId) {
5102
+ await writeAnalyticsConfirmedIdentityProfile(client, {
5103
+ siteId: input.siteId,
5104
+ personId,
5105
+ consentSubjectRefs: [input.visitorId ?? "", input.sessionId ?? ""],
5106
+ evidenceType: "authenticated_user",
5107
+ email: input.email,
5108
+ phone: input.phone
5109
+ });
5110
+ }
4355
5111
  return personId;
4356
5112
  }
4357
5113
  async function linkAnalyticsFormIdentity(input) {
4358
5114
  const client = await getAnalyticsPool().connect();
4359
5115
  try {
4360
5116
  await client.query("BEGIN");
4361
- const person = await client.query(
4362
- `INSERT INTO analytics_people(id, site_id, crm_person_ref) VALUES ($1,$2,$3)
4363
- ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at = now() RETURNING id`,
4364
- [randomUUID(), input.siteId, input.crmPersonRef]
4365
- );
4366
- const personId = person.rows[0].id;
5117
+ if (!await hasCurrentAnalyticsIdentityConsent(
5118
+ client,
5119
+ input.siteId,
5120
+ input.visitorId,
5121
+ input.sessionId
5122
+ )) {
5123
+ await client.query("COMMIT");
5124
+ return { personId: null, linkedSignals: 0 };
5125
+ }
5126
+ const namespaceId = await ensurePrimaryAnalyticsIdentityNamespace(client, input.siteId, "form");
5127
+ const deterministicSignals = [
5128
+ ...input.email ? [{ kind: "email", valueHmac: identityHmac("email", input.email) }] : [],
5129
+ ...input.phone ? [{ kind: "phone", valueHmac: identityHmac("phone", input.phone) }] : []
5130
+ ];
5131
+ await assertAnalyticsIdentitySignalsNotTombstoned(client, {
5132
+ siteId: input.siteId,
5133
+ signals: deterministicSignals
5134
+ });
5135
+ const personId = await resolveAndMergeConfirmedAnalyticsPerson(client, {
5136
+ siteId: input.siteId,
5137
+ namespaceId,
5138
+ crmPersonRef: input.crmPersonRef,
5139
+ signals: deterministicSignals
5140
+ });
4367
5141
  const signals = [];
4368
5142
  if (input.visitorId)
4369
5143
  signals.push({
@@ -4405,22 +5179,27 @@ async function linkAnalyticsFormIdentity(input) {
4405
5179
  }
4406
5180
  for (const signal of signals) {
4407
5181
  const node = await client.query(
4408
- `INSERT INTO analytics_identity_nodes(id, site_id, kind, value_hmac) VALUES ($1,$2,$3,$4)
4409
- ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now() RETURNING id`,
5182
+ `INSERT INTO analytics_identity_nodes(id, site_id, namespace_id, kind, value_hmac) VALUES ($1,$2,$3,$4,$5)
5183
+ ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now(),
5184
+ namespace_id=COALESCE(analytics_identity_nodes.namespace_id,EXCLUDED.namespace_id) RETURNING id`,
4410
5185
  [
4411
- randomUUID(),
5186
+ randomUUID2(),
4412
5187
  input.siteId,
5188
+ namespaceId,
4413
5189
  signal.kind,
4414
5190
  identityHmac(signal.kind, signal.value)
4415
5191
  ]
4416
5192
  );
4417
5193
  await client.query(
4418
- `INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
4419
- VALUES ($1,$2,$3,$4,$5,$6)
4420
- ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at = now(), confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence)`,
5194
+ `INSERT INTO analytics_identity_edges(id, site_id, namespace_id, person_id, identity_node_id, evidence_kind, confidence)
5195
+ VALUES ($1,$2,$3,$4,$5,$6,$7)
5196
+ ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at = now(),
5197
+ confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence),
5198
+ namespace_id=COALESCE(analytics_identity_edges.namespace_id,EXCLUDED.namespace_id)`,
4421
5199
  [
4422
- randomUUID(),
5200
+ randomUUID2(),
4423
5201
  input.siteId,
5202
+ namespaceId,
4424
5203
  personId,
4425
5204
  node.rows[0].id,
4426
5205
  signal.evidence,
@@ -4434,42 +5213,80 @@ async function linkAnalyticsFormIdentity(input) {
4434
5213
  [personId, input.submissionId, input.siteId]
4435
5214
  );
4436
5215
  if (input.visitorId || input.sessionId) {
4437
- const visitorHmac = input.visitorId ? identityHmac("visitor_id", input.visitorId) : null;
4438
- const sessionHmac = input.sessionId ? identityHmac("session_id", input.sessionId) : null;
4439
- await client.query(
4440
- `UPDATE analytics_events SET person_id = $1 WHERE site_id = $2 AND person_id IS NULL
4441
- AND (($3::text IS NOT NULL AND visitor_id = $3) OR ($4::text IS NOT NULL AND session_id = $4))`,
4442
- [
4443
- personId,
4444
- input.siteId,
4445
- input.visitorId ?? null,
4446
- input.sessionId ?? null
4447
- ]
4448
- );
4449
- await client.query(
4450
- `UPDATE analytics_conversions SET person_id = $1 WHERE site_id = $2 AND person_id IS NULL AND $3::text IS NOT NULL AND session_id = $3`,
4451
- [personId, input.siteId, input.sessionId ?? null]
4452
- );
4453
- await client.query(
4454
- `UPDATE analytics_sessions SET person_id = $1
4455
- WHERE site_id = $2 AND person_id IS NULL
4456
- AND (($3::text IS NOT NULL AND visitor_hmac = $3)
4457
- OR ($4::text IS NOT NULL AND session_hmac = $4))`,
4458
- [personId, input.siteId, visitorHmac, sessionHmac]
5216
+ await backfillAnalyticsConfirmedHistory(client, {
5217
+ siteId: input.siteId,
5218
+ personId,
5219
+ visitorId: input.visitorId,
5220
+ sessionId: input.sessionId
5221
+ });
5222
+ }
5223
+ await writeAnalyticsConfirmedIdentityProfile(client, {
5224
+ siteId: input.siteId,
5225
+ personId,
5226
+ consentSubjectRefs: [input.visitorId ?? ""],
5227
+ evidenceType: input.evidenceType ?? "first_party_form",
5228
+ displayName: input.displayName,
5229
+ email: input.email,
5230
+ phone: input.phone
5231
+ });
5232
+ await client.query("COMMIT");
5233
+ return { personId, linkedSignals: signals.length };
5234
+ } catch (error) {
5235
+ await client.query("ROLLBACK");
5236
+ throw error;
5237
+ } finally {
5238
+ client.release();
5239
+ }
5240
+ }
5241
+ async function enrichAnalyticsExistingCrmIdentity(input) {
5242
+ const client = await getAnalyticsPool().connect();
5243
+ try {
5244
+ await client.query("BEGIN");
5245
+ const existing = await client.query(
5246
+ `SELECT p.id,p.public_ref,p.namespace_id FROM analytics_people p
5247
+ JOIN analytics_identity_namespaces n ON n.id=p.namespace_id AND n.site_id=p.site_id AND n.status='approved'
5248
+ WHERE p.site_id=$1 AND p.crm_person_ref=$2 LIMIT 1 FOR UPDATE OF p`,
5249
+ [input.siteId, input.crmPersonRef]
5250
+ );
5251
+ const person = existing.rows[0];
5252
+ if (!person || !await hasCurrentPersonIdentityConsent(client, input.siteId, person.public_ref)) {
5253
+ await client.query("COMMIT");
5254
+ return { personId: null, linkedSignals: 0 };
5255
+ }
5256
+ const signals = [
5257
+ ...input.email ? [{ kind: "email", value: input.email, confidence: 1 }] : [],
5258
+ ...input.phone ? [{ kind: "phone", value: input.phone, confidence: 0.99 }] : []
5259
+ ];
5260
+ await assertAnalyticsIdentitySignalsNotTombstoned(client, {
5261
+ siteId: input.siteId,
5262
+ signals: signals.map((signal) => ({ kind: signal.kind, valueHmac: identityHmac(signal.kind, signal.value) }))
5263
+ });
5264
+ for (const signal of signals) {
5265
+ const node = await client.query(
5266
+ `INSERT INTO analytics_identity_nodes(id,site_id,namespace_id,kind,value_hmac) VALUES($1,$2,$3,$4,$5)
5267
+ ON CONFLICT(site_id,kind,value_hmac) DO UPDATE SET last_seen_at=now(),
5268
+ namespace_id=COALESCE(analytics_identity_nodes.namespace_id,EXCLUDED.namespace_id) RETURNING id`,
5269
+ [randomUUID2(), input.siteId, person.namespace_id, signal.kind, identityHmac(signal.kind, signal.value)]
4459
5270
  );
4460
5271
  await client.query(
4461
- `UPDATE analytics_attribution_touches SET person_id = $1
4462
- WHERE site_id = $2 AND person_id IS NULL
4463
- AND (($3::text IS NOT NULL AND visitor_hmac = $3)
4464
- OR ($4::text IS NOT NULL AND session_id IN (
4465
- SELECT id FROM analytics_sessions
4466
- WHERE site_id = $2 AND session_hmac = $4
4467
- )))`,
4468
- [personId, input.siteId, visitorHmac, sessionHmac]
5272
+ `INSERT INTO analytics_identity_edges(id,site_id,namespace_id,person_id,identity_node_id,evidence_kind,confidence)
5273
+ VALUES($1,$2,$3,$4,$5,'crm_contact',$6)
5274
+ ON CONFLICT(person_id,identity_node_id,evidence_kind) DO UPDATE SET last_seen_at=now(),
5275
+ confidence=greatest(analytics_identity_edges.confidence,EXCLUDED.confidence)`,
5276
+ [randomUUID2(), input.siteId, person.namespace_id, person.id, node.rows[0].id, signal.confidence]
4469
5277
  );
4470
5278
  }
5279
+ await writeAnalyticsConfirmedIdentityProfile(client, {
5280
+ siteId: input.siteId,
5281
+ personId: person.id,
5282
+ consentSubjectRefs: [person.public_ref],
5283
+ evidenceType: "crm_contact",
5284
+ displayName: input.displayName,
5285
+ email: input.email,
5286
+ phone: input.phone
5287
+ });
4471
5288
  await client.query("COMMIT");
4472
- return { personId, linkedSignals: signals.length };
5289
+ return { personId: person.id, linkedSignals: signals.length };
4473
5290
  } catch (error) {
4474
5291
  await client.query("ROLLBACK");
4475
5292
  throw error;
@@ -4536,7 +5353,7 @@ async function createAnalyticsCrmImport(input) {
4536
5353
  const db = getAnalyticsPool();
4537
5354
  await requireEditor(db, input.siteId, input.userId);
4538
5355
  const client = await db.connect();
4539
- const id = randomUUID();
5356
+ const id = randomUUID2();
4540
5357
  try {
4541
5358
  await client.query("BEGIN");
4542
5359
  await client.query(
@@ -4556,7 +5373,7 @@ async function createAnalyticsCrmImport(input) {
4556
5373
  await client.query(
4557
5374
  `INSERT INTO analytics_crm_import_rows(id, import_id, crm_person_ref, payload_ciphertext)
4558
5375
  VALUES ($1,$2,$3,$4) ON CONFLICT(import_id, crm_person_ref) DO NOTHING`,
4559
- [randomUUID(), id, row.crmPersonRef, row.payloadCiphertext]
5376
+ [randomUUID2(), id, row.crmPersonRef, row.payloadCiphertext]
4560
5377
  );
4561
5378
  }
4562
5379
  await client.query("COMMIT");
@@ -4679,7 +5496,7 @@ async function createAnalyticsActivationDestination(input) {
4679
5496
  `INSERT INTO analytics_activation_destinations(id, site_id, platform, name, connection_ref, external_dataset_id, event_mapping, provider_config, created_by_user_id)
4680
5497
  VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9) RETURNING *`,
4681
5498
  [
4682
- randomUUID(),
5499
+ randomUUID2(),
4683
5500
  input.siteId,
4684
5501
  input.platform,
4685
5502
  input.name.trim(),
@@ -4826,8 +5643,8 @@ async function testAnalyticsActivationDestination(input) {
4826
5643
  400
4827
5644
  );
4828
5645
  }
4829
- const testId = randomUUID();
4830
- const syntheticHash = createHash("sha256").update(`xray-provider-test:${testId}`).digest("hex");
5646
+ const testId = randomUUID2();
5647
+ const syntheticHash = createHash2("sha256").update(`xray-provider-test:${testId}`).digest("hex");
4831
5648
  const providerConfig = destination.provider_config ?? {};
4832
5649
  const payload = {
4833
5650
  eventId: `xray-test:${testId}`,
@@ -5064,7 +5881,7 @@ async function queueAnalyticsActivation(input) {
5064
5881
  `INSERT INTO analytics_activation_jobs(id, destination_id, conversion_id, person_id, payload_ciphertext)
5065
5882
  VALUES ($1,$2,$3,$4,$5) ON CONFLICT(destination_id, conversion_id) DO NOTHING`,
5066
5883
  [
5067
- randomUUID(),
5884
+ randomUUID2(),
5068
5885
  destination.id,
5069
5886
  input.conversionId,
5070
5887
  input.personId ?? null,
@@ -5076,7 +5893,7 @@ async function queueAnalyticsActivation(input) {
5076
5893
  return queued;
5077
5894
  }
5078
5895
  async function queueAnalyticsFormDelivery(input) {
5079
- const id = randomUUID();
5896
+ const id = randomUUID2();
5080
5897
  await getAnalyticsPool().query(
5081
5898
  `INSERT INTO analytics_form_delivery_jobs(id, submission_id, owner_user_id, payload_ciphertext, last_error_code)
5082
5899
  VALUES ($1,$2,$3,$4,$5)`,
@@ -5233,7 +6050,7 @@ async function analyticsHealth(siteId, userId) {
5233
6050
  }
5234
6051
  async function refreshAnalyticsDailyRollups(input) {
5235
6052
  const db = getAnalyticsPool();
5236
- const runId = randomUUID();
6053
+ const runId = randomUUID2();
5237
6054
  await db.query(
5238
6055
  `INSERT INTO analytics_rollup_runs(id, window_start, window_end, status) VALUES ($1, $2, $3, 'running')`,
5239
6056
  [runId, input.start, input.end]
@@ -5430,7 +6247,7 @@ async function createAnalyticsExport(input) {
5430
6247
  `Generated ${(/* @__PURE__ */ new Date()).toISOString()} from the governed ${input.report} report contract.`
5431
6248
  ].join("\n");
5432
6249
  }
5433
- const id = randomUUID();
6250
+ const id = randomUUID2();
5434
6251
  const inserted = await getAnalyticsPool().query(
5435
6252
  `INSERT INTO analytics_exports(id, site_id, requested_by_user_id, idempotency_key, report, format, filters, content)
5436
6253
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
@@ -5465,6 +6282,13 @@ async function createAnalyticsExport(input) {
5465
6282
  }
5466
6283
 
5467
6284
  export {
6285
+ encryptAnalyticsRestricted,
6286
+ decryptAnalyticsRestricted,
6287
+ analyticsIdentityHmac,
6288
+ signAnalyticsWebhook,
6289
+ verifyAnalyticsWebhookSignature,
6290
+ AnalyticsIdentityProfileError,
6291
+ resolveAnalyticsMaskedIdentityProfile,
5468
6292
  AnalyticsProviderRegistryError,
5469
6293
  getAnalyticsProviderDefinition,
5470
6294
  assertConnectionSupportsProvider,
@@ -5492,6 +6316,10 @@ export {
5492
6316
  listAnalyticsHostGroups,
5493
6317
  prepareAnalyticsLinkerIssue,
5494
6318
  redeemAnalyticsLinkerRecord,
6319
+ analyticsIdentityResolutionAllowed,
6320
+ analyticsIdentityPromotionAllowed,
6321
+ projectAnalyticsPixelEventConsent,
6322
+ resolveAnalyticsConfirmedActivationIdentity,
5495
6323
  MAX_ENGAGED_MS,
5496
6324
  ENGAGED_SESSION_MS,
5497
6325
  normalizeAnalyticsPath,
@@ -5520,7 +6348,10 @@ export {
5520
6348
  recordAnalyticsFormSubmission,
5521
6349
  sanitizeClickIds,
5522
6350
  identityHmac,
6351
+ backfillAnalyticsConfirmedHistory,
6352
+ linkAnalyticsIdentityInTransaction,
5523
6353
  linkAnalyticsFormIdentity,
6354
+ enrichAnalyticsExistingCrmIdentity,
5524
6355
  listAnalyticsPeople,
5525
6356
  getAnalyticsPersonJourney,
5526
6357
  createAnalyticsCrmImport,