Authorization
A policy answers one question: may this caller run this
operation on this actor? Because an actor has an address, the answer can name
the instance — op.resource.key is the actor's key.
The app decides, most actors declare nothing
Authorization is app-wide by default. Point sigxActors at your
server app and its policy covers every
actor you have not spoken about:
// vite.config.ts
sigxActors({ app: '/src/actors/app.ts', serverApp: '/src/server-app.ts' })
// src/server-app.ts
createServerApp({
authenticate: (rq) => sessionFrom(rq.request),
authorize: requireAuthenticated, // every actor inherits this
codec: principalCodec, // required for cross-hop propagation
});
That is the whole setup for the common case. An actor declares a policy only when it needs something different from the app default.
Per-instance policies
A policy is core's ServerPolicy — (principal, rq, op) => boolean, strict-true
to allow — and op.resource carries the address:
type ActorResource = {
kind: 'actor';
type: string; // 'Cart'
key: string; // 'u_123'
method: string; // 'checkout'
};
So the policy every real system wants is one line:
// src/policies.ts
export const ownsIt: ServerPolicy = (p, rq, op) =>
p != null && op.resource.key === p.id;
export const CartActor = defineActor({
type: 'Cart',
authorize: ownsIt,
methodAuthorize: {
applyDiscount: requireStaff, // this one method needs more
},
state: () => ({ items: [] as Item[] }),
methods: (ctx) => ({ /* … */ }),
});
methodAuthorize ANDs after authorize — it does not replace it. A call to
applyDiscount above must satisfy ownsIt and requireStaff. Policies within an
array AND together too, so a chain is a conjunction at every level.
Declaring a deliberately open actor:
defineActor({ type: 'Counter', allowAnonymous: true, /* … */ });
allowAnonymous waives the identity gate only. Middleware and authentication
still run, and a policy declared alongside it still decides — against a nullable
principal. The two together are coherent, not contradictory.
sigxActors({ requireAuthorization }) is the build gate, on by default. It asks
whether an actor has said anything about access; an app-wide default answers for
every actor at once.
Three properties worth stating outright
They run on every transport. The wire endpoint and in-process actor() calls
both run the policy. You cannot bypass one by calling from the server. Where the
distinction matters, a policy reads op.fn.transport ('wire' or
'in-process'); Type#method identity stays the same on both.
They run outside the turn sequence. A slow auth check never occupies the actor, so a burst of unauthorized calls cannot queue behind each other or block legitimate ones.
Actor-to-actor calls do not re-run them. ctx.actor(Other, key).method() is
intra-system: the decision was made at the edge where the request entered. Treat an
actor method as trusted once it is executing.
ctx.principal
The authenticated identity is a first-class slot on the call envelope, and actors
read it as ctx.principal:
methods: (ctx) => ({
async checkout() {
audit(ctx.principal?.id, 'checkout', ctx.key);
},
}),
It is decoded lazily and memoized, and it carries unchanged through
ctx.actor and ctx.publish hops and host-to-host — so a downstream actor sees
whoever entered the system, not the actor that called it.
Cross-hop propagation needs codec on
createServerApp. Without one it propagates nothing and
dev-warns exactly once — fail-closed at the reader, but easy to miss if you
only ever test in one process.
Identity is not a bag key
ctx.principal and ctx.bag look similar and are
not interchangeable:
| carries | can it be forged? | |
|---|---|---|
ctx.principal | the authenticated identity | No — it rides its own envelope slot, unreachable from .with({ bag }) |
ctx.bag | app data (a request id, a tenant hint, a feature flag) | it is caller-supplied by design |
Two failure modes follow. A bag key can be forged by any caller that can build a request, and it can be dropped by an author forgetting to stamp it — and a missing entry reads as "unauthenticated", which is the one thing an identity channel must never be ambiguous about. Identity gets its own slot for exactly those reasons; the bag stays for data.
Jobs authorize at enqueue
A job outlives the request that started it. A crash-resume can happen on another host hours later with nobody waiting, so there is no live request to authorize against.
So start is the entry point that decides, and the detached run body reads a
snapshot:
const run = await ctx.jobs.start(ImportJob, input); // authorized HERE
// inside the job body:
job.principal // the snapshot, persisted with the run
ctx.principal // null — by design, there is no request behind a detached run
ctx.principal being null inside a detached run is exactly why job.principal
exists. Read the snapshot, not the ambient identity. (The same holds for a
task body, for the same reason — no request is behind it.)
Cacheable reads
A cacheable read still authorizes on GET, and a
denial answers Cache-Control: no-store. But public: true is refused at
definition time on an authorized read — a shared cache would serve one caller's
copy to the next, and core's contract for a public cache entry is args-only.
Without public, a declared read is still cached per client with Vary: Cookie.
Packaged actors
sigxActors() excludes node_modules, so it cannot see inside a
packaged actor to enforce the gate.
Declaring authorize or allowAnonymous is the package author's responsibility;
with a server app configured, the app default covers a package that declares
nothing.
Next steps
- Wire protocol — where authorization sits in a request, and the context bag.
- Cacheable reads — the
publicrestriction in context. - Server functions — the pipeline actors inherit.
