Skip to main content

@happyvertical/smrt-users

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

Installation​

pnpm add @happyvertical/smrt-users

Usage​

Auth component adapter​

The @happyvertical/smrt-users/svelte authentication components take a UsersAuthAdapter. Bind each callback to an application server action; the components never receive a session bearer, API-key value, magic-link signing secret, or OIDC client secret. On the server, password actions use createPasswordCredentialHandlers(), magic-link actions use MagicLinkService, and OIDC actions use OidcLoginService. Session and API key revoke actions must verify the current account owns the selected record before calling SessionService.destroySession() or the profile API-key service. Passkey sign-in is deliberately hidden unless the host passes a passkeyCeremony callback to createUsersAuthAdapter(). An endpoint alone cannot enable it: the callback must perform the WebAuthn browser ceremony and delegate its verified result to an authorized server action. Build it with createPasskeySignInCeremony() against the routes of createPasskeyCredentialHandlers() (see "Passkey sign-in").

Auth components display safe, registered users.auth.* messages when an adapter rejects; callback diagnostics are never rendered. Configure these messages through the s-m-r-t UI i18n context. Existing error and success feedback updates when the locale changes, preserving form drafts without repeating the auth action. Default English messages are registered by the auth module.

<script lang="ts">
import { SignInForm, type UsersAuthAdapter } from '@happyvertical/smrt-users/svelte';

const adapter: UsersAuthAdapter = {
async signInWithPassword({ email, password }) {
await fetch('/auth/password/sign-in', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
});
},
};
</script>

<SignInForm {adapter} />

The endpoint in this example is application-owned. It must call the package handler/service on the server; it is not a generated public identity API.

Source-bound mailbox enrollment​

MailboxEnrollmentService proves mailbox ownership before an application creates a destination. Unlike returning-user mailbox authentication, it needs no existing User or membership. It creates no session or membership. Both calls require an initialized caller transaction executor:

const enrollment = await MailboxEnrollmentService.create({
db: tx, transaction: 'caller', secret, loginLimiter: rootLimiter,
});
await enrollment.request({
email: storedSubmission.recipient,
binding: canonicalSourceBinding,
sourceKey: trustedClientAddress,
deliver: sendMailboxProof,
}); // { requested: true }; proof goes only to the normalized mailbox callback

const identity = await enrollment.authenticate({
token: submittedProof,
binding: canonicalSourceBinding,
expectedUserId: currentUser?.id,
sessionId: currentSession?.id,
}); // { userId, profileId, email }; active canonical global User + Person
// Authorize and write host destination/membership using this same tx.

Recompute binding from trusted immutable source data on every call, for example JSON.stringify(['host:submission:v1', workspaceId, submissionId, payloadSha256, normalizedRecipient]). Never trust a posted binding, workspace, recipient or destination. The proof signs its SHA-256 digest with the isolated smrt:mailbox-enrollment:v1 issuer, expires in ten minutes, and is single use. It cannot authenticate through login or invitation endpoints.

rootLimiter must persist independently of tx; request budgets survive transport failure and rollback. SQLite's single connection needs an independent limiter store. The trusted deliver callback must send only to its supplied email, never return or log the token, and reject on transport failure. A proof mailed by a rolled-back request is unusable. There is no durable outbox here.

Authenticate reuses only an active, unambiguous, canonically owned global identity. It refuses pending/suspended/inactive Users and unowned matching Profiles instead of adopting them. Existing actor/session constraints use the same first-class-session policy as mailbox login. Keep proof consumption, identity creation and all host writes in one transaction and roll back on any error. Concurrent distinct proofs may hit canonical identity conflicts: retry the entire transaction after rollback. The failed proof remains available.

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
  • it commits created/updated rows in small batched transactions; when calling it inside your own transaction, pass that transaction's database handle

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: '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​

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

The handler only handles its own session/context initialization failures: without postgresRls the request continues once with empty locals; with postgresRls it returns 500. Errors thrown downstream of resolve() — including SvelteKit redirect() from a later handle — propagate unchanged and the downstream chain runs exactly once.

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

Register the client: redirect URL and landing URL​

Register two URLs with the IdP, not one. getOidcClientRegistration() derives both from the same callbackPath/loginPath options the route handlers use (defaults /auth/<provider>/callback and /auth/<provider>/login):

import { getOidcClientRegistration } from '@happyvertical/smrt-users/sveltekit';

const { redirectUri, landingUrl } = getOidcClientRegistration(
'https://app.example.com',
{ provider: 'kanidm' },
);
// redirectUri: https://app.example.com/auth/kanidm/callback
// landingUrl: https://app.example.com/auth/kanidm/login

For Kanidm:

kanidm system oauth2 add-redirect-url <client> https://app.example.com/auth/kanidm/callback
kanidm system oauth2 set-landing-url <client> https://app.example.com/auth/kanidm/login

The landing URL must be the login start route, not the app's home page. Kanidm (verified through 1.11) holds the app's pending authorization in a short-lived cookie. It discards that cookie whenever the user passes through its own /ui/login page, for example by pressing "Return to Login" after a mistyped password, and also after 15 minutes. After signing in, the user then lands in Kanidm's apps panel instead of returning to the app. The app never sees this happen, so the app tile in that panel, which links to the landing URL, is the only way back. When the tile points at the login start route, one click signs the user in, because their Kanidm session already exists. When it points at the home page, the user arrives signed out and has to start sign-in again. Other IdPs that offer an "initiate login URI" or app-launcher URL should use the same landingUrl.

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 {
backfillLegacyUserProfiles,
backfillUserEmailKeys,
} from '@happyvertical/smrt-users';

