Skip to content

YAOSGit/access-lib

Repository files navigation

access-lib

Pure ABAC guard composition library — composable, type-safe, zero dependencies

Node Version TypeScript Version Standard Schema

Uses Vitest Uses Biome


Table of Contents

Getting Started

API Reference

Development


Overview

access-lib is a pure Attribute-Based Access Control (ABAC) library for TypeScript. Guards are composable boolean units. You define conditions, compose them with boolean algebra, and wrap functions or check access points.

No roles table. No action registry. No policy DSL. Just conditions and composition.

Standard Schema integration means any schema library with a conforming adapter works as the validator — Zod, Valibot, ArkType, and others.

What Makes This Unique

  • Guards as boolean algebra — compose with .and(), .or(), .not() to build any access expression
  • Type-safe composition — Standard Schema validates context and resource before any condition runs
  • Two modes — curried Guard (pass context explicitly) or bound BoundGuard (context resolved from a provider)
  • Immutable — every composition method returns a new guard, the original is never mutated
  • Zero runtime dependencies — Standard Schema is a type-only peer dependency; no runtime overhead

Installation

npm install @yaos-git/access-lib

Standard Schema must be available as a peer dependency. It is installed automatically in most setups.

npm install @standard-schema/spec

Install your preferred schema library and use it with createGuard:

# Zod (example)
npm install zod

Quick Start

import { createGuard, AccessDeniedError } from '@yaos-git/access-lib';
import { z } from 'zod';

// 1. Define schemas
const contextSchema = z.object({
	userId: z.string(),
	role:   z.enum(['admin', 'editor', 'viewer']),
});

const resourceSchema = z.object({
	id:      z.string(),
	ownerId: z.string(),
	status:  z.enum(['draft', 'published']),
});

// 2. Create a factory
const factory = createGuard({ contextSchema, resourceSchema });

// 3. Define guards
const isAdmin = factory(({ context }) => context.role === 'admin');
const isOwner = factory(({ context, resource }) => context.userId === resource.ownerId);
const isDraft = factory(({ resource }) => resource.status === 'draft');

// 4. Compose guards
const canPublish = isOwner.and(isDraft).or(isAdmin);

// 5. Check access
const doc    = { id: 'doc-1', ownerId: 'user-1', status: 'draft' as const };
const author = { userId: 'user-1', role: 'viewer' as const };

const allowed = await canPublish.can(author, doc);   // true

// 6. Protect a function
async function publishDocument(doc: typeof resourceSchema._type) {
	return `published ${doc.id}`;
}

const protectedPublish = canPublish.protect(publishDocument);

try {
	const result = await protectedPublish(author)(doc);
} catch (err) {
	if (err instanceof AccessDeniedError) {
		console.error('Access denied:', err.context, err.resource);
	}
}

API Reference

createGuard

Creates a guard factory bound to a context schema and a resource schema.

function createGuard<TContext, TResource>(
	config: GuardFactoryConfig<TContext, TResource>,
): GuardFactoryResult<TContext, TResource>;

function createGuard<TContext, TResource>(
	config: GuardFactoryConfigWithProvider<TContext, TResource>,
): BoundGuardFactoryResult<TContext, TResource>;
Config field Type Required Description
contextSchema StandardSchemaV1<TContext> Yes Schema to validate the access context
resourceSchema StandardSchemaV1<TResource> Yes Schema to validate the resource being accessed
contextProvider () => TContext | Promise<TContext> No If provided, returns BoundGuard instances

Without contextProvider — returns a factory that produces Guard instances. Call .can(context, resource) with both arguments.

const factory = createGuard({ contextSchema, resourceSchema });
const isAdmin = factory(({ context }) => context.role === 'admin');

With contextProvider — returns a factory that produces BoundGuard instances. Context is resolved automatically. Call .can(resource) with only the resource.

const factory = createGuard({
	contextSchema,
	resourceSchema,
	contextProvider: () => getCurrentSession().user,
});
const isAdmin = factory(({ context }) => context.role === 'admin');

factory(condition)

Creates a guard from a condition function.

type Condition<TContext, TResource> = (params: {
	context:  TContext;
	resource: TResource;
}) => boolean | Promise<boolean>;

