mirror of
https://github.com/hanzoai/id.git
synced 2026-10-16 18:38:15 +00:00
Load no browser tag on identity hosts, and start the stream only under the policy in force
The portal no longer starts @hanzo/event's tag manager. Once a stored or new choice allows Analytics, it asks cloud for the brand's consent policy with the brand's key and no credential, hands only the policy to @hanzo/event, and leaves the tags cloud lists beside it unloaded. The event client starts after the policy arrives and only if the choice still counts under it, so an Accept stored under an older policy, a refusal cloud saw, or no answer at all starts nothing and stores nothing, and the banner asks again. The page reloads only when the visitor withdraws Analytics after the client started, on the choice they store, never on a policy's arrival. The completed sign-up goes to the stream with the visitor's consent stamped on it, for cloud to forward server-side. The spec lists Google's and Meta's tags for every key and holds every identity host to loading none, and covers a newer policy, a refusal cloud saw and GPC over a stored Accept: one load, the form, nothing sent or kept. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -2,13 +2,14 @@
|
||||
* The cookie choice as a visitor meets it, on each identity host: nothing that
|
||||
* measures before Accept or after Reject, nothing for a browser that refuses,
|
||||
* and after Accept the brand's event stream on the brand's own key — and no
|
||||
* Google or Meta tag at all, because each identity host's project holds none.
|
||||
* Google or Meta tag at all, though cloud lists both for every key here: the
|
||||
* portal reads only the consent policy from cloud's answer.
|
||||
*
|
||||
* Every request the page makes to its own host is answered from `dist/` (the
|
||||
* SPA fallback included) and `/v1/iam` from an IAM double. Every request that
|
||||
* leaves the host is recorded, and each test holds them to the set the page may
|
||||
* send at that moment: the brand mark from its CDN always, cloud's tag config
|
||||
* and event ingest once the visitor accepts. A request to a measuring host is
|
||||
* send at that moment: the brand mark from its CDN always, cloud's policy and
|
||||
* event ingest once the visitor accepts. A request to a measuring host is
|
||||
* answered empty, so it is counted and goes no further.
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
@@ -36,20 +37,11 @@ const BRANDS = [
|
||||
type Brand = (typeof BRANDS)[number]
|
||||
const brand = (host: Brand['host']) => BRANDS.find((x) => x.host === host)!
|
||||
|
||||
/**
|
||||
* cloud's tag sets as production answers them: the hanzo.id SITE project holds
|
||||
* Hanzo's GA4 and Meta pixel, and every identity host's runtime key names a
|
||||
* project with none. A page that asked with the site key would load both.
|
||||
*/
|
||||
const HANZO_SITE = 'pk-_GVF3KlaqprlPHDumBMRFeFU1lIGOaarQfgimNVDIo8'
|
||||
function tagsFor(key: string | null): unknown[] {
|
||||
if (key === HANZO_SITE)
|
||||
return [
|
||||
{ platform: 'ga4', type: 'ga', id: 'G-EKBNQ969GE' },
|
||||
{ platform: 'meta', type: 'meta', id: '1790308611893001' },
|
||||
]
|
||||
return []
|
||||
}
|
||||
/** Google's and Meta's tags, listed beside the policy for every key; none may load. */
|
||||
const TAGS = [
|
||||
{ platform: 'ga4', type: 'ga', id: 'G-EKBNQ969GE' },
|
||||
{ platform: 'meta', type: 'meta', id: '1790308611893001' },
|
||||
]
|
||||
|
||||
/** A host that measures: Google, Meta, and the retired native tags. */
|
||||
const MEASURES = /googletagmanager\.com|google-analytics\.com|(^|\.)facebook\.(com|net)$|analytics\.hanzo\.ai|insights\.hanzo\.ai/
|
||||
@@ -97,7 +89,14 @@ function iam(path: string, b: Brand): unknown {
|
||||
return { status: 'ok', data: {} }
|
||||
}
|
||||
|
||||
async function serve(context: BrowserContext, b: Brand, refuses?: 'gpc' | 'dnt'): Promise<Seen> {
|
||||
/** What the browser refuses, and the policy cloud serves: its version, and whether it saw a refusal. */
|
||||
interface Visit {
|
||||
refuses?: 'gpc' | 'dnt'
|
||||
version?: number
|
||||
refused?: boolean
|
||||
}
|
||||
|
||||
async function serve(context: BrowserContext, b: Brand, { refuses, version = 1, refused = false }: Visit = {}): Promise<Seen> {
|
||||
if (!existsSync(join(DIST, 'index.html'))) throw new Error('no dist/ — run `pnpm --filter @hanzo/id-web build` first')
|
||||
const seen: Seen = { phase: 'load', off: [], events: [], configs: [] }
|
||||
// A person's browser, not an automated one: @hanzo/event loads no tag for a webdriver.
|
||||
@@ -121,8 +120,8 @@ async function serve(context: BrowserContext, b: Brand, refuses?: 'gpc' | 'dnt')
|
||||
return route.fulfill({
|
||||
headers: CORS(origin),
|
||||
json: {
|
||||
tags: tagsFor(url.searchParams.get('key')),
|
||||
consent: { region: '', mode: 'opt-in', defaults: { analytics: false, marketing: false, ads: false }, gpc: false, version: 1 },
|
||||
tags: TAGS,
|
||||
consent: { region: '', mode: 'opt-in', defaults: { analytics: false, marketing: false, ads: false }, refused, version },
|
||||
audience: 'person',
|
||||
},
|
||||
})
|
||||
@@ -212,7 +211,7 @@ function nothing(seen: Seen) {
|
||||
expect(seen.events, 'and no event at all').toEqual([])
|
||||
}
|
||||
|
||||
/** A choice made on this host before: what the banner stores on Accept. */
|
||||
/** A choice made on this host before, under policy 2: what the banner stores on Accept. */
|
||||
async function accepted(context: BrowserContext, b: Brand) {
|
||||
const url = `https://${b.host}`
|
||||
await context.addCookies([
|
||||
@@ -221,6 +220,13 @@ async function accepted(context: BrowserContext, b: Brand) {
|
||||
])
|
||||
}
|
||||
|
||||
/** Counts the documents the tab loads, the first one included. */
|
||||
function documents(page: Page): () => number {
|
||||
let n = 0
|
||||
page.on('framenavigated', (f) => void (f === page.mainFrame() && n++))
|
||||
return () => n
|
||||
}
|
||||
|
||||
/** No Google or Meta tag: an identity host's project holds none. */
|
||||
function untagged(seen: Seen) {
|
||||
expect(
|
||||
@@ -264,7 +270,7 @@ for (const b of BRANDS.filter((x) => x.asked)) {
|
||||
|
||||
for (const refuses of ['gpc', 'dnt'] as const) {
|
||||
test(`${b.host}: a browser sending ${refuses.toUpperCase()} is not asked and nothing measures`, async ({ context, page }) => {
|
||||
const seen = await serve(context, b, refuses)
|
||||
const seen = await serve(context, b, { refuses })
|
||||
await open(page, b)
|
||||
await expect(page.locator('[data-slot="consent"]')).toHaveCount(0)
|
||||
await fault(page)
|
||||
@@ -301,12 +307,11 @@ for (const b of BRANDS.filter((x) => x.asked)) {
|
||||
await expect.poll(() => seen.events.length, { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
|
||||
// The visitor changes their mind from "Cookie settings".
|
||||
let documents = 0
|
||||
page.on('framenavigated', (f) => void (f === page.mainFrame() && documents++))
|
||||
const loaded = documents(page)
|
||||
seen.phase = 'withdrawn'
|
||||
await page.locator('[data-slot="consent-link"]').click()
|
||||
await page.getByRole('button', { name: 'Reject all' }).click()
|
||||
await expect.poll(() => documents, 'the page starts over without its client').toBe(1)
|
||||
await expect.poll(loaded, 'the page starts over without its client').toBe(1)
|
||||
await expect(page.locator('.id-brand-footer')).toBeVisible()
|
||||
await fault(page)
|
||||
await page.waitForTimeout(8000)
|
||||
@@ -316,6 +321,34 @@ for (const b of BRANDS.filter((x) => x.asked)) {
|
||||
expect(await kept(context, page)).toEqual([])
|
||||
})
|
||||
|
||||
/**
|
||||
* A choice stored before, and cloud's policy now: a newer version, a refusal
|
||||
* cloud saw on the request, or GPC in the browser. None of them is a choice
|
||||
* the visitor made on this page, so the page loads once and stays: the form
|
||||
* renders, nothing is stored or sent, and only a newer policy asks again.
|
||||
*/
|
||||
for (const [what, visit, asks] of [
|
||||
['a newer policy', { version: 3 }, true],
|
||||
['a refusal cloud saw', { version: 2, refused: true }, false],
|
||||
['GPC in the browser', { refuses: 'gpc' }, false],
|
||||
] as const) {
|
||||
test(`${b.host}: an Accept stored before, then ${what}: one load, the form, nothing sent`, async ({ context, page }) => {
|
||||
const seen = await serve(context, b, visit)
|
||||
await accepted(context, b)
|
||||
const loaded = documents(page)
|
||||
await open(page, b)
|
||||
await page.waitForTimeout(3000)
|
||||
expect(loaded(), 'the page does not reload itself').toBeLessThanOrEqual(2)
|
||||
await expect(page.locator('main h1')).toHaveText('Sign in')
|
||||
await expect(page.locator('input[autocomplete=username]')).toBeVisible()
|
||||
await expect(page.locator('[data-slot="consent"]')).toHaveCount(asks ? 1 : 0)
|
||||
await fault(page)
|
||||
nothing(seen)
|
||||
expect(await kept(context, page), 'no visitor id, session or cohort was written').toEqual([])
|
||||
expect(unexpected(seen, (o) => mark(o) || (cloud(o) && o.url.pathname === '/v1/project/tags'))).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
for (const fails of ['refused', 'replaced'] as const) {
|
||||
test(`${b.host}: the sign-in stands when the banner's chunk is ${fails}`, async ({ context, page }) => {
|
||||
const seen = await serve(context, b)
|
||||
@@ -425,10 +458,9 @@ test('hanzo.id: a completed sign-up is ONE moment on the stream, with its consen
|
||||
.filter((e) => e.event === 'signup_completed')
|
||||
expect(done, 'counted once on the stream').toHaveLength(1)
|
||||
const p = done[0]!.properties!
|
||||
expect(p.event_id).toBeTruthy()
|
||||
expect(p.consent, 'with the choice it was made under, for cloud to forward on').toBe('analytics,marketing,ads')
|
||||
expect(p.tags, 'no browser tag heard it').toBe('')
|
||||
expect(p.url, 'the address leaves as its path').toBe(`https://${b.host}/signup`)
|
||||
expect(p.method).toBe('email')
|
||||
expect(done[0]!.url, 'the address leaves as its path').toBe(`https://${b.host}/signup`)
|
||||
untagged(seen)
|
||||
expect(unexpected(seen, (o) => mark(o) || cloud(o) || o.url.hostname === 'hanzo.ai')).toEqual([])
|
||||
})
|
||||
|
||||
@@ -113,18 +113,6 @@ test('a location leaves as its path, in every field that holds one', () => {
|
||||
assert.equal(e.path, '/signup', 'the part a funnel reads is untouched')
|
||||
})
|
||||
|
||||
test('the address track() stamps into a moment\'s properties leaves as its path too', () => {
|
||||
const e = first(
|
||||
bare(
|
||||
batch({
|
||||
url: 'https://lux.id/signup?state=S',
|
||||
properties: { url: 'https://lux.id/signup?client_id=C&code_challenge=X#f', method: 'email' },
|
||||
}),
|
||||
),
|
||||
)
|
||||
assert.deepEqual(e.properties, { url: 'https://lux.id/signup', method: 'email' })
|
||||
})
|
||||
|
||||
test('an address with nothing to drop is unchanged, and an absent one stays absent', () => {
|
||||
const e = first(bare(batch({ url: 'https://lux.id/login', path: '/login' })))
|
||||
assert.equal(e.url, 'https://lux.id/login')
|
||||
|
||||
+60
-46
@@ -1,14 +1,17 @@
|
||||
// Telemetry for the sign-in portal — pageviews, the sign-up funnel and errors,
|
||||
// anonymous, via the ONE @hanzo/event client (POST /v1/event, the front door
|
||||
// cloud fans out into the web / product / error lenses), and the brand's browser
|
||||
// tags through @hanzo/event's tag manager. index.html carries no tag of its own.
|
||||
// cloud fans out into the web / product / error lenses). No browser tag loads
|
||||
// here, from index.html or from @hanzo/event's tag manager, which this portal
|
||||
// never starts: an identity page carries codes, invites and OIDC requests in its
|
||||
// address, and an ad platform's tag reads the address itself. A moment that
|
||||
// counts for an ad platform, a completed sign-up, rides the stream with the
|
||||
// visitor's consent stamped on it, and cloud forwards it server-side.
|
||||
//
|
||||
// Nothing here runs before the visitor chooses. The choice is @hanzo/event's
|
||||
// (`hz_consent`, drawn by the @hanzo/ui banner App mounts): until it allows
|
||||
// Analytics there is no client, and until it allows Analytics or Marketing the
|
||||
// tag manager is not started, so not even the brand's tag config is fetched.
|
||||
// Global Privacy Control and Do Not Track are a refusal the library reads
|
||||
// itself, so a browser sending either is never asked and nothing starts.
|
||||
// Analytics, under the policy cloud serves now, there is no client. Global
|
||||
// Privacy Control and Do Not Track are a refusal the library reads itself, so a
|
||||
// browser sending either is never asked and nothing starts.
|
||||
//
|
||||
// Attribution is anonymous until an account exists. There is exactly ONE moment
|
||||
// that changes — a completed sign-up, where `Signup` calls `identify()` with the
|
||||
@@ -27,7 +30,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createAnalytics, ORG_DOMAIN, startTags, type Transport } from '@hanzo/event'
|
||||
import { CONSENT_EVENT, createAnalytics, readConsent, serveConsent, type Transport } from '@hanzo/event'
|
||||
import { AnalyticsProvider as EventProvider, useConsent, usePageview } from '@hanzo/event/react'
|
||||
import { ingestKey, loadRuntime, resolveOrg } from '@hanzo/id-shared'
|
||||
|
||||
@@ -81,23 +84,17 @@ export function telemetryAllowed(pathname: string): boolean {
|
||||
* first-touch attribution persists it — so one such arrival stamps the request
|
||||
* on every event for the rest of the visit, `url` or no `url`.
|
||||
*
|
||||
* And the `url` PROPERTY: `track()` stamps the page's full address into the
|
||||
* properties of the moment it states, beside the two fields above.
|
||||
*
|
||||
* The client's own redaction reaches some of these and not the rest: it works
|
||||
* by NAME, so `state`, `nonce` and `code_challenge` leave as `[redacted]` while
|
||||
* `client_id` and `redirect_uri` ride through untouched. See
|
||||
* funnel.wire.test.tsx, which sends the same view with and without this.
|
||||
*/
|
||||
export function bare(body: string): string {
|
||||
const wire = JSON.parse(body) as {
|
||||
batch: { url?: string; referrer?: string; properties?: { url?: unknown } }[]
|
||||
}
|
||||
const wire = JSON.parse(body) as { batch: { url?: string; referrer?: string }[] }
|
||||
const path = (u: string) => u.split(/[?#]/)[0]!
|
||||
for (const e of wire.batch) {
|
||||
if (e.url) e.url = path(e.url)
|
||||
if (e.referrer) e.referrer = path(e.referrer)
|
||||
if (typeof e.properties?.url === 'string') e.properties.url = path(e.properties.url)
|
||||
}
|
||||
return JSON.stringify(wire)
|
||||
}
|
||||
@@ -209,7 +206,7 @@ function silent(): Client {
|
||||
* another brand's key: that is silent, it reads as working, and it is only
|
||||
* visible later in a tenant that cannot explain the traffic.
|
||||
*/
|
||||
export async function brandKey(host: string): Promise<{ org: string; key?: string }> {
|
||||
export async function brandKey(host: string): Promise<string | undefined> {
|
||||
const runtime = await loadRuntime()
|
||||
const org = resolveOrg(host, { catalog: runtime.catalog }).orgId
|
||||
const key = ingestKey(org, runtime.keyring)
|
||||
@@ -218,24 +215,40 @@ export async function brandKey(host: string): Promise<{ org: string; key?: strin
|
||||
`[event] ${host} resolves to org "${org}" and no ingest key names it — this host reports nothing rather than reporting as another brand`,
|
||||
)
|
||||
}
|
||||
return { org, key }
|
||||
return key
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the client and starts the tag manager for THIS BRAND, once the visitor
|
||||
* allows it.
|
||||
* Hands @hanzo/event the consent policy cloud serves for the brand's project,
|
||||
* and reports whether one arrived.
|
||||
*
|
||||
* ONE key for both. The stream files its events under the brand's key, and the
|
||||
* tag manager asks cloud for the tag set of the project that same key names
|
||||
* (`GET /v1/project/tags?key=`), so a brand's page loads that brand's GA4 and
|
||||
* pixels and no other's. A host with no key starts neither.
|
||||
* The policy rides cloud's tag config (`GET /v1/project/tags`); only its
|
||||
* `consent` is read, and the tags it lists are never loaded. No credential goes
|
||||
* with it. A choice counts only under the policy in force, so a stored Accept
|
||||
* from an older policy, or a refusal cloud saw on the request, is undone here
|
||||
* before anything is stored or sent, and the banner asks again.
|
||||
*/
|
||||
async function served(key: string, host: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${HOST}/v1/project/tags?${new URLSearchParams({ key, host })}`, { credentials: 'omit' })
|
||||
if (!res.ok) return false
|
||||
const body = (await res.json()) as { consent?: unknown }
|
||||
if (body.consent == null) return false
|
||||
serveConsent(body.consent)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts the client for THIS BRAND, once the visitor allows Analytics under the
|
||||
* policy cloud serves.
|
||||
*
|
||||
* The visitor's choice decides each half: the stream needs Analytics, the tag
|
||||
* manager needs Analytics or Marketing and then loads only the tags the choice
|
||||
* covers. A route whose URL carries a credential starts neither, whatever was
|
||||
* chosen — the tags would read the address too. GA4's cross-domain linker gets
|
||||
* the brand's own domains, so a visit that crosses from the brand's site to its
|
||||
* identity host stays one session and never joins another brand's.
|
||||
* The client files its events under the brand's key, and the policy is asked
|
||||
* for with the same key. A host with no key asks nothing and starts nothing, and
|
||||
* neither does a route whose URL carries a credential, whatever was chosen. No
|
||||
* policy arriving — cloud unreachable — starts nothing either.
|
||||
*
|
||||
* The provider is mounted from the first render with an INERT client and handed
|
||||
* the real one once the key is known. `AnalyticsProvider` builds its instance
|
||||
@@ -258,39 +271,40 @@ export function Analytics({ children }: { children: ReactNode }) {
|
||||
// full-page: the path a document is loaded at is the path it dies at, so there
|
||||
// is no window in which a `/callback` load is measured under an earlier
|
||||
// route's decision.
|
||||
const route = telemetryAllowed(pathname)
|
||||
const measured = route && choice.analytics
|
||||
const tagged = route && (choice.analytics || choice.marketing)
|
||||
const measured = telemetryAllowed(pathname) && choice.analytics
|
||||
|
||||
const [client, setClient] = useState<Client>(silent)
|
||||
const [live, setLive] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tagged) return
|
||||
if (!measured) return
|
||||
let cancelled = false
|
||||
let stop: (() => void) | undefined
|
||||
const host = window.location.hostname
|
||||
void brandKey(host).then(({ org, key }) => {
|
||||
void (async () => {
|
||||
const key = await brandKey(host)
|
||||
if (cancelled || !key) return
|
||||
const domains = ORG_DOMAIN[org]
|
||||
stop = startTags({ key, host, domains: domains && [...domains] })
|
||||
if (!measured) return
|
||||
if (!(await served(key, host)) || cancelled || !readConsent().analytics) return
|
||||
setClient(createAnalytics({ product: PRODUCT, host: HOST, ingestKey: key, enabled: true, transport }))
|
||||
setLive(true)
|
||||
})
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
stop?.()
|
||||
}
|
||||
}, [tagged, measured])
|
||||
}, [measured])
|
||||
|
||||
// A withdrawal of Analytics after the client started reloads the page once,
|
||||
// as the tag manager does for a tag that ran: the client keeps its error and
|
||||
// unload listeners for the life of the document, and only a new document,
|
||||
// which starts none, is sure to send nothing.
|
||||
// The visitor withdrawing Analytics after the client started reloads the page
|
||||
// once: the client keeps its error and unload listeners for the life of the
|
||||
// document, and only a new document, which starts none, is sure to send
|
||||
// nothing. Only a choice the visitor stores does this — a policy arriving
|
||||
// never does, so a policy cloud bumps cannot set the page reloading.
|
||||
useEffect(() => {
|
||||
if (live && !measured) window.location.reload()
|
||||
}, [live, measured])
|
||||
if (!live) return
|
||||
const withdrawn = () => {
|
||||
if (!readConsent().analytics) window.location.reload()
|
||||
}
|
||||
window.addEventListener(CONSENT_EVENT, withdrawn)
|
||||
return () => window.removeEventListener(CONSENT_EVENT, withdrawn)
|
||||
}, [live])
|
||||
|
||||
return (
|
||||
<EventProvider client={client}>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// stream, and every later caller is handed it with the FIRST caller's key,
|
||||
// transport and identity. On a page that is the point: one host, one key.
|
||||
// Across test documents it is how hanzo.id reported on Lux's key.
|
||||
import { rejectAll, resetClients } from '@hanzo/event'
|
||||
import { rejectAll, resetClients, serveConsent } from '@hanzo/event'
|
||||
import { resetRuntime } from '@hanzo/id-shared'
|
||||
|
||||
export function newDocument(): void {
|
||||
@@ -22,11 +22,13 @@ export function newDocument(): void {
|
||||
|
||||
/**
|
||||
* A visitor who has not chosen: what @hanzo/event stored for the last one is
|
||||
* withdrawn (a refusal forgets every id and touch it wrote), and the choice
|
||||
* itself is expired, under whichever name the page's protocol gave it.
|
||||
* withdrawn (a refusal forgets every id and touch it wrote), the choice itself
|
||||
* is expired under whichever name the page's protocol gave it, and the policy
|
||||
* cloud served the last document is dropped.
|
||||
*/
|
||||
export function newVisitor(): void {
|
||||
rejectAll()
|
||||
serveConsent(null)
|
||||
for (const name of ['hz_consent', 'hz_consent_v', '__Host-hz_consent', '__Host-hz_consent_v']) {
|
||||
document.cookie = `${name}=; Max-Age=0; Path=/; Secure`
|
||||
}
|
||||
|
||||
@@ -128,9 +128,12 @@ function wire() {
|
||||
if (String(url).includes('/config.json')) {
|
||||
return { ok: true, json: async () => SERVED } as unknown as Response
|
||||
}
|
||||
// The brand's tag set: none, so the moments reach the stream alone.
|
||||
// The policy cloud serves for the brand's project; the client starts once it is in.
|
||||
if (String(url).includes('/v1/project/tags')) {
|
||||
return { ok: true, json: async () => ({ tags: [], audience: 'person' }) } as unknown as Response
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ tags: [], consent: { mode: 'opt-in', version: 1 }, audience: 'person' }),
|
||||
} as unknown as Response
|
||||
}
|
||||
const auth = (init?.headers as Record<string, string> | undefined)?.Authorization
|
||||
record(String(init?.body ?? '{}'), auth?.replace(/^Bearer /, ''))
|
||||
@@ -237,9 +240,9 @@ async function mount(outcome: Outcome = 'ok', enableSignUp = true): Promise<HTML
|
||||
</Analytics>
|
||||
</StrictMode>,
|
||||
)
|
||||
// The runtime config, then the policy, then the live client.
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve()
|
||||
})
|
||||
return container
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* The account exists before the completion is stated, so stating it can never
|
||||
* be what keeps the person on this page: a tag that throws inside `track()`
|
||||
* (a broken vendor build, an extension's shim) still lets the browser leave for
|
||||
* the app that asked.
|
||||
*/
|
||||
import { afterEach, test, vi } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import type { Brand } from '@hanzo/id-shared'
|
||||
import { createAuthClient } from '@hanzo/id-auth'
|
||||
import { Signup } from './Signup'
|
||||
|
||||
vi.mock('@hanzo/event', async (actual) => ({
|
||||
...(await actual<typeof import('@hanzo/event')>()),
|
||||
track: () => {
|
||||
throw new Error('pixel threw')
|
||||
},
|
||||
}))
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const ORG = {
|
||||
orgId: 'hanzo',
|
||||
iamUrl: 'https://hanzo.id',
|
||||
iamIssuer: 'https://hanzo.id',
|
||||
clientId: 'hanzo-console',
|
||||
appName: 'hanzo-console',
|
||||
publicOrigin: 'https://hanzo.id',
|
||||
brandPackage: '@hanzo/brand',
|
||||
} as Parameters<typeof createAuthClient>[0]['org']
|
||||
|
||||
const BRAND = { name: 'Hanzo', logoUrl: '', faviconUrl: '' } as unknown as Brand
|
||||
const REDIRECT = 'https://hanzo.ai/auth/callback'
|
||||
|
||||
/** IAM registers the account and mints the code the app asked for. */
|
||||
const iam = (async (input: RequestInfo | URL) => {
|
||||
const path = new URL(input.toString()).pathname
|
||||
const body =
|
||||
path === '/v1/iam/signup'
|
||||
? { status: 'ok', data: { id: 'u-1', owner: 'hanzo', name: 'ada' } }
|
||||
: path === '/v1/iam/login'
|
||||
? { status: 'ok', data: 'c-minted' }
|
||||
: { status: 'ok', data: { enableSignUp: true } }
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
test('a tag that throws at completion does not keep the new account on this page', async () => {
|
||||
const request = new URLSearchParams({ client_id: 'hanzo-app', redirect_uri: REDIRECT, response_type: 'code', state: 'S' })
|
||||
;(window as unknown as { happyDOM: { setURL(u: string): void } }).happyDOM.setURL(`https://hanzo.id/signup?${request}`)
|
||||
const here = window.location.href
|
||||
const gone: string[] = []
|
||||
Object.defineProperty(window.location, 'href', { configurable: true, get: () => here, set: (u: string) => void gone.push(u) })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const { container } = render(<Signup client={createAuthClient({ org: ORG, fetchImpl: iam })} brand={BRAND} />)
|
||||
await waitFor(() => assert.ok(container.querySelector('input[type=password]')))
|
||||
await act(async () => {
|
||||
fireEvent.change(container.querySelector('input[type=email]')!, { target: { value: '[email protected]' } })
|
||||
fireEvent.change(container.querySelector('input[type=password]')!, { target: { value: 'correct horse battery staple' } })
|
||||
})
|
||||
await act(async () => void fireEvent.click(container.querySelector('button[type=submit]')!))
|
||||
|
||||
await waitFor(() => assert.equal(gone.length, 1, 'the browser leaves for the app'))
|
||||
assert.ok(gone[0]!.startsWith(REDIRECT), `to the app that asked, got ${gone[0]}`)
|
||||
assert.equal(container.querySelector('[role=alert]')?.textContent ?? '', '', 'and no error is shown')
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EVENTS, track } from '@hanzo/event'
|
||||
import { EVENTS } from '@hanzo/event'
|
||||
import { useAnalytics } from '@hanzo/event/react'
|
||||
import type { Brand } from '@hanzo/id-shared'
|
||||
import { EmailConfirmForm, SignupForm, SocialButtons, type AuthClient, type LoginResponse } from '@hanzo/id-auth'
|
||||
@@ -160,16 +160,10 @@ export function Signup({ client, brand }: { client: AuthClient; brand: Brand })
|
||||
// The subject alone — an opaque IAM id, no traits. The address is on
|
||||
// the form a few lines above and has no business on this wire.
|
||||
if (subject) analytics.identify(subject)
|
||||
// ONE moment, under one event id, to our stream and to each of the
|
||||
// brand's tags the visitor's choice allows (GA4 `sign_up`, Meta
|
||||
// `CompleteRegistration`), so no platform counts it twice. The
|
||||
// account exists already and the browser leaves for it next, so a
|
||||
// tag that throws here must not keep it on this page.
|
||||
try {
|
||||
track(analytics, EVENTS.SIGNUP_COMPLETED, { method: 'email' })
|
||||
} catch (err) {
|
||||
console.error('[event] signup_completed was not stated', err)
|
||||
}
|
||||
// The conversion, on the stream: the client stamps the visitor's
|
||||
// consent on it, and cloud forwards it to the ad platforms that
|
||||
// consent allows, server-side. No browser tag runs on this page.
|
||||
analytics.capture(EVENTS.SIGNUP_COMPLETED, { method: 'email' })
|
||||
// The browser leaves for the app on the next line, so the whole
|
||||
// buffer goes now rather than waiting on the flush timer.
|
||||
analytics.flush(true)
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* The consent policy a brand's page asks cloud for, and what the answer decides.
|
||||
*
|
||||
* The page asks once a choice allows Analytics, with the brand's own key and no
|
||||
* credential, and reads only the policy: the tags cloud lists beside it are
|
||||
* never loaded. The client starts only after the policy arrives and only if the
|
||||
* choice still counts under it — a choice stored under an older policy, or a
|
||||
* refusal cloud saw, starts nothing and stores nothing.
|
||||
*/
|
||||
import { afterEach, beforeEach, test, vi } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { acceptAll, asks, readConsent } from '@hanzo/event'
|
||||
import { Analytics } from './analytics'
|
||||
import { newDocument, newVisitor } from './document'
|
||||
|
||||
const LUX = 'pk-luxKEY00000000000'
|
||||
const HANZO = 'pk-hanzoKEY0000000000'
|
||||
|
||||
const SERVED = {
|
||||
iamTenantConfigJson: JSON.stringify({
|
||||
'hanzo.id': { orgId: 'hanzo', clientId: 'hanzo-console' },
|
||||
'lux.id': { orgId: 'lux', clientId: 'lux-cloud' },
|
||||
'pars.id': { orgId: 'pars', clientId: 'pars-console' },
|
||||
}),
|
||||
ingestKeyring: JSON.stringify({ hanzo: HANZO, lux: LUX }),
|
||||
v: 1,
|
||||
}
|
||||
|
||||
/** cloud's answer: Google's and Meta's tags beside the policy, which must stay unloaded. */
|
||||
const policy = (consent: Record<string, unknown>) => ({
|
||||
tags: [
|
||||
{ platform: 'ga4', type: 'ga', id: 'G-EKBNQ969GE' },
|
||||
{ platform: 'meta', type: 'meta', id: '1790308611893001' },
|
||||
],
|
||||
consent: { region: '', mode: 'opt-in', defaults: { analytics: false, marketing: false, ads: false }, version: 1, ...consent },
|
||||
audience: 'person',
|
||||
})
|
||||
|
||||
let asked: { url: URL; init?: RequestInit }[]
|
||||
let sent: string[]
|
||||
let answer: () => Response
|
||||
|
||||
beforeEach(() => {
|
||||
newDocument()
|
||||
asked = []
|
||||
sent = []
|
||||
answer = () => Response.json(policy({}))
|
||||
vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => {
|
||||
if (String(url).includes('/config.json')) return Response.json(SERVED)
|
||||
if (String(url).includes('/v1/project/tags')) {
|
||||
asked.push({ url: new URL(String(url)), init })
|
||||
return answer()
|
||||
}
|
||||
sent.push(String(init?.body ?? ''))
|
||||
return Response.json({ accepted: 1 })
|
||||
})
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
configurable: true,
|
||||
value: (_u: string, blob: Blob) => {
|
||||
void blob.text().then((b) => sent.push(b))
|
||||
return true
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
newVisitor()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function load(host: string) {
|
||||
;(window as unknown as { happyDOM: { setURL(u: string): void } }).happyDOM.setURL(`https://${host}/login`)
|
||||
render(
|
||||
<Analytics>
|
||||
<div>portal</div>
|
||||
</Analytics>,
|
||||
)
|
||||
await settle()
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 8; i++) await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
/** Leaving delivers what the page holds, as a visitor's leaving does. */
|
||||
async function leave() {
|
||||
await act(async () => void window.dispatchEvent(new Event('pagehide')))
|
||||
await settle()
|
||||
}
|
||||
|
||||
/** A choice stored on this host under policy `v`. */
|
||||
function stored(v: number) {
|
||||
document.cookie = "__Host-hz_consent=analytics%2Cmarketing%2Cads; Path=/; Secure"
|
||||
document.cookie = `__Host-hz_consent_v=${v}; Path=/; Secure`
|
||||
}
|
||||
|
||||
test('a brand with no key never asks, even after Accept', async () => {
|
||||
;(window as unknown as { happyDOM: { setURL(u: string): void } }).happyDOM.setURL('https://pars.id/login')
|
||||
acceptAll()
|
||||
await load('pars.id')
|
||||
await leave()
|
||||
assert.equal(asked.length, 0, 'pars names no key, so it asks nothing')
|
||||
assert.equal(sent.length, 0)
|
||||
})
|
||||
|
||||
test('a brand with a key asks nothing before the visitor chooses', async () => {
|
||||
await load('lux.id')
|
||||
await leave()
|
||||
assert.equal(asked.length, 0)
|
||||
assert.equal(sent.length, 0)
|
||||
})
|
||||
|
||||
test('after Accept, Lux asks for its policy with its own key and no credential, then reports', async () => {
|
||||
await load('lux.id')
|
||||
await act(async () => acceptAll())
|
||||
await settle()
|
||||
|
||||
assert.equal(asked.length, 1, `one policy request — got ${asked.length}`)
|
||||
const [ask] = asked
|
||||
assert.equal(ask!.url.pathname, '/v1/project/tags')
|
||||
assert.equal(ask!.url.searchParams.get('key'), LUX)
|
||||
assert.equal(ask!.url.searchParams.get('host'), 'lux.id')
|
||||
assert.equal(ask!.init?.credentials, 'omit')
|
||||
await leave()
|
||||
assert.ok(sent.some((b) => b.includes('"$pageview"')), 'the stream starts once the policy is in')
|
||||
})
|
||||
|
||||
test('a choice stored under an older policy starts nothing, stores nothing, and is asked again', async () => {
|
||||
;(window as unknown as { happyDOM: { setURL(u: string): void } }).happyDOM.setURL('https://lux.id/login')
|
||||
stored(2)
|
||||
answer = () => Response.json(policy({ version: 3 }))
|
||||
await load('lux.id')
|
||||
await leave()
|
||||
assert.equal(asked.length, 1)
|
||||
assert.equal(readConsent().analytics, false, 'the old Accept does not count under policy 3')
|
||||
assert.equal(asks(), true, 'and the banner asks again')
|
||||
assert.equal(sent.length, 0)
|
||||
assert.equal(document.cookie.includes('iam-anon-id'), false, 'no visitor id was kept')
|
||||
})
|
||||
|
||||
test('a refusal cloud saw on the request starts nothing', async () => {
|
||||
;(window as unknown as { happyDOM: { setURL(u: string): void } }).happyDOM.setURL('https://lux.id/login')
|
||||
stored(2)
|
||||
answer = () => Response.json(policy({ version: 2, refused: true }))
|
||||
await load('lux.id')
|
||||
await leave()
|
||||
assert.equal(readConsent().analytics, false)
|
||||
assert.equal(sent.length, 0)
|
||||
})
|
||||
|
||||
test('no policy, no client: an unreachable cloud starts nothing', async () => {
|
||||
;(window as unknown as { happyDOM: { setURL(u: string): void } }).happyDOM.setURL('https://lux.id/login')
|
||||
stored(2)
|
||||
answer = () => new Response('unavailable', { status: 503 })
|
||||
await load('lux.id')
|
||||
await leave()
|
||||
assert.equal(asked.length, 1)
|
||||
assert.equal(sent.length, 0)
|
||||
})
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Which tag set a brand's page asks for, and when.
|
||||
*
|
||||
* The tag manager asks cloud once per document for the tag set of the project
|
||||
* the key names (`GET /v1/project/tags?key=`), and loads only what that set
|
||||
* holds. So the key on that one request decides whose GA4 and whose pixels a
|
||||
* brand's visitors load — Lux's page must name Lux's key, and a brand with no
|
||||
* key must not ask at all, or it would be served the set a bare host resolves to.
|
||||
*
|
||||
* The tag manager keeps its state for the life of the document (one request per
|
||||
* page), and a test file is one document, so the cases run in order and only the
|
||||
* last one is allowed to ask.
|
||||
*/
|
||||
import { afterEach, beforeEach, test, vi } from 'vitest'
|
||||
import assert from 'node:assert/strict'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { acceptAll } from '@hanzo/event'
|
||||
import { Analytics } from './analytics'
|
||||
import { newDocument, newVisitor } from './document'
|
||||
|
||||
const LUX = 'pk-luxKEY00000000000'
|
||||
const HANZO = 'pk-hanzoKEY0000000000'
|
||||
|
||||
const SERVED = {
|
||||
iamTenantConfigJson: JSON.stringify({
|
||||
'hanzo.id': { orgId: 'hanzo', clientId: 'hanzo-console' },
|
||||
'lux.id': { orgId: 'lux', clientId: 'lux-cloud' },
|
||||
'pars.id': { orgId: 'pars', clientId: 'pars-console' },
|
||||
}),
|
||||
ingestKeyring: JSON.stringify({ hanzo: HANZO, lux: LUX }),
|
||||
v: 1,
|
||||
}
|
||||
|
||||
let tagged: URL[]
|
||||
|
||||
beforeEach(() => {
|
||||
newDocument()
|
||||
tagged = []
|
||||
vi.stubGlobal('fetch', async (url: string) => {
|
||||
if (String(url).includes('/config.json')) return { ok: true, json: async () => SERVED } as unknown as Response
|
||||
if (String(url).includes('/v1/project/tags')) {
|
||||
tagged.push(new URL(String(url)))
|
||||
return { ok: true, json: async () => ({ tags: [], audience: 'person' }) } as unknown as Response
|
||||
}
|
||||
return { ok: true, status: 200, text: async () => '{}' } as unknown as Response
|
||||
})
|
||||
Object.defineProperty(navigator, 'sendBeacon', { value: () => true, configurable: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
newVisitor()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function load(host: string) {
|
||||
;(window as unknown as { happyDOM: { setURL(u: string): void } }).happyDOM.setURL(`https://${host}/login`)
|
||||
render(
|
||||
<Analytics>
|
||||
<div>portal</div>
|
||||
</Analytics>,
|
||||
)
|
||||
await settle()
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 4; i++) await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
test('a brand with no key never asks for a tag set, even after Accept', async () => {
|
||||
acceptAll()
|
||||
await load('pars.id')
|
||||
await settle()
|
||||
assert.equal(tagged.length, 0, 'pars names no key, so it starts no tags')
|
||||
})
|
||||
|
||||
test('a brand with a key asks nothing before the visitor chooses', async () => {
|
||||
await load('lux.id')
|
||||
assert.equal(tagged.length, 0)
|
||||
})
|
||||
|
||||
test('after Accept, Lux asks for its own tag set with its own key, once', async () => {
|
||||
await load('lux.id')
|
||||
await act(async () => acceptAll())
|
||||
await settle()
|
||||
|
||||
assert.equal(tagged.length, 1, `one tag-config request — got ${tagged.length}`)
|
||||
const [ask] = tagged
|
||||
assert.equal(ask!.pathname, '/v1/project/tags')
|
||||
assert.equal(ask!.searchParams.get('key'), LUX)
|
||||
assert.equal(ask!.searchParams.get('host'), 'lux.id')
|
||||
assert.notEqual(ask!.searchParams.get('key'), HANZO)
|
||||
})
|
||||
Reference in New Issue
Block a user