mcp-scraper 0.72.7 → 0.73.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 (35) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/README.md +16 -2
  3. package/dist/{analytics-repository-6YLRSWYO.js → analytics-repository-MSLJBHLU.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-SR7GSLEA.js → chunk-5EWXRBEH.js} +816 -113
  9. package/dist/{chunk-FVL4GUTP.js → chunk-EQJRPDUS.js} +2 -2
  10. package/dist/{chunk-RRZAYUEX.js → chunk-JIWGG7Z4.js} +1 -1
  11. package/dist/{chunk-ZSVXNLLM.js → chunk-KRIIVDV2.js} +188 -14
  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-N6MCRSQW.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",
@@ -1289,6 +1544,33 @@ async function migrateAnalytics() {
1289
1544
  UNIQUE(site_id, purpose, subject_ref)
1290
1545
  )
1291
1546
  `);
1547
+ await client.query(`
1548
+ CREATE TABLE IF NOT EXISTS analytics_identity_tombstones (
1549
+ id uuid PRIMARY KEY,
1550
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1551
+ person_id uuid,
1552
+ kind text NOT NULL,
1553
+ value_hmac text NOT NULL,
1554
+ reason text NOT NULL CHECK (reason IN ('subject_deletion')),
1555
+ created_at timestamptz NOT NULL DEFAULT now(),
1556
+ UNIQUE(site_id,kind,value_hmac)
1557
+ )
1558
+ `);
1559
+ await client.query(`
1560
+ CREATE TABLE IF NOT EXISTS analytics_identity_profile_access_audits (
1561
+ id uuid PRIMARY KEY,
1562
+ site_id uuid NOT NULL REFERENCES analytics_sites(id) ON DELETE CASCADE,
1563
+ person_id uuid,
1564
+ actor_user_id bigint NOT NULL,
1565
+ subject_ref_hmac text NOT NULL,
1566
+ purpose text NOT NULL CHECK (purpose IN ('journey_identity_display')),
1567
+ result text NOT NULL CHECK (result IN ('returned_masked','not_found','unavailable','rate_limited')),
1568
+ created_at timestamptz NOT NULL DEFAULT now()
1569
+ )
1570
+ `);
1571
+ await client.query(
1572
+ "CREATE INDEX IF NOT EXISTS analytics_identity_profile_access_rate ON analytics_identity_profile_access_audits(site_id,actor_user_id,created_at DESC)"
1573
+ );
1292
1574
  await client.query(
1293
1575
  `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
1576
  );
@@ -1390,6 +1672,11 @@ async function migrateAnalytics() {
1390
1672
  await client.query(
1391
1673
  `ALTER TABLE analytics_people ADD COLUMN IF NOT EXISTS public_ref text`
1392
1674
  );
1675
+ await client.query(
1676
+ `UPDATE analytics_people
1677
+ SET public_ref = 'xrp_' || substr(md5(random()::text || clock_timestamp()::text || id::text || site_id::text), 1, 24)
1678
+ WHERE public_ref IS NULL`
1679
+ );
1393
1680
  await client.query(
1394
1681
  `ALTER TABLE analytics_events ADD COLUMN IF NOT EXISTS event_definition_id uuid REFERENCES analytics_event_definitions(id) ON DELETE SET NULL`
1395
1682
  );
@@ -1554,6 +1841,9 @@ async function migrateAnalytics() {
1554
1841
  PRIMARY KEY(site_id, subject_ref)
1555
1842
  )
1556
1843
  `);
1844
+ await client.query(
1845
+ "ALTER TABLE analytics_restricted_data ADD COLUMN IF NOT EXISTS consent_receipt_id uuid REFERENCES analytics_consent_receipts(id) ON DELETE CASCADE"
1846
+ );
1557
1847
  await client.query(`
1558
1848
  CREATE TABLE IF NOT EXISTS analytics_identity_namespaces (
1559
1849
  id uuid PRIMARY KEY,
@@ -1586,13 +1876,19 @@ async function migrateAnalytics() {
1586
1876
  )
1587
1877
  `);
1588
1878
  await client.query(
1589
- `ALTER TABLE analytics_people ADD COLUMN IF NOT EXISTS namespace_id uuid REFERENCES analytics_identity_namespaces(id) ON DELETE RESTRICT`
1879
+ `ALTER TABLE analytics_people ADD COLUMN IF NOT EXISTS namespace_id uuid`
1590
1880
  );
1591
1881
  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`
1882
+ `ALTER TABLE analytics_identity_nodes ADD COLUMN IF NOT EXISTS namespace_id uuid`
1593
1883
  );
1594
1884
  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`
1885
+ `ALTER TABLE analytics_identity_edges ADD COLUMN IF NOT EXISTS namespace_id uuid`
1886
+ );
1887
+ await client.query(
1888
+ "CREATE UNIQUE INDEX IF NOT EXISTS analytics_people_site_ref ON analytics_people(id, site_id)"
1889
+ );
1890
+ await client.query(
1891
+ "CREATE UNIQUE INDEX IF NOT EXISTS analytics_identity_nodes_site_ref ON analytics_identity_nodes(id, site_id)"
1596
1892
  );
1597
1893
  await client.query(
1598
1894
  "CREATE UNIQUE INDEX IF NOT EXISTS analytics_identity_nodes_namespace_ref ON analytics_identity_nodes(id, site_id, namespace_id)"
@@ -1600,6 +1896,62 @@ async function migrateAnalytics() {
1600
1896
  await client.query(
1601
1897
  "CREATE UNIQUE INDEX IF NOT EXISTS analytics_people_namespace_ref ON analytics_people(id, site_id, namespace_id)"
1602
1898
  );
1899
+ await client.query(`
1900
+ DO $$
1901
+ BEGIN
1902
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_people_namespace_site_fkey') THEN
1903
+ ALTER TABLE analytics_people ADD CONSTRAINT analytics_people_namespace_site_fkey
1904
+ FOREIGN KEY(namespace_id, site_id) REFERENCES analytics_identity_namespaces(id, site_id)
1905
+ ON DELETE RESTRICT NOT VALID;
1906
+ END IF;
1907
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_nodes_namespace_site_fkey') THEN
1908
+ ALTER TABLE analytics_identity_nodes ADD CONSTRAINT analytics_identity_nodes_namespace_site_fkey
1909
+ FOREIGN KEY(namespace_id, site_id) REFERENCES analytics_identity_namespaces(id, site_id)
1910
+ ON DELETE RESTRICT NOT VALID;
1911
+ END IF;
1912
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_person_site_fkey') THEN
1913
+ ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_person_site_fkey
1914
+ FOREIGN KEY(person_id, site_id) REFERENCES analytics_people(id, site_id)
1915
+ ON DELETE CASCADE NOT VALID;
1916
+ END IF;
1917
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_node_site_fkey') THEN
1918
+ ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_node_site_fkey
1919
+ FOREIGN KEY(identity_node_id, site_id) REFERENCES analytics_identity_nodes(id, site_id)
1920
+ ON DELETE CASCADE NOT VALID;
1921
+ END IF;
1922
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_namespace_site_fkey') THEN
1923
+ ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_namespace_site_fkey
1924
+ FOREIGN KEY(namespace_id, site_id) REFERENCES analytics_identity_namespaces(id, site_id)
1925
+ ON DELETE RESTRICT NOT VALID;
1926
+ END IF;
1927
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_person_namespace_fkey') THEN
1928
+ ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_person_namespace_fkey
1929
+ FOREIGN KEY(person_id, site_id, namespace_id) REFERENCES analytics_people(id, site_id, namespace_id)
1930
+ ON DELETE CASCADE NOT VALID;
1931
+ END IF;
1932
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='analytics_identity_edges_node_namespace_fkey') THEN
1933
+ ALTER TABLE analytics_identity_edges ADD CONSTRAINT analytics_identity_edges_node_namespace_fkey
1934
+ FOREIGN KEY(identity_node_id, site_id, namespace_id) REFERENCES analytics_identity_nodes(id, site_id, namespace_id)
1935
+ ON DELETE CASCADE NOT VALID;
1936
+ END IF;
1937
+ END $$
1938
+ `);
1939
+ for (const constraint of [
1940
+ ["analytics_people", "analytics_people_namespace_site_fkey"],
1941
+ ["analytics_identity_nodes", "analytics_identity_nodes_namespace_site_fkey"],
1942
+ ["analytics_identity_edges", "analytics_identity_edges_person_site_fkey"],
1943
+ ["analytics_identity_edges", "analytics_identity_edges_node_site_fkey"],
1944
+ ["analytics_identity_edges", "analytics_identity_edges_namespace_site_fkey"],
1945
+ ["analytics_identity_edges", "analytics_identity_edges_person_namespace_fkey"],
1946
+ ["analytics_identity_edges", "analytics_identity_edges_node_namespace_fkey"]
1947
+ ]) {
1948
+ await client.query(`ALTER TABLE ${constraint[0]} VALIDATE CONSTRAINT ${constraint[1]}`);
1949
+ }
1950
+ await client.query("ALTER TABLE analytics_people DROP CONSTRAINT IF EXISTS analytics_people_namespace_id_fkey");
1951
+ await client.query("ALTER TABLE analytics_identity_nodes DROP CONSTRAINT IF EXISTS analytics_identity_nodes_namespace_id_fkey");
1952
+ await client.query("ALTER TABLE analytics_identity_edges DROP CONSTRAINT IF EXISTS analytics_identity_edges_namespace_id_fkey");
1953
+ await client.query("ALTER TABLE analytics_identity_edges DROP CONSTRAINT IF EXISTS analytics_identity_edges_person_id_fkey");
1954
+ await client.query("ALTER TABLE analytics_identity_edges DROP CONSTRAINT IF EXISTS analytics_identity_edges_identity_node_id_fkey");
1603
1955
  await client.query(`