factory(condition: Condition<TContext, TResource>): Guard<TContext, TResource>
// or BoundGuard if factory was created with contextProvider

The condition receives validated context and resource — schemas are applied before the condition runs.


factory.extend(baseCondition)

Creates a new factory that prepends a base condition to every guard it produces. Conditions are evaluated in sequence with short-circuit semantics: if the base condition returns false, the guard's own condition is never evaluated.

factory.extend(baseCondition: Condition<TContext, TResource>): typeof factory
// All guards from adminFactory will first require role === 'admin'
const adminFactory = factory.extend(({ context }) => context.role === 'admin');

const canDeleteOwn = adminFactory(
	({ context, resource }) => context.userId === resource.ownerId,
);

The original factory is not affected. factory.extend() returns a new independent factory.


Guard

Returned by createGuard factories without a contextProvider. Both context and resource must be passed explicitly.

guard.can(context, resource)

Validates inputs and evaluates the condition.

guard.can(context: TContext, resource: TResource): Promise<boolean>

Throws ValidationError if either input fails schema validation.

guard.protect(fn)

Wraps an async function. Returns a curried function: call with context first, then with the original function's arguments.

guard.protect(fn: (...args: TArgs) => Promise<TReturn>): (context: TContext) => (...args: TArgs) => Promise<TReturn>
const protectedFn = isAdmin.protect(deleteDocument);

// Pre-bind context and reuse
const asAdmin = protectedFn(adminCtx);
await asAdmin(doc1);
await asAdmin(doc2);

Throws AccessDeniedError when the guard denies access.


BoundGuard

Returned by createGuard factories with a contextProvider. Context is resolved from the provider on every call.

boundGuard.can(resource)

Resolves context from the provider, validates both inputs, and evaluates the condition.

boundGuard.can(resource: TResource): Promise<boolean>

boundGuard.protect(fn)

Wraps an async function. Returns a flat function — context is resolved automatically from the provider.

boundGuard.protect(fn: (...args: TArgs) => Promise<TReturn>): (...args: TArgs) => Promise<TReturn>
const protectedFn = boundIsAdmin.protect(deleteDocument);
await protectedFn(doc);   // context resolved from provider

Composition

All composition methods are available on both Guard and BoundGuard. Each method accepts either a guard instance or a raw Condition function.

.and(other)

Returns a new guard that passes when both sides pass. Short-circuits on the first false.

guard.and(other: Condition<TContext, TResource> | Guard<TContext, TResource>): Guard<TContext, TResource>

.or(other)

Returns a new guard that passes when either side passes. Short-circuits on the first true.

guard.or(other: Condition<TContext, TResource> | Guard<TContext, TResource>): Guard<TContext, TResource>

.not()

Returns a new guard that inverts the result of the original.

guard.not(): Guard<TContext, TResource>

Composition examples:

// Owner AND draft
const canEditDraft = isOwner.and(isDraft);

// (Owner AND draft) OR admin
const canPublish = isOwner.and(isDraft).or(isAdmin);

// Not published
const notPublished = isPublished.not();

// Inline condition
const canEditLong = isOwner.and(({ resource }) => resource.id.length > 0);
Method Semantics Short-circuit
.and(other) Both must pass Yes — stops at first false
.or(other) Either must pass Yes — stops at first true
.not() Inverts the result N/A

Errors

AccessDeniedError

Thrown by .protect() when the guard denies access. Extends Error.

class AccessDeniedError extends Error {
	readonly context:  unknown;   // validated context value
	readonly resource: unknown;   // validated resource value
}
try {
	await protectedFn(viewer)(doc);
} catch (err) {
	if (err instanceof AccessDeniedError) {
		// err.context  — who was denied
		// err.resource — what they were denied access to
	}
}

ValidationError

Thrown by .can() and .protect() when schema validation fails. Extends Error.

class ValidationError extends Error {
	readonly issues: readonly StandardSchemaV1.Issue[];
}

Validation is applied to both context and resource before the condition runs. If validation fails, the condition is never evaluated.


Available Scripts

Development Scripts

Script Description
npm run dev Run TypeScript checking and test watcher concurrently
npm run dev:typescript Run TypeScript type checking in watch mode
npm run dev:test Run Vitest in watch mode

