@gurulu/cli 0.4.7 → 1.0.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 (190) hide show
  1. package/LICENSE +92 -0
  2. package/README.md +35 -106
  3. package/dist/bin.d.ts +3 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +25751 -0
  6. package/dist/commands/auth.d.ts +23 -20
  7. package/dist/commands/auth.d.ts.map +1 -0
  8. package/dist/commands/doctor.d.ts +20 -6
  9. package/dist/commands/doctor.d.ts.map +1 -0
  10. package/dist/commands/init.d.ts +33 -11
  11. package/dist/commands/init.d.ts.map +1 -0
  12. package/dist/commands/pull.d.ts +13 -0
  13. package/dist/commands/pull.d.ts.map +1 -0
  14. package/dist/commands/push.d.ts +40 -0
  15. package/dist/commands/push.d.ts.map +1 -0
  16. package/dist/commands/validate.d.ts +36 -0
  17. package/dist/commands/validate.d.ts.map +1 -0
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +25326 -876
  21. package/dist/lib/api.d.ts +139 -0
  22. package/dist/lib/api.d.ts.map +1 -0
  23. package/dist/lib/codegen.d.ts +4 -0
  24. package/dist/lib/codegen.d.ts.map +1 -0
  25. package/dist/lib/config.d.ts +43 -0
  26. package/dist/lib/config.d.ts.map +1 -0
  27. package/dist/lib/detect.d.ts +27 -0
  28. package/dist/lib/detect.d.ts.map +1 -0
  29. package/dist/lib/detect.js +106 -0
  30. package/dist/lib/exec-install.d.ts +21 -0
  31. package/dist/lib/exec-install.d.ts.map +1 -0
  32. package/dist/lib/install-plan.d.ts +25 -0
  33. package/dist/lib/install-plan.d.ts.map +1 -0
  34. package/dist/lib/install-plan.js +161 -0
  35. package/package.json +51 -20
  36. package/bin/gurulu.js +0 -2
  37. package/dist/api-client.d.ts +0 -33
  38. package/dist/api-client.js +0 -175
  39. package/dist/commands/add-server.d.ts +0 -9
  40. package/dist/commands/add-server.js +0 -162
  41. package/dist/commands/alerts.d.ts +0 -27
  42. package/dist/commands/alerts.js +0 -309
  43. package/dist/commands/api-keys.d.ts +0 -20
  44. package/dist/commands/api-keys.js +0 -130
  45. package/dist/commands/attribution.d.ts +0 -22
  46. package/dist/commands/attribution.js +0 -111
  47. package/dist/commands/audiences.d.ts +0 -23
  48. package/dist/commands/audiences.js +0 -243
  49. package/dist/commands/audit.d.ts +0 -20
  50. package/dist/commands/audit.js +0 -130
  51. package/dist/commands/auth.js +0 -249
  52. package/dist/commands/chat.d.ts +0 -19
  53. package/dist/commands/chat.js +0 -118
  54. package/dist/commands/config.d.ts +0 -10
  55. package/dist/commands/config.js +0 -92
  56. package/dist/commands/consent.d.ts +0 -27
  57. package/dist/commands/consent.js +0 -233
  58. package/dist/commands/conversion-paths.d.ts +0 -19
  59. package/dist/commands/conversion-paths.js +0 -55
  60. package/dist/commands/db.d.ts +0 -25
  61. package/dist/commands/db.js +0 -330
  62. package/dist/commands/destinations.d.ts +0 -20
  63. package/dist/commands/destinations.js +0 -191
  64. package/dist/commands/doctor.js +0 -360
  65. package/dist/commands/errors.d.ts +0 -27
  66. package/dist/commands/errors.js +0 -121
  67. package/dist/commands/events.d.ts +0 -33
  68. package/dist/commands/events.js +0 -371
  69. package/dist/commands/experiments.d.ts +0 -22
  70. package/dist/commands/experiments.js +0 -264
  71. package/dist/commands/funnels.d.ts +0 -17
  72. package/dist/commands/funnels.js +0 -203
  73. package/dist/commands/goals.d.ts +0 -18
  74. package/dist/commands/goals.js +0 -214
  75. package/dist/commands/heatmap.d.ts +0 -27
  76. package/dist/commands/heatmap.js +0 -112
  77. package/dist/commands/identity.d.ts +0 -29
  78. package/dist/commands/identity.js +0 -328
  79. package/dist/commands/init.js +0 -215
  80. package/dist/commands/insights.d.ts +0 -10
  81. package/dist/commands/insights.js +0 -77
  82. package/dist/commands/install.d.ts +0 -259
  83. package/dist/commands/install.js +0 -1590
  84. package/dist/commands/login.d.ts +0 -20
  85. package/dist/commands/login.js +0 -170
  86. package/dist/commands/logout.d.ts +0 -10
  87. package/dist/commands/logout.js +0 -41
  88. package/dist/commands/playground.d.ts +0 -11
  89. package/dist/commands/playground.js +0 -47
  90. package/dist/commands/releases.d.ts +0 -17
  91. package/dist/commands/releases.js +0 -54
  92. package/dist/commands/replay.d.ts +0 -18
  93. package/dist/commands/replay.js +0 -64
  94. package/dist/commands/secrets.d.ts +0 -19
  95. package/dist/commands/secrets.js +0 -145
  96. package/dist/commands/setup.d.ts +0 -21
  97. package/dist/commands/setup.js +0 -67
  98. package/dist/commands/sites.d.ts +0 -18
  99. package/dist/commands/sites.js +0 -139
  100. package/dist/commands/skad.d.ts +0 -18
  101. package/dist/commands/skad.js +0 -53
  102. package/dist/commands/sourcemap.d.ts +0 -33
  103. package/dist/commands/sourcemap.js +0 -204
  104. package/dist/commands/status.d.ts +0 -7
  105. package/dist/commands/status.js +0 -136
  106. package/dist/commands/upgrade.d.ts +0 -21
  107. package/dist/commands/upgrade.js +0 -183
  108. package/dist/commands/warehouse.d.ts +0 -20
  109. package/dist/commands/warehouse.js +0 -65
  110. package/dist/commands/warehouses.d.ts +0 -17
  111. package/dist/commands/warehouses.js +0 -182
  112. package/dist/commands/watch.d.ts +0 -45
  113. package/dist/commands/watch.js +0 -258
  114. package/dist/commands/whoami.d.ts +0 -9
  115. package/dist/commands/whoami.js +0 -50
  116. package/dist/config.d.ts +0 -75
  117. package/dist/config.js +0 -329
  118. package/dist/frameworks/detect.d.ts +0 -8
  119. package/dist/frameworks/detect.js +0 -458
  120. package/dist/install-intent-proposal.d.ts +0 -99
  121. package/dist/install-intent-proposal.js +0 -202
  122. package/dist/utils/api.d.ts +0 -20
  123. package/dist/utils/api.js +0 -47
  124. package/dist/utils/config.d.ts +0 -13
  125. package/dist/utils/config.js +0 -30
  126. package/dist/utils/confirm.d.ts +0 -17
  127. package/dist/utils/confirm.js +0 -40
  128. package/dist/utils/dry-run.d.ts +0 -20
  129. package/dist/utils/dry-run.js +0 -67
  130. package/dist/utils/from-file.d.ts +0 -9
  131. package/dist/utils/from-file.js +0 -72
  132. package/dist/utils/redact.d.ts +0 -14
  133. package/dist/utils/redact.js +0 -48
  134. package/dist/utils/ui.d.ts +0 -14
  135. package/dist/utils/ui.js +0 -59
  136. package/scripts/.gitkeep +0 -0
  137. package/scripts/README-gurulu-agentic-install.md +0 -114
  138. package/scripts/README-gurulu-scan.md +0 -98
  139. package/scripts/audit-cli-scopes.mjs +0 -204
  140. package/scripts/backfill-tenant-id.mjs +0 -172
  141. package/scripts/backfill-tenant-links.ts +0 -252
  142. package/scripts/backup-clickhouse.sh +0 -27
  143. package/scripts/backup-postgres.sh +0 -19
  144. package/scripts/bootstrap-runtime-schema.mjs +0 -87
  145. package/scripts/bootstrap-stripe.mjs +0 -158
  146. package/scripts/gurulu-agentic-install.lib.cjs +0 -762
  147. package/scripts/gurulu-agentic-install.mjs +0 -623
  148. package/scripts/gurulu-scan.lib.cjs +0 -1509
  149. package/scripts/gurulu-scan.mjs +0 -91
  150. package/scripts/gurulu-verify-install.lib.cjs +0 -334
  151. package/scripts/gurulu-verify-install.mjs +0 -59
  152. package/scripts/init-ssl.sh +0 -26
  153. package/scripts/migrate-flow-graph-enums.sh +0 -86
  154. package/scripts/monitor-disk.sh +0 -24
  155. package/scripts/patches/astro.patch.cjs +0 -74
  156. package/scripts/patches/auto-instrument/ast-helper.cjs +0 -480
  157. package/scripts/patches/auto-instrument/astro.cjs +0 -273
  158. package/scripts/patches/auto-instrument/express.cjs +0 -383
  159. package/scripts/patches/auto-instrument/fastify.cjs +0 -262
  160. package/scripts/patches/auto-instrument/hono.cjs +0 -392
  161. package/scripts/patches/auto-instrument/index.cjs +0 -80
  162. package/scripts/patches/auto-instrument/nestjs.cjs +0 -286
  163. package/scripts/patches/auto-instrument/nextjs-app-router.cjs +0 -345
  164. package/scripts/patches/auto-instrument/nextjs-pages.cjs +0 -361
  165. package/scripts/patches/auto-instrument/remix.cjs +0 -168
  166. package/scripts/patches/auto-instrument/sdk-helper-map.cjs +0 -241
  167. package/scripts/patches/auto-instrument/singleton-helper.cjs +0 -193
  168. package/scripts/patches/auto-instrument/sveltekit.cjs +0 -161
  169. package/scripts/patches/auto-instrument/vite-react.cjs +0 -37
  170. package/scripts/patches/auto-instrument/vue.cjs +0 -196
  171. package/scripts/patches/express.patch.cjs +0 -99
  172. package/scripts/patches/fastify.patch.cjs +0 -108
  173. package/scripts/patches/index.cjs +0 -300
  174. package/scripts/patches/nestjs.patch.cjs +0 -112
  175. package/scripts/patches/nextjs-app-router.patch.cjs +0 -97
  176. package/scripts/patches/nextjs-pages.patch.cjs +0 -97
  177. package/scripts/patches/remix.patch.cjs +0 -75
  178. package/scripts/patches/sveltekit.patch.cjs +0 -72
  179. package/scripts/patches/vite-react.patch.cjs +0 -73
  180. package/scripts/patches/vue.patch.cjs +0 -82
  181. package/scripts/renew-ssl.sh +0 -14
  182. package/scripts/resolve-migration.sh +0 -23
  183. package/scripts/seed-cli-dev-keys.mjs +0 -130
  184. package/scripts/seed-test-data.mjs +0 -391
  185. package/scripts/spike-browserless.ts +0 -65
  186. package/scripts/tenant-pivot-consistency-check.mjs +0 -205
  187. package/scripts/tenant-pivot-phase-3-cleanup.lib.cjs +0 -258
  188. package/scripts/tenant-pivot-phase-3-cleanup.mjs +0 -98
  189. package/scripts/test-identity-resolution.ts +0 -804
  190. package/scripts/validate-gurulu-schemas.mjs +0 -79
