@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/src/core.ts CHANGED
@@ -415,9 +415,20 @@ export class Analytics {
415
415
  this.queue = []
416
416
  this.clearTimer()
417
417
 
418
- const key = this.cfg.ingestKey?.trim() || undefined
419
- // A publishable key and a bearer JWT are mutually exclusive doors; the key wins.
420
- const token = key ? undefined : this.cfg.getToken?.() ?? undefined
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
- // the batch it actually posts.
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
- /** Runs hz.js against a stub browser and returns everything it posted. */
27
- function runSnippet(): { sent: WireEvent[]; api: { track(n: string): void; flush(): void } } {
28
- const sent: WireEvent[] = []
29
- const store = () => {
30
- const m = new Map<string, string>()
31
- return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => void m.set(k, v) }
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 g = globalThis as Record<string, unknown>
34
- g.location = { href: 'https://x.test/p?a=1', pathname: '/p', search: '?a=1', hostname: 'x.test', host: 'x.test' }
35
- g.document = {
36
- currentScript: { getAttribute: (a: string) => (a === 'data-product' ? 'test' : null) },
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
- g.navigator = { doNotTrack: '0' }
45
- g.localStorage = store()
46
- g.sessionStorage = store()
47
- g.history = { pushState: () => {}, replaceState: () => {} }
48
- g.addEventListener = () => {}
49
- g.PerformanceObserver = undefined
50
- g.fetch = (_u: string, init: { body: string }) => {
51
- sent.push(...(JSON.parse(init.body).batch as WireEvent[]))
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
- g.window = g
121
+ })
122
+ define('window', g)
123
+
55
124
  new Function(SRC)()
56
- return { sent, api: (g.window as { hanzo: { track(n: string): void; flush(): void } }).hanzo }
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.track('checkout_started')
67
- run.api.flush()
68
- expect(run.sent.length).toBeGreaterThan(0)
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 run.sent) {
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.track('a')
82
- run.api.track('b')
83
- run.api.flush()
84
- const ids = new Set(run.sent.map((e) => e.sessionId))
85
- expect(ids.size).toBe(1)
86
- expect(new Set(run.sent.map((e) => e.messageId)).size).toBe(run.sent.length)
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.flush()
91
- const pv = run.sent.find((e) => e.type === 'pageview')
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
- expect(pv!.libraryVersion).toMatch(/^\d+\.\d+\.\d+$/)
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.11'
4
+ export const VERSION = '0.3.13'