# Identify people with any authentication

Available in `@iann29/rastro@0.6.0` and later. Upgrade and deploy the host
before using these APIs in the browser or enabling the directory in federation.

Rastro connects a person's name and email to their visits, actions and
conversions. The product's backend resolves the signed-in user; Rastro does not
require Better Auth or a particular auth provider. Profiles and events stay in
the product's Rastro component.

## Shared contract

```ts
const profile = await analytics.identifyVisitor(ctx, {
  siteId,
  visitorId: user.analyticsId,
  previousVisitorId, // optional: the browser's pre-login visitor ID
  name: user.name,
  email: user.email,
});
```

This is a trusted server call. Resolve `user` and authorize `siteId` on the
server. The result is `{ visitorId, name?, email?, identifiedAt, updatedAt }`.
Use the returned `visitorId` in the tracker. A repeated call is idempotent;
omitted attributes keep their values, and `name: null` or `email: null` removes
that attribute. Names are bounded to 160 characters and emails to 320.

Choose one stable ID per product user. Existing tracker IDs can be reused
unchanged. `visitorIdForUser(userId)` converts arbitrary provider IDs into a
stable tracker-compatible ID. Use your product's user primary key when auth
providers can change. With raw Convex JWT identities, use the complete
`tokenIdentifier`, which includes the issuer, rather than `subject` alone. Do
not use email as the identity key: it can change or be shared.

## Project-defined attributes and the people directory

No user table, workspace model, auth provider or business field is required.
Keep your product's existing user key and map its current attributes on the
server, in the same resolver used above:

```ts
return {
  siteId: authorizedSiteId,
  visitorId: user.analyticsId,
  name: user.name,
  email: user.email,
  attributes: { language: "pt-BR", level: 3, enrolled: true },
};
```

An education app may send `level` and `enrolled`; a marketplace may send
`seller` and `orders`; a SaaS may send `plan` and `seats`. These are examples,
not built-in columns. Attribute values are strings, finite numbers or booleans.
Dates can be ISO strings. Attributes describe current state; put historical
context on the event's existing `properties` instead. Nested objects and arrays
are not supported in this first version.

Updates merge keys. Omitted keys are preserved; a null value deletes one key:
`attributes: { level: 4, enrolled: null }`. Empty strings, zero and false are
valid values. Each profile allows 32 keys, keys of up to 64 ASCII characters
starting with a letter (`A-Z`, `a-z`, digits, `.`, `_`, `-`), strings up to 512
characters, and at most 4096 UTF-8 bytes for the complete attribute map. Invalid
updates fail atomically. These limits also bound read and index costs.

```ts
const page = await analytics.listPeople(ctx, {
  siteId: authorizedSiteId,
  filter: { field: "attribute", key: "level", value: 3 },
  paginationOpts: { cursor: null, numItems: 25 },
});
// page: { page: Person[], isDone, continueCursor }
```

The optional filter is either one exact typed attribute match, or a prefix:
`{ field: "name" | "email" | "visitorId", prefix: "Maria" }`. Names and emails
use case-insensitive Unicode-normalized prefixes; IDs preserve case. Text `"3"`
differs from number `3`. Each page uses the site's database indexes. Pages allow
1–50 people; pass `continueCursor` back as the next cursor. Cursors are scoped
to the site and filter. Clear the cursor when either changes. The directory
includes all identified profiles, independently of the dashboard's event date
range.

Re-export `listPeople` from `exposeFederatedAnalyticsApi` alongside
`getVisitorProfile`. The optional `people` capability enables the **Pessoas**
dashboard view. Existing hosts stay connected without it. People and attributes
are available only to authorized readers of that site; public links cannot list
them. The local `exposeAnalyticsApi` authorizer receives `visitor.list`.

For profiles created by the earlier unreleased identity implementation, call
`analytics.backfillVisitorProfiles(ctx, { siteId })` from a site-admin mutation
or action after deployment. Each call updates at most 64 profiles and returns
`{ updated, isDone }`; schedule another bounded call if `isDone` is false. The
local API wrapper uses the `visitor.index` authorization operation; it is not
exposed through federation. The example `analytics.ts` exports an
owner-authorized wrapper. Backfill preserves identity timestamps and is safe to
retry. Until it finishes, name/email search refuses to return partial results;
unfiltered and ID listings remain available. Fresh installations need no
backfill.

## Funnels across identification

Funnel progress resolves the canonical person and their pre-login aliases. Steps
recorded before login continue after login, including queued events under an
already-linked browser ID. Repeated identification and another tab do not start
another attempt inside the active conversion window. The original events and
their IDs stay intact. When multiple aliases already have progress, the furthest
attempt inside its conversion window takes precedence; expired attempts restart
only on the first step, as before.

Linking does not retroactively recompute already-counted funnel attempts or
other historical aggregates. Events must still arrive in step order; this change
does not add late-event replay.

## Convex with Better Auth

In the product's `convex/analyticsIdentity.ts`, use its existing auth component:

```ts
import { Rastro } from "@iann29/rastro";
import { components } from "./_generated/api";
import type { MutationCtx } from "./_generated/server";
import { authComponent } from "./auth";

const analytics = new Rastro(components.rastroAnalytics);

export const identifyVisitor = analytics.identifyVisitorMutation({
  resolveUser: async (ctx: MutationCtx) => {
    const user = await authComponent.safeGetAuthUser(ctx);
    if (!user) return null;
    return {
      siteId: "YOUR_PRODUCT_SITE_ID",
      visitorId: user._id,
      name: user.name,
      email: user.email,
    };
  },
});
```

`safeGetAuthUser` checks the Better Auth session. Rastro adds no Better Auth
runtime dependency. With multiple workspaces, resolve the site from the user's
authorized workspace in this callback instead of accepting a site from the
browser.

