The Subject
ctx.subject — what's on it, the anonymous fallback, and how to pattern-match on the type discriminator.
Every server-side executor receives ctx.subject — the typed identity of whoever is calling. It's always present (anonymous requests get an explicit anonymous subject), so app code never has to null-check before reading basic fields.
Shape
type Subject =
| { type: 'user'; id: string; tenantId: string; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
| { type: 'apiKey'; id: string; tenantId: string; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
| { type: 'serviceAccount'; id: string; tenantId: string; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
| { type: 'system'; id: string; tenantId: null; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
| { type: 'anonymous'; id: null; tenantId: string | null }scopes is the permission currency — the auth strategy that resolved the subject puts the computed scopes here, and @voltro/plugin-rbac compiles a caller's roles onto the same set. There is no roles field on the wire; roles are an authoring convenience that resolve to scopes.
The type discriminator narrows downstream fields:
if (ctx.subject.type === 'user') {
// ctx.subject.id, tenantId are strings
}
if (ctx.subject.type === 'anonymous') {
// ctx.subject.tenantId is string | null; id is null
}App metadata — subjectFromUser(user, { metadata })
metadata is the free-form slot the framework itself never reads. It is where a provider credential captured at login belongs — a plugin's credentialsResolver reads it back per request (@voltro/plugin-atlassian looks for subject.metadata.jiraToken, say), so nothing has to be re-fetched or stored server-side per call.
Pass it when you build the Subject:
import { subjectFromUser } from '@voltro/plugin-auth'
const subject = subjectFromUser(user, {
memberships, // → metadata.memberships
metadata: { jiraToken: atlassianPat }, // → metadata.jiraToken
})The two merge — neither clobbers the other. When a memberships key appears in both, the dedicated memberships option wins: it is the typed input, and it is the one projected into the { tenantId, role } shape subjectMemberships() and the tenant switcher read. Without the option, a memberships key inside metadata passes through unchanged. A Subject built with neither option has no metadata key at all.
Keys set this way survive the login paths: the sign-in / sign-up / magic-link / passkey handlers merge sessionId onto the existing slot, and the password strategy merges provider — they add, they don't replace. The one exception is naming a key sessionId or provider yourself; those two are overwritten by design.
It survives a tenant switch too. A switch rebuilds the Subject from the user record, so the built-in /switch-tenant route passes the caller's current subject.metadata through to handleSwitchTenant — a credential parked here keeps working after the user changes tenant. Calling handleSwitchTenant yourself? Pass metadata or the credential is dropped, and the symptom is unpleasant to diagnose: the user stays signed in while every call to the provider starts failing. memberships is deliberately not carried — it is re-derived for the target tenant, and a carried copy would report a role the user does not hold there.
Two things not to put here. Anything the caller could benefit from changing — the slot rides the signed session cookie, so it is tamper-evident, but it is also stale by design: it reflects the moment of sign-in, not the current database. And anything large — it is re-serialised into every session cookie.
Resolution
AuthMiddleware resolves a Subject on every request by running the strategy chain — composeAuthStrategies evaluates each strategy in order, first matched wins, first failed short-circuits to anonymous. A typical chain resolves, in order:
- Built-in password cookie —
voltroPasswordStrategyreadsvoltro:sessionand HMAC-verifies it. →type: 'user'. - API key —
apiKeyStrategyfrom@voltro/protocol/apikeymapsAuthorization: Bearer <prefix>_<token>→ a scopedapiKeysubject. This ships today; add it to theauth.strategieschain inapp.config.ts. →type: 'apiKey'. - In-process system calls — code running under
runAsSystem(cron, workflows, backfills) carries asystemsubject; it's never produced from an inbound request. →type: 'system'. - Anonymous fallback — nothing matched. The composer's fallback reads the
x-tenantheader and producesanonymousSubject(tenant). →type: 'anonymous'.
An anonymous request with x-tenant: <id> therefore carries that tenant on the subject, scoping reads on public tables. The header is unauthenticated — never trust it for writes.
Common patterns
Require sign-in
import { Schema } from 'effect'
class Unauthorised extends Schema.TaggedError<Unauthorised>()('Unauthorised', {}) {}
export default async (input, ctx) => {
if (ctx.subject.type !== 'user') throw new Unauthorised()
// ctx.subject.id is narrowed to string
}Require sign-in + specific tenant
import { assertOwnTenant } from '@voltro/plugin-multitenancy'
export default async (input, ctx) => {
if (ctx.subject.type !== 'user') throw new Unauthorised()
assertOwnTenant(input.tenantId, ctx.subject)
// …
}Allow system OR user
const canTrigger = ctx.subject.type === 'user' || ctx.subject.type === 'system'
if (!canTrigger) throw new Unauthorised({})RBAC
@voltro/plugin-rbac builds on the subject's scopes. Roles compile to scopes — the plugin resolves the caller's roles, flattens them onto the resolved scope set, and the permission() handler guard checks it in one line:
import { permission } from '@voltro/plugin-rbac'
yield* permission(ctx, 'admin:full') // Effect<void, ScopeError>See the RBAC plugin for the full model.
Anonymous tenant resolution
For public APIs where you want anonymous callers scoped to a tenant (multi-tenant marketing site, public listings), nothing extra to configure: when no strategy matches, composeAuthStrategies' default fallback reads the x-tenant header and yields anonymousSubject(tenant):
import { anonymousSubject } from '@voltro/protocol'
// fallback output for `x-tenant: foo`:
// { type: 'anonymous', id: null, tenantId: 'foo' }Tables with tenant() then scope anonymous reads to foo. To require a tenant on anonymous callers — so a tenant-less request can't read across the whole DB on tables that aren't tenant()-scoped — pass anonymousTenantRequired: true to composeAuthStrategies:
const resolveSubject = composeAuthStrategies(strategies, {
anonymousTenantRequired: true, // no x-tenant + no matched strategy → throws Unauthenticated
})When set, an unmatched call with no x-tenant header throws Unauthenticated instead of yielding a null-tenant anonymous Subject. (Ignored when you supply a custom fallback — that function owns the decision.)
Custom resolvers
For exotic auth setups (mTLS, custom JWTs from an upstream gateway) write a custom AuthStrategy and add it to the auth.strategies chain in app.config.ts — there is no runtime.resolveSubject field:
// app.config.ts
import { anonymousSubject } from '@voltro/protocol'
export default {
type: 'api' as const,
name: 'myApi',
auth: {
strategies: [
{
id: 'mtls',
resolve: (input) => {
const cn = input.headers['x-client-cert-cn']
return cn
? { kind: 'matched', subject: { type: 'system', id: `mtls:${cn}`, tenantId: null } }
: { kind: 'skip' }
},
},
],
},
}The built-in password strategy still runs first; your strategy adds to the chain. Most apps never need this.
ctx.subject in subscriptions
ctx.subject is captured at subscribe-time. If the cookie expires mid-subscription:
- Subsequent mutations from the now-expired client fail with
Unauthorised. - The subscription itself continues to stream until the client reconnects or the server drops it.
- On reconnect, the new connection re-resolves the subject — anonymous if the cookie is gone.
The open stream isn't force-killed on expiry; the next write fails with Unauthenticated and the client can re-auth or reconnect. That gentler UX is the framework's behaviour.
Anti-patterns
- Reading
ctx.subject.idwithout narrowing. It'sstringonuser/apiKey/serviceAccount/system, butnullonanonymous. Narrow ontypefirst. - Trusting
subject.tenantIdfor writes. It's read-only context. AlwaysassertOwnTenant(input.tenantId, ctx.subject)for any write that takes a tenant. - Caching subjects across requests. The subject is request-scoped; sessions expire, role membership changes. Resolve fresh each time.