Identity Port
:::caution Pre-release This is a pre-1.0 library (v0.1.1) — API may change without notice. :::
next-story does not own an identity system, and never will. It depends on identity through a single narrow port and ships a deliberately minimal default so the package works standalone on day one.
The contract
type IdentityResolver = (req: Request) => Promise<{
anon_id: string;
user_id?: string;
}>;
That is the entire surface — one function, one argument, two fields out.
reqis a standard WebRequest, not a Next.jsNextRequestand not a NodeIncomingMessage. The collector runs on the Edge runtime, so the port must be implementable with Web APIs alone (req.headers,TextEncoder, Web Crypto) — noBuffer, nonode:*imports.anon_idis required and must be stable for the same browser across sessions — the join key that makes a journey reconstructible when there is no logged-in user.user_idis optional. Absent means "this request has no known user," a normal, expected answer, not an error.
Rules a conforming implementation must follow
- Never throw to signal "anonymous." A missing user is
user_id: undefined. Throwing means the resolver itself is broken, and the collector rejects the batch. - Never return a pre-hashed
user_id. Return the subject id as-is — hashing it here would permanently destroy joinability with a future real identity system's subject id. - Never derive
anon_idfrom an IP address. Raw IP is never stored anywhere in next-story (see Privacy & Data Model); an IP-derived anon id would smuggle it back in through a side door. - Be cheap. It runs on every ingest batch.
What ships by default
A cookie-based implementation that mints and reads a first-party __ns_aid
cookie and always returns user_id: undefined. That default exists so a site
can install the SDK and get correct journey data without wiring up anything
else. Sites that already know who their user is override the resolver at
collector setup and return their own user_id.
Reference install
The demo app in this repo composes an external identity adapter
(@idhub/identity-adapter-nextjs) with the journey token middleware, in one
middleware() function — Next.js allows exactly one per app:
apps/demo/middleware.ts— runswithIdentity()first so the freshly mintedanon_idcookie is on the incoming request before the journey-token branch runs.apps/demo/lib/identity-cookie.ts— the one sharedAnonymousIdCookieConfig, imported by bothmiddleware.ts(the only writer) and the app's server-rendered layout (the reader), so the browser never ends up holding two different_anon_idcookies.
How identity is persisted
Identity is stored as facts with timestamps, never as a mutation of
history. anon_id and user_id are two separate nullable columns on
raw_event and session_summary — when an event is recorded with no known
user, user_id stays NULL forever; it is never backfilled after a later
login.
The link itself is what gets recorded instead, in an append-only
identity_link(id, anon_id, user_id, linked_at) table — never UPDATE, never
DELETE. There is deliberately no unique constraint on (anon_id, user_id):
a repeat observation is a new fact with a new timestamp, not a conflict, since
the relationship is many-to-many over time in both directions (one device
shared by several users; one user across many devices, cookie clears, or ITP
expiry).
The payoff: integration is a join, not a migration
Because history is never rewritten, adopting a real shared identity system later never requires touching a single existing row:
- Implement
IdentityResolveragainst it and pass it to the collector. New events start carryinguser_iddirectly. - Have it write link rows into
identity_linkas it observes them. Historical anonymous sessions become attributable by join, retroactively, with no backfill. - Nothing else changes — no schema migration, no reprocessing, no rollup invalidation, since aggregate tables carry no identifier columns at all.
Why next-story doesn't import an identity system from anywhere
There is nothing to import: no dependency of this package ships an identity/anon-id system today. next-story defines the port and ships a working cookie-based default because the alternative is blocking on a system that does not exist.
Don't confuse this with per-site ingest key authentication (a separate, machine-credential concern for authenticating the calling site, not the end user in the browser) — different concern, different lifetime, different blast radius. Resolving one through the other would be a mistake.