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

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:

  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.