Outlets
Outlets extend the pause/resume model with state persistence and delivery channels. Instead of manually serializing workflow state and building resume endpoints, outlets handle the full cycle: persist paused state into a token, deliver that token to the user (via HTTP response or email), and resume the workflow when the token comes back.
This is ideal for multi-step HTTP flows — login, registration, password recovery, checkout — where each step collects user input through a form, email link, or API call.
How Outlets Work
- A step pauses by returning an outlet signal (
outletHttp(...)oroutletEmail(...)) - The outlet handler persists the workflow state into a token (encrypted or server-stored)
- The appropriate outlet delivers the token — HTTP outlet returns it in the response body, email outlet sends it in a link
- The user resumes by sending the token back (in a form submission, query param, or cookie)
- The outlet handler retrieves the state from the token and resumes the workflow
All of this is orchestrated by a single method: moostWf.handleOutlet(config).
Setup
Install or update the required packages:
pnpm add @moostjs/event-wf@latestThe outlet APIs come from the underlying packages (@prostojs/wf v0.2.1+ and @wooksjs/event-wf v0.7.16+), but everything is re-exported from @moostjs/event-wf for convenience — you only need one import source.
Quick Example
A login flow with an HTTP outlet that serves forms and collects user input:
import { Controller, Injectable } from 'moost'
import { Post } from '@moostjs/event-http'
import {
MoostWf,
Workflow, WorkflowSchema, Step, WorkflowParam,
outletHttp, useWfFinished,
createHttpOutlet, HandleStateStrategy, WfStateStoreMemory,
} from '@moostjs/event-wf'
// Login is security-sensitive — use HandleStateStrategy for server-side state + fail-closed semantics.
// Swap WfStateStoreMemory for a Redis/database-backed WfStateStore in production.
const state = new HandleStateStrategy({ store: new WfStateStoreMemory() })
const httpOutlet = createHttpOutlet()
@Injectable()
@Controller('auth')
class AuthController {
constructor(private wf: MoostWf) {}
@Post('flow')
async flow() {
return this.wf.handleOutlet({
allow: ['auth/login'],
state,
outlets: [httpOutlet],
})
}
// controller prefix 'auth' + workflow path 'login' = schema ID 'auth/login'
@Workflow('login')
@WorkflowSchema(['credentials', 'create-session'])
loginFlow() {}
@Step('credentials')
async credentials(
@WorkflowParam('context') ctx: any,
@WorkflowParam('input') input?: { username: string; password: string },
) {
if (input) {
ctx.userId = await verifyCredentials(input.username, input.password)
return
}
return outletHttp({ type: 'login-form', fields: ['username', 'password'] })
}
@Step('create-session')
async createSession(@WorkflowParam('context') ctx: any) {
const session = await createUserSession(ctx.userId)
useWfFinished().set({
type: 'redirect',
value: '/dashboard',
cookies: { sid: { value: session.id, options: { httpOnly: true } } },
})
}
}What happens at runtime:
- Client sends
POST /auth/flowwith{ "wfid": "auth/login" } - Workflow starts →
credentialsstep returnsoutletHttp(...)→ workflow pauses - State is persisted by the strategy (server-side row for
HandleStateStrategy, encrypted blob forEncapsulatedStateStrategy) → HTTP outlet returns the form payload + token (wfs) in the response - Client renders the form, user fills it in, sends
POST /auth/flowwith{ "wfs": "<token>", "input": { "username": "...", "password": "..." } }— step input must be nested under theinputkey - State is retrieved via
consume()(atomic mutex against concurrent resumes) → workflow resumes atcredentialswith input →create-sessionruns → workflow finishes - Response includes the redirect and session cookie from
useWfFinished()
The handleOutlet Method
MoostWf.handleOutlet(config) is the single entry point for outlet-driven workflows. Call it from any HTTP handler — it reads wfid and wfs from the request, starts or resumes the workflow, and returns the result.
@Post('flow')
async flow() {
return this.wf.handleOutlet({
allow: ['auth/login', 'auth/recovery'], // which workflows are allowed
state: stateStrategy, // how to persist/retrieve paused state
outlets: [httpOutlet, emailOutlet], // delivery channels
})
}It routes through MoostWf.start() and resume() internally, so all Moost DI, interceptors, and pipes work normally in your workflow steps.
Configuration
The full WfOutletTriggerConfig interface:
| Field | Type | Description |
|---|---|---|
allow | string[] | Whitelist of workflow IDs that can be started. Empty = all allowed |
block | string[] | Blacklist of workflow IDs (checked after allow) |
state | WfStateStrategy | { strategies: Record<string, WfStateStrategy>, default: string | ((wfid) => string) } | State persistence strategy (required). See State Strategies below. |
outlets | WfOutlet[] | Registered delivery channels (required) |
token | WfOutletTokenConfig | Token read/write/naming configuration |
wfidName | string | Request parameter name for workflow ID (default: 'wfid') |
initialContext | (body, wfid) => unknown | Factory for initial workflow context |
onFinished | (ctx) => unknown | Override the completion response |
Token Configuration
Control where the state token is read from and written to:
this.wf.handleOutlet({
// ...
token: {
read: ['body', 'query', 'cookie'], // where to look for the token (default: all three)
write: 'body', // where to write the token in the response (default: 'body')
name: 'wfs', // parameter name (default: 'wfs')
},
})Token Stability & Resume Semantics
The wfs token is a workflow session credential, not a per-step single-use token. With HandleStateStrategy it is minted on start and reused on every resume (the engine re-persists under the same handle), so a single wfs survives browser refresh, bookmark-and-resume, magic-link reopen on a different device, and lost-connection retry across the entire workflow run.
Every resume still calls strategy.consume(token) atomically before the step runs — but only as a brief mutex against concurrent resumes. Two tabs racing the same wfs: one wins, the other gets HTTP 410 Gone with body { error: 'Invalid or expired workflow state' }. After the winner re-persists under the same handle, the loser's next attempt succeeds.
With EncapsulatedStateStrategy the token IS the state — consume() is a stateless no-op, and a copy of the token remains valid for the full TTL. Because the ciphertext is a function of the (now-advanced) state, the response token differs from the request token on every state-mutating resume, so clients on this strategy must always read the renewed wfs from each response.
Fail-closed on unexpected errors. The engine re-persists under the same handle only after the step returns. An unexpected throw during resume skips that re-persist — the handle is gone and the user must restart the workflow. This is the security-preferred behavior (no lingering replayable token after a failed attempt). For expected validation failures (wrong password, invalid input, rate limit), return an outlet signal from the step handler instead of throwing — the engine re-persists under the same handle on the re-pause, and the same wfs keeps working.
Replay protection. Single-use rotation at the transport layer is no longer the model. The workflow record itself advances after each successful step, so a replayed token resumes from the current step, not a previous one — there is no way to re-execute a past step via the token. Steps with non-idempotent side effects should still guard themselves at the step layer (idempotency keys in the form payload, advance counters, etc.); do not rely on token rotation for replay safety.
Out-of-Band Outlets
An outlet's tokenDelivery field controls whether the wfs token is returned to the HTTP caller or delivered through the outlet's own channel:
tokenDelivery: 'caller'(default forcreateHttpOutlet()) — token is merged into the HTTP response body or cookie pertoken.write.tokenDelivery: 'out-of-band'(default forcreateEmailOutlet()) — token is NOT returned to the caller; the outlet delivers it via its own channel (email link, SMS, etc.).
Any custom outlet whose resumer is a different principal than the HTTP caller MUST declare 'out-of-band', otherwise the caller receives a token they shouldn't have — a privilege escalation vector.
State Strategies
A state strategy controls how paused workflow state is persisted and retrieved. Two strategies are provided.
Selection rule
If the flow has any real-world side effect (auth, credentials, money, permissions, invites), use HandleStateStrategy. It is the only strategy with server-side state — required for revocation, fail-closed semantics on unexpected errors, and a stable resume URL across the entire workflow. Use EncapsulatedStateStrategy only when every step is idempotent and replay-within-TTL is harmless.
EncapsulatedStateStrategy
Encrypts the entire workflow state into the token itself using AES-256-GCM. No server-side storage needed — the token is self-contained.
import { EncapsulatedStateStrategy } from '@moostjs/event-wf'
const state = new EncapsulatedStateStrategy({
secret: process.env.WF_SECRET!, // must be exactly 32 bytes (64-char hex string)
defaultTtl: 30 * 60 * 1000, // optional: default expiration (30 minutes)
})Pros: Zero infrastructure, horizontally scalable, no cleanup needed.
Cons: Tokens are larger (they contain the full state), cannot be revoked server-side, and — most importantly — the token cannot be invalidated. consume() is a no-op alias for retrieve(), so any copy of the token (forwarded email link, logged URL, browser history) remains valid for the full TTL. The token does rotate on every state-mutating resume (the ciphertext changes with the state), but the previous token stays valid until it expires. Do not use for auth, password reset, invite accept, financial operations, or anything else where replay within the TTL window is a problem.
HandleStateStrategy
Stores state server-side with an opaque handle as the token. Requires a WfStateStore implementation. Required for security-sensitive flows.
import { HandleStateStrategy, WfStateStoreMemory } from '@moostjs/event-wf'
// In-memory store (for development/testing)
const store = new WfStateStoreMemory()
const state = new HandleStateStrategy({
store,
defaultTtl: 30 * 60 * 1000,
generateHandle: () => crypto.randomUUID(), // optional custom handle generator
})Pros: Small tokens, server-side revocation, and fail-closed on unexpected errors — consume() runs atomically before the step (acting as a mutex against concurrent resumes), and the engine re-persists under the same handle only after the step returns. If the step throws unexpectedly, the handle is gone and the workflow cannot be resumed without restart. The wfs itself stays stable across normal resumes (refresh, bookmark, magic-link reopen all keep working).
Cons: Requires a persistent store in production. In-memory WfStateStoreMemory is for dev/testing only — it loses state on restart and does not survive across replicas.
Custom Store
Implement WfStateStore to use your own database:
import type { WfStateStore, WfState } from '@moostjs/event-wf'
class RedisStateStore implements WfStateStore {
constructor(private redis: RedisClient) {}
async set(handle: string, state: WfState, expiresAt?: number) {
const ttl = expiresAt ? Math.ceil((expiresAt - Date.now()) / 1000) : 0
await this.redis.set(`wf:${handle}`, JSON.stringify(state), ttl ? { EX: ttl } : {})
}
async get(handle: string) {
const data = await this.redis.get(`wf:${handle}`)
return data ? { state: JSON.parse(data) } : null
}
async delete(handle: string) {
await this.redis.del(`wf:${handle}`)
}
async getAndDelete(handle: string) {
const data = await this.get(handle)
if (data) await this.delete(handle)
return data
}
}Named Strategy Registry
Register strategies under names and pick one per workflow (or per workflow run). The active strategy name is embedded in the issued token as <name>.<raw>, so resume always picks the strategy that persisted the state — each strategy can have its own independent storage.
const encapsulated = new EncapsulatedStateStrategy({ secret: process.env.WF_SECRET! })
const durable = new HandleStateStrategy({ store: new RedisStore() })
this.wf.handleOutlet({
state: {
strategies: { mem: encapsulated, kv: durable },
default: (wfid) => (wfid.startsWith('auth/') ? 'kv' : 'mem'),
},
// ...
})strategies— record of namedWfStateStrategyinstances. Names must match/^[A-Za-z0-9_-]+$/.default— either a name string or(wfid) => nameinvoked on workflow start.
Passing a bare WfStateStrategy (the shortcut form) is auto-promoted to { strategies: { default: <strategy> }, default: 'default' }.
Swapping the Strategy at Runtime
A step can escalate from cheap encapsulated state to durable storage right before a long-running pause:
import { swapStrategy } from '@moostjs/event-wf'
@Step('await-approval')
async awaitApproval() {
swapStrategy('kv') // next pause persists via the 'kv' strategy
return outletHttp({ fields: ['decision'] })
}swapStrategy(name) validates only the name format. Unknown names surface as a loud error from the trigger at pause time, when it cannot find the name in its registry.
To inspect the active name from within a step, use useWfStrategy().current().
Outlets
An outlet is a delivery channel that sends the state token to the user. Two outlets are provided.
HTTP Outlet
Returns the outlet payload as the HTTP response body. The client receives the form definition and the state token, renders the form, and submits back to the same endpoint.
import { createHttpOutlet } from '@moostjs/event-wf'
const httpOutlet = createHttpOutlet()
// With a transform function to reshape the payload:
const httpOutlet = createHttpOutlet({
transform: (payload, context) => ({
...payload,
csrfToken: generateCsrf(),
}),
})In your step, return outletHttp(payload) to trigger the HTTP outlet:
@Step('collect-address')
async collectAddress(
@WorkflowParam('context') ctx: TCheckoutContext,
@WorkflowParam('input') input?: TAddress,
) {
if (input) {
ctx.address = input
return
}
return outletHttp({
type: 'address-form',
fields: ['street', 'city', 'zip', 'country'],
defaults: ctx.address,
})
}The response the client receives is the outlet payload itself (merged with the outlet context, if any) plus the wfs token at the top level:
{
"type": "address-form",
"fields": ["street", "city", "zip", "country"],
"defaults": null,
"wfs": "<encrypted-or-handle-token>"
}To resume, the client posts the token back with the step input nested under input:
{ "wfs": "<token>", "input": { "street": "123 Main St", "city": "Springfield", "zip": "62701", "country": "US" } }Email Outlet
Sends a token via email. You provide the send function — the outlet handles token generation and delivery orchestration.
import { createEmailOutlet } from '@moostjs/event-wf'
const emailOutlet = createEmailOutlet(async ({ target, template, context, token }) => {
await mailer.send({
to: target,
subject: getSubject(template),
html: renderTemplate(template, {
...context,
actionUrl: `https://app.example.com/auth/flow?wfs=${token}`,
}),
})
})In your step, return outletEmail(target, template, context?) to send an email:
@Step('send-verification')
@StepTTL(30 * 60 * 1000) // link valid for 30 minutes
async sendVerification(@WorkflowParam('context') ctx: TRegistrationContext) {
return outletEmail(ctx.email, 'verify-email', { name: ctx.name })
}When the user clicks the link, the wfs token in the query string resumes the workflow — token.read includes 'query' by default for exactly this case. Make sure the trigger endpoint also accepts GET requests (see the complete example).
Custom Outlets
Implement the WfOutlet interface for other delivery channels (SMS, push notifications, webhooks):
import type { WfOutlet, WfOutletRequest, WfOutletResult } from '@moostjs/event-wf'
const smsOutlet: WfOutlet = {
name: 'sms',
tokenDelivery: 'out-of-band', // SMS recipient ≠ HTTP caller — do not leak token to the caller
async deliver(request: WfOutletRequest, token: string): Promise<WfOutletResult | void> {
await smsService.send({
to: request.target!,
body: `Your code: ${token.slice(0, 6)}`, // short code from token
})
// Return void — no HTTP response needed for SMS
},
}Use the generic outlet() helper in your step:
import { outlet } from '@moostjs/event-wf'
@Step('send-sms-code')
async sendSmsCode(@WorkflowParam('context') ctx: any) {
return outlet('sms', { target: ctx.phone })
}Step TTL
The @StepTTL(ms) decorator sets an expiration time on the paused state token. The state strategy uses this to auto-expire persisted state.
import { StepTTL } from '@moostjs/event-wf'
@Step('send-recovery-email')
@StepTTL(30 * 60 * 1000) // 30 minutes
async sendRecoveryEmail(@WorkflowParam('context') ctx: any) {
return outletEmail(ctx.email, 'recovery', { name: ctx.name })
}Without @StepTTL, the strategy's defaultTtl is used (or no expiration if that is also unset).
You can also set expiration inline without the decorator:
return { ...outletEmail(ctx.email, 'recovery'), expires: Date.now() + 30 * 60 * 1000 }Workflow Completion
Use the useWfFinished() composable in the final step to control what the HTTP trigger returns when the workflow completes:
import { useWfFinished } from '@moostjs/event-wf'
@Step('complete')
async complete(@WorkflowParam('context') ctx: any) {
useWfFinished().set({
type: 'redirect', // 'redirect' or 'data'
value: '/dashboard', // URL for redirect, or response body for data
status: 302, // optional HTTP status
cookies: { // optional cookies to set
'session-id': {
value: ctx.sessionId,
options: { httpOnly: true, secure: true },
},
},
})
}If neither useWfFinished() nor the trigger's onFinished callback is used, the outlet handler responds with the literal { finished: true } — it does not expose the workflow context or state. Use onFinished (on the trigger config) or useWfFinished() (in a step) to return data from the workflow.
Complete Example: Auth Workflows
A real-world controller with login and password recovery flows sharing a single HTTP endpoint:
import { Controller, Injectable } from 'moost'
import { Get, Post } from '@moostjs/event-http'
import {
MoostWf,
Workflow, WorkflowSchema, Step, WorkflowParam, StepTTL,
outletHttp, outletEmail, useWfFinished,
createHttpOutlet, createEmailOutlet,
HandleStateStrategy, WfStateStoreMemory,
} from '@moostjs/event-wf'
// Auth flows are security-sensitive — HandleStateStrategy gives server-side state, revocation, and fail-closed semantics.
// Use a Redis/database-backed WfStateStore in production (WfStateStoreMemory is dev-only).
const stateStrategy = new HandleStateStrategy({ store: new WfStateStoreMemory() })
const httpOutlet = createHttpOutlet()
const emailOutlet = createEmailOutlet(async ({ target, template, context, token }) => {
await mailer.send({
to: target,
template,
data: { ...context, verifyUrl: `/auth/flow?wfs=${token}` },
})
})
@Injectable()
@Controller('auth')
class AuthController {
constructor(
private wf: MoostWf,
private users: UserService,
private sessions: SessionService,
) {}
// --- Single HTTP endpoint for all auth workflows ---
// POST for form submissions, GET for magic links (token.read includes 'query' by default)
@Get('flow')
@Post('flow')
async flow() {
return this.wf.handleOutlet({
allow: ['auth/login', 'auth/recovery'],
state: stateStrategy,
outlets: [httpOutlet, emailOutlet],
})
}
// --- Login workflow ---
// controller prefix 'auth' + workflow path 'login' = schema ID 'auth/login'
@Workflow('login')
@WorkflowSchema([
'login-form',
{ condition: (ctx) => ctx.mfaRequired, steps: ['mfa-verify'] },
'create-session',
])
loginFlow() {}
@Step('login-form')
async loginForm(
@WorkflowParam('context') ctx: any,
@WorkflowParam('input') input?: { username: string; password: string },
) {
if (input) {
const user = await this.users.authenticate(input.username, input.password)
ctx.userId = user.id
ctx.mfaRequired = user.mfaEnabled
return
}
return outletHttp({ type: 'login', fields: ['username', 'password'] })
}
@Step('mfa-verify')
async mfaVerify(
@WorkflowParam('context') ctx: any,
@WorkflowParam('input') input?: { code: string },
) {
if (input) {
await this.users.verifyMfa(ctx.userId, input.code)
return
}
return outletHttp({ type: 'mfa', fields: ['code'] })
}
// --- Recovery workflow --- (effective schema ID: 'auth/recovery')
@Workflow('recovery')
@WorkflowSchema(['recovery-email', 'send-link', 'reset-password', 'create-session'])
recoveryFlow() {}
@Step('recovery-email')
async recoveryEmail(
@WorkflowParam('context') ctx: any,
@WorkflowParam('input') input?: { email: string },
) {
if (input) {
const user = await this.users.findByEmail(input.email)
ctx.userId = user.id
ctx.email = user.email
ctx.name = user.name
return
}
return outletHttp({ type: 'email-form', fields: ['email'] })
}
@Step('send-link')
@StepTTL(30 * 60 * 1000) // 30-minute magic link
async sendLink(@WorkflowParam('context') ctx: any) {
return outletEmail(ctx.email, 'recovery', { name: ctx.name })
}
@Step('reset-password')
async resetPassword(
@WorkflowParam('context') ctx: any,
@WorkflowParam('input') input?: { password: string },
) {
if (input) {
await this.users.resetPassword(ctx.userId, input.password)
return
}
return outletHttp({ type: 'password-form', fields: ['password'] })
}
// --- Shared completion step ---
@Step('create-session')
async createSession(@WorkflowParam('context') ctx: any) {
const session = await this.sessions.create(ctx.userId)
useWfFinished().set({
type: 'redirect',
value: '/dashboard',
cookies: { sid: { value: session.id, options: { httpOnly: true } } },
})
}
}Client-side flow for login (the wfs minted at start is reused across every resume — with HandleStateStrategy the engine re-persists under the same handle):
POST /auth/flow { "wfid": "auth/login" }
← 200 { "type": "login", "fields": ["username", "password"], "wfs": "T1" }
POST /auth/flow { "wfs": "T1", "input": { "username": "alice", "password": "s3cret" } }
← 200 { "type": "mfa", "fields": ["code"], "wfs": "T1" }
(or skip MFA if not required → redirect directly)
POST /auth/flow { "wfs": "T1", "input": { "code": "123456" } }
← 302 Location: /dashboard Set-Cookie: sid=...Client-side flow for recovery (same wfs throughout — the email-link token is the same T1 minted at start; on EncapsulatedStateStrategy the token would rotate on every state change instead):
POST /auth/flow { "wfid": "auth/recovery" }
← 200 { "type": "email-form", "fields": ["email"], "wfs": "T1" }
POST /auth/flow { "wfs": "T1", "input": { "email": "alice@example.com" } }
← 200 (email sent, workflow paused at email outlet — no wfs in response; tokenDelivery: 'out-of-band')
GET /auth/flow?wfs=T1 (magic link — handled by the @Get('flow') route)
← 200 { "type": "password-form", "fields": ["password"], "wfs": "T1" }
POST /auth/flow { "wfs": "T1", "input": { "password": "n3wP@ss" } }
← 302 Location: /dashboard Set-Cookie: sid=...Initial Context
Use initialContext to seed the workflow context from the request body when starting:
this.wf.handleOutlet({
// ...
initialContext: (body, wfid) => ({
startedAt: Date.now(),
source: body?.referrer ?? 'direct',
}),
})The function receives the parsed request body and the workflow ID.
Composables
useWfOutlet()
Advanced composable for accessing outlet infrastructure from within a workflow step:
import { useWfOutlet } from '@moostjs/event-wf'
const { getOutlets, getOutlet } = useWfOutlet()Most steps do not need this — use outletHttp() / outletEmail() / outlet() instead.
useWfStrategy()
Inspect or swap the active state strategy name from within a workflow step. The new name applies to the next pause — it travels back to the outlet trigger via output.inputRequired.stateStrategy and persists in the issued token's prefix.
import { swapStrategy, useWfStrategy } from '@moostjs/event-wf'
const { current, swap } = useWfStrategy()
current() // → 'mem'
swap('kv') // or use the sugar: swapStrategy('kv')Only the name format is validated here (/^[A-Za-z0-9_-]+$/); existence in the trigger's registry is validated at pause time.
useWfFinished()
Sets the completion response when the workflow finishes. See Workflow Completion above.
import { useWfFinished } from '@moostjs/event-wf'
useWfFinished().set({ type: 'data', value: { success: true } })