# Account management

Every SDK call resolves against a **trust boundary** with three
coordinates:

```
   who          User (human)  OR  ApiKey / PAT (agent)
   where        Tenant   (+ optional Datalake)
   what         Role assumed by this session
```

Account management is how those three coordinates are established
and reasoned about. This page covers six SDK namespaces that work
together:

| Namespace          | Methods                                       |
|--------------------|-----------------------------------------------|
| `api.admin`        | `signUp`, `confirmUser`, `createTenantApiKey` — **dev / integration-test fixtures only** |
| `api.sessions`     | `verify`                                      |
| `api.tenants`      | `create`, `list`                              |
| `api.invitations`  | `create`, `list`, `accept`                    |
| `api.ping`         | unauthenticated health check (`GET /api/ping`) |
| `createBootstrapSession` | mint a **tenantless** Bearer (top-level helper, not a namespace) |
| `createSession`    | mint a **tenant-scoped** Bearer (top-level helper, not a namespace) |
| `revokeSession`    | revoke the current Bearer (top-level helper, parallel to the two minters) |

Two top-level concerns sit above the surface:

1. **Who is calling** — a human (Bearer) or an agent (API key / PAT)
2. **What that caller can do** — gated by role + by credential type

Both branches differ in lifecycle, capability, and whether they
can be created programmatically. See §3 "Access control".

## 1. Identity — humans

Humans authenticate by exchanging email+password for a Bearer.
The Bearer carries the trust-boundary coordinates and travels in
the `Authorization: Bearer <token>` header on every subsequent
request.

### Three scopes, two minters

Sessions come in three scopes — **root**, **tenantless**, and
**tenant-scoped** — but there are only **two** functions that mint a
Bearer. `createBootstrapSession` mints the keyless tenantless Bearer
(root is a tenantless Bearer that happens to belong to a platform-admin
user); `createSession` mints the tenant-scoped Bearer.

```typescript
import { createBootstrapSession, createSession } from '@alvera-ai/platform-sdk'

// tenantless — POST /api/v1/admin/bootstrap-session. Keyless by structural
// necessity (it mints the very first Bearer of an environment, before any
// tenant key exists). Returned tenant + role are ALWAYS null. This is an
// integration-test / dev-only route — absent from prod builds.
const tenantless = await createBootstrapSession({
  baseUrl, email, password,
})
// tenantless.tenant === null  &&  tenantless.role === null

// root — the SAME call, with a platform-admin user's credentials. The
// admin capability comes from the user, not a different function. Use it
// for admin.signUp / admin.confirmUser / admin.createTenantApiKey.
const root = await createBootstrapSession({
  baseUrl, email: rootEmail, password: rootPassword,
})

// tenant-scoped — POST /api/v1/sessions, the canonical Bearer for tenant
// operations. REQUIRES both tenantSlug AND a resolvable tenant publishable
// key (sent as X-API-Key): the server 401s with "X-API-Key required to
// sign in to a tenant" without one, and 403s a key belonging to a different
// tenant. The minted Bearer inherits the origin policy of the key that
// minted it, and that same key must accompany every subsequent request.
const tenant = await createSession({
  baseUrl, email, password, tenantSlug, apiKey: tenantApiKey,
})
// tenant.tenant.slug === tenantSlug
// tenant.role.name   === 'tenant_admin' | 'member' | ...
```

The tenant API key is minted by a tenant admin in the platform's
API-keys page (Settings → API Keys), or programmatically via the
platform-admin side door (a root Bearer):

```typescript
const { data: minted } = await rootApi.admin.createTenantApiKey(tenantSlug, {
  name: 'Bootstrap Key',
  data_access_mode: 'raw',
})
// minted.api_key — the publishable key value (thread into createSession)
```

See `AGENTS.md` "Sessions come in three scopes" for when each is
appropriate. Tenantless is a transient state used once: to create
a tenant (creator path) or accept an invitation (invitee path).

**Scope discipline for app builders:** root and tenantless sessions
are platform-UI / admin-bootstrap surfaces (signup, confirmation,
tenant creation, test setup) — never ship them inside an app. An
app authenticates **tenant-scoped against exactly one tenant**,
with that tenant's publishable key; the host behind an action's
`link_prefix` in particular resolves links for one tenant
(`authenticated_links.md`).

