mirror of
https://github.com/hanzoai/agency.git
synced 2026-11-02 19:13:28 +00:00
220 lines
8.6 KiB
Plaintext
220 lines
8.6 KiB
Plaintext
/*! anon.js — THE anonymous-identity chain. ONE implementation, two distributions.
|
|
*
|
|
* One browser is ONE person on every Hanzo surface, whichever client a page
|
|
* happens to have loaded. There were once three implementations writing TWO keys —
|
|
* `hz_anon_id` and `hz_id` — so the same visitor was several people depending on
|
|
* which snippet the surface shipped.
|
|
*
|
|
* The two call sites:
|
|
* 1. src/storage.ts — the bundled npm client; IMPORTS this file.
|
|
* 2. hanzoai/cloud apps/analytics/tag.js — the tag served at
|
|
* /v1/event.js; vendors this file and its tag.go serves the marked region
|
|
* with the tag as one asset, so cloud holds no second copy either.
|
|
*
|
|
* (2) has no bundler and cannot import anything, which is why the chain lives in a
|
|
* file that is plain ES5 rather than in a .ts: the region between the BEGIN and END
|
|
* markers is COPIED VERBATIM. Keep it ES5, dependency-free, `hz`-prefixed (it is
|
|
* spliced into other people's scopes) and unformatted — a reformat here is a diff
|
|
* against the vendored copy. After editing, resync that copy:
|
|
*
|
|
* curl -fsSL https://unpkg.com/@hanzo/event/src/anon.js -o apps/analytics/anon.js
|
|
*/
|
|
|
|
/* ── BEGIN hz anon chain — copied VERBATIM into hanzoai/cloud ──────────────── */
|
|
|
|
/** The ONE anonymous-id key, on every surface and in every distribution.
|
|
*
|
|
* It names what it is, not who ships it: this value is visible to anyone who opens
|
|
* their own cookie jar, and a vendor's name has no business there. Identity is IAM's
|
|
* concern whether or not the visitor has said who they are. */
|
|
var HZ_ANON_KEY = 'iam-anon-id'
|
|
|
|
/** Keys an id may already be living under. READ, never written.
|
|
*
|
|
* Each was canonical once — `hz_anon_id` most recently, and `hz_id` before it, when a
|
|
* no-build tag wrote a second identity space and one page could hold two people.
|
|
* Adopting an id already in the wild is what makes this a rename rather than an
|
|
* erasure: minting over one detaches a returning visitor from their own history.
|
|
* They are never written, so they leave with the cookies that hold them. */
|
|
var HZ_ANON_ADOPT = ['hz_anon_id', 'hz_id']
|
|
|
|
/** The registrable domain the cookie is scoped to, so docs, cloud, console,
|
|
* studio, pay, id and www all read the ONE id. localStorage cannot do this: it is
|
|
* ORIGIN-scoped, which is what made one journey arrive as several strangers. */
|
|
var HZ_ANON_DOMAIN = 'hanzo.ai'
|
|
|
|
/** Two years, rewritten on every read, so the cookie rolls forward with the
|
|
* visitor instead of expiring two years after first touch. Safari caps a
|
|
* SCRIPT-written cookie at 7 days no matter what this says, so the rewrite is
|
|
* what keeps a returning Safari visitor: each read re-arms the 7-day window. */
|
|
var HZ_ANON_MAX_AGE = 2 * 365 * 24 * 60 * 60
|
|
|
|
/** Last resort for a browser that refuses cookies AND localStorage: without it
|
|
* every event in a page load would mint an id of its own. */
|
|
var hzAnonMemo
|
|
|
|
/**
|
|
* hzUuidv7 mints a time-ordered UUIDv7 (RFC 9562 §5.7) for `now` in epoch ms.
|
|
*
|
|
* It has to be v7, and this is the only minter any distribution may use. The
|
|
* session rollups on the event plane derive a session's start instant FROM THE ID
|
|
* and admit only ids whose version nibble is 7, so a crypto.randomUUID() (v4) id
|
|
* is not merely unordered there — it is DISCARDED, silently, and the rollup stays
|
|
* empty. Without crypto only the ENTROPY degrades; the shape is always a valid v7.
|
|
*/
|
|
function hzUuidv7(now) {
|
|
var b = new Uint8Array(16)
|
|
var i
|
|
var c = typeof crypto !== 'undefined' ? crypto : undefined
|
|
if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b)
|
|
else for (i = 0; i < 16; i++) b[i] = (Math.random() * 256) | 0
|
|
var t = Math.floor(now === undefined ? Date.now() : now)
|
|
for (i = 5; i >= 0; i--) {
|
|
b[i] = t % 256
|
|
t = Math.floor(t / 256)
|
|
}
|
|
b[6] = 0x70 | (b[6] & 0x0f) // version 7
|
|
b[8] = 0x80 | (b[8] & 0x3f) // variant 0b10
|
|
var h = ''
|
|
for (i = 0; i < 16; i++) {
|
|
h += (b[i] + 0x100).toString(16).slice(1)
|
|
if (i === 3 || i === 5 || i === 7 || i === 9) h += '-'
|
|
}
|
|
return h
|
|
}
|
|
|
|
/** The cookie jar, or null wherever there is no document to read one from. */
|
|
function hzAnonJar() {
|
|
try {
|
|
if (typeof document === 'undefined' || typeof document.cookie !== 'string') return null
|
|
return document
|
|
} catch (e) {
|
|
return null // sandboxed frame with an opaque origin
|
|
}
|
|
}
|
|
|
|
/** localStorage, or null when the browser refuses it (Safari private mode). */
|
|
function hzAnonStore() {
|
|
try {
|
|
if (typeof window === 'undefined' || !window.localStorage) return null
|
|
return window.localStorage
|
|
} catch (e) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** One stored value, or '' — a jar can read as well as refuse to. */
|
|
function hzAnonItem(store, name) {
|
|
try {
|
|
return (store && store.getItem(name)) || ''
|
|
} catch (e) {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
/** The value of cookie `name`, or ''. */
|
|
function hzAnonCookie(name) {
|
|
var d = hzAnonJar()
|
|
if (!d) return ''
|
|
var parts = d.cookie.split(';')
|
|
for (var i = 0; i < parts.length; i++) {
|
|
var eq = parts[i].indexOf('=')
|
|
if (eq < 0 || parts[i].slice(0, eq).trim() !== name) continue
|
|
var v = parts[i].slice(eq + 1).trim()
|
|
if (!v) continue
|
|
try {
|
|
return decodeURIComponent(v)
|
|
} catch (e) {
|
|
return v // not percent-encoded — take it as written
|
|
}
|
|
}
|
|
return ''
|
|
}
|
|
|
|
/** Writes `name` on the registrable domain, for as long as the browser allows. */
|
|
function hzAnonWrite(name, value) {
|
|
var d = hzAnonJar()
|
|
if (!d) return
|
|
var host = ''
|
|
var secure = false
|
|
try {
|
|
if (typeof window !== 'undefined' && window.location) {
|
|
host = window.location.hostname || ''
|
|
// A Secure cookie is refused outright by a non-secure origin, which would
|
|
// strand http://localhost dev on the localStorage path.
|
|
secure = window.location.protocol === 'https:'
|
|
}
|
|
} catch (e) {
|
|
/* location unreachable — write a host-only, non-secure cookie */
|
|
}
|
|
// encodeURIComponent leaves a UUID byte-identical while making any value that is
|
|
// not one unable to forge a `;` and inject an attribute.
|
|
var c = name + '=' + encodeURIComponent(value)
|
|
c += '; Path=/; Max-Age=' + HZ_ANON_MAX_AGE + '; SameSite=Lax'
|
|
// Off hanzo.ai (localhost, previews, other registrable domains) the attribute
|
|
// would be rejected and the whole cookie dropped, so it stays host-only there.
|
|
// Prefixing both sides with '.' matches the domain itself and its subdomains
|
|
// while refusing a suffix that merely ends in the same letters (evilhanzo.ai).
|
|
if (('.' + host).slice(-(HZ_ANON_DOMAIN.length + 1)) === '.' + HZ_ANON_DOMAIN) {
|
|
c += '; Domain=' + HZ_ANON_DOMAIN
|
|
}
|
|
if (secure) c += '; Secure'
|
|
try {
|
|
d.cookie = c
|
|
} catch (e) {
|
|
/* cookies refused — localStorage still carries the id */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* hzAnonId returns the stable anonymous id for this browser, '' during SSR.
|
|
*
|
|
* Resolution is strictly ADDITIVE — every id that already exists is ADOPTED, and
|
|
* only a browser holding none of them is given a new one:
|
|
*
|
|
* cookie · localStorage iam-anon-id · adopted keys · in-memory · mint
|
|
*
|
|
* Minting over an id resets a returning visitor and detaches them from their own
|
|
* history, so the order is the migration: the cookie is the shared home, the two
|
|
* localStorage keys are what the three implementations wrote before it existed,
|
|
* and each is read until nothing is left to adopt.
|
|
*
|
|
* localStorage keeps being written, so a rollback finds everyone where it left
|
|
* them, and a browser that refuses cookies still holds one id per origin.
|
|
*/
|
|
/**
|
|
* hzAnonAdopted returns an id already living under a key that used to be canonical,
|
|
* from the cookie jar first — that is the copy shared across subdomains, so a visitor
|
|
* who arrived on one host is the same person on the next.
|
|
*/
|
|
function hzAnonAdopted(s) {
|
|
for (var i = 0; i < HZ_ANON_ADOPT.length; i++) {
|
|
var id = hzAnonCookie(HZ_ANON_ADOPT[i]) || hzAnonItem(s, HZ_ANON_ADOPT[i])
|
|
if (id) return id
|
|
}
|
|
return ''
|
|
}
|
|
|
|
function hzAnonId() {
|
|
if (typeof window === 'undefined') return '' // SSR / prerender: no browser to identify
|
|
var s = hzAnonStore()
|
|
var id =
|
|
hzAnonCookie(HZ_ANON_KEY) ||
|
|
hzAnonItem(s, HZ_ANON_KEY) ||
|
|
hzAnonAdopted(s) ||
|
|
hzAnonMemo ||
|
|
hzUuidv7()
|
|
hzAnonMemo = id
|
|
hzAnonWrite(HZ_ANON_KEY, id)
|
|
try {
|
|
if (s && s.getItem(HZ_ANON_KEY) !== id) s.setItem(HZ_ANON_KEY, id)
|
|
} catch (e) {
|
|
/* quota exhausted, or a private-mode jar that reads but refuses writes */
|
|
}
|
|
return id
|
|
}
|
|
|
|
/* ── END hz anon chain ─────────────────────────────────────────────────────── */
|
|
|
|
export { hzAnonId, hzUuidv7 }
|