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 →
postgresUserStoreover the framework's pool. Register the plugin's tables (authTablesfrom@voltro/plugin-auth/schema) in yourdatabaseHandleso 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
userstable from@voltro/plugin-auth/schema. - Uses parameterised queries — no SQL injection.
- Maps the Postgres
23505unique-violation onemailto a typedUserAlreadyExistsErroron 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 = usersTableRun 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,tenantIdandcreatedAtmust exist. - A column mapped
falsedoes not exist there. Reads answernull; a write whose whole purpose is that column —updatePasswordforpasswordHash, the MFA methods formfaSecret/mfaEnrolledAt,markEmailVerifiedforemailVerifiedAt— fails as a defect naming the mapping, rather than failing on a column the database does not have.updatedAt: falsemerely omits the stamp. deactivatedAtisfalseby default because the shipped table has no such column; map it and the record carries it for@voltro/plugin-deactivation's guard.insertwrites the mapped columns only. A table of yours with furtherNOT NULLcolumns is aninsertyou 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
userstable and once on a snake_caseaccountstable with noupdatedAt.
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:
record.tenantIdif you pass it explicitly (custom handler with an invite flow).AuthConfig.defaultTenantIdotherwise.
For real apps, you want one of:
- Per-invite tenant — store an
invitestable; 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
HttpOnlycookies, accidentally returningpasswordHashfrom a query is a disaster. Use a separate "PublicUser" type for everything that crosses the wire. - Using
tenantId: undefinedfor "global" users. Voltro doesn't model global users — every user belongs to exactly one tenant. For cross-tenant admins, use a separatestafftable or thesystemsubject. - Auth + business data in the same table. Keep
usersminimal (email, hash, tenantId, timestamps). Profile data, settings, etc. go in joined tables.