@hanzo/event 0.3.11 → 0.3.13
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/README.md +15 -0
- package/dist/index.cjs +43 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +43 -3
- package/dist/index.mjs.map +1 -1
- package/dist/react.cjs +43 -3
- package/dist/react.cjs.map +1 -1
- package/dist/react.mjs +43 -3
- package/dist/react.mjs.map +1 -1
- package/hz.js +45 -4
- package/package.json +1 -1
- package/src/core.test.ts +29 -0
- package/src/core.ts +14 -3
- package/src/hz.test.ts +166 -36
- package/src/scrub.test.ts +27 -0
- package/src/scrub.ts +36 -0
- package/src/version.ts +1 -1
package/src/core.ts
CHANGED
|
@@ -415,9 +415,20 @@ export class Analytics {
|
|
|
415
415
|
this.queue = []
|
|
416
416
|
this.clearTimer()
|
|
417
417
|
|
|
418
|
-
|
|
419
|
-
//
|
|
420
|
-
|
|
418
|
+
// A publishable key and a bearer JWT are mutually exclusive doors, and the
|
|
419
|
+
// BEARER WINS. It names a real principal and resolves to THAT person's org;
|
|
420
|
+
// a pk- names one org for everybody holding it. So the key is what attributes
|
|
421
|
+
// a visitor nobody has vouched for, and it must never displace someone who
|
|
422
|
+
// has been.
|
|
423
|
+
//
|
|
424
|
+
// The precedence used to run the other way, which was survivable only while
|
|
425
|
+
// the key had to be passed in code. Once it also resolves from the build env,
|
|
426
|
+
// key-wins means setting one variable silently blanks every signed-in user's
|
|
427
|
+
// token and re-files their events under whichever org minted the key — on a
|
|
428
|
+
// console served to several brands from one bundle, that is a cross-tenant
|
|
429
|
+
// leak introduced by an env var.
|
|
430
|
+
const token = this.cfg.getToken?.() ?? undefined
|
|
431
|
+
const key = token ? undefined : this.cfg.ingestKey?.trim() || undefined
|
|
421
432
|
// Only a headerful bearer JWT blocks the beacon: sendBeacon cannot set an
|
|
422
433
|
// Authorization header. A pk_ rides ?ingest_key; a cookie rides credentials.
|
|
423
434
|
const useBeacon = beacon && !token
|
package/src/hz.test.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// hz.js is the no-build distribution — 300 lines of shipped client that no test
|
|
2
2
|
// had ever executed. It restates, by hand, what the bundled client imports, so the
|
|
3
3
|
// two can drift; this runs the real file against a minimal browser stub and reads
|
|
4
|
-
//
|
|
4
|
+
// what it actually posts — batch, URL and headers, because the credential is not
|
|
5
|
+
// in the body and a test that reads only the batch cannot see it.
|
|
5
6
|
|
|
6
7
|
import { describe, expect, it, beforeEach } from 'vitest'
|
|
7
8
|
import { readFileSync } from 'node:fs'
|
|
8
9
|
import { fileURLToPath } from 'node:url'
|
|
9
10
|
|
|
10
11
|
const SRC = readFileSync(fileURLToPath(new URL('../hz.js', import.meta.url)), 'utf8')
|
|
12
|
+
const PKG = JSON.parse(
|
|
13
|
+
readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'),
|
|
14
|
+
) as { version: string }
|
|
11
15
|
|
|
12
16
|
/** The event plane's session-rollup admission gate, transcribed from its own SQL. */
|
|
13
17
|
const versionNibble = (id: string): bigint => (BigInt('0x' + id.replace(/-/g, '')) >> 76n) & 15n
|
|
@@ -23,39 +27,107 @@ interface WireEvent {
|
|
|
23
27
|
libraryVersion: string
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
/** One recorded transmission: which transport carried it, where, under what headers. */
|
|
31
|
+
interface Post {
|
|
32
|
+
via: 'beacon' | 'fetch'
|
|
33
|
+
url: string
|
|
34
|
+
headers: Record<string, string>
|
|
35
|
+
batch: WireEvent[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface StubOptions {
|
|
39
|
+
/** data-* attributes on the <script> tag. */
|
|
40
|
+
attrs?: Record<string, string>
|
|
41
|
+
/** navigator fields — doNotTrack, globalPrivacyControl, msDoNotTrack. */
|
|
42
|
+
navigator?: Record<string, unknown>
|
|
43
|
+
/** Seed localStorage (e.g. an explicit hz_consent choice). */
|
|
44
|
+
storage?: Record<string, string>
|
|
45
|
+
/** Let navigator.sendBeacon succeed, so the beacon path is the one measured. */
|
|
46
|
+
beacon?: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Api = { track(n: string, p?: unknown): void; flush(): void }
|
|
50
|
+
|
|
51
|
+
/** Runs hz.js against a stub browser and returns everything it posted.
|
|
52
|
+
*
|
|
53
|
+
* Globals are DEFINED, not assigned: Node ≥ 21 ships a real `navigator` whose
|
|
54
|
+
* descriptor is an accessor with no setter, so the plain assignment this
|
|
55
|
+
* harness used threw — and every hz.js test failed on a current runtime,
|
|
56
|
+
* leaving the shipped file with no executed coverage again. */
|
|
57
|
+
function runSnippet(opts: StubOptions = {}): { posts: Post[]; api: Api | undefined } {
|
|
58
|
+
const posts: Post[] = []
|
|
59
|
+
const store = (seed: Record<string, string> = {}) => {
|
|
60
|
+
const m = new Map<string, string>(Object.entries(seed))
|
|
61
|
+
return {
|
|
62
|
+
getItem: (k: string) => m.get(k) ?? null,
|
|
63
|
+
setItem: (k: string, v: string) => void m.set(k, v),
|
|
64
|
+
removeItem: (k: string) => void m.delete(k),
|
|
65
|
+
}
|
|
32
66
|
}
|
|
33
|
-
const
|
|
34
|
-
g
|
|
35
|
-
|
|
36
|
-
|
|
67
|
+
const attrs: Record<string, string> = { 'data-product': 'test', ...opts.attrs }
|
|
68
|
+
const g = globalThis as unknown as Record<string, unknown>
|
|
69
|
+
const define = (name: string, value: unknown) =>
|
|
70
|
+
Object.defineProperty(g, name, { value, configurable: true, writable: true })
|
|
71
|
+
|
|
72
|
+
define('location', {
|
|
73
|
+
href: 'https://x.test/p?a=1',
|
|
74
|
+
pathname: '/p',
|
|
75
|
+
search: '?a=1',
|
|
76
|
+
hostname: 'x.test',
|
|
77
|
+
host: 'x.test',
|
|
78
|
+
})
|
|
79
|
+
define('document', {
|
|
80
|
+
currentScript: { getAttribute: (a: string) => attrs[a] ?? null },
|
|
37
81
|
referrer: '',
|
|
38
82
|
addEventListener: () => {},
|
|
39
83
|
documentElement: { scrollHeight: 1000 },
|
|
40
84
|
visibilityState: 'visible',
|
|
41
85
|
createElement: () => ({}),
|
|
42
86
|
head: { appendChild: () => {} },
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
87
|
+
})
|
|
88
|
+
define('navigator', {
|
|
89
|
+
doNotTrack: '0',
|
|
90
|
+
...opts.navigator,
|
|
91
|
+
sendBeacon: opts.beacon
|
|
92
|
+
? (url: string, blob: { body: string }) => {
|
|
93
|
+
posts.push({ via: 'beacon', url, headers: {}, batch: JSON.parse(blob.body).batch })
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
: undefined,
|
|
97
|
+
})
|
|
98
|
+
define(
|
|
99
|
+
'Blob',
|
|
100
|
+
class {
|
|
101
|
+
body: string
|
|
102
|
+
constructor(parts: string[]) {
|
|
103
|
+
this.body = parts.join('')
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
define('localStorage', store(opts.storage))
|
|
108
|
+
define('sessionStorage', store())
|
|
109
|
+
define('history', { pushState: () => {}, replaceState: () => {} })
|
|
110
|
+
define('addEventListener', () => {})
|
|
111
|
+
define('PerformanceObserver', undefined)
|
|
112
|
+
define('hzDNT', undefined)
|
|
113
|
+
define('doNotTrack', undefined)
|
|
114
|
+
// The stub `window` IS globalThis, so a public API installed by an earlier run
|
|
115
|
+
// would still be there — and "it refused" would be indistinguishable from
|
|
116
|
+
// "the previous run's API answered".
|
|
117
|
+
define('hanzo', undefined)
|
|
118
|
+
define('fetch', (url: string, init: { body: string; headers: Record<string, string> }) => {
|
|
119
|
+
posts.push({ via: 'fetch', url, headers: init.headers, batch: JSON.parse(init.body).batch })
|
|
52
120
|
return Promise.resolve()
|
|
53
|
-
}
|
|
54
|
-
|
|
121
|
+
})
|
|
122
|
+
define('window', g)
|
|
123
|
+
|
|
55
124
|
new Function(SRC)()
|
|
56
|
-
return {
|
|
125
|
+
return { posts, api: (g.window as { hanzo?: Api }).hanzo }
|
|
57
126
|
}
|
|
58
127
|
|
|
128
|
+
/** Every event across every transmission, in order. */
|
|
129
|
+
const sentOf = (r: { posts: Post[] }): WireEvent[] => r.posts.flatMap((p) => p.batch)
|
|
130
|
+
|
|
59
131
|
describe('hz.js', () => {
|
|
60
132
|
let run: ReturnType<typeof runSnippet>
|
|
61
133
|
beforeEach(() => {
|
|
@@ -63,11 +135,12 @@ describe('hz.js', () => {
|
|
|
63
135
|
})
|
|
64
136
|
|
|
65
137
|
it('mints session ids the plane admits', () => {
|
|
66
|
-
run.api
|
|
67
|
-
run.api
|
|
68
|
-
|
|
138
|
+
run.api!.track('checkout_started')
|
|
139
|
+
run.api!.flush()
|
|
140
|
+
const sent = sentOf(run)
|
|
141
|
+
expect(sent.length).toBeGreaterThan(0)
|
|
69
142
|
const before = Date.now()
|
|
70
|
-
for (const ev of
|
|
143
|
+
for (const ev of sent) {
|
|
71
144
|
expect(versionNibble(ev.sessionId)).toBe(7n)
|
|
72
145
|
expect(versionNibble(ev.messageId)).toBe(7n)
|
|
73
146
|
expect(versionNibble(ev.anonymousId)).toBe(7n)
|
|
@@ -78,19 +151,76 @@ describe('hz.js', () => {
|
|
|
78
151
|
})
|
|
79
152
|
|
|
80
153
|
it('holds one session id across every event it emits', () => {
|
|
81
|
-
run.api
|
|
82
|
-
run.api
|
|
83
|
-
run.api
|
|
84
|
-
const
|
|
85
|
-
expect(
|
|
86
|
-
expect(new Set(
|
|
154
|
+
run.api!.track('a')
|
|
155
|
+
run.api!.track('b')
|
|
156
|
+
run.api!.flush()
|
|
157
|
+
const sent = sentOf(run)
|
|
158
|
+
expect(new Set(sent.map((e) => e.sessionId)).size).toBe(1)
|
|
159
|
+
expect(new Set(sent.map((e) => e.messageId)).size).toBe(sent.length)
|
|
87
160
|
})
|
|
88
161
|
|
|
89
162
|
it('emits the auto pageview on load and stamps the library', () => {
|
|
90
|
-
run.api
|
|
91
|
-
const pv = run.
|
|
163
|
+
run.api!.flush()
|
|
164
|
+
const pv = sentOf(run).find((e) => e.type === 'pageview')
|
|
92
165
|
expect(pv).toBeDefined()
|
|
93
166
|
expect(pv!.library).toBe('hz.js')
|
|
94
|
-
|
|
167
|
+
// The stamped version IS the package version. It had drifted to 0.3.9 against
|
|
168
|
+
// a published 0.3.11, so every static-site row in the warehouse was dated to a
|
|
169
|
+
// release three patches old — including the ones that changed what it sends.
|
|
170
|
+
expect(pv!.libraryVersion).toBe(PKG.version)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ── the publishable key ───────────────────────────────────────────────────
|
|
174
|
+
// Through 0.3.11 this file could present none at all: no header, no query. A
|
|
175
|
+
// keyed static surface therefore sent UNATTRIBUTED writes, which the door
|
|
176
|
+
// refuses — silently, because nothing here reads the response.
|
|
177
|
+
|
|
178
|
+
it('presents the ingest key as a bearer on fetch', () => {
|
|
179
|
+
const r = runSnippet({ attrs: { 'data-ingest-key': 'pk-abc123' } })
|
|
180
|
+
r.api!.flush()
|
|
181
|
+
const post = r.posts.at(-1)!
|
|
182
|
+
expect(post.via).toBe('fetch')
|
|
183
|
+
expect(post.headers.authorization).toBe('Bearer pk-abc123')
|
|
184
|
+
expect(post.url).toBe('https://api.hanzo.ai/v1/event')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('presents the ingest key in the query on a headerless beacon', () => {
|
|
188
|
+
const r = runSnippet({ attrs: { 'data-ingest-key': 'pk-abc123' }, beacon: true })
|
|
189
|
+
r.api!.flush()
|
|
190
|
+
const post = r.posts.at(-1)!
|
|
191
|
+
expect(post.via).toBe('beacon')
|
|
192
|
+
expect(post.url).toBe('https://api.hanzo.ai/v1/event?ingest_key=pk-abc123')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('sends no credential when no key is declared', () => {
|
|
196
|
+
run.api!.flush()
|
|
197
|
+
const post = run.posts.at(-1)!
|
|
198
|
+
expect(post.headers.authorization).toBeUndefined()
|
|
199
|
+
expect(post.url).not.toContain('ingest_key')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// ── consent ───────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
it('refuses under Global Privacy Control', () => {
|
|
205
|
+
const r = runSnippet({ navigator: { globalPrivacyControl: true } })
|
|
206
|
+
expect(r.api).toBeUndefined()
|
|
207
|
+
expect(r.posts).toEqual([])
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('refuses under Do Not Track, in each of its spellings', () => {
|
|
211
|
+
for (const nav of [{ doNotTrack: '1' }, { doNotTrack: 'yes' }, { msDoNotTrack: '1' }]) {
|
|
212
|
+
expect(runSnippet({ navigator: nav }).api).toBeUndefined()
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('refuses on a stored denial', () => {
|
|
217
|
+
expect(runSnippet({ storage: { hz_consent: 'denied' } }).api).toBeUndefined()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('an explicit grant outranks the browser signal', () => {
|
|
221
|
+
const r = runSnippet({ navigator: { doNotTrack: '1' }, storage: { hz_consent: 'granted' } })
|
|
222
|
+
expect(r.api).toBeDefined()
|
|
223
|
+
r.api!.flush()
|
|
224
|
+
expect(sentOf(r).length).toBeGreaterThan(0)
|
|
95
225
|
})
|
|
96
226
|
})
|
package/src/scrub.test.ts
CHANGED
|
@@ -94,3 +94,30 @@ describe('card numbers', () => {
|
|
|
94
94
|
expect(out).toBe('request 1753468800000 timed out')
|
|
95
95
|
})
|
|
96
96
|
})
|
|
97
|
+
|
|
98
|
+
describe('credential params in a URL', () => {
|
|
99
|
+
// The client stamps url = window.location.href on EVERY event, so one visit to
|
|
100
|
+
// an OAuth callback would otherwise put a live, still-redeemable authorization
|
|
101
|
+
// code on the wire once per event.
|
|
102
|
+
it('redacts an OAuth code and state, which have no matchable shape', () => {
|
|
103
|
+
const out = scrubText('https://hanzo.id/callback?code=4%2F0AeanS0bQx7Lm&state=xyzzy123')
|
|
104
|
+
expect(out).not.toContain('4%2F0AeanS0bQx7Lm')
|
|
105
|
+
expect(out).not.toContain('xyzzy123')
|
|
106
|
+
expect(out).toContain('code=')
|
|
107
|
+
expect(out).toContain('https://hanzo.id/callback')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('redacts reset / invite / session tokens too', () => {
|
|
111
|
+
for (const q of ['reset_token=abc123def', 'invite=q7Wm2', 'session_id=s-9182', 'api_key=plain']) {
|
|
112
|
+
const out = scrubText('https://hanzo.ai/x?' + q)
|
|
113
|
+
expect(out.split('=')[1]).not.toMatch(/abc123def|q7Wm2|s-9182|plain/)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('leaves ordinary params alone', () => {
|
|
118
|
+
const out = scrubText('https://hanzo.ai/pricing?plan=pro&utm_source=hn&page=2')
|
|
119
|
+
expect(out).toContain('plan=pro')
|
|
120
|
+
expect(out).toContain('utm_source=hn')
|
|
121
|
+
expect(out).toContain('page=2')
|
|
122
|
+
})
|
|
123
|
+
})
|
package/src/scrub.ts
CHANGED
|
@@ -79,8 +79,44 @@ const RE_EMAIL = /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/g
|
|
|
79
79
|
const RE_IPV4 = /\b(?:\d{1,3}\.){3}\d{1,3}\b/g
|
|
80
80
|
const RE_IPV6 = /\b(?:[0-9A-Fa-f]{1,4}:){2,7}[0-9A-Fa-f]{1,4}\b/g
|
|
81
81
|
|
|
82
|
+
// A CREDENTIAL IN A QUERY PARAM HAS NO SHAPE TO MATCH.
|
|
83
|
+
//
|
|
84
|
+
// Every pattern above recognises a secret by what it LOOKS like — a JWT's three
|
|
85
|
+
// dots, `sk-`, `AKIA`, `ghp_`. An OAuth `code`, a `state`, a password-reset
|
|
86
|
+
// nonce and an invite token are opaque random strings, indistinguishable from a
|
|
87
|
+
// page id, so none of them match and all of them survive.
|
|
88
|
+
//
|
|
89
|
+
// That matters because the client stamps `url: window.location.href` on EVERY
|
|
90
|
+
// event, not just pageviews. So one visit to `/callback?code=…&state=…` puts a
|
|
91
|
+
// live authorization code on the wire, once per event, in cleartext — and the
|
|
92
|
+
// code is exchangeable until it is redeemed.
|
|
93
|
+
//
|
|
94
|
+
// The fix is to redact by the NAME the value is filed under rather than by the
|
|
95
|
+
// value's shape, which is the only signal available for an opaque token. The
|
|
96
|
+
// name half is bounded (`{1,32}`) and the value half stops at the first
|
|
97
|
+
// separator, so neither half can backtrack across a long string — the same
|
|
98
|
+
// discipline the creds-in-URL pattern above documents.
|
|
99
|
+
const CREDENTIAL_PARAMS = [
|
|
100
|
+
'code', 'state', 'token', 'access_token', 'id_token', 'refresh_token',
|
|
101
|
+
'auth', 'authorization', 'secret', 'client_secret', 'password', 'passwd', 'pwd',
|
|
102
|
+
'api_key', 'apikey', 'key', 'session', 'session_id', 'sid',
|
|
103
|
+
'sig', 'signature', 'nonce', 'otp', 'invite', 'invitation',
|
|
104
|
+
'reset_token', 'confirmation_token', 'magic', 'ticket', 'assertion',
|
|
105
|
+
]
|
|
106
|
+
const RE_CREDENTIAL_PARAM = new RegExp(
|
|
107
|
+
'([?&#;](?:' + CREDENTIAL_PARAMS.join('|') + ')=)[^&#;\\s]{1,4096}',
|
|
108
|
+
'gi',
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
/** redactCredentialParams removes the VALUE of any query parameter whose NAME
|
|
112
|
+
* says it carries a credential, leaving the name so the URL still reads. */
|
|
113
|
+
export function redactCredentialParams(s: string): string {
|
|
114
|
+
return s.replace(RE_CREDENTIAL_PARAM, (_m, prefix: string) => prefix + REDACTED)
|
|
115
|
+
}
|
|
116
|
+
|
|
82
117
|
/** redactSecrets removes known secret shapes. Always applied. */
|
|
83
118
|
export function redactSecrets(s: string): string {
|
|
119
|
+
s = redactCredentialParams(s)
|
|
84
120
|
for (const re of SECRET_PATTERNS) s = s.replace(re, REDACTED)
|
|
85
121
|
return redactPAN(s)
|
|
86
122
|
}
|
package/src/version.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// The library version, stamped on every event (`libraryVersion`) and on the
|
|
2
2
|
// Sentry `sdk` block. It lives alone so `sentry.ts` can read it without importing
|
|
3
3
|
// `core.ts` — core imports sentry, so the reverse would be an import cycle.
|
|
4
|
-
export const VERSION = '0.3.
|
|
4
|
+
export const VERSION = '0.3.13'
|