@@ -1,804 +0,0 @@
1
- #!/usr/bin/env npx tsx
2
- /**
3
- * End-to-end test suite for the identity resolution system.
4
- *
5
- * Validates all recent identity features by calling the ingest API endpoints
6
- * directly over HTTP. Designed to run against a local (or staging) instance.
7
- *
8
- * Usage:
9
- * npx tsx scripts/test-identity-resolution.ts
10
- *
11
- * Environment variables:
12
- * TEST_BASE_URL — API base (default: http://localhost:3000)
13
- * TEST_SITE_ID — Site id to use (must exist in Prisma with a valid token)
14
- * TEST_SITE_TOKEN — Bearer token for the test site
15
- * CLICKHOUSE_URL — ClickHouse HTTP endpoint (default: http://localhost:8123)
16
- * CLICKHOUSE_DATABASE — ClickHouse database (default: gurulu)
17
- */
18
-
19
- // ---------------------------------------------------------------------------
20
- // Config
21
- // ---------------------------------------------------------------------------
22
-
23
- const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:3000';
24
- const SITE_ID = process.env.TEST_SITE_ID || 'test-identity-validation';
25
- const SITE_TOKEN = process.env.TEST_SITE_TOKEN || '';
26
- const CH_URL = process.env.CLICKHOUSE_URL || 'http://localhost:8123';
27
- const CH_DB = process.env.CLICKHOUSE_DATABASE || 'gurulu';
28
-
29
- // ---------------------------------------------------------------------------
30
- // Helpers
31
- // ---------------------------------------------------------------------------
32
-
33
- let passCount = 0;
34
- let failCount = 0;
35
- let skipCount = 0;
36
-
37
- function uid(): string {
38
- return crypto.randomUUID();
39
- }
40
-
41
- function log(status: 'PASS' | 'FAIL' | 'SKIP' | 'INFO', msg: string) {
42
- const colors: Record<string, string> = {
43
- PASS: '\x1b[32m',
44
- FAIL: '\x1b[31m',
45
- SKIP: '\x1b[33m',
46
- INFO: '\x1b[36m',
47
- };
48
- const reset = '\x1b[0m';
49
- console.log(`${colors[status]}[${status}]${reset} ${msg}`);
50
- if (status === 'PASS') passCount++;
51
- if (status === 'FAIL') failCount++;
52
- if (status === 'SKIP') skipCount++;
53
- }
54
-
55
- function assert(condition: boolean, label: string, detail?: string): boolean {
56
- if (condition) {
57
- log('PASS', label);
58
- return true;
59
- }
60
- log('FAIL', `${label}${detail ? ' — ' + detail : ''}`);
61
- return false;
62
- }
63
-
64
- /** POST to /ingest/v1/identify */
65
- async function identify(body: Record<string, unknown>): Promise<Record<string, unknown>> {
66
- const headers: Record<string, string> = {
67
- 'Content-Type': 'application/json',
68
- Origin: 'http://localhost:3000',
69
- };
70
- if (SITE_TOKEN) headers['Authorization'] = `Bearer ${SITE_TOKEN}`;
71
-
72
- const res = await fetch(`${BASE_URL}/ingest/v1/identify`, {
73
- method: 'POST',
74
- headers,
75
- body: JSON.stringify({ site_id: SITE_ID, ...body }),
76
- });
77
- const json = (await res.json()) as Record<string, unknown>;
78
- return { ...json, _status: res.status };
79
- }
80
-
81
- /** POST to /ingest/v1/collect */
82
- async function collect(events: Record<string, unknown>[]): Promise<Record<string, unknown>> {
83
- const headers: Record<string, string> = {
84
- 'Content-Type': 'application/json',
85
- Origin: 'http://localhost:3000',
86
- };
87
- if (SITE_TOKEN) headers['Authorization'] = `Bearer ${SITE_TOKEN}`;
88
-
89
- const res = await fetch(`${BASE_URL}/ingest/v1/collect`, {
90
- method: 'POST',
91
- headers,
92
- body: JSON.stringify({ site_id: SITE_ID, events }),
93
- });
94
- const json = (await res.json()) as Record<string, unknown>;
95
- return { ...json, _status: res.status };
96
- }
97
-
98
- /** Query ClickHouse directly */
99
- async function chQuery(
100
- query: string,
101
- params?: Record<string, string | number>,
102
- ): Promise<{ data: Record<string, unknown>[]; rows: number }> {
103
- const url = new URL(CH_URL);
104
- url.searchParams.set('database', CH_DB);
105
- url.searchParams.set('default_format', 'JSON');
106
- if (params) {
107
- for (const [key, value] of Object.entries(params)) {
108
- let v = String(value);
109
- if (v.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/)) {
110
- v = v.replace('T', ' ').replace('Z', '');
111
- }
112
- url.searchParams.set(`param_${key}`, v);
113
- }
114
- }
115
- const res = await fetch(url.toString(), {
116
- method: 'POST',
117
- body: query,
118
- headers: { 'Content-Type': 'text/plain' },
119
- });
120
- if (!res.ok) {
121
- const text = await res.text();
122
- throw new Error(`ClickHouse error ${res.status}: ${text}`);
123
- }
124
- return res.json() as Promise<{ data: Record<string, unknown>[]; rows: number }>;
125
- }
126
-
127
- /** Short delay to let async fire-and-forget writes settle */
128
- function wait(ms = 1500): Promise<void> {
129
- return new Promise((resolve) => setTimeout(resolve, ms));
130
- }
131
-
132
- // ---------------------------------------------------------------------------
133
- // Connectivity pre-check
134
- // ---------------------------------------------------------------------------
135
-
136
- async function preCheck(): Promise<boolean> {
137
- log('INFO', `Base URL: ${BASE_URL}`);
138
- log('INFO', `Site ID: ${SITE_ID}`);
139
- log('INFO', `CH URL: ${CH_URL}`);
140
-
141
- // Check API reachability
142
- try {
143
- const res = await fetch(`${BASE_URL}/api/status`, { signal: AbortSignal.timeout(5000) });
144
- if (!res.ok) {
145
- log('FAIL', `API not reachable (status ${res.status}) — is the dev server running?`);
146
- return false;
147
- }
148
- } catch (err) {
149
- log('FAIL', `API not reachable at ${BASE_URL} — ${(err as Error).message}`);
150
- return false;
151
- }
152
-
153
- // Check that the site exists by attempting a minimal identify
154
- const probe = await identify({
155
- anonymous_id: uid(),
156
- user_id: 'probe-' + uid(),
157
- });
158
- if ((probe._status as number) === 404) {
159
- log('FAIL', `Site "${SITE_ID}" not found. Create it first or set TEST_SITE_ID.`);
160
- return false;
161
- }
162
- if ((probe._status as number) === 401) {
163
- log('FAIL', 'Auth failed — set TEST_SITE_TOKEN to the site Bearer token.');
164
- return false;
165
- }
166
- if (probe.status !== 'ok') {
167
- log('FAIL', `Unexpected probe response: ${JSON.stringify(probe)}`);
168
- return false;
169
- }
170
-
171
- log('PASS', 'Pre-check: API reachable and site valid');
172
- return true;
173
- }
174
-
175
- // ---------------------------------------------------------------------------
176
- // Test 1: Basic identify + claim creation
177
- // ---------------------------------------------------------------------------
178
-
179
- async function test01_basicIdentify() {
180
- log('INFO', '--- Test 1: Basic identify + claim creation ---');
181
-
182
- const anonId = uid();
183
- const userId = 'user-' + uid().slice(0, 8);
184
- const email = `test-${uid().slice(0, 6)}@identity-test.com`;
185
-
186
- const res = await identify({
187
- anonymous_id: anonId,
188
- user_id: userId,
189
- traits: { email },
190
- });
191
-
192
- assert(res.status === 'ok', 'T1: identify returned ok');
193
- assert(typeof res.canonical_id === 'string' && (res.canonical_id as string).length > 0,
194
- 'T1: canonical_id is a non-empty string', `got: ${res.canonical_id}`);
195
- }
196
-
197
- // ---------------------------------------------------------------------------
198
- // Test 2: Phone E.164 normalization
199
- // ---------------------------------------------------------------------------
200
-
201
- async function test02_phoneNormalization() {
202
- log('INFO', '--- Test 2: Phone E.164 normalization ---');
203
-
204
- // 2a: Formatted phone with country code should normalize
205
- const anonA = uid();
206
- const resA = await identify({
207
- anonymous_id: anonA,
208
- user_id: 'phone-a-' + uid().slice(0, 8),
209
- traits: { phone: '+90 (532) 123-4567' },
210
- });
211
- assert(resA.status === 'ok', 'T2a: Formatted phone accepted');
212
- assert(typeof resA.canonical_id === 'string' && (resA.canonical_id as string).length > 0,
213
- 'T2a: canonical_id returned');
214
-
215
- // 2b: Phone without country code should be silently ignored (no + prefix)
216
- const anonB = uid();
217
- const resB = await identify({
218
- anonymous_id: anonB,
219
- user_id: 'phone-b-' + uid().slice(0, 8),
220
- traits: { phone: '5321234567' },
221
- });
222
- // Should still succeed (phone is optional) but the phone claim won't be created.
223
- assert(resB.status === 'ok', 'T2b: Phone without country code — identify still ok');
224
-
225
- // 2c: Valid E.164 phone
226
- const anonC = uid();
227
- const resC = await identify({
228
- anonymous_id: anonC,
229
- user_id: 'phone-c-' + uid().slice(0, 8),
230
- traits: { phone: '+905321234567' },
231
- });
232
- assert(resC.status === 'ok', 'T2c: Valid E.164 phone accepted');
233
- assert(typeof resC.canonical_id === 'string' && (resC.canonical_id as string).length > 0,
234
- 'T2c: canonical_id returned for E.164 phone');
235
- }
236
-
237
- // ---------------------------------------------------------------------------
238
- // Test 3: OAuth provider ID
239
- // ---------------------------------------------------------------------------
240
-
241
- async function test03_oauthProvider() {
242
- log('INFO', '--- Test 3: OAuth provider ID ---');
243
-
244
- // 3a: Known provider (google)
245
- const anonA = uid();
246
- const resA = await identify({
247
- anonymous_id: anonA,
248
- user_id: 'oauth-a-' + uid().slice(0, 8),
249
- oauth_provider: 'google',
250
- oauth_id: '118234567890',
251
- });
252
- assert(resA.status === 'ok', 'T3a: Google OAuth identify ok');
253
- assert(typeof resA.canonical_id === 'string' && (resA.canonical_id as string).length > 0,
254
- 'T3a: canonical_id returned');
255
-
256
- // 3b: Unknown provider should map to "custom:id"
257
- const anonB = uid();
258
- const resB = await identify({
259
- anonymous_id: anonB,
260
- user_id: 'oauth-b-' + uid().slice(0, 8),
261
- oauth_provider: 'myapp',
262
- oauth_id: '999888777',
263
- });
264
- assert(resB.status === 'ok', 'T3b: Unknown provider identify ok (maps to custom:id)');
265
- }
266
-
267
- // ---------------------------------------------------------------------------
268
- // Test 4: Cross-browser merge (same email)
269
- // ---------------------------------------------------------------------------
270
-
271
- async function test04_crossBrowserMerge() {
272
- log('INFO', '--- Test 4: Cross-browser merge (same email) ---');
273
-
274
- const sharedEmail = `merge-${uid().slice(0, 6)}@identity-test.com`;
275
-
276
- // User A from browser 1
277
- const anonA = uid();
278
- const resA = await identify({
279
- anonymous_id: anonA,
280
- user_id: 'merge-a-' + uid().slice(0, 8),
281
- traits: { email: sharedEmail },
282
- });
283
- assert(resA.status === 'ok', 'T4: User A identified');
284
-
285
- // User B from browser 2 — same email
286
- const anonB = uid();
287
- const resB = await identify({
288
- anonymous_id: anonB,
289
- user_id: 'merge-b-' + uid().slice(0, 8),
290
- traits: { email: sharedEmail },
291
- });
292
- assert(resB.status === 'ok', 'T4: User B identified');
293
-
294
- // Both should resolve to the same canonical_id
295
- assert(
296
- resA.canonical_id === resB.canonical_id,
297
- 'T4: Both browsers merge to same canonical_id',
298
- `A=${resA.canonical_id}, B=${resB.canonical_id}`,
299
- );
300
- }
301
-
302
- // ---------------------------------------------------------------------------
303
- // Test 5: Cross-identifier merge (email + phone)
304
- // ---------------------------------------------------------------------------
305
-
306
- async function test05_crossIdentifierMerge() {
307
- log('INFO', '--- Test 5: Cross-identifier merge (email + phone) ---');
308
-
309
- const emailA = `xid-${uid().slice(0, 6)}@identity-test.com`;
310
- const phoneB = `+9053${Math.floor(10000000 + Math.random() * 90000000)}`;
311
-
312
- // User A: email only
313
- const anonA = uid();
314
- const resA = await identify({
315
- anonymous_id: anonA,
316
- user_id: 'xid-a-' + uid().slice(0, 8),
317
- traits: { email: emailA },
318
- });
319
- assert(resA.status === 'ok', 'T5: User A (email) identified');
320
- const canonA = resA.canonical_id;
321
-
322
- // User B: phone only
323
- const anonB = uid();
324
- const resB = await identify({
325
- anonymous_id: anonB,
326
- user_id: 'xid-b-' + uid().slice(0, 8),
327
- traits: { phone: phoneB },
328
- });
329
- assert(resB.status === 'ok', 'T5: User B (phone) identified');
330
- const canonB = resB.canonical_id;
331
-
332
- // Verify they start as different people
333
- assert(canonA !== canonB, 'T5: Initially A and B are different persons',
334
- `A=${canonA}, B=${canonB}`);
335
-
336
- // User C: carries BOTH email + phone, triggering cross-identifier merge
337
- const anonC = uid();
338
- const resC = await identify({
339
- anonymous_id: anonC,
340
- user_id: 'xid-c-' + uid().slice(0, 8),
341
- traits: { email: emailA, phone: phoneB },
342
- });
343
- assert(resC.status === 'ok', 'T5: User C (email+phone) identified');
344
-
345
- // Check merge happened
346
- const mergedCanon = resC.canonical_id;
347
- // Re-identify A to see if they resolve to the merged person
348
- const resA2 = await identify({
349
- anonymous_id: anonA,
350
- user_id: 'xid-a-' + uid().slice(0, 8),
351
- traits: { email: emailA },
352
- });
353
- const resB2 = await identify({
354
- anonymous_id: anonB,
355
- user_id: 'xid-b-' + uid().slice(0, 8),
356
- traits: { phone: phoneB },
357
- });
358
-
359
- assert(
360
- resA2.canonical_id === mergedCanon,
361
- 'T5: After merge, A resolves to merged canonical',
362
- `A2=${resA2.canonical_id}, merged=${mergedCanon}`,
363
- );
364
- assert(
365
- resB2.canonical_id === mergedCanon,
366
- 'T5: After merge, B resolves to merged canonical',
367
- `B2=${resB2.canonical_id}, merged=${mergedCanon}`,
368
- );
369
-
370
- // Check merge metadata in response
371
- if (resC.merges) {
372
- const merges = resC.merges as Array<Record<string, unknown>>;
373
- assert(merges.length > 0, 'T5: Merge events present in response');
374
- const m = merges[0];
375
- assert(
376
- typeof m.winner === 'string' && typeof m.loser === 'string',
377
- 'T5: Merge event has winner/loser',
378
- `winner=${m.winner}, loser=${m.loser}`,
379
- );
380
- } else {
381
- log('INFO', 'T5: No merge metadata in response (may have matched on first claim)');
382
- }
383
- }
384
-
385
- // ---------------------------------------------------------------------------
386
- // Test 6: Consent level enforcement
387
- // ---------------------------------------------------------------------------
388
-
389
- async function test06_consentEnforcement() {
390
- log('INFO', '--- Test 6: Consent level enforcement ---');
391
-
392
- // 6a: consent=none → consent_skipped
393
- const resNone = await identify({
394
- anonymous_id: uid(),
395
- user_id: 'consent-none-' + uid().slice(0, 8),
396
- traits: { email: `none-${uid().slice(0, 6)}@test.com` },
397
- consent_level: 'none',
398
- });
399
- assert(resNone.consent_skipped === true, 'T6a: consent=none returns consent_skipped');
400
- assert(resNone.canonical_id === null, 'T6a: consent=none returns null canonical_id');
401
-
402
- // 6b: consent=analytics → only anonymous_id, no PII claims
403
- const analyticsAnon = uid();
404
- const resAnalytics = await identify({
405
- anonymous_id: analyticsAnon,
406
- user_id: 'consent-analytics-' + uid().slice(0, 8),
407
- traits: { email: `analytics-${uid().slice(0, 6)}@test.com` },
408
- consent_level: 'analytics',
409
- });
410
- assert(resAnalytics.status === 'ok', 'T6b: consent=analytics accepted');
411
- assert(
412
- typeof resAnalytics.canonical_id === 'string' && (resAnalytics.canonical_id as string).length > 0,
413
- 'T6b: consent=analytics still returns canonical_id (for anonymous_id claim)',
414
- );
415
-
416
- // 6c: consent=marketing → all claims
417
- const marketingAnon = uid();
418
- const marketingEmail = `marketing-${uid().slice(0, 6)}@test.com`;
419
- const resMarketing = await identify({
420
- anonymous_id: marketingAnon,
421
- user_id: 'consent-mkt-' + uid().slice(0, 8),
422
- traits: { email: marketingEmail },
423
- consent_level: 'marketing',
424
- });
425
- assert(resMarketing.status === 'ok', 'T6c: consent=marketing accepted');
426
- assert(typeof resMarketing.canonical_id === 'string', 'T6c: canonical_id returned');
427
-
428
- // 6d: consent=full → all claims
429
- const fullAnon = uid();
430
- const fullEmail = `full-${uid().slice(0, 6)}@test.com`;
431
- const resFull = await identify({
432
- anonymous_id: fullAnon,
433
- user_id: 'consent-full-' + uid().slice(0, 8),
434
- traits: { email: fullEmail },
435
- consent_level: 'full',
436
- });
437
- assert(resFull.status === 'ok', 'T6d: consent=full accepted');
438
- assert(typeof resFull.canonical_id === 'string', 'T6d: canonical_id returned');
439
- }
440
-
441
- // ---------------------------------------------------------------------------
442
- // Test 7: Consent downgrade
443
- // ---------------------------------------------------------------------------
444
-
445
- async function test07_consentDowngrade() {
446
- log('INFO', '--- Test 7: Consent downgrade ---');
447
-
448
- const anonId = uid();
449
- const userId = 'consent-dg-' + uid().slice(0, 8);
450
- const email = `dg-${uid().slice(0, 6)}@test.com`;
451
-
452
- // Step 1: identify with full consent and email
453
- const res1 = await identify({
454
- anonymous_id: anonId,
455
- user_id: userId,
456
- traits: { email },
457
- consent_level: 'full',
458
- });
459
- assert(res1.status === 'ok', 'T7: Initial full-consent identify ok');
460
- const canonId = res1.canonical_id as string;
461
-
462
- // Step 2: Send $consent_update event downgrading to "none"
463
- const consentEvent = {
464
- anonymous_id: anonId,
465
- session_id: uid(),
466
- event_type: 'custom',
467
- event_name: '$consent_update',
468
- page_url: 'http://localhost:3000/',
469
- page_title: 'Test',
470
- referrer: '',
471
- timestamp: new Date().toISOString(),
472
- properties: JSON.stringify({
473
- level: 'none',
474
- }),
475
- };
476
- const collectRes = await collect([consentEvent]);
477
- assert(
478
- collectRes.status === 'ok' || (collectRes._status as number) === 200,
479
- 'T7: $consent_update event collected',
480
- );
481
-
482
- // Wait for async processing
483
- await wait(2000);
484
-
485
- // Step 3: Re-identify with email + consent=none → should be blocked
486
- const res3 = await identify({
487
- anonymous_id: anonId,
488
- user_id: userId,
489
- traits: { email: `new-${uid().slice(0, 6)}@test.com` },
490
- consent_level: 'none',
491
- });
492
- assert(
493
- res3.consent_skipped === true,
494
- 'T7: After consent downgrade, identify with consent=none is skipped',
495
- );
496
- }
497
-
498
- // ---------------------------------------------------------------------------
499
- // Test 8: $identity_merge audit events in ClickHouse
500
- // ---------------------------------------------------------------------------
501
-
502
- async function test08_mergeAuditEvents() {
503
- log('INFO', '--- Test 8: $identity_merge audit events ---');
504
-
505
- const sharedEmail = `audit-${uid().slice(0, 6)}@identity-test.com`;
506
-
507
- // Create two separate persons then merge via shared email
508
- const anonA = uid();
509
- const resA = await identify({
510
- anonymous_id: anonA,
511
- user_id: 'audit-a-' + uid().slice(0, 8),
512
- traits: { email: sharedEmail },
513
- });
514
- assert(resA.status === 'ok', 'T8: Person A identified');
515
-
516
- const anonB = uid();
517
- const resB = await identify({
518
- anonymous_id: anonB,
519
- user_id: 'audit-b-' + uid().slice(0, 8),
520
- traits: { email: sharedEmail },
521
- });
522
- assert(resB.status === 'ok', 'T8: Person B identified (should merge)');
523
-
524
- // Wait for async ClickHouse write
525
- await wait(3000);
526
-
527
- // Query ClickHouse for merge events
528
- try {
529
- const result = await chQuery(
530
- `SELECT winner_canonical_id, loser_canonical_id, merge_reason, claim_types_involved
531
- FROM identity_merge_events
532
- WHERE site_id = {p_site:String}
533
- ORDER BY timestamp DESC
534
- LIMIT 10`,
535
- { p_site: SITE_ID },
536
- );
537
-
538
- if (result.rows === 0) {
539
- log('SKIP', 'T8: No merge events in ClickHouse (merge may not have occurred if same person)');
540
- } else {
541
- const row = result.data[0];
542
- assert(
543
- typeof row.winner_canonical_id === 'string' && (row.winner_canonical_id as string).length > 0,
544
- 'T8: winner_canonical_id populated',
545
- );
546
- assert(
547
- typeof row.loser_canonical_id === 'string' && (row.loser_canonical_id as string).length > 0,
548
- 'T8: loser_canonical_id populated',
549
- );
550
- assert(
551
- typeof row.merge_reason === 'string' && (row.merge_reason as string).length > 0,
552
- 'T8: merge_reason populated',
553
- `reason=${row.merge_reason}`,
554
- );
555
- log('INFO', `T8: Found ${result.rows} merge event(s). Latest: winner=${row.winner_canonical_id}, loser=${row.loser_canonical_id}, reason=${row.merge_reason}`);
556
- }
557
- } catch (err) {
558
- log('SKIP', `T8: ClickHouse query failed — ${(err as Error).message}`);
559
- }
560
- }
561
-
562
- // ---------------------------------------------------------------------------
563
- // Test 9: Device fingerprint claim
564
- // ---------------------------------------------------------------------------
565
-
566
- async function test09_deviceFingerprint() {
567
- log('INFO', '--- Test 9: Device fingerprint claim ---');
568
-
569
- const anonId = uid();
570
- const deviceId = 'fp_' + uid().slice(0, 12);
571
-
572
- const res = await identify({
573
- anonymous_id: anonId,
574
- user_id: 'device-' + uid().slice(0, 8),
575
- device_id: deviceId,
576
- consent_level: 'full',
577
- });
578
-
579
- assert(res.status === 'ok', 'T9: Identify with device_id ok');
580
- assert(
581
- typeof res.canonical_id === 'string' && (res.canonical_id as string).length > 0,
582
- 'T9: canonical_id returned with device fingerprint',
583
- );
584
- // The device_id claim is created with confidence 0.6 — we can't directly
585
- // verify the confidence from the API response, but the claim being
586
- // processed without error is the key signal. Deeper verification would
587
- // require a DB query.
588
- log('INFO', 'T9: device_id claim processed (confidence 0.6 set internally)');
589
- }
590
-
591
- // ---------------------------------------------------------------------------
592
- // Test 10: ip_hash in events_raw
593
- // ---------------------------------------------------------------------------
594
-
595
- async function test10_ipHash() {
596
- log('INFO', '--- Test 10: ip_hash in events_raw ---');
597
-
598
- const anonId = uid();
599
- const sessionId = uid();
600
- const eventName = `test_iphash_${uid().slice(0, 6)}`;
601
-
602
- const event = {
603
- anonymous_id: anonId,
604
- session_id: sessionId,
605
- event_type: 'custom',
606
- event_name: eventName,
607
- page_url: 'http://localhost:3000/test-ip-hash',
608
- page_title: 'IP Hash Test',
609
- referrer: '',
610
- timestamp: new Date().toISOString(),
611
- properties: JSON.stringify({}),
612
- };
613
-
614
- const res = await collect([event]);
615
- assert(
616
- res.status === 'ok' || (res._status as number) === 200,
617
- 'T10: Event collected',
618
- );
619
-
620
- // Wait for ClickHouse write
621
- await wait(2000);
622
-
623
- try {
624
- const result = await chQuery(
625
- `SELECT ip_hash, ip
626
- FROM events_raw
627
- WHERE site_id = {p_site:String}
628
- AND event_name = {p_name:String}
629
- ORDER BY timestamp DESC
630
- LIMIT 1`,
631
- { p_site: SITE_ID, p_name: eventName },
632
- );
633
-
634
- if (result.rows === 0) {
635
- log('SKIP', 'T10: Event not found in ClickHouse (may need longer wait)');
636
- } else {
637
- const row = result.data[0];
638
- assert(
639
- typeof row.ip_hash === 'string' && (row.ip_hash as string).length > 0,
640
- 'T10: ip_hash is populated',
641
- `ip_hash=${row.ip_hash}`,
642
- );
643
- log('INFO', `T10: ip=${row.ip}, ip_hash=${row.ip_hash}`);
644
- }
645
- } catch (err) {
646
- log('SKIP', `T10: ClickHouse query failed — ${(err as Error).message}`);
647
- }
648
- }
649
-
650
- // ---------------------------------------------------------------------------
651
- // Test 11: Consent=analytics blocks PII but allows anonymous_id
652
- // ---------------------------------------------------------------------------
653
-
654
- async function test11_analyticsConsentPIIBlock() {
655
- log('INFO', '--- Test 11: Analytics consent — same email does NOT merge ---');
656
-
657
- const sharedEmail = `analytics-pii-${uid().slice(0, 6)}@test.com`;
658
-
659
- // User A with full consent — email claim created
660
- const anonA = uid();
661
- const resA = await identify({
662
- anonymous_id: anonA,
663
- user_id: 'apii-a-' + uid().slice(0, 8),
664
- traits: { email: sharedEmail },
665
- consent_level: 'full',
666
- });
667
- assert(resA.status === 'ok', 'T11: User A (full consent) identified');
668
-
669
- // User B with analytics consent — email should be filtered out, so no merge
670
- const anonB = uid();
671
- const resB = await identify({
672
- anonymous_id: anonB,
673
- user_id: 'apii-b-' + uid().slice(0, 8),
674
- traits: { email: sharedEmail },
675
- consent_level: 'analytics',
676
- });
677
- assert(resB.status === 'ok', 'T11: User B (analytics consent) identified');
678
- assert(
679
- resA.canonical_id !== resB.canonical_id,
680
- 'T11: Different canonical_ids (email PII blocked at analytics level)',
681
- `A=${resA.canonical_id}, B=${resB.canonical_id}`,
682
- );
683
- }
684
-
685
- // ---------------------------------------------------------------------------
686
- // Test 12: Multiple identifies with same anonymous_id are stable
687
- // ---------------------------------------------------------------------------
688
-
689
- async function test12_idempotentIdentify() {
690
- log('INFO', '--- Test 12: Idempotent identify (same anonymous_id) ---');
691
-
692
- const anonId = uid();
693
- const userId = 'idem-' + uid().slice(0, 8);
694
- const email = `idem-${uid().slice(0, 6)}@test.com`;
695
-
696
- const res1 = await identify({
697
- anonymous_id: anonId,
698
- user_id: userId,
699
- traits: { email },
700
- });
701
- const res2 = await identify({
702
- anonymous_id: anonId,
703
- user_id: userId,
704
- traits: { email },
705
- });
706
-
707
- assert(res1.status === 'ok' && res2.status === 'ok', 'T12: Both identifies ok');
708
- assert(
709
- res1.canonical_id === res2.canonical_id,
710
- 'T12: Same anonymous_id + email → same canonical_id',
711
- `r1=${res1.canonical_id}, r2=${res2.canonical_id}`,
712
- );
713
- }
714
-
715
- // ---------------------------------------------------------------------------
716
- // Cleanup
717
- // ---------------------------------------------------------------------------
718
-
719
- async function cleanup() {
720
- log('INFO', '--- Cleanup ---');
721
- // ClickHouse: delete test rows (lightweight_delete or ALTER TABLE DELETE)
722
- // These are best-effort; if they fail (e.g., CH not available), that's ok.
723
- try {
724
- await chQuery(
725
- `ALTER TABLE identity_merge_events DELETE WHERE site_id = {p_site:String}`,
726
- { p_site: SITE_ID },
727
- );
728
- log('INFO', 'Cleaned identity_merge_events');
729
- } catch {
730
- log('INFO', 'Skipped identity_merge_events cleanup (table may not exist)');
731
- }
732
-
733
- try {
734
- await chQuery(
735
- `ALTER TABLE events_raw DELETE WHERE site_id = {p_site:String} AND event_name LIKE 'test_%'`,
736
- { p_site: SITE_ID },
737
- );
738
- log('INFO', 'Cleaned test events from events_raw');
739
- } catch {
740
- log('INFO', 'Skipped events_raw cleanup');
741
- }
742
-
743
- try {
744
- await chQuery(
745
- `ALTER TABLE identity_map DELETE WHERE site_id = {p_site:String}`,
746
- { p_site: SITE_ID },
747
- );
748
- log('INFO', 'Cleaned identity_map');
749
- } catch {
750
- log('INFO', 'Skipped identity_map cleanup');
751
- }
752
-
753
- // Note: Prisma cleanup (CanonicalPerson, IdentityClaim) is intentionally
754
- // skipped — the test site should be a dedicated test tenant. If needed, add
755
- // a Prisma-based cleanup via a separate admin endpoint or direct DB access.
756
- log('INFO', 'Cleanup complete (ClickHouse only; Prisma records retained for inspection)');
757
- }
758
-
759
- // ---------------------------------------------------------------------------
760
- // Runner
761
- // ---------------------------------------------------------------------------
762
-
763
- async function main() {
764
- console.log('\n========================================');
765
- console.log(' Identity Resolution E2E Test Suite');
766
- console.log('========================================\n');
767
-
768
- const ready = await preCheck();
769
- if (!ready) {
770
- console.log('\nPre-check failed. Aborting.\n');
771
- process.exit(1);
772
- }
773
-
774
- console.log('');
775
-
776
- try {
777
- await test01_basicIdentify();
778
- await test02_phoneNormalization();
779
- await test03_oauthProvider();
780
- await test04_crossBrowserMerge();
781
- await test05_crossIdentifierMerge();
782
- await test06_consentEnforcement();
783
- await test07_consentDowngrade();
784
- await test08_mergeAuditEvents();
785
- await test09_deviceFingerprint();
786
- await test10_ipHash();
787
- await test11_analyticsConsentPIIBlock();
788
- await test12_idempotentIdentify();
789
- } catch (err) {
790
- log('FAIL', `Unhandled error: ${(err as Error).message}`);
791
- console.error(err);
792
- }
793
-
794
- console.log('');
795
- await cleanup();
796
-
797
- console.log('\n========================================');
798
- console.log(` Results: ${passCount} passed, ${failCount} failed, ${skipCount} skipped`);
799
- console.log('========================================\n');
800
-
801
- process.exit(failCount > 0 ? 1 : 0);
802
- }
803
-
804
- main();