@maroonedsoftware/policies
v0.4.0
Published
A collection of policies for ServerKit.
Maintainers
Readme
@maroonedsoftware/policies
A small, DI-friendly policy framework for ServerKit. Encode allow/deny rules as named, injectable Policy classes; resolve them at call sites through a typed PolicyService.
Installation
pnpm add @maroonedsoftware/policiesFeatures
Policybase class — implement a singleevaluatemethod and returnallow(),deny(reason, details?, internalDetails?), ordenyStepUp(reason, requirement)- Typed
PolicyResult— discriminated union withisPolicyResultAllowed/isPolicyResultDeniedguards; denial results carrydetails(rendered to clients) andinternalDetails(log-only) - Named registry — register each policy under a stable name (e.g.
'email.allowed') so callers depend on the name andPolicyService, not on concrete classes - Type-safe call sites — declare a
Policiesmap ({ <name>: <ContextShape> }) andBasePolicyService.check/assertenforce the right context per name at compile time - Per-evaluation envelope — subclass
BasePolicyServiceto attach request-scoped state (current time, session, request id, …) without each policy reaching for it - Fluent step-up denials —
denyStepUp(reason, { within, acceptableMethods, … })bundles aStepUpRequirementinto the response underkind: 'step_up_required'
Concepts
- A policy is a single rule that takes a context and returns
PolicyResult. - The registry (
PolicyRegistryMap) maps a stable string name to the DI identifier of the policy class. - The policy service (
PolicyService) is the abstract handle call sites depend on;BasePolicyServiceis the default implementation that pulls each policy from the DI container and supplies it with a fresh envelope. - An envelope (
PolicyEnvelope) is the per-evaluation context shared across all policies — at minimumnow: DateTime. Subclass to add session, request id, etc.
Usage
Define a policy
import { Injectable } from 'injectkit';
import { Policy, PolicyResult, PolicyEnvelope } from '@maroonedsoftware/policies';
interface EmailAllowedContext {
value: string;
}
@Injectable()
class EmailAllowedPolicy extends Policy<EmailAllowedContext> {
async evaluate(context: EmailAllowedContext, _envelope: PolicyEnvelope): Promise<PolicyResult> {
if (!context.value.includes('@')) return this.deny('invalid_format');
if (context.value.endsWith('@disposable.com')) return this.deny('deny_list');
return this.allow();
}
}Wire up a PolicyService in your app
import { BasePolicyService, PolicyEnvelope, PolicyRegistryMap, PolicyService } from '@maroonedsoftware/policies';
import { Injectable } from 'injectkit';
import { DateTime } from 'luxon';
type AppPolicies = {
'email.allowed': { value: string };
'phone.allowed': { value: string };
};
@Injectable()
class AppPolicyService extends BasePolicyService<AppPolicies> {
protected async buildEnvelope(): Promise<PolicyEnvelope> {
return { now: DateTime.utc() };
}
}
// At bootstrap:
registry.register(EmailAllowedPolicy).useClass(EmailAllowedPolicy).asSingleton();
registry.register(PolicyRegistryMap).useFactory(() => {
const map = new PolicyRegistryMap();
map.set('email.allowed', EmailAllowedPolicy);
return map;
});
registry.register(PolicyService).useClass(AppPolicyService).asSingleton();Evaluate at call sites
const policyService = container.get(PolicyService);
// `check` returns the discriminated result — branch on `allowed`.
const result = await policyService.check('email.allowed', { value: '[email protected]' });
if (!result.allowed) {
throw httpError(400).withDetails({ value: result.reason });
}
// `assert` throws HTTP 403 on deny. The denial result is split across the thrown error:
// - `result.details` (client-facing) → `HttpError.details`, rendered to the response body.
// - `result.internalDetails` (operator/log-only) → `HttpError.internalDetails`, merged with
// framework context (`policyName`, `reason`, `kind: 'policy_violation'`), never on the wire.
// - `result.headers` → `HttpError.withHeaders`, attached to the HTTP response.
await policyService.assert('email.allowed', { value: '[email protected]' });Step-up denials
When a policy needs proof of recent re-authentication, return a step-up denial. The bundled StepUpRequirement is serialised under details.stepUp so clients can drive the user through a re-auth challenge before retrying:
return this.denyStepUp('recent_auth_required', {
within: Duration.fromObject({ minutes: 5 }),
acceptableMethods: ['fido', 'authenticator'],
});Response headers on deny
deny(...) and denyStepUp(...) return a PolicyDenialBuilder with a fluent .withHeaders(headers) setter. BasePolicyService.assert forwards them to HttpError.withHeaders so the response carries them. Use for WWW-Authenticate on auth/MFA policies, Retry-After on rate-limit policies, etc.:
return this.deny('mfa_required').withHeaders({ 'WWW-Authenticate': 'Bearer error="mfa_required"' });
return this.denyStepUp('aal2_required', { within: Duration.fromObject({ minutes: 15 }) }).withHeaders({
'WWW-Authenticate': 'Bearer error="aal2_required"',
});API
Policy<Context, Envelope>
Abstract base class. Subclass and implement evaluate(context, envelope): Promise<PolicyResult>.
| Helper | Returns | Description |
| ------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| allow() | { allowed: true } | Allow the request |
| deny(reason, details?, internalDetails?) | PolicyDenialBuilder (implements PolicyResultDenied) | Deny with a machine-readable reason. details is rendered to the HTTP response by assert; internalDetails lands in the thrown error's internalDetails (logs only, never on the wire). Chain .withHeaders({...}) to attach HTTP headers (e.g. WWW-Authenticate). |
| denyStepUp(reason, requirement) | PolicyDenialBuilder | Deny and attach a StepUpRequirement clients can use to drive a re-auth challenge. Chain .withHeaders({...}) to attach response headers. |
PolicyService
Abstract DI handle. Implementations supply a per-evaluation envelope.
| Method | Returns | Description |
| ---------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------- |
| check(policyName, context) | Promise<PolicyResult> | Resolve the registered policy and return its result. Throws when policyName is not registered. |
| assert(policyName, context) | Promise<void> | Same as check, but throws HTTP 403 on deny. result.details is surfaced under HttpError.details; result.internalDetails is merged with framework context (policyName, reason, kind: 'policy_violation') under HttpError.internalDetails; result.headers is forwarded to HttpError.withHeaders. |
BasePolicyService<TPolicies, TEnvelope>
Default PolicyService. Subclass and implement buildEnvelope(): Promise<TEnvelope>. The TPolicies type parameter ties policy names to their context shape, giving call sites compile-time type safety.
PolicyRegistryMap
Map<string, Identifier<Policy>>. Populate at bootstrap to bind each policy name to its DI identifier.
Types
| Type | Shape |
| --------------------- | ------------------------------------------------------------------------------------------------------ |
| PolicyResultAllowed | { allowed: true } |
| PolicyResultDenied | { allowed: false; reason: string; details?: Record<string, unknown>; internalDetails?: Record<string, unknown>; headers?: Record<string, string> } |
| PolicyResult | PolicyResultAllowed \| PolicyResultDenied |
| PolicyEnvelope | { now: DateTime } (extend in subclasses) |
| StepUpRequirement | { within: Duration; acceptableMethods?; acceptableKinds?; excludeMethods? } |
| Guard | Description |
| --------------------------- | -------------------------------------------- |
| isPolicyResultAllowed(r) | Narrows r to the allowed branch. |
| isPolicyResultDenied(r) | Narrows r to the denied branch. |