### Key-only clients — machine-to-machine on the publishable key

`sessionToken` is optional on the client config. The host behind an
action's `link_prefix`, authenticating purely on its tenant's
publishable key, constructs with just the key — no session mint, no
fabricated empty token:

```typescript
const api = createIsolatedPlatformApi({ baseUrl, apiKey })
```

Such a client sends `X-API-Key` and **no `Authorization` header**,
and reaches exactly the `public_api` (publishable-key) allowlist —
five calls:

```
   api.authenticatedLinks.exchange                a tap on a link
   api.authenticatedLinks.updateMessageTracking   opened / form_submitted
   api.dataActivationClients.ingest               submit rows
   api.dataActivationClients.ingestFile           submit a file
   api.mdm.verify                                 resolve an identity
```

(`authenticated_links.md` owns the details of the two link calls.)
Every call outside these five returns
401; that ceiling is the point, not an error to work around.
Minting a session and passing `sessionToken` unlocks the full
tenant-scoped API exactly as before — the two credentials are
complementary (the key identifies the client; the Bearer
authorizes), never substitutes.

### Explicit teardown — `revokeSession`

The inverse of `createSession`. Revokes the current Bearer on
the server and short-circuits the local `api` instance — every
subsequent request through the same instance returns 401:

```typescript
import { revokeSession } from '@alvera-ai/platform-sdk'

await revokeSession()              // DELETE /api/v1/sessions
// api.* calls now return 401
```

Use this for graceful sign-out flows in long-running consumers
(CLIs, daemons). Letting the Bearer reach `expires_at` is fine
for short-lived scripts, but explicit revocation removes the
token server-side immediately and reduces the window of misuse
if the token leaks.

### Liveness check — `api.ping`

A no-auth health probe used by load balancers, smoke tests, and
SDK consumer setup checks. The response confirms the platform
is reachable and reports the build version + database
connectivity:

```typescript
const { data } = await api.ping()
// data.status           === 'ok'
// data.version          — platform build version
// data.database_status  === 'connected' | 'disconnected'
// data.timestamp        — ISO 8601
// data.iam_role_arn     — the platform's own IAM task role,
//                         or null where it runs without AWS
```

`api.ping` works on any api instance regardless of session
state — useful as the first call in tests to detect a stale
local server before authentication failures muddy the diagnostic.

`iam_role_arn` is optional and nullable (`string | null`). It is
the principal a customer names in the trust policy of a role they
create for the platform to assume, so it is served unauthenticated
by design — see "`assume_role` needs the platform's ARN" in
[tools.md](./tools.md) for how to use it. It is `null` wherever the
platform runs without AWS, the normal case in local development,
so handle the null rather than assuming a string.

### Sign up

```typescript
// signUp lives under the admin namespace (there is no `api.auth`); it is an
// integration-test fixture, reachable only from a root Bearer.
const { data: user } = await rootApi.admin.signUp({
  email: 'sarah@example.com',
  password: '...',
  first_name: 'Sarah',
  last_name: 'Mitchell',
})
// user.id is a UUID; user.confirmed_at is null
```

A newly signed-up user CANNOT sign in until confirmed. The
production confirmation path is **email-driven**: the platform
sends a confirmation email; the user clicks a link; the platform
records `confirmed_at`. **This email flow is not exposed in the
SDK** — it's a browser+email round-trip outside the API surface.

### `api.admin.confirmUser` is a dev fixture, not a production API

```typescript
// DEV / VITEST ONLY — do NOT call from production consumer code
await rootApi.admin.confirmUser(userId)
```

`api.admin.confirmUser` bypasses the email-confirmation gate. It
exists so integration tests can simulate a confirmed user without
a real inbox round-trip. Production consumers MUST NOT use it:

- It requires a root Bearer (impossible to obtain in customer
  environments).
- The email gate exists to prove a real person controls the
  inbox — programmatically bypassing it defeats the human-gate
  invariant (see §3).
- Future platform releases may restrict it further or remove it
  from the public surface entirely.

The method ships in the typed client to support integration-test
fixtures; treat it as a test-only helper, not a production API.

