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 {
findByEmail: (email: string) => Effect.Effect<UserRecord | null>
findById: (id: string) => Effect.Effect<UserRecord | null>
insert: (user: Omit<UserRecord, 'createdAt'>) => Effect.Effect<UserRecord, UserAlreadyExistsError>
// MFA / TOTP enrolment — back the handlers in HTTP handlers § MFA.
setMfaSecret: (userId: string, secret: string) => Effect.Effect<UserRecord, UserNotFoundError>
markMfaEnrolled: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>
clearMfa: (userId: string) => Effect.Effect<UserRecord, UserNotFoundError>
}
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 and no updatedAt field. The interface is deliberately small. 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.
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.
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),
}
}),
// … the remaining methods (findById, insert, setMfaSecret,
// markMfaEnrolled, clearMfa) all return Effects too …
})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.