Skip to content

Sessions and middleware

Every command and read starts with a question: who is asking? Middleware answers it before the request reaches the domain, and provides the answer as a request context that decide and validate read.

A request context is a service that travels with the request into the domain. It has a schema, because a command carries it to wherever its aggregate runs.

packages/domain/src/CurrentActor.context.ts
import * as Domain from "alchemy/Domain";
import { Actor } from "./Actor.ts";
/**
* Who is making the current request. `Session` provides it; commands read it
* in `decide` and views in `validate`. Senders without a session (policies)
* provide it themselves.
*/
export class CurrentActor extends Domain.RequestContext<CurrentActor>()(
"Bank/CurrentActor",
Actor,
) {}
packages/domain/src/Actor.ts
import * as Schema from "effect/Schema";
import { CustomerId } from "./Customer/Customer.id.ts";
/**
* Who is acting. `Session` provides it as the `CurrentActor` of each
* request; events that need an audit trail record it as `by`.
*/
export const Actor = Schema.Union([
Schema.TaggedStruct("Customer", { customerId: CustomerId }),
Schema.TaggedStruct("Agent", { agentId: Schema.String }),
Schema.TaggedStruct("System", { name: Schema.String }),
]);
export type Actor = typeof Actor.Type;
export const customer = (customerId: string): Actor => ({
_tag: "Customer",
customerId: CustomerId.make(customerId),
});
export const agent = (agentId: string): Actor => ({ _tag: "Agent", agentId });
export const system = (name: string): Actor => ({ _tag: "System", name });

Session is middleware that provides CurrentActor. Browsers send a session cookie; scripts and tests send an Authorization header.

packages/domain/src/Session.middleware.ts
import * as Headers from "effect/http/Headers";
import * as RpcMiddleware from "effect/rpc/RpcMiddleware";
import * as Schema from "effect/Schema";
import { CurrentActor } from "./CurrentActor.context.ts";
/** The request has no session. */
export class Unauthorized extends Schema.TaggedError<Unauthorized>()("Unauthorized", {}) {}
/** The cookie that carries a customer's session in a browser. */
export const SESSION_COOKIE = "bank_session";
/**
* Authenticates a request, from a customer or a support agent, and provides
* who is making it as the {@link CurrentActor}. Authorization is up to each
* command's `decide` and each view's `validate`.
*
* Browsers authenticate with the session cookie, which also reaches live
* reads: a WebSocket upgrade carries cookies, never a header a client sets.
* Other clients (tests, support tooling) send `Authorization` instead.
*/
export class Session extends RpcMiddleware.Service<Session, { provides: CurrentActor }>()(
"Bank/Session",
{ error: Unauthorized },
) {}
/** Client side, for clients that are not browsers: send this token with every call. */
export const sessionClient = (token: string) =>
RpcMiddleware.layerClient(Session, ({ request, next }) =>
next({ ...request, headers: Headers.set(request.headers, "authorization", `Bearer ${token}`) }),
);

The Worker that serves the Api provides SessionLive. A client that isn’t a browser adds sessionClient to attach its header.

List the middleware on each command and view that needs it. Middleware runs in order, and what it provides is what decide and validate may read.

packages/domain/src/Account/SetNickname.command.ts
import * as Domain from "alchemy/Domain";
import * as Schema from "effect/Schema";
import { Session } from "../Session.middleware.ts";
import { AccountId } from "./Account.id.ts";
import { NotOwner } from "./NotOwner.rejection.ts";
export class NicknameSet extends Domain.Event("NicknameSet", { nickname: Schema.String }) {}
export class SetNickname extends Domain.Command("SetNickname", {
target: AccountId,
middleware: [Session],
input: { nickname: Schema.String },
events: [NicknameSet],
rejects: [NotOwner],
}) {}

decide then checks the actor against the account. See Aggregate.

sessionRoute sets the session cookie. HttpOnly keeps it from scripts, and SameSite=Strict keeps it off requests from other sites, WebSocket upgrades included. The Worker sends /api/session to it.

Cookies only work when the app and the Api share an origin. The app’s Worker serves the page and forwards /api to the bank.

packages/web/src/worker.ts
interface Fetcher {
fetch(request: Request): Promise<Response>;
}
/**
* The app's Worker: the client build, plus `/api` forwarded to the bank over
* a service binding. One origin for both, so the session cookie is
* first-party and reaches every call and every live read's WebSocket.
*/
export default {
fetch(request: Request, env: { readonly ASSETS: Fetcher; readonly BANK: Fetcher }) {
const { pathname } = new URL(request.url);
return pathname.startsWith("/api") ? env.BANK.fetch(request) : env.ASSETS.fetch(request);
},
};
alchemy.run.ts
import BankWorker from "@bank/server/BankWorker";
import * as Alchemy from "alchemy";
import * as Axiom from "alchemy/Axiom";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Stripe from "alchemy/Stripe";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
export default Alchemy.Stack(
"CloudflareDomainExample",
{
providers: Layer.mergeAll(Cloudflare.providers(), Stripe.providers(), Axiom.providers()),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const bank = yield* BankWorker;
// The foldkit app, built with Vite. Its Worker serves the build and
// forwards /api to the bank, so the app and the Api share one origin.
const app = yield* Cloudflare.Website.Foldkit("BankApp", {
rootDir: "packages/web",
main: "src/worker.ts",
env: { BANK: bank },
assets: { runWorkerFirst: ["/api", "/api/*"] },
});
return { url: bank.url.as<string>(), app: app.url.as<string>() };
}),
);

env: { BANK: bank } gives the app’s Worker a binding to the bank, and runWorkerFirst sends /api through it.

A browser can’t set headers on a WebSocket, and a URL ends up in logs. A live read authenticates with what its WebSocket upgrade carries: in a browser, the session cookie. Scripts and tests send Authorization, which they can set on their WebSockets too. See Testing an Api.

Add middleware for anything that needs more than a session. WithdrawMoney lists [Session, MfaChallenge], so a withdrawal needs both.

packages/domain/src/MfaChallenge.middleware.ts
import * as RpcMiddleware from "effect/rpc/RpcMiddleware";
import * as Schema from "effect/Schema";
/** The request lacks a valid second factor. */
export class MfaRequired extends Schema.TaggedError<MfaRequired>()("MfaRequired", {}) {}
/** Requires a second factor for sensitive operations. */
export class MfaChallenge extends RpcMiddleware.Service<MfaChallenge>()("Bank/MfaChallenge", {
error: MfaRequired,
}) {}

A policy has no request, so it provides CurrentActor itself before sending a command that needs one, usually as the system. See Policy.