@hanzo/event 0.3.14 → 0.3.15

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/hz.js CHANGED
@@ -55,7 +55,7 @@
55
55
  }
56
56
 
57
57
  var LIB = 'hz.js'
58
- var VERSION = '0.3.14'
58
+ var VERSION = '0.3.15'
59
59
  var host = (s.getAttribute('data-host') || 'https://api.hanzo.ai').replace(/\/+$/, '')
60
60
  var product = s.getAttribute('data-product') || location.hostname
61
61
  var capture = s.getAttribute('data-capture') !== '0'
@@ -69,42 +69,203 @@
69
69
  // fine in the browser, and filed nothing.
70
70
  var key = s.getAttribute('data-ingest-key') || ''
71
71
 
72
- // uuidv7 (RFC 9562 §5.7) — the same minter as src/uid.ts, restated here for the
73
- // same reason `clean` restates scrub.ts: this file has no bundler and cannot
74
- // import it. It has to be v7. The session rollups on the plane derive a session's
75
- // start instant from the 48-bit millisecond timestamp the id carries and admit
76
- // only ids whose version nibble is 7, so a crypto.randomUUID() (v4) session id —
77
- // and equally the old base36 fallback, which does not even parse as a UUID — is
78
- // dropped there silently and the session never appears.
79
- function uid(now) {
80
- var b = new Uint8Array(16),
81
- i
82
- if (typeof crypto !== 'undefined' && crypto.getRandomValues) crypto.getRandomValues(b)
83
- else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
84
- var t = Math.floor(now || Date.now())
85
- for (i = 5; i >= 0; i--) {
86
- b[i] = t % 256
87
- t = Math.floor(t / 256)
72
+ // ── the shared anonymous-identity chain ───────────────────────────────────
73
+ // COPIED VERBATIM from @hanzo/event's src/anon.js, markers and all, for the same
74
+ // reason `clean` restates scrub.ts: this file has no bundler and cannot import
75
+ // anything. It is the ONE chain and the ONE key, so a page carrying this tag and
76
+ // the npm client resolves to one person; src/anon.test.ts fails on a byte of
77
+ // drift between the two copies. EDIT src/anon.js, never this copy, and do not
78
+ // reformat it the indentation is part of the byte comparison.
79
+
80
+ /* ── BEGIN hz anon chain — copied VERBATIM into hz.js and hanzoai/cloud ────── */
81
+
82
+ /** The ONE anonymous-id key, on every surface and in every distribution. */
83
+ var HZ_ANON_KEY = 'hz_anon_id'
84
+
85
+ /** hz.js used to write `hz_id` a SECOND identity space, so the one-paste tag
86
+ * and the npm client were two different people on one page. It is READ and never
87
+ * written: an id already in the wild is ADOPTED into the shared identity, because
88
+ * minting over one detaches a returning visitor from their own history. */
89
+ var HZ_ANON_LEGACY_KEY = 'hz_id'
90
+
91
+ /** The registrable domain the cookie is scoped to, so docs, cloud, console,
92
+ * studio, pay, id and www all read the ONE id. localStorage cannot do this: it is
93
+ * ORIGIN-scoped, which is what made one journey arrive as several strangers. */
94
+ var HZ_ANON_DOMAIN = 'hanzo.ai'
95
+
96
+ /** Two years, rewritten on every read, so the cookie rolls forward with the
97
+ * visitor instead of expiring two years after first touch. Safari caps a
98
+ * SCRIPT-written cookie at 7 days no matter what this says, so the rewrite is
99
+ * what keeps a returning Safari visitor: each read re-arms the 7-day window. */
100
+ var HZ_ANON_MAX_AGE = 2 * 365 * 24 * 60 * 60
101
+
102
+ /** Last resort for a browser that refuses cookies AND localStorage: without it
103
+ * every event in a page load would mint an id of its own. */
104
+ var hzAnonMemo
105
+
106
+ /**
107
+ * hzUuidv7 mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch ms.
108
+ *
109
+ * It has to be v7, and this is the only minter any distribution may use. The
110
+ * session rollups on the event plane derive a session's start instant FROM THE ID
111
+ * and admit only ids whose version nibble is 7, so a crypto.randomUUID() (v4) id
112
+ * is not merely unordered there — it is DISCARDED, silently, and the rollup stays
113
+ * empty. Without crypto only the ENTROPY degrades; the shape is always a valid v7.
114
+ */
115
+ function hzUuidv7(now) {
116
+ var b = new Uint8Array(16)
117
+ var i
118
+ var c = typeof crypto !== 'undefined' ? crypto : undefined
119
+ if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b)
120
+ else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
121
+ var t = Math.floor(now === undefined ? Date.now() : now)
122
+ for (i = 5; i >= 0; i--) {
123
+ b[i] = t % 256
124
+ t = Math.floor(t / 256)
125
+ }
126
+ b[6] = 0x70 | (b[6] & 0x0f) // version 7
127
+ b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
128
+ var h = ''
129
+ for (i = 0; i < 16; i++) {
130
+ h += (b[i] + 0x100).toString(16).slice(1)
131
+ if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
132
+ }
133
+ return h
134
+ }
135
+
136
+ /** The cookie jar, or null wherever there is no document to read one from. */
137
+ function hzAnonJar() {
138
+ try {
139
+ if (typeof document === 'undefined' || typeof document.cookie !== 'string') return null
140
+ return document
141
+ } catch (e) {
142
+ return null // sandboxed frame with an opaque origin
143
+ }
144
+ }
145
+
146
+ /** localStorage, or null when the browser refuses it (Safari private mode). */
147
+ function hzAnonStore() {
148
+ try {
149
+ if (typeof window === 'undefined' || !window.localStorage) return null
150
+ return window.localStorage
151
+ } catch (e) {
152
+ return null
153
+ }
154
+ }
155
+
156
+ /** One stored value, or '' — a jar can read as well as refuse to. */
157
+ function hzAnonItem(store, name) {
158
+ try {
159
+ return (store && store.getItem(name)) || ''
160
+ } catch (e) {
161
+ return ''
162
+ }
163
+ }
164
+
165
+ /** The value of cookie `name`, or ''. */
166
+ function hzAnonCookie(name) {
167
+ var d = hzAnonJar()
168
+ if (!d) return ''
169
+ var parts = d.cookie.split(';')
170
+ for (var i = 0; i < parts.length; i++) {
171
+ var eq = parts[i].indexOf('=')
172
+ if (eq < 0 || parts[i].slice(0, eq).trim() !== name) continue
173
+ var v = parts[i].slice(eq + 1).trim()
174
+ if (!v) continue
175
+ try {
176
+ return decodeURIComponent(v)
177
+ } catch (e) {
178
+ return v // not percent-encoded — take it as written
88
179
  }
89
- b[6] = 0x70 | (b[6] & 0x0f) // version 7
90
- b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
91
- var h = ''
92
- for (i = 0; i < 16; i++) {
93
- h += (b[i] + 0x100).toString(16).slice(1)
94
- if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
180
+ }
181
+ return ''
182
+ }
183
+
184
+ /** Writes `name` on the registrable domain, for as long as the browser allows. */
185
+ function hzAnonWrite(name, value) {
186
+ var d = hzAnonJar()
187
+ if (!d) return
188
+ var host = ''
189
+ var secure = false
190
+ try {
191
+ if (typeof window !== 'undefined' && window.location) {
192
+ host = window.location.hostname || ''
193
+ // A Secure cookie is refused outright by a non-secure origin, which would
194
+ // strand http://localhost dev on the localStorage path.
195
+ secure = window.location.protocol === 'https:'
95
196
  }
96
- return h
197
+ } catch (e) {
198
+ /* location unreachable — write a host-only, non-secure cookie */
199
+ }
200
+ // encodeURIComponent leaves a UUID byte-identical while making any value that is
201
+ // not one unable to forge a `;` and inject an attribute.
202
+ var c = name + '=' + encodeURIComponent(value)
203
+ c += '; Path=/; Max-Age=' + HZ_ANON_MAX_AGE + '; SameSite=Lax'
204
+ // Off hanzo.ai (localhost, previews, other registrable domains) the attribute
205
+ // would be rejected and the whole cookie dropped, so it stays host-only there.
206
+ // Prefixing both sides with '.' matches the domain itself and its subdomains
207
+ // while refusing a suffix that merely ends in the same letters (evilhanzo.ai).
208
+ if (('.' + host).slice(-(HZ_ANON_DOMAIN.length + 1)) === '.' + HZ_ANON_DOMAIN) {
209
+ c += '; Domain=' + HZ_ANON_DOMAIN
97
210
  }
211
+ if (secure) c += '; Secure'
212
+ try {
213
+ d.cookie = c
214
+ } catch (e) {
215
+ /* cookies refused — localStorage still carries the id */
216
+ }
217
+ }
218
+
219
+ /**
220
+ * hzAnonId returns the stable anonymous id for this browser, '' during SSR.
221
+ *
222
+ * Resolution is strictly ADDITIVE — every id that already exists is ADOPTED, and
223
+ * only a browser holding none of them is given a new one:
224
+ *
225
+ * cookie · localStorage hz_anon_id · localStorage hz_id · in-memory · mint
226
+ *
227
+ * Minting over an id resets a returning visitor and detaches them from their own
228
+ * history, so the order is the migration: the cookie is the shared home, the two
229
+ * localStorage keys are what the three implementations wrote before it existed,
230
+ * and each is read until nothing is left to adopt.
231
+ *
232
+ * localStorage keeps being written, so a rollback finds everyone where it left
233
+ * them, and a browser that refuses cookies still holds one id per origin.
234
+ */
235
+ function hzAnonId() {
236
+ if (typeof window === 'undefined') return '' // SSR / prerender: no browser to identify
237
+ var s = hzAnonStore()
238
+ var id =
239
+ hzAnonCookie(HZ_ANON_KEY) ||
240
+ hzAnonItem(s, HZ_ANON_KEY) ||
241
+ hzAnonItem(s, HZ_ANON_LEGACY_KEY) ||
242
+ hzAnonMemo ||
243
+ hzUuidv7()
244
+ hzAnonMemo = id
245
+ hzAnonWrite(HZ_ANON_KEY, id)
246
+ try {
247
+ if (s && s.getItem(HZ_ANON_KEY) !== id) s.setItem(HZ_ANON_KEY, id)
248
+ } catch (e) {
249
+ /* quota exhausted, or a private-mode jar that reads but refuses writes */
250
+ }
251
+ return id
252
+ }
253
+
254
+ /* ── END hz anon chain ─────────────────────────────────────────────────────── */
255
+
98
256
  function stored(store, key) {
99
257
  try {
100
258
  var v = store.getItem(key)
101
- if (!v) store.setItem(key, (v = uid()))
259
+ if (!v) store.setItem(key, (v = hzUuidv7()))
102
260
  return v
103
261
  } catch (e) {
104
262
  return 'anon'
105
263
  }
106
264
  }
107
- var anon = stored(localStorage, 'hz_id')
265
+ // This used to mint into a localStorage key of its own, so the tag and
266
+ // @hanzo/event counted one visitor as two people. The chain adopts that old id
267
+ // where it exists rather than orphaning it.
268
+ var anon = hzAnonId()
108
269
  var sid = stored(sessionStorage, 'hz_sid')
109
270
  var person = null
110
271
  try {
@@ -177,7 +338,7 @@
177
338
  // server cannot tell which distribution emitted it.
178
339
  function send(kind, event, props) {
179
340
  queue.push({
180
- messageId: uid(),
341
+ messageId: hzUuidv7(),
181
342
  type: kind,
182
343
  event: event,
183
344
  timestamp: new Date().toISOString(),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hanzo/event",
3
- "version": "0.3.14",
4
- "description": "Hanzo Event \u2014 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 \u2014 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.",
3
+ "version": "0.3.15",
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/",
7
7
  "access": "public",
@@ -37,13 +37,6 @@
37
37
  "funnel",
38
38
  "hanzo"
39
39
  ],
40
- "scripts": {
41
- "build": "tsup",
42
- "dev": "tsup --watch",
43
- "test": "vitest run",
44
- "typecheck": "tsc --noEmit",
45
- "clean": "rm -rf dist"
46
- },
47
40
  "exports": {
48
41
  ".": {
49
42
  "import": {
@@ -81,5 +74,12 @@
81
74
  "tsup": "^8.5.1",
82
75
  "typescript": "^5.9.3",
83
76
  "vitest": "^4.1.0"
77
+ },
78
+ "scripts": {
79
+ "build": "tsup",
80
+ "dev": "tsup --watch",
81
+ "test": "vitest run",
82
+ "typecheck": "tsc --noEmit",
83
+ "clean": "rm -rf dist"
84
84
  }
85
- }
85
+ }
package/src/anon.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ // Types for anon.js, which is hand-written ES5 rather than TypeScript because
2
+ // hz.js and the door's hosted tag inline it VERBATIM and neither has a compiler.
3
+ // The declarations are here so the bundled client still imports it typed.
4
+
5
+ /** Mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch milliseconds. */
6
+ export declare function hzUuidv7(now?: number): string
7
+
8
+ /**
9
+ * The stable anonymous id for this browser, or '' during SSR.
10
+ *
11
+ * cookie · localStorage `hz_anon_id` · localStorage `hz_id` · in-memory · mint —
12
+ * every existing id is adopted, and only a browser holding none is given a new one.
13
+ */
14
+ export declare function hzAnonId(): string
package/src/anon.js ADDED
@@ -0,0 +1,199 @@
1
+ /*! anon.js — THE anonymous-identity chain. ONE implementation, three distributions.
2
+ *
3
+ * One browser is ONE person on every Hanzo surface, whichever client a page
4
+ * happens to have loaded. There were three implementations writing TWO keys —
5
+ * `hz_anon_id` (the npm client, the hosted tag) and `hz_id` (hz.js) — so the same
6
+ * visitor was several people depending on which snippet the surface shipped.
7
+ *
8
+ * The three call sites:
9
+ * 1. src/storage.ts — the bundled npm client; IMPORTS this file.
10
+ * 2. hz.js — the no-build script tag; INLINES the marked region.
11
+ * 3. hanzoai/cloud apps/analytics/tag.js — the tag the door hosts at
12
+ * /v1/event.js; vendors this file and its tag.go serves the marked region
13
+ * with the tag as one asset, so the door holds no second copy either.
14
+ *
15
+ * (2) and (3) have no bundler and cannot import anything, which is why the chain
16
+ * lives in a file that is plain ES5 rather than in a .ts: the region between the
17
+ * BEGIN and END markers is COPIED VERBATIM, and src/anon.test.ts fails if hz.js's
18
+ * copy is so much as a byte different. Keep the region ES5, dependency-free,
19
+ * `hz`-prefixed (it is spliced into other people's scopes) and unformatted — a
20
+ * reformat of one copy is a diff against the other.
21
+ */
22
+
23
+ /* ── BEGIN hz anon chain — copied VERBATIM into hz.js and hanzoai/cloud ────── */
24
+
25
+ /** The ONE anonymous-id key, on every surface and in every distribution. */
26
+ var HZ_ANON_KEY = 'hz_anon_id'
27
+
28
+ /** hz.js used to write `hz_id` — a SECOND identity space, so the one-paste tag
29
+ * and the npm client were two different people on one page. It is READ and never
30
+ * written: an id already in the wild is ADOPTED into the shared identity, because
31
+ * minting over one detaches a returning visitor from their own history. */
32
+ var HZ_ANON_LEGACY_KEY = 'hz_id'
33
+
34
+ /** The registrable domain the cookie is scoped to, so docs, cloud, console,
35
+ * studio, pay, id and www all read the ONE id. localStorage cannot do this: it is
36
+ * ORIGIN-scoped, which is what made one journey arrive as several strangers. */
37
+ var HZ_ANON_DOMAIN = 'hanzo.ai'
38
+
39
+ /** Two years, rewritten on every read, so the cookie rolls forward with the
40
+ * visitor instead of expiring two years after first touch. Safari caps a
41
+ * SCRIPT-written cookie at 7 days no matter what this says, so the rewrite is
42
+ * what keeps a returning Safari visitor: each read re-arms the 7-day window. */
43
+ var HZ_ANON_MAX_AGE = 2 * 365 * 24 * 60 * 60
44
+
45
+ /** Last resort for a browser that refuses cookies AND localStorage: without it
46
+ * every event in a page load would mint an id of its own. */
47
+ var hzAnonMemo
48
+
49
+ /**
50
+ * hzUuidv7 mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch ms.
51
+ *
52
+ * It has to be v7, and this is the only minter any distribution may use. The
53
+ * session rollups on the event plane derive a session's start instant FROM THE ID
54
+ * and admit only ids whose version nibble is 7, so a crypto.randomUUID() (v4) id
55
+ * is not merely unordered there — it is DISCARDED, silently, and the rollup stays
56
+ * empty. Without crypto only the ENTROPY degrades; the shape is always a valid v7.
57
+ */
58
+ function hzUuidv7(now) {
59
+ var b = new Uint8Array(16)
60
+ var i
61
+ var c = typeof crypto !== 'undefined' ? crypto : undefined
62
+ if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b)
63
+ else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
64
+ var t = Math.floor(now === undefined ? Date.now() : now)
65
+ for (i = 5; i >= 0; i--) {
66
+ b[i] = t % 256
67
+ t = Math.floor(t / 256)
68
+ }
69
+ b[6] = 0x70 | (b[6] & 0x0f) // version 7
70
+ b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
71
+ var h = ''
72
+ for (i = 0; i < 16; i++) {
73
+ h += (b[i] + 0x100).toString(16).slice(1)
74
+ if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
75
+ }
76
+ return h
77
+ }
78
+
79
+ /** The cookie jar, or null wherever there is no document to read one from. */
80
+ function hzAnonJar() {
81
+ try {
82
+ if (typeof document === 'undefined' || typeof document.cookie !== 'string') return null
83
+ return document
84
+ } catch (e) {
85
+ return null // sandboxed frame with an opaque origin
86
+ }
87
+ }
88
+
89
+ /** localStorage, or null when the browser refuses it (Safari private mode). */
90
+ function hzAnonStore() {
91
+ try {
92
+ if (typeof window === 'undefined' || !window.localStorage) return null
93
+ return window.localStorage
94
+ } catch (e) {
95
+ return null
96
+ }
97
+ }
98
+
99
+ /** One stored value, or '' — a jar can read as well as refuse to. */
100
+ function hzAnonItem(store, name) {
101
+ try {
102
+ return (store && store.getItem(name)) || ''
103
+ } catch (e) {
104
+ return ''
105
+ }
106
+ }
107
+
108
+ /** The value of cookie `name`, or ''. */
109
+ function hzAnonCookie(name) {
110
+ var d = hzAnonJar()
111
+ if (!d) return ''
112
+ var parts = d.cookie.split(';')
113
+ for (var i = 0; i < parts.length; i++) {
114
+ var eq = parts[i].indexOf('=')
115
+ if (eq < 0 || parts[i].slice(0, eq).trim() !== name) continue
116
+ var v = parts[i].slice(eq + 1).trim()
117
+ if (!v) continue
118
+ try {
119
+ return decodeURIComponent(v)
120
+ } catch (e) {
121
+ return v // not percent-encoded — take it as written
122
+ }
123
+ }
124
+ return ''
125
+ }
126
+
127
+ /** Writes `name` on the registrable domain, for as long as the browser allows. */
128
+ function hzAnonWrite(name, value) {
129
+ var d = hzAnonJar()
130
+ if (!d) return
131
+ var host = ''
132
+ var secure = false
133
+ try {
134
+ if (typeof window !== 'undefined' && window.location) {
135
+ host = window.location.hostname || ''
136
+ // A Secure cookie is refused outright by a non-secure origin, which would
137
+ // strand http://localhost dev on the localStorage path.
138
+ secure = window.location.protocol === 'https:'
139
+ }
140
+ } catch (e) {
141
+ /* location unreachable — write a host-only, non-secure cookie */
142
+ }
143
+ // encodeURIComponent leaves a UUID byte-identical while making any value that is
144
+ // not one unable to forge a `;` and inject an attribute.
145
+ var c = name + '=' + encodeURIComponent(value)
146
+ c += '; Path=/; Max-Age=' + HZ_ANON_MAX_AGE + '; SameSite=Lax'
147
+ // Off hanzo.ai (localhost, previews, other registrable domains) the attribute
148
+ // would be rejected and the whole cookie dropped, so it stays host-only there.
149
+ // Prefixing both sides with '.' matches the domain itself and its subdomains
150
+ // while refusing a suffix that merely ends in the same letters (evilhanzo.ai).
151
+ if (('.' + host).slice(-(HZ_ANON_DOMAIN.length + 1)) === '.' + HZ_ANON_DOMAIN) {
152
+ c += '; Domain=' + HZ_ANON_DOMAIN
153
+ }
154
+ if (secure) c += '; Secure'
155
+ try {
156
+ d.cookie = c
157
+ } catch (e) {
158
+ /* cookies refused — localStorage still carries the id */
159
+ }
160
+ }
161
+
162
+ /**
163
+ * hzAnonId returns the stable anonymous id for this browser, '' during SSR.
164
+ *
165
+ * Resolution is strictly ADDITIVE — every id that already exists is ADOPTED, and
166
+ * only a browser holding none of them is given a new one:
167
+ *
168
+ * cookie · localStorage hz_anon_id · localStorage hz_id · in-memory · mint
169
+ *
170
+ * Minting over an id resets a returning visitor and detaches them from their own
171
+ * history, so the order is the migration: the cookie is the shared home, the two
172
+ * localStorage keys are what the three implementations wrote before it existed,
173
+ * and each is read until nothing is left to adopt.
174
+ *
175
+ * localStorage keeps being written, so a rollback finds everyone where it left
176
+ * them, and a browser that refuses cookies still holds one id per origin.
177
+ */
178
+ function hzAnonId() {
179
+ if (typeof window === 'undefined') return '' // SSR / prerender: no browser to identify
180
+ var s = hzAnonStore()
181
+ var id =
182
+ hzAnonCookie(HZ_ANON_KEY) ||
183
+ hzAnonItem(s, HZ_ANON_KEY) ||
184
+ hzAnonItem(s, HZ_ANON_LEGACY_KEY) ||
185
+ hzAnonMemo ||
186
+ hzUuidv7()
187
+ hzAnonMemo = id
188
+ hzAnonWrite(HZ_ANON_KEY, id)
189
+ try {
190
+ if (s && s.getItem(HZ_ANON_KEY) !== id) s.setItem(HZ_ANON_KEY, id)
191
+ } catch (e) {
192
+ /* quota exhausted, or a private-mode jar that reads but refuses writes */
193
+ }
194
+ return id
195
+ }
196
+
197
+ /* ── END hz anon chain ─────────────────────────────────────────────────────── */
198
+
199
+ export { hzAnonId, hzUuidv7 }
@@ -0,0 +1,80 @@
1
+ // The anonymous-identity chain is ONE implementation with three call sites, and
2
+ // two of them cannot import it: hz.js and the tag hanzoai/cloud hosts at
3
+ // /v1/event.js have no bundler, so they carry the marked region of anon.js
4
+ // VERBATIM. That is the drift risk this file exists to remove — three snippets
5
+ // that agree the day they are written and disagree a quarter later, which is
6
+ // exactly how the same browser came to hold two different anonymous ids under two
7
+ // different key names.
8
+ //
9
+ // The behavioural contract is proven where each distribution runs it:
10
+ // storage.test.ts (the npm client) and hz.test.ts (the script tag). This file
11
+ // proves the copies are the SAME TEXT, which is the only thing that keeps those
12
+ // two suites testing one implementation instead of two.
13
+
14
+ import { describe, expect, it } from 'vitest'
15
+ import { readFileSync } from 'node:fs'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const read = (rel: string) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), 'utf8')
19
+
20
+ const BEGIN = '/* ── BEGIN hz anon chain'
21
+ const END = '/* ── END hz anon chain'
22
+
23
+ /** Where the shared region starts and ends in a file that carries it. */
24
+ function span(src: string, what: string): [number, number] {
25
+ const b = src.indexOf(BEGIN)
26
+ const e = src.indexOf(END)
27
+ expect(b, `${what} has no BEGIN marker`).toBeGreaterThanOrEqual(0)
28
+ expect(e, `${what} has no END marker`).toBeGreaterThan(b)
29
+ return [b, src.indexOf('\n', e) + 1]
30
+ }
31
+
32
+ /** The shared region, markers included. */
33
+ const region = (src: string, what: string): string => src.slice(...span(src, what))
34
+
35
+ /** Everything that is NOT the shared region. */
36
+ function outside(src: string, what: string): string {
37
+ const [b, e] = span(src, what)
38
+ return src.slice(0, b) + src.slice(e)
39
+ }
40
+
41
+ const ANON = read('./anon.js')
42
+ const HZ = read('../hz.js')
43
+
44
+ describe('the shared anon chain', () => {
45
+ it('is in hz.js as the same text, byte for byte', () => {
46
+ // Not "equivalent" — IDENTICAL. Anything weaker (normalising whitespace,
47
+ // comparing behaviour) permits the copies to say different things about which
48
+ // key they read, which is the bug. To change the chain, edit src/anon.js and
49
+ // re-splice; never patch a copy.
50
+ expect(region(HZ, 'hz.js')).toBe(region(ANON, 'anon.js'))
51
+ })
52
+
53
+ it('names one key, and hz.js holds no identity code of its own', () => {
54
+ // hz.js used to mint into hz_id — a second identity space, so the one-paste
55
+ // tag and the npm client were two people on one page. Neither key may be
56
+ // NAMED outside the shared region: a snippet that spells a key is a snippet
57
+ // that has an opinion about identity, and there is one opinion now.
58
+ const rest = outside(HZ, 'hz.js')
59
+ expect(rest).not.toContain("'hz_anon_id'")
60
+ expect(rest).not.toContain("'hz_id'")
61
+ })
62
+
63
+ it('is inlineable: the region imports nothing and declares no ES6', () => {
64
+ // It is spliced into two files that have no bundler and into pages the client
65
+ // does not control, so it must be ES5, self-contained and hz-prefixed.
66
+ const r = region(ANON, 'anon.js')
67
+ expect(r).not.toMatch(/\b(?:import|export|const|let|class|=>)\b|=>/)
68
+ for (const [, name] of r.matchAll(/^(?:function|var) ([A-Za-z_$][\w$]*)/gm)) {
69
+ expect(name, `${name} would collide in a host page's scope`).toMatch(/^(?:hz|HZ_)/)
70
+ }
71
+ })
72
+
73
+ it('ships in the published package, for the door to vendor', () => {
74
+ // hanzoai/cloud vendors this file whole and its tag.go serves the region with
75
+ // the tag as one asset, so the third call site holds no second copy either.
76
+ const pkg = JSON.parse(read('../package.json')) as { files: string[] }
77
+ expect(pkg.files).toContain('src')
78
+ expect(pkg.files).toContain('hz.js')
79
+ })
80
+ })