@hanzo/event 0.3.10 → 0.3.12

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.test.ts CHANGED
@@ -360,26 +360,26 @@ describe('Analytics capture', () => {
360
360
  // The failure this closes is silent: a surface with no key attributes nothing
361
361
  // for a logged-out visitor, the door refuses the write, and the page shows no
362
362
  // sign of it. The key must resolve from the env exactly as the DSN does.
363
- process.env.NEXT_PUBLIC_HANZO_EVENT_KEY = 'pk-live-from-env'
363
+ process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-from-env'
364
364
  try {
365
365
  const a = mk() // no key in config
366
366
  a.capture('x')
367
367
  a.flush(true)
368
368
  expect(tx.sent[0].ingestKey).toBe('pk-live-from-env')
369
369
  } finally {
370
- delete process.env.NEXT_PUBLIC_HANZO_EVENT_KEY
370
+ delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
371
371
  }
372
372
  })
373
373
 
374
374
  it('prefers an explicit ingest key over the build env', () => {
375
- process.env.NEXT_PUBLIC_HANZO_EVENT_KEY = 'pk-live-from-env'
375
+ process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-from-env'
376
376
  try {
377
377
  const a = mk({ ingestKey: 'pk-live-explicit' })
378
378
  a.capture('x')
379
379
  a.flush(true)
380
380
  expect(tx.sent[0].ingestKey).toBe('pk-live-explicit')
381
381
  } finally {
382
- delete process.env.NEXT_PUBLIC_HANZO_EVENT_KEY
382
+ delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
383
383
  }
384
384
  })
385
385
 
@@ -390,6 +390,35 @@ describe('Analytics capture', () => {
390
390
  expect(tx.sent[0].ingestKey).toBeUndefined()
391
391
  })
392
392
 
393
+ it('a signed-in bearer WINS over a key from the build env', () => {
394
+ // The leak this closes: one console bundle is served to several brands, and a
395
+ // pk- names ONE org. If an env-sourced key displaced the bearer, every
396
+ // signed-in user's events would re-file under whichever org minted the key.
397
+ process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-one-org'
398
+ try {
399
+ const a = mk({ getToken: () => 'jwt-of-a-real-person' })
400
+ a.capture('x')
401
+ a.flush()
402
+ expect(tx.sent[0].token).toBe('jwt-of-a-real-person')
403
+ expect(tx.sent[0].ingestKey).toBeUndefined()
404
+ } finally {
405
+ delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
406
+ }
407
+ })
408
+
409
+ it('an anonymous visitor still rides the key', () => {
410
+ process.env.NEXT_PUBLIC_EVENT_INGEST_KEY = 'pk-live-one-org'
411
+ try {
412
+ const a = mk({ getToken: () => undefined }) // logged out
413
+ a.capture('x')
414
+ a.flush()
415
+ expect(tx.sent[0].ingestKey).toBe('pk-live-one-org')
416
+ expect(tx.sent[0].token).toBeUndefined()
417
+ } finally {
418
+ delete process.env.NEXT_PUBLIC_EVENT_INGEST_KEY
419
+ }
420
+ })
421
+
393
422
  it('setCohort rides subsequent events', () => {
394
423
  const a = mk()
395
424
  a.setCohort({ signupWeek: '2026-W29', channel: 'paid', refCode: 'REF9' })
package/src/core.ts CHANGED
@@ -232,14 +232,21 @@ export class Analytics {
232
232
  captureErrors: true,
233
233
  ...config,
234
234
  // The publishable key resolves the SAME way the DSN below does: an explicit
235
- // config wins, else the inlined build-time env. Without this the key was the
236
- // one piece of wiring a surface could not declare the way it declares every
237
- // other piece, so every surface that shipped without passing it in code sent
238
- // its beacons unattributed and an unattributed write is refused (401
239
- // ingest_key_required), which is silent in the page and invisible until you
240
- // read the warehouse and find the host missing entirely.
241
- ingestKey:
242
- config.ingestKey ?? readEnv('NEXT_PUBLIC_HANZO_EVENT_KEY') ?? readEnv('HANZO_EVENT_KEY'),
235
+ // config wins, else the inlined build-time env.
236
+ //
237
+ // NEXT_PUBLIC_EVENT_INGEST_KEY is that env, and it is the name the fleet
238
+ // ALREADY carries end to end KMS holds deploy/EVENT_INGEST_KEY, each
239
+ // Dockerfile takes it as the EVENT_INGEST_KEY build-arg and re-exports it
240
+ // with the NEXT_PUBLIC_ prefix Next needs to inline it. Reading anything
241
+ // else here would add a fourth spelling of one value.
242
+ //
243
+ // Without this the key was the one piece of wiring a surface could not
244
+ // declare the way it declares every other piece, so a surface that shipped
245
+ // without passing it in code sent its beacons unattributed — and an
246
+ // unattributed write is refused (401 ingest_key_required), which is silent
247
+ // in the page and invisible until you read the warehouse and find the host
248
+ // missing entirely.
249
+ ingestKey: config.ingestKey ?? readEnv('NEXT_PUBLIC_EVENT_INGEST_KEY'),
243
250
  }
244
251
  this.transport = config.transport ?? new DefaultTransport()
245
252
  // Error plane, most specific source first: an explicit DSN wins, then the
@@ -408,9 +415,20 @@ export class Analytics {
408
415
  this.queue = []
409
416
  this.clearTimer()
410
417
 
411
- const key = this.cfg.ingestKey?.trim() || undefined
412
- // A publishable key and a bearer JWT are mutually exclusive doors; the key wins.
413
- 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
414
432
  // Only a headerful bearer JWT blocks the beacon: sendBeacon cannot set an
415
433
  // Authorization header. A pk_ rides ?ingest_key; a cookie rides credentials.
416
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/types.ts CHANGED
@@ -120,9 +120,12 @@ export interface AnalyticsConfig {
120
120
  * a reading principal — so it is safe to ship in a bundle. Mint one per org with
121
121
  * POST /v1/keys {"type":"publishable"}.
122
122
  *
123
- * Omit it and the client reads NEXT_PUBLIC_HANZO_EVENT_KEY, then HANZO_EVENT_KEY,
124
- * from the inlined build env the same resolution `dsn` uses, so a surface
125
- * declares BOTH planes the same way and neither needs code to switch on.
123
+ * Omit it and the client reads NEXT_PUBLIC_EVENT_INGEST_KEY from the inlined
124
+ * build env, the same way `dsn` falls back — so a surface declares BOTH planes
125
+ * in its build and neither needs code to switch on. That is the ONE spelling
126
+ * the fleet already carries: KMS holds deploy/EVENT_INGEST_KEY, and each
127
+ * Dockerfile takes EVENT_INGEST_KEY as a build-arg and re-exports it with the
128
+ * NEXT_PUBLIC_ prefix that makes Next inline it.
126
129
  *
127
130
  * A surface with no key at all still reports for whoever is SIGNED IN (the
128
131
  * session credential attributes them), and drops every logged-out visitor: the
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.9'
4
+ export const VERSION = '0.3.12'