@lucerna-dev/identity
v0.0.1-alpha.1
Published
The Lucerna identity primitive: who is the current user and what do we know about them. Product SDKs consume the record and react to changes — `@lucerna-dev/gates-browser` refetches decisions on every change, and any Gates read accepts `identity.current()
Downloads
55
Readme
@lucerna-dev/identity
The Lucerna identity primitive: who is the current user and what do we know about them. Product SDKs consume the record and react to changes — @lucerna-dev/gates-browser refetches decisions on every change, and any Gates read accepts identity.current() directly. The coupling is structural, not a dependency: anything with the same { userId, traits } shape works, in either direction.
Every change also upserts the person to Lucerna People, authenticated by the required apiKey — identify once in your app and targeting, audiences and enrichment stay in sync without a second integration.
Install
pnpm add @lucerna-dev/identityQuickstart
import { createIdentity } from "@lucerna-dev/identity";
const identity = createIdentity({ apiKey: "ck_client_YOUR_KEY" });
// Logged out: a sticky anonymous id, ready for Gates bucketing.
const visitor = identity.current(); // { userId: "anon_…", anonymous: true, traits: {} }
// Sign-in: a burst of writes coalesces into one People upsert.
identity.identify({ userId: "u_42", email: "[email protected]", traits: { plan: "pro" } });
identity.trait("theme", "dark");
const user = identity.current(); // { userId: "u_42", traits: { plan: "pro", theme: "dark" }, … }This block runs verbatim in test/readme.test.ts — if it drifts from the package, the test suite fails.
apiKey is any Lucerna key granted people:identify: in browsers the publishable ck_client_… key (write-only there); on servers the server key or a scoped ck_key_… key.
API
createIdentity(options) → Identity.
| Option | Default | What it does |
| ------------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------- |
| apiKey | required | Authenticates the People upsert every change sends |
| storage / storageKey | — / "lucerna:identity" | Opt-in on-device persistence (IdentityStorage) — wire only after the user consented |
| baseUrl | https://api.uselucerna.app | API origin, for self-hosted or local development |
| syncAnonymous | false | Also upsert anonymous visitors (off by default — every visitor would mint a ghost profile) |
| onError | — | Tap for sync failures — identity itself never throws |
| fetch | global fetch | Transport override (tests, custom dispatchers) |
| Method | What it does |
| -------------------- | ----------------------------------------------------------------------------------------------- |
| identify(input) | Declare who the user is. Same userId merges traits; a different one replaces the record |
| trait(key, value) | Set one trait; null deletes it |
| traits(values) | Merge several traits; null values delete |
| reset() | Logout: fresh anonymous id, traits cleared, storage overwritten |
| current() | A defensive copy of the IdentityUser — safe to hand to any SDK or mutate |
| onChange(listener) | Subscribe to changes; returns unsubscribe |
Guarantees & semantics
userIdis pseudonymous — your app's id (u_42), never an email. It is the id every Gates read buckets by, so it must be stable across sessions and devices.- PII rides separately from traits.
emailandnameare dedicated fields the sync layer routes to People's encrypted columns;traitsare plaintext targeting data everywhere they travel and must never carry raw PII. Trait values are stringified on write. - Users never bleed into each other. Identifying a different
userId(or coming from anonymous) starts a clean record — the previous user's traits, email and name are dropped, not merged.reset()mints a fresh anonymous id. - Anonymous users get sticky bucketing. The
anon_<uuid>id gives logged-out users stable Gates assignments for the session (and across sessions withstorage). Anonymous visitors are not synced to People unlesssyncAnonymous: true. - Persistence is opt-in and consent-gated. Without a
storageadapter, nothing is ever written to the device. Corrupt entries, privacy mode and quota errors fall back to a fresh in-memory identity — storage never throws into app code. - Upserts are coalesced and deduped. A burst of writes (
identify()+ severaltrait()calls) delivers one upsert; a payload identical to the last successful one is not resent. - Nothing here throws. Sync failures report through
onError; a listener or error tap that itself throws is swallowed — it never breaks identity or its peers.
Errors & failure modes
The People upsert is fire-and-forget: a failed send reports through onError and leaves the dedupe slot unset, so the next change (or an identical retry) goes out again. No response is ever awaited on the write path — identify/trait/reset are synchronous and cannot fail.
