Skip to main content

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.

  • req is a standard Web Request, not a Next.js NextRequest and not a Node IncomingMessage. The collector runs on the Edge runtime, so the port must be implementable with Web APIs alone (req.headers, TextEncoder, Web Crypto) — no Buffer, no node:* imports.
  • anon_id is 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_id is optional. Absent means "this request has no known user," a normal, expected answer, not an error.

Rules a conforming implementation must follow

  1. 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.
  2. 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.
  3. Never derive anon_id from 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.
  4. 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 — runs withIdentity() first so the freshly minted anon_id cookie is on the incoming request before the journey-token branch runs.
  • apps/demo/lib/identity-cookie.ts — the one shared AnonymousIdCookieConfig, imported by both middleware.ts (the only writer) and the app's server-rendered layout (the reader), so the browser never ends up holding two different _anon_id cookies.

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:

  1. Implement IdentityResolver against it and pass it to the collector. New events start carrying user_id directly.
  2. Have it write link rows into identity_link as it observes them. Historical anonymous sessions become attributable by join, retroactively, with no backfill.
  3. 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.