@hanzo/event 0.3.41 → 0.3.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core.d.ts +8 -0
- package/dist/core.d.ts.map +1 -1
- package/dist/events.d.ts +1 -1
- package/dist/index.cjs +133 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +130 -6
- package/dist/index.mjs.map +1 -1
- package/dist/link.d.ts +28 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/react.cjs +129 -5
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +129 -5
- package/dist/react.mjs.map +1 -1
- package/dist/scrub.d.ts +5 -0
- package/dist/scrub.d.ts.map +1 -1
- package/dist/storage.d.ts +6 -0
- package/dist/storage.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/anon.d.ts +3 -0
- package/src/anon.js +19 -1
- package/src/core.test.ts +19 -0
- package/src/core.ts +52 -7
- package/src/events.ts +1 -1
- package/src/index.ts +2 -0
- package/src/link.test.ts +222 -0
- package/src/link.ts +103 -0
- package/src/scrub.test.ts +34 -1
- package/src/scrub.ts +20 -0
- package/src/storage.ts +14 -1
package/dist/scrub.d.ts
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
export declare function redactCredentialParams(s: string): string;
|
|
4
4
|
/** redactSecrets removes known secret shapes. Always applied. */
|
|
5
5
|
export declare function redactSecrets(s: string): string;
|
|
6
|
+
/** withoutFragment returns a URL with its fragment removed. A browser never
|
|
7
|
+
* sends the fragment to any server — it is where an app keeps what must stay
|
|
8
|
+
* on the device, a share link's secret among them — so the client does not
|
|
9
|
+
* send it either. */
|
|
10
|
+
export declare function withoutFragment(url: string): string;
|
|
6
11
|
/** scrubPII masks emails and IPs. Applied unless PII capture is enabled. */
|
|
7
12
|
export declare function scrubPII(s: string): string;
|
|
8
13
|
/** Longest free-text field this module will scrub. Every pattern here is a regex
|
package/dist/scrub.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scrub.d.ts","sourceRoot":"","sources":["../src/scrub.ts"],"names":[],"mappings":"AA8GA;6EAC6E;AAC7E,wBAAgB,sBAAsB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAExD;
|
|
1
|
+
{"version":3,"file":"scrub.d.ts","sourceRoot":"","sources":["../src/scrub.ts"],"names":[],"mappings":"AA8GA;6EAC6E;AAC7E,wBAAgB,sBAAsB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAExD;AAYD,iEAAiE;AACjE,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAK/C;AAED;;;sBAGsB;AACtB,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAGnD;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAK1C;AAED;;;;yEAIyE;AACzE,eAAO,MAAM,aAAa,OAAO,CAAA;AAEjC;4DAC4D;AAC5D,wBAAgB,QAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,SAAgB,GAAG,MAAM,CAE/D;AAED;qFACqF;AACrF,wBAAgB,SAAS,CAAC,CAAC,EAAE,MAAM,GAAG,SAAS,EAAE,UAAU,UAAQ,GAAG,MAAM,CAM3E"}
|
package/dist/storage.d.ts
CHANGED
|
@@ -21,6 +21,12 @@ export declare function anonId(): string | undefined;
|
|
|
21
21
|
* and the recorded `last` from disagreeing.
|
|
22
22
|
*/
|
|
23
23
|
export declare function sessionId(now?: number): string | undefined;
|
|
24
|
+
/** Make `id` the current session, as if it had begun now. The session a link
|
|
25
|
+
* carries is the one the visitor is already in, so the journey stays one session
|
|
26
|
+
* across the hop instead of splitting at the host boundary. */
|
|
27
|
+
export declare function adoptSession(id: string, now?: number): void;
|
|
28
|
+
/** Make `id` the browser's anonymous id, replacing the one it held. */
|
|
29
|
+
export declare function adoptAnonId(id: string): void;
|
|
24
30
|
/** Read the persisted first-touch attribution. */
|
|
25
31
|
export declare function getFirstTouch(): Attribution | undefined;
|
|
26
32
|
/** Persist first-touch attribution ONCE — never overwrite an existing record. */
|
package/dist/storage.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAqBlD;;;;;;;;;;;GAWG;AACH,wBAAgB,MAAM,IAAI,MAAM,GAAG,SAAS,CAE3C;AAOD;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,GAAG,SAAa,GAAG,MAAM,GAAG,SAAS,CAgB9D;AAED,kDAAkD;AAClD,wBAAgB,aAAa,IAAI,WAAW,GAAG,SAAS,CASvD;AAED,iFAAiF;AACjF,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,WAAW,GAAG,WAAW,CAM7D;AAED,wCAAwC;AACxC,wBAAgB,SAAS,IAAI,MAAM,GAAG,SAAS,CAS9C;AAED,+DAA+D;AAC/D,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUjD"}
|
|
1
|
+
{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAqBlD;;;;;;;;;;;GAWG;AACH,wBAAgB,MAAM,IAAI,MAAM,GAAG,SAAS,CAE3C;AAOD;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,GAAG,SAAa,GAAG,MAAM,GAAG,SAAS,CAgB9D;AAED;;gEAEgE;AAChE,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,IAAI,CAG/D;AAED,uEAAuE;AACvE,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAE5C;AAED,kDAAkD;AAClD,wBAAgB,aAAa,IAAI,WAAW,GAAG,SAAS,CASvD;AAED,iFAAiF;AACjF,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,WAAW,GAAG,WAAW,CAM7D;AAED,wCAAwC;AACxC,wBAAgB,SAAS,IAAI,MAAM,GAAG,SAAS,CAS9C;AAED,+DAA+D;AAC/D,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUjD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/event",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.43",
|
|
4
4
|
"description": "Hanzo Event — the ONE telemetry client. Emits pageview/event/identify/group to the Hanzo Cloud event stream (POST /v1/event), AND reports errors to Sentry as real Sentry envelopes — the error plane needs a DSN, without one nothing reaches Sentry. First-touch attribution, beacon-on-unload, auto error capture, client-side secret/PII scrubbing, a shared event + goal vocabulary. Subsumes @sentry.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org/",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
25
25
|
"url": "git+https://github.com/hanzoai/ui.git",
|
|
26
|
-
"directory": "
|
|
26
|
+
"directory": "pkg/event"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"analytics",
|
package/src/anon.d.ts
CHANGED
|
@@ -12,3 +12,6 @@ export declare function hzUuidv7(now?: number): string
|
|
|
12
12
|
* every existing id is adopted, and only a browser holding none is given a new one.
|
|
13
13
|
*/
|
|
14
14
|
export declare function hzAnonId(): string
|
|
15
|
+
|
|
16
|
+
/** Makes `id` this browser's anonymous id, replacing the one it held. */
|
|
17
|
+
export declare function hzAnonAdopt(id: string): void
|
package/src/anon.js
CHANGED
|
@@ -214,6 +214,24 @@ function hzAnonId() {
|
|
|
214
214
|
return id
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* hzAnonAdopt makes `id` this browser's anonymous id, replacing whatever it held.
|
|
219
|
+
*
|
|
220
|
+
* A visitor who followed a link from another Hanzo host arrives carrying the id that
|
|
221
|
+
* host minted; keeping the local one splits one journey into two strangers. The
|
|
222
|
+
* caller has already checked the shape.
|
|
223
|
+
*/
|
|
224
|
+
function hzAnonAdopt(id) {
|
|
225
|
+
hzAnonMemo = id
|
|
226
|
+
hzAnonWrite(HZ_ANON_KEY, id)
|
|
227
|
+
try {
|
|
228
|
+
var s = hzAnonStore()
|
|
229
|
+
if (s) s.setItem(HZ_ANON_KEY, id)
|
|
230
|
+
} catch (e) {
|
|
231
|
+
/* quota exhausted or a read-only jar — the cookie and memo still carry it */
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
217
235
|
/* ── END hz anon chain ─────────────────────────────────────────────────────── */
|
|
218
236
|
|
|
219
|
-
export { hzAnonId, hzUuidv7 }
|
|
237
|
+
export { hzAnonId, hzAnonAdopt, hzUuidv7 }
|
package/src/core.test.ts
CHANGED
|
@@ -270,6 +270,25 @@ describe('Analytics capture', () => {
|
|
|
270
270
|
})
|
|
271
271
|
})
|
|
272
272
|
|
|
273
|
+
// A share link keeps its secret in the fragment, which a browser never sends
|
|
274
|
+
// to a server. Stamped from window.location.href, it reached /v1/event on
|
|
275
|
+
// every event of the page, and a 43-character base64url secret matches no
|
|
276
|
+
// other secret shape. The fragment is dropped from every stamped location.
|
|
277
|
+
it('never sends a fragment', () => {
|
|
278
|
+
const secret = 'Zq3LwX0p-Tf9_aB7kQmN2rS8vY1cD4eF6gH5jK0lMnO'
|
|
279
|
+
withLocation('https://hanzo.ai/chat/shared#' + secret, 'https://hanzo.ai/login#' + secret, () => {
|
|
280
|
+
const a = mk()
|
|
281
|
+
a.pageview()
|
|
282
|
+
a.capture('$click')
|
|
283
|
+
a.flush()
|
|
284
|
+
for (const e of tx.all) {
|
|
285
|
+
expect(JSON.stringify(e)).not.toContain(secret)
|
|
286
|
+
expect(e.url).toBe('https://hanzo.ai/chat/shared')
|
|
287
|
+
expect(e.referrer).toBe('https://hanzo.ai/login')
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
273
292
|
// A redactor that mangles ordinary URLs would destroy the analytics it
|
|
274
293
|
// exists to protect, so the common case must pass through byte-for-byte.
|
|
275
294
|
it('leaves an ordinary url untouched', () => {
|
package/src/core.ts
CHANGED
|
@@ -49,7 +49,7 @@ import { dsnForProduct } from './dsn'
|
|
|
49
49
|
import { keyForPage } from './org'
|
|
50
50
|
import { EXCEPTION, PAGEVIEW } from './events'
|
|
51
51
|
import { exceptionProperties } from './exception'
|
|
52
|
-
import { scrubText } from './scrub'
|
|
52
|
+
import { scrubText, withoutFragment } from './scrub'
|
|
53
53
|
import {
|
|
54
54
|
buildEnvelope,
|
|
55
55
|
buildSentryEvent,
|
|
@@ -58,6 +58,8 @@ import {
|
|
|
58
58
|
type ErrorIdentity,
|
|
59
59
|
} from './sentry'
|
|
60
60
|
import {
|
|
61
|
+
adoptAnonId,
|
|
62
|
+
adoptSession,
|
|
61
63
|
anonId,
|
|
62
64
|
sessionId,
|
|
63
65
|
getFirstTouch,
|
|
@@ -66,6 +68,7 @@ import {
|
|
|
66
68
|
mergeCohort,
|
|
67
69
|
} from './storage'
|
|
68
70
|
import { uuidv7 } from './uid'
|
|
71
|
+
import { linkUrl, readLink, stripLink } from './link'
|
|
69
72
|
import type {
|
|
70
73
|
AnalyticsConfig,
|
|
71
74
|
Attribution,
|
|
@@ -349,10 +352,26 @@ export class Analytics {
|
|
|
349
352
|
this.started = true
|
|
350
353
|
if (!isBrowser()) return
|
|
351
354
|
|
|
355
|
+
// A link from another Hanzo host carries the visitor with it. Adopt it FIRST, so
|
|
356
|
+
// the first pageview is already the same person, session and first touch.
|
|
357
|
+
const linked = readLink(window.location.search)
|
|
358
|
+
if (linked.anonId) adoptAnonId(linked.anonId)
|
|
359
|
+
if (linked.sessionId) adoptSession(linked.sessionId)
|
|
360
|
+
const clean = stripLink(window.location.href)
|
|
361
|
+
if (clean !== undefined) {
|
|
362
|
+
try {
|
|
363
|
+
window.history.replaceState(window.history.state, '', clean)
|
|
364
|
+
} catch {
|
|
365
|
+
/* a sandboxed frame may refuse; the params are then simply visible */
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
352
369
|
const parsed = parseAttribution(window.location.search, document.referrer)
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
370
|
+
// The first touch the link carries is the visit's real origin. Without it this
|
|
371
|
+
// host's own "first touch" would be the referring Hanzo page, which says nothing.
|
|
372
|
+
const first = linked.firstTouch && !getFirstTouch() ? setFirstTouchOnce(linked.firstTouch) : undefined
|
|
373
|
+
this.attribution =
|
|
374
|
+
first ?? (hasAttribution(parsed) ? setFirstTouchOnce(parsed) : getFirstTouch() ?? parsed)
|
|
356
375
|
this.cohort = mergeCohort({
|
|
357
376
|
channel: this.attribution.channel ?? deriveChannel(this.attribution),
|
|
358
377
|
refCode: this.attribution.refCode,
|
|
@@ -378,6 +397,29 @@ export class Analytics {
|
|
|
378
397
|
}
|
|
379
398
|
}
|
|
380
399
|
|
|
400
|
+
/** link returns `url` carrying this visitor's anonymous id, session and first
|
|
401
|
+
* touch when the destination is a Hanzo-owned host, so the journey continues
|
|
402
|
+
* across the hop. Any other URL comes back unchanged. */
|
|
403
|
+
link(url: string): string {
|
|
404
|
+
if (!isBrowser()) return url
|
|
405
|
+
if (!this.started) this.init()
|
|
406
|
+
return linkUrl(
|
|
407
|
+
url,
|
|
408
|
+
{ anonId: anonId(), sessionId: sessionId(), firstTouch: this.attribution },
|
|
409
|
+
window.location.hostname,
|
|
410
|
+
window.location.href,
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** authorize decorates an OAuth authorize URL for the redirect to IAM and flushes
|
|
415
|
+
* what is queued, because the page is about to unload. Assign the result to
|
|
416
|
+
* `location`. */
|
|
417
|
+
authorize(url: string): string {
|
|
418
|
+
const out = this.link(url)
|
|
419
|
+
this.flush(true)
|
|
420
|
+
return out
|
|
421
|
+
}
|
|
422
|
+
|
|
381
423
|
/** identify binds the current visitor to a stable person id (post-login). */
|
|
382
424
|
identify(personId: string, traits?: Record<string, unknown>): void {
|
|
383
425
|
this.personId = personId
|
|
@@ -645,10 +687,13 @@ export class Analytics {
|
|
|
645
687
|
// definition of "must not leave the browser", already tested, mirroring the
|
|
646
688
|
// server's. Guarded on presence so an absent field stays absent instead of
|
|
647
689
|
// becoming the empty string that `host` derivation reads as a page.
|
|
690
|
+
//
|
|
691
|
+
// The fragment goes first and whole: nothing after `#` is ever the server's
|
|
692
|
+
// (see withoutFragment), so it is dropped rather than scrubbed.
|
|
648
693
|
const capturePII = this.cfg.capturePII ?? false
|
|
649
|
-
if (wire.url) wire.url = scrubText(wire.url, capturePII)
|
|
650
|
-
if (wire.path) wire.path = scrubText(wire.path, capturePII)
|
|
651
|
-
if (wire.referrer) wire.referrer = scrubText(wire.referrer, capturePII)
|
|
694
|
+
if (wire.url) wire.url = scrubText(withoutFragment(wire.url), capturePII)
|
|
695
|
+
if (wire.path) wire.path = scrubText(withoutFragment(wire.path), capturePII)
|
|
696
|
+
if (wire.referrer) wire.referrer = scrubText(withoutFragment(wire.referrer), capturePII)
|
|
652
697
|
return wire
|
|
653
698
|
}
|
|
654
699
|
|
package/src/events.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* and there is still exactly one definition.
|
|
10
10
|
*
|
|
11
11
|
* Nothing about the API moved. Every existing import keeps working, the names
|
|
12
|
-
* and their values are unchanged, and `
|
|
12
|
+
* and their values are unchanged, and `pkg/events/test/catalog.mjs` fails the
|
|
13
13
|
* build if a name and its meaning ever disagree.
|
|
14
14
|
*
|
|
15
15
|
* The naming convention lives with the names, in @hanzo/events and TAXONOMY.md:
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,8 @@ export {
|
|
|
17
17
|
} from './core'
|
|
18
18
|
export { parseDsn, buildSentryEvent, buildEnvelope, framesFromStack } from './sentry'
|
|
19
19
|
export { uuidv7, uuidv7Time } from './uid'
|
|
20
|
+
export { linkUrl, readLink, stripLink, LINK_PARAMS } from './link'
|
|
21
|
+
export type { LinkState } from './link'
|
|
20
22
|
export { PRODUCT_PROJECT, dsnForProduct } from './dsn'
|
|
21
23
|
export type { ErrorIdentity } from './sentry'
|
|
22
24
|
export { scrubText, redactSecrets, scrubPII } from './scrub'
|
package/src/link.test.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Cross-domain continuity: a link between Hanzo hosts carries the visitor, the
|
|
2
|
+
// destination adopts it before its first pageview and cleans the address bar.
|
|
3
|
+
|
|
4
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
5
|
+
import { linkUrl, readLink, stripLink, encodeFirstTouch } from './link'
|
|
6
|
+
import { keyFor, keyForPage } from './org'
|
|
7
|
+
import type { Attribution, Transport, WireEvent } from './types'
|
|
8
|
+
|
|
9
|
+
const AID = '01920000-0000-7000-8000-0000000000aa'
|
|
10
|
+
const SID = '01920000-0000-7000-8000-0000000000bb'
|
|
11
|
+
const LOCAL = '01920000-0000-7000-8000-0000000000cc'
|
|
12
|
+
const FT: Attribution = {
|
|
13
|
+
utm: { source: 'x', medium: 'cpc', campaign: 'launch' },
|
|
14
|
+
refCode: 'r1',
|
|
15
|
+
referrer: 'https://t.co/abc',
|
|
16
|
+
channel: 'paid',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const g = globalThis as Record<string, unknown>
|
|
20
|
+
|
|
21
|
+
function browser(href: string, storage: Record<string, string> = {}) {
|
|
22
|
+
const url = new URL(href)
|
|
23
|
+
const store = new Map(Object.entries(storage))
|
|
24
|
+
const jar = new Map<string, string>()
|
|
25
|
+
const replaced: string[] = []
|
|
26
|
+
const location = {
|
|
27
|
+
href: url.href,
|
|
28
|
+
hostname: url.hostname,
|
|
29
|
+
protocol: url.protocol,
|
|
30
|
+
pathname: url.pathname,
|
|
31
|
+
search: url.search,
|
|
32
|
+
}
|
|
33
|
+
g.window = {
|
|
34
|
+
location,
|
|
35
|
+
history: {
|
|
36
|
+
state: null,
|
|
37
|
+
replaceState: (_s: unknown, _t: string, next: string) => {
|
|
38
|
+
replaced.push(next)
|
|
39
|
+
const u = new URL(next, url)
|
|
40
|
+
location.href = u.href
|
|
41
|
+
location.search = u.search
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
localStorage: {
|
|
45
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
46
|
+
setItem: (k: string, v: string) => void store.set(k, v),
|
|
47
|
+
},
|
|
48
|
+
addEventListener: () => {},
|
|
49
|
+
}
|
|
50
|
+
g.document = {
|
|
51
|
+
get cookie() {
|
|
52
|
+
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ')
|
|
53
|
+
},
|
|
54
|
+
set cookie(raw: string) {
|
|
55
|
+
const kv = raw.split(';')[0]
|
|
56
|
+
const eq = kv.indexOf('=')
|
|
57
|
+
jar.set(kv.slice(0, eq).trim(), kv.slice(eq + 1))
|
|
58
|
+
},
|
|
59
|
+
referrer: 'https://hanzo.ai/',
|
|
60
|
+
visibilityState: 'visible',
|
|
61
|
+
}
|
|
62
|
+
return { store, jar, replaced, location }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class Tx implements Transport {
|
|
66
|
+
all: WireEvent[] = []
|
|
67
|
+
send(_u: string, body: string) {
|
|
68
|
+
try {
|
|
69
|
+
this.all.push(...(JSON.parse(body) as { batch: WireEvent[] }).batch)
|
|
70
|
+
} catch {
|
|
71
|
+
/* not JSON */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const load = async () => {
|
|
77
|
+
vi.resetModules()
|
|
78
|
+
return await import('./core')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
delete g.window
|
|
83
|
+
delete g.document
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('linkUrl', () => {
|
|
87
|
+
const state = { anonId: AID, sessionId: SID, firstTouch: FT }
|
|
88
|
+
|
|
89
|
+
it('appends id, session and first touch for Hanzo-owned hosts', () => {
|
|
90
|
+
for (const host of ['hanzo.id', 'hanzo.app', 'hanzo.team', 'hanzo.bot', 'id.lux.network', 'zoo.ngo']) {
|
|
91
|
+
const u = new URL(linkUrl(`https://${host}/login`, state, 'hanzo.ai'))
|
|
92
|
+
expect(u.searchParams.get('hz_aid')).toBe(AID)
|
|
93
|
+
expect(u.searchParams.get('hz_sid')).toBe(SID)
|
|
94
|
+
expect(readLink(u.search).firstTouch).toEqual(FT)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('leaves foreign hosts and same-host links alone', () => {
|
|
99
|
+
expect(linkUrl('https://example.com/x', state, 'hanzo.ai')).toBe('https://example.com/x')
|
|
100
|
+
expect(linkUrl('https://hanzo.ai/x', state, 'hanzo.ai')).toBe('https://hanzo.ai/x')
|
|
101
|
+
expect(linkUrl('mailto:z@hanzo.ai', state, 'hanzo.ai')).toBe('mailto:z@hanzo.ai')
|
|
102
|
+
expect(linkUrl('https://evilhanzo.id/x', state, 'hanzo.ai')).toBe('https://evilhanzo.id/x')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('keeps existing query and hash, and refuses a non-UUID id', () => {
|
|
106
|
+
const out = linkUrl('https://hanzo.id/login/oauth/authorize?client_id=a&x=1#h', { anonId: 'nope' }, 'hanzo.ai')
|
|
107
|
+
expect(out).toBe('https://hanzo.id/login/oauth/authorize?client_id=a&x=1#h')
|
|
108
|
+
const ok = new URL(linkUrl('https://hanzo.id/a?client_id=a#h', state, 'hanzo.ai'))
|
|
109
|
+
expect(ok.searchParams.get('client_id')).toBe('a')
|
|
110
|
+
expect(ok.hash).toBe('#h')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('round-trips first touch and rejects garbage', () => {
|
|
114
|
+
expect(readLink('?hz_ft=' + encodeURIComponent(encodeFirstTouch(FT))).firstTouch).toEqual(FT)
|
|
115
|
+
expect(readLink('?hz_ft=%5B1%5D&hz_aid=zzz').firstTouch).toBeUndefined()
|
|
116
|
+
expect(readLink('?hz_aid=zzz').anonId).toBeUndefined()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('stripLink removes only its own params', () => {
|
|
120
|
+
expect(stripLink(`https://hanzo.id/a?hz_aid=${AID}&keep=1&hz_sid=${SID}#f`)).toBe('/a?keep=1#f')
|
|
121
|
+
expect(stripLink('https://hanzo.id/a?keep=1')).toBeUndefined()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('Analytics adopts a link on init', () => {
|
|
126
|
+
const href = `https://hanzo.id/login?client_id=c&hz_aid=${AID}&hz_sid=${SID}&hz_ft=${encodeURIComponent(encodeFirstTouch(FT))}`
|
|
127
|
+
|
|
128
|
+
it('first pageview carries the linked ids, first touch and a clean address bar', async () => {
|
|
129
|
+
const b = browser(href, { 'iam-anon-id': LOCAL })
|
|
130
|
+
const { Analytics } = await load()
|
|
131
|
+
const tx = new Tx()
|
|
132
|
+
const a = new Analytics({ product: 't', host: '', transport: tx })
|
|
133
|
+
a.pageview()
|
|
134
|
+
a.flush()
|
|
135
|
+
const e = tx.all[0]
|
|
136
|
+
expect(e.anonymousId).toBe(AID)
|
|
137
|
+
expect(e.sessionId).toBe(SID)
|
|
138
|
+
expect(e.channel).toBe('paid')
|
|
139
|
+
expect(e.utm?.campaign).toBe('launch')
|
|
140
|
+
expect(e.url).not.toContain('hz_aid')
|
|
141
|
+
expect(b.replaced).toEqual(['/login?client_id=c'])
|
|
142
|
+
expect(b.location.search).toBe('?client_id=c')
|
|
143
|
+
expect(b.jar.get('iam-anon-id')).toBe(AID)
|
|
144
|
+
expect(b.store.get('iam-anon-id')).toBe(AID)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('an existing first touch is not overwritten by a link', async () => {
|
|
148
|
+
browser(href, { hz_first_touch: JSON.stringify({ utm: { source: 'old' }, channel: 'organic' }) })
|
|
149
|
+
const { Analytics } = await load()
|
|
150
|
+
const tx = new Tx()
|
|
151
|
+
const a = new Analytics({ product: 't', host: '', transport: tx })
|
|
152
|
+
a.pageview()
|
|
153
|
+
a.flush()
|
|
154
|
+
expect(tx.all[0].utm?.source).toBe('old')
|
|
155
|
+
expect(tx.all[0].anonymousId).toBe(AID)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('a link with a bad id changes nothing but is still stripped', async () => {
|
|
159
|
+
const b = browser('https://hanzo.id/x?hz_aid=evil', { 'iam-anon-id': LOCAL })
|
|
160
|
+
const { Analytics } = await load()
|
|
161
|
+
const tx = new Tx()
|
|
162
|
+
new Analytics({ product: 't', host: '', transport: tx }).pageview()
|
|
163
|
+
expect(b.replaced).toEqual(['/x'])
|
|
164
|
+
expect(b.store.get('iam-anon-id')).toBe(LOCAL)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('Analytics.link / authorize / identify', () => {
|
|
169
|
+
it('link decorates an IAM authorize URL with the current visitor', async () => {
|
|
170
|
+
browser('https://hanzo.ai/chat?utm_source=news', { 'iam-anon-id': LOCAL })
|
|
171
|
+
const { Analytics } = await load()
|
|
172
|
+
const a = new Analytics({ product: 't', host: '', transport: new Tx() })
|
|
173
|
+
const u = new URL(a.link('https://hanzo.id/login/oauth/authorize?client_id=c&state=s'))
|
|
174
|
+
expect(u.searchParams.get('client_id')).toBe('c')
|
|
175
|
+
expect(u.searchParams.get('hz_aid')).toBe(LOCAL)
|
|
176
|
+
expect(u.searchParams.get('hz_sid')).toMatch(/^[0-9a-f-]{36}$/)
|
|
177
|
+
expect(readLink(u.search).firstTouch?.utm.source).toBe('news')
|
|
178
|
+
expect(a.link('https://example.com/x')).toBe('https://example.com/x')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('authorize decorates and flushes what is queued before unload', async () => {
|
|
182
|
+
browser('https://hanzo.ai/chat', { 'iam-anon-id': LOCAL })
|
|
183
|
+
const { Analytics } = await load()
|
|
184
|
+
const tx = new Tx()
|
|
185
|
+
const a = new Analytics({ product: 't', host: '', transport: tx, flushIntervalMs: 999999 })
|
|
186
|
+
a.capture('chat_started')
|
|
187
|
+
const out = a.authorize('https://hanzo.id/login/oauth/authorize?client_id=c')
|
|
188
|
+
expect(out).toContain('hz_aid=' + LOCAL)
|
|
189
|
+
expect(tx.all.map((e) => e.event)).toEqual(['chat_started'])
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('identify emits the anonymous id and the person id together', async () => {
|
|
193
|
+
browser('https://hanzo.ai/', { 'iam-anon-id': LOCAL })
|
|
194
|
+
const { Analytics } = await load()
|
|
195
|
+
const tx = new Tx()
|
|
196
|
+
const a = new Analytics({ product: 't', host: '', transport: tx })
|
|
197
|
+
a.identify('person-1')
|
|
198
|
+
a.flush()
|
|
199
|
+
const e = tx.all[0]
|
|
200
|
+
expect(e.type).toBe('identify')
|
|
201
|
+
expect(e.anonymousId).toBe(LOCAL)
|
|
202
|
+
expect(e.personId).toBe('person-1')
|
|
203
|
+
expect(e.distinctId).toBe('person-1')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('one ingest key across the journey', () => {
|
|
208
|
+
it('hanzo.ai, hanzo.id, hanzo.app resolve to the same org key', () => {
|
|
209
|
+
const k = keyFor('hanzo.ai')
|
|
210
|
+
expect(k).toBeTruthy()
|
|
211
|
+
for (const h of ['www.hanzo.ai', 'hanzo.id', 'hanzo.app', 'hanzo.team', 'hanzo.bot']) expect(keyFor(h)).toBe(k)
|
|
212
|
+
const ring = { hanzo: 'pk-runtime' }
|
|
213
|
+
expect(keyFor('hanzo.id', ring)).toBe(keyFor('hanzo.ai', ring))
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('keyForPage on either host names the same project', () => {
|
|
217
|
+
browser('https://hanzo.id/login')
|
|
218
|
+
const id = keyForPage()
|
|
219
|
+
browser('https://hanzo.ai/')
|
|
220
|
+
expect(keyForPage()).toBe(id)
|
|
221
|
+
})
|
|
222
|
+
})
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Cross-domain visitor continuity.
|
|
2
|
+
//
|
|
3
|
+
// Cookies and localStorage stop at the registrable domain, so a visitor who goes
|
|
4
|
+
// hanzo.ai -> hanzo.id (sign-in) -> back arrived as separate people on each host, and
|
|
5
|
+
// the journey across the sign-in redirect could not be read. A link between two
|
|
6
|
+
// Hanzo hosts therefore carries the visitor's identity in three short query
|
|
7
|
+
// parameters, and the destination adopts them before its first pageview and
|
|
8
|
+
// removes them from the address bar.
|
|
9
|
+
//
|
|
10
|
+
// hz_aid anonymous id hz_sid session id hz_ft first-touch attribution
|
|
11
|
+
//
|
|
12
|
+
// They are appended ONLY when the destination host belongs to a brand in ORG_DOMAIN:
|
|
13
|
+
// an id handed to a host outside the table would be handed to a stranger.
|
|
14
|
+
|
|
15
|
+
import { orgOf } from './org'
|
|
16
|
+
import type { Attribution } from './types'
|
|
17
|
+
|
|
18
|
+
export const LINK_PARAMS = { anon: 'hz_aid', session: 'hz_sid', firstTouch: 'hz_ft' } as const
|
|
19
|
+
|
|
20
|
+
/** Ids are v7 UUIDs; anything else in a URL is someone else's input and is refused. */
|
|
21
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
22
|
+
|
|
23
|
+
export const isId = (v: string | null | undefined): v is string => !!v && UUID.test(v)
|
|
24
|
+
|
|
25
|
+
/** What a link carries. Any field may be absent. */
|
|
26
|
+
export interface LinkState {
|
|
27
|
+
anonId?: string
|
|
28
|
+
sessionId?: string
|
|
29
|
+
firstTouch?: Attribution
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** First touch as a compact tuple: utm source, medium, campaign, term, content, refCode,
|
|
33
|
+
* referrer, channel. Short because it rides a URL; a tuple because keys cost bytes. */
|
|
34
|
+
export function encodeFirstTouch(a: Attribution): string {
|
|
35
|
+
return JSON.stringify([
|
|
36
|
+
a.utm.source, a.utm.medium, a.utm.campaign, a.utm.term, a.utm.content,
|
|
37
|
+
a.refCode, a.referrer, a.channel,
|
|
38
|
+
].map((v) => v ?? ''))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function decodeFirstTouch(raw: string | null | undefined): Attribution | undefined {
|
|
42
|
+
if (!raw || raw.length > 1024) return undefined
|
|
43
|
+
try {
|
|
44
|
+
const t: unknown = JSON.parse(raw)
|
|
45
|
+
if (!Array.isArray(t) || t.length !== 8 || t.some((v) => typeof v !== 'string')) return undefined
|
|
46
|
+
const [source, medium, campaign, term, content, refCode, referrer, channel] = t as string[]
|
|
47
|
+
const u = (v: string) => v || undefined
|
|
48
|
+
return {
|
|
49
|
+
utm: { source: u(source), medium: u(medium), campaign: u(campaign), term: u(term), content: u(content) },
|
|
50
|
+
refCode: u(refCode),
|
|
51
|
+
referrer: u(referrer),
|
|
52
|
+
channel: u(channel),
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** linkUrl returns `url` with the visitor's identity appended when its host is
|
|
60
|
+
* Hanzo-owned and is not `fromHost`; otherwise `url` unchanged. Relative URLs
|
|
61
|
+
* resolve against `base`. Never throws. */
|
|
62
|
+
export function linkUrl(url: string, state: LinkState, fromHost: string, base?: string): string {
|
|
63
|
+
let u: URL
|
|
64
|
+
try {
|
|
65
|
+
u = new URL(url, base)
|
|
66
|
+
} catch {
|
|
67
|
+
return url
|
|
68
|
+
}
|
|
69
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') return url
|
|
70
|
+
if (!orgOf(u.hostname) || u.hostname === fromHost) return url
|
|
71
|
+
const set = (k: string, v: string | undefined) => v && u.searchParams.set(k, v)
|
|
72
|
+
if (isId(state.anonId)) set(LINK_PARAMS.anon, state.anonId)
|
|
73
|
+
if (isId(state.sessionId)) set(LINK_PARAMS.session, state.sessionId)
|
|
74
|
+
if (state.firstTouch) set(LINK_PARAMS.firstTouch, encodeFirstTouch(state.firstTouch))
|
|
75
|
+
return u.toString()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** readLink parses what a link carried out of a location.search value. Invalid
|
|
79
|
+
* values are dropped, not repaired. */
|
|
80
|
+
export function readLink(search: string): LinkState {
|
|
81
|
+
const q = new URLSearchParams(search || '')
|
|
82
|
+
const a = q.get(LINK_PARAMS.anon)
|
|
83
|
+
const s = q.get(LINK_PARAMS.session)
|
|
84
|
+
return {
|
|
85
|
+
anonId: isId(a) ? a : undefined,
|
|
86
|
+
sessionId: isId(s) ? s : undefined,
|
|
87
|
+
firstTouch: decodeFirstTouch(q.get(LINK_PARAMS.firstTouch)),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** stripLink returns the path, query and hash of `href` without the link parameters, or
|
|
92
|
+
* undefined when there were none. */
|
|
93
|
+
export function stripLink(href: string): string | undefined {
|
|
94
|
+
const u = new URL(href)
|
|
95
|
+
let hit = false
|
|
96
|
+
for (const k of Object.values(LINK_PARAMS)) {
|
|
97
|
+
if (u.searchParams.has(k)) {
|
|
98
|
+
u.searchParams.delete(k)
|
|
99
|
+
hit = true
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return hit ? u.pathname + u.search + u.hash : undefined
|
|
103
|
+
}
|
package/src/scrub.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { redactSecrets, scrubPII, scrubText, MAX_SCRUB_LEN } from './scrub'
|
|
2
|
+
import { redactSecrets, scrubPII, scrubText, withoutFragment, MAX_SCRUB_LEN } from './scrub'
|
|
3
3
|
|
|
4
4
|
describe('redactSecrets (always applied)', () => {
|
|
5
5
|
it('redacts a hanzo key', () => {
|
|
@@ -121,3 +121,36 @@ describe('credential params in a URL', () => {
|
|
|
121
121
|
expect(out).toContain('page=2')
|
|
122
122
|
})
|
|
123
123
|
})
|
|
124
|
+
|
|
125
|
+
describe('a 256-bit base64url secret', () => {
|
|
126
|
+
const secret = 'Zq3LwX0p-Tf9_aB7kQmN2rS8vY1cD4eF6gH5jK0lMnO'
|
|
127
|
+
it('is redacted wherever it stands alone', () => {
|
|
128
|
+
expect(secret).toHaveLength(43)
|
|
129
|
+
for (const s of [secret, 'token ' + secret + ' expired', '/chat/shared/' + secret, '{"token":"' + secret + '"}']) {
|
|
130
|
+
expect(redactSecrets(s)).not.toContain(secret)
|
|
131
|
+
expect(redactSecrets(s)).toContain('[redacted]')
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
it('is redacted with no digit in it, and behind an encoded fragment mark', () => {
|
|
135
|
+
const letters = 'ZqLwXp-Tf_aBkQmNrSvYcDeFgHjKlMnOpQrStUvWxYz'
|
|
136
|
+
expect(letters).toHaveLength(43)
|
|
137
|
+
expect(redactSecrets('token=' + letters)).not.toContain(letters)
|
|
138
|
+
const returnUrl = 'https://hanzo.ai/login?next=%2Fchat%2Fshared%23' + secret
|
|
139
|
+
expect(redactSecrets(returnUrl)).not.toContain(secret)
|
|
140
|
+
expect(redactSecrets(returnUrl)).toContain('%23[redacted]')
|
|
141
|
+
})
|
|
142
|
+
it('leaves a longer or shorter run, and a word-only run, alone', () => {
|
|
143
|
+
expect(redactSecrets(secret + 'x')).toBe(secret + 'x')
|
|
144
|
+
expect(redactSecrets(secret.slice(1))).toBe(secret.slice(1))
|
|
145
|
+
const slug = 'the-quick-brown-fox-jumps-over-the-lazy-dogs'.slice(0, 43)
|
|
146
|
+
expect(slug).toHaveLength(43)
|
|
147
|
+
expect(redactSecrets(slug)).toBe(slug)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('withoutFragment', () => {
|
|
152
|
+
it('drops everything from the first #', () => {
|
|
153
|
+
expect(withoutFragment('https://hanzo.ai/chat/shared#abc#def')).toBe('https://hanzo.ai/chat/shared')
|
|
154
|
+
expect(withoutFragment('https://hanzo.ai/pricing?plan=pro')).toBe('https://hanzo.ai/pricing?plan=pro')
|
|
155
|
+
})
|
|
156
|
+
})
|
package/src/scrub.ts
CHANGED
|
@@ -114,13 +114,33 @@ export function redactCredentialParams(s: string): string {
|
|
|
114
114
|
return s.replace(RE_CREDENTIAL_PARAM, (_m, prefix: string) => prefix + REDACTED)
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// A 256-bit secret written as unpadded base64url is exactly 43 characters of
|
|
118
|
+
// [A-Za-z0-9_-] — the shape of a share-link secret, and of any other opaque key
|
|
119
|
+
// minted the same way. The run must stand alone — nothing of the same alphabet
|
|
120
|
+
// on either side, a percent-escape such as the `%23` of an encoded `#` counting
|
|
121
|
+
// as a boundary — so it never fires inside a longer token, and it must mix upper
|
|
122
|
+
// and lower case, which a random 32 bytes does with near certainty and an
|
|
123
|
+
// ordinary word or slug does not. No lookbehind, so it runs on every engine the
|
|
124
|
+
// client ships to.
|
|
125
|
+
const RE_SECRET43 = /(^|[^A-Za-z0-9_-]|%[0-9A-Fa-f]{2})((?=[A-Za-z0-9_-]{0,42}[a-z])(?=[A-Za-z0-9_-]{0,42}[A-Z])[A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/g
|
|
126
|
+
|
|
117
127
|
/** redactSecrets removes known secret shapes. Always applied. */
|
|
118
128
|
export function redactSecrets(s: string): string {
|
|
119
129
|
s = redactCredentialParams(s)
|
|
120
130
|
for (const re of SECRET_PATTERNS) s = s.replace(re, REDACTED)
|
|
131
|
+
s = s.replace(RE_SECRET43, (_m, lead: string) => lead + REDACTED)
|
|
121
132
|
return redactPAN(s)
|
|
122
133
|
}
|
|
123
134
|
|
|
135
|
+
/** withoutFragment returns a URL with its fragment removed. A browser never
|
|
136
|
+
* sends the fragment to any server — it is where an app keeps what must stay
|
|
137
|
+
* on the device, a share link's secret among them — so the client does not
|
|
138
|
+
* send it either. */
|
|
139
|
+
export function withoutFragment(url: string): string {
|
|
140
|
+
const at = url.indexOf('#')
|
|
141
|
+
return at < 0 ? url : url.slice(0, at)
|
|
142
|
+
}
|
|
143
|
+
|
|
124
144
|
/** scrubPII masks emails and IPs. Applied unless PII capture is enabled. */
|
|
125
145
|
export function scrubPII(s: string): string {
|
|
126
146
|
s = s.replace(RE_EMAIL, EMAIL_MARK)
|