Skip to content

savvycodes/policy

Repository files navigation

Policy

Warning

This is still in development and has no stable API.

A tiny, type-safe authorization framework for Node.js applications. Composable. Extensible. Performant.

Installation

pnpm add @savvycodes/policy

Getting started

Create a policy builder that knows about the current user (or whatever context type you need to validate your policy). Every call to the builder returns a strongly typed policy function for a specific resource.

// app/policies/base.ts
import { createPolicy } from "@savvycodes/policy";

export type User = { id: string; scopes: string[] };

export const policy = createPolicy<User>();

// app/policies/project.ts
import type { Project } from "./types";
import { policy } from "./base";

export const isOwner = policy<Project>(async (user, project) => {
  return project.ownerId === user.id;
});

export const isProjectLead = policy<Project>((user, project) => {
  return project.leadId === user.id;
});

export const isStakeholder = policy<Project>((user, project) => {
  return project.stakeholderIds.includes(user.id);
});

// app/routes/project.ts
import { enforce } from "@savvycodes/policy";
import { isOwner } from "@/app/policies/project";

export async function loader() {
  const { user } = await fakedb.getUser();
  const project = await fakedb.getProject();

  await enforce(isOwner, user, project);
}

Compose policies

Combine policies with and and or. Unbound policies share the same resource; pre-bind a resource with .with when you need to mix different resource types.

import { enforce, or, and } from "@savvycodes/policy";
import { isOwner, isProjectLead, isStakeholder } from "@/app/policies/project";

export async function loader() {
  const { user } = await fakedb.getUser();
  const project = await fakedb.getProject();

  await enforce(and(isOwner, isProjectLead), user, project);
  await enforce(or(isOwner, isProjectLead), user, project);

  // Deep compositions work as expected
  await enforce(or(and(isOwner, isProjectLead), isStakeholder), user, project);
}

Mixing resources

Policies expose a .with(resource) (alias .bind(resource)) helper that returns a bound policy. This lets you combine different resource types inside a single logical expression while keeping the type system honest.

const isProjectLead = policy<Project>(
  (user, project) => project.leadId === user.id
);
const isTeamOwner = policy<Team>((user, team) => team.ownerId === user.id);

const composite = and(isProjectLead.with(project), isTeamOwner.with(team));

await enforce(composite, user);

Filters / pre-checks

Use a before hook to short-circuit policy evaluation. Returning true authorizes immediately, false denies, and null (or undefined) falls through to the original handler.

export const policy = createPolicy<User>({
  async before(user) {
    if (user.scopes.includes("superuser")) return true;
    return null;
  },
});

Custom errors

enforce throws an AuthorizationError on failure. Provide a custom message or error instance if you need bespoke behaviour.

await enforce(isOwner, user, project, { message: "Forbidden" });

await enforce(isOwner, user, project, {
  error: () => new HttpError(403, "Forbidden"),
});

Development

pnpm install
pnpm test
pnpm lint
pnpm build

TODOs

Logging/metrics

  • Provide metrics
  • Allow custom loggers (pino)

Caching

  • Explore caching API (memory, valkey?, sqlite?)
  • Integration with existing caching libs

Express

  • Separate package: @savvycodes/policy/express
  • Explore middleware ergonomics
  • Offer helpers for route-based enforcement

React Router / Remix

  • Separate package: @savvycodes/policy/remix
  • Explore loader/action helpers
  • Provide declarative APIs for nested routes

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published