## Convex with custom auth or another JWT provider

Use the same mutation with your own user lookup:

```ts
import { Rastro, visitorIdForUser } from "@iann29/rastro";
import { components } from "./_generated/api";
import type { MutationCtx } from "./_generated/server";

const analytics = new Rastro(components.rastroAnalytics);

export const identifyVisitor = analytics.identifyVisitorMutation({
  resolveUser: async (ctx: MutationCtx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return null;
    return {
      siteId: "YOUR_PRODUCT_SITE_ID",
      visitorId: await visitorIdForUser(identity.tokenIdentifier),
      name: identity.name,
      email: identity.email,
    };
  },
});
```

For custom database-backed sessions, replace the resolver body with the
product's existing authenticated-user lookup. Returning `null` refuses the
request. The public mutation accepts only `previousVisitorId`; user ID, name,
email and site scope come from the resolver.

## React and browser lifecycle

After the login has established the backend session:

```tsx
const identifyVisitor = useMutation(api.analyticsIdentity.identifyVisitor);
const rastro = useRastro();

async function identifyAfterLogin() {
  const before = rastro.context();
  const profile = await identifyVisitor({
    previousVisitorId:
      before && !before.identified ? before.visitorId : undefined,
  });
  rastro.identify(profile.visitorId);
  // Keep this ID in your auth state and pass it to RastroScript on later loads.
}

async function signOut() {
  await yourAuth.signOut();
  rastro.reset();
}
```

Import `useRastro` and `RastroScript` from `@iann29/rastro/react`; use your
app's existing `useMutation` from `convex/react`. Call identification again
after restoring a logged-in session. Discard a pending identification response
if the auth session has changed before it returns.

`<RastroScript visitorId={analyticsVisitorId} ... />` also synchronizes ID
changes and logout (`visitorId` becoming undefined), including a delayed script
load. Mount one tracker per page. Prefer one lifecycle owner: either the prop or
the explicit commands, so a logout does not reset twice.

For another frontend framework, use the equivalent commands:

```js
const before = window.rastro?.("context");
// Authenticate with your product backend, then obtain its Rastro profile.
window.rastro?.("identify", profile.visitorId);
// After logout:
window.rastro?.("reset");
```

`context` returns `{ visitorId, sessionId, identified }`. The browser commands
change the tracker ID, not the stored profile. Identification flushes queued
events under their original ID and opens a new session without a page reload.
The first login retains acquisition context. Logout creates a new browser
visitor; logout and direct account switches clear the previous person's
campaign/referral context. `identify` and `reset` from `useRastro` return false
when the deferred tracker is not ready; the `visitorId` prop handles that case.

## Other backends: authenticated HTTP

Mount an HTTP action on the Convex deployment that owns the Rastro component.
The external backend can be Node, Python, Go, Rails, or any HTTP client. The
analytics storage still runs on Convex or Synapse.

```ts
// convex/http.ts — merge into the product's existing router.
http.route({
  path: "/analytics/identify",
  method: "POST",
  handler: analytics.identifyVisitorHttpAction({
    authorize: async (_ctx, request, siteId) => {
      const key = env.RASTRO_IDENTITY_SERVER_KEY;
      if (
        !key ||
        request.headers.get("authorization") !== `Bearer ${key}` ||
        siteId !== env.RASTRO_IDENTITY_SITE_ID
      ) {
        throw new Error("Not authorized");
      }
    },
  }),
});
```

Declare the two optional string env vars in `convex/convex.config.ts`. The
complete router and declarations are in `example/convex/`. Provision a random
server key and share it only with the authorized backend. If multiple backends
or sites are served, the authorizer must resolve each credential's site scope.
Do not expose the key in `VITE_`, `NEXT_PUBLIC_`, or browser code.

The external backend first authenticates its own user, then sends:

```ts
const response = await fetch(process.env.RASTRO_IDENTITY_URL!, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${process.env.RASTRO_IDENTITY_SERVER_KEY}`,
  },
  body: JSON.stringify({
    siteId: process.env.RASTRO_IDENTITY_SITE_ID,
    visitorId: user.analyticsId,
    previousVisitorId,
    name: user.name,
    email: user.email,
  }),
});
if (!response.ok) throw new Error(`Rastro identify failed: ${response.status}`);
const profile = await response.json();
// Return profile.visitorId to the authenticated browser.
```

The endpoint accepts at most 8 KiB of JSON, validates the same fields as the SDK
and sends `Cache-Control: no-store`. Statuses: 200 success, 400 invalid input,
403 denied credential/site, 404 unknown site, 409 conflicting identity, 413
oversized body, 415 wrong content type, 429 alias limit, 500 server failure.

## Dashboard and existing history

Re-export `getVisitorProfile` from `exposeFederatedAnalyticsApi` in the host's
`convex/rastroFederation.ts`. It is advertised by the optional `visitorProfiles`
capability. Private live/session reports include a profile when one exists; old
hosts and unidentified visitors continue to work without it.

The dashboard shows names/emails in **Ao vivo** and **Jornadas**, searches those
attributes within the loaded session records, and opens the person's history for
the selected site and period. Existing events retain their original ID; the
identity link resolves them at read time. A profile is keyed by site and
visitor, with no email-based merging. An identified person cannot be linked into
another person. Each identity supports the existing limit of ten pre-login
aliases. Profile attributes persist independently of event retention and can be
cleared with explicit null updates.

Profile reads require a reader's authorization for that site. Public dashboard
links omit profiles and cannot call `getVisitorProfile`. The tracking endpoint
continues to accept browser telemetry; an identity label does not turn
browser-sent events into authenticated business transactions. Record revenue
through the existing trusted `trackConversion` server API.
