User stores

memoryUserStore, postgresUserStore, and writing your own UserStore against a different backend.

A UserStore is the interface between @voltro/plugin-auth and your user data. The plugin ships two implementations + the interface so you can plug in your own.

The interface

Every method returns an Effect, not a Promise:

import type { UserStore } from '@voltro/plugin-auth'
import { Effect } from 'effect'

interface UserStore {
  // Identity
  findByEmail: (email: string) => Effect.Effect<UserRecord | null>
  findById:    (id: string)    => Effect.Effect<UserRecord | null>
  insert:      (user: Omit<UserRecord, 'createdAt'>) => Effect.Effect<UserRecord, UserAlreadyExistsError>
  updatePassword:    (userId: string, newHash: string) => Effect.Effect<UserRecord, UserNotFoundError>
  markEmailVerified: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>
  // MFA / TOTP enrolment + recovery codes
  setMfaSecret:    (userId: string, secret: string) => Effect.Effect<UserRecord, UserNotFoundError>
  markMfaEnrolled: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>
  clearMfa:        (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>
  replaceRecoveryCodes: (userId: string, codeHashes: ReadonlyArray<string>) => Effect.Effect<void>
  consumeRecoveryCode:  (userId: string, codeHash: string) => Effect.Effect<boolean>
  countRecoveryCodes:   (userId: string) => Effect.Effect<number>
  // Memberships + switch-tenant
  listMemberships: (userId: string) => Effect.Effect<ReadonlyArray<MembershipRecord>>
  addMembership:   (membership: Omit<MembershipRecord, 'joinedAt'>) => Effect.Effect<MembershipRecord>
  membershipRole:  (userId: string, tenantId: string) => Effect.Effect<string | null>
  // Sessions — enumeration + revocation
  listSessions:    (userId: string) => Effect.Effect<ReadonlyArray<SessionRecord>>
  insertSession:   (session: Omit<SessionRecord, 'createdAt' | 'lastSeenAt'>) => Effect.Effect<SessionRecord>
  findSession:     (sessionId: string) => Effect.Effect<SessionRecord | null>
  touchSession:    (sessionId: string, expiresAt: Date) => Effect.Effect<void>
  revokeSession:   (userId: string, sessionId: string) => Effect.Effect<boolean>
  revokeAllOtherSessions: (userId: string, keepSessionId: string) => Effect.Effect<number>
  // One-time tokens (magic link, password reset, email verification)
  insertToken:  (token: Omit<TokenRecord, 'consumedAt' | 'createdAt'>) => Effect.Effect<TokenRecord>
  consumeToken: (tokenHash: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>
  latestToken:  (userId: string, purpose: TokenPurpose) => Effect.Effect<TokenRecord | null>
  // Tenant invitations
  insertInvitation:     (invitation: Omit<InvitationRecord, 'acceptedAt' | 'acceptedByUserId' | 'revokedAt' | 'revokedByUserId' | 'createdAt'>) => Effect.Effect<InvitationRecord>
  findInvitationByHash: (tokenHash: string) => Effect.Effect<InvitationRecord | null>
  acceptInvitation:     (tokenHash: string, acceptedByUserId: string, now: Date) => Effect.Effect<InvitationRecord | null>
  revokeInvitation:     (invitationId: string, tenantId: string, revokedByUserId: string, now: Date) => Effect.Effect<boolean>
  listInvitations:      (tenantId: string) => Effect.Effect<ReadonlyArray<InvitationRecord>>
  countPendingInvitations:     (tenantId: string, now: Date) => Effect.Effect<number>
  supersedePendingInvitations: (tenantId: string, email: string, revokedByUserId: string, now: Date) => Effect.Effect<number>
  // Impersonation grants
  insertImpersonationGrant:        (grant: Omit<ImpersonationGrantRecord, 'startedAt' | 'endedAt' | 'endedReason'>) => Effect.Effect<ImpersonationGrantRecord>
  findImpersonationGrantBySession: (sessionId: string) => Effect.Effect<ImpersonationGrantRecord | null>
  endImpersonationGrant:           (sessionId: string, endedReason: string, now: Date) => Effect.Effect<boolean>
  // Passkeys (WebAuthn)
  listPasskeys:          (userId: string) => Effect.Effect<ReadonlyArray<PasskeyRecord>>
  findPasskey:           (credentialId: string) => Effect.Effect<PasskeyRecord | null>
  insertPasskey:         (passkey: Omit<PasskeyRecord, 'createdAt' | 'lastUsedAt'>) => Effect.Effect<PasskeyRecord>
  advancePasskeyCounter: (credentialId: string, newCounter: number) => Effect.Effect<boolean>
  // Brute-force lockout (keyed by normalized email)
  isLockedOut:        (email: string, now: number) => Effect.Effect<LockoutState>
  recordLoginFailure: (email: string, now: number, policy: LockoutPolicy) => Effect.Effect<void>
  clearLoginFailures: (email: string) => Effect.Effect<void>
}

interface UserRecord {
  readonly id: string
  readonly email: string
  readonly passwordHash?: string | null   // absent = this account has no password
  readonly tenantId: string
  readonly createdAt: Date
  readonly mfaSecret?: string | null      // base32 TOTP secret; null = not enrolled
  readonly mfaEnrolledAt?: Date | null    // first successful verify; null until enrolled
}

passwordHash is optional — not every identity has a password. An SSO-only, magic-link-only, passkey-only or provider-PAT app simply omits it, and the users table column is nullable to match. Do not invent a placeholder: a fake hash is a real value sitting in the column a verifier compares against, which is strictly worse than storing nothing. null, undefined and '' all mean "no password".

handleSignIn refuses a password sign-in for such an account the same way it refuses an unknown email — it still burns a decoy scrypt and returns the identical 401 with no cookie — so the response reveals neither that the account exists nor that it is password-less. Assigning a hash later (password reset, set-password) promotes the account to a password user normally. createdAt stays required; every store can supply it, and insert takes Omit<UserRecord, 'createdAt'> so you never pass one yourself.

There is no updatePasswordHash method — a password change is updatePassword(userId, newHash) — and no updatedAt field. The interface is NOT small: it is the whole surface the auth handlers need — 40 members across identity, MFA and recovery codes, memberships, sessions, one-time tokens, invitations, impersonation, passkeys and lockout — and a custom store implements all of it; the block above is the complete list, and the exported UserStore type is what the compiler checks it against. An app with an existing identity table keeps it: postgresUserStore(sql, { identity }) maps the identity role onto that table (below), and the roles section explains why a store of one role is enough for the handlers that read only it. Add your own fields (display name, avatar URL, locale) on a sibling table joined by userId — keep the auth-critical fields in the auth table.

Roles — a store is nine interfaces, and a handler asks for the ones it reads

UserStore is the union of nine role interfaces: IdentityStore (find, insert, password, email-verified), MfaStore (TOTP + recovery codes), MembershipStore, SessionStore, TokenStore, InvitationStore, ImpersonationStore, PasskeyStore and LockoutStore. Every handler's store parameter is typed with the roles that handler actually reads, so a store that implements one role is a complete argument to every handler that needs only that role — and the compiler names the handler that needs more.

import type {
  IdentityStore, MfaStore, MembershipStore, SessionStore, TokenStore,
  InvitationStore, ImpersonationStore, PasskeyStore, LockoutStore, UserStore,
} from '@voltro/plugin-auth'
import { handleSignIn, handleListSessions, postgresUserStore } from '@voltro/plugin-auth'

// UserStore = every role at once — what the shipped stores return.
declare const full: UserStore
// A handler asks for the roles it reads — and nothing more:
//   handleSignIn      (…, store: IdentityStore & MembershipStore & SessionStore & TokenStore & LockoutStore, …)
//   handleListSessions(…, store: SessionStore)
// So a store implementing ONE role is a complete argument wherever that role is all a handler needs,
// and a missing role is a compile error naming the handler, never a runtime "is not a function".
declare const sessionsOnly: SessionStore
handleListSessions({ userId: 'u1' }, sessionsOnly)

authRoutesPlugin mounts everything, so it takes the full UserStore; compose one from the shipped store and your own role: { ...postgresUserStore(sql), ...myIdentity }.

appUserStore — users in the app's own database

authRoutesPlugin is built in app.config.ts, before the framework has opened its pool, so a durable store needs a way to reach that pool without opening a second one. Hand the plugin a resolver instead of a store:

import { appUserStore, authRoutesPlugin } from '@voltro/plugin-auth'

export default {
  type: 'api' as const,
  plugins: [
    authRoutesPlugin({ store: appUserStore(), defaultTenantId: 'acme' }),
  ],
}

The plugin calls the resolver once the database is bound and serves every request from what it returns:

  • postgres → postgresUserStore over the framework's pool. Register the plugin's tables (authTables from @voltro/plugin-auth/schema) in your databaseHandle so auto-migrate creates them.
  • store: 'memory' → the in-process store. Nothing else in the app is durable either, so the two keep the same lifetime.
  • any other dialect → the boot fails with the reason. There is no SQL user store for it yet, and silently keeping accounts in memory beside a durable database would lose every account on restart. Pass your own UserStore.

appUserStore(options) takes the same options as postgresUserStore, including identity. Any function ({ sql }) => UserStore works as a resolver; sql is absent on store: 'memory'.

Every signed-in user is an actor

The audit() mixin stamps createdBy / updatedBy with the user's id, and those columns reference the core actors table. authRoutesPlugin therefore writes an actors row (kind: 'user') for the user before each session is issued, through the store it is bound to — on every session-granting path (sign-up, sign-in, MFA, magic link, passkey). Without it, the first write a new user makes to an audited table fails its foreign key on postgres. Replace the behaviour with the ensureActor option if your actor rows come from elsewhere.

memoryUserStore

For dev + tests:

import { memoryUserStore, hashPassword } from '@voltro/plugin-auth'
import { Effect } from 'effect'

const store = memoryUserStore()
await Effect.runPromise(Effect.gen(function* () {
  yield* store.insert({
    id:           'usr_demo',
    email:        'demo@example.com',
    passwordHash: yield* hashPassword('voltro-demo-2026'),
    tenantId:     'acme',
  })
}))

memoryUserStore(seed?) accepts an optional seed array of UserRecords.

Lives in-process. Restart = data gone. Use it for CI smoke tests + the cloud /demo query.

postgresUserStore

For production:

import { postgresUserStore } from '@voltro/plugin-auth'
import { SqlClient } from '@effect/sql'
import { Effect } from 'effect'

// postgresUserStore is SYNCHRONOUS — it takes an @effect/sql SqlClient and
// returns a UserStore directly. No await, no { url }, no internal pool.
// The caller owns the SqlClient's lifecycle.
const program = Effect.gen(function* () {
  const sql   = yield* SqlClient.SqlClient
  const store = postgresUserStore(sql)
  // … use store …
})

What it does:

  • Runs its queries through the provided SqlClient — it does NOT open or own a connection.
  • Reads + writes the users table from @voltro/plugin-auth/schema.
  • Uses parameterised queries — no SQL injection.
  • Maps the Postgres 23505 unique-violation on email to a typed UserAlreadyExistsError on insert; other SQL errors become defects.

The schema you need is in your database/schema.ts:

import { usersTable } from '@voltro/plugin-auth/schema'
export const users = usersTable

Run voltro migrate after adding this — the table + indexes (email UNIQUE, tenantId btree) get created.

Your own identity table — postgresUserStore(sql, { identity })

The identity role is the one an app very often already has: a users table from a previous stack, with its own name, its own column names and foreign keys pointing at it. identity maps the five identity methods onto that table; every other role stays on the plugin's own tables (authTables), so sessions, tokens, passkeys and the rest work unchanged.

import { postgresUserStore } from '@voltro/plugin-auth'

const store = postgresUserStore(sql, {
  identity: {
    table: 'accounts',                 // or 'app.accounts' — a qualified name is two identifiers
    columns: {
      id: 'account_id',
      email: 'mail',
      tenantId: 'org_id',
      createdAt: 'inserted_at',
      passwordHash: 'pw_hash',
      updatedAt: false,                // my table has no such column: the stamp is omitted
      mfaSecret: 'totp_secret',
      mfaEnrolledAt: 'totp_enrolled_at',
      emailVerifiedAt: 'mail_verified_at',
      deactivatedAt: 'deactivated_at', // read onto UserRecord.deactivatedAt for the deactivation guard
    },
  },
})
  • Unmapped columns keep their record name. id, email, tenantId and createdAt must exist.
  • A column mapped false does not exist there. Reads answer null; a write whose whole purpose is that column — updatePassword for passwordHash, the MFA methods for mfaSecret / mfaEnrolledAt, markEmailVerified for emailVerifiedAt — fails as a defect naming the mapping, rather than failing on a column the database does not have. updatedAt: false merely omits the stamp.
  • deactivatedAt is false by default because the shipped table has no such column; map it and the record carries it for @voltro/plugin-deactivation's guard.
  • insert writes the mapped columns only. A table of yours with further NOT NULL columns is an insert you wrap (the scoped-extension example below) — the store cannot know them.
  • The same contract runs against it. The plugin's store contract suite runs once on the shipped users table and once on a snake_case accounts table with no updatedAt.

Custom store — example: external IdP

If you sync users from an external identity provider, write a store that reads from your provider's API + your local cache:

import type { UserStore } from '@voltro/plugin-auth'
import { Effect } from 'effect'

const okta = (): UserStore => ({
  findByEmail: (email) =>
    Effect.promise(async () => {
      const oktaUser = await fetch(`https://okta.acme.com/api/v1/users?email=${email}`)
        .then((r) => r.json())
      if (!oktaUser) return null
      return {
        id:           `okta:${oktaUser.id}`,
        email:        oktaUser.profile.email,
        tenantId:     oktaUser.profile.tenantId,
        createdAt:    new Date(oktaUser.created),
      }
    }),
  // … and every other member of the interface above — all 40 of them, each
  //   returning an Effect. A partial object is a type error, not a partial store.
})

For an external IdP you usually wouldn't use handleSignIn at all — sign-in happens at the IdP and a strategy (e.g. the Okta path of @voltro/plugin-auth-oidc) verifies the resulting JWT. A custom UserStore only matters if you also need to persist a local mirror.

Custom store — example: scoped extension

If you want to keep postgresUserStore for the auth-critical bits but augment with your own fields:

import { postgresUserStore } from '@voltro/plugin-auth'
import type { UserStore } from '@voltro/plugin-auth'
import { Effect } from 'effect'

const base = postgresUserStore(sql)   // sql: a provided SqlClient

const extended: UserStore = {
  ...base,
  insert: (user) =>
    base.insert(user).pipe(
      Effect.tap((inserted) =>
        // Side-effect: also create a profile row
        Effect.promise(() =>
          ctx.store.insert('profiles', { userId: inserted.id, displayName: '' }),
        ),
      ),
    ),
}

Wrap don't fork — the base store is maintained by the framework; you keep your extensions in your code.

Tenant on sign-up

UserStore.insert(record) requires tenantId. The handler picks it from:

  1. record.tenantId if you pass it explicitly (custom handler with an invite flow).
  2. AuthConfig.defaultTenantId otherwise.

For real apps, you want one of:

  • Per-invite tenant — store an invites table; sign-up consumes an invite token that names the tenant.
  • One-tenant-per-email-domain — derive tenant from email domain on sign-up.
  • Self-serve tenant create — sign-up creates a new tenant + the user is its admin.

The framework doesn't pick for you; the auth plugin ships the primitives + you wire the policy.

Anti-patterns

  • Storing the password hash with the user-facing record. Even with HttpOnly cookies, accidentally returning passwordHash from a query is a disaster. Use a separate "PublicUser" type for everything that crosses the wire.
  • Using tenantId: undefined for "global" users. Voltro doesn't model global users — every user belongs to exactly one tenant. For cross-tenant admins, use a separate staff table or the system subject.
  • Auth + business data in the same table. Keep users minimal (email, hash, tenantId, timestamps). Profile data, settings, etc. go in joined tables.