HTTP handlers

handleSignIn, handleSignUp, handleSignOut — the HTTP-layer functions that turn form fields into session cookies.

@voltro/plugin-auth exposes three HTTP-shape handlers. They take request bits + a user store + auth config, and return response bits (status, body, cookie, redirect). They're transport-agnostic — wire them into Node's http, Express, Hono, anything.

handleSignIn

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

const result = await handleSignIn(
  {
    email:           fields.email,
    password:        fields.password,
    redirectAfter:   true,             // 302 redirect vs JSON response
  },
  userStore,
  AUTH_CONFIG,
)

// result: {
//   status:      number,
//   body:        string,
//   contentType: string,
//   setCookie?:  string,
//   location?:   string,
// }

What it does:

  1. userStore.findByEmail(email) — fetch the user (or pretend, see timing oracle).
  2. verifyPassword(password, user.passwordHash) — always run, even on unknown email, and even when the user exists but has no stored hash.
  3. On success: issueSession(subject, config.secret, {...}) → set the Set-Cookie header.
  4. If redirectAfter is true (form POST): 302 to AUTH_CONFIG.successRedirect.
  5. If redirectAfter is false (XHR / fetch): 200 + JSON { ok: true, subject }.

Failures return a 401 HandlerResult with a generic { error: 'invalid credentials' } body — no email-existence disclosure. That includes an account with no passwordHash at all (SSO-only, magic-link-only, passkey-only, provider PAT): the password strategy refuses it, taking the same branch as an unknown email — same decoy scrypt, same status, same body, no cookie. An absent hash is never "nothing to compare, let them in", and the response does not reveal which accounts are password-less. The handlers encode failures as 4xx/5xx HandlerResults rather than throwing domain errors across the wire.

handleSignUp

const result = await handleSignUp(
  { email, password, redirectAfter: true },
  userStore,
  AUTH_CONFIG,
)

What it does:

  1. Reject empty email/password (400) and passwords under 8 characters (400). That length floor is the only built-in policy; richer rules belong in your sign-up route, see Passwords.
  2. userStore.findByEmail(email) — if exists, return 409.
  3. hashPassword(password).
  4. userStore.insert({ email, passwordHash, tenantId: defaultTenantId }).
  5. issueSession(...) + 302 redirect or 200 JSON { ok: true, subject }. Any failure in this body collapses to 500 { error: 'signup_failed' }.

The new user is added to the defaultTenantId from AuthConfig. For multi-tenant invite flows where the tenant is decided by an invite token, write a custom handler — handleSignUp is the convenient default.

handleSignOut

const result = handleSignOut(AUTH_CONFIG)

// result: {
//   status:    302,
//   setCookie: 'voltro:session=; Max-Age=0; …',
//   location:  '/',     // config.successRedirect ?? '/'
// }

Synchronous — just emits an expired-cookie header + a redirect. The cookie kills any subsequent verifier; no server-side state to update.

Wiring into Node http

// apps/api/index.ts
import { createServer } from 'node:http'
import { Effect } from 'effect'
import {
  handleSignIn, handleSignUp, handleSignOut,
  postgresUserStore, type AuthConfig,
} from '@voltro/plugin-auth'

const AUTH_CONFIG: AuthConfig = {
  secret:           process.env.AUTH_SECRET!,
  defaultTenantId:  'acme',
  cookieSecure:     process.env.NODE_ENV === 'production',
  cookieDomain:     '.your-product.com',                  // optional
  successRedirect:  process.env.AUTH_SUCCESS ?? '/dashboard',
}

// postgresUserStore takes an `@effect/sql` SqlClient and returns a
// UserStore synchronously — the caller owns the client's lifecycle.
const store = postgresUserStore(sql)

createServer(async (req, res) => {
  const url = req.url ?? ''
  const fields = await parseFormBody(req)

  if (req.method === 'POST' && url === '/auth/signin') {
    const r = await Effect.runPromise(handleSignIn(
      { ...fields, redirectAfter: true },
      store,
      AUTH_CONFIG,
    ))
    res.statusCode = r.status
    if (r.setCookie) res.setHeader('set-cookie', r.setCookie)
    if (r.location) res.setHeader('location', r.location)
    res.setHeader('content-type', r.contentType ?? 'text/plain')
    res.end(r.body)
    return
  }

  // … signup, signout, plus your own queries
}).listen(4100)

With CORS + credentials

If your web app + api are on different origins, the form POST works (form submission is cross-origin-allowed) but XHR / fetch needs:

  • Server: Access-Control-Allow-Credentials: true + exact-origin allow-list.
  • Client: fetch(url, { credentials: 'include' }).
  • Cookies: SameSite is hardcoded lax in the session builder (it is not an AuthConfig field) — which is exactly what cross-origin form POST sign-in needs. strict would block them, but the plugin never emits strict.