Build Scripts

Script Description
npm run build Bundle with esbuild and emit declarations

Lint Scripts

Script Description
npm run lint Run type checking, linting, formatting, and audit
npm run lint:types Run TypeScript type checking only
npm run lint:fix Check and fix linting issues with Biome
npm run lint:format Format all files with Biome
npm run lint:check Check code for linting issues with Biome
npm run lint:audit Run npm audit

Testing Scripts

Script Description
npm test Run all tests (unit, types, e2e)
npm run test:unit Run unit tests
npm run test:types Run type-level tests
npm run test:e2e Run end-to-end tests
npm run coverage Run tests with coverage report

Tech Stack

Core

  • Standard Schema — Type-only peer dep; any conforming schema library works at runtime
  • Zod — Used in dev and tests only; not a runtime dependency

Build and Development

  • esbuild — Fast bundler
  • tsgo — TypeScript type checker (native preview)
  • Vitest — Unit and type-level testing framework
  • Biome — Linter and formatter

Project Structure

access-lib/
├── src/
│   ├── core/
│   │   ├── BoundGuard/             # BoundGuard class (contextProvider mode)
│   │   │   ├── index.ts
│   │   │   └── index.test.ts
│   │   ├── Condition/              # Boolean evaluation helpers (and/or/not)
│   │   │   ├── index.ts
│   │   │   └── index.test.ts
│   │   ├── Guard/                  # Guard class (explicit context mode)
│   │   │   ├── index.ts
│   │   │   └── index.test.ts
│   │   ├── GuardFactory/           # createGuard factory function
│   │   │   ├── index.ts
│   │   │   └── index.test.ts
│   │   └── Validator/              # Standard Schema validation adapter
│   │       ├── index.ts
│   │       └── index.test.ts
│   ├── types/
│   │   ├── Error/                  # AccessDeniedError, ValidationError
│   │   │   └── index.ts
│   │   └── Guard/                  # Condition, GuardFactoryConfig types
│   │       ├── index.ts
│   │       └── index.test-d.ts
│   └── index.ts                    # Public API surface
├── examples/
│   ├── basic-guards/               # createGuard and .can() walkthrough
│   ├── guard-composition/          # .and(), .or(), .not() walkthrough
│   └── protect-wrapper/            # .protect() and factory.extend() walkthrough
├── biome.json                      # Biome configuration
├── tsconfig.json                   # TypeScript base configuration
├── tsconfig.app.json               # App TypeScript configuration
├── tsconfig.vitest.json            # Vitest TypeScript configuration
├── vitest.config.ts                # Vitest shared configuration
├── vitest.unit.config.ts           # Unit test configuration
├── vitest.type.config.ts           # Type test configuration
├── vitest.e2e.config.ts            # E2E test configuration
├── esbuild.config.js               # esbuild bundler configuration
└── package.json

Versioning

This project uses a custom versioning scheme: MAJORYY.MINOR.PATCH

Part Description Example
MAJOR Major version number 1
YY Year (last 2 digits) 26 for 2026
MINOR Minor version 0
PATCH Patch version 0

Example: 126.0.0 = Major version 1, released in 2026, minor 0, patch 0


Style Guide

Conventions for contributing to this project. All rules are enforced by code review; Biome handles formatting and lint.

Exports

  • Named exports only — no export default. Every module uses export function, export const, export class, or export type.
  • import type — always use import type for type-only imports.
  • .js extensions — all relative imports use explicit .js extensions (ESM requirement).

Naming

  • PascalCase for class and type names.
  • camelCase for functions, variables, and instances.
  • PascalCase directories for core/ and types/ modules.

Formatting

  • Tab indentation — enforced by Biome.
  • Single quotes for string literals — enforced by Biome.

Types

  • Use type for all type definitions — never interface.
  • Shared types live in src/types/TypeName/index.ts with a co-located TypeName.test-d.ts.
  • No duplicate type definitions — import from the canonical source.

Testing

  • Every module has a co-located test file.
  • Type-level tests use index.test-d.ts with expectTypeOf / assertType.
  • Unit tests use index.test.ts with Vitest.

License

ISC

About

Pure ABAC guard composition library — composable, type-safe, zero dependencies

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors