Skip to content

Serve several kinds of caller from one route

Goal: answer one route for more than one kind of party — a richer view for a signed-in user, a public view for everyone else — without splitting the route or hand-rolling the “try auth A, else B” branching.

Name an ordered sum of peer actors on the by clause with |. The boundary tries each in declared order and binds the first that verifies; the body matches on which one it was.

context api
type UserId = String where NonEmpty
type Note = { id: String, owner: String }
actor User { auth = Bearer(secret = "AUTH_JWT_SECRET"), identity = UserId }
service api from http {
on GET("/notes/:id") by who: User | Visitor (id: String) -> Effect[HttpResult[Note]] {
match who {
User(u) => Ok(Note { id: id, owner: u })
Visitor => Ok(Note { id: id, owner: "public" })
}
}
}

Each arm binds that actor’s identity directlyUser(u) gives u : UserId (the arm already names the actor, so there is no .identity step); a party with no identity, like Visitor, binds nothing. If no member verifies, the route fails closed with 401.

A sum is checked for reachability — decidably, at the scheme level:

  • It needs a binder — the body learns which party verified by matching it.
  • Peers are distinguished by scheme, so no two members may share one (User | Visitor ✓; two Bearer actors ✗).
  • A catch-all comes last. Visitor (scheme None) accepts everyone, so anything after it is unreachable — write User | Visitor, never Visitor | User.
  • Refinements are not members. User | Admin is rejected — every Admin is a User, so the arm is dead. Narrow inside an arm instead (see authorisation invariants).
  • The body match must be exhaustive over the members.

Members can verify different ways — a header (Bearer) and a body (Signature) — in one route. The boundary reads the body once, tries each member against the material in hand, and parses the body from the same bytes:

context api
type UserId = String where NonEmpty
type Event = { id: String }
actor User { auth = Bearer(secret = "AUTH_JWT_SECRET"), identity = UserId }
actor Hook { auth = Signature(secret = "WH_SECRET", header = "X-Signature") }
service api from http {
on POST("/ingest") by who: User | Hook (body: Event) -> Effect[HttpResult[String]] {
match who {
User(u) => Ok(u)
Hook => Ok(body.id)
}
}
}

Next: add an authorisation invariant to a member, or read the reference.