@hanzo/event 0.3.49 → 0.3.52

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/tags.d.ts CHANGED
@@ -16,12 +16,13 @@ export interface TagOptions {
16
16
  domains?: string[];
17
17
  }
18
18
  /**
19
- * The publishable key of each site's project, which is how cloud finds the site's
20
- * tag set. A `pk-` is public by design (it ships in every page); it names a site,
21
- * never a platform id. A host absent here has no tag config unless `start` is
22
- * given a key.
19
+ * Resolves once every tag this visitor's choice loads has taken the page view, or after
20
+ * `limit` ms, whichever is first: the tag set has answered, gtag.js has loaded or failed,
21
+ * and Meta's library has replaced the stub that queued the call. A page that is about to
22
+ * leave waits on this so the visit is counted where it arrived. A tag the choice does not
23
+ * load is not waited for.
23
24
  */
24
- export declare const SITE_KEY: Readonly<Record<string, string>>;
25
+ export declare function ready(limit?: number): Promise<void>;
25
26
  /**
26
27
  * Starts the tag manager: fetches the site's tag set and loads what consent
27
28
  * allows, again on every consent change. Safe to call on every page load, and
@@ -1 +1 @@
1
- {"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../src/tags.ts"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAA;AAEvC,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAChC;AAED,MAAM,WAAW,UAAU;IACzB,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,0DAA0D;IAC1D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAED;;;;;GAKG;AACH,eAAO,MAAM,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUpD,CAAA;AAuNF;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,CAAC,GAAE,UAAe,GAAG,MAAM,IAAI,CAmCpD;AAOD,uFAAuF;AACvF,wBAAgB,KAAK,IAAI,MAAM,CAc9B;AA+CD;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,SAAU,GAAG,IAAI,CAiCpG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,KAAK,CACnB,MAAM,EAAE,SAAS,GAAG,SAAS,EAC7B,IAAI,EAAE,MAAM,EACZ,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;AACpC,kGAAkG;AAClG,IAAI,CAAC,EAAE,MAAM,GACZ,IAAI,CAsBN;AAED,gGAAgG;AAChG,wBAAgB,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAG/C"}
1
+ {"version":3,"file":"tags.d.ts","sourceRoot":"","sources":["../src/tags.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,QAAQ,CAAA;AAEvC,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,EAAE,EAAE,MAAM,CAAA;IACV,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAChC;AAED,MAAM,WAAW,UAAU;IACzB,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,0DAA0D;IAC1D,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,wDAAwD;IACxD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8DAA8D;IAC9D,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAqCD;;;;;;GAMG;AACH,wBAAgB,KAAK,CAAC,KAAK,SAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAcjD;AAoLD;;;;GAIG;AACH,wBAAgB,KAAK,CAAC,CAAC,GAAE,UAAe,GAAG,MAAM,IAAI,CAoCpD;AAOD,uFAAuF;AACvF,wBAAgB,KAAK,IAAI,MAAM,CAc9B;AA+CD;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,SAAU,GAAG,IAAI,CAiCpG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,KAAK,CACnB,MAAM,EAAE,SAAS,GAAG,SAAS,EAC7B,IAAI,EAAE,MAAM,EACZ,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM;AACpC,kGAAkG;AAClG,IAAI,CAAC,EAAE,MAAM,GACZ,IAAI,CAsBN;AAED,gGAAgG;AAChG,wBAAgB,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAG/C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/event",
3
- "version": "0.3.49",
3
+ "version": "0.3.52",
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/",
@@ -1,7 +1,7 @@
1
1
  // Consent: opt-in outside the US, opt-out inside it, GPC always wins.
2
2
 
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
4
- import { CONSENT_COOKIE, acceptAll, asks, read, region, rejectAll, save } from './consent'
4
+ import { CONSENT_COOKIE, CONSENT_VERSION_COOKIE, acceptAll, asks, notices, read, region, rejectAll, save, serve } from './consent'
5
5
  import { touch, capture } from './touch'
6
6
 
7
7
  const g = globalThis as Record<string, unknown>
@@ -42,6 +42,7 @@ function browser(zone: string, opts: { gpc?: boolean; cookie?: string; href?: st
42
42
  }
43
43
 
44
44
  afterEach(() => {
45
+ serve(null)
45
46
  for (const k of ['window', 'document', 'Event']) delete g[k]
46
47
  for (const [k, d] of Object.entries(original)) if (d) Object.defineProperty(globalThis, k, d)
47
48
  vi.restoreAllMocks()
@@ -77,9 +78,9 @@ describe('the default before a choice', () => {
77
78
  expect(asks()).toBe(false)
78
79
  })
79
80
 
80
- it('Global Privacy Control turns marketing off in the US', () => {
81
+ it('Global Privacy Control turns marketing and ads off in the US, analytics stays', () => {
81
82
  browser('America/Denver', { gpc: true })
82
- expect(read()).toEqual({ analytics: false, marketing: false, ads: false })
83
+ expect(read()).toEqual({ analytics: true, marketing: false, ads: false })
83
84
  })
84
85
 
85
86
  it('GPC wins over a stored yes', () => {
@@ -138,3 +139,59 @@ describe('click ids', () => {
138
139
  expect(touch({ analytics: true, marketing: false, ads: false })).not.toHaveProperty('gclid')
139
140
  })
140
141
  })
142
+
143
+ describe('the rule cloud serves', () => {
144
+ const rule = (mode: 'opt-in' | 'opt-out', version = 1) => ({
145
+ region: mode === 'opt-in' ? 'DE' : 'US',
146
+ mode,
147
+ version,
148
+ gpc: false,
149
+ defaults:
150
+ mode === 'opt-in'
151
+ ? { analytics: false, marketing: false, ads: false }
152
+ : { analytics: true, marketing: true, ads: true },
153
+ })
154
+
155
+ it('beats the time zone, both ways', () => {
156
+ browser('America/New_York')
157
+ serve(rule('opt-in'))
158
+ expect(region()).toBe('opt-in')
159
+ expect(asks()).toBe(true)
160
+ expect(read()).toEqual({ analytics: false, marketing: false, ads: false })
161
+ browser('Europe/Berlin')
162
+ serve(rule('opt-out'))
163
+ expect(asks()).toBe(false)
164
+ expect(notices()).toBe(true)
165
+ expect(read()).toEqual({ analytics: true, marketing: true, ads: true })
166
+ })
167
+
168
+ it('tells the page when it arrives, so a banner drawn early redraws', () => {
169
+ const b = browser('Europe/Berlin')
170
+ serve(rule('opt-in'))
171
+ expect(b.fired).toContain('hzpolicy')
172
+ })
173
+
174
+ it('a choice made under an older policy is asked again', () => {
175
+ const b = browser('Europe/Berlin', { cookie: 'analytics' })
176
+ b.jar.set(CONSENT_VERSION_COOKIE, '1')
177
+ serve(rule('opt-in', 1))
178
+ expect(asks()).toBe(false)
179
+ serve(rule('opt-in', 2))
180
+ expect(asks()).toBe(true)
181
+ expect(read().analytics).toBe(false)
182
+ })
183
+
184
+ it('a choice is stored with the version it was made under', () => {
185
+ const b = browser('Europe/Berlin')
186
+ serve(rule('opt-in', 3))
187
+ acceptAll()
188
+ expect(b.jar.get(CONSENT_VERSION_COOKIE)).toBe('3')
189
+ expect(asks()).toBe(false)
190
+ })
191
+
192
+ it('ignores an answer that is not a rule', () => {
193
+ browser('Europe/Berlin')
194
+ serve({ tags: [] })
195
+ expect(region()).toBe('opt-in')
196
+ })
197
+ })
package/src/consent.ts CHANGED
@@ -15,10 +15,16 @@
15
15
  // ads off in every region whatever is stored: California reads GPC as the opt-out
16
16
  // of sale and sharing.
17
17
  //
18
- // There is no geo lookup. The page cannot ask a server where the visitor is, so
19
- // the zone is the browser's own time zone, and a zone that is not a US zone is
20
- // treated as opt-in. Wrong in the safe direction: a US visitor on a foreign
21
- // clock is asked, and a European is never presumed to have agreed.
18
+ // The region comes from cloud. The site's tag config (`GET /v1/project/tags`) answers
19
+ // with the rule that binds this visitor, resolved at the edge from where the request
20
+ // came from (`serve`). Until it answers, and if it never does, the zone is the browser's
21
+ // own time zone and a zone that is not a US zone is treated as opt-in: wrong in the
22
+ // safe direction, a US visitor on a foreign clock is asked and a European is never
23
+ // presumed to have agreed.
24
+ //
25
+ // The policy has a version. A choice is stored beside the version it was made under
26
+ // (`hz_consent_v`); when the policy changes, the stored choice no longer counts and the
27
+ // visitor is asked again.
22
28
  //
23
29
  // The choice is a first-party cookie, `hz_consent`, on the registrable domain
24
30
  // for 13 months: the granted categories, comma separated. cloud reads the same
@@ -28,6 +34,12 @@ import { get, set } from './cookie'
28
34
 
29
35
  export const CONSENT_COOKIE = 'hz_consent'
30
36
 
37
+ /** The policy version the stored choice was made under. */
38
+ export const CONSENT_VERSION_COOKIE = 'hz_consent_v'
39
+
40
+ /** Fired on `window` when cloud's rule for this visitor arrives. */
41
+ export const POLICY_EVENT = 'hzpolicy'
42
+
31
43
  /** Fired on `window` whenever a choice is stored. */
