# @happyvertical/smrt-users

Multi-tenant user management with RBAC, hierarchical tenants, session handling, and SvelteKit integration.

## Installation

```bash
pnpm add @happyvertical/smrt-users
```

## Usage

### Application discovery conformance

The SvelteKit `createResourceListHandler()` export produces the app CLI
discovery response and now includes a deterministic `artifact`. Consumers can
import the published `@happyvertical/smrt-users/app-contract` entrypoint to
validate the schema/version selector and SHA-256 integrity digest before using
the embedded resource catalog. See
[`@happyvertical/smrt-app-cli`](../app-cli/README.md#discovery-conformance-artifact)
for the exact packaged-runtime pinning workflow.

### Roles and permissions

```typescript
import {
  RoleCollection, MembershipCollection, PermissionResolver,
} from '@happyvertical/smrt-users';

const db = { db: { type: 'sqlite', url: 'app.db' } };

// Seed system roles (owner, admin, member, viewer) — required at app init
const roles = await RoleCollection.create(db);
await roles.seedSystemRoles();

// Assign a user to a tenant with the admin role
const memberships = await MembershipCollection.create(db);
const adminRole = await roles.findBySlug('admin');
await (await memberships.create({
  userId: user.id, tenantId: tenant.id, roleId: adminRole.id,
})).save();

// Check permissions
const resolver = await PermissionResolver.create(db);
await resolver.hasPermission(user.id, tenant.id, 'articles.create');
```

### Manifest-derived permission catalog

s-m-r-t objects now contribute permissions automatically based on their public
surface area.

```typescript
import { SmrtObject, smrt } from '@happyvertical/smrt-core';

@smrt({
  api: { include: ['list', 'create', 'publish'] },
  cli: { include: ['get', 'archive'] },
  collection: 'articles',
  mcp: { include: ['update'] },
  tenantScoped: { mode: 'required' },
})
class Article extends SmrtObject {
  tenantId: string = '';
  title: string = '';

  async publish(): Promise<boolean> {
    return true;
  }

  async archive(): Promise<boolean> {
    return true;
  }
}
```

This produces the following permission slugs:

- `articles.read` from `list` or `get`
- `articles.create`
- `articles.update`
- `articles.publish`
- `articles.archive`

Non-public methods and actions that are not exposed through API, CLI, or MCP are
not added to the catalog.

### Sync the permission catalog

Use `syncPermissionCatalog()` during bootstrapping, migrations, or deploy hooks
to upsert discovered permissions into the `Permission` table.

```typescript
import { syncPermissionCatalog } from '@happyvertical/smrt-users';

const db = {
  db: {
    type: 'postgres' as const,
    url: process.env.DATABASE_URL!,
  },
};

const result = await syncPermissionCatalog(db);

console.log('created', result.created);
console.log('updated', result.updated);
console.log('unchanged', result.unchanged);
```

Catalog sync is additive and fail-closed:

- it creates missing `Permission` rows
- it updates `name`, `description`, and `category` by slug
- it does not auto-grant permissions to roles
- it does not delete stale permissions in v1

### App-defined permissions in `smrt.config.ts`

Use package config for permissions that do not come from the manifest.

```typescript
// smrt.config.ts
import { defineConfig } from '@happyvertical/smrt-config';

export default defineConfig({
  packages: {
    users: {
      permissions: {
        custom: [
          {
            category: 'app',
            description: 'Allows access to the operations dashboard',
            name: 'View Operations Dashboard',
            slug: 'operations.dashboard',
          },
          {
            category: 'audits',
            name: 'Inspect Audit Rows',
            postgres: {
              bindings: [
                {
                  action: 'select',
                  tableName: 'audit_logs',
                },
                {
                  action: 'insert',
                  tableName: 'audit_logs',
                },
              ],
            },
            slug: 'audits.inspect',
          },
        ],
        postgres: {
          enabled: true,
        },
      },
    },
  },
});
```

Custom permissions merge with manifest-derived permissions by slug. If the same
slug is registered with conflicting metadata, s-m-r-t throws so the mismatch is
visible early.

### Runtime permission registration

Use `registerPermissionDefinitions()` when a package or integration needs to
declare permissions at runtime.

```typescript
import {
  registerPermissionDefinitions,
  syncPermissionCatalog,
} from '@happyvertical/smrt-users';

const unregister = registerPermissionDefinitions([
  {
    category: 'billing',
    description: 'Allows exporting invoices',
    name: 'Export Invoices',
    slug: 'invoices.export',
  },
]);

try {
  await syncPermissionCatalog({
    db: { type: 'sqlite', url: 'app.db' },
  });
} finally {
  unregister();
}
```

### Postgres RLS enforcement

For Postgres, s-m-r-t can generate and apply row-level security policies directly
from the permission catalog.

```typescript
import {
  applyPostgresPermissionPolicies,
  generatePostgresPermissionSql,
  syncPermissionCatalog,
} from '@happyvertical/smrt-users';

const db = {
  db: {
    type: 'postgres' as const,
    url: process.env.DATABASE_URL!,
  },
};

await syncPermissionCatalog(db);

const preview = generatePostgresPermissionSql(db);
console.log(preview.targets);
console.log(preview.skipped);

await applyPostgresPermissionPolicies(db);
```

Automatic policy generation currently applies only to objects that are:

- tenant-scoped with `tenantScoped: { mode: 'required' }`
- backed by a real Postgres table
- mapped to a single tenant field

Automatic CRUD policy mapping is fixed in v1:

- `SELECT` -> `<collection>.read`
- `INSERT` -> `<collection>.create`
- `UPDATE` -> `<collection>.update`
- `DELETE` -> `<collection>.delete`

Optional-tenancy and global tables are skipped and returned in
`result.skipped` instead of generating unsafe policies. Custom permissions can
participate in RLS by adding explicit Postgres bindings as shown above.

### Access requests (request access / waitlist)

Capture a prospective user from a public form *before* they are a real `User`,
let an operator triage, and **graduate** an approved request into a `User`
(optionally attached to a tenant). `createAccessRequest` is **public-safe** (no
auth) — expose it from your own rate-limited endpoint. Operator methods are gated
by an optional `authorize` hook (capabilities `access-requests:read` /
`access-requests:manage`). Lifecycle events let apps send invites/notifications;
this package never owns email delivery.

```typescript
import { AccessRequestService } from '@happyvertical/smrt-users';

const accessRequests = await AccessRequestService.create({
  db: { type: 'postgres', url: process.env.DATABASE_URL },

  // Optional: gate operator methods against your permission system.
  // (createAccessRequest is always public-safe and never calls this.)
  authorize: async ({ capability, by }) => {
    if (!by || !(await isPlatformOperator(by, capability))) {
      throw new Error(`Missing capability: ${capability}`);
    }
  },

  // Optional: react to lifecycle changes (send a magic link on graduate, etc.).
  onEvent: async (event) => {
    if (event.type === 'access-request.graduated' && event.user) {
      await sendWelcomeEmail(event.user.email);
    }
  },
});

// 1) Public form handler (app adds rate-limiting) — no auth required.
const request = await accessRequests.createAccessRequest({
  email: 'jane@example.com',
  name: 'Jane Doe',
  source: 'www',
  context: { company: 'Acme', intendedUse: 'evaluation' },
});

// 2) Operator triages the queue.
const open = await accessRequests.listAccessRequests({
  status: AccessRequestStatus.REQUESTED,
  by: operatorId,
});
await accessRequests.approveAccessRequest(request.id, { by: operatorId });

// 3) Graduate into a User — operator picks new-vs-existing tenant per request:
//    a) brand-new tenant, requester as owner
const { user, tenant, membership } = await accessRequests.graduateAccessRequest(
  request.id,
  { by: operatorId, tenant: { create: { name: 'Acme Inc' } } },
);
//    b) existing tenant:  { tenant: { tenantId, role: 'member' } }
//    c) user only:        { tenant: 'none' }
// Graduation is idempotent and reuses the existing User/Membership paths.
```

### SvelteKit hooks

```typescript
// hooks.server.ts
import { createSessionHandler } from '@happyvertical/smrt-users/sveltekit';

export const handle = createSessionHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL },
  enterTenantContext: true,
  postgresRls: true,
  ttl: 7 * 24 * 60 * 60, // 7 days in seconds
  skipPaths: ['/api/health'],
});
// Populates event.locals: { user, permissions, tenantId, sessionId }

// +page.server.ts
import { createSessionCookie, destroySessionCookie } from '@happyvertical/smrt-users/sveltekit';

await createSessionCookie(event, userId, tenantId, { db }); // login
await destroySessionCookie(event, { db });                   // logout
```

### OIDC login with Kanidm or Dex

Kanidm and Dex both work through the generic s-m-r-t OIDC flow. Configure one or
more providers under `packages.users.auth.oidc.providers`, then add login and
callback route handlers.

```typescript
// smrt.config.ts
import { defineConfig } from '@happyvertical/smrt-config';

export default defineConfig({
  packages: {
    users: {
      auth: {
        oidc: {
          defaultProvider: 'kanidm',
          providers: {
            kanidm: {
              kind: 'kanidm',
              issuer: process.env.KANIDM_ISSUER!,
              clientId: process.env.KANIDM_CLIENT_ID!,
              clientSecret: process.env.KANIDM_CLIENT_SECRET,
              redirectUri: 'http://localhost:5173/auth/kanidm/callback',
            },
            dex: {
              kind: 'dex',
              issuer: process.env.DEX_ISSUER!,
              clientId: process.env.DEX_CLIENT_ID!,
              clientSecret: process.env.DEX_CLIENT_SECRET,
              redirectUri: 'http://localhost:5173/auth/dex/callback',
            },
          },
        },
      },
    },
  },
});
```

```typescript
// src/routes/auth/[provider]/login/+server.ts
import { createOidcLoginHandler } from '@happyvertical/smrt-users/sveltekit';

export const GET = createOidcLoginHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL! },
});
```

```typescript
// src/routes/auth/[provider]/callback/+server.ts
import { createOidcCallbackHandler } from '@happyvertical/smrt-users/sveltekit';

export const GET = createOidcCallbackHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL! },
  successRedirect: '/dashboard',
});
```

The callback verifies `state`, PKCE, issuer, audience, nonce, and the provider
JWKS-signed ID token, falling back to the OIDC UserInfo endpoint when the ID
token omits required profile claims like `email`. Temporary transaction cookies
are HMAC-signed with the provider `clientSecret` when present; public clients
can pass `transactionCookieSecret` to the route helpers. On success it creates
or reuses a s-m-r-t `Profile`, links an `OidcIdentity`, creates or reuses a `User`,
records `lastLoginAt`, and sets the standard s-m-r-t session cookie.

RFC 9207 authorization-response issuer validation uses exact string comparison
against the discovered issuer before an authorization code or provider error is
trusted. When discovery advertises
`authorization_response_iss_parameter_supported: true`, a missing `iss` is also
rejected. Remote MCP deployments must require their external authorization
server to advertise and emit `iss`; see the
[remote MCP authorization guide](../../docs/content/architecture/remote-mcp-authorization.md).

The typed [OIDC provisioning decision matrix](../profiles/src/testing/oidcProvisioningDecisionMatrix.ts)
is the canonical behavior contract shared with Profiles. Its executable rows
declare exact reuse and new-identity outcomes, resolver invocation and
rebinding, ownership/collision failures, readiness, retries, adapter support,
public errors, and permitted Profile/OIDC identity/User/session creation. For a
new identity, the Users path is deliberately fail-closed before User or session
creation unless the selected Profile is the one safe, unowned global `Person`
allowed by that matrix. An owned Profile still returns `profile_owned` unless
the application explicitly supplies the owner authorization described below.
An exact issuer/subject link may instead continue to its already-owned canonical
global `Person`, but it cannot be rebound.

Canonical Profile failures use `CanonicalPersonProfileError` from
`@happyvertical/smrt-profiles`, with codes `ambiguous_email`, `email_mismatch`,
`email_key_backfill_required`, `missing_profile`, `non_person`,
`reservation_conflict`, or `tenant_scoped`.
User ownership/provisioning failures use `OidcProvisioningError`, with codes
`ambiguous_identity`, `concurrency_conflict`, `profile_owned`, `rejected`,
`transaction_required`, `user_email_backfill_required`, or
`user_email_conflict`. `completeOidcLogin()` rejects with the full error. The
ready-made callback handler passes that error to a configured `failureRedirect`
callback; without one it returns a generic 401 and does not expose account,
resolver, or database details to the browser.

Applications that already own an identity-reconciliation policy can provide a
`resolveProfile` hook without replacing transaction cookies, token exchange,
claim verification, or session creation:

```typescript
// src/routes/auth/[provider]/callback/+server.ts
import { createOidcCallbackHandler } from '@happyvertical/smrt-users/sveltekit';

export const GET = createOidcCallbackHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL! },
  resolveProfile: async ({ claims, db }) => {
    // All reads and writes must use this transaction-bound `db` handle.
    const profile = await resolveApplicationIdentity({ claims, db });

    // undefined: use SMRT's secure default
    // null: reject this login
    // Profile: select an application-reconciled canonical global Person
    return profile;
  },
  successRedirect: '/dashboard',
});
```

The service and SvelteKit handler run the hook after protocol claim validation
and inside the same provisioning transaction as OIDC identity and User
creation. Direct `UserCollection.getOrCreateFromOidc()` callers must first
validate and trust their supplied claims. The hook may run again after a
concurrent unique-key conflict, so it must be idempotent. For a new
issuer/subject, a supplied Profile is still validated as the unique, unowned
global `Person` for a verified email; resolver reuse is rejected unless
`email_verified` is exactly `true`. For an exact existing issuer/subject,
`null` still rejects login, a supplied Profile must be the already-linked
Profile and cannot rebind it, and stable-link owner/canonical-Person checks
still apply. The resolver receives a separate frozen claims snapshot; retry
locks, identity lookups, and persistence retain s-m-r-t's immutable internal
snapshot.

An invitation or approval workflow that pre-provisions both the canonical
global `Person` and its approved owning `User` can authorize the first identity
binding with `authorizeProfileOwner`:

```typescript
// src/routes/auth/[provider]/callback/+server.ts
import { ProfileCollection } from '@happyvertical/smrt-profiles';
import { createOidcCallbackHandler } from '@happyvertical/smrt-users/sveltekit';

export const GET = createOidcCallbackHandler({
  db: { type: 'postgres', url: process.env.DATABASE_URL! },
  authorizeProfileOwner: async ({ claims, db, users }) => {
    // This application record is the authorization decision. Select by its
    // approved IDs; do not authorize an account from matching email alone.
    const approval = await findApprovedOidcUser({
      db,
      email: claims.email,
    });
    if (!approval) return undefined; // preserve SMRT's secure default

    const profiles = await ProfileCollection.create({ db });
    const [profile, user] = await Promise.all([
      profiles.get({ id: approval.profileId }),
      users.get({ id: approval.userId }),
    ]);
    if (!profile || !user) return null; // explicitly reject stale approval
    return { profile, user };
  },
  successRedirect: '/dashboard',
});
```

The authorizer runs after protocol validation and inside the provisioning
transaction. It receives frozen normalized claims, the transaction-bound `db`,
and a `UserCollection` bound to that same transaction. Return both selected
objects only after application authorization; `undefined` uses the fail-closed
default and `null` rejects. s-m-r-t reloads and verifies the selected IDs rather
than trusting the returned objects: `email_verified` must be exactly `true`,
the Profile must be the unique canonical global `Person` for the claim email,
exactly one User must own it, and that User must have the same normalized email.
An exact issuer/subject cannot be rebound. Identity creation and login remain
atomic, and a race retry may invoke the authorizer again, so its reads and
writes must use only the supplied handles and be idempotent. Supplying both
`resolveProfile` and `authorizeProfileOwner` is allowed only when they select
the same Profile.

When userinfo supplies a missing email, its `email_verified` value travels with
that email as one source-bound pair. s-m-r-t never borrows a verification flag
from the ID token for a userinfo address, or from userinfo for an ID-token
address.

The concurrency guarantee uses four database arbiters: nullable unique
`OidcIdentity.identityKey`, private unique
`oidc_profile_email_reservations.email_key`, nullable unique `User.emailKey`,
and unique `User.profileId`. `User.emailKey` is derived from the trimmed,
lowercase email on every save, preventing independent database connections from
creating ambiguous User rows for the same address. Profile and User keys share
the exported TypeScript `normalizeIdentityEmail()` implementation; identity
lookups never depend on adapter-specific SQL `lower()` or `trim()` behavior.
Before trusting those keys, identity lookup verifies that every stored key
on a returned candidate still equals the application-normalized source email.
Every OIDC path validates or synchronizes its canonical Profile and therefore
requires the Profile email-key readiness marker. Creating a User or checking
User email uniqueness additionally requires the User email-key marker. A stable
issuer/subject that already has an owning User skips only the User email-key
lookup and marker. Full table validation stays in the explicit backfill, while
guarded runtime paths use only indexed candidate rows.
In-process callbacks also acquire the exact issuer/subject and normalized email
locks in deterministic order, including when the same subject presents changed
email claims on independent database handles. SQLite and DuckDB also acquire a
database-URL transaction lock because one adapter cannot safely overlap
unrelated root transactions; PostgreSQL deadlock and serialization failures use
a bounded transaction retry. Owner-authorized binding uses the same contract:
pass the DuckDB root handle and let s-m-r-t serialize the callback transaction.
New OIDC Profiles use non-semantic unique slugs,
so equal IdP display names cannot overwrite one another through s-m-r-t's
natural-key upsert.
Existing installations must run `smrt db:status`, `smrt db:migrate`, then
`smrt db:status` before deploying this users version; legacy identities reserve
an address only after the Profile passes canonical validation, and existing
issuer/subject reuse synchronizes that reservation with the Profile's current
stored email. Stop or upgrade old Profile and User writers before migration.
Before migration, find duplicate ownership links:

```sql
SELECT profile_id, COUNT(*) AS user_count
FROM users
WHERE profile_id IS NOT NULL
GROUP BY profile_id
HAVING COUNT(*) > 1;
```

Reconcile every result before applying the unique Profile constraint; legacy
empty-string Profile placeholders should be normalized to `NULL`. Multiple
`NULL` links remain valid. After the schema migration, populate both durable
keys from a single deploy process:

```typescript
import { backfillProfileEmailKeys } from '@happyvertical/smrt-profiles';
import { backfillUserEmailKeys } from '@happyvertical/smrt-users';

await backfillProfileEmailKeys(database);
await backfillUserEmailKeys(database);
```

The supported backfills are transactional and idempotent. The User backfill
fails without changing rows if legacy emails are still ambiguous; reconcile
the reported normalized keys and rerun it. All OIDC paths require the Profile
marker; paths that create a User or arbitrate User email uniqueness also require
the User marker. Run both before enabling OIDC provisioning. Pass the
root database to provisioning on adapters such as DuckDB that do not support
nested savepoints; root adapters must expose `beginTransaction`. A handle
exposing only `transaction()` is ambiguous and fails closed before resolver
writes rather than risking a nested transaction that could roll back
caller-owned work. A transaction-bound handle reads an existing
`_smrt_backfills` table but never attempts tracker DDL; pass the root database
when initialization or recovery is needed. OIDC `iss` and `sub` are preserved as exact opaque,
case-sensitive identifiers (trim is used only to reject blank claims), so
whitespace-distinct subjects never reuse one another.

With `postgresRls: true`, s-m-r-t opens a request-scoped Postgres transaction,
loads the session, resolves permissions, and sets session variables used by the
generated RLS helpers:

- `smrt.tenant_id`
- `smrt.user_id`
- `smrt.session_id`
- `smrt.permissions`
- `smrt.super_admin_bypass`
- `smrt.system_context`

With `enterTenantContext: true`, the same request also enters
`@happyvertical/smrt-tenancy` context so regular collection access is scoped to
the current tenant in application code.

### Request-scoped database access

Generated SvelteKit helpers and custom server code can read the current
request-scoped database, which is especially useful when Postgres RLS is enabled
and you want collection operations to use the active transaction.

```typescript
import {
  getRequestScopedDatabase,
  withSessionPermissionContext,
} from '@happyvertical/smrt-users';

const response = await withSessionPermissionContext(
  {
    db: { type: 'postgres', url: process.env.DATABASE_URL! },
    enterTenantContext: true,
    postgresRls: true,
    sessionId,
  },
  async (context) => {
    const database = getRequestScopedDatabase();

    console.log(context.permissions);
    console.log(database === context.database); // true

    return new Response('ok');
  },
);
```

## Key Concepts

### Permission cascade (4 levels)

PermissionResolver evaluates permissions in order, where each level can add or remove grants:

1. **Tenant hierarchy** -- walk ancestors, apply TenantPermissionOverride at each level
2. **Membership role** -- base permissions from the user's role in the tenant
3. **Group roles** -- permissions from all groups the user belongs to in that tenant
4. **Membership overrides** -- per-user GRANT/DENY (DENY always wins)

Tenant-level inherited permissions are part of the effective permission set
returned by `resolvePermissions()` and `SessionService.loadSessionContext()`.

### Hierarchical tenants

Tenants support parent-child trees (max depth 10). Two flags control inheritance: `cascadePermissions` (parent pushes down) and `inheritPermissions` (child accepts). Both must be true for permissions to flow.

### Tenant policies

TenantService supports three modes: `flexible` (no auto-create), `personal` (auto-create on first login, deletable), `required` (auto-create, must keep at least one).

## API

### Models

| Export | Description |
|--------|-------------|
| `User` | Auth identity. Email auto-lowercased. `profileId` is a unique cross-package Profile reference (one User per non-null Profile). |
| `Tenant` | Organizational boundary. STI. Hierarchical via `parentTenantId`/`hierarchyPath`. |
| `Role` | Permission template. `tenantId = null` for system roles. `isSystem` blocks deletion. |
| `Permission` | Named capability. Slug format: `resource.action`. |
| `Session` | Server-side session. Secure UUID. TTL in seconds. |
| `Group` | Team within a tenant. Gains permissions via GroupRole. |
| `Membership` | User + Tenant + Role junction. UNIQUE(userId, tenantId). |
| `MembershipOverride` | Per-user permission grant/deny on a membership. |
| `TenantPermissionOverride` | Tenant-level permission override (INHERIT/GRANT/DENY). |
| `GroupMember`, `GroupRole`, `RolePermission` | Junction tables for groups and role-permission assignments. |
| `AccessRequest` | "Request access / waitlist" record captured before a `User` exists. Closed generated surface — all access via `AccessRequestService`. |

### Collections

| Export | Description |
|--------|-------------|
| `UserCollection`, `TenantCollection`, `RoleCollection` | Core CRUD. TenantCollection adds `createChild()`, `getTree()`. RoleCollection adds `seedSystemRoles()`. |
| `PermissionCollection`, `SessionCollection` | Permission CRUD with `findByIds()`. Session CRUD with `findValidSession()`, `deleteExpired()`. |
| `MembershipCollection` | Membership CRUD, `findByUserAndTenant()` |
| `MembershipOverrideCollection`, `TenantPermissionOverrideCollection` | Override management at membership and tenant levels |
| `GroupCollection`, `GroupMemberCollection`, `GroupRoleCollection`, `RolePermissionCollection` | Group and role-permission junction management |
| `AccessRequestCollection` | AccessRequest queries: `findByEmail()`, `findOpenByEmail()`, `findByStatus()`, `findOpen()` |

### Services

| Export | Description |
|--------|-------------|
| `PermissionResolver` | Resolves effective permissions via 4-level cascade. `hasPermission()`, `resolvePermissions()`. |
| `PermissionCatalogService`, `syncPermissionCatalog()` | Discovers manifest/config/runtime permissions and upserts them into `Permission` rows. |
| `registerPermissionDefinitions()` | Register app or integration permissions at runtime and receive an unregister cleanup function. |
| `generatePostgresPermissionSql()`, `applyPostgresPermissionPolicies()` | Preview or apply Postgres RLS helper functions and table policies. |
| `SessionService` | High-level session management. `createSession()`, `loadSessionContext()`, `destroySession()`. |
| `OidcLoginService` | Generic OIDC authorization-code login with PKCE for Kanidm, Dex, and other standards-compliant providers. |
| `backfillUserEmailKeys` | Idempotently populate durable normalized-email keys after migrating legacy Users; fails closed on duplicates. |
| `OidcProfileResolver` | Transaction-bound pre-provision hook for application identity reconciliation. |
| `OidcProfileOwnerAuthorizer` | Transaction-bound application authorization for binding a first identity to an existing canonical Profile and its sole approved User owner. |
| `NormalizedOidcClaims` | Frozen resolver claims with required normalized `email`. |
| `withSessionPermissionContext()` | Loads a session, optionally enters tenancy context, and exposes a request-scoped database/permission context. |
| `getCurrentSessionPermissionContext()`, `getRequestScopedDatabase()` | Read the active request/session context inside app code. |
| `TenantService` | Policy-driven tenant lifecycle. `ensureTenantForUser()`, `createTenantWithOwnership()`. |
| `AccessRequestService` | Request-access/waitlist lifecycle + graduation. `createAccessRequest()` (public-safe), `list`/`get`/`approve`/`decline`/`cancel`, `graduateAccessRequest()` (new/existing/no tenant). Capability + event hooks. |

### SvelteKit (`@happyvertical/smrt-users/sveltekit`)

| Export | Description |
|--------|-------------|
| `createSessionHandler` | SvelteKit handle hook that populates `event.locals`, and can also enter tenancy context and Postgres RLS request transactions |
| `createSessionCookie` | Set session cookie after login |
| `destroySessionCookie` | Clear session cookie on logout |
| `switchSessionTenant` | Change tenant context for current session |
| `beginOidcLogin`, `completeOidcLogin` | Low-level SvelteKit helpers for custom OIDC login routes |
| `createOidcLoginHandler`, `createOidcCallbackHandler` | Ready-to-use SvelteKit route handlers for OIDC login and callback |
| `createMobileAuthHandlers` | Mountable `/api/mobile` PKCE, bearer session, bootstrap, logout, and route-guard handlers |
| `resolveMobileUploadDedupKey` | Resolves `clientCaptureId` with `Idempotency-Key` fallback for app-owned multipart routes |
| `SessionLocals` | Type for `event.locals` (extend in `app.d.ts`) |

`createMobileAuthHandlers({ buildExtras })` places app-domain bootstrap data
under `MobileSessionBootstrap.extras`. Do not add app fields at the response
top level: they are outside the shared contract and the Kotlin client ignores
unknown top-level keys. Multipart ingestion remains app-owned; follow
[`mobile-upload-contract.md`](../../docs/content/architecture/mobile-upload-contract.md)
for authentication, deduplication, and status semantics.

### Types & Constants

| Export | Description |
|--------|-------------|
| `UserStatus`, `TenantStatus`, `SessionStatus`, `MembershipStatus` | Status enums |
| `AccessRequestStatus` | Access-request lifecycle enum (`REQUESTED`/`APPROVED`/`DECLINED`/`GRADUATED`/`CANCELED`) |
| `OverrideEffect`, `TenantPermissionEffect` | Override effect enums |
| `ACCESS_REQUEST_CAPABILITIES`, `AccessRequestError` | Operator capability slugs; typed domain error (`error.code`) |
| `DEFAULT_ROLE_SLUGS`, `DEFAULT_ROLES`, `DEFAULT_TENANT_POLICY` | System role slugs, role configs, default tenant policy |
| `DEFAULT_SESSION_TTL`, `MAX_TENANT_HIERARCHY_DEPTH` | 604800 (7 days in seconds), 10 |
| `TenantHierarchyError` | Thrown when hierarchy depth limit is exceeded |

## Dependencies

- `@happyvertical/smrt-core` -- ORM, `@smrt()` decorator, SmrtObject/SmrtCollection
- `@happyvertical/smrt-types` -- shared enums (UserStatus, SessionStatus, etc.)
- `@happyvertical/smrt-profiles` -- optional peer dependency for profile linking
- `jose` -- JWT/JWKS verification for OIDC and magic-link tokens
- `svelte` -- optional peer dependency for Svelte components

## License

MIT
