@happyvertical/smrt-users
v0.40.61
Published
Multi-tenant user management for the SMRT framework - users, tenants, roles, permissions, groups
Maintainers
Readme
@happyvertical/smrt-users
Multi-tenant user management with RBAC, hierarchical tenants, session handling, and SvelteKit integration.
Installation
pnpm add @happyvertical/smrt-usersUsage
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.readfromlistorgetarticles.createarticles.updatearticles.publisharticles.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
Permissionrows - it updates
name,description, andcategoryby 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>.readINSERT-><collection>.createUPDATE-><collection>.updateDELETE-><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 }); // logoutOIDC 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_idsmrt.user_idsmrt.session_idsmrt.permissionssmrt.super_admin_bypasssmrt.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:
- Tenant hierarchy -- walk ancestors, apply TenantPermissionOverride at each level
- Membership role -- base permissions from the user's role in the tenant
- Group roles -- permissions from all groups the user belongs to in that tenant
- 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 linkingjose-- JWT/JWKS verification for OIDC and magic-link tokenssvelte-- optional peer dependency for Svelte components
License
MIT