await backfillProfileEmailKeys(database);
await backfillUserEmailKeys(database);
await backfillLegacyUserProfiles(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. The legacy Profile backfill creates and links a canonical global Person for each User whose Profile link is still null or blank. It preserves the User ID and data, creates no OIDC identity, and never infers ownership from email: any same-email Profile, duplicate User email, or conflicting reservation must be reconciled explicitly before retrying. Its marker records a successful pass but does not hide Users imported later. All OIDC paths require the Profile marker; paths that create a User or arbitrate User email uniqueness also require the User marker. Run all three 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.

Login rate limiting and lockout​

Every credential-based sign-in (terminal approval, device PIN, password sign-in, and any passkey flow an app adds) draws from one shared budget, LoginAttemptLimiter (#3273). The budget lives in users_login_attempts, so it holds across every replica on Postgres; keys are hashed, so the table never becomes an index of emails or IPs.

import {
InvalidCredentialsError,
LoginAttemptLimiter,
LoginRateLimitError,
} from '@happyvertical/smrt-users';

const limiter = await LoginAttemptLimiter.create({
db,
maxAttempts: 5, // per key, per window
windowSeconds: 300,
lockout: { baseSeconds: 60, factor: 2, maxSeconds: 3600 },
keyPepper: process.env.LOGIN_KEY_PEPPER,
});

const lease = await limiter.reserve({
kind: 'password',
subject: submittedEmailKey, // what the client typed — NOT a resolved user
source: clientIp, // or a device/station id
});
if (!lease.allowed) throw new LoginRateLimitError(lease); // 429 + Retry-After

try {
// Always do the same work for an unknown account as for a wrong password.
const ok = await verifyPassword(user?.passwordHash ?? DUMMY_HASH, password);
if (!ok || !user) {
await lease.fail();
throw new InvalidCredentialsError();
}
await lease.succeed();
} catch (error) {
if (!(error instanceof InvalidCredentialsError)) await lease.release();
throw error;
}

Subject and source are independent budgets; either one exhausted refuses the attempt, and a refusal on the source hands the subject reservation back so one noisy address cannot burn every account. The failure that exhausts a window — or that completes another maxAttempts consecutive failures, however they were paced across windows — locks the key for base × factor^n seconds, where n counts consecutive exhausted budgets. A success resets the subject's streak only; a shared source (a tablet, an office address) keeps its history, so one valid credential cannot clear the backoff on guessing at others. Instead a source sheds one failure per sourceStreakDecaySeconds (default: twice the window) since its last failure: mistakes spaced further apart than that never add up to locking everyone behind the source, while a burst of guesses leaves no time to decay and still escalates. Either streak is forgiven outright after streakResetSeconds without a failure. Every decision is reported to a LoginAuditSink — by default a durable UsersLoginAuditEvent row (pruned after 90 days by the retention sweep), or pass audit to forward into the host's own log, or audit: false. TerminalAuthService uses this limiter for approvals, keyed on the approving user only under its own subject prefix, so that budget never pools with the same person's PIN or password budget; pass loginLimiter to share one instance and audit sink.

Separate account and address budgets (#3531)​

By default both budgets use maxAttempts, windowSeconds and lockout. Size them independently with subject and source (each field falls back to the top-level option), or turn the address budget off with source: false:

const limiter = await LoginAttemptLimiter.create({
db,
subject: { maxFailures: 5, lockout: { baseSeconds: 60, maxSeconds: 3600 } },
source: { maxFailures: 100, windowSeconds: 300, lockout: false },
// or: source: false,
});

When a failure starts a source lockout the audit sink also receives one source-locked entry carrying the source hash and retryAfterSeconds but no subject hash and none of the caller's metadata, so it attributes an outage to an address or device without naming the accounts that were tried. It is reported at most once per source per sourceLockoutAuditSeconds (default 300) per limiter instance. Refusals are recorded as denied entries with the refusing scope in their metadata.

Deployments without a trustworthy client address. If the app sits behind a reverse proxy that does not forward a client address you can trust, every browser shares one source, and a handful of failures from anyone — including for emails that do not exist — locks password sign-in out for everyone. Prefer, in order:

  1. Make the proxy set a client address you trust (and strip any the client sent), and pass that, not a raw X-Forwarded-For, as ipAddress.
  2. Otherwise give the source budget a much larger allowance with no lockout (source: { maxFailures: 200, lockout: false }), so it only caps bursts.
  3. Or disable it (source: false) and rate limit /signin at the proxy instead. The per-account budget still applies to every attempt.

source: false applies to every credential kind using that limiter. A PIN sign-in keys its source on the enrolled device, which is what stops guessing across many people's short PINs on one tablet, so if PIN and password share an instance, prefer option 2 or give PasswordCredentialService its own limiter options rather than disabling the source budget for both.

Per-person PIN on an enrolled device​

A shared tablet enrolled through the terminal device-code grant holds a device-account bearer session. DeviceCredentialService (#3276) lets a person sign in on that device only with a short credential and mints a session layered on the device session: it carries authMethod: 'pin' and parentSessionId, is valid only while the device session is, never widens tenant scope, and signs out independently.

import { createDeviceCredentialHandlers } from '@happyvertical/smrt-users/sveltekit';

export const deviceAuth = createDeviceCredentialHandlers({
db,
pin: { pepper: process.env.PIN_PEPPER }, // required in production
personIdleSeconds: 15 * 60, // sliding; default 8 hours
personMaxSeconds: 10 * 60 * 60, // absolute; default none
// Optional: the most anyone may do on this device. Default: no ceiling.
deviceCeiling: async (device) => devices.permissionCeilingFor(device.user.id),
// smrt-users does not own a Device object: say whether this device
// account is still an enrolled, active device.
assertEnrolledDevice: async (device) =>
(await devices.findActiveByUserId(device.user.id)) !== null,
});
// POST /api/device/pin/sign-in → deviceAuth.pinSignIn (device bearer + { userId, pin })
// POST /api/device/sign-out → deviceAuth.signOut (person bearer)
// PUT /api/device/pin → deviceAuth.setPin (self with currentPin, or admin)
// POST /api/device/pin/reset → deviceAuth.resetPin (admin; forces a new PIN)
// DELETE /api/device/pin → deviceAuth.clearPin

The tablet swaps its Authorization bearer to the returned sessionId for person-attributed work; loadSessionContext() resolves it with parent set, so host gates can tell a person-on-device session from a browser session via authMethod/parent (also on event.locals.authMethod and event.locals.sessionParent). Any bearer that is not a first-class device-enrolled session — a browser cookie, a mobile session, another person's layered session — is refused with the same 401 as a wrong PIN. Lockout is per person and per device through the login limiter. Administering PINs needs users.pin.manage from a non-PIN session; a PIN session can only change its own PIN with the current one. An admin reset revokes the person's live PIN sessions and flags mustReset; signing in with the temporary PIN then yields a restricted session that resolves to no permissions and can only call setPin, which ends it — the person signs in again with the new PIN.

Whose authority. The device session authenticates the tablet; the person's session authorizes the work. Its permissions are resolved on every load from the person's own membership in the device tenant (role plus per-membership overrides) — never the device account's — so a welder and a foreman on the same tablet get different permission sets. Both identities are on the resolved context: user is the person and parent (event.locals.sessionParent, and session.parent on the request permission context) is the device session and its account, so a consumer can record "this person, at this tablet".

Device ceiling. deviceCeiling(device) may return permission slugs that cap every person on that device. A non-null result is snapshotted into the person's session at sign-in (data.permissionCeiling) and intersected with their resolved permissions on every load; it only removes slugs, and [] leaves none. A ceilinged session never receives superAdminBypass or systemContext from withSessionPermissionContext, and assertOperationPermission / checkOperationPermission called inside that session deny anything outside the ceiling. Returning null (or omitting the hook) means no ceiling. Because it is a snapshot, a ceiling change applies at each person's next sign-in; a throwing hook refuses the sign-in.

Switching people. signOut ends only the person's session. With singleOccupant (default true) a successful sign-in also ends every other person's session on that device session, so signing in as the next person is the hand-over; a failed sign-in leaves the current person signed in. (Two sign-ins that overlap on one device can end each other — both are refused and the person signs in again.) Set singleOccupant: false for devices several people stay signed in on. personIdleSeconds is a sliding idle timeout stored on the session (data.idleSeconds), so it slides by that value whichever SessionService resolves the bearer and is independent of the device session's long life. personMaxSeconds adds an absolute lifetime (data.absoluteExpiresAt) that activity cannot extend. permissionCeiling, idleSeconds, and absoluteExpiresAt are reserved Session.data keys (SESSION_DATA_KEYS): server-set at mint, never to be written from client input.

Operational contracts. parent.sessionId is the device's bearer credential: keep SessionContext.parent / locals.sessionParent server-side and give clients parent.userId as the device identity (Session.parentSessionId is a sensitive field and never appears in public serialization). Resolve person bearers with service.loadPersonSession(token): it re-checks assertEnrolledDevice on every call and revokes the person session of a device that fails it (the PIN management handlers resolve person bearers the same way). A host that resolves them through its own SessionService must instead revoke the device's bearer session when it un-enrols the device. assertEnrolledDevice returning false is un-enrolment (the person session is revoked); a throw only refuses that request. The PIN is one per person across tenants, so PIN administration (users.pin.manage) reaches only people whose every active membership is in the administrator's session tenant; people who belong to several tenants manage their own PIN from a first-class session. users.pin.manage is impersonation-equivalent on enrolled devices — whoever sets a person's PIN can sign in as them there, and the membership rule is checked when the PIN is written, not when the person later joins another tenant — so grant it like an owner-level permission; every administrative change is audited with the actor's id. Any PIN change ends the sessions minted under the old PIN (a person changing their own keeps the session they changed it from).

Existing installations need smrt db:migrate for the additive sessions.auth_method / sessions.parent_session_id columns and the users_login_attempts, users_login_audit_events, and users_pin_credentials tables.

Other credential kinds (a fob or badge reader, say) implement DeviceCredentialVerifier and go through service.signIn(verifier, input); PinVerifier is the reference implementation.

Password sign-in​

For a self-hosted public password deployment, set SMRT_AUTH_READINESS_MODULE=@happyvertical/smrt-users. The existing app-runtime provider-readiness factory then opens the configured PostgreSQL database and requires the password, session, membership, and login-limiter schema used by PasswordCredentialService; it never creates schema. Browser owner bootstrap remains local-only, so production owners are provisioned by a separate operator path.

PasswordCredentialService (#3274) gives an app without an identity provider email-or-username + password sign-in for existing, active users. It never creates a user. The password lives in its own object, UsersPasswordCredential (users_password_credentials, one row per person), not on User: it is sensitive, has no generated REST/MCP/CLI surface, never enters the change feed, and toPublicJSON() omits the hash. The service and the handlers below are its only read and write paths.

Username rollout: run the normal explicit application schema migration before serving the updated users package. The additive nullable username and unique username_key columns leave existing accounts unchanged; no username is inferred from an email or profile, and no data backfill is needed. Assign usernames through User.save() so the canonical key is maintained. Usernames are global, NFKC normalized, trimmed, lowercased, and limited to 1–64 ASCII letters/digits with internal dots, underscores or hyphens; @ remains reserved for email sign-in.

Pass { identifier, password } to the handler for either email or username (the existing { email, password } body remains supported). Applications that need no workspace chooser can configure tenantId: 'unique-active-membership'. The service verifies the password first, then requires exactly one active direct membership before minting a tenant-bound session. Zero or multiple active memberships return the same generic refusal as other invalid credentials; an ambiguous login never returns an unscoped session. Explicit tenant IDs and omitting the tenant option retain their existing behavior.

Hashing. scrypt from node:crypto (no native dependency) with a random per-hash salt; the parameters are stored in each hash (scrypt$N$r$p$salt$hash) and compared with timingSafeEqual. Raising password.scrypt never breaks existing passwords: a weaker hash still verifies and is rehashed under the current parameters on the next successful sign-in, by a guarded update that never overwrites a password changed in the meantime. A sign-in for an unknown email, or a user with no password, runs the same scrypt verification against a dummy hash, and a check against a hash still carrying weaker parameters is padded with extra scrypt work up to the current policy's cost, so timing does not reveal whether the account or credential exists. Only ever raise the parameters: hashes stronger than the policy are not rehashed downward and would verify slower than the dummy. Passwords are NFKC-normalized.

Sign-in. The submitted email is normalized exactly as User.emailKey is and matched against that column (legacy users need backfillUserEmailKeys() first), and the attempt is reserved through the shared LoginAttemptLimiter before any credential work: per account (the email key, whether or not it exists) and per client address. A request missing its email or password is reserved and failed like a wrong password. The person must be ACTIVE and, when a tenant is given, hold an ACTIVE membership in it. A successful sign-in mints a first-class session with authMethod: 'password' through SessionService.createSession, re-checks that the verified password is still current (so a concurrent change or reset cannot leave a session behind), and revokes the browser's previous cookie session. Every refusal — unknown email, no password, wrong password, inactive user, no membership, malformed or over-long input — is the same PasswordCredentialError / 401; a rate limit is LoginRateLimitError / 429 with Retry-After, and it is reached the same way for an unknown email as for a real one. Audit rows carry hashed keys and the tenant only: never the email, the password, the hash, or the user id.

Policy (password option): minLength (default 10), maxLength (default 256, hard ceiling 1024 — longer input is refused before hashing, at sign-in too), a small built-in DEFAULT_PASSWORD_DENYLIST extended by denylist, a refusal of the person's own email, and a reject(password, { userId, email }) hook for anything else (a breached-password check, shop words). There are no composition rules. Optional pepper. Violations are PasswordPolicyError (400) with a message fit to display.

Lifecycle and authority.

CallWhoSessions ended
setPasswordthe person, for their first password; or an admin with users.password.managenone; all of the person's if an admin replaced an existing one
changePasswordthe person, proving the current password (from the sign-in budget)every other session of theirs
resetPasswordan admin with users.password.manage, never for themself; mustChange defaults to trueevery session of the person
clearPasswordan admin, or the person proving the current passwordthe person's password sessions
replaceForgottenPasswordthe person, from a fresh out-of-band recovery session (off unless recovery is configured)every session of the person, this one included

Self-service writes are guarded on the exact credential the person proved (or, for a first password, insert-only), so a reset that lands while a change or clear is in flight is never overwritten with a password chosen under the old one; the slower request fails with PasswordCredentialError. Every write that ends sessions ends them both before and after the credential write, so a failure between the two leaves the person signed out rather than signed in under a password that is gone.

A reset with mustChange makes the next sign-in a restricted session: it resolves to no permissions (an empty permission ceiling) and can only call changePassword, which ends it; the person then signs in with the new password. A device PIN (layered) session never manages passwords. Like the PIN, the password is one per person across tenants, so users.password.manage reaches only people whose every active membership is in the administrator's session tenant. It is impersonation-equivalent — grant it like an owner-level permission. It is registered in the runtime catalog (ensurePasswordPermissionsRegistered(), called by the service), so syncPermissionCatalog() creates it and the default role matrix (seedRolePermissions()) grants it to owner and admin (*), not to member or viewer.

Forgotten password (#3905). A person who signed in some other way — an owner-recovery or operator-issued link, an emailed code — can replace a password they forgot, without the current one, through replaceForgottenPassword({ actor, newPassword }). It is disabled until the host opts in, and every condition is checked on the stored session row, not on the caller's context:

import {
outOfBandProofSessionData,
PasswordCredentialService,
SessionService,
} from '@happyvertical/smrt-users';

const sessions = await SessionService.create({ db });

const passwords = await PasswordCredentialService.create({
db,
recovery: {
authMethods: ['owner-recovery'], // channels minted ONLY after an out-of-band proof
maxAgeSeconds: 600, // default 10 minutes, at most 1 hour
assertProof: ({ actor, provenAt }) => true, // optional extra host check
},
});

// In the recovery flow, after verifying the link or code (never from client input):
const sessionId = await sessions.createSession(user.id, tenantId, {
authMethod: 'owner-recovery',
data: outOfBandProofSessionData(), // stamps SESSION_DATA_KEYS.outOfBandProofAt
});

The call is refused (PasswordCredentialForbiddenError / 403) unless the session is valid and the person's own, first-class (not layered, no permission ceiling), has one of the listed authMethods (password and pin can never be listed), and carries a proof stamp no older than maxAgeSeconds (a stamp more than 30 seconds in the future is refused). The freshness bound is measured from the stamp, which a tenant switch copies to the rotated session, so switching tenants never makes an old proof look new. assertProof, when given, must return exactly true. On success the new password (policy-checked, mustChange cleared) is written guarded on the row read before any hashing — or inserted if the person had none — in the same transaction as a conditional update that consumes the recovery session, so of any concurrent uses of one proof exactly one succeeds and a failed write leaves the proof unconsumed. Every other session of the person ends before and after the write, and a managed audit entry records action: 'recover' with the channel and the proof's age. Do not list a channel whose sessions an ordinary sign-in can mint: whoever holds such a session can then choose the account's password.

Consumer wiring (SvelteKit)​

// src/lib/server/password-auth.ts
import { createPasswordCredentialHandlers } from '@happyvertical/smrt-users/sveltekit';

export const passwordAuth = createPasswordCredentialHandlers({
db,
tenantId: () => SHOP_TENANT_ID, // value or (event) => …; omit for no tenant context
cookieName: 'sid', // the same cookie createSessionHandler reads
sessionTtl: 12 * 60 * 60, // default 7 days
loginLimiter, // optional: share one limiter with PIN/terminal
password: { minLength: 12, denylist: ['teamworks fabrication'] },
});
// POST /auth/password → passwordAuth.signIn ({ email, password }, JSON or form)
// PUT /account/password → passwordAuth.changePassword ({ currentPassword, newPassword })
// POST /account/password → passwordAuth.setPassword ({ password } or admin { userId, password })
// DELETE /account/password → passwordAuth.clearPassword ({ currentPassword } or admin { userId })
// POST /admin/users/password → passwordAuth.resetPassword ({ userId, password, mustChange? })
// POST /account/password/recover → passwordAuth.replaceForgottenPassword ({ newPassword }; needs `recovery`)
// sign out → destroySessionCookie(event, { db })

What the app must do:

  • Mount createSessionHandler with the same cookieName; the management handlers act as the session it puts in event.locals and refuse (401) when locals.authMethod is absent.
  • Put the sign-in route on the signed-out allowlist; leave CSRF protection on (SvelteKit's origin check covers form posts, JSON needs CORS preflight).
  • On mustChange: true (or a 200 from changePassword with signedOut: true), send the person to the change-password page or back to sign-in.
  • Run syncPermissionCatalog() (or seedRolePermissions()) after the service is first constructed, and grant users.password.manage to any other role that administers passwords.
  • Run smrt db:migrate once for the new users_password_credentials table.
  • For a form action instead of an endpoint, call (await passwordAuth.service()).signIn({ … }) and set the cookie with the returned sessionId; keep the session id out of page data.

Passkeys/WebAuthn are a separate credential: see "Passkey sign-in" below.

Passkey sign-in​

PasskeyCredentialService (#3275) gives an app passkey (WebAuthn) sign-in for existing, active users, with no username typed: the authenticator offers its discoverable credential and the credential names the person. It never creates a user. Parsing and verification are @simplewebauthn/server; this package owns the ceremony state, the sign-in order and the authority rules. Passkeys live in their own sensitive objects, UsersPasskeyCredential (users_passkey_credentials, many per person) and UsersPasskeyChallenge (users_passkey_challenges, a replay ledger), with no generated REST/MCP/CLI surface and no change-feed entry. The service and handlers below are their only read and write paths. Sign-in mints a first-class session with authMethod: 'passkey'; tenant access still comes only from an active membership (explicit tenant, or 'unique-active-membership', exactly as for passwords).

Setup: ORIGIN and the relying party​

Passkeys are bound to the relying party, so relyingParty is required and is validated at construction. origin is the exact origin(s) the browser runs on: an absolute https: URL with no path (http: only for localhost, 127.0.0.1, [::1]). rpId defaults to the hostname of the first origin and must be that hostname or a parent domain of every origin. Choose it once: changing the RP id orphans every registered passkey.

// src/lib/server/passkey-auth.ts
import { createPasskeyCredentialHandlers } from '@happyvertical/smrt-users/sveltekit';

export const passkeyAuth = createPasskeyCredentialHandlers({
db,
relyingParty: { origin: ORIGIN, rpName: 'Teamworks' }, // ORIGIN = https://app.example.com
challengeSecret: PASSKEY_CHALLENGE_SECRET, // >= 32 chars; REQUIRED with more than one process
tenantId: () => SHOP_TENANT_ID, // value or (event) => …; omit for no tenant context
cookieName: 'sid', // the same cookie createSessionHandler reads
loginLimiter, // optional: share one limiter with password/PIN
freshSessionSeconds: 15 * 60, // 60..3600, default 900
maxPasskeysPerUser: 10, // 1..100, default 10
});
// POST /auth/passkey/options → passkeyAuth.authenticationOptions → { ceremonyId, options }
// POST /auth/passkey/sign-in → passkeyAuth.signIn ({ ceremonyId, response })
// POST /account/passkeys/options → passkeyAuth.registrationOptions → { ceremonyId, options }
// GET /account/passkeys → passkeyAuth.list (?userId= for administrators)
// POST /account/passkeys → passkeyAuth.register ({ ceremonyId, response, name? })
// PATCH /account/passkeys → passkeyAuth.rename ({ passkeyId, name })
// DELETE /account/passkeys → passkeyAuth.remove ({ passkeyId })
// POST /admin/passkeys/remove-all → passkeyAuth.removeAll ({ userId })

In the browser, @happyvertical/smrt-users/svelte provides browserSupportsPasskeys(), createPasskeySignInCeremony() (a passkeyCeremony for createUsersAuthAdapter, which reveals the sign-in button) and registerPasskey({ name }). A refused request throws a PasskeyRequestError carrying the server's status and code.

Ceremonies and replay​

beginAuthentication() / beginRegistration() return an opaque ceremonyId: base64url(payload).base64url(HMAC-SHA256(challengeSecret, payload)), where the payload holds the purpose, a random 32-byte challenge, an expiry (challengeTtlSeconds, 1 to 600, default 300) and, for registration, the person plus the SHA-256 of the registering session id (never the raw id). Beginning writes nothing, so an unauthenticated request costs no storage. Finishing verifies the MAC in constant time, the purpose, the expiry and a 4 KB bound, then consumes the challenge by inserting its hash into the unique ledger: a replay is a conflict and is refused. Without challengeSecret a random per-process secret is used, which only works while one process begins and finishes every ceremony.

Sign-in order and refusals​

The submitted credential id is reserved against the shared LoginAttemptLimiter (kind passkey, source = client address) before any other work. Then: ceremony verified and consumed, credential loaded, assertion verified (origin, RP id, user verification required), user handle checked against the stored owner, signature counter checked (it must advance whenever either side is non-zero; a regression is refused and audited as reason: 'counter'), user active, membership. The session is minted and the credential re-checked with one guarded UPDATE of the counter, so a passkey removed (or used concurrently) mid-sign-in leaves no session. Every refusal other than a rate limit is one PasskeyCredentialError ("Invalid credentials."), HTTP 401 invalid_credentials; a rate limit is LoginRateLimitError, HTTP 429 with Retry-After.

Managing passkeys​

  • Registering (and a person removing their own) requires a session authenticated within freshSessionSeconds (tenant switching keeps the original time) by an interactive method in freshAuthMethods (default password, passkey, oidc, magic-link), all decided on the stored session row; layered/PIN sessions and sessions with a permission ceiling are refused. A terminal session never counts as fresh (nor does a null or unlisted method): approving a device code lets whoever holds any session, however stale, mint a brand new one, so its age proves nothing. terminal and pin are rejected in freshAuthMethods at construction. Listing and renaming one's own passkeys also refuse a permission ceiling. A stale session is PasskeyReauthenticationRequiredError (HTTP 401 reauthentication_required): sign in again, then retry.
  • listPasskeys returns { id, name, createdAt, lastUsedAt, backedUp, deviceType, transports }, never key material or credential ids.
  • users.passkey.manage lets an administrator list and remove the passkeys of people whose every active membership is in the administrator's own tenant (the same single-tenant rule as users.password.manage).
  • Removing a passkey ends sessions before and after the delete: the person's other passkey sessions (and the current one only if it was signed in with the removed passkey); an administrator's removal ends all of them.
  • Every management call writes a managed audit event (kind passkey; action, self, actor, revoked sessions; never key material).

Recovery when every passkey is lost​

An administrator holding users.passkey.manage calls removeAllPasskeys for the person (never for themselves), which deletes all their passkeys and ends every passkey session. The person then signs in with another credential (a password or a magic link) and registers a new passkey from that fresh session. Keep at least one non-passkey path available for every person.

Run smrt db:migrate once for the two new tables, and sync the permission catalog so users.passkey.manage can be granted. The retention sweep reaps expired challenge rows (users-passkey-challenges), keeping each row for 10 minutes past its expiry so a process with a slow clock cannot replay it. Every state-changing passkey route requires content-type: application/json (415 otherwise). When challengeSecret is unset the service logs one warning at creation.

Trusted deployment owner provisioning​

Self-hosted password deployments keep browser owner bootstrap disabled. After building the app, run migrations and provision the owner from a trusted operator shell using the compiled registration entry. These commands need Node and the published packages, without a Vite runtime:

node --import ./.smrt/runtime/register.js node_modules/@happyvertical/smrt-cli/bin/smrt.js db:migrate
node --import ./.smrt/runtime/register.js node_modules/@happyvertical/smrt-cli/bin/smrt.js users:provision-owner \
--tenant-id 2acb0135-6d63-40dc-a8d4-fd87d37cd197 --tenant-name Hearth \
--user-id 753d9480-fd17-4f21-8771-345e83833fe3 \
--email owner@example.com --username owner --password-stdin < /run/secrets/owner-password

Use your own stable UUIDs and identities; retain them for retries. The database comes from normal CLI configuration (packages.cli.database, or DATABASE_URL and DATABASE_TYPE=postgres). Supply at least one email or username. The password must be one UTF-8 line; a final newline is removed. --password-file also accepts a regular mode-0400/0600 file (no final symlink); stdin redirection works with mounted secret symlinks. Password arguments and environment inputs are unsupported.

The Node-only @happyvertical/smrt-users/operator export provides provisionPasswordOwner(db, input, passwordPolicy?) for operator tools that use the application's custom password policy or pepper. The CLI uses the ordinary default policy. Never expose this function through browser, REST, MCP, or session handlers: possession of the database connection is its authority.

Provisioning supports PostgreSQL only. It creates the tenant, User, owner membership, and ordinary password credential in one transaction, without minting a session. A fresh authority catalog receives the standard roles and grants; existing role grants are never reseeded. Both creation and exact retries require at least one effective permission, resolved with tenant and membership DENY overrides inside that transaction. Existing restricted roles remain restricted; success does not promise every application permission. An empty or fully denied permission set refuses with the ordinary sanitized error and CLI exit status 1, rolling back new identities and leaving existing credentials and grants intact. If roles were initialized without grants, inspect the intended authority policy and explicitly seed approved grants using RoleCollection.seedSystemRoles({ seedPermissions: true }) from a trusted operator tool, or administer the desired restricted grants through the public role services, before retrying. Provisioning never restores deliberately revoked grants automatically.

An existing target tenant must be an active root tenant with the exact name and no memberships. Existing or partial User identities are refused except for a complete exact retry with the same active owner membership and password, which returns unchanged without writes. It never resets a password, reactivates an account, or adopts an ambiguous identity. After provisioning, the owner signs in using the normal password route. Local loopback bootstrap remains unchanged.

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().

Resource-scoped grants​

Use checkResourceOperationPermission() where an application must restrict an otherwise tenant-authorized operation to one exact resource. Pass the resource tenant/type/id and an application-owned verifyResource callback. The guard first requires the catalogued tenant permission, then requires an active exact ResourceGrant; a grant for project A never matches project B. A resource deny overrides a resource grant, while tenant and membership denies remain the upstream gate. The verifier must load authoritative ownership and fail closed.

ResourceGrantService is the only public grant surface: generated REST and MCP operations are disabled. Its caller supplies a catalogued administrative operation for the bootstrap grant, avoiding a circular "already delegated" requirement. Delegation requires a live, delegable parent owned by the actor and covering the same tenant/resource/permission; chains are bounded and ancestor revocation immediately invalidates every child at the next guard check.

Hierarchical tenants​

TenantCollection is the only hierarchy traversal API. Use getAncestorIds(), getDescendantIds(), and requireChildOf() rather than walking parentTenantId in an application.

For a reviewed cross-tenant list read, use withTenantCatalogReadScope() for the active family, its ancestors, and global rows, or withTenantHierarchyContext() for one authorized root and its descendants. Both require an active tenant and user, a non-empty qualified collection allowlist, active enableTenancy() enforcement, and an authorizer that returns exactly true; every other result fails closed. The callback can widen only list() for those collections. get(), raw queries, saves, and deletes retain normal tenancy guards (tenant-scoped collection writes and raw queries are rejected while the scope is active). This is a trusted server API, not a sandbox: direct database executors and models without tenancy remain the host's responsibility. Catalog scopes never include descendants or siblings; subtree scopes never include ancestors or global rows. The actor and database session authority are unchanged. PostgreSQL RLS remains an additional restriction: effective visibility is the intersection of the approved scope and the database policies, so a stricter RLS policy may exclude ancestor or global rows even after application authorization.

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.

The materialized hierarchyPath / hierarchyLevel are derived by the framework: Tenant.save() recomputes them from the real parentTenantId chain on every save — through the collection helpers, a plain tenant.parentTenantId = x; await tenant.save(), or an STI subclass — and re-materializes every descendant when they change. A missing parent, a cycle, or a move that would push any descendant past the depth limit throws TenantHierarchyError before anything is written. Never set them by hand. Because a parent link carries authority down the tree, a new or changed parentTenantId must also be visible under the caller's own tenancy scope (the save loads it through TenantCollection); run platform-level reparenting in withSystemContext().

Both hierarchy features — inheritsToDescendants and the ancestor-read policy below — walk that path and fail closed without it. Rows written before the framework maintained it (a correct parent_tenant_id but an empty path at level 0) must be backfilled once, after db:migrate:

smrt db:materialize-tenant-hierarchy --dry-run # report what would change
smrt db:materialize-tenant-hierarchy # apply (idempotent)

Or programmatically: await materializeTenantHierarchy(db, { dryRun }). It writes only rows that differ (a second run is a no-op), runs in one transaction, touches only the two derived columns, and refuses — writing nothing — when any tenant's parent chain is broken, listing the offenders.

Upgrading an existing fleet. Two things change the moment the new package is deployed, before any backfill:

  1. Run --dry-run first, as a health check. A tenant whose real parent_tenant_id chain is broken — a missing parent, a cycle, or deeper than 10 (older releases accepted explicit hierarchyLevel/hierarchyPath create inputs without checking) — now makes permission resolution for that tenant and its whole subtree throw TenantHierarchyError (code: PARENT_NOT_FOUND, CIRCULAR_REFERENCE, MAX_DEPTH_EXCEEDED) instead of guessing. The dry run lists exactly these rows; repair their parent_tenant_id by hand (the backfill refuses to write while any exist), then apply.
  2. The tenant-override cascade follows the real parent chain. Resolution no longer trusts a stored path it cannot verify, so a never-materialized child now receives its real ancestors' TenantPermissionOverride rows in both directions — DENYs and GRANTs (subject to cascadePermissions / inheritPermissions). That is the intended cascade and what the backfill produces anyway, but ancestor GRANT overrides that were previously inert on such rows become live: audit them before rolling out. inheritsToDescendants and the ancestor-read policy stay dormant on those rows until the backfill runs.

Permission resolution works under the @happyvertical/smrt-tenancy interceptor regardless of which users classes you register as tenant-scoped: the resolver's own cross-tenant reads (ancestor tenant overrides, ancestor and descendant memberships) run in the framework's system context, keyed by the user and tenant being resolved. It returns permissions, never rows; your application's reads stay filtered.

Read-only ancestor visibility (opt-in, off by default)​

Membership authority travels DOWN the hierarchy: a direct membership in the tenant being resolved, or — per role, via inheritsToDescendants — the nearest active ancestor membership. Nothing a user holds on a DESCENDANT contributes anything at an ancestor, so a principal whose only membership is on a child tenant resolves to no permissions at the root. That is the safe default and it stays the default.

Some hierarchies legitimately need the other direction for reading: a network-level list that members of the network's child publications are meant to see. Declare it explicitly:

// smrt.config.ts
export default defineConfig({
packages: {
users: {
permissions: {
ancestorRead: {
// Descendant SYSTEM role slugs allowed to contribute upward.
// Exact match, and only `tenantId: null, isSystem: true` roles
// (what `seedSystemRoles()` creates) can match.
roles: ['member', 'editor'],
// Collections whose `read` may travel up. `*` matches any run of
// characters and may appear anywhere (`'site_*'`, `'*_pages'`);
// `'*'` alone means every collection the declared role can read.
collections: ['publications', 'tenants'],
// Hierarchy hops from the membership up to the tenant being
// resolved. Default 1 (immediate parent only).
maxDepth: 2,
},
},
},
},
});

With that declared, resolvePermissions(user, networkRootId) for a publication-only member returns publications.read and tenants.read — and nothing else. The bounds are hard:

RuleBehavior
DefaultOff. Undeclared, malformed, or empty-on-either-axis policies resolve exactly as before.
Actionread only (list/get normalize to read). create/update/delete/custom actions can never travel upward.
EscalationIntersected with both the declared role's own catalog grants and the principal's effective permissions in the contributing tenant. The role bound means a descendant administrator cannot widen the contribution with a tenant GRANT, a group role, or a membership GRANT; the effective bound means a DENY that removed the permission at home removes it at the ancestor too.
Role identityOnly a governed SYSTEM role (tenantId null, isSystem: true) can match a declared slug. Slugs are not unique across a hierarchy, so a tenant-scoped custom role named member contributes nothing — a descendant's administrator cannot mint its way into the allow-list.
DirectionStrictly upward, to verified ancestors only. Siblings share no ancestor relationship and are unreachable.
PrecedenceApplies only when NO membership authorized the tenant. A direct membership (even inactive) still pins resolution; an ancestor tenant-level DENY still subtracts.
HierarchyThe materialized hierarchyPath is verified link-by-link against real parentTenantId rows; stale, over-deep, or inconsistent paths fail closed.
BypassSuper-admin and system-context bypass are unchanged.

This grants the operation, not the rows. An ancestor-read grant authorizes publications.read at the ancestor. It is not visibility of any sibling tenant's rows: row scoping remains with the @happyvertical/smrt-tenancy interceptor and the generated Postgres RLS policies, which still bind reads to the tenant the context is entered with. A member of publication A authorized at the network root still cannot read publication B's rows.

Resolution happens inside PermissionResolver.resolvePermissions(), the single point SessionService.loadSessionContext(), withPrincipalPermissionContext(), assertOperationPermission(), the generated REST/MCP surfaces, and the published smrt.permissions RLS variable all flow through — so every consumer sees one answer. Resolution is uncached: a membership, role, or policy change takes effect on the next resolution, and there is no permission cache to invalidate.

Known limitation. The grant carries no membership, so PermissionResolutionResult.membershipId and loadSessionContext().tenantAuthorization.membershipId stay null. A surface that requires a non-empty membershipId for a required-tenant session — such as @happyvertical/smrt-app-runtime's deployed runtime — therefore still refuses an ancestor-read-only principal (it fails closed, with tenant_context_unauthorized). Consume the grant through withPrincipalPermissionContext() / assertOperationPermission(), which read the resolved permission set. Carrying ancestorReadFromTenantIds into tenantAuthorization is tracked in #2947.

Bind a policy to one resolver instead of the global config (tests, embedded runtimes) with PermissionResolver.create(options, { ancestorReadPolicy }); pass null to force it off regardless of configuration.

Adoption note​

This is additive and off by default — no schema migration is required, and no existing deployment changes behavior until permissions.ancestorRead is declared. It does require materialized tenant paths: run smrt db:materialize-tenant-hierarchy once if your tenants were created before the framework maintained hierarchyPath (see Hierarchical tenants). When adopting it, declare the narrowest role and collection lists that make the ancestor-level list work, and keep maxDepth at the smallest value your hierarchy needs. roles must name system-role slugs; a tenant-scoped role with the same slug is ignored by design. Do not reach for it to grant an ancestor-level action: if a principal needs to act at the root, give it a root membership or a role grant, not a read policy.

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​

ExportDescription
UserAuth identity. Email auto-lowercased. profileId is a unique cross-package Profile reference (one User per non-null Profile).
TenantOrganizational boundary. STI. Hierarchical via parentTenantId/hierarchyPath.
RolePermission template. tenantId = null for system roles. isSystem blocks deletion.
PermissionNamed capability. Slug format: resource.action.
SessionServer-side session. Secure UUID. TTL in seconds. authMethod records the channel; parentSessionId makes it a layered session valid only while its parent is. Reserved data keys (SESSION_DATA_KEYS) carry a permission ceiling, sliding idle timeout, and absolute expiry.
UsersLoginAttempt, UsersLoginAuditEventHashed-key login budget rows and durable sign-in audit events (#3273). Closed generated surface.
UsersPinCredentialPer-person scrypt PIN hash for enrolled-device sign-in (#3276). Closed generated surface.
UsersPasswordCredentialPer-person scrypt password hash for email + password sign-in (#3274). Sensitive; closed generated surface.
UsersPasskeyCredential, UsersPasskeyChallengePer-person WebAuthn passkeys (many per person) and the consumed-challenge replay ledger (#3275). Sensitive; closed generated surface.
GroupTeam within a tenant. Gains permissions via GroupRole.
MembershipUser + Tenant + Role junction. UNIQUE(userId, tenantId).
MembershipOverridePer-user permission grant/deny on a membership.
LegalMemberTenant-scoped legal member register entry, separate from authentication/RBAC membership and subscriptions.
MemberClassConfigurable legal member classification; classes have no built-in authority or entitlement effect.
LegalMemberStatusHistoryAppend-only effective status history for historical and record-date queries.
LegalMemberReferenceQualified reference to authoritative shares, dues, subscription, agreement, or ledger evidence; it never stores a balance.
MemberOfficerAppointmentEffective-dated board/officer register entry; it is not an RBAC grant or ballot engine.
TenantPermissionOverrideTenant-level permission override (INHERIT/GRANT/DENY).
GroupMember, GroupRole, RolePermissionJunction tables for groups and role-permission assignments.
AccessRequest"Request access / waitlist" record captured before a User exists. Closed generated surface — all access via AccessRequestService.

MemberRegisterService manages a tenant's legal member register. It is distinct from Membership, which is a user-to-tenant authorization row, and from subscriptions, which resolve paid entitlements. A legal member may be an individual or organisation and may link to a user/profile or an external-party reference, without requiring any of them.

The service writes effective status history, references financial evidence without copying balances, and effective board/officer terms. asOf(recordDate, policy) produces persisted, neutral eligibility snapshots plus CSV/JSON exports. A policy that requires shares or dues evidence fails closed unless the register has an explicitly authoritative reference for every required kind; the snapshot names that missing evidence rather than inferring payment status.

For GET-style capabilities, use the member-scoped read API instead:

const register = new MemberRegisterService(db, { tenantId });
const decision = await register.eligibilityFor(
{ userId: signedInUser.id }, // or { memberId: legalMemberId }
'2026-06-01',
{ memberClassKeys: ['worker'], requireAuthoritativeKinds: ['dues'] },
);

eligibilityFor(identity, recordDate, policy) performs only reads on the supplied executor. It opens no transaction, persists no snapshot and never lists the register. Its query count is independent of unrelated members; history and references are loaded only for the selected member. Exactly one nonempty selector is required. A user identity must resolve to exactly one member in this tenant; missing, foreign and ambiguous identities throw MEMBER_REGISTER_NOT_FOUND. Malformed dates/selectors/policy values throw MEMBER_REGISTER_INVALID; an active foreign tenant context throws MEMBER_REGISTER_TENANT_MISMATCH. Database failures propagate. Hosts map these failures to denied capabilities and must derive tenant and identity from an authorized session, never trust submitted identity fields. This is legal-register eligibility: host-level suspension/bans and operation permissions still require their own checks. Individual reads do not promise a transactionally stable multi-query snapshot; an accepted command re-evaluates on its own transaction.

Persist immutable evidence explicitly when accepting a command:

await register.withTransaction(async (bound, executor) => {
const snapshot = await bound.captureEligibility(
{ memberId: legalMemberId }, '2026-06-01', policy,
);
if (!snapshot.eligible) throw new Error('Participation denied');
// Persist the accepted command with executor, referencing snapshot.id.
// Throwing rolls back both command and evidence.
});

withTransaction supplies a bound service and the exact database executor used by its reads and writes; callbacks must use that executor for their command and must not retain either value after completion. Nested calls through the bound service reuse the transaction. captureEligibility re-evaluates authoritative records and creates one immutable MemberEligibilitySnapshot, including the policy inputs. Called alone, it opens its own transaction. It does not accept a caller-authored decision or grant host authorization. Each successful call is a new evidence event; hosts implement command idempotency in the same transaction, and skip capture for an already accepted command. A refused or failed transaction leaves no snapshot. The existing asOf remains the explicit whole-register snapshot/export operation and is unsuitable for ordinary capability GETs.

An omitted memberClassKeys policy allows every class; an explicit empty list allows none. statuses defaults to ['active'], and an empty status list also allows none. Snapshots preserve normalized class keys (null means unrestricted), statuses, and required evidence kinds so the decision retains its policy inputs.

Hosts must authorize the trusted MemberRegisterActor before constructing the service. Generated REST, CLI and MCP operations are closed for these sensitive records. Every service lookup and write stays in the actor's tenant; an active context for another tenant is rejected. Direct collections are trusted internal APIs, not an authorization boundary for untrusted input.

enroll, setStatus and asOf require database transaction support and commit all of their writes together. History and references are append-only, and snapshots reject updates and deletes. Same-day history corrections use persisted sequence order. Eligibility uses effective history; LegalMember.status and leftOn are display caches refreshed by setStatus, so scheduled changes may make those caches stale until another write. Use eligibilityFor for read-only record-date decisions, or captureEligibility for accepted-command evidence.

Use revokeReference(referenceId, { effectiveOn }) when previously authoritative evidence is withdrawn. It appends a dated record targeting the original reference; that original stops supporting eligibility on the revocation date. Earlier dates and already saved snapshots remain unchanged. A replacement authoritative reference must be explicitly added when evidence becomes valid again.

Enrollment accepts global profiles or profiles owned by the actor's tenant, and rejects foreign-tenant profiles. Members returned from enrollment and status changes are rebound to the caller's database so subsequent saves do not reuse a completed transaction's connection. Saves still require the appropriate tenant context.

Apply the generated s-m-r-t manifest migration before using the register; its six new tables require no auth-membership or subscription backfill. userId links to the local User; profileId uses the cross-package reference @happyvertical/smrt-profiles:Profile. Financial evidence is an opaque sourceType/sourceId reference (for example @happyvertical/smrt-ledgers:JournalEntry or @happyvertical/smrt-subscriptions:TenantSubscription); the host validates its authority and effective dates, and no posting or balance is copied here.

Collections​

ExportDescription
UserCollection, TenantCollection, RoleCollectionCore CRUD. TenantCollection adds createChild(), getTree(). RoleCollection adds seedSystemRoles().
PermissionCollection, SessionCollectionPermission CRUD with findByIds(). Session CRUD with findValidSession(), deleteExpired().
MembershipCollectionMembership CRUD, findByUserAndTenant()
MembershipOverrideCollection, TenantPermissionOverrideCollectionOverride management at membership and tenant levels
GroupCollection, GroupMemberCollection, GroupRoleCollection, RolePermissionCollectionGroup and role-permission junction management
AccessRequestCollectionAccessRequest queries: findByEmail(), findOpenByEmail(), findByStatus(), findOpen()
UsersLoginAttemptCollection, UsersLoginAuditEventCollection, UsersPinCredentialCollection, UsersPasswordCredentialCollection, UsersPasskeyCredentialCollection, UsersPasskeyChallengeCollectionAtomic limiter primitives, audit storage, and PIN, password and passkey credential rows behind the services below

Services​

ExportDescription
PermissionResolverResolves effective permissions via 4-level cascade. hasPermission(), resolvePermissions(). Honors the opt-in permissions.ancestorRead policy.
normalizeAncestorReadPolicy(), getConfiguredAncestorReadPolicy(), isAncestorReadableSlug()Validate and apply the declared read-only ancestor-visibility policy.
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.
SessionServiceHigh-level session management. createSession(), loadSessionContext(), destroySession(); tenant contexts include direct or inherited membership provenance, authMethod, and parent for layered sessions.
LoginAttemptLimiterShared sign-in budget with exponential lockout and audit (#3273): reserve() → lease .fail()/.succeed()/.release(). LoginRateLimitError, InvalidCredentialsError, LoginAuditSink, DurableLoginAuditSink.
DeviceCredentialServicePer-person sign-in layered on an enrolled device session (#3276): signInWithPin(), signIn(verifier, input), signOut(), setPin(), resetPin(), clearPin(). Person authority with optional deviceCeiling, singleOccupant hand-over, personIdleSeconds / personMaxSeconds. PinVerifier, DeviceCredentialVerifier.
PasswordCredentialServiceEmail + password sign-in for existing active users (#3274): signIn(), setPassword(), changePassword(), resetPassword(), clearPassword(), hasPassword(), validatePassword(). PasswordCredentialError, PasswordCredentialForbiddenError, PasswordPolicyError.
PasskeyCredentialServicePasskey (WebAuthn) sign-in for existing active users (#3275): beginAuthentication(), finishAuthentication(), beginRegistration(), finishRegistration(), listPasskeys(), renamePasskey(), removePasskey(), removeAllPasskeys(), hasPasskeys(). PasskeyCredentialError, PasskeyCredentialForbiddenError, PasskeyReauthenticationRequiredError, PasskeyRegistrationError.
OidcLoginServiceGeneric OIDC authorization-code login with PKCE for Kanidm, Dex, and other standards-compliant providers.
backfillLegacyUserProfilesTransactionally create and link canonical global Person Profiles for legacy Users; never creates OIDC identities or infers ownership.
backfillUserEmailKeysIdempotently populate durable normalized-email keys after migrating legacy Users; fails closed on duplicates.
materializeTenantHierarchyIdempotently backfill hierarchy_path/hierarchy_level from parent_tenant_id ({ dryRun }); refuses a broken chain without writing. CLI: smrt db:materialize-tenant-hierarchy.
planTenantHierarchyPure planner behind the backfill: expected fields, changes, and broken-chain problems for a set of tenant rows.
OidcProfileResolverTransaction-bound pre-provision hook for application identity reconciliation.
OidcProfileOwnerAuthorizerTransaction-bound application authorization for binding a first identity to an existing canonical Profile and its sole approved User owner.
NormalizedOidcClaimsFrozen 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.
TenantServicePolicy-driven tenant lifecycle. ensureTenantForUser(), createTenantWithOwnership().
AccessRequestServiceRequest-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)​

ExportDescription
createSessionHandlerSvelteKit handle hook that populates event.locals, and can also enter tenancy context and Postgres RLS request transactions
createSessionCookieSet session cookie after login
destroySessionCookieClear session cookie on logout
switchSessionTenantChange tenant context for current session
beginOidcLogin, completeOidcLoginLow-level SvelteKit helpers for custom OIDC login routes
createOidcLoginHandler, createOidcCallbackHandlerReady-to-use SvelteKit route handlers for OIDC login and callback
getOidcClientRegistrationRedirect URL and landing (login start) URL to register with the IdP for an OIDC client
createMobileAuthHandlersMountable /api/mobile PKCE, bearer session, bootstrap, logout, and route-guard handlers
createDeviceCredentialHandlersMountable PIN sign-in, sign-out, and PIN management handlers for enrolled devices
createPasswordCredentialHandlersMountable password sign-in (sets the session cookie) and password set/change/reset/clear handlers
createPasskeyCredentialHandlersMountable passkey sign-in (sets the session cookie), registration, list, rename and remove handlers
resolveMobileUploadDedupKeyResolves clientCaptureId with Idempotency-Key fallback for app-owned multipart routes
SessionLocalsType 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​

ExportDescription
UserStatus, TenantStatus, SessionStatus, MembershipStatusStatus enums
AccessRequestStatusAccess-request lifecycle enum (REQUESTED/APPROVED/DECLINED/GRADUATED/CANCELED)
OverrideEffect, TenantPermissionEffectOverride effect enums
ACCESS_REQUEST_CAPABILITIES, AccessRequestErrorOperator capability slugs; typed domain error (error.code)
DEFAULT_ROLE_SLUGS, DEFAULT_ROLES, DEFAULT_TENANT_POLICYSystem role slugs, role configs, default tenant policy
DEFAULT_SESSION_TTL, MAX_TENANT_HIERARCHY_DEPTH604800 (7 days in seconds), 10
DEFAULT_LOGIN_MAX_ATTEMPTS, DEFAULT_LOGIN_ATTEMPT_WINDOW_SECONDS, DEFAULT_LOGIN_LOCKOUT_*5 attempts per 300 s window; lockout 60 s × 2ⁿ, capped at 3600 s
DEFAULT_PIN_MANAGE_PERMISSION, PIN_LOGIN_KINDusers.pin.manage, pin
DEFAULT_PASSWORD_MANAGE_PERMISSION, PASSWORD_LOGIN_KINDusers.password.manage, password
DEFAULT_PASSKEY_MANAGE_PERMISSION, PASSKEY_LOGIN_KIND, PASSKEY_SESSION_DATA_KEYusers.passkey.manage, passkey, passkeyCredentialId
DEFAULT_PASSWORD_MIN_LENGTH, DEFAULT_PASSWORD_MAX_LENGTH, PASSWORD_MAX_LENGTH_CEILING, DEFAULT_PASSWORD_DENYLIST10, 256, 1024, built-in common-password list
TenantHierarchyErrorThrown on a missing parent, a cycle, or exceeding the hierarchy depth limit (code)
TenantHierarchyMaterializationErrorThrown by materializeTenantHierarchy when any tenant's parent chain is broken; lists problems

RoleCollection.seedSystemRoles() seeds these global system identities:

RoleUUID
Owner566aef94-1328-4876-b291-b7cf18d4df01
Administrator566aef94-1328-4876-b291-b7cf18d4df02
Member566aef94-1328-4876-b291-b7cf18d4df03
Viewer566aef94-1328-4876-b291-b7cf18d4df04

Repeat seeding requires each built-in role to have the documented UUID, global scope, and system flag. A conflicting global slug or occupied reserved UUID fails seeding without rewriting role ownership or memberships. Tenant roles can share these slugs under their own identities.

Trusted provisioning can pass reservedIdentity to TenantService.createTenantWithOwnership() on its transaction-bound service. The service applies maxTenants, resolves the global owner role, and inserts the tenant and membership with the supplied IDs. A collision aborts provisioning. Keep these IDs under server control.

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

Upgrade duplicate role grants (#3329)​

RolePermission now has a unique natural key (role_id, permission_id). Concurrent seeders converge through core's conflict-tolerant writes, preserving one grant ID per pair. Permissions already use (slug, context) and system roles use the tenant-aware natural key. System-role bootstrap holds a PostgreSQL transaction advisory lock across its read/create sequence so two seeders also return the same global (tenant_id = NULL) role IDs. The concurrent cold-seed test checks all three catalogs. Seeding still uses bounded batches and is additive unless prune: true; concurrent passes must use the same catalog/matrix.

Existing deployments need a maintenance window before ordinary schema migration: adding the new unique index directly fails if duplicates exist. Back up the database, stop every application writer and bootstrap/seed process, and run the following once with the new package from an operator process:

import { getDatabase } from '@happyvertical/sql';
import { deduplicateRolePermissions } from '@happyvertical/smrt-users';

const db = await getDatabase(databaseConfig);
console.log(await deduplicateRolePermissions(db, { dryRun: true }));
console.log(await deduplicateRolePermissions(db, {
maintenanceConfirmed: true,
}));

The migration keeps the earliest created_at per pair (lowest id breaks ties; null timestamps sort last), deletes only extra grants, and creates role_permissions_role_id_permission_id_idx in the same transaction. Other pairs and surviving grant data remain intact. Failure rolls the transaction back; after resolving its cause, rerun the migration. Dry runs change nothing. The helper requires transaction support and supports SQLite, DuckDB and PostgreSQL; PostgreSQL locks the grant table while repairing it. Writer shutdown is still required because older application versions cannot seed safely against the new constraint. Apply the remaining application schema migrations, run smrt doctor --db / db:status --parity, deploy the new version to every writer, then resume traffic. Do not roll back application writers without also restoring the pre-upgrade schema/database backup.

Upgrade duplicate overrides and group members (#3375)​

MembershipOverride is unique on (membership_id, permission_id), TenantPermissionOverride on (tenant_id, permission_id) and GroupMember on (group_id, user_id). setOverride() (and the grant/deny/inherit helpers) is an upsert on that key: concurrent calls leave one row, the last effect written wins, and an existing override keeps its id. addMember() is idempotent. removeOverride() / removeMember() delete every row for the pair. Their (slug, context) index is no longer unique, and the single-column membership_id / group_id indexes are replaced by the composite ones.

Existing deployments upgrade exactly like the role-grant upgrade above, in the same maintenance window and before ordinary schema migration:

import { deduplicateAuthorityPairs } from '@happyvertical/smrt-users';

console.log(await deduplicateAuthorityPairs(db, { dryRun: true }));
console.log(await deduplicateAuthorityPairs(db, {
maintenanceConfirmed: true,
}));

For an override pair it keeps the row whose effect the resolver already applies — DENY, then GRANT, then INHERIT — so no pair's effective authority changes; ties, and group members, keep the earliest created_at (lowest id breaks ties; null timestamps sort last). It creates membership_overrides_membership_id_permission_id_idx, tenant_permission_overrides_tenant_id_permission_id_idx and group_members_group_id_user_id_idx in the same transaction as the deletes, locks the three tables on PostgreSQL, and is safe to rerun. Until it has run, new-version override and group-member writes fail (there is no unique index for their upsert to target), so writers stay stopped until it and the schema migration complete.

Hosted OAuth authorization​

Use SmrtOAuthAuthorizationStorage with the SDK authorization server, and SmrtOAuthAuthorizationService to bind consent to existing browser sessions:

import { createAuthorizationServer } from '@happyvertical/auth/server';
import {
SmrtOAuthAuthorizationService,
SmrtOAuthAuthorizationStorage,
} from '@happyvertical/smrt-users';

const authorization = await SmrtOAuthAuthorizationService.create({
db,
scopePermissions: { 'catalog:read': ['catalog.read'] },
});
const server = createAuthorizationServer({
issuer: 'https://app.example/oauth',
signingKey,
storage: await SmrtOAuthAuthorizationStorage.create({ db }),
identity: authorization.identity,
scopes: ['catalog:read'],
resources: ['https://app.example/mcp'],
});
// After explicit same-origin browser consent:
const parsed = await server.parseAuthorizationRequest(url.searchParams);
const redirect = await authorization.approve(server, parsed, sessionId);
// On every bearer request, verify cryptography AND live authorization:
const payload = await server.verifyAccessToken(token, 'https://app.example/mcp');
const context = await authorization.validateAccessTokenClaims(payload);
if (!context) throw new Error('Authorization is no longer active.');

The SvelteKit export createOAuthHandlers supplies optional session-aware consent and account-grant routes. listGrants(sessionId) and revokeGrant(sessionId, grantId) confine account management to the signed-in subject. Migrate the private users_oauth_* tables before enabling the issuer. See OAuth authorization for lifecycle, revocation, permission ceilings and the database test matrix.

For local SQLite processes sharing a file, configure the storage's optional recoverDatabase factory to recreate an invalidated connection and reapply its settings. Instance-only storage fails closed on an invalidated connection; it never silently drops PRAGMAs or temporary state. PostgreSQL does not require this option. The transaction notes show the factory contract.

HTTP CLI collection action discovery​

createResourceListHandler can discover custom actions on registered typed collections when cli.include names the methods and the API exposes them. Generated SvelteKit startup supplies the action host's discovery metadata, including collection ancestry; an undecorated domain collection parent retains its inherited behavior without extra annotations. Collection defaults, wildcard-only includes, and mirror: 'api' alone stay hidden; collection CRUD is never advertised. Static and instance collection methods use collection-scoped URLs. Explicit @smrt({ collection }) chooses the route namespace independently of the backing model's storage.

When a collection inherits its item's namespace and both expose commands, discovery composes disjoint commands under the model's resource identity. The generated startup binds the exact live host constructor; composition requires that host's registered item model and the same canonical collection receiver. Each command retains its originating class's policy context and HTTP route. Denied commands are omitted independently. Explicit duplicate namespaces, unrelated resources, multiple hosts and overlapping command names/routes still fail closed as configuration collisions.

The existing commandPolicy filters these commands with the same session and class metadata as model resources. Discovery does not authorize execution or rows. Generated collection action writes still require the item model's collection permission (for example items.submit, even if the route is /api/operations/submit), and domain actions retain their tenant checks. See collection action resources.