32
44
  export const CONSENT_EVENT = 'hzconsent'
33
45
 
@@ -39,6 +51,40 @@ export interface Choice {
39
51
 
40
52
  export type Region = 'opt-in' | 'opt-out'
41
53
 
54
+ /** The rule cloud resolved for this visitor. */
55
+ export interface Policy {
56
+ region: string
57
+ mode: Region
58
+ defaults: Choice
59
+ gpc: boolean
60
+ version: number
61
+ notice?: string
62
+ }
63
+
64
+ let served: Policy | undefined
65
+
66
+ /** Takes cloud's rule for this visitor and tells the page, so a banner drawn early redraws. */
67
+ export function serve(p: unknown): void {
68
+ if (p == null) {
69
+ served = undefined
70
+ return
71
+ }
72
+ const d = p as Partial<Policy>
73
+ if ( (d.mode !== 'opt-in' && d.mode !== 'opt-out') || !d.defaults) return
74
+ served = {
75
+ region: String(d.region ?? ''),
76
+ mode: d.mode,
77
+ defaults: { analytics: !!d.defaults.analytics, marketing: !!d.defaults.marketing, ads: !!d.defaults.ads },
78
+ gpc: d.gpc === true,
79
+ version: Number(d.version) || 0,
80
+ notice: typeof d.notice === 'string' ? d.notice : undefined,
81
+ }
82
+ if (typeof window !== 'undefined') window.dispatchEvent(new Event(POLICY_EVENT))
83
+ }
84
+
85
+ /** The rule cloud resolved, once it has answered. */
86
+ export const policy = (): Policy | undefined => served
87
+
42
88
  type Nav = { doNotTrack?: string; msDoNotTrack?: string; globalPrivacyControl?: boolean }
43
89
 
44
90
  const US =
@@ -60,6 +106,7 @@ export function refused(): boolean {
60
106
 
61
107
  /** Which rule applies to this visitor. */
62
108
  export function region(): Region {
109
+ if (served) return served.mode
63
110
  try {
64
111
  const zone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? ''
65
112
  return US.test(zone) ? 'opt-out' : 'opt-in'
@@ -70,18 +117,23 @@ export function region(): Region {
70
117
 
71
118
  /** Whether the visitor has stored a choice. */
72
119
  export function stored(): boolean {
73
- return get(CONSENT_COOKIE) !== undefined
120
+ if (get(CONSENT_COOKIE) === undefined) return false
121
+ return !served || get(CONSENT_VERSION_COOKIE) === String(served.version)
74
122
  }
75
123
 
76
124
  /** The visitor's choice: the stored one, else the regional default, with GPC applied. */
77
125
  export function read(): Choice {
78
- const kept = get(CONSENT_COOKIE)
126
+ const kept = stored() ? get(CONSENT_COOKIE) : undefined
79
127
  const on =
80
128
  kept !== undefined
81
129
  ? kept.split(',')
82
- : region() === 'opt-in' || refused()
83
- ? []
84
- : ['analytics', 'marketing', 'ads']
130
+ : served
131
+ ? (['analytics', 'marketing', 'ads'] as const).filter((k) => served!.defaults[k])
132
+ : region() === 'opt-in'
133
+ ? []
134
+ : gpc()
135
+ ? ['analytics']
136
+ : ['analytics', 'marketing', 'ads']
85
137
  const has = (k: string) => on.includes(k)
86
138
  return { analytics: has('analytics'), marketing: has('marketing') && !gpc(), ads: has('ads') && !gpc() }
87
139
  }
@@ -91,6 +143,11 @@ export function asks(): boolean {
91
143
  return typeof document !== 'undefined' && region() === 'opt-in' && !stored()
92
144
  }
93
145
 
146
+ /** Whether a small notice must be shown: an opt-out visitor who has not yet seen it. */
147
+ export function notices(): boolean {
148
+ return typeof document !== 'undefined' && region() === 'opt-out' && !stored()
149
+ }
150
+
94
151
  /** The choice as the `consent` property and the cookie spell it. */
95
152
  export function render(c: Choice): string {
96
153
  return (['analytics', 'marketing', 'ads'] as const).filter((k) => c[k]).join(',')
@@ -106,6 +163,7 @@ export function render(c: Choice): string {
106
163
  export function save(c: Choice): void {
107
164
  const out: Choice = { analytics: c.analytics, marketing: c.marketing && !gpc(), ads: c.ads && !gpc() }
108
165
  set(CONSENT_COOKIE, render(out), 395)
166
+ set(CONSENT_VERSION_COOKIE, String(served?.version ?? 0), 395)
109
167
  if (typeof window !== 'undefined') window.dispatchEvent(new Event(CONSENT_EVENT))
110
168
  }
111
169
 
package/src/core.test.ts CHANGED
@@ -396,17 +396,17 @@ describe('Analytics capture', () => {
396
396
  expect(tx.sent[0].ingestKey).toBeUndefined()
397
397
  })
398
398
 
399
- it('a signed-in bearer WINS over a key from the build env', () => {
400
- // The leak this closes: one console bundle is served to several brands, and a
401
- // pk- names ONE org. If an env-sourced key displaced the bearer, every
402
- // signed-in user's events would re-file under whichever org minted the key.
399
+ it('a signed-in page sends the key AND the bearer; the bearer never replaces the key', () => {
400
+ // The person is the bearer and the project is the key. The server files the event
401
+ // under the key's project only when it belongs to the bearer's own org, so a key
402
+ // from the build env cannot re-file a person's events under another org.
403
403
  process.env.NEXT_PUBLIC_PUBLISHABLE_KEY = 'pk-live-one-org'
404
404
  try {
405
405
  const a = mk({ getToken: () => 'jwt-of-a-real-person' })
406
406
  a.capture('x')
407
407
  a.flush()
408
408
  expect(tx.sent[0].token).toBe('jwt-of-a-real-person')
409
- expect(tx.sent[0].ingestKey).toBeUndefined()
409
+ expect(tx.sent[0].ingestKey).toBe('pk-live-one-org')
410
410
  } finally {
411
411
  delete process.env.NEXT_PUBLIC_PUBLISHABLE_KEY
412
412
  }
package/src/core.ts CHANGED
@@ -105,6 +105,12 @@ function normalizeError(err: unknown): Exception {
105
105
  // unload listeners RN doesn't have), while `capture()`/`flush()` — which are NOT
106
106
  // gated on this and post through plain `fetch` — still emit. So the client is
107
107
  // inert-safe under SSR/Node and live on RN, without a second transport.
108
+ /** The screen and language a page view carries, for the readers that derive device and locale. */
109
+ const viewport = (): Record<string, string> =>
110
+ isBrowser()
111
+ ? { screen: `${window.screen?.width ?? 0}x${window.screen?.height ?? 0}`, language: navigator.language || '' }
112
+ : {}
113
+
108
114
  const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined'
109
115
 
110
116
  /** serializeBatch stringifies a batch, salvaging what it can. `properties` is
@@ -186,13 +192,15 @@ class DefaultTransport implements Transport {
186
192
  // for an origin that cannot pass one. A JWT is first-party by construction, so
187
193
  // it keeps the header and the session it travels with, and so does the error
188
194
  // plane, which names its own envelope type.
189
- const simple = opts.ingestKey !== undefined && opts.contentType === undefined
195
+ const simple = opts.ingestKey !== undefined && opts.token === undefined && opts.contentType === undefined
190
196
  const headers: Record<string, string> = {
191
197
  'Content-Type': simple ? BEACON_CONTENT_TYPE : (opts.contentType ?? 'application/json'),
192
198
  }
193
- const bearer = simple ? undefined : (opts.ingestKey ?? opts.token)
199
+ // A signed-in page sends the key (which project) in the query AND the person's bearer
200
+ // (who) in the header; the bearer never replaces the key.
201
+ const bearer = simple ? undefined : (opts.token ?? opts.ingestKey)
194
202
  if (bearer) headers.Authorization = `Bearer ${bearer}`
195
- void fetch(simple ? appendQuery(url, 'ingest_key', opts.ingestKey!) : url, {
203
+ void fetch(opts.ingestKey ? appendQuery(url, 'ingest_key', opts.ingestKey) : url, {
196
204
  method: 'POST',
197
205
  headers,
198
206
  body,
@@ -375,7 +383,7 @@ export class Analytics {
375
383
  const view = (p ?? '') + '\0' + (isBrowser() ? window.location.href : '')
376
384
  if (view === this.counted) return
377
385
  this.counted = view
378
- this.enqueue('pageview', PAGEVIEW, { path: p, properties })
386
+ this.enqueue('pageview', PAGEVIEW, { path: p, properties: { ...viewport(), ...properties } })
379
387
  }
380
388
 
381
389
  /** capture records a named product event with optional properties. Commerce
@@ -441,20 +449,10 @@ export class Analytics {
441
449
  this.queue = []
442
450
  this.clearTimer()
443
451
 
444
- // A publishable key and a bearer JWT are mutually exclusive credentials, and the
445
- // BEARER WINS. It names a real principal and resolves to THAT person's org;
446
- // a pk- names one org for everybody holding it. So the key is what attributes
447
- // a visitor nobody has vouched for, and it must never displace someone who
448
- // has been.
449
- //
450
- // The precedence used to run the other way, which was survivable only while
451
- // the key had to be passed in code. Once it also resolves from the build env,
452
- // key-wins means setting one variable silently blanks every signed-in user's
453
- // token and re-files their events under whichever org minted the key — on a
454
- // console served to several brands from one bundle, that is a cross-tenant
455
- // leak introduced by an env var.
452
+ // Two kinds of credential, and both ride when both are held: the publishable key
453
+ // names the project, the bearer names the person. The bearer never replaces the key.
456
454
  const token = this.cfg.getToken?.() ?? undefined
457
- const key = token ? undefined : this.cfg.ingestKey?.trim() || undefined
455
+ const key = this.cfg.ingestKey?.trim() || undefined
458
456
  // Only a bearer JWT blocks the beacon: sendBeacon cannot set an Authorization
459
457
  // header. A pk- rides ?ingest_key; a cookie rides credentials.
460
458
  const useBeacon = beacon && !token
package/src/index.ts CHANGED
@@ -24,12 +24,12 @@ export { EVENTS, EXCEPTION, PAGEVIEW } from './events'
24
24
  export { exceptionEntry, exceptionProperties, fingerprint, digest } from './exception'
25
25
  export type { EventName } from './events'
26
26
  export {
27
- CONSENT_COOKIE, CONSENT_EVENT, acceptAll, asks, gpc, read as readConsent, refused, region,
27
+ CONSENT_COOKIE, CONSENT_EVENT, CONSENT_VERSION_COOKIE, POLICY_EVENT, acceptAll, asks, gpc, notices, policy as consentPolicy, serve as serveConsent, read as readConsent, refused, region,
28
28
  rejectAll, render as renderConsent, save as saveConsent, stored as storedConsent,
29
29
  } from './consent'
30
- export type { Choice, Region } from './consent'
30
+ export type { Choice, Policy as ConsentRule, Region } from './consent'
31
31
  export { CLICK_IDS, capture as captureClick, touch } from './touch'
32
- export { start as startTags, track, mirror, reach, visit, SITE_KEY } from './tags'
32
+ export { start as startTags, ready as tagsReady, track, mirror, reach, visit } from './tags'
33
33
  export type { BrowserTag, TagOptions } from './tags'
34
34
  export { commerceItem, worth } from './items'
35
35
  export type { CommerceItem, PlanLine } from './items'
@@ -64,5 +64,5 @@ export type {
64
64
  * correctly while configuring nothing. `keyFor` takes an optional keyring so a
65
65
  * runtime that receives one (hanzo.id serves every brand from one image) resolves
66
66
  * through this same function rather than a second copy of it. */
67
- export { ORG_DOMAIN, ORG_KEY, orgOf, keyFor, keyForPage } from './org'
67
+ export { ORG_DOMAIN, ORG_KEY, SITE_KEY, siteKey, orgOf, keyFor, keyForPage } from './org'
68
68
  export type { Keyring } from './org'
package/src/link.test.ts CHANGED
@@ -204,11 +204,12 @@ describe('Analytics.link / authorize / identify', () => {
204
204
  })
205
205
  })
206
206
 
207
- describe('one ingest key across the journey', () => {
208
- it('hanzo.ai, hanzo.id, hanzo.app resolve to the same org key', () => {
207
+ describe('one visitor across the journey, each site its own project', () => {
208
+ it('www.hanzo.ai is hanzo.ai; every site owns a key; a runtime keyring stays per org', () => {
209
209
  const k = keyFor('hanzo.ai')
210
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)
211
+ expect(keyFor('www.hanzo.ai')).toBe(k)
212
+ for (const h of ['hanzo.id', 'hanzo.app', 'hanzo.team', 'hanzo.bot']) expect(keyFor(h)).not.toBe(k)
212
213
  const ring = { hanzo: 'pk-runtime' }
213
214
  expect(keyFor('hanzo.id', ring)).toBe(keyFor('hanzo.ai', ring))
214
215
  })
package/src/org.test.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, afterEach } from 'vitest'
2
2
 
3
3
  import { createAnalytics } from './core'
4
- import { ORG_KEY, keyFor, keyForPage, orgOf } from './org'
4
+ import { ORG_KEY, SITE_KEY, keyFor, keyForPage, orgOf, siteKey } from './org'
5
5
 
6
6
  const ENV = 'NEXT_PUBLIC_PUBLISHABLE_KEY'
7
7
 
@@ -45,7 +45,6 @@ describe('the org that owns a host', () => {
45
45
  'hanzo.codes',
46
46
  'hanzoskills.com',
47
47
  'hanzo.ventures',
48
- 'hanzo.team',
49
48
  ]) {
50
49
  expect(orgOf(host)).toBe('hanzo')
51
50
  expect(keyFor(host)).toBe(ORG_KEY.hanzo)
@@ -127,3 +126,33 @@ describe('a surface that configures nothing', () => {
127
126
  expect((none as unknown as { cfg: { ingestKey?: string } }).cfg.ingestKey).toBeUndefined()
128
127
  })
129
128
  })
129
+
130
+ describe('the hanzo org names its sites', () => {
131
+ it('names hanzo.network, hanzo.market, hanzo.industries and hanzo.agency, each with its own project key', () => {
132
+ for (const host of ['hanzo.network', 'hanzo.market', 'hanzo.industries', 'hanzo.agency']) {
133
+ expect(orgOf(host)).toBe('hanzo')
134
+ expect(keyFor(host)).toBe(SITE_KEY[host])
135
+ }
136
+ })
137
+ })
138
+
139
+ describe('a site files its events under its own project', () => {
140
+ it('each first-party site resolves to its own key, and its tag set follows', () => {
141
+ const keys = Object.values(SITE_KEY)
142
+ expect(keyFor('docs.hanzo.ai')).toBe(SITE_KEY['docs.hanzo.ai'])
143
+ expect(keyFor('hanzo.chat')).toBe(SITE_KEY['hanzo.chat'])
144
+ expect(keyFor('hanzo.team')).not.toBe(keyFor('hanzo.ai'))
145
+ expect(keyFor('hanzo.ai')).toBe(ORG_KEY.hanzo)
146
+ expect(keys.every((k) => k.startsWith('pk-'))).toBe(true)
147
+ })
148
+
149
+ it('www. and a hanzo.app subdomain reach their site', () => {
150
+ expect(siteKey('www.hanzo.ai')).toBe(SITE_KEY['hanzo.ai'])
151
+ expect(siteKey('demo.hanzo.app')).toBe(SITE_KEY['hanzo.app'])
152
+ expect(siteKey('evil-hanzo.ai')).toBeUndefined()
153
+ })
154
+
155
+ it('a host with no site of its own falls back to its org', () => {
156
+ expect(keyFor('hanzo.works')).toBe(ORG_KEY.hanzo)
157
+ })
158
+ })
package/src/org.ts CHANGED
@@ -93,11 +93,45 @@ export const ORG_DOMAIN: Readonly<Record<string, readonly string[]>> = Object.fr
93
93
  * recreate a project, move BOTH.
94
94
  */
95
95
  export const ORG_KEY: Keyring = Object.freeze({
96
- hanzo: 'pk-rM_CdaF2MQckGCrla113SrR1oH4zvqN8xh2I95Z9tY8',
96
+ hanzo: 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
97
97
  lux: 'pk-gUZp6ZVfhJzSwK-rb4oLbVkpCnMBx5uSCpxf_5yEhQk',
98
98
  zoo: 'pk-3TKpKnERV9AQSsBUERWkZejC1O1mUxc1jRzsP3MPbs4',
99
99
  })
100
100
 
101
+ /**
102
+ * The publishable key of each site's project, which is how cloud finds the site's
103
+ * tag set and the project its events are filed under. Each site is ONE project with its
104
+ * own key. A `pk-` is public by design (it ships in every page); it names a site,
105
+ * never a platform id. A host absent here falls back to its org's key for events and
106
+ * has no tag config unless `start` is given a key.
107
+ */
108
+ export const SITE_KEY: Readonly<Record<string, string>> = Object.freeze({
109
+ 'hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
110
+ 'www.hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
111
+ 'hanzo.app': 'pk-wlnXN2a9_vmCm60yTFtQ629Q8TyuaxBNZbY1RWT72gQ',
112
+ 'hanzo.team': 'pk-NCzD2FiHpZv8KUpkCX4olT1LJOJsMxBC_Z8NkiQsOFQ',
113
+ 'pay.hanzo.ai': 'pk-eX6kv7JZNoiYn1WkeJH3tT_8OvkVYScmCXnLMwxTKf8',
114
+ 'platform.hanzo.ai': 'pk-My1RpZLEUnTj8vAdPbWKYdDUuhxZJ4dVjHcwjZN4rZ8',
115
+ 'docs.hanzo.ai': 'pk-jukhtjMT2ymoeBDAeFjINQWlBv-v9sNn1TPztiCrrwk',
116
+ 'hanzo.bot': 'pk-W5d7Mn7ZukT7igyscIy6Pqe8JpA0Ge604Yn4xNR4JCU',
117
+ 'hanzo.chat': 'pk-ATxWeB2jNyRSxDgqnkGoDj3CCbzrmTxSktUh9zlrO-E',
118
+ 'hanzo.id': 'pk-_GVF3KlaqprlPHDumBMRFeFU1lIGOaarQfgimNVDIo8',
119
+ 'hanzo.industries': 'pk-Vh1wvL2HqIAv9qpVvmrznTI9uMHrTD-jyIC-vnlBsgY',
120
+ 'hanzo.market': 'pk-DN3xQFa0H61epUVg9OkiOYLzCdteNt9hE6f-S-ednjQ',
121
+ 'hanzo.network': 'pk-YJ2eiA_7ckLStMPte8Ar8FsmRp_ycmeIeRbpGprZYmM',
122
+ 'hanzo.agency': 'pk-26a2clOaJFe24aKJZ0PkI-GWR9F4EEa8rthHzgy_Efw',
123
+ 'console.hanzo.ai': 'pk-oFNXoiO1wOD8PqGdHYyiSq6jaNCdrd7TpztGpwM2ZiQ',
124
+ 'admin.hanzo.ai': 'pk-RYZwxkomW-tlAnEu2DKo_kAzcxFbSF2YiYcsUY48QL0',
125
+ 'bot.hanzo.ai': 'pk-W5d7Mn7ZukT7igyscIy6Pqe8JpA0Ge604Yn4xNR4JCU',
126
+ 'cloud.hanzo.ai': 'pk-RAfEGHPoNdCEU9fnA_cPd_Xo9Tci44rlYQV9xuJ1Ob0',
127
+ })
128
+
129
+ /** The project key of the site a host is: its own entry, `www.` stripped, a `*.hanzo.app` site under hanzo.app. */
130
+ export function siteKey(host: string): string | undefined {
131
+ const h = normalize(host).replace(/^www\./, '')
132
+ return SITE_KEY[h] ?? (h.endsWith('.hanzo.app') ? SITE_KEY['hanzo.app'] : undefined)
133
+ }
134
+
101
135
  /** `pk-` is publishable; `sk-` is not, and there is no third thing. Checked at the
102
136
  * one point a key becomes something a page will send, so a mistyped entry fails
103
137
  * closed rather than putting a secret in every visitor's tab. */
@@ -142,6 +176,10 @@ export function orgOf(host: string): string | undefined {
142
176
  * this SAME function rather than a second copy of it.
143
177
  */
144
178
  export function keyFor(host: string, keyring: Keyring = ORG_KEY): string | undefined {
179
+ if (keyring === ORG_KEY) {
180
+ const site = siteKey(host)
181
+ if (site) return site
182
+ }
145
183
  const org = orgOf(host)
146
184
  if (!org) return undefined
147
185
  const key = keyring[org]
package/src/react.tsx CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  } from 'react'
23
23
  import { Analytics, createAnalytics } from './core'
24
24
  import type { AnalyticsConfig } from './types'
25
- import { CONSENT_EVENT, read, render, type Choice } from './consent'
25
+ import { CONSENT_EVENT, POLICY_EVENT, read, render, type Choice } from './consent'
26
26
 
27
27
  const Ctx = createContext<Analytics | null>(null)
28
28
 
@@ -125,7 +125,11 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
125
125
 
126
126
  const listen = (on: () => void) => {
127
127
  window.addEventListener(CONSENT_EVENT, on)
128
- return () => window.removeEventListener(CONSENT_EVENT, on)
128
+ window.addEventListener(POLICY_EVENT, on)
129
+ return () => {
130
+ window.removeEventListener(CONSENT_EVENT, on)
131
+ window.removeEventListener(POLICY_EVENT, on)
132
+ }
129
133
  }
130
134
 
131
135
  /**
package/src/tags.ts CHANGED
@@ -32,7 +32,8 @@
32
32
  // reads the same table.
33
33
 
34
34
  import { namesOn } from '@hanzo/events'
35
- import { CONSENT_EVENT, read, render, type Choice } from './consent'
35
+ import { CONSENT_EVENT, read, render, serve, type Choice } from './consent'
36
+ import { siteKey } from './org'
36
37
  import { capture, touch } from './touch'
37
38
  import type { Analytics } from './core'
38
39
 
@@ -54,24 +55,6 @@ export interface TagOptions {
54
55
  domains?: string[]
55
56
  }
56
57
 
57
- /**
58
- * The publishable key of each site's project, which is how cloud finds the site's
59
- * tag set. A `pk-` is public by design (it ships in every page); it names a site,
60
- * never a platform id. A host absent here has no tag config unless `start` is
61
- * given a key.
62
- */
63
- export const SITE_KEY: Readonly<Record<string, string>> = Object.freeze({
64
- 'hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
65
- 'www.hanzo.ai': 'pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc',
66
- 'hanzo.app': 'pk-wlnXN2a9_vmCm60yTFtQ629Q8TyuaxBNZbY1RWT72gQ',
67
- 'hanzo.team': 'pk-NCzD2FiHpZv8KUpkCX4olT1LJOJsMxBC_Z8NkiQsOFQ',
68
- 'pay.hanzo.ai': 'pk-eX6kv7JZNoiYn1WkeJH3tT_8OvkVYScmCXnLMwxTKf8',
69
- 'platform.hanzo.ai': 'pk-My1RpZLEUnTj8vAdPbWKYdDUuhxZJ4dVjHcwjZN4rZ8',
70
- 'docs.hanzo.ai': 'pk-jukhtjMT2ymoeBDAeFjINQWlBv-v9sNn1TPztiCrrwk',
71
- 'hanzo.bot': 'pk-W5d7Mn7ZukT7igyscIy6Pqe8JpA0Ge604Yn4xNR4JCU',
72
- 'cloud.hanzo.ai': 'pk-RAfEGHPoNdCEU9fnA_cPd_Xo9Tci44rlYQV9xuJ1Ob0',
73
- })
74
-
75
58
  type Call = (...args: unknown[]) => void
76
59
  type Page = {
77
60
  dataLayer?: unknown[]
@@ -107,6 +90,29 @@ function flush(): void {
107
90
  if (settled() || leaving) held.splice(0).forEach((send) => send())
108
91
  }
109
92
 
93
+ /**
94
+ * Resolves once every tag this visitor's choice loads has taken the page view, or after
95
+ * `limit` ms, whichever is first: the tag set has answered, gtag.js has loaded or failed,
96
+ * and Meta's library has replaced the stub that queued the call. A page that is about to
97
+ * leave waits on this so the visit is counted where it arrived. A tag the choice does not
98
+ * load is not waited for.
99
+ */
100
+ export function ready(limit = 3000): Promise<void> {
101
+ if (typeof window === 'undefined') return Promise.resolve()
102
+ const start = Date.now()
103
+ const meta = () => {
104
+ const fbq = page().fbq
105
+ return ![...loaded].some((k) => k.startsWith('meta:')) || typeof fbq?.callMethod === 'function'
106
+ }
107
+ return new Promise((resolve) => {
108
+ const tick = () => {
109
+ if ((configured && settled() && meta()) || Date.now() - start >= limit) return resolve()
110
+ setTimeout(tick, 100)
111
+ }
112
+ tick()
113
+ })
114
+ }
115
+
110
116
  function script(src: string, onload?: () => void, onerror?: () => void): void {
111
117
  const s = document.createElement('script')
112
118
  s.async = true
@@ -300,7 +306,7 @@ export function start(o: TagOptions = {}): () => void {
300
306
  const base = (o.base ?? 'https://api.hanzo.ai').replace(/\/$/, '')
301
307
  const q = new URLSearchParams()
302
308
  const host = o.host ?? window.location.hostname
303
- const key = o.key ?? SITE_KEY[host]
309
+ const key = o.key ?? siteKey(host)
304
310
  if (key) q.set('key', key)
305
311
  q.set('host', host)
306
312
  const answer = (list: BrowserTag[]) => {
@@ -312,8 +318,9 @@ export function start(o: TagOptions = {}): () => void {
312
318
  const cap = setTimeout(() => answer([]), 3000)
313
319
  fetch(`${base}/v1/project/tags?${q}`)
314
320
  .then((r) => (r.ok ? r.json() : { tags: [] }))
315
- .then((j: { tags?: BrowserTag[] }) => {
321
+ .then((j: { tags?: BrowserTag[]; consent?: unknown }) => {
316
322
  clearTimeout(cap)
323
+ serve(j.consent)
317
324
  answer(Array.isArray(j.tags) ? j.tags : [])
318
325
  })
319
326
  .catch(() => {