### `api.sessions.verify` — "who am I, where, with what role"

```typescript
const { data: ctx } = await api.sessions.verify()
// ctx.tenant?.slug — current tenant (null if tenantless)
// ctx.role?.name   — role assumed in this session
// ctx.user         — { id, first_name, last_name, ... }
```

Use cases:

1. **Pre-flight check** before performing role-gated operations —
   confirm the Bearer is tenant-scoped + has the expected role.
2. **Bearer validity probe** — if the call throws, the Bearer is
   expired or revoked; re-mint via `createSession`.
3. **Cached-Bearer rehydration** — when persisting Bearers across
   processes (integration tests, CLI sessions), verify before
   reusing.

For pattern (3), call `verify()` on the stored Bearer and only
re-mint via `createSession` if the verify call fails.

## 2. Tenant

A **tenant** is the top-level data sovereignty boundary. Tenants
own datalakes; datalakes own everything else. A user can belong
to many tenants; a Bearer is scoped to exactly one.

### Two paths to a tenant-scoped Bearer

```
  ┌─ creator path ─┐                ┌─ invitee path ─┐
  │                                 │
  admin.signUp                      admin.signUp
  │                                 │
  api.admin.confirmUser*            api.admin.confirmUser*   (* production: email link)
  │                                 │
  createBootstrapSession            createBootstrapSession
  │                                 │
  api.tenants.create                api.invitations.list → .accept
  │                                 │
  createSession ({ tenantSlug })    createSession ({ tenantSlug })
  ▼                                 ▼
        ┌──── tenant-scoped Bearer ────┐
```

Both paths converge on a tenant-scoped Bearer. The creator
becomes `tenant_admin` of the new tenant; the invitee assumes the
role specified in their invitation.

### `api.tenants.create`

```typescript
const { data } = await tenantlessApi.tenants.create({
  name: 'Acme Health',
})
// data.id, data.slug, data.name — server-derived (flat TenantResponse)
```