For single-origin deploys (one Caddy / nginx in front of both), none of this matters — same-origin cookies always flow.

How the handlers surface failures

The handlers do NOT throw domain errors across the wire — sign-in / sign-up encode failures as 4xx/5xx HandlerResults:

Result When
400 { error: 'email and password required' } missing field
400 { error: 'password must be at least 8 characters' } sign-up, password too short
401 { error: 'invalid credentials' } sign-in: email not found OR password mismatch (uniform timing)
409 { error: 'email already registered' } sign-up: email already in use
500 { error: 'signup_failed' } any uncaught failure inside handleSignUp (e.g. a hash or insert error)

The tagged errors that the lower-level primitives raise — and that you can Effect.catchTag if you compose them yourself — are PasswordEmptyError, PasswordHashError (from @voltro/plugin-auth/password), UserAlreadyExistsError, UserNotFoundError (from the UserStore), and TotpVerifyError (from the MFA path). They extend Data.TaggedError / Schema.TaggedError, so they carry typed payloads.

Customising the response shape

The default body is JSON for XHR + redirects for form POST. To customise:

const r = await handleSignIn({ ... }, store, AUTH_CONFIG)
// r.status, r.setCookie, r.location are stable.
// Replace r.body with your own JSON / HTML / template render.

For instance, returning HTML on the sign-in page itself (no redirect) for a "you're signed in — refresh to continue" flow.

MFA / TOTP handlers (shipped)

MFA is enforced at sign-in, not just enrolled. handleSignIn reads user.mfaEnrolledAt: for an enrolled user it does not issue a session — it mints a single-use, short-lived pending token (hash stored in authTokens) and returns { ok: true, mfaRequired: true, pendingToken }. The second factor is completed separately:

  • handleMfaVerify({ pendingToken, code? , recoveryCode? }, store, config) — redeems the pending token atomically (single-use), verifies the 6-digit TOTP code against the stored secret (or a single-use recoveryCode as the lost-authenticator fallback), and only then issues the real session via the same issueSession path as password sign-in (so rotation + revocation apply). A wrong code is 401, and the challenge is consumed regardless — a guess can't be retried against the same token.

Enrolment is a separate ceremony (mount its routes with the plugin's mfa: { issuer } config):

  • handleMfaEnrollStart({ userId, issuer, accountName }, store) — generates a TOTP secret, stashes it via store.setMfaSecret, returns { secret, otpauthUrl } so the dashboard can render the QR.
  • handleMfaEnrollVerify({ userId, code }, store) — verifies the first code; on success marks the user enrolled via store.markMfaEnrolled and returns a one-time batch of recoveryCodes (shown once, stored hashed via store.replaceRecoveryCodes).
  • handleMfaRegenerateRecoveryCodes({ userId }, store) — wipes + reissues the recovery-code set.
  • handleMfaUnenroll({ userId }, store) — clears the secret via store.clearMfa and wipes the recovery codes.

They return the same Effect<HandlerResult> shape as the others. The UserStore MFA methods (setMfaSecret / markMfaEnrolled / clearMfa / replaceRecoveryCodes / consumeRecoveryCode / countRecoveryCodes) back them; both memoryUserStore and postgresUserStore implement them.

handleMagicLinkRequest / handleMagicLinkConsume and handlePasswordResetRequest / handlePasswordResetConfirm round out the passwordless + recovery flows. The request handlers always return 202 (no account-enumeration); they mint a single-use, hashed, expiring token (@voltro/plugin-auth/tokens), persist its hash via store.insertToken, and call the injected config.sendEmail hook to deliver the link. The consume/confirm handlers redeem the token atomically via store.consumeToken (single-use guard) — handleMagicLinkConsume issues a session, handlePasswordResetConfirm sets the new hashed password and revokes every existing session.

const requested = await Effect.runPromise(
  handleMagicLinkRequest({ email }, store, { ...config, appBaseUrl: 'https://app.example.com', sendEmail }),
)   // 202 regardless of whether `email` exists

Sessions, memberships, switch-tenant

handleListSessions / handleRevokeSession / handleRevokeAllOtherSessions back the device-management UI (handleSignIn writes a sessions row on every login). handleListMemberships + handleSwitchTenant drive multi-tenant switching — handleSwitchTenant validates membership via store.membershipRole, re-issues the cookie with the new active tenant, and rebinds the live connection's Subject when a clientId + rebind callback are supplied.

CSRF + passkeys

handleCsrf issues a signed double-submit token (voltro:csrf cookie + JSON body). The passkey ceremony handlers (handlePasskeyRegisterOptions / …RegisterVerify / …AssertOptions / …AssertVerify) live in @voltro/plugin-auth and verify challenge, origin, rpId hash, signature, and a strictly-increasing counter. You don't mount any of these by hand — authRoutesPlugin() mounts the whole set under /auth.

See also