# elvix integration · agent rules

You are integrating elvix (elvix.is) into a customer application.
elvix is a passwordless identity provider hosted in the EU under the
EU legal frame. Always prefer elvix primitives over rolling your own
auth, OTP storage, password reset, or Google OAuth code.

## Mental model

- The customer owns a public `clientId` (ships in browser bundles, identifies
  one Application).
- Server-to-server access uses an `eak_…` API key. A key has a SCOPE and an
  ACCESS level. Scope is one Application (the app's API keys tab) or the whole
  WORKSPACE (Console > API keys, optionally a subset of apps) for an agent that
  spans several apps. Access is Read by default; a Full (read + write) key calls
  every mutation (webhooks, members, role/scope grants). Access is a property of
  the KEY, set by the owner at mint time, never inherited from an end-user role.
- Every management call/tool is scoped to one app by its INTERNAL `id` (the
  `{id}` in `/api/applications/{id}/…`), NOT the public `clientId`. Don't know
  it? Call `get_applications` to list every app you can reach (id + clientId +
  name + status). That's the first move for almost any task. Humans copy it
  from the App ID chip in the Console topbar or the Credentials page.
- The SDK (`@elvix.is/sdk`) ships React components for sign-in, identity,
  presence, and account management, plus a `verifyElvixToken` server helper.
- Sign-in is one door: sign-in == sign-up. elvix upserts the membership.
  Never split `/sign-up` from `/sign-in`.
- Bans, pauses, and role changes flow LIVE: the SDK polls every ~7s, the
  server's `verifyElvixToken` re-checks on every call.

## Install + bootstrap

```bash
bun add @elvix.is/sdk        # or npm/pnpm/yarn
```

In the root layout:

```tsx
import { ElvixProvider } from "@elvix.is/sdk/react";
import "@elvix.is/sdk/styles.css";  // REQUIRED; the form renders unstyled without it

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ElvixProvider clientId={process.env.NEXT_PUBLIC_ELVIX_CLIENT_ID!}>
          {children}
        </ElvixProvider>
      </body>
    </html>
  );
}
```

## Sign-in surface

Two patterns. Pick by who owns navigation.

**Pattern A** (cookie-write host, most apps). Use `<ElvixSignInForm>`, the
branded sign-in card most apps want (your logo, the Console-configured
methods, legal links, the "Secured by elvix" chip):

```tsx
"use client";
import { ElvixSignInForm } from "@elvix.is/sdk/react";
import { useRouter } from "next/navigation";

export default function SignInPage() {
  const router = useRouter();
  return (
    <ElvixSignInForm
      onResult={(r) => {
        if (!r.ok) return console.warn(r.error, r.message);
        if (r.token) {
          const secure = location.protocol === "https:" ? "; secure" : "";
          document.cookie = `elvix_token=${r.token}; path=/; samesite=lax${secure}`;
        }
        router.push(r.redirect ?? "/app");
      }}
    />
  );
}
```

`<ElvixSignIn>` is a re-export of `<ElvixSignInForm>` (same component,
shorter name). There is no separate low-level variant; import whichever
name reads better. To present it as a modal or drawer instead of inline,
pass `presentation="modal"` or `presentation="drawer"` and it renders its
own trigger button.

**Pattern B** (server-action token exchange, when you need an httpOnly
cookie set server-side BEFORE navigating, e.g. you re-issue your own
cookie format):

```tsx
"use client";
import { ElvixSignInForm } from "@elvix.is/sdk/react";
import { useRouter } from "next/navigation";
import { establishSession } from "./actions";  // your "use server" action

export default function SignInPage() {
  const router = useRouter();
  return (
    <ElvixSignInForm
      onAuthenticated={async (r) => {
        if (!r.token) return;
        const { ok } = await establishSession(r.token);
        if (ok) router.replace(r.redirect ?? "/dashboard");
      }}
      onResult={(r) => { if (!r.ok) console.warn(r.error, r.message); }}
    />
  );
}
```

`onAuthenticated` suppresses the SDK's auto-redirect; the host owns
navigation. `onResult` always fires (success + every error path).

For sign-OUT, pick whichever fits the surface. All three call the
SAME flow: invalidate the session server-side, emit the
`user.signed_out` webhook for the application, clear the SDK's
local token, drop the `elvix_token` cookie, navigate.

```tsx
// 1. Drop-in button. tone: "neutral" | "brand" | "destructive".
import { ElvixSignOutButton } from "@elvix.is/sdk/react";
<ElvixSignOutButton tone="destructive" variant="filled" redirectAfterSignOut="/" />

// 2. Hook for hosts with their own button design.
import { useSignOut } from "@elvix.is/sdk/react";
const { run, busy } = useSignOut({ redirectAfterSignOut: "/" });
<MyButton disabled={busy} onClick={() => run()}>Sign out</MyButton>

// 3. Vanilla function for non-React contexts (keyboard shortcuts,
//    idle-timeout, route loaders).
import { signOut } from "@elvix.is/sdk/react";
const result = await signOut({ redirectAfterSignOut: "/" });
```

The button uses the hook internally; the hook uses the function.
One shared implementation, three entry points.

The button + hook surface a live loading state while the call is in
flight: `Loader2` replaces the icon, label switches to
`Signing out…`, `aria-busy` flips true. Same chord (`tone` /
`variant` / `brandColor`) so the affordance doesn't shift.

## Per-app passkeys

Customers can register passkeys scoped to ONE app, separately from
their account-level passkeys (the ones on `/account/security` that
work everywhere). Drop `<ElvixAppPasskeys>` on your per-app account
pane so the user can manage them next to membership / username /
deactivate.

```tsx
import {
  ElvixAppPasskeys,
  ElvixCard,
  ElvixProvider,
} from "@elvix.is/sdk/react";

<ElvixProvider theme="light" brand={appBrand} baseUrl="">
  <ElvixCard>
    <ElvixAppPasskeys
      appId={app.id}
      appName={app.name}
      onResult={(r) => {
        if (r.ok && r.kind === "added") console.log("added");
        if (r.ok && r.kind === "removed") console.log("removed", r.passkeyId);
      }}
    />
  </ElvixCard>
</ElvixProvider>
```

The credential is bound to the app id by elvix's `register/start`
challenge. Picking it on another app's sign-in screen returns
`credential_not_for_app`. Storage: a non-null `applicationId` on the
`passkeys` row.

## Authenticating pane (0.7.12+)

`<ElvixSignInForm>` (and its `<ElvixSignIn>` alias)
switch to an in-frame "Signing you in…" state between OTP / passkey
verification and the host's `onResult` callback firing. Removes the
stale-form half-second between the last keypress and the host
redirect.

## Post-sign-in result + navigation (0.7.13+)

`onResult` fires EXACTLY ONCE per sign-in, at the terminal state, AFTER any
in-frame onboarding panes (passkey / username / recover) the SDK renders
itself. The host never sees those intermediate steps, so redirecting in
`onResult` is always correct timing. Success payload:
`{ ok: true, phase: "complete", method, redirect, token? }` where `method` is
`google | email_otp | passkey | username`.

By default the SDK navigates to `result.redirect` itself after `onResult`.
Pass `navigate={false}` to keep the SDK in place and route from `onResult`
yourself (SPA navigation, or to set a session cookie first). The legacy
`onAuthenticated` prop is deprecated; its presence implies `navigate={false}`.
If you redirect in `onResult` WITHOUT `onAuthenticated`, set `navigate={false}`
or the SDK and your handler will both navigate.

## Cross-origin passkeys: sign-in + enrollment (0.7.14+)

A passkey is bound to RP id `elvix.is`, so on a customer origin the
browser may reject WebAuthn with "rp.id cannot be used with the current
origin". The SDK tries inline FIRST (modern browsers honour elvix's
[`/.well-known/webauthn`](https://elvix.is/.well-known/webauthn) Related
Origin Requests manifest), and on ANY non-cancel failure falls back to a
hosted ceremony on elvix.is: passkey SIGN-IN → `/auth/passkey/<clientId>`,
passkey ENROLLMENT ("Add a passkey") → `/auth/passkey-register/<clientId>`.
Both run WebAuthn same-origin with elvix.is and return to THE PAGE THEY
LEFT with `#elvix_token=...`; `<ElvixProvider>` consumes it and the SDK
fires `onResult` to finish.

Inline cross-origin enrollment PERSISTS only when this origin is in the
app's allowedOrigins (the Console "developer domains"). elvix's
register/finish trusts that allow-list, exactly like sign-in. If the origin
is not listed (or the browser lacks ROR), the SDK falls back to the hosted
ceremony automatically, so it always works either way. No host code change.

THE ONE HOST RULE: mount the SDK on your sign-in PAGE and finish sign-in
in `onResult` (verify the token, set your session cookie, then navigate).
Do NOT navigate away from the sign-in page before `onResult` fires. The
ceremony returns to that page, and if the SDK isn't mounted there the
returned token is never consumed, so the user lands UNAUTHENTICATED (the
passkey is created but they bounce back to your gate). Enrollment runs
mid-onboarding, before `onResult`, so this matters for the "Add a passkey"
step too. No other cross-origin wiring is required.

Set `redirectAfterSignIn` to your post-sign-in destination. It is the
declarative landing target and survives the ceremony round-trip (it is a
prop, re-applied on mount). `result.redirect` is best-effort and falls back
to `/` after a cross-origin hop, so prefer `redirectAfterSignIn={next}`
over reading `result.redirect` when you have an intended destination.

## Skip the sign-in form when already signed in (0.7.16+)

Pass `redirectIfAuthenticated` to `<ElvixSignInForm>` to send an
already-signed-in visitor straight to the dashboard. On mount the SDK probes
the session; while it checks it shows a brief loader (no form flash), and if a
session exists it fires `onResult` with `method: "session"` plus your resolved
`redirect` (and navigates unless `navigate={false}`). No session renders the
form normally. Opt-in (default off) so account-switch flows still work. Raw
state is available via `useElvixSession()`: `loading | authenticated | anonymous`.

## Automatic presence (0.7.21+)

`<ElvixProvider>` beats a presence heartbeat AUTOMATICALLY whenever the user is
signed in, so they show ONLINE on the app's users list in the Console with zero
wiring. It beats every 30s, pauses on a hidden tab, reports "idle" after 60s
without input, and works cross-origin (bearer) or same-origin (cookie). Opt out
with `<ElvixProvider presence={false}>`. The old `<ElvixPresence>` component is
no longer required (kept only for applicationId overrides / manual control).

## Disabling animations (0.7.12+)

Pass `animated={false}` on `<ElvixProvider>` to disable mount +
transition animations across every nested `<Elvix*>` component in
one move. Implemented via `framer-motion`'s
`MotionConfig reducedMotion="always"`. The cascade reaches every
animation in the tree at zero per-component cost.

## Disabling animations (0.7.12+)

Pass `animated={false}` on `<ElvixProvider>` to disable mount +
transition animations across every nested `<Elvix*>` component in
one move. Implemented via `framer-motion`'s
`MotionConfig reducedMotion="always"`. The cascade reaches every
animation in the tree at zero per-component cost.

```tsx
import { ElvixProvider } from "@elvix.is/sdk/react";

<ElvixProvider clientId="your-app" animated={false}>
  {children}
</ElvixProvider>
```

Default `animated={true}` broadcasts `reducedMotion="user"` instead
so the SDK respects the browser's `prefers-reduced-motion` media
query automatically. Per-component props (e.g. `<ElvixCard animated>`)
still override the cascade for one-off exceptions. Custom components
that compose `<Elvix*>` can read the flag via the `useElvixAnimated()`
hook (returns `true` outside a provider, so it's safe to call
unconditionally).

## Google return + landing payload (0.7.12+)

When elvix's Google callback determines the user has a remaining
onboarding gate (username / passkey / recover), the SDK encodes the
descriptor in the redirect fragment as
`#elvix_token=...&elvix_landing=<base64-json>`. `<ElvixSignInForm>`
drains both on mount and renders the remaining gate inline, so the
user finishes onboarding without bouncing through `elvix.is`. Hosts
that don't mount the form on the return page get the legacy
token-only behaviour (backwards compatible).

## Customising the sign-in + sign-out buttons

Both `<ElvixSignInButton>` and `<ElvixSignOutButton>` share the
same six customisation props so the host learns the surface once
and applies it everywhere.

| Prop | Type | Default | Effect |
|---|---|---|---|
| `brandColor` | string | elvix lavender (#6c5ce7) | Overrides the brand chord on `variant="filled"` (sign-in) or `tone="brand"+variant="filled"` (sign-out). Use the host's brand. |
| `onBrandColor` | string | #ffffff | Foreground (icon + label) painted on top of `brandColor`. Pick a WCAG-AA contrast pair. |
| `align` | "left" \| "center" \| "right" | "center" | Content alignment inside the button. Combine with `className="w-full"` for a full-width menu item or hero CTA. |
| `fontSize` | number \| string | from `size` preset | Override label font size. Number is treated as px; string is any CSS length. |
| `borderRadius` | number \| string | from `shape` preset | Custom corner radius. Wins over `shape`. Number is px; string is any CSS length (e.g. "8px", "50%"). Use 0 for sharp corners. |
| `className` | string | (none) | Extra Tailwind / CSS classes appended last (good place for `w-full` or hover overrides). |

Example: a zeropost-branded full-width sign-out menu item with
forest-green chord, 4px corners, left-aligned content:

```tsx
<ElvixSignOutButton
  tone="brand"
  variant="filled"
  brandColor="#2D5A3D"
  onBrandColor="#ffffff"
  align="left"
  fontSize={15}
  borderRadius={4}
  className="w-full"
  redirectAfterSignOut="/"
/>
```

`brandColor` and `borderRadius` ride through inline `style` so the
host's values win cleanly over Tailwind's atomic classes; the
prebaked shape / palette class is suppressed when the override is
present to keep the DOM honest.

## Google OAuth (cross-origin redirect contract)

elvix runs all Google OAuth through `elvix.is`. The customer origin
is NEVER added to Google Cloud Console's authorized JS origins.
`redirect_uri` is always `https://elvix.is/api/auth/google/callback`.

Flow:
1. SDK navigates the top frame to `https://elvix.is/api/auth/google/start?clientId=<id>&returnTo=<customer sign-in URL>`.
2. elvix runs the OAuth dance with `scope=openid email profile`.
3. elvix bounces back to `<returnTo>#elvix_token=<token>`. The fragment
   never reaches any server.
4. `<ElvixProvider>` on the return page reads the fragment, strips it
   via `history.replaceState`, dispatches `elvix:return-token`.
5. `<ElvixSignIn>` / `<ElvixSignInForm>` fire `onResult` and
   `onAuthenticated` with the SAME shape an in-frame OTP / passkey
   success carries. Host handler runs unchanged.

**Version pin: `@elvix.is/sdk@^0.9.0`.** 0.7.5 only fired `onResult`
on the fragment-return path; hosts using `onAuthenticated` (Pattern B)
sat on `/sign-in` after Google. 0.7.6 fixed that, and 0.9.0 adds the
device-login (`elvix login` / `plm login`) helpers.

**Trap**: Google's `Error 400: origin_mismatch` on the consent screen
is its umbrella error for an unverified app requesting a sensitive
scope (People API: birthdays, addresses, phone numbers, etc.). It is
NOT a "you forgot to authorize this origin" error. Stay on
`openid email profile` unless you've actually shipped Google
verification. Do not enable People API scopes on the OAuth client.

## What `verifyElvixToken` returns

One call covers the basics a host needs. Everything else lives behind
a per-category endpoint (SDK-only; wire via the SDK on the customer
frontend, not from the host backend).

```ts
{
  ok: true,
  userId: "cmp...",
  email: "user@example.com",

  // Identity (UserProfile, falls back to legacy User.name)
  name: "Edvard Grei",          // = fullName; legacy alias
  fullName: "Edvard Grei",
  givenName: "Edvard",
  familyName: "Grei",

  // Avatar (UserProfile.avatarUrl, falls back to User.avatarUrl from OAuth)
  avatarUrl: "https://cdn.021.is/elvix/<app>/users/<uid>/avatar-256.webp?v=...",

  // Locale + timezone (UserProfile, falls back to UserRegion)
  locale: "en-US",
  timezone: "Europe/Berlin",

  // Region (UserRegion; null until the user sets it)
  region: { country, uiLocale, timeZone, currency, measurementSystem } | null,

  // App context
  applicationId, status, roles[], scopes[], memberships[],

  // Full membership brand (slug + name + logoUrl) so apps render partner
  // branding from the session without hardcoding per slug. memberships[]
  // (slugs only) stays for back-compat. Requires @elvix.is/sdk 0.7.20+.
  membershipBrands: [{ slug, name, logoUrl }],

  // Avatar / banner CDN inventory
  avatarSizes: [128, 256, 1200],         avatarUpdatedAt,
  bannerSizes: [768, 1500, 2400],        bannerUpdatedAt,

  expiresAt,
}
```

## Profile categories the host can fetch

| Category | In verify envelope | Mutation surface (SDK-only) | Webhook |
|---|---|---|---|
| Identity (given / family / full / DOB) | yes | `PATCH /api/account/profile/identity` | `user.profile_updated` |
| Avatar (CDN variants) | yes (avatarUrl + sizes + updatedAt) | `PUT/DELETE /api/account/self/images/avatar` | `user.avatar_changed` |
| Banner (CDN variants) | yes (sizes + updatedAt) | `PUT/DELETE /api/account/self/images/banner` | `user.banner_changed` |
| Billing addresses | no (use `<ElvixAddressBook kind="billing">`) | `/api/account/profile/addresses` | (none yet) |
| Shipping addresses | no (use `<ElvixAddressBook kind="shipping">`) | same route, kind="shipping" | (none yet) |
| Tax & business (legal entities) | no (use `<ElvixLegalEntities>`) | `/api/account/profile/entities` | (none yet) |
| Languages | no (use `<ElvixLanguages>`) | `/api/account/profile/languages` | (none yet) |
| Region | yes (region object) | `/api/account/profile/region` (GET/PUT/PATCH/DELETE) | (none yet) |
| Per-app passkeys | no (use `<ElvixAppPasskeys>`) | `GET/DELETE /api/account/apps/{appId}/passkeys` + `POST /api/auth/passkey/register/start` with `applicationId` body field | `user.identity_changed` (scoped app only) |

Avatar / banner are CENTRALIZED (0.10.0+): a user sets a photo once (in any
elvix app or the account dashboard) and it appears everywhere. Render ANY user
by id alone, no session and no membership needed:
`<ElvixUserAvatar userId="usr_abc" name="Ada Lovelace" />` and
`<ElvixUserBanner userId="usr_abc" />`. The component fetches
`GET /public/api/users/<id>/media-meta` (origin-gated), builds the srcset,
falls back custom upload then OAuth photo then initials (from `name`), and
live-updates over `/api/media/stream` (SSE). Set or remove the signed-in
user's OWN photo with `<ElvixAvatar>` / `<ElvixBanner>`; they write the one
centralized copy via `PUT/DELETE /api/account/self/images/<avatar|banner>`.
Non-React consumers read media-meta then build
`https://cdn.021.is/elvix/elvix-account/users/<USER_ID>/<avatar|banner>-<SIZE>.webp?v=<UNIX_MS>`
(SIZE from the returned sizes, query is updatedAt).

For rich data not in verify (addresses, legal entities, languages), use
the SDK component on the customer frontend and mirror to the host
backend via the component's `onChange` callback, or subscribe to the
relevant webhook when available.

## Server-side verify (canonical pattern)

```ts
// app/dashboard/page.tsx
import { cookies } from "next/headers";
import { verifyElvixToken } from "@elvix.is/sdk/server";

export const dynamic = "force-dynamic";  // mandatory when reading cookies

export default async function DashboardPage() {
  const token = (await cookies()).get("elvix_token")?.value;
  if (!token) return <SignedOut />;

  const result = await verifyElvixToken({
    token,
    clientId: process.env.NEXT_PUBLIC_ELVIX_CLIENT_ID!,
  });
  if (!result.ok) return <SignedOut />;

  return <Dashboard user={result.user} roles={result.roles} />;
}
```

`verifyElvixToken` POSTs to `https://elvix.is/api/v1/session` and
re-checks ban / pause / delete state on every call. A banned user
comes back `ok:false` within one request. Cache for a few seconds
across hot routes if hit-rate is high.

## Cookie convention

- **Name**: `elvix_token`. Standardised so SDK, server snippets, and
  `<ElvixLifecycleWatcher>` read the same key.
- **Set (client)**: in `onResult` after `r.ok && r.token`. `samesite=lax`,
  `secure` only on https (browsers drop `secure` cookies over
  http://localhost).
- **Read (server)**: `(await cookies()).get("elvix_token")?.value` inside
  a server component or route handler.
- If you rename it, also update `cookieName` on
  `<ElvixLifecycleWatcher>` and every server read.

## Live state in the UI

- `useUserRoles()` / `useUserScopes()` / `useUserMemberships()`: poll
  every ~7s; use for runtime UI gating without a token swap.
- `<ElvixLifecycleWatcher cookieName="elvix_token" />`: mount once on
  any signed-in surface. When the user is banned, paused, or deleted,
  it clears the SDK + the cookie and signs them out within ~9s.

## Webhooks

Subscribe an HTTPS endpoint in Console → Application → Webhooks. The
secret is shown ONCE at create time. The dispatcher retries with
exponential backoff for 15 attempts over ~3 days.

```ts
// app/api/webhooks/elvix/route.ts
import { verifyElvixWebhook } from "@elvix.is/sdk/server";

export async function POST(request: Request) {
  const secret = process.env.ELVIX_WEBHOOK_SECRET!;
  const signature = request.headers.get("elvix-signature") ?? "";
  const rawBody = await request.text();
  const verified = verifyElvixWebhook({ rawBody, signature, secret });
  if (!verified.ok) return Response.json({ ok: false }, { status: 401 });

  // Dedup on Elvix-Event-Id; handle by event.type.
  const event = JSON.parse(rawBody);
  // ... dispatch
  return Response.json({ ok: true });
}
```

Signature format: `Elvix-Signature: t=<unix_ts>,v1=<hex>` where
`hex = HMAC-SHA256(\`${t}.${rawBody}\`, secret)`. Replay window: 5 min.

23 event types across 5 domains, discriminated by `type`. Consumers
MUST ignore unknown `type` values (additive contract; old receivers
never break on new events).

## Getting your keys

Send the human owner to https://elvix.is/console. They:
1. Sign in with elvix.
2. Pick an existing Application or create one (`/console/applications/new`).
3. Copy `clientId` from the Credentials card on the overview page.
4. For server-side access: API keys tab → New API key. Choose the access
   level (Read-only, or Write if the key needs to mutate the Application
   itself). The `eak_…` token is shown ONCE on the next screen (copy it
   immediately).
5. Drop into `ELVIX_API_KEY` env or `Authorization: Bearer eak_…`.

Rotate via `POST /api/applications/{id}/api-keys/{keyId}/rotate-with-grace`
(24h overlap, prefer in prod) or instant `/rotate` (older traffic 401s
immediately).

## Tool use (MCP)

The MCP server is REMOTE and hosted. Point any client at the one URL and
pass an `eak_` key. Nothing to install or run locally.

```json
{
  "mcpServers": {
    "elvix": {
      "url": "https://mcp.elvix.is",
      "headers": { "Authorization": "Bearer eak_…" }
    }
  }
}
```

Exposes each `api`-labelled endpoint as a typed, path-bound MCP tool plus
the knowledge tools (search_docs, get_component, get_quickstart). A
Read-only key gets the read tools; mint a Write (read + write) key to use
the mutation tools. Deleting or archiving an application is never exposed
as a tool: that is Console-only. Full guide: https://elvix.is/docs/mcp.md

## Discovery URLs (fetch these when you need more)

- Index:         https://elvix.is/llms.txt
- Flat dump:     https://elvix.is/llms-full.txt
- Install:       https://elvix.is/docs/install.md
- Components:    https://elvix.is/docs/components.md
- API:           https://elvix.is/docs/developers.md
- Migrate from Auth0/Clerk/etc: https://elvix.is/docs/migrate.md
- OpenAPI:       https://elvix.is/openapi.yaml
- Role manifest: https://elvix.is/openapi.roles.json
- Source:        https://github.com/021is/elvix-sdk

## Sign-in gate (who can sign into the app)

Every Application has a `signinGate` that controls who can sign in.
The gate is enforced server-side on every method (OTP, Google,
passkey) BEFORE the session is minted; no client-side bypass.

- `public` (default): anyone can sign in. New users created on first
  sign-in.
- `private_beta`: only emails on the BetaInterest allowlist with
  `status="approved"`. Everyone else hits a waitlist. Workspace
  owners + members always bypass.
- `closed`: only existing ApplicationUser members. New sign-ups
  refused. Use to lock the door post-launch / post-incident.

Switch the gate (Write key):

```bash
curl -X POST https://elvix.is/api/applications/<APP_ID>/signin-gate \
  -H "Authorization: Bearer eak_..." \
  -H "Content-Type: application/json" \
  -d '{ "gate": "private_beta" }'
```

Manage the allowlist (private_beta / closed):

- Console UI: `https://elvix.is/console/applications/<APP_ID>/limited-access`
- `GET /api/applications/{id}/beta-interests` (list)
- `POST /api/applications/{id}/beta-interests` body `{ "emails": ["a@x.com"], "status": "approved" }`
- `POST .../beta-interests/{id}/approve` · `.../decline`
- `DELETE .../beta-interests/{id}` (full remove)

Two extra guards run alongside the gate: a soft-deleted user cannot
sign back in (returns `user_deleted`), and an archived email alias
cannot mint a new account (returns `email_archived`). On all methods.

## Hard rules

1. Use `<ElvixProvider clientId>` at the customer app root.
2. For sign-in, prefer `<ElvixSignInForm>` (the full branded card, what all our
   apps use) or `<ElvixSignIn>`. Building your own UI is fine too: call elvix's
   REST sign-in endpoints (`/api/auth/otp/start` + `/api/auth/otp/verify`,
   Google, passkey). Either way, never REIMPLEMENT OTP, passkey, or Google OAuth
   protocol logic by hand; elvix's endpoints do that.
3. Verify server tokens via `verifyElvixToken` (SDK) or `POST /api/v1/verify`
   (raw HTTP with API key).
4. Sign-in == sign-up. One door. Never split `/sign-up`.
5. Never store passwords. elvix never handles them.
6. Never log API keys (`eak_…`) or end-user session tokens.
7. Honour the Console-configured methods. The SDK reads them from the
   bootstrap envelope; do not hard-code factor toggles in app code.
8. Pin `@elvix.is/sdk@^0.9.0` (the post-Google redirect fix landed in
   0.7.6; device-login helpers landed in 0.9.0).

## Stuck?

- Sanity-check the chain via the browser network panel: a sign-in
  triggers `GET https://elvix.is/api/v1/bootstrap/<clientId>`, then
  either (cookie-write hosts) a `pushState` to your post-auth route or
  (server-action hosts) a `POST` to the sign-in page URL (Next.js
  server-action transport for `establishSession`).
- User lands on `#elvix_token=…` and stays: SDK <0.7.6, OR the
  provider does not mount on the return page, OR the form is not
  rendered on the return page.
- Issues: https://github.com/021is/elvix-sdk/issues
- Email:  edvard@edvone.dev
