Skip to content

Resource resolvers

ResourceResolverMap maps a resource name to the shape of the thing it loads and the id that loads it.

declare module "@effect-server-utils/authz/resource-resolver-registry" {
interface ResourceResolverMap {
todo: { resourceType: Todo; idType: TodoId };
organization: { resourceType: Organization; idType: OrganizationId };
}
}

A resource that appears here is scoped: every action on it requires an id, the framework loads the resource through the registered resolver, and the loaded value is handed to the check.

A resource absent from this map is unscoped: no action on it takes an id, no resolver runs, and ResourceMissing never enters a call site’s error channel.

const calls = Effect.gen(function* () {
yield* hasPermissions("todo", "update", todoId); // scoped — id required
yield* hasPermissions("platform", "administer"); // unscoped — id forbidden
});

Both mistakes are compile errors. The API uses a variadic-tuple third argument rather than overloads specifically because the errors read better that way: a missing id is “Expected 3 arguments, but got 2”, not “not assignable to type ‘never’”.

Scopedness being a property of the resource rather than of the action is the modelling decision worth internalising. todo.list does not become unscoped just because listing has no single subject — if todo is scoped, model the listing as a different resource.

type Resolver<R> = (
id: IdFor<R>,
) => Effect.Effect<ResourceTypeFor<R>, NotFoundFor<R> | CheckFailure, never>;
import { makeResourceResolverRegistry } from "@effect-server-utils/authz";
const ResourceResolversLive = makeResourceResolverRegistry({
todo: (id) => todoRepository.byId(id),
organization: (id) => organizationRepository.byId(id),
});

The composition root calls this with the union of every module’s contributions; a test calls it with a synthetic map.

The configured CheckFailure is in the channel regardless — resolving a resource reads the store, and a transient outage there is the caller’s problem to retry. A resolver that died on it instead would report a retryable outage as an internal error, while the identical outage one step later, inside the check or the use case, propagated as a failure.

Most resources load a row and can therefore report absence, so NotFoundFor<R> defaults to your configured resourceMissing. A resource whose identity is its id — an “echo” resolver with nothing to load — opts out:

declare module "@effect-server-utils/authz/resource-resolver-registry" {
interface ResourceResolverMap {
tenant: { resourceType: TenantId; idType: TenantId; notFound: never };
}
}
const ResourceResolversLive = makeResourceResolverRegistry({
tenant: (id) => Effect.succeed(id),
});

notFound: never removes NotFound from every call site’s error channel for that resource, so there is no unreachable branch to defend against.

Calling hasPermissions for a scoped resource with no registered resolver dies:

ResourceResolverRegistry: no resolver registered for resource "todo"

Like a missing policy, this is a wiring bug rather than an authorization outcome.

Export What it is
ResourceResolverMap the interface a host augments
ResourceName keyof ResourceResolverMap — i.e. the scoped resources
IdFor<R> the id type a resource is loaded by
ResourceTypeFor<R> what a resolver returns and a check receives
NotFoundFor<R> the absence signal, or never if opted out
Resolver<R> the resolver signature
makeResourceResolverRegistry(resolvers) Layer<ResourceResolverRegistry>
ResourceResolverRegistry the service hasPermissions reads