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.
Sign sessions by hand: the Worker signs a token, and middleware checks it. To sign users in with GitHub, Google or another provider, see Sign in with better-auth.
Declare who is asking
Section titled “Declare who is asking”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.
import * as Domain from "@alchemy.run/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,) {}The key ("Bank/CurrentActor") names the value when it travels with a request, so each request context needs its own.
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 });Authenticate with middleware
Section titled “Authenticate with middleware”Session is middleware that provides CurrentActor. Browsers send a session cookie; scripts and tests send an Authorization header.
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.
Guard commands and views
Section titled “Guard commands and views”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.
import * as Domain from "@alchemy.run/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.
Sign browsers in with a cookie
Section titled “Sign browsers in with a cookie”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.
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); },};import BankWorker from "@bank/server/BankWorker";import * as Alchemy from "alchemy";import * as Axiom from "@alchemy.run/axiom";import * as Cloudflare from "@alchemy.run/cloudflare";import * as Stripe from "@alchemy.run/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.
Sign the session
Section titled “Sign the session”import { Actor, customer } from "@bank/domain/Actor";import { CurrentActor } from "@bank/domain/CurrentActor.context";import { CustomerId } from "@bank/domain/Customer/Customer.id";import { SESSION_COOKIE, Session, Unauthorized } from "@bank/domain/Session.middleware";import * as Alchemy from "alchemy";import * as Clock from "effect/Clock";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Base64Url from "effect/encoding/Base64Url";import * as Cookies from "effect/http/Cookies";import * as Headers from "effect/http/Headers";import { HttpServerRequest } from "effect/http/HttpServerRequest";import * as HttpServerResponse from "effect/http/HttpServerResponse";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Redacted from "effect/Redacted";import * as Result from "effect/Result";import * as Schema from "effect/Schema";
/** How long a session lasts. */const SESSION_DAYS = 7;
/** The key that signs sessions: the same for every request to this stage. */export class SessionKey extends Context.Service< SessionKey, Effect.Effect<Redacted.Redacted<string>>>()("Bank/SessionKey") {}
/** A random key, made once when the stage is first deployed and kept in its state. */export const SessionKeyLive = Layer.effect( SessionKey, Effect.gen(function* () { const key = yield* Alchemy.Random("SessionKey"); return yield* key.text; }),);
const hmacKey = Effect.fn(function* () { const secret = Redacted.value(yield* yield* SessionKey); return yield* Effect.promise(() => crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"], ), );});
/** What a session token says: who, and until when. */const Claims = Schema.fromJsonString(Schema.Struct({ actor: Actor, expires: Schema.Number }));
/** A session token for `actor`: its claims and their HMAC, so no one without the key can make one. */export const signSession = Effect.fn(function* (actor: Actor) { const expires = (yield* Clock.currentTimeMillis) + SESSION_DAYS * 24 * 60 * 60 * 1000; // Encoding our own claims can't fail. const json = yield* Schema.encodeEffect(Claims)({ actor, expires }).pipe(Effect.orDie); const claims = Base64Url.encode(json); const key = yield* hmacKey(); const mac = yield* Effect.promise(() => crypto.subtle.sign("HMAC", key, new TextEncoder().encode(claims)), ); return `${claims}.${Base64Url.encode(new Uint8Array(mac))}`;});
/** The actor a token was signed for, if its signature checks out and it hasn't expired. */const verifySession = Effect.fn(function* (token: string) { const [claims, mac, ...rest] = token.split("."); if (!claims || !mac || rest.length > 0) return Option.none<Actor>(); const signature = Base64Url.decode(mac); if (Result.isFailure(signature)) return Option.none<Actor>(); const key = yield* hmacKey(); // `verify` compares in constant time. const valid = yield* Effect.promise(() => crypto.subtle.verify( "HMAC", key, new Uint8Array(signature.success), new TextEncoder().encode(claims), ), ); const json = Base64Url.decodeString(claims); if (!valid || Result.isFailure(json)) return Option.none<Actor>(); const decoded = yield* Schema.decodeEffect(Claims)(json.success).pipe(Effect.option); const now = yield* Clock.currentTimeMillis; return Option.flatMap(decoded, (c) => (c.expires > now ? Option.some(c.actor) : Option.none()));});
/** The token a request carries: its session cookie (browsers), or `Authorization: Bearer` (scripts). */const tokenOf = (headers: Headers.Headers): Option.Option<string> => Option.orElse( Option.flatMap(Headers.get(headers, "cookie"), (cookie) => Option.fromNullishOr(Cookies.parseHeader(cookie)[SESSION_COOKIE]), ), () => Option.flatMap(Headers.get(headers, "authorization"), (value) => value.startsWith("Bearer ") ? Option.some(value.slice("Bearer ".length)) : Option.none(), ), );
/** Who a request is from, if it carries a valid session. */export const actorOf = Effect.fn(function* (headers: Headers.Headers) { const token = tokenOf(headers); return Option.isNone(token) ? Option.none<Actor>() : yield* verifySession(token.value);});
/** The {@link Session} middleware: provides the request's actor, or fails with Unauthorized. */export const SessionLive = Layer.effect( Session, Effect.gen(function* () { const key = yield* SessionKey; return (effect, { headers }) => actorOf(headers).pipe( Effect.provideService(SessionKey, key), Effect.flatMap( Option.match({ onNone: () => Effect.fail(new Unauthorized()), onSome: (actor) => Effect.provideService(effect, CurrentActor, actor), }), ), ); }),);
const SignIn = Schema.Struct({ customerId: CustomerId });
/** * `POST /api/session` signs a customer in: it sets the signed session cookie, * and returns the same token for scripts to send as `Authorization: Bearer`. * `DELETE` signs them out. `HttpOnly` keeps the cookie from scripts, and * `SameSite=Strict` keeps it off cross-site requests, WebSocket upgrades * included. Demo only: anyone may sign in as any customer. Check a password, * a magic link or a provider here before signing. */export const sessionRoute = Effect.gen(function* () { const request = yield* HttpServerRequest; const options = { httpOnly: true, secure: true, sameSite: "strict", path: "/" } as const; if (request.method === "DELETE") { return HttpServerResponse.empty({ status: 204 }).pipe( HttpServerResponse.setCookieUnsafe(SESSION_COOKIE, "", { ...options, maxAge: 0 }), ); } if (request.method !== "POST") return HttpServerResponse.empty({ status: 405 }); const body = yield* request.json.pipe( Effect.flatMap(Schema.decodeUnknownEffect(SignIn)), Effect.option, ); if (Option.isNone(body)) return HttpServerResponse.text("Expected { customerId }", { status: 400 }); const token = yield* signSession(customer(body.value.customerId)); return yield* HttpServerResponse.json({ token }).pipe( Effect.map( HttpServerResponse.setCookieUnsafe(SESSION_COOKIE, token, { ...options, maxAge: `${SESSION_DAYS} days`, }), ), );});A session token is who is signed in and until when, plus an HMAC of both made with the Worker’s key. No one without the key can make one, and editing a token breaks its signature. crypto.subtle.verify checks it in constant time.
SessionKeyLive makes the key with Alchemy.Random: a random secret created on the first deploy, kept in the stage’s state and bound to the Worker. Every request to the stage checks against the same key.
sessionRoute signs the token at sign-in and sets it as the cookie. It returns the same token for scripts to send as Authorization: Bearer. Here anyone may sign in as any customer: put your own check, such as a password or a magic link, before signSession.
Keep tokens out of URLs
Section titled “Keep tokens out of URLs”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.
Ask for a second factor
Section titled “Ask for a second factor”Add middleware for anything that needs more than a session. WithdrawMoney lists [Session, MfaChallenge], so a withdrawal needs both.
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,}) {}Act without a session
Section titled “Act without a session”A policy has no request, so it provides CurrentActor itself before sending a command that needs one, usually as the system. See Policy.