1604
1956
  CREATE TABLE IF NOT EXISTS analytics_candidate_associations (
1605
1957
  id uuid PRIMARY KEY,
@@ -2265,7 +2617,7 @@ async function migrateAnalytics() {
2265
2617
  }
2266
2618
  function normalizeSlug(value) {
2267
2619
  const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
2268
- return slug || `site-${randomBytes(4).toString("hex")}`;
2620
+ return slug || `site-${randomBytes2(4).toString("hex")}`;
2269
2621
  }
2270
2622
  function normalizeObservedHostname(origin) {
2271
2623
  if (!origin || origin === "null") return null;
@@ -2278,7 +2630,7 @@ function normalizeObservedHostname(origin) {
2278
2630
  }
2279
2631
  }
2280
2632
  function publicPixelId() {
2281
- return `px_${randomBytes(18).toString("base64url")}`;
2633
+ return `px_${randomBytes2(18).toString("base64url")}`;
2282
2634
  }
2283
2635
  function mapRows(rows) {
2284
2636
  return rows;
@@ -2316,7 +2668,7 @@ async function requireAnalyticsEditor(client, siteId, userId) {
2316
2668
  async function createAnalyticsSite(input) {
2317
2669
  const db = getAnalyticsPool();
2318
2670
  const client = await db.connect();
2319
- const id = randomUUID();
2671
+ const id = randomUUID2();
2320
2672
  const baseSlug = normalizeSlug(input.slug || input.name);
2321
2673
  try {
2322
2674
  await client.query("BEGIN");
@@ -2332,7 +2684,7 @@ async function createAnalyticsSite(input) {
2332
2684
  } catch (error) {
2333
2685
  const code = error.code;
2334
2686
  if (code !== "23505" || attempt === 2) throw error;
2335
- slug = `${baseSlug}-${randomBytes(2).toString("hex")}`;
2687
+ slug = `${baseSlug}-${randomBytes2(2).toString("hex")}`;
2336
2688
  }
2337
2689
  }
2338
2690
  await client.query(
@@ -2547,7 +2899,7 @@ async function createAnalyticsPixel(input) {
2547
2899
  VALUES ($1, $2, $3, $4, $5)
2548
2900
  RETURNING id, site_id, public_id, name, environment, status, created_at::text, NULL::text AS last_event_at`,
2549
2901
  [
2550
- randomUUID(),
2902
+ randomUUID2(),
2551
2903
  input.siteId,
2552
2904
  publicPixelId(),
2553
2905
  input.name.trim(),
@@ -2624,7 +2976,7 @@ async function setAnalyticsPixelDomainState(input) {
2624
2976
  SELECT $1, p.id, $4, $5 FROM analytics_pixels p WHERE p.id = $2 AND p.site_id = $3
2625
2977
  ON CONFLICT(pixel_id, hostname) DO UPDATE SET state = EXCLUDED.state, updated_at = now()
2626
2978
  RETURNING id`,
2627
- [randomUUID(), input.pixelId, input.siteId, hostname, input.state]
2979
+ [randomUUID2(), input.pixelId, input.siteId, hostname, input.state]
2628
2980
  );
2629
2981
  if (!result.rowCount)
2630
2982
  throw new AnalyticsRepositoryError(
@@ -2666,7 +3018,7 @@ async function upsertAnalyticsHostGroup(input) {
2666
3018
  ON CONFLICT(pixel_id, name) DO UPDATE SET
2667
3019
  auto_decorate=EXCLUDED.auto_decorate, updated_at=now()
2668
3020
  RETURNING id`,
2669
- [randomUUID(), input.siteId, input.pixelId, input.name.trim().slice(0, 120), Boolean(input.autoDecorate)]
3021
+ [randomUUID2(), input.siteId, input.pixelId, input.name.trim().slice(0, 120), Boolean(input.autoDecorate)]
2670
3022
  );
2671
3023
  await client.query("DELETE FROM analytics_host_group_members WHERE group_id=$1", [group.rows[0].id]);
2672
3024
  for (const hostname of hostnames)
@@ -2749,7 +3101,7 @@ async function prepareAnalyticsLinkerIssue(input) {
2749
3101
  VALUES ($1,$2,'visitor_id',$3)
2750
3102
  ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at=now()
2751
3103
  RETURNING id`,
2752
- [randomUUID(), siteId, identityHmac("visitor_id", input.visitorId)]
3104
+ [randomUUID2(), siteId, identityHmac("visitor_id", input.visitorId)]
2753
3105
  );
2754
3106
  const existingPerson = await client.query(
2755
3107
  `SELECT person_id FROM analytics_identity_edges
@@ -2760,19 +3112,20 @@ async function prepareAnalyticsLinkerIssue(input) {
2760
3112
  let personId = existingPerson.rows[0]?.person_id;
2761
3113
  if (!personId) {
2762
3114
  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)}`]
3115
+ `INSERT INTO analytics_people(id, site_id, crm_person_ref, public_ref) VALUES ($1,$2,$3,$4)
3116
+ ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at=now(),
3117
+ public_ref=COALESCE(analytics_people.public_ref,EXCLUDED.public_ref) RETURNING id`,
3118
+ [randomUUID2(), siteId, `XRay::anonymous::${identityHmac("visitor_id", input.visitorId).slice(0, 24)}`, newAnalyticsPersonPublicRef()]
2766
3119
  );
2767
3120
  personId = person.rows[0].id;
2768
3121
  await client.query(
2769
3122
  `INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
2770
3123
  VALUES ($1,$2,$3,$4,'first_party_linker',0.980)
2771
3124
  ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at=now()`,
2772
- [randomUUID(), siteId, personId, node.rows[0].id]
3125
+ [randomUUID2(), siteId, personId, node.rows[0].id]
2773
3126
  );
2774
3127
  }
2775
- const tokenId = randomUUID();
3128
+ const tokenId = randomUUID2();
2776
3129
  await client.query(
2777
3130
  `INSERT INTO analytics_linker_tokens(
2778
3131
  id, site_id, pixel_id, host_group_id, identity_node_id,
@@ -2828,13 +3181,13 @@ async function redeemAnalyticsLinkerRecord(input) {
2828
3181
  VALUES ($1,$2,'visitor_id',$3)
2829
3182
  ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at=now()
2830
3183
  RETURNING id`,
2831
- [randomUUID(), input.siteId, identityHmac("visitor_id", input.visitorId)]
3184
+ [randomUUID2(), input.siteId, identityHmac("visitor_id", input.visitorId)]
2832
3185
  );
2833
3186
  await client.query(
2834
3187
  `INSERT INTO analytics_identity_edges(id, site_id, person_id, identity_node_id, evidence_kind, confidence)
2835
3188
  VALUES ($1,$2,$3,$4,'cross_domain_linker',0.980)
2836
3189
  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]
3190
+ [randomUUID2(), input.siteId, personId, targetNode.rows[0].id]
2838
3191
  );
2839
3192
  await client.query(
2840
3193
  "UPDATE analytics_events SET person_id=$1 WHERE site_id=$2 AND visitor_id=$3 AND person_id IS NULL",
@@ -2858,6 +3211,133 @@ async function redeemAnalyticsLinkerRecord(input) {
2858
3211
  client.release();
2859
3212
  }
2860
3213
  }
3214
+ function analyticsIdentityResolutionAllowed(event) {
3215
+ return Boolean(
3216
+ event.consent?.analytics === "granted" && (event.consent.purposes?.enhanced_matching === "granted" || event.consent.adUserData === "granted") && event.consent.globalPrivacyControl !== true
3217
+ );
3218
+ }
3219
+ function analyticsIdentityPromotionAllowed(event) {
3220
+ return Boolean(event.identity && analyticsIdentityResolutionAllowed(event));
3221
+ }
3222
+ async function projectAnalyticsPixelEventConsent(client, input) {
3223
+ const visitorId = input.event.visitorId?.trim().slice(0, 240);
3224
+ const consent = input.event.consent;
3225
+ if (!visitorId || !consent) return null;
3226
+ const occurredAtMs = Date.parse(input.event.occurredAt);
3227
+ const serverNow = Date.now();
3228
+ if (!Number.isFinite(occurredAtMs) || occurredAtMs > serverNow + 5 * 6e4 || occurredAtMs < serverNow - 24 * 60 * 6e4)
3229
+ return null;
3230
+ const explicit = consent.purposes;
3231
+ const purposes = {
3232
+ siteAnalytics: explicit ? explicit.site_analytics === "granted" : consent.analytics === "granted",
3233
+ enhancedMatching: explicit ? explicit.enhanced_matching === "granted" : consent.adUserData === "granted",
3234
+ advertisingMeasurement: explicit ? explicit.advertising_measurement === "granted" : consent.adPersonalization === "granted"
3235
+ };
3236
+ const gpc = consent.globalPrivacyControl === true;
3237
+ if (gpc) {
3238
+ purposes.siteAnalytics = false;
3239
+ purposes.enhancedMatching = false;
3240
+ purposes.advertisingMeasurement = false;
3241
+ }
3242
+ const action = gpc ? "gpc" : Object.values(purposes).every(Boolean) ? "accept_all" : Object.values(purposes).every((value) => !value) ? "reject_all" : "save_choices";
3243
+ const occurredAt = new Date(occurredAtMs).toISOString();
3244
+ const previous = await client.query(
3245
+ `SELECT s.latest_receipt_id::text,s.site_analytics,s.enhanced_matching,s.advertising_measurement,s.gpc,r.action
3246
+ FROM analytics_consent_current_state s JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id
3247
+ WHERE s.site_id=$1 AND s.subject_ref=$2 FOR UPDATE`,
3248
+ [input.siteId, visitorId]
3249
+ );
3250
+ const prior = previous.rows[0];
3251
+ 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;
3252
+ const receiptId = randomUUID2();
3253
+ await client.query(
3254
+ `INSERT INTO analytics_consent_receipts(id,site_id,pixel_id,subject_ref,policy_version,jurisdiction_profile,
3255
+ necessary,site_analytics,enhanced_matching,advertising_measurement,gpc,action,source,presented_at,occurred_at,supersedes_id)
3256
+ VALUES($1,$2,$3,$4,1,'pixel',true,$5,$6,$7,$8,$9,'gateway',$10,$10,$11)`,
3257
+ [
3258
+ receiptId,
3259
+ input.siteId,
3260
+ input.pixelId,
3261
+ visitorId,
3262
+ purposes.siteAnalytics,
3263
+ purposes.enhancedMatching,
3264
+ purposes.advertisingMeasurement,
3265
+ gpc,
3266
+ action,
3267
+ occurredAt,
3268
+ prior?.latest_receipt_id ?? null
3269
+ ]
3270
+ );
3271
+ await client.query(
3272
+ `INSERT INTO analytics_consent_current_state(site_id,subject_ref,latest_receipt_id,policy_version,jurisdiction_profile,
3273
+ necessary,site_analytics,enhanced_matching,advertising_measurement,gpc,effective_at)
3274
+ VALUES($1,$2,$3,1,'pixel',true,$4,$5,$6,$7,$8)
3275
+ ON CONFLICT(site_id,subject_ref) DO UPDATE SET latest_receipt_id=EXCLUDED.latest_receipt_id,
3276
+ policy_version=EXCLUDED.policy_version,jurisdiction_profile=EXCLUDED.jurisdiction_profile,
3277
+ site_analytics=EXCLUDED.site_analytics,enhanced_matching=EXCLUDED.enhanced_matching,
3278
+ advertising_measurement=EXCLUDED.advertising_measurement,gpc=EXCLUDED.gpc,
3279
+ effective_at=EXCLUDED.effective_at,updated_at=now()
3280
+ WHERE EXCLUDED.effective_at>=analytics_consent_current_state.effective_at`,
3281
+ [
3282
+ input.siteId,
3283
+ visitorId,
3284
+ receiptId,
3285
+ purposes.siteAnalytics,
3286
+ purposes.enhancedMatching,
3287
+ purposes.advertisingMeasurement,
3288
+ gpc,
3289
+ occurredAt
3290
+ ]
3291
+ );
3292
+ return receiptId;
3293
+ }
3294
+ async function resolveAnalyticsConfirmedActivationIdentity(db, input) {
3295
+ if (!input.subjectRef) return { personId: null, activationEligible: false };
3296
+ const result = await db.query(
3297
+ `SELECT p.id::text AS person_id,s.advertising_measurement
3298
+ FROM analytics_people p
3299
+ JOIN analytics_consent_current_state s ON s.site_id=p.site_id AND s.subject_ref=$2
3300
+ JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id AND r.site_id=s.site_id
3301
+ WHERE p.site_id=$1
3302
+ AND (($3::uuid IS NOT NULL AND p.id=$3) OR ($4::text IS NOT NULL AND EXISTS (
3303
+ SELECT 1 FROM analytics_events e WHERE e.site_id=p.site_id AND e.event_id=$4
3304
+ AND e.person_id=p.id AND e.visitor_id=$2
3305
+ )))
3306
+ AND s.site_analytics=true AND s.gpc=false AND r.action NOT IN ('withdraw','gpc')
3307
+ AND (s.expires_at IS NULL OR s.expires_at>now()) LIMIT 1`,
3308
+ [input.siteId, input.subjectRef, input.personId ?? null, input.eventId ?? null]
3309
+ );
3310
+ const row = result.rows[0];
3311
+ return row ? { personId: row.person_id, activationEligible: row.advertising_measurement === true } : { personId: null, activationEligible: false };
3312
+ }
3313
+ async function hasCurrentAnalyticsIdentityConsent(client, siteId, visitorId, sessionId) {
3314
+ if (!visitorId?.trim() || !sessionId?.trim()) return false;
3315
+ const result = await client.query(
3316
+ `SELECT 1 FROM analytics_consent_current_state s
3317
+ JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id AND r.site_id=s.site_id
3318
+ WHERE s.site_id=$1 AND s.subject_ref=$2
3319
+ AND s.site_analytics=true AND s.enhanced_matching=true AND s.gpc=false
3320
+ AND r.action NOT IN ('withdraw','gpc')
3321
+ AND (s.expires_at IS NULL OR s.expires_at>now())
3322
+ AND EXISTS (SELECT 1 FROM analytics_sessions x
3323
+ WHERE x.site_id=s.site_id AND x.visitor_hmac=$3 AND x.session_hmac=$4)
3324
+ ORDER BY s.effective_at DESC LIMIT 1`,
3325
+ [siteId, visitorId, identityHmac("visitor_id", visitorId), identityHmac("session_id", sessionId)]
3326
+ );
3327
+ return Boolean(result.rowCount);
3328
+ }
3329
+ async function hasCurrentPersonIdentityConsent(client, siteId, personPublicRef) {
3330
+ const result = await client.query(
3331
+ `SELECT 1 FROM analytics_consent_current_state s
3332
+ JOIN analytics_consent_receipts r ON r.id=s.latest_receipt_id AND r.site_id=s.site_id
3333
+ WHERE s.site_id=$1 AND s.subject_ref=$2
3334
+ AND s.site_analytics=true AND s.enhanced_matching=true AND s.gpc=false
3335
+ AND r.action NOT IN ('withdraw','gpc')
3336
+ AND (s.expires_at IS NULL OR s.expires_at>now()) LIMIT 1`,
3337
+ [siteId, personPublicRef]
3338
+ );
3339
+ return Boolean(result.rowCount);
3340
+ }
2861
3341
  var MAX_ENGAGED_MS = 30 * 60 * 1e3;
2862
3342
  var ENGAGED_SESSION_MS = 1e4;
2863
3343
  function boundedEngagedMs(value) {
@@ -2926,7 +3406,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
2926
3406
  updated_at = now()
2927
3407
  RETURNING id`,
2928
3408
  [
2929
- randomUUID(),
3409
+ randomUUID2(),
2930
3410
  input.siteId,
2931
3411
  input.pixelId,
2932
3412
  visitorHmac,
@@ -2942,7 +3422,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
2942
3422
  input.event.source || input.event.medium || input.event.campaign || input.event.referrer || Object.keys(clickIds).length
2943
3423
  );
2944
3424
  if (!hasTouch) return;
2945
- const touchHash = createHash("sha256").update(
3425
+ const touchHash = createHash2("sha256").update(
2946
3426
  JSON.stringify({
2947
3427
  siteId: input.siteId,
2948
3428
  sessionHmac,
@@ -2963,7 +3443,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
2963
3443
  person_id = COALESCE(analytics_attribution_touches.person_id, EXCLUDED.person_id)
2964
3444
  RETURNING id`,
2965
3445
  [
2966
- randomUUID(),
3446
+ randomUUID2(),
2967
3447
  input.siteId,
2968
3448
  session.rows[0].id,
2969
3449
  input.personId ?? null,
@@ -2987,7 +3467,7 @@ async function materializeAnalyticsSessionAndTouch(input) {
2987
3467
  }
2988
3468
  async function ingestAnalyticsEvents(input) {
2989
3469
  const db = getAnalyticsPool();
2990
- const requestId = `air_${randomUUID()}`;
3470
+ const requestId = `air_${randomUUID2()}`;
2991
3471
  const hostname = normalizeObservedHostname(input.origin);
2992
3472
  const pixelResult = await db.query(
2993
3473
  `SELECT p.id, p.site_id, p.status,
@@ -3008,7 +3488,7 @@ async function ingestAnalyticsEvents(input) {
3008
3488
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
3009
3489
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
3010
3490
  [
3011
- randomUUID(),
3491
+ randomUUID2(),
3012
3492
  requestId,
3013
3493
  pixel?.site_id ?? null,
3014
3494
  pixel?.id ?? null,
@@ -3033,7 +3513,7 @@ async function ingestAnalyticsEvents(input) {
3033
3513
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
3034
3514
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
3035
3515
  [
3036
- randomUUID(),
3516
+ randomUUID2(),
3037
3517
  requestId,
3038
3518
  pixel.site_id,
3039
3519
  pixel.id,
@@ -3058,7 +3538,7 @@ async function ingestAnalyticsEvents(input) {
3058
3538
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, rejected_count, reason_codes)
3059
3539
  VALUES ($1, $2, $3, $4, $5, $6::jsonb)`,
3060
3540
  [
3061
- randomUUID(),
3541
+ randomUUID2(),
3062
3542
  requestId,
3063
3543
  pixel.site_id,
3064
3544
  pixel.id,
@@ -3082,7 +3562,7 @@ async function ingestAnalyticsEvents(input) {
3082
3562
  ON CONFLICT(pixel_id, hostname) DO UPDATE
3083
3563
  SET last_seen_at = now(), updated_at = now()
3084
3564
  RETURNING state`,
3085
- [randomUUID(), pixel.id, hostname]
3565
+ [randomUUID2(), pixel.id, hostname]
3086
3566
  );
3087
3567
  if (domainResult.rows[0]?.state !== "approved") {
3088
3568
  await db.query(
@@ -3096,7 +3576,7 @@ async function ingestAnalyticsEvents(input) {
3096
3576
  `INSERT INTO analytics_ingestion_receipts(id, request_id, site_id, pixel_id, hostname, rejected_count, reason_codes)
3097
3577
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
3098
3578
  [
3099
- randomUUID(),
3579
+ randomUUID2(),
3100
3580
  requestId,
3101
3581
  pixel.site_id,
3102
3582
  pixel.id,
@@ -3123,18 +3603,14 @@ async function ingestAnalyticsEvents(input) {
3123
3603
  await client.query("BEGIN");
3124
3604
  for (const event of input.events) {
3125
3605
  try {
3126
- const attribution = resolveAnalyticsAttribution(event);
3127
- const identifiedPersonId = event.identity && event.consent?.analytics === "granted" ? await linkAnalyticsIdentityInTransaction(client, {
3606
+ await projectAnalyticsPixelEventConsent(client, {
3128
3607
  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(
3608
+ pixelId: pixel.id,
3609
+ event
3610
+ });
3611
+ const attribution = resolveAnalyticsAttribution(event);
3612
+ const eventIdentity = event.identity;
3613
+ const resolvedPerson = analyticsIdentityResolutionAllowed(event) && (event.visitorId || event.sessionId) ? await client.query(
3138
3614
  `SELECT e.person_id FROM analytics_identity_nodes n JOIN analytics_identity_edges e ON e.identity_node_id = n.id
3139
3615
  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
3616
  ORDER BY e.confidence DESC, e.last_seen_at DESC LIMIT 1`,
@@ -3156,7 +3632,7 @@ async function ingestAnalyticsEvents(input) {
3156
3632
  $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33
3157
3633
  ) ON CONFLICT(site_id, event_id) DO NOTHING`,
3158
3634
  [
3159
- randomUUID(),
3635
+ randomUUID2(),
3160
3636
  event.eventId,
3161
3637
  pixel.site_id,
3162
3638
  pixel.id,
@@ -3193,13 +3669,34 @@ async function ingestAnalyticsEvents(input) {
3193
3669
  );
3194
3670
  if (inserted.rowCount) {
3195
3671
  accepted += 1;
3672
+ const resolvedPersonId = resolvedPerson.rows[0]?.person_id;
3196
3673
  await materializeAnalyticsSessionAndTouch({
3197
3674
  client,
3198
3675
  siteId: pixel.site_id,
3199
3676
  pixelId: pixel.id,
3200
- personId: resolvedPerson.rows[0]?.person_id,
3677
+ personId: resolvedPersonId,
3201
3678
  event
3202
3679
  });
3680
+ if (resolvedPersonId && event.eventName === "identify") {
3681
+ await backfillAnalyticsConfirmedHistory(client, {
3682
+ siteId: pixel.site_id,
3683
+ personId: resolvedPersonId,
3684
+ visitorId: event.visitorId,
3685
+ sessionId: event.sessionId
3686
+ });
3687
+ }
3688
+ if (eventIdentity && analyticsIdentityPromotionAllowed(event)) {
3689
+ await linkAnalyticsIdentityInTransaction(client, {
3690
+ siteId: pixel.site_id,
3691
+ visitorId: event.visitorId,
3692
+ sessionId: event.sessionId,
3693
+ email: eventIdentity.email,
3694
+ phone: eventIdentity.phone,
3695
+ customerId: eventIdentity.customerId,
3696
+ orderId: eventIdentity.orderId,
3697
+ clickIds: event.clickIds
3698
+ });
3699
+ }
3203
3700
  } else {
3204
3701
  rejected += 1;
3205
3702
  reasonCodes.add("duplicate_event");
@@ -3222,7 +3719,7 @@ async function ingestAnalyticsEvents(input) {
3222
3719
  id, request_id, site_id, pixel_id, hostname, accepted_count, rejected_count, reason_codes
3223
3720
  ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
3224
3721
  [
3225
- randomUUID(),
3722
+ randomUUID2(),
3226
3723
  requestId,
3227
3724
  pixel.site_id,
3228
3725
  pixel.id,
@@ -3254,7 +3751,7 @@ function normalizeGeographyCode(value, max) {
3254
3751
  return normalized && /^[A-Z0-9-]+$/.test(normalized) ? normalized.slice(0, max) : null;
3255
3752
  }
3256
3753
  function pageFingerprint(scope, siteId, filters) {
3257
- return createHash("sha256").update(JSON.stringify({ scope, siteId, filters })).digest("base64url").slice(0, 18);
3754
+ return createHash2("sha256").update(JSON.stringify({ scope, siteId, filters })).digest("base64url").slice(0, 18);
3258
3755
  }
3259
3756
  function decodePageOffset(cursor, fingerprint) {
3260
3757
  if (!cursor) return 0;
@@ -3313,7 +3810,7 @@ async function createAnalyticsConversion(input) {
3313
3810
  ORDER BY e.confidence DESC LIMIT 1`,
3314
3811
  [input.siteId, identityHmac("session_id", input.sessionId)]
3315
3812
  ) : { rows: [] };
3316
- const proposedId = randomUUID();
3813
+ const proposedId = randomUUID2();
3317
3814
  const inserted = await client.query(
3318
3815
  `INSERT INTO analytics_conversions(
3319
3816
  id, source_event_id, site_id, pixel_id, session_id, conversion_kind,
@@ -3353,7 +3850,7 @@ async function createAnalyticsConversion(input) {
3353
3850
  ) VALUES ($1,$2,$3,$4,$5)
3354
3851
  ON CONFLICT(destination_id, conversion_id) DO NOTHING`,
3355
3852
  [
3356
- randomUUID(),
3853
+ randomUUID2(),
3357
3854
  destination.id,
3358
3855
  conversionId,
3359
3856
  resolvedPerson.rows[0]?.person_id ?? null,
@@ -4117,7 +4614,7 @@ async function createAnalyticsCampaignLink(input) {
4117
4614
  404
4118
4615
  );
4119
4616
  }
4120
- const shortCode = (input.shortCode?.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-") || randomBytes(5).toString("base64url").toLowerCase()).slice(0, 48);
4617
+ const shortCode = (input.shortCode?.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-") || randomBytes2(5).toString("base64url").toLowerCase()).slice(0, 48);
4121
4618
  try {
4122
4619
  const result = await db.query(
4123
4620
  `INSERT INTO analytics_campaign_links(
@@ -4126,7 +4623,7 @@ async function createAnalyticsCampaignLink(input) {
4126
4623
  ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
4127
4624
  RETURNING *, 0::int AS click_count`,
4128
4625
  [
4129
- randomUUID(),
4626
+ randomUUID2(),
4130
4627
  input.siteId,
4131
4628
  input.pixelId ?? null,
4132
4629
  input.name.trim(),
@@ -4197,7 +4694,7 @@ async function resolveAnalyticsCampaignLink(shortCode, referrer) {
4197
4694
  if (!row) return null;
4198
4695
  await db.query(
4199
4696
  `INSERT INTO analytics_campaign_clicks(id, link_id, referrer) VALUES ($1, $2, $3)`,
4200
- [randomUUID(), row.id, normalizeAnalyticsUrl(referrer || void 0)]
4697
+ [randomUUID2(), row.id, normalizeAnalyticsUrl(referrer || void 0)]
4201
4698
  );
4202
4699
  return buildTaggedCampaignUrl(row);
4203
4700
  }
@@ -4215,14 +4712,14 @@ async function createAnalyticsForm(input) {
4215
4712
  404
4216
4713
  );
4217
4714
  const baseSlug = normalizeSlug(input.name);
4218
- const slug = `${baseSlug}-${randomBytes(3).toString("hex")}`;
4219
- const publicId = `form_${randomBytes(18).toString("base64url")}`;
4715
+ const slug = `${baseSlug}-${randomBytes2(3).toString("hex")}`;
4716
+ const publicId = `form_${randomBytes2(18).toString("base64url")}`;
4220
4717
  const result = await db.query(
4221
4718
  `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
4719
  VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9,$10,$11,$12,$13,$14)
4223
4720
  RETURNING *, 0::int AS submission_count`,
4224
4721
  [
4225
- randomUUID(),
4722
+ randomUUID2(),
4226
4723
  publicId,
4227
4724
  input.siteId,
4228
4725
  input.pixelId,
@@ -4267,7 +4764,7 @@ async function getPublicAnalyticsForm(publicId) {
4267
4764
  return result.rows[0] ?? null;
4268
4765
  }
4269
4766
  async function recordAnalyticsFormSubmission(input) {
4270
- const id = randomUUID();
4767
+ const id = randomUUID2();
4271
4768
  await getAnalyticsPool().query(
4272
4769
  `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
4770
  VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb)`,
@@ -4311,16 +4808,128 @@ function sanitizeClickIds(value) {
4311
4808
  return output;
4312
4809
  }
4313
4810
  function identityHmac(kind, value) {
4314
- return createHmac("sha256", getSessionSecret()).update(`${kind}:${value.trim().toLowerCase()}`).digest("hex");
4811
+ return createHmac2("sha256", getSessionSecret()).update(`${kind}:${value.trim().toLowerCase()}`).digest("hex");
4812
+ }
4813
+ function newAnalyticsPersonPublicRef() {
4814
+ return `xrp_${randomBytes2(18).toString("base64url")}`;
4815
+ }
4816
+ async function ensurePrimaryAnalyticsIdentityNamespace(client, siteId, source) {
4817
+ let namespace = await client.query(
4818
+ "SELECT id FROM analytics_identity_namespaces WHERE site_id=$1 AND status='approved' ORDER BY version DESC LIMIT 1",
4819
+ [siteId]
4820
+ );
4821
+ if (!namespace.rows[0]) {
4822
+ await client.query(
4823
+ `INSERT INTO analytics_identity_namespaces(
4824
+ id,site_id,name,status,approved_hosts,approved_sources,version,approved_by_user_id,approved_at
4825
+ )
4826
+ SELECT $1,s.id,'X-Ray primary','approved',
4827
+ COALESCE((SELECT jsonb_agg(d.hostname ORDER BY d.hostname)
4828
+ FROM analytics_pixels p
4829
+ JOIN analytics_pixel_domains d ON d.pixel_id=p.id AND d.state='approved'
4830
+ WHERE p.site_id=s.id),'[]'::jsonb),
4831
+ $3::jsonb,1,s.owner_user_id,now()
4832
+ FROM analytics_sites s WHERE s.id=$2
4833
+ ON CONFLICT(site_id,name,version) DO NOTHING`,
4834
+ [randomUUID2(), siteId, JSON.stringify([source])]
4835
+ );
4836
+ namespace = await client.query(
4837
+ "SELECT id FROM analytics_identity_namespaces WHERE site_id=$1 AND status='approved' ORDER BY version DESC LIMIT 1",
4838
+ [siteId]
4839
+ );
4840
+ }
4841
+ const namespaceId = namespace.rows[0]?.id;
4842
+ if (!namespaceId)
4843
+ throw new AnalyticsRepositoryError(
4844
+ "identity_namespace_unavailable",
4845
+ "The Site identity graph could not be initialized.",
4846
+ 409
4847
+ );
4848
+ await client.query(
4849
+ `UPDATE analytics_identity_namespaces
4850
+ SET approved_sources=(SELECT jsonb_agg(value ORDER BY value)
4851
+ FROM (SELECT DISTINCT value
4852
+ FROM jsonb_array_elements_text(approved_sources || $3::jsonb)) merged_sources),
4853
+ approved_hosts=(SELECT COALESCE(jsonb_agg(hostname ORDER BY hostname),'[]'::jsonb)
4854
+ FROM (SELECT DISTINCT hostname
4855
+ FROM (SELECT jsonb_array_elements_text(approved_hosts) AS hostname
4856
+ UNION ALL
4857
+ SELECT d.hostname
4858
+ FROM analytics_pixels p
4859
+ JOIN analytics_pixel_domains d ON d.pixel_id=p.id AND d.state='approved'
4860
+ WHERE p.site_id=$2) hosts) distinct_hosts),
4861
+ updated_at=now()
4862
+ WHERE id=$1 AND site_id=$2`,
4863
+ [namespaceId, siteId, JSON.stringify([source])]
4864
+ );
4865
+ return namespaceId;
4866
+ }
4867
+ async function backfillAnalyticsConfirmedHistory(client, input) {
4868
+ if (!input.visitorId && !input.sessionId)
4869
+ return { events: 0, sessions: 0, touches: 0, conversions: 0 };
4870
+ const visitorHmac = input.visitorId ? identityHmac("visitor_id", input.visitorId) : null;
4871
+ const sessionHmac = input.sessionId ? identityHmac("session_id", input.sessionId) : null;
4872
+ const events = await client.query(
4873
+ `UPDATE analytics_events SET person_id = $1 WHERE site_id = $2 AND person_id IS NULL
4874
+ AND (($3::text IS NOT NULL AND visitor_id = $3) OR ($4::text IS NOT NULL AND session_id = $4))`,
4875
+ [input.personId, input.siteId, input.visitorId ?? null, input.sessionId ?? null]
4876
+ );
4877
+ const conversions = await client.query(
4878
+ `UPDATE analytics_conversions SET person_id = $1 WHERE site_id = $2 AND person_id IS NULL
4879
+ AND $3::text IS NOT NULL AND session_id = $3`,
4880
+ [input.personId, input.siteId, input.sessionId ?? null]
4881
+ );
4882
+ const sessions = await client.query(
4883
+ `UPDATE analytics_sessions SET person_id = $1
4884
+ WHERE site_id = $2 AND person_id IS NULL
4885
+ AND (($3::text IS NOT NULL AND visitor_hmac = $3)
4886
+ OR ($4::text IS NOT NULL AND session_hmac = $4))`,
4887
+ [input.personId, input.siteId, visitorHmac, sessionHmac]
4888
+ );
4889
+ const touches = await client.query(
4890
+ `UPDATE analytics_attribution_touches SET person_id = $1
4891
+ WHERE site_id = $2 AND person_id IS NULL
4892
+ AND (($3::text IS NOT NULL AND visitor_hmac = $3)
4893
+ OR ($4::text IS NOT NULL AND session_id IN (
4894
+ SELECT id FROM analytics_sessions
4895
+ WHERE site_id = $2 AND session_hmac = $4
4896
+ )))`,
4897
+ [input.personId, input.siteId, visitorHmac, sessionHmac]
4898
+ );
4899
+ return {
4900
+ events: events.rowCount ?? 0,
4901
+ sessions: sessions.rowCount ?? 0,
4902
+ touches: touches.rowCount ?? 0,
4903
+ conversions: conversions.rowCount ?? 0
4904
+ };
4315
4905
  }
4316
4906
  async function linkAnalyticsIdentityInTransaction(client, input) {
4317
4907
  const stable = input.customerId || input.orderId || input.email || input.phone;
4318
4908
  if (!stable) return null;
4909
+ if (!await hasCurrentAnalyticsIdentityConsent(
4910
+ client,
4911
+ input.siteId,
4912
+ input.visitorId,
4913
+ input.sessionId
4914
+ )) return null;
4915
+ const namespaceId = await ensurePrimaryAnalyticsIdentityNamespace(client, input.siteId, "pixel");
4916
+ const tombstoneSignals = [
4917
+ ...input.email ? [{ kind: "email", valueHmac: identityHmac("email", input.email) }] : [],
4918
+ ...input.phone ? [{ kind: "phone", valueHmac: identityHmac("phone", input.phone) }] : [],
4919
+ ...input.customerId ? [{ kind: "customer_id", valueHmac: identityHmac("customer_id", input.customerId) }] : [],
4920
+ ...input.orderId ? [{ kind: "customer_id", valueHmac: identityHmac("customer_id", `order:${input.orderId}`) }] : []
4921
+ ];
4922
+ await assertAnalyticsIdentitySignalsNotTombstoned(client, {
4923
+ siteId: input.siteId,
4924
+ signals: tombstoneSignals
4925
+ });
4319
4926
  const crmPersonRef = `XRay::${identityHmac("person_ref", stable).slice(0, 24)}`;
4320
4927
  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]
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, namespaceId, crmPersonRef, newAnalyticsPersonPublicRef()]
4324
4933
  );
4325
4934
  const personId = person.rows[0].id;
4326
4935
  const signals = [];
@@ -4340,28 +4949,65 @@ async function linkAnalyticsIdentityInTransaction(client, input) {
4340
4949
  signals.push({ kind: "click_id", value: `${platform}:${clickId}`, evidence: platform, confidence: 0.9 });
4341
4950
  for (const signal of signals) {
4342
4951
  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)]
4952
+ `INSERT INTO analytics_identity_nodes(id, site_id, namespace_id, kind, value_hmac) VALUES ($1,$2,$3,$4,$5)
4953
+ ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now(),
4954
+ namespace_id=COALESCE(analytics_identity_nodes.namespace_id,EXCLUDED.namespace_id) RETURNING id`,
4955
+ [randomUUID2(), input.siteId, namespaceId, signal.kind, identityHmac(signal.kind, signal.value)]
4346
4956
  );
4347
4957
  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)
4958
+ `INSERT INTO analytics_identity_edges(id, site_id, namespace_id, person_id, identity_node_id, evidence_kind, confidence)
4959
+ VALUES ($1,$2,$3,$4,$5,$6,$7)
4350
4960
  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]
4961
+ SET last_seen_at = now(), confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence),
4962
+ namespace_id=COALESCE(analytics_identity_edges.namespace_id,EXCLUDED.namespace_id)`,
4963
+ [randomUUID2(), input.siteId, namespaceId, personId, node.rows[0].id, signal.evidence, signal.confidence]
4353
4964
  );
4354
4965
  }
4966
+ await backfillAnalyticsConfirmedHistory(client, {
4967
+ siteId: input.siteId,
4968
+ personId,
4969
+ visitorId: input.visitorId,
4970
+ sessionId: input.sessionId
4971
+ });
4972
+ if (input.customerId) {
4973
+ await writeAnalyticsConfirmedIdentityProfile(client, {
4974
+ siteId: input.siteId,
4975
+ personId,
4976
+ consentSubjectRefs: [input.visitorId ?? "", input.sessionId ?? ""],
4977
+ evidenceType: "authenticated_user",
4978
+ email: input.email,
4979
+ phone: input.phone
4980
+ });
4981
+ }
4355
4982
  return personId;
4356
4983
  }
4357
4984
  async function linkAnalyticsFormIdentity(input) {
4358
4985
  const client = await getAnalyticsPool().connect();
4359
4986
  try {
4360
4987
  await client.query("BEGIN");
4988
+ if (!await hasCurrentAnalyticsIdentityConsent(
4989
+ client,
4990
+ input.siteId,
4991
+ input.visitorId,
4992
+ input.sessionId
4993
+ )) {
4994
+ await client.query("COMMIT");
4995
+ return { personId: null, linkedSignals: 0 };
4996
+ }
4997
+ const namespaceId = await ensurePrimaryAnalyticsIdentityNamespace(client, input.siteId, "form");
4998
+ await assertAnalyticsIdentitySignalsNotTombstoned(client, {
4999
+ siteId: input.siteId,
5000
+ signals: [
5001
+ ...input.email ? [{ kind: "email", valueHmac: identityHmac("email", input.email) }] : [],
5002
+ ...input.phone ? [{ kind: "phone", valueHmac: identityHmac("phone", input.phone) }] : []
5003
+ ]
5004
+ });
4361
5005
  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]
5006
+ `INSERT INTO analytics_people(id, site_id, namespace_id, crm_person_ref, public_ref) VALUES ($1,$2,$3,$4,$5)
5007
+ ON CONFLICT(site_id, crm_person_ref) DO UPDATE SET last_seen_at = now(),
5008
+ namespace_id=COALESCE(analytics_people.namespace_id,EXCLUDED.namespace_id),
5009
+ public_ref=COALESCE(analytics_people.public_ref,EXCLUDED.public_ref) RETURNING id`,
5010
+ [randomUUID2(), input.siteId, namespaceId, input.crmPersonRef, newAnalyticsPersonPublicRef()]
4365
5011
  );
4366
5012
  const personId = person.rows[0].id;
4367
5013
  const signals = [];
@@ -4405,22 +5051,27 @@ async function linkAnalyticsFormIdentity(input) {
4405
5051
  }
4406
5052
  for (const signal of signals) {
4407
5053
  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`,
5054
+ `INSERT INTO analytics_identity_nodes(id, site_id, namespace_id, kind, value_hmac) VALUES ($1,$2,$3,$4,$5)
5055
+ ON CONFLICT(site_id, kind, value_hmac) DO UPDATE SET last_seen_at = now(),
5056
+ namespace_id=COALESCE(analytics_identity_nodes.namespace_id,EXCLUDED.namespace_id) RETURNING id`,
4410
5057
  [
4411
- randomUUID(),
5058
+ randomUUID2(),
4412
5059
  input.siteId,
5060
+ namespaceId,
4413
5061
  signal.kind,
4414
5062
  identityHmac(signal.kind, signal.value)
4415
5063
  ]
4416
5064
  );
4417
5065
  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)`,
5066
+ `INSERT INTO analytics_identity_edges(id, site_id, namespace_id, person_id, identity_node_id, evidence_kind, confidence)
5067
+ VALUES ($1,$2,$3,$4,$5,$6,$7)
5068
+ ON CONFLICT(person_id, identity_node_id, evidence_kind) DO UPDATE SET last_seen_at = now(),
5069
+ confidence = greatest(analytics_identity_edges.confidence, EXCLUDED.confidence),
5070
+ namespace_id=COALESCE(analytics_identity_edges.namespace_id,EXCLUDED.namespace_id)`,
4421
5071
  [
4422
- randomUUID(),
5072
+ randomUUID2(),
4423
5073
  input.siteId,
5074
+ namespaceId,
4424
5075
  personId,
4425
5076
  node.rows[0].id,
4426
5077
  signal.evidence,
@@ -4434,42 +5085,80 @@ async function linkAnalyticsFormIdentity(input) {
4434
5085
  [personId, input.submissionId, input.siteId]
4435
5086
  );
4436
5087
  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]
5088
+ await backfillAnalyticsConfirmedHistory(client, {
5089
+ siteId: input.siteId,
5090
+ personId,
5091
+ visitorId: input.visitorId,
5092
+ sessionId: input.sessionId
5093
+ });
5094
+ }
5095
+ await writeAnalyticsConfirmedIdentityProfile(client, {
5096
+ siteId: input.siteId,
5097
+ personId,
5098
+ consentSubjectRefs: [input.visitorId ?? ""],
5099
+ evidenceType: input.evidenceType ?? "first_party_form",
5100
+ displayName: input.displayName,
5101
+ email: input.email,
5102
+ phone: input.phone
5103
+ });
5104
+ await client.query("COMMIT");
5105
+ return { personId, linkedSignals: signals.length };
5106
+ } catch (error) {
5107
+ await client.query("ROLLBACK");
5108
+ throw error;
5109
+ } finally {
5110
+ client.release();
5111
+ }
5112
+ }
5113
+ async function enrichAnalyticsExistingCrmIdentity(input) {
5114
+ const client = await getAnalyticsPool().connect();
5115
+ try {
5116
+ await client.query("BEGIN");
5117
+ const existing = await client.query(
5118
+ `SELECT p.id,p.public_ref,p.namespace_id FROM analytics_people p
5119
+ JOIN analytics_identity_namespaces n ON n.id=p.namespace_id AND n.site_id=p.site_id AND n.status='approved'
5120
+ WHERE p.site_id=$1 AND p.crm_person_ref=$2 LIMIT 1 FOR UPDATE OF p`,
5121
+ [input.siteId, input.crmPersonRef]
5122
+ );
5123
+ const person = existing.rows[0];
5124
+ if (!person || !await hasCurrentPersonIdentityConsent(client, input.siteId, person.public_ref)) {
5125
+ await client.query("COMMIT");
5126
+ return { personId: null, linkedSignals: 0 };
5127
+ }
5128
+ const signals = [
5129
+ ...input.email ? [{ kind: "email", value: input.email, confidence: 1 }] : [],
5130
+ ...input.phone ? [{ kind: "phone", value: input.phone, confidence: 0.99 }] : []
5131
+ ];
5132
+ await assertAnalyticsIdentitySignalsNotTombstoned(client, {
5133
+ siteId: input.siteId,
5134
+ signals: signals.map((signal) => ({ kind: signal.kind, valueHmac: identityHmac(signal.kind, signal.value) }))
5135
+ });
5136
+ for (const signal of signals) {
5137
+ const node = await client.query(
5138
+ `INSERT INTO analytics_identity_nodes(id,site_id,namespace_id,kind,value_hmac) VALUES($1,$2,$3,$4,$5)
5139
+ ON CONFLICT(site_id,kind,value_hmac) DO UPDATE SET last_seen_at=now(),
5140
+ namespace_id=COALESCE(analytics_identity_nodes.namespace_id,EXCLUDED.namespace_id) RETURNING id`,
5141
+ [randomUUID2(), input.siteId, person.namespace_id, signal.kind, identityHmac(signal.kind, signal.value)]
4459
5142
  );
4460
5143
  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]
5144
+ `INSERT INTO analytics_identity_edges(id,site_id,namespace_id,person_id,identity_node_id,evidence_kind,confidence)
5145
+ VALUES($1,$2,$3,$4,$5,'crm_contact',$6)
5146
+ ON CONFLICT(person_id,identity_node_id,evidence_kind) DO UPDATE SET last_seen_at=now(),
5147
+ confidence=greatest(analytics_identity_edges.confidence,EXCLUDED.confidence)`,
5148
+ [randomUUID2(), input.siteId, person.namespace_id, person.id, node.rows[0].id, signal.confidence]
4469
5149
  );
4470
5150
  }
5151
+ await writeAnalyticsConfirmedIdentityProfile(client, {
5152
+ siteId: input.siteId,
5153
+ personId: person.id,
5154
+ consentSubjectRefs: [person.public_ref],
5155
+ evidenceType: "crm_contact",
5156
+ displayName: input.displayName,
5157
+ email: input.email,
5158
+ phone: input.phone
5159
+ });
4471
5160
  await client.query("COMMIT");
4472
- return { personId, linkedSignals: signals.length };
5161
+ return { personId: person.id, linkedSignals: signals.length };
4473
5162
  } catch (error) {
4474
5163
  await client.query("ROLLBACK");
4475
5164
  throw error;
@@ -4536,7 +5225,7 @@ async function createAnalyticsCrmImport(input) {
4536
5225
  const db = getAnalyticsPool();
4537
5226
  await requireEditor(db, input.siteId, input.userId);
4538
5227
  const client = await db.connect();
4539
- const id = randomUUID();
5228
+ const id = randomUUID2();
4540
5229
  try {
4541
5230
  await client.query("BEGIN");
4542
5231
  await client.query(
@@ -4556,7 +5245,7 @@ async function createAnalyticsCrmImport(input) {
4556
5245
  await client.query(
4557
5246
  `INSERT INTO analytics_crm_import_rows(id, import_id, crm_person_ref, payload_ciphertext)
4558
5247
  VALUES ($1,$2,$3,$4) ON CONFLICT(import_id, crm_person_ref) DO NOTHING`,
4559
- [randomUUID(), id, row.crmPersonRef, row.payloadCiphertext]
5248
+ [randomUUID2(), id, row.crmPersonRef, row.payloadCiphertext]
4560
5249
  );
4561
5250
  }
4562
5251
  await client.query("COMMIT");
@@ -4679,7 +5368,7 @@ async function createAnalyticsActivationDestination(input) {
4679
5368
  `INSERT INTO analytics_activation_destinations(id, site_id, platform, name, connection_ref, external_dataset_id, event_mapping, provider_config, created_by_user_id)
4680
5369
  VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9) RETURNING *`,
4681
5370
  [
4682
- randomUUID(),
5371
+ randomUUID2(),
4683
5372
  input.siteId,
4684
5373
  input.platform,
4685
5374
  input.name.trim(),
@@ -4826,8 +5515,8 @@ async function testAnalyticsActivationDestination(input) {
4826
5515
  400
4827
5516
  );
4828
5517
  }
4829
- const testId = randomUUID();
4830
- const syntheticHash = createHash("sha256").update(`xray-provider-test:${testId}`).digest("hex");
5518
+ const testId = randomUUID2();
5519
+ const syntheticHash = createHash2("sha256").update(`xray-provider-test:${testId}`).digest("hex");
4831
5520
  const providerConfig = destination.provider_config ?? {};
4832
5521
  const payload = {
4833
5522
  eventId: `xray-test:${testId}`,
@@ -5064,7 +5753,7 @@ async function queueAnalyticsActivation(input) {
5064
5753
  `INSERT INTO analytics_activation_jobs(id, destination_id, conversion_id, person_id, payload_ciphertext)
5065
5754
  VALUES ($1,$2,$3,$4,$5) ON CONFLICT(destination_id, conversion_id) DO NOTHING`,
5066
5755
  [
5067
- randomUUID(),
5756
+ randomUUID2(),
5068
5757
  destination.id,
5069
5758
  input.conversionId,
5070
5759
  input.personId ?? null,
@@ -5076,7 +5765,7 @@ async function queueAnalyticsActivation(input) {
5076
5765
  return queued;
5077
5766
  }
5078
5767
  async function queueAnalyticsFormDelivery(input) {
5079
- const id = randomUUID();
5768
+ const id = randomUUID2();
5080
5769
  await getAnalyticsPool().query(
5081
5770
  `INSERT INTO analytics_form_delivery_jobs(id, submission_id, owner_user_id, payload_ciphertext, last_error_code)
5082
5771
  VALUES ($1,$2,$3,$4,$5)`,
@@ -5233,7 +5922,7 @@ async function analyticsHealth(siteId, userId) {
5233
5922
  }
5234
5923
  async function refreshAnalyticsDailyRollups(input) {
5235
5924
  const db = getAnalyticsPool();
5236
- const runId = randomUUID();
5925
+ const runId = randomUUID2();
5237
5926
  await db.query(
5238
5927
  `INSERT INTO analytics_rollup_runs(id, window_start, window_end, status) VALUES ($1, $2, $3, 'running')`,
5239
5928
  [runId, input.start, input.end]
@@ -5430,7 +6119,7 @@ async function createAnalyticsExport(input) {
5430
6119
  `Generated ${(/* @__PURE__ */ new Date()).toISOString()} from the governed ${input.report} report contract.`
5431
6120
  ].join("\n");
5432
6121
  }
5433
- const id = randomUUID();
6122
+ const id = randomUUID2();
5434
6123
  const inserted = await getAnalyticsPool().query(
5435
6124
  `INSERT INTO analytics_exports(id, site_id, requested_by_user_id, idempotency_key, report, format, filters, content)
5436
6125
  VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
@@ -5465,6 +6154,13 @@ async function createAnalyticsExport(input) {
5465
6154
  }
5466
6155
 
5467
6156
  export {
6157
+ encryptAnalyticsRestricted,
6158
+ decryptAnalyticsRestricted,
6159
+ analyticsIdentityHmac,
6160
+ signAnalyticsWebhook,
6161
+ verifyAnalyticsWebhookSignature,
6162
+ AnalyticsIdentityProfileError,
6163
+ resolveAnalyticsMaskedIdentityProfile,
5468
6164
  AnalyticsProviderRegistryError,
5469
6165
  getAnalyticsProviderDefinition,
5470
6166
  assertConnectionSupportsProvider,
@@ -5492,6 +6188,10 @@ export {
5492
6188
  listAnalyticsHostGroups,
5493
6189
  prepareAnalyticsLinkerIssue,
5494
6190
  redeemAnalyticsLinkerRecord,
6191
+ analyticsIdentityResolutionAllowed,
6192
+ analyticsIdentityPromotionAllowed,
6193
+ projectAnalyticsPixelEventConsent,
6194
+ resolveAnalyticsConfirmedActivationIdentity,
5495
6195
  MAX_ENGAGED_MS,
5496
6196
  ENGAGED_SESSION_MS,
5497
6197
  normalizeAnalyticsPath,
@@ -5520,7 +6220,10 @@ export {
5520
6220
  recordAnalyticsFormSubmission,
5521
6221
  sanitizeClickIds,
5522
6222
  identityHmac,
6223
+ backfillAnalyticsConfirmedHistory,
6224
+ linkAnalyticsIdentityInTransaction,
5523
6225
  linkAnalyticsFormIdentity,
6226
+ enrichAnalyticsExistingCrmIdentity,
5524
6227
  listAnalyticsPeople,
5525
6228
  getAnalyticsPersonJourney,
5526
6229
  createAnalyticsCrmImport,