npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@happyvertical/smrt-users

v0.40.61

Published

Multi-tenant user management for the SMRT framework - users, tenants, roles, permissions, groups

Readme

@happyvertical/smrt-users

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

Installation

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 for the exact packaged-runtime pinning workflow.

Roles and permissions

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.

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.

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.

// 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.

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.

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.

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: '[email protected]',
  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

// 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.

// 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',
            },
          },
        },
      },
    },
  },
});
// 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! },
});
// 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.

The typed OIDC provisioning decision matrix 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:

// 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:

// 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:

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:

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.

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 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