IdentityProvider
The identity provider interface: emitter facet (get/subscribe) and ingest facet (resolve), plus the Identity model.
Identity providers are the third family of Dotyc building blocks — resolvers, distinct from adapters (consumers). One provider ideally exposes both facets in a single package: the emitter facet reads the current user from your auth SDK; the ingest facet verifies the session or JWT server-side.
Interface
interface IdentityProvider {
emitter?: {
get(): IdentityClaim | null
subscribe(cb: (i: IdentityClaim | null) => void): () => void
}
ingest?: {
resolve(request: Request): Promise<Identity | null> // session or JWT
}
}| Member | Description |
|---|---|
emitter.get | Returns the current identity claim, or null when signed out. |
emitter.subscribe | Notifies on identity changes (login, logout, refresh); returns an unsubscribe function. The emitter absorbs noisy notifications via its dedupe hash. |
ingest.resolve | Resolves the incoming Request (session cookie or JWT) into a verified identity, or null. |
The Identity model
interface Identity {
anonymousId: string // lightweight per-device id — cookie (localStorage fallback)
sessionId: string // lightweight per-session id — volatile, rotates on inactivity
userId?: string // the reconciliation pivot (verified at ingest)
accountId?: string // the organization / account (B2B)
traits?: Record<string, unknown>
verified: boolean // false = emitter claim, true = resolved at ingest
}IdentityClaim is the same shape without verified — it is what the emitter sends inside the Envelope.
Trust model: client-declared identity is a claim; identity resolved at ingest is the truth. When both exist, the verified one wins and overrides the claim. For apps without a backend session, the provider verifies the JWT issued by your auth (Clerk, Supabase, Neon Auth, Better Auth all emit one) — verified identity with no server of your own.
Reconciliation: as soon as an ingested event carries both lightweight ids and a verified identity, the anonymousId/sessionId to userId link is signaled to adapters through their identify hook — without waiting for an explicit identify() call. Dedupe caches at both stages (persisted hash on the emitter, a TTL cache backed by the dispatcher's store) prevent hammering. The merge itself belongs to the destinations: Dotyc stores nothing.
Usage
import { clerkIdentity } from '@dotyc/clerk/client'
const dotyc = createEmitter({ catalog, transport, identity: clerkIdentity() })import { clerkIdentity } from '@dotyc/clerk/server'
const dispatcher = createDispatcher({ catalog, wiring, identity: clerkIdentity() })A custom ingest-side provider:
const myIdentity: IdentityProvider = {
ingest: {
async resolve(request) {
const session = await auth.getSession(request)
if (!session) return null
return {
anonymousId: readAnonymousCookie(request),
sessionId: readSessionIdCookie(request),
userId: session.userId,
accountId: session.orgId,
verified: true,
}
},
},
}Notes
- Without a provider, identity is manual:
identify()/reset()on the emitter. identify()is not a catalog event; login business logic belongs to an explicit catalog event such assigned_in.
sessionId rotation rule are still open.