All your permission logic in one place. Type-safe. Multi-tenant. Explainable.
Most auth libraries give you create, read, update, delete and call it a day. But your app has invoice:approve, your users are admin in one tenant and viewer in another, and when access breaks, nobody can tell you why without grepping the entire codebase.
Sentinel replaces scattered role checks with a single policy engine — domain actions instead of CRUD, tenant-scoped roles by default, and every decision tells you exactly which rule matched and why.
Zero dependencies. ~1,800 lines. 1:1 test-to-code ratio.
Without Sentinel — scattered, fragile, no tenant awareness:
app.post("/invoices/:id/approve", async (req, res) => {
if (
user.role === "admin" ||
(user.role === "manager" && invoice.ownerId === user.id)
) {
// which tenant? who knows
// why was this allowed? good luck
}
});With Sentinel — centralized, type-safe, explainable:
app.post(
"/invoices/:id/approve",
guard(engine, "invoice:approve", "invoice", {
getSubject: (req) => req.user,
getResourceContext: (req) => ({ ownerId: req.params.id }),
getTenantId: (req) => req.headers["x-tenant-id"],
}),
handler,
);npm install @siremzam/sentinelimport { AccessEngine, createPolicyFactory } from "@siremzam/sentinel";
import type { SchemaDefinition, Subject } from "@siremzam/sentinel";
interface MySchema extends SchemaDefinition {
roles: "owner" | "admin" | "manager" | "member" | "viewer";
resources: "invoice" | "project" | "user";
actions:
| "invoice:create"
| "invoice:read"
| "invoice:approve"
| "invoice:send"
| "project:read"
| "project:archive"
| "user:read"
| "user:impersonate";
}Every role, resource, and action gets autocomplete and compile-time validation. Typos are caught before your code runs.
const { allow, deny } = createPolicyFactory<MySchema>();
const engine = new AccessEngine<MySchema>({ schema: {} as MySchema });
engine.addRules(
allow()
.roles("admin", "owner")
.anyAction()
.anyResource()
.describe("Admins and owners have full access")
.build(),
allow()
.roles("manager")
.actions("invoice:*" as MySchema["actions"])
.on("invoice")
.describe("Managers can do anything with invoices")
.build(),
allow()
.roles("member")
.actions("invoice:read", "invoice:create")
.on("invoice")
.when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
.describe("Members can read/create their own invoices")
.build(),
);const user: Subject<MySchema> = {
id: "user-42",
roles: [
{ role: "admin", tenantId: "tenant-a" },
{ role: "viewer", tenantId: "tenant-b" },
],
};
engine.evaluate(user, "invoice:approve", "invoice", {}, "tenant-a");
// → allowed: true (user is admin in tenant-a)
engine.evaluate(user, "invoice:approve", "invoice", {}, "tenant-b");
// → allowed: false (user is only a viewer in tenant-b)That's it. You're up and running.
Try it live in the interactive playground →
Policy editor, multi-tenant evaluation, explain traces, and audit log — all in the browser. (source)
Your IDE autocompletes invoice:approve and rejects invoice:aprove. Every policy, condition, and middleware call is type-checked at compile time. This isn't convenience — it's a security boundary.
// TypeScript catches this at compile time, not in production
engine.evaluate(user, "invoice:aprove", "invoice");
// ^^^^^^^^^^^^^^^^ — Type errorRoles are scoped to tenants in the data model itself. No middleware hacks. No "effective role" workarounds. And strictTenancy mode throws if you forget the tenant ID — so cross-tenant bugs surface in development, not from a customer email on Friday.
const engine = new AccessEngine<MySchema>({
schema: {} as MySchema,
strictTenancy: true, // forget tenantId? this throws, not leaks
});When a user reports "I can't access this," don't grep your codebase. Replay the decision:
const result = engine.explain(user, "invoice:approve", "invoice", {}, "tenant-b");
// result.allowed → false
// result.reason → "No matching rule — default deny"
// result.evaluatedRules → per-rule trace: which matched on role, action, resource, conditionsEvery evaluation also emits a structured audit event via onDecision — who asked, what for, which rule decided it, how long it took.
invoice:approve. project:archive. user:impersonate. Your authorization speaks your domain language, not create/read/update/delete.
How it compares
| Feature | Sentinel | Casbin | accesscontrol | CASL |
|---|---|---|---|---|
| TypeScript-first (full inference) | Yes | Partial | Partial | Yes |
Domain actions (invoice:approve) |
Native | Via model config | No (CRUD only) | Via subject |
| Multi-tenancy (per-tenant roles) | Built-in | Manual | No | Manual |
| ABAC conditions | Sync + async | Via matchers | No | Via conditions |
| Role hierarchy | Built-in, cycle-detected | Via model | Built-in | No |
| Audit trail | onDecision + toAuditEntry() |
Via watcher | No | No |
| Debug/explain | explain() with per-rule trace |
No | No | No |
| UI permission set | permitted() returns Set |
No | permission.filter() |
ability.can() per action |
| JSON policy storage | exportRules / importRules |
CSV / JSON adapters | No | Via @casl/ability/extra |
| Server mode (HTTP microservice) | Built-in | No | No | No |
| Middleware | Express, Fastify, Hono, NestJS | Express (community) | Express (community) | Express, NestJS |
| Dependencies | 0 | 2+ | 2 | 1+ |
| DSL required | No (pure TypeScript) | Yes | No | No |
How evaluation works
When you call engine.evaluate(subject, action, resource, context?, tenantId?):
1. Resolve the subject's roles
└─ Filter role assignments by tenantId (if provided)
└─ Expand via role hierarchy (if configured)
2. Find candidate rules
└─ For each rule: does role match? action match? resource match?
└─ Wildcard patterns ("invoice:*") are pre-compiled to regex at addRule() time
3. Sort candidates
└─ Higher priority first
└─ At equal priority, deny rules sort before allow rules
4. Evaluate candidates in order (first match wins)
└─ No conditions? → rule matches immediately
└─ Has conditions? → all conditions must return true
└─ Condition throws? → treated as false (fail-closed)
5. Return decision
└─ Matched rule found → use its effect (allow or deny)
└─ No match → default deny
Every decision includes the matched rule (or null), a human-readable reason, evaluation duration, and full request context.
Concepts glossary
| Term | Meaning |
|---|---|
| RBAC | Role-Based Access Control. Permissions are assigned to roles, users are assigned roles. |
| ABAC | Attribute-Based Access Control. Permissions depend on attributes of the subject, resource, or environment — expressed as conditions. |
| Subject | The entity requesting access — typically a user. Has an id and an array of roles. |
| Resource | The thing being accessed — "invoice", "project", "user". |
| Action | What the subject wants to do — "invoice:approve", "project:archive". Uses resource:verb format. |
| Policy Rule | A single authorization rule with an effect (allow or deny), and optionally conditions, priority, and a description. |
| Condition | A function attached to a rule that receives the evaluation context and returns true/false. Used for ABAC. |
| Tenant | An organizational unit in a multi-tenant system. Users can have different roles in different tenants. |
| Decision | The result of an evaluation — contains allowed, the matched rule, timing, and a human-readable reason. |
| Priority | A number (default 0) that determines rule evaluation order. Higher priority rules are checked first. |
| Role Hierarchy | A definition that one role inherits all permissions of another — e.g., admin inherits from manager. |
Core features
createPolicyFactory eliminates the <MySchema> generic on every rule:
const { allow, deny } = createPolicyFactory<MySchema>();
allow().roles("admin").anyAction().anyResource().build();
deny().roles("viewer").actions("report:export").on("report").build();Attach predicates to any rule. All conditions on a rule must pass:
allow()
.roles("member")
.actions("invoice:update")
.on("invoice")
.when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
.when(ctx => ctx.resourceContext.status !== "finalized")
.build();Conditions receive the full EvaluationContext — subject, action, resource, resourceContext, and tenantId. Stack multiple .when() calls; they are AND'd together.
For conditions that need database lookups or API calls:
engine.addRule(
allow()
.roles("member")
.actions("report:export")
.on("report")
.when(async (ctx) => {
const quota = await db.getExportQuota(ctx.subject.id);
return quota.remaining > 0;
})
.build(),
);
const decision = await engine.evaluateAsync(user, "report:export", "report");Use evaluateAsync(), permittedAsync(), and explainAsync() for async conditions. The engine throws a clear error if you accidentally use the sync API with async conditions.
allow().roles("manager").actions("invoice:*" as MySchema["actions"]).on("invoice").build();
allow().roles("viewer").actions("*:read" as MySchema["actions"]).anyResource().build();Wildcards are pre-compiled to regexes at addRule() time — no per-evaluation cost.
import { RoleHierarchy } from "@siremzam/sentinel";
const hierarchy = new RoleHierarchy<MySchema>()
.define("owner", ["admin"])
.define("admin", ["manager"])
.define("manager", ["member"])
.define("member", ["viewer"]);
const engine = new AccessEngine<MySchema>({
schema: {} as MySchema,
roleHierarchy: hierarchy,
});Cycles are detected at definition time and throw immediately.
- Higher
prioritywins (default: 0) - At equal priority,
denywins overallow
deny().anyRole().actions("user:impersonate").on("user").build();
allow().roles("owner").actions("user:impersonate").on("user").priority(10).build();Role assignments are tenant-scoped. When evaluating with a tenantId, only roles for that tenant (or global roles) are considered:
const user: Subject<MySchema> = {
id: "user-1",
roles: [
{ role: "admin", tenantId: "acme-corp" },
{ role: "viewer", tenantId: "globex" },
{ role: "member" }, // global — applies in any tenant
],
};
engine.evaluate(user, "invoice:approve", "invoice", {}, "acme-corp");
// admin + member roles
engine.evaluate(user, "invoice:approve", "invoice", {}, "globex");
// viewer + member rolesPrevents accidental cross-tenant access:
const engine = new AccessEngine<MySchema>({
schema: {} as MySchema,
strictTenancy: true,
});
engine.evaluate(user, "invoice:read", "invoice");
// THROWS — tenantId required for tenant-scoped subjectsConditions that throw are treated as false (fail-closed):
const engine = new AccessEngine<MySchema>({
schema: {} as MySchema,
onConditionError: ({ ruleId, conditionIndex, error }) => {
logger.warn("Condition failed", { ruleId, conditionIndex, error });
},
});Observability
Every evaluation emits a structured Decision event:
import { toAuditEntry } from "@siremzam/sentinel";
const engine = new AccessEngine<MySchema>({
schema: {} as MySchema,
onDecision: (decision) => {
const entry = toAuditEntry(decision);
auditLog.write(entry);
},
});
// Or subscribe at runtime
const unsubscribe = engine.onDecision((d) => auditLog.write(toAuditEntry(d)));
unsubscribe();Full evaluation trace showing every rule, whether it matched, and why:
const result = engine.explain(user, "invoice:approve", "invoice");
console.log(result.allowed);
console.log(result.reason);
for (const evalRule of result.evaluatedRules) {
console.log({
ruleId: evalRule.rule.id,
roleMatched: evalRule.roleMatched,
actionMatched: evalRule.actionMatched,
resourceMatched: evalRule.resourceMatched,
conditionResults: evalRule.conditionResults,
matched: evalRule.matched,
});
}For async conditions, use engine.explainAsync().
Convert a Decision to a serialization-safe format:
const decision = engine.evaluate(user, "invoice:approve", "invoice");
const entry = toAuditEntry(decision);
// Safe to JSON.stringify — no functions, no circular referencesDrive button visibility and menu items:
const actions = engine.permitted(
user,
"invoice",
["invoice:create", "invoice:read", "invoice:approve", "invoice:send"],
{ ownerId: user.id },
"tenant-a",
);
// Set { "invoice:create", "invoice:read" }For async conditions, use engine.permittedAsync().
Integration & middleware
import { guard } from "@siremzam/sentinel/middleware/express";
app.post(
"/invoices/:id/approve",
guard(engine, "invoice:approve", "invoice", {
getSubject: (req) => req.user,
getResourceContext: (req) => ({ id: req.params.id }),
getTenantId: (req) => req.headers["x-tenant-id"],
}),
handler,
);import { fastifyGuard } from "@siremzam/sentinel/middleware/fastify";
fastify.post("/invoices/:id/approve", {
preHandler: fastifyGuard(engine, "invoice:approve", "invoice", {
getSubject: (req) => req.user,
getResourceContext: (req) => ({ id: req.params.id }),
getTenantId: (req) => req.headers["x-tenant-id"],
}),
}, handler);import { honoGuard } from "@siremzam/sentinel/middleware/hono";
app.post(
"/invoices/:id/approve",
honoGuard(engine, "invoice:approve", "invoice", {
getSubject: (c) => c.get("user"),
getResourceContext: (c) => ({ id: c.req.param("id") }),
getTenantId: (c) => c.req.header("x-tenant-id"),
}),
handler,
);import {
createAuthorizeDecorator,
createAuthGuard,
} from "@siremzam/sentinel/middleware/nestjs";
const Authorize = createAuthorizeDecorator<MySchema>();
const AuthGuard = createAuthGuard<MySchema>({
engine,
getSubject: (req) => req.user as Subject<MySchema>,
getTenantId: (req) => req.headers["x-tenant-id"] as string,
});
@Controller("invoices")
class InvoiceController {
@Post(":id/approve")
@Authorize("invoice:approve", "invoice")
approve(@Param("id") id: string) {
return { approved: true };
}
}
app.useGlobalGuards(new AuthGuard());No dependency on @nestjs/common or reflect-metadata. Uses a WeakMap for metadata storage.
Run the engine as a standalone HTTP authorization microservice for polyglot architectures:
import { createAuthServer } from "@siremzam/sentinel/server";
const server = createAuthServer({
engine,
port: 3100,
authenticate: (req) => req.headers["x-api-key"] === process.env.AUTH_SERVER_KEY,
maxBodyBytes: 1024 * 1024,
});
await server.start();| Endpoint | Method | Description |
|---|---|---|
/health |
GET | Health check with rules count and uptime |
/rules |
GET | List loaded rules (serialization-safe) |
/evaluate |
POST | Evaluate an authorization request |
Zero dependencies. Uses Node's built-in http module.
Store policies in a database, config file, or load from an API:
import {
exportRulesToJson,
importRulesFromJson,
ConditionRegistry,
} from "@siremzam/sentinel";
const json = exportRulesToJson(engine.getRules());
const rules = importRulesFromJson<MySchema>(json);
engine.addRules(...rules);Conditions use a named registry since functions can't be serialized:
const conditions = new ConditionRegistry<MySchema>();
conditions.register("isOwner", (ctx) => ctx.subject.id === ctx.resourceContext.ownerId);
const rules = importRulesFromJson<MySchema>(json, conditions);Performance & benchmarks
const engine = new AccessEngine<MySchema>({
schema: {} as MySchema,
cacheSize: 1000,
});
engine.evaluate(user, "invoice:read", "invoice"); // evaluated
engine.evaluate(user, "invoice:read", "invoice"); // cache hit
engine.addRule(newRule); // cache cleared automatically
engine.clearCache(); // manual control
engine.cacheStats; // { size: 0, maxSize: 1000 }Only unconditional evaluations are cached — conditional results are always re-evaluated.
Measured on Node v18.18.0, Apple Silicon (ARM64). Run npm run benchmark to reproduce.
| Scenario | 100 rules | 1,000 rules | 10,000 rules |
|---|---|---|---|
evaluate (no cache) |
4.3 µs / 231k ops/s | 42.6 µs / 23k ops/s | 1,091 µs / 917 ops/s |
evaluate (cache hit) |
0.6 µs / 1.66M ops/s | 1.8 µs / 553k ops/s | 29.1 µs / 34k ops/s |
evaluate (all conditional) |
3.4 µs / 292k ops/s | 40.2 µs / 25k ops/s | 1,064 µs / 940 ops/s |
permitted (18 actions) |
60.2 µs / 17k ops/s | 718 µs / 1.4k ops/s | 18,924 µs / 53 ops/s |
explain (full trace) |
22.4 µs / 45k ops/s | 564 µs / 1.8k ops/s | 6,444 µs / 155 ops/s |
Most SaaS apps have 10–50 rules. At 100 rules, a single evaluation takes 4.3 µs — 230,000 checks per second on a single core. With caching: 0.6 µs.
Patterns & recipes
allow()
.roles("member")
.actions("invoice:update")
.on("invoice")
.when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
.build();allow()
.roles("trial")
.actions("report:export")
.on("report")
.when(ctx => {
const createdAt = new Date(ctx.resourceContext.trialStartedAt as string);
const daysSince = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
return daysSince <= 14;
})
.build();const BETA_TENANTS = new Set(["acme-corp", "initech"]);
allow()
.anyRole()
.actions("analytics:view")
.on("analytics")
.when(ctx => BETA_TENANTS.has(ctx.tenantId ?? ""))
.build();allow()
.roles("member")
.actions("api:call")
.on("api")
.when(async ctx => {
const usage = await rateLimiter.check(ctx.subject.id);
return usage.remaining > 0;
})
.build();deny()
.anyRole()
.actions("project:delete", "project:archive")
.on("project")
.build();
allow()
.roles("owner")
.actions("project:delete", "project:archive")
.on("project")
.priority(10)
.build();allow()
.roles("admin")
.actions("settings:update")
.on("settings")
.when(async ctx => {
const ip = ctx.resourceContext.clientIp as string;
const geo = await geoService.lookup(ip);
return geo.isOfficeNetwork;
})
.build();Testing your policies
import { describe, it, expect } from "vitest";
describe("invoice policies", () => {
it("allows managers to approve invoices in their tenant", () => {
const result = engine.explain(manager, "invoice:approve", "invoice", {}, "acme");
expect(result.allowed).toBe(true);
expect(result.reason).toContain("manager-invoices");
});
it("denies viewers from approving invoices", () => {
const result = engine.explain(viewer, "invoice:approve", "invoice", {}, "acme");
expect(result.allowed).toBe(false);
expect(result.reason).toBe("No matching rule — default deny");
});
it("respects ownership conditions", () => {
const result = engine.explain(
member,
"invoice:read",
"invoice",
{ ownerId: "someone-else" },
"acme",
);
const ownershipRule = result.evaluatedRules.find(
e => e.rule.id === "member-own-invoices",
);
expect(ownershipRule?.conditionResults[0]?.passed).toBe(false);
});
it("prevents cross-tenant access", () => {
const resultAcme = engine.evaluate(user, "invoice:approve", "invoice", {}, "acme");
const resultGlobex = engine.evaluate(user, "invoice:approve", "invoice", {}, "globex");
expect(resultAcme.allowed).toBe(true);
expect(resultGlobex.allowed).toBe(false);
});
});explain() returns per-rule evaluation details — which rules matched on role, action, and resource, and which conditions passed or failed. When a test fails, the trace tells you exactly why.
Migration guides
| CASL | Sentinel |
|---|---|
defineAbility(can => { can('read', 'Article') }) |
allow().actions("article:read").on("article").build() |
ability.can('read', 'Article') |
engine.evaluate(user, "article:read", "article") |
subject('Article', article) |
Actions use resource:verb format natively |
conditions: { authorId: user.id } |
.when(ctx => ctx.subject.id === ctx.resourceContext.authorId) |
| No multi-tenancy | Built-in: { role: "admin", tenantId: "acme" } |
| No explain/debug | engine.explain() gives per-rule trace |
Key difference: CASL uses MongoDB-style conditions (declarative objects). Sentinel uses functions — async calls, date math, external lookups — with compile-time type safety.
| Casbin | Sentinel |
|---|---|
Model file (model.conf) |
Pure TypeScript schema interface |
Policy file (policy.csv) |
Fluent builder API or JSON import |
e.Enforce("alice", "data1", "read") |
engine.evaluate(user, "data:read", "data") |
| Custom matchers for ABAC | .when() conditions with full TypeScript |
| Role manager | RoleHierarchy with cycle detection |
Key difference: Casbin requires its own DSL. Sentinel is pure TypeScript — your IDE autocompletes everything.
| accesscontrol | Sentinel |
|---|---|
ac.grant('admin').createAny('video') |
allow().roles("admin").actions("video:create").on("video").build() |
| CRUD only | Domain verbs: invoice:approve, order:ship |
| No conditions/ABAC | Full ABAC with .when() conditions |
| No multi-tenancy | Built-in per-tenant role assignments |
Key difference: accesscontrol is locked into CRUD semantics. Sentinel treats domain verbs as first-class.
API reference
| Method | Description |
|---|---|
addRule(rule) |
Add a single policy rule (frozen on add) |
addRules(...rules) |
Add multiple rules |
removeRule(id) |
Remove a rule by ID |
getRules() |
Get all rules (frozen, readonly) |
clearRules() |
Remove all rules |
evaluate(subject, action, resource, ctx?, tenantId?) |
Synchronous evaluation |
evaluateAsync(...) |
Async evaluation (for async conditions) |
permitted(subject, resource, actions, ctx?, tenantId?) |
Which actions are allowed? Returns Set |
permittedAsync(...) |
Async version of permitted() |
explain(subject, action, resource, ctx?, tenantId?) |
Full evaluation trace |
explainAsync(...) |
Async version of explain() |
can(subject) |
Start fluent check chain |
onDecision(listener) |
Subscribe to decisions, returns unsubscribe fn |
allow() / deny() |
Shorthand rule builders |
clearCache() |
Clear the evaluation cache |
cacheStats |
{ size, maxSize } or null if caching disabled |
| Option | Description |
|---|---|
schema |
Your schema type (used for type inference, not read at runtime) |
defaultEffect |
"deny" (default) or "allow" |
onDecision |
Listener called on every evaluation |
onConditionError |
Called when a condition throws (fail-closed) |
strictTenancy |
Throw if tenantId is omitted for tenant-scoped subjects |
roleHierarchy |
A RoleHierarchy instance |
cacheSize |
LRU cache capacity (0 = disabled) |
| Method | Description |
|---|---|
.id(id) |
Set rule ID |
.roles(...roles) |
Restrict to specific roles |
.anyRole() |
Match any role |
.actions(...actions) |
Restrict to specific actions (supports * wildcards) |
.anyAction() |
Match any action |
.on(...resources) |
Restrict to specific resources |
.anyResource() |
Match any resource |
.when(condition) |
Add a condition (stackable) |
.priority(n) |
Set priority (higher wins) |
.describe(text) |
Human-readable description |
.build() |
Produce the PolicyRule object |
| Method | Description |
|---|---|
.define(role, inheritsFrom) |
Define inheritance (detects cycles) |
.resolve(role) |
Get full set of roles including inherited |
.resolveAll(roles) |
Resolve multiple roles merged |
.definedRoles() |
List roles with inheritance rules |
| Method | Description |
|---|---|
.register(name, fn) |
Register a named condition |
.get(name) |
Look up a condition |
.has(name) |
Check if registered |
.names() |
List all registered names |
Every evaluation returns a Decision containing:
allowed— boolean resulteffect—"allow","deny", or"default-deny"matchedRule— the rule that determined the outcome (or null)reason— human-readable explanationdurationMs— evaluation timetimestamp— when the decision was made- Full request context (subject, action, resource, tenantId)
Serialization-safe version of Decision via toAuditEntry():
allowed,effect,reason,durationMs,timestampmatchedRuleId,matchedRuleDescriptionsubjectId,action,resource,tenantId
Returned by engine.explain():
allowed,effect,reason,durationMsevaluatedRules— array ofRuleEvaluation<S>with per-rule and per-condition details
- You need a full policy language. If you want Rego (OPA) or Cedar (AWS), this isn't that. Sentinel policies are TypeScript code.
- You need Zanzibar-style relationship graphs. For "who can access this Google Doc?" with nested sharing, use SpiceDB or OpenFGA.
- You need a hosted authorization service. Look at Permit.io or Oso Cloud.
- Your model is truly just CRUD. If
create/read/update/deleteis all you need with no tenants, simpler libraries may suffice.
- Deny by default. No rule match = no access.
- Fail closed. Condition throws =
false. No silent privilege escalation. - Frozen rules. Rules are
Object.freeze'd on add. Mutation after insertion is impossible. - Cache safety. Only unconditional evaluations are cached. Conditional results are never cached.
- Strict tenancy. Throws if
tenantIdis omitted for tenant-scoped subjects. - Import validation.
importRulesFromJson()validates theeffectfield and rejects invalid values. - Server hardening.
createAuthServersupportsauthenticatecallback and configurablemaxBodyBytes.
See SECURITY.md for responsible disclosure.
- Async conditions without opt-in — No more
asyncConditions: trueflag. UseevaluateAsync(),explainAsync(), orpermittedAsync()when you have async conditions; the engine detects them automatically. asyncConditionsoption deprecated (will be removed in v2)
See the full CHANGELOG.
See CONTRIBUTING.md.