@hanzo/event 0.3.50 → 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
@@ -15,13 +15,6 @@ export interface TagOptions {
15
15
  /** Domains one visit crosses, so GA4 keeps it one session. */
16
16
  domains?: string[];
17
17
  }
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.
23
- */
24
- export declare const SITE_KEY: Readonly<Record<string, string>>;
25
18
  /**
26
19
  * Resolves once every tag this visitor's choice loads has taken the page view, or after
27
20
  * `limit` ms, whichever is first: the tag set has answered, gtag.js has loaded or failed,
@@ -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,CAepD,CAAA;AAqCF;;;;;;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,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.50",
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/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, ready as tagsReady, 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)
@@ -128,11 +127,32 @@ describe('a surface that configures nothing', () => {
128
127
  })
129
128
  })
130
129
 
131
- describe('the hanzo org files under the hanzo.ai project', () => {
132
- it('names hanzo.network, hanzo.market, hanzo.industries and hanzo.agency', () => {
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', () => {
133
132
  for (const host of ['hanzo.network', 'hanzo.market', 'hanzo.industries', 'hanzo.agency']) {
134
133
  expect(orgOf(host)).toBe('hanzo')
135
- expect(keyFor(host)).toBe('pk-CmfLA2K6kvsPflrS9DSkt06H_kSoQB_21sjedt6VJdc')
134
+ expect(keyFor(host)).toBe(SITE_KEY[host])
136
135
  }
137
136
  })
138
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
@@ -98,6 +98,40 @@ export const ORG_KEY: Keyring = Object.freeze({
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,29 +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
- 'hanzo.chat': 'pk-ATxWeB2jNyRSxDgqnkGoDj3CCbzrmTxSktUh9zlrO-E',
73
- 'hanzo.id': 'pk-_GVF3KlaqprlPHDumBMRFeFU1lIGOaarQfgimNVDIo8',
74
- 'hanzo.industries': 'pk-Vh1wvL2HqIAv9qpVvmrznTI9uMHrTD-jyIC-vnlBsgY',
75
- 'hanzo.market': 'pk-DN3xQFa0H61epUVg9OkiOYLzCdteNt9hE6f-S-ednjQ',
76
- 'hanzo.network': 'pk-YJ2eiA_7ckLStMPte8Ar8FsmRp_ycmeIeRbpGprZYmM',
77
- 'cloud.hanzo.ai': 'pk-RAfEGHPoNdCEU9fnA_cPd_Xo9Tci44rlYQV9xuJ1Ob0',
78
- })
79
-
80
58
  type Call = (...args: unknown[]) => void
81
59
  type Page = {
82
60
  dataLayer?: unknown[]
@@ -328,7 +306,7 @@ export function start(o: TagOptions = {}): () => void {
328
306
  const base = (o.base ?? 'https://api.hanzo.ai').replace(/\/$/, '')
329
307
  const q = new URLSearchParams()
330
308
  const host = o.host ?? window.location.hostname
331
- const key = o.key ?? SITE_KEY[host]
309
+ const key = o.key ?? siteKey(host)
332
310
  if (key) q.set('key', key)
333
311
  q.set('host', host)
334
312
  const answer = (list: BrowserTag[]) => {
@@ -340,8 +318,9 @@ export function start(o: TagOptions = {}): () => void {
340
318
  const cap = setTimeout(() => answer([]), 3000)
341
319
  fetch(`${base}/v1/project/tags?${q}`)
342
320
  .then((r) => (r.ok ? r.json() : { tags: [] }))
343
- .then((j: { tags?: BrowserTag[] }) => {
321
+ .then((j: { tags?: BrowserTag[]; consent?: unknown }) => {
344
322
  clearTimeout(cap)
323
+ serve(j.consent)
345
324
  answer(Array.isArray(j.tags) ? j.tags : [])
346
325
  })
347
326
  .catch(() => {