The slug is server-derived from `name` per the universal slug
rules (see `type_naming.md` "Never pre-compute the slug
client-side"). Use the returned slug for downstream calls.

**Gotcha**: `tenants.create` returns only the tenant — a flat
`TenantResponse` (`id`, `slug`, `name`, `description`), **not** a Bearer
and with no `session_token`. The caller's tenantless Bearer stays valid,
but it is still tenantless. To act on the new tenant, mint a tenant-scoped
Bearer via `createSession({ ..., tenantSlug, apiKey })` — which is also
where the mandatory `X-API-Key` first enters the flow. (The tenant's
default `public_api` key is auto-minted at creation, but its plaintext is
not returned here — read it from Settings → API Keys, or mint a fresh key
via `admin.createTenantApiKey`.)

### `api.tenants.list`

```typescript
const { data: tenants } = await api.tenants.list()
// tenants.data: TenantResponse[] — every tenant the current Bearer can see
// takes an optional Flop query (page/page_size/order_by/filters —
// see type_naming.md "List response types")
```

A root Bearer sees every tenant; a tenant-scoped Bearer typically
sees only its own. Use this for tenant pickers in apps where a
user belongs to multiple tenants.

## 3. Access control

The platform separates **who you are** from **what you can do**.
Identity (§1) proves the first; this section covers the second.

### Roles — two vocabularies, one consumer

The platform exposes two distinct role vocabularies on the wire,
each rooted in a different schema. Knowing which surface
demands which vocabulary is the difference between a clean
invite and a 422.

#### Vocabulary A: Invitation enum (3 values)

What you SUBMIT when creating an invitation. The
`InvitationRequest.role` field is a closed 3-value enum:

| Invitation `role` | Result on accept                       |
|-------------------|----------------------------------------|
| `'member'`        | Standard tenant user                   |
| `'researcher'`    | Ceiling of `tokenized`; cannot read the raw lake |
| `'admin'`         | Full tenant management                 |

```typescript
const { data: invite } = await api.invitations.create(tenantSlug, {
  email: 'emma@example.com',
  role: 'admin',                  // NOT 'tenant_admin' — that's the role-name vocab
})
```

Passing any value outside this 3-set returns a 422 on `/role`.

#### Vocabulary B: Role-record names (what `session.role.name` carries)

What you READ from a tenant-scoped Bearer's role record. The
session carries a role-record name string that's distinct from
the invitation enum:

| `session.role.name`     | Scope                              | Source vocab |
|-------------------------|------------------------------------|--------------|
| `'tenant_admin'`        | Full tenant management             | invitation `'admin'`  |
| `'member'`              | Standard tenant user               | invitation `'member'` |
| `'public_api'`          | Lowest-privilege publishable key (Stripe `pk_` analogue); the mandatory `X-API-Key` companion | provisioned, not invited |
| `'datalake_admin'`      | Full datalake management (requires `datalake_id` on session) | console-mediated |
| `'researcher'`          | Tokenized-only datalake access     | invitation `'researcher'` |
| `'datalake_api'`        | Programmatic datalake access       | provisioned, not invited |

```typescript
const session = await createSession({ baseUrl, email, password, tenantSlug, apiKey: tenantApiKey })
// session.role.name === 'tenant_admin'   if accepted from a role: 'admin' invitation
// session.role.name === 'member'         if accepted from a role: 'member' invitation
// session.role.name === 'researcher'     if accepted from a role: 'researcher' invitation
```

The asymmetry is deliberate but easy to trip over: the
invitation enum value `'admin'` becomes the role-record name
`'tenant_admin'`. The `'member'` and `'researcher'` values
carry through unchanged.

#### Why two vocabularies

The Invitation/Membership schema (Vocabulary A) is the
coarse-grained role assignment surfaced at invite time. The
Role-record system (Vocabulary B) is the fine-grained policy
entity that drives row-level access and permission checks at every
request. They co-exist because the invitation captures the
caller's intent ("admin-tier user"), while the role record
captures the platform's enforcement vocabulary
("tenant-scoped admin role").

Datalake-scoped role records (`datalake_admin`, `researcher`
when used at datalake scope, `datalake_api`) require the
session to carry a `datalake_id` in addition to a `tenant_id`.
Most SDK consumers stay at tenant scope; datalake-scoped
sessions appear in console-mediated flows and are uncommon in
SDK-consumer code.

Reserved roles (`platform_admin`, `system`, `system_api`,
`bot_*`) bypass tenant-based access isolation and are
provisioned only by the platform itself — they never appear on
consumer-minted sessions or invitations and are out of scope
for SDK consumers.

### Inviting humans into a tenant

```typescript
// As tenant_admin
const { data: invite } = await api.invitations.create(tenantSlug, {
  email: 'emma@example.com',
  role: 'member',
})

// As the invitee (tenantless Bearer)
const { data: invites } = await tenantlessApi.invitations.list()
const mine = invites.data.find(i => i.tenant.slug === tenantSlug)
const { data: membership } = await tenantlessApi.invitations.accept(mine.id)

// Then mint a tenant-scoped Bearer
const session = await createSession({ baseUrl, email, password, tenantSlug, apiKey: tenantApiKey })
// session.role.name === 'member'
```

The duplicate-invite envelope carries `detail` containing
"already invited" — see `errors.md` for the canonical 422 shape.
Treat it as a benign idempotency signal on retry.

### Agent authentication — two-tier model

Agents (programmatic callers) authenticate via two distinct
credential types with very different capability profiles:

| Credential type | Capability                | Created programmatically?    |
|-----------------|---------------------------|------------------------------|
| **API key**     | Write-only, very limited  | **Yes** — safe by scope      |
| **PAT** (delegated) | Full per delegating human's role | **No** — human-gated     |

The rule: **anything that gives an agent broad access is gated by
a human**. Specifically:

- **API keys** can be minted programmatically because their
  capability is intrinsically narrow (write-only, scoped to
  specific endpoints). A leaked API key can deposit data; it
  cannot exfiltrate it.
- **PATs** delegate a human's full role to an agent. Because the
  resulting agent-Bearer has the same capability as the human,
  PAT issuance MUST require human consent at mint time — the
  human is the gate.
- **User signup + confirmation** is human-gated (email flow) for
  the same reason: a programmatically-created confirmed user
  could mint Bearers without any human in the loop. The
  email-link confirmation step proves a real person controls the
  inbox.

### What the SDK exposes for agent auth

**Today the SDK exposes neither credential-lifecycle surface.**
API key and PAT minting / listing / revocation happen via
operator-mediated consoles, not via SDK methods. The absence
of these lifecycle methods from the SDK is the contract.

When the SDK does need to *authenticate as* an agent (uncommon in
fixture code; common in production agent runtimes), the credential
travels in the appropriate request header — see the credentials
your operator provides for the exact header convention. A session
Bearer minted via `createSession` is also accepted for any
SDK-driven agent workflow.

## 4. Lifecycle

Bearers and credentials have distinct lifecycles. Summary:

| Credential | Expires?                              | Revocation        |
|------------|---------------------------------------|-------------------|
| Bearer (session) | Yes — `expires_in` (default 24h, max 30d) | Server-side revoke |
| API key    | No — until explicit revoke            | Operator console  |
| PAT        | Configurable at issue time            | Operator console  |

Bearer expiry surfaces as a 401 on the next call after expiry —
re-mint via `createSession`. The SDK does NOT auto-refresh
Bearers. For long-running agent processes, mint short-lived
Bearers per logical unit of work rather than holding one for
hours.

## 5. Error envelopes

All endpoints in this page emit the standard JSON:API envelope
documented in `errors.md`. Common rejections:

| `source.pointer` / shape                        | Cause                                        |
|-------------------------------------------------|----------------------------------------------|
| `/email` "has already been taken"               | `admin.signUp` with an existing email        |
| `/password` "is too short"                      | password below minimum length                |
| top-level 401 (no envelope)                     | Bearer missing, expired, or revoked          |
| top-level 403 (no envelope)                     | role lacks permission for the action         |
| `/email` "already invited"                      | duplicate `invitations.create`               |
| `/tenant_slug` "not found"                      | tenant slug doesn't exist or invisible to Bearer |

A `tenants.create` `/name` uniqueness collision comes back as a
**409 Conflict** (not 422 — uniqueness constraints route to 409; see
`errors.md`) — names must be unique platform-wide. Re-attempt with a
different name or treat as "already exists" via `api.tenants.list`
lookup.

## 6. Gotchas

1. **`api.admin.confirmUser` is dev-only.** Production user
   confirmation happens via emailed link, outside the SDK. Treat
   the SDK method as a test fixture; do not ship code that calls
   it from a customer-facing path.

2. **`tenants.create` returns a tenant, not a Bearer.** The response is
   a flat `TenantResponse` (`id`, `slug`, `name`, `description`) with no
   `session_token`. After creating a tenant, mint a tenant-scoped Bearer
   via `createSession({ ..., tenantSlug, apiKey })` to act on it.

3. **Tenantless Bearer is a transient state.** Only two
   operations are useful with it: `tenants.create` (creator path)
   and `invitations.list`/`.accept` (invitee path). For everything
   else, mint a tenant-scoped Bearer first.

4. **`api.sessions.verify` is the source of truth for current
   scope.** Don't infer tenant or role from the Bearer string or
   from the `tenants.create` response — call `verify` if you
   need to know.

5. **No SDK methods for API key or PAT lifecycle.** Credential
   issuance is operator-console-mediated. If your agent process
   needs an API key, the operator provisions it out-of-band; the
   SDK consumes it but does not create it.

6. **Bearers do not auto-refresh.** A long-running process holding
   a Bearer for >24h will see 401s after expiry. Either mint
   short-lived Bearers per logical unit of work, or wrap calls in
   a re-mint-on-401 retry helper local to your code (the SDK does
   not provide one).

7. **Invitation acceptance is idempotent at the membership level
   but not at the invitation level.** A second `accept(id)` call
   on the same invitation will 422 (already accepted), but the
   first acceptance's membership is unaffected. Treat retries via
   the "already accepted" 422 the same as "already invited" — a
   benign idempotency signal.

8. **Multi-tenant Bearers don't exist.** A Bearer is scoped to
   exactly one tenant at mint time. To act as multiple tenants
   concurrently, mint one Bearer per tenant and use
   `createIsolatedPlatformApi` (see `AGENTS.md`).
