@siremzam/sentinel 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +195 -312
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -5,97 +5,45 @@
5
5
  [![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](https://www.npmjs.com/package/@siremzam/sentinel)
6
6
  [![license](https://img.shields.io/npm/l/@siremzam/sentinel)](./LICENSE)
7
7
 
8
- **TypeScript-first, domain-driven authorization engine for modern SaaS apps.**
8
+ **All your permission logic in one place. Type-safe. Multi-tenant. Explainable.**
9
9
 
10
- Most Node.js authorization libraries were built in the CRUD era — they model permissions as `create`, `read`, `update`, `delete` on "resources." But modern apps don't think that way. They think in domain verbs: `invoice:approve`, `project:archive`, `user:impersonate`.
10
+ 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.
11
11
 
12
- This library was built from a different starting point:
12
+ 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.
13
13
 
14
- - **Your domain actions are not CRUD.** Model `order:ship`, not `update`.
15
- - **Your tenants are not an afterthought.** A user is admin in Tenant A and viewer in Tenant B. That's the default, not an edge case.
16
- - **Your types should work for you.** TypeScript autocompletes your actions, resources, and roles everywhere — policies, checks, middleware.
17
- - **Your authorization decisions should be observable.** Every `allow` and `deny` emits a structured event with timing, reason, and the matched rule.
18
- - **Your policies belong in one place.** Not scattered across 47 route handlers.
19
-
20
- **Zero runtime dependencies. ~1,800 lines. 1:1 test-to-code ratio.**
21
-
22
- > ### Try it live
23
- >
24
- > **[Open the interactive playground →](https://vegtelenseg.github.io/sentinel-example/)**
25
- >
26
- > Policy editor, multi-tenant evaluation, explain traces, and audit log — all in the browser.
27
- > ([source](https://github.com/vegtelenseg/sentinel-example))
14
+ Zero dependencies. ~1,800 lines. 1:1 test-to-code ratio.
28
15
 
29
16
  ---
30
17
 
31
- ### What's New in 0.3.3
32
-
33
- - Hono middleware — `honoGuard()` via `@siremzam/sentinel/middleware/hono`
34
- - README rewrite: evaluation walkthrough, concepts glossary, patterns & recipes, migration guide, benchmark data
35
- - Standalone example (`examples/standalone/`) — no HTTP server needed
18
+ ### Before and after
36
19
 
37
- See the full [CHANGELOG](./CHANGELOG.md).
20
+ **Without Sentinel** scattered, fragile, no tenant awareness:
38
21
 
39
- ---
40
-
41
- ## Table of Contents
42
-
43
- - [How It Compares](#how-it-compares)
44
- - [Install](#install)
45
- - [Quick Start](#quick-start)
46
- - [How Evaluation Works](#how-evaluation-works)
47
- - [Concepts](#concepts)
48
- - [Core Features](#core-features)
49
- - [Policy Factory](#policy-factory)
50
- - [Conditions (ABAC)](#conditions-abac)
51
- - [Async Conditions](#async-conditions)
52
- - [Wildcard Action Patterns](#wildcard-action-patterns)
53
- - [Role Hierarchy](#role-hierarchy)
54
- - [Priority and Deny Resolution](#priority-and-deny-resolution)
55
- - [Multitenancy](#multitenancy)
56
- - [Strict Tenancy](#strict-tenancy)
57
- - [Condition Error Handling](#condition-error-handling)
58
- - [Observability](#observability)
59
- - [Decision Events](#decision-events)
60
- - [explain() — Debug Authorization](#explain--debug-authorization)
61
- - [toAuditEntry()](#toauditentry)
62
- - [permitted() — UI Rendering](#permitted--ui-rendering)
63
- - [Integration](#integration)
64
- - [Middleware (Express, Fastify, Hono, NestJS)](#middleware)
65
- - [Server Mode](#server-mode)
66
- - [JSON Policy Serialization](#json-policy-serialization)
67
- - [Performance](#performance)
68
- - [Evaluation Cache](#evaluation-cache)
69
- - [Benchmarks](#benchmarks)
70
- - [Patterns and Recipes](#patterns-and-recipes)
71
- - [Testing Your Policies](#testing-your-policies)
72
- - [Migration Guide](#migration-guide)
73
- - [When NOT to Use This](#when-not-to-use-this)
74
- - [Security](#security)
75
- - [API Reference](#api-reference)
76
- - [Philosophy](#philosophy)
77
- - [Contributing](#contributing)
78
- - [License](#license)
79
-
80
- ---
22
+ ```typescript
23
+ app.post("/invoices/:id/approve", async (req, res) => {
24
+ if (
25
+ user.role === "admin" ||
26
+ (user.role === "manager" && invoice.ownerId === user.id)
27
+ ) {
28
+ // which tenant? who knows
29
+ // why was this allowed? good luck
30
+ }
31
+ });
32
+ ```
81
33
 
82
- ## How It Compares
34
+ **With Sentinel** centralized, type-safe, explainable:
83
35
 
84
- | Feature | **@siremzam/sentinel** | Casbin | accesscontrol | CASL |
85
- |---|---|---|---|---|
86
- | TypeScript-first (full inference) | Yes | Partial | Partial | Yes |
87
- | Domain actions (`invoice:approve`) | Native | Via model config | No (CRUD only) | Via `subject` |
88
- | Multi-tenancy (per-tenant roles) | Built-in | Manual | No | Manual |
89
- | ABAC conditions | Sync + async | Via matchers | No | Via `conditions` |
90
- | Role hierarchy | Built-in, cycle-detected | Via model | Built-in | No |
91
- | Evaluation audit trail | `onDecision` + `toAuditEntry()` | Via watcher | No | No |
92
- | Debug/explain mode | `explain()` with per-rule trace | No | No | No |
93
- | UI permission set | `permitted()` returns `Set` | No | `permission.filter()` | `ability.can()` per action |
94
- | JSON policy storage | `exportRules` / `importRules` + `ConditionRegistry` | CSV / JSON adapters | No | Via `@casl/ability/extra` |
95
- | Server mode (HTTP microservice) | Built-in (`createAuthServer`) | No | No | No |
96
- | Middleware | Express, Fastify, Hono, NestJS | Express (community) | Express (community) | Express, NestJS |
97
- | Dependencies | **0** | 2+ | 2 | 1+ |
98
- | DSL required | **No** (pure TypeScript) | Yes (Casbin model) | No | No |
36
+ ```typescript
37
+ app.post(
38
+ "/invoices/:id/approve",
39
+ guard(engine, "invoice:approve", "invoice", {
40
+ getSubject: (req) => req.user,
41
+ getResourceContext: (req) => ({ ownerId: req.params.id }),
42
+ getTenantId: (req) => req.headers["x-tenant-id"],
43
+ }),
44
+ handler,
45
+ );
46
+ ```
99
47
 
100
48
  ---
101
49
 
@@ -105,14 +53,12 @@ See the full [CHANGELOG](./CHANGELOG.md).
105
53
  npm install @siremzam/sentinel
106
54
  ```
107
55
 
108
- ---
109
-
110
56
  ## Quick Start
111
57
 
112
- ### 1. Define your schema
58
+ ### 1. Define your schema (TypeScript does the rest)
113
59
 
114
60
  ```typescript
115
- import { AccessEngine, createPolicyFactory, RoleHierarchy } from "@siremzam/sentinel";
61
+ import { AccessEngine, createPolicyFactory } from "@siremzam/sentinel";
116
62
  import type { SchemaDefinition, Subject } from "@siremzam/sentinel";
117
63
 
118
64
  interface MySchema extends SchemaDefinition {
@@ -130,24 +76,17 @@ interface MySchema extends SchemaDefinition {
130
76
  }
131
77
  ```
132
78
 
133
- TypeScript now knows every valid role, resource, and action. Autocomplete works everywhere.
79
+ Every role, resource, and action gets autocomplete and compile-time validation. Typos are caught before your code runs.
134
80
 
135
- ### 2. Create the engine and add policies
81
+ ### 2. Write policies that read like English
136
82
 
137
83
  ```typescript
138
84
  const { allow, deny } = createPolicyFactory<MySchema>();
139
85
 
140
- const engine = new AccessEngine<MySchema>({
141
- schema: {} as MySchema,
142
- });
143
- ```
144
-
145
- > **Why `{} as MySchema`?** The `schema` field exists purely for TypeScript inference — it carries your type information through the engine at compile time. The runtime value is never read. Think of it as a type witness, not data.
86
+ const engine = new AccessEngine<MySchema>({ schema: {} as MySchema });
146
87
 
147
- ```typescript
148
88
  engine.addRules(
149
89
  allow()
150
- .id("admin-full-access")
151
90
  .roles("admin", "owner")
152
91
  .anyAction()
153
92
  .anyResource()
@@ -155,7 +94,6 @@ engine.addRules(
155
94
  .build(),
156
95
 
157
96
  allow()
158
- .id("manager-invoices")
159
97
  .roles("manager")
160
98
  .actions("invoice:*" as MySchema["actions"])
161
99
  .on("invoice")
@@ -163,34 +101,16 @@ engine.addRules(
163
101
  .build(),
164
102
 
165
103
  allow()
166
- .id("member-own-invoices")
167
104
  .roles("member")
168
105
  .actions("invoice:read", "invoice:create")
169
106
  .on("invoice")
170
107
  .when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
171
108
  .describe("Members can read/create their own invoices")
172
109
  .build(),
173
-
174
- deny()
175
- .id("no-impersonation")
176
- .anyRole()
177
- .actions("user:impersonate")
178
- .on("user")
179
- .describe("Nobody can impersonate by default")
180
- .build(),
181
-
182
- allow()
183
- .id("owner-impersonate")
184
- .roles("owner")
185
- .actions("user:impersonate")
186
- .on("user")
187
- .priority(10)
188
- .describe("Except owners, who can impersonate")
189
- .build(),
190
110
  );
191
111
  ```
192
112
 
193
- ### 3. Check permissions
113
+ ### 3. Check permissions (with tenant context)
194
114
 
195
115
  ```typescript
196
116
  const user: Subject<MySchema> = {
@@ -201,43 +121,94 @@ const user: Subject<MySchema> = {
201
121
  ],
202
122
  };
203
123
 
204
- // Fluent API
205
- const decision = engine.can(user).perform("invoice:approve").on("invoice", {}, "tenant-a");
206
- // decision.allowed === true
124
+ engine.evaluate(user, "invoice:approve", "invoice", {}, "tenant-a");
125
+ // allowed: true (user is admin in tenant-a)
207
126
 
208
- // Direct evaluation
209
- const d2 = engine.evaluate(user, "invoice:approve", "invoice", {}, "tenant-b");
210
- // d2.allowed === false (user is only a viewer in tenant-b)
127
+ engine.evaluate(user, "invoice:approve", "invoice", {}, "tenant-b");
128
+ // allowed: false (user is only a viewer in tenant-b)
211
129
  ```
212
130
 
213
- ### 4. Observe decisions
131
+ That's it. You're up and running.
132
+
133
+ > **[Try it live in the interactive playground →](https://vegtelenseg.github.io/sentinel-example/)**
134
+ >
135
+ > Policy editor, multi-tenant evaluation, explain traces, and audit log — all in the browser. ([source](https://github.com/vegtelenseg/sentinel-example))
136
+
137
+ ---
138
+
139
+ ## Why Sentinel?
140
+
141
+ ### 1. Type-safe schema
142
+
143
+ 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.
214
144
 
215
145
  ```typescript
216
- import { toAuditEntry } from "@siremzam/sentinel";
146
+ // TypeScript catches this at compile time, not in production
147
+ engine.evaluate(user, "invoice:aprove", "invoice");
148
+ // ^^^^^^^^^^^^^^^^ — Type error
149
+ ```
150
+
151
+ ### 2. Multi-tenancy that doesn't leak
152
+
153
+ Roles 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.
217
154
 
155
+ ```typescript
218
156
  const engine = new AccessEngine<MySchema>({
219
157
  schema: {} as MySchema,
220
- onDecision: (decision) => {
221
- const entry = toAuditEntry(decision);
222
- auditLog.write(entry);
223
- },
158
+ strictTenancy: true, // forget tenantId? this throws, not leaks
224
159
  });
225
160
  ```
226
161
 
227
- Or subscribe at runtime:
162
+ ### 3. explain() — debug authorization in seconds
163
+
164
+ When a user reports "I can't access this," don't grep your codebase. Replay the decision:
228
165
 
229
166
  ```typescript
230
- const unsubscribe = engine.onDecision((d) => auditLog.write(toAuditEntry(d)));
231
- unsubscribe(); // when done
167
+ const result = engine.explain(user, "invoice:approve", "invoice", {}, "tenant-b");
168
+ // result.allowed → false
169
+ // result.reason → "No matching rule — default deny"
170
+ // result.evaluatedRules → per-rule trace: which matched on role, action, resource, conditions
232
171
  ```
233
172
 
234
- > **[Open the playground →](https://vegtelenseg.github.io/sentinel-example/)** to see all of this running interactively.
173
+ Every evaluation also emits a structured audit event via `onDecision` who asked, what for, which rule decided it, how long it took.
174
+
175
+ ### 4. Domain actions, not CRUD
176
+
177
+ `invoice:approve`. `project:archive`. `user:impersonate`. Your authorization speaks your domain language, not `create`/`read`/`update`/`delete`.
178
+
179
+ ---
180
+
181
+ <details>
182
+ <summary><strong>How it compares</strong></summary>
183
+
184
+ | Feature | **Sentinel** | Casbin | accesscontrol | CASL |
185
+ |---|---|---|---|---|
186
+ | TypeScript-first (full inference) | Yes | Partial | Partial | Yes |
187
+ | Domain actions (`invoice:approve`) | Native | Via model config | No (CRUD only) | Via `subject` |
188
+ | Multi-tenancy (per-tenant roles) | Built-in | Manual | No | Manual |
189
+ | ABAC conditions | Sync + async | Via matchers | No | Via `conditions` |
190
+ | Role hierarchy | Built-in, cycle-detected | Via model | Built-in | No |
191
+ | Audit trail | `onDecision` + `toAuditEntry()` | Via watcher | No | No |
192
+ | Debug/explain | `explain()` with per-rule trace | No | No | No |
193
+ | UI permission set | `permitted()` returns `Set` | No | `permission.filter()` | `ability.can()` per action |
194
+ | JSON policy storage | `exportRules` / `importRules` | CSV / JSON adapters | No | Via `@casl/ability/extra` |
195
+ | Server mode (HTTP microservice) | Built-in | No | No | No |
196
+ | Middleware | Express, Fastify, Hono, NestJS | Express (community) | Express (community) | Express, NestJS |
197
+ | Dependencies | **0** | 2+ | 2 | 1+ |
198
+ | DSL required | **No** (pure TypeScript) | Yes | No | No |
199
+
200
+ </details>
235
201
 
236
202
  ---
237
203
 
238
- ## How Evaluation Works
204
+ ## Docs
239
205
 
240
- When you call `engine.evaluate(subject, action, resource, context?, tenantId?)`, the engine runs this algorithm:
206
+ ### Everything below is organized by topic. Click to expand what you need.
207
+
208
+ <details>
209
+ <summary><strong>How evaluation works</strong></summary>
210
+
211
+ When you call `engine.evaluate(subject, action, resource, context?, tenantId?)`:
241
212
 
242
213
  ```
243
214
  1. Resolve the subject's roles
@@ -262,16 +233,12 @@ When you call `engine.evaluate(subject, action, resource, context?, tenantId?)`,
262
233
  └─ No match → default deny
263
234
  ```
264
235
 
265
- Every decision includes the matched rule (or null), a human-readable reason, evaluation duration, and full request context. Decisions with only unconditional rule matches are eligible for LRU caching.
266
-
267
- ---
268
-
269
- ## Concepts
236
+ Every decision includes the matched rule (or null), a human-readable reason, evaluation duration, and full request context.
270
237
 
271
- If you're new to authorization systems, here's a quick glossary:
238
+ </details>
272
239
 
273
240
  <details>
274
- <summary><strong>Expand glossary</strong></summary>
241
+ <summary><strong>Concepts glossary</strong></summary>
275
242
 
276
243
  | Term | Meaning |
277
244
  |---|---|
@@ -279,28 +246,24 @@ If you're new to authorization systems, here's a quick glossary:
279
246
  | **ABAC** | Attribute-Based Access Control. Permissions depend on attributes of the subject, resource, or environment — expressed as conditions. |
280
247
  | **Subject** | The entity requesting access — typically a user. Has an `id` and an array of `roles`. |
281
248
  | **Resource** | The thing being accessed — `"invoice"`, `"project"`, `"user"`. |
282
- | **Action** | What the subject wants to do to the resource — `"invoice:approve"`, `"project:archive"`. Uses `resource:verb` format. |
283
- | **Policy Rule** | A single authorization rule: "allow managers to perform invoice:approve on invoice." Has an effect (`allow` or `deny`), and optionally conditions, priority, and a description. |
284
- | **Condition** | A function attached to a rule that receives the evaluation context and returns `true` or `false`. Used for ABAC — e.g., "only if the user owns the resource." |
285
- | **Tenant** | An organizational unit in a multi-tenant system (e.g., a company). Users can have different roles in different tenants. |
286
- | **Decision** | The result of evaluating a request — contains `allowed`, the matched rule, timing, and a human-readable reason. |
287
- | **Effect** | Either `"allow"` or `"deny"`. Determines what happens when a rule matches. |
249
+ | **Action** | What the subject wants to do — `"invoice:approve"`, `"project:archive"`. Uses `resource:verb` format. |
250
+ | **Policy Rule** | A single authorization rule with an effect (`allow` or `deny`), and optionally conditions, priority, and a description. |
251
+ | **Condition** | A function attached to a rule that receives the evaluation context and returns `true`/`false`. Used for ABAC. |
252
+ | **Tenant** | An organizational unit in a multi-tenant system. Users can have different roles in different tenants. |
253
+ | **Decision** | The result of an evaluation — contains `allowed`, the matched rule, timing, and a human-readable reason. |
288
254
  | **Priority** | A number (default 0) that determines rule evaluation order. Higher priority rules are checked first. |
289
255
  | **Role Hierarchy** | A definition that one role inherits all permissions of another — e.g., `admin` inherits from `manager`. |
290
256
 
291
257
  </details>
292
258
 
293
- ---
294
-
295
- ## Core Features
259
+ <details>
260
+ <summary><strong>Core features</strong></summary>
296
261
 
297
262
  ### Policy Factory
298
263
 
299
- `createPolicyFactory` eliminates the `<MySchema>` generic parameter on every rule:
264
+ `createPolicyFactory` eliminates the `<MySchema>` generic on every rule:
300
265
 
301
266
  ```typescript
302
- import { createPolicyFactory } from "@siremzam/sentinel";
303
-
304
267
  const { allow, deny } = createPolicyFactory<MySchema>();
305
268
 
306
269
  allow().roles("admin").anyAction().anyResource().build();
@@ -309,7 +272,7 @@ deny().roles("viewer").actions("report:export").on("report").build();
309
272
 
310
273
  ### Conditions (ABAC)
311
274
 
312
- Attach predicates to any rule. All conditions on a rule must pass for it to match:
275
+ Attach predicates to any rule. All conditions on a rule must pass:
313
276
 
314
277
  ```typescript
315
278
  allow()
@@ -325,13 +288,9 @@ Conditions receive the full `EvaluationContext` — subject, action, resource, r
325
288
 
326
289
  ### Async Conditions
327
290
 
328
- For conditions that need database lookups or API calls, use async functions and the `*Async` evaluation methods:
291
+ For conditions that need database lookups or API calls:
329
292
 
330
293
  ```typescript
331
- const engine = new AccessEngine<MySchema>({
332
- schema: {} as MySchema,
333
- });
334
-
335
294
  engine.addRule(
336
295
  allow()
337
296
  .roles("member")
@@ -347,26 +306,19 @@ engine.addRule(
347
306
  const decision = await engine.evaluateAsync(user, "report:export", "report");
348
307
  ```
349
308
 
350
- Use `evaluateAsync()`, `permittedAsync()`, and `explainAsync()` when you have async conditions. If you accidentally call the synchronous `evaluate()` or `explain()` with async conditions, the engine throws a clear error guiding you to the async API.
309
+ Use `evaluateAsync()`, `permittedAsync()`, and `explainAsync()` for async conditions. The engine throws a clear error if you accidentally use the sync API with async conditions.
351
310
 
352
311
  ### Wildcard Action Patterns
353
312
 
354
- Use `*` in action patterns to match groups of actions:
355
-
356
313
  ```typescript
357
- // Match all invoice actions
358
314
  allow().roles("manager").actions("invoice:*" as MySchema["actions"]).on("invoice").build();
359
-
360
- // Match all read actions across resources
361
315
  allow().roles("viewer").actions("*:read" as MySchema["actions"]).anyResource().build();
362
316
  ```
363
317
 
364
- Wildcard patterns are pre-compiled to regexes at `addRule()` time — no per-evaluation regex cost.
318
+ Wildcards are pre-compiled to regexes at `addRule()` time — no per-evaluation cost.
365
319
 
366
320
  ### Role Hierarchy
367
321
 
368
- Define that higher roles inherit all permissions of lower roles:
369
-
370
322
  ```typescript
371
323
  import { RoleHierarchy } from "@siremzam/sentinel";
372
324
 
@@ -380,16 +332,6 @@ const engine = new AccessEngine<MySchema>({
380
332
  schema: {} as MySchema,
381
333
  roleHierarchy: hierarchy,
382
334
  });
383
-
384
- engine.addRules(
385
- allow().id("viewer-read").roles("viewer").actions("invoice:read").on("invoice").build(),
386
- allow().id("member-create").roles("member").actions("invoice:create").on("invoice").build(),
387
- allow().id("admin-approve").roles("admin").actions("invoice:approve").on("invoice").build(),
388
- );
389
-
390
- // Admins can read (inherited from viewer), create (from member), AND approve (their own)
391
- // Members can read (from viewer) and create, but NOT approve
392
- // Viewers can only read
393
335
  ```
394
336
 
395
337
  Cycles are detected at definition time and throw immediately.
@@ -398,19 +340,15 @@ Cycles are detected at definition time and throw immediately.
398
340
 
399
341
  - Higher `priority` wins (default: 0)
400
342
  - At equal priority, `deny` wins over `allow`
401
- - This lets you create broad deny rules with targeted allow overrides
402
343
 
403
344
  ```typescript
404
- // Deny impersonation for everyone at priority 0
405
345
  deny().anyRole().actions("user:impersonate").on("user").build();
406
-
407
- // Allow it for owners at priority 10 — this wins
408
346
  allow().roles("owner").actions("user:impersonate").on("user").priority(10).build();
409
347
  ```
410
348
 
411
349
  ### Multitenancy
412
350
 
413
- Role assignments are tenant-scoped. When evaluating with a `tenantId`, only roles assigned to that tenant (or globally, with no tenantId) are considered:
351
+ Role assignments are tenant-scoped. When evaluating with a `tenantId`, only roles for that tenant (or global roles) are considered:
414
352
 
415
353
  ```typescript
416
354
  const user: Subject<MySchema> = {
@@ -422,16 +360,16 @@ const user: Subject<MySchema> = {
422
360
  ],
423
361
  };
424
362
 
425
- // In acme-corp context: user has admin + member roles
426
363
  engine.evaluate(user, "invoice:approve", "invoice", {}, "acme-corp");
364
+ // admin + member roles
427
365
 
428
- // In globex context: user has viewer + member roles
429
366
  engine.evaluate(user, "invoice:approve", "invoice", {}, "globex");
367
+ // viewer + member roles
430
368
  ```
431
369
 
432
370
  ### Strict Tenancy
433
371
 
434
- Prevents accidental cross-tenant access by requiring explicit `tenantId` when the subject has tenant-scoped roles:
372
+ Prevents accidental cross-tenant access:
435
373
 
436
374
  ```typescript
437
375
  const engine = new AccessEngine<MySchema>({
@@ -439,16 +377,13 @@ const engine = new AccessEngine<MySchema>({
439
377
  strictTenancy: true,
440
378
  });
441
379
 
442
- // THROWS — tenantId is required because user has tenant-scoped roles
443
380
  engine.evaluate(user, "invoice:read", "invoice");
444
-
445
- // OK — explicit tenant context
446
- engine.evaluate(user, "invoice:read", "invoice", {}, "acme");
381
+ // THROWS — tenantId required for tenant-scoped subjects
447
382
  ```
448
383
 
449
384
  ### Condition Error Handling
450
385
 
451
- Conditions that throw are treated as `false` (fail-closed). Surface errors with `onConditionError`:
386
+ Conditions that throw are treated as `false` (fail-closed):
452
387
 
453
388
  ```typescript
454
389
  const engine = new AccessEngine<MySchema>({
@@ -459,13 +394,14 @@ const engine = new AccessEngine<MySchema>({
459
394
  });
460
395
  ```
461
396
 
462
- ---
397
+ </details>
463
398
 
464
- ## Observability
399
+ <details>
400
+ <summary><strong>Observability</strong></summary>
465
401
 
466
402
  ### Decision Events
467
403
 
468
- Every evaluation emits a structured `Decision` event. Subscribe at construction or at runtime:
404
+ Every evaluation emits a structured `Decision` event:
469
405
 
470
406
  ```typescript
471
407
  import { toAuditEntry } from "@siremzam/sentinel";
@@ -478,9 +414,9 @@ const engine = new AccessEngine<MySchema>({
478
414
  },
479
415
  });
480
416
 
481
- // Or at runtime
417
+ // Or subscribe at runtime
482
418
  const unsubscribe = engine.onDecision((d) => auditLog.write(toAuditEntry(d)));
483
- unsubscribe(); // when done
419
+ unsubscribe();
484
420
  ```
485
421
 
486
422
  ### explain() — Debug Authorization
@@ -490,8 +426,8 @@ Full evaluation trace showing every rule, whether it matched, and why:
490
426
  ```typescript
491
427
  const result = engine.explain(user, "invoice:approve", "invoice");
492
428
 
493
- console.log(result.allowed); // false
494
- console.log(result.reason); // "No matching rule — default deny"
429
+ console.log(result.allowed);
430
+ console.log(result.reason);
495
431
 
496
432
  for (const evalRule of result.evaluatedRules) {
497
433
  console.log({
@@ -509,11 +445,9 @@ For async conditions, use `engine.explainAsync()`.
509
445
 
510
446
  ### toAuditEntry()
511
447
 
512
- Convert a `Decision` to a serialization-safe format for logging, queuing, or storage:
448
+ Convert a `Decision` to a serialization-safe format:
513
449
 
514
450
  ```typescript
515
- import { toAuditEntry } from "@siremzam/sentinel";
516
-
517
451
  const decision = engine.evaluate(user, "invoice:approve", "invoice");
518
452
  const entry = toAuditEntry(decision);
519
453
  // Safe to JSON.stringify — no functions, no circular references
@@ -521,7 +455,7 @@ const entry = toAuditEntry(decision);
521
455
 
522
456
  ### permitted() — UI Rendering
523
457
 
524
- Ask "what can this user do?" to drive button visibility and menu items:
458
+ Drive button visibility and menu items:
525
459
 
526
460
  ```typescript
527
461
  const actions = engine.permitted(
@@ -536,13 +470,12 @@ const actions = engine.permitted(
536
470
 
537
471
  For async conditions, use `engine.permittedAsync()`.
538
472
 
539
- ---
540
-
541
- ## Integration
473
+ </details>
542
474
 
543
- ### Middleware
475
+ <details>
476
+ <summary><strong>Integration & middleware</strong></summary>
544
477
 
545
- **Express:**
478
+ ### Express
546
479
 
547
480
  ```typescript
548
481
  import { guard } from "@siremzam/sentinel/middleware/express";
@@ -558,7 +491,7 @@ app.post(
558
491
  );
559
492
  ```
560
493
 
561
- **Fastify:**
494
+ ### Fastify
562
495
 
563
496
  ```typescript
564
497
  import { fastifyGuard } from "@siremzam/sentinel/middleware/fastify";
@@ -572,7 +505,7 @@ fastify.post("/invoices/:id/approve", {
572
505
  }, handler);
573
506
  ```
574
507
 
575
- **Hono:**
508
+ ### Hono
576
509
 
577
510
  ```typescript
578
511
  import { honoGuard } from "@siremzam/sentinel/middleware/hono";
@@ -588,7 +521,7 @@ app.post(
588
521
  );
589
522
  ```
590
523
 
591
- **NestJS:**
524
+ ### NestJS
592
525
 
593
526
  ```typescript
594
527
  import {
@@ -620,29 +553,21 @@ No dependency on `@nestjs/common` or `reflect-metadata`. Uses a WeakMap for meta
620
553
 
621
554
  ### Server Mode
622
555
 
623
- Run the engine as a standalone HTTP authorization microservice. This is useful when authorization logic needs to be shared across polyglot services (e.g., a Go API and a Python worker both calling the same policy engine), or when you want to decouple authorization decisions from your application servers entirely.
556
+ Run the engine as a standalone HTTP authorization microservice for polyglot architectures:
624
557
 
625
558
  ```typescript
626
- import { AccessEngine } from "@siremzam/sentinel";
627
559
  import { createAuthServer } from "@siremzam/sentinel/server";
628
560
 
629
- const engine = new AccessEngine<MySchema>({ schema: {} as MySchema });
630
- engine.addRules(/* ... */);
631
-
632
561
  const server = createAuthServer({
633
562
  engine,
634
563
  port: 3100,
635
- authenticate: (req) => {
636
- return req.headers["x-api-key"] === process.env.AUTH_SERVER_KEY;
637
- },
638
- maxBodyBytes: 1024 * 1024, // 1 MB (default)
564
+ authenticate: (req) => req.headers["x-api-key"] === process.env.AUTH_SERVER_KEY,
565
+ maxBodyBytes: 1024 * 1024,
639
566
  });
640
567
 
641
568
  await server.start();
642
569
  ```
643
570
 
644
- **Endpoints:**
645
-
646
571
  | Endpoint | Method | Description |
647
572
  |---|---|---|
648
573
  | `/health` | GET | Health check with rules count and uptime |
@@ -653,7 +578,7 @@ Zero dependencies. Uses Node's built-in `http` module.
653
578
 
654
579
  ### JSON Policy Serialization
655
580
 
656
- Store policies in a database, config file, or load them from an API:
581
+ Store policies in a database, config file, or load from an API:
657
582
 
658
583
  ```typescript
659
584
  import {
@@ -662,10 +587,7 @@ import {
662
587
  ConditionRegistry,
663
588
  } from "@siremzam/sentinel";
664
589
 
665
- // Export rules to JSON
666
590
  const json = exportRulesToJson(engine.getRules());
667
-
668
- // Import rules back (validates effect and id fields)
669
591
  const rules = importRulesFromJson<MySchema>(json);
670
592
  engine.addRules(...rules);
671
593
  ```
@@ -675,21 +597,17 @@ Conditions use a named registry since functions can't be serialized:
675
597
  ```typescript
676
598
  const conditions = new ConditionRegistry<MySchema>();
677
599
  conditions.register("isOwner", (ctx) => ctx.subject.id === ctx.resourceContext.ownerId);
678
- conditions.register("isActive", (ctx) => ctx.resourceContext.status === "active");
679
600
 
680
601
  const rules = importRulesFromJson<MySchema>(json, conditions);
681
602
  ```
682
603
 
683
- Unknown condition names throw with a helpful error listing available conditions.
684
-
685
- ---
604
+ </details>
686
605
 
687
- ## Performance
606
+ <details>
607
+ <summary><strong>Performance & benchmarks</strong></summary>
688
608
 
689
609
  ### Evaluation Cache
690
610
 
691
- For hot paths where the same subject/action/resource is checked repeatedly:
692
-
693
611
  ```typescript
694
612
  const engine = new AccessEngine<MySchema>({
695
613
  schema: {} as MySchema,
@@ -704,7 +622,7 @@ engine.clearCache(); // manual control
704
622
  engine.cacheStats; // { size: 0, maxSize: 1000 }
705
623
  ```
706
624
 
707
- Only unconditional rule evaluations are cached — conditional results are always re-evaluated because they depend on `resourceContext`.
625
+ Only unconditional evaluations are cached — conditional results are always re-evaluated.
708
626
 
709
627
  ### Benchmarks
710
628
 
@@ -718,24 +636,21 @@ Measured on Node v18.18.0, Apple Silicon (ARM64). Run `npm run benchmark` to rep
718
636
  | `permitted` (18 actions) | 60.2 µs / 17k ops/s | 718 µs / 1.4k ops/s | 18,924 µs / 53 ops/s |
719
637
  | `explain` (full trace) | 22.4 µs / 45k ops/s | 564 µs / 1.8k ops/s | 6,444 µs / 155 ops/s |
720
638
 
721
- Most SaaS apps have 10–50 rules. At 100 rules, a single evaluation takes **4.3 µs** — you can run 230,000 authorization checks per second on a single core. With caching enabled, that drops to **0.6 µs**.
722
-
723
- ---
639
+ 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**.
724
640
 
725
- ## Patterns and Recipes
641
+ </details>
726
642
 
727
- Real-world authorization scenarios and how to model them.
643
+ <details>
644
+ <summary><strong>Patterns & recipes</strong></summary>
728
645
 
729
646
  ### Ownership — "Users can only edit their own resources"
730
647
 
731
648
  ```typescript
732
649
  allow()
733
- .id("edit-own-invoice")
734
650
  .roles("member")
735
651
  .actions("invoice:update")
736
652
  .on("invoice")
737
653
  .when(ctx => ctx.subject.id === ctx.resourceContext.ownerId)
738
- .describe("Members can edit their own invoices")
739
654
  .build();
740
655
  ```
741
656
 
@@ -743,7 +658,6 @@ allow()
743
658
 
744
659
  ```typescript
745
660
  allow()
746
- .id("trial-access")
747
661
  .roles("trial")
748
662
  .actions("report:export")
749
663
  .on("report")
@@ -752,7 +666,6 @@ allow()
752
666
  const daysSince = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24);
753
667
  return daysSince <= 14;
754
668
  })
755
- .describe("Trial users can export for 14 days")
756
669
  .build();
757
670
  ```
758
671
 
@@ -762,12 +675,10 @@ allow()
762
675
  const BETA_TENANTS = new Set(["acme-corp", "initech"]);
763
676
 
764
677
  allow()
765
- .id("beta-analytics")
766
678
  .anyRole()
767
679
  .actions("analytics:view")
768
680
  .on("analytics")
769
681
  .when(ctx => BETA_TENANTS.has(ctx.tenantId ?? ""))
770
- .describe("Analytics dashboard is in beta for select tenants")
771
682
  .build();
772
683
  ```
773
684
 
@@ -775,7 +686,6 @@ allow()
775
686
 
776
687
  ```typescript
777
688
  allow()
778
- .id("api-rate-limit")
779
689
  .roles("member")
780
690
  .actions("api:call")
781
691
  .on("api")
@@ -783,38 +693,30 @@ allow()
783
693
  const usage = await rateLimiter.check(ctx.subject.id);
784
694
  return usage.remaining > 0;
785
695
  })
786
- .describe("Members can call API within rate limit")
787
696
  .build();
788
697
  ```
789
698
 
790
699
  ### Broad Deny with Targeted Override
791
700
 
792
701
  ```typescript
793
- // Deny all destructive actions at priority 0
794
702
  deny()
795
- .id("freeze-destructive")
796
703
  .anyRole()
797
704
  .actions("project:delete", "project:archive")
798
705
  .on("project")
799
- .describe("Destructive project actions are frozen")
800
706
  .build();
801
707
 
802
- // Allow owners to override at priority 10
803
708
  allow()
804
- .id("owner-override")
805
709
  .roles("owner")
806
710
  .actions("project:delete", "project:archive")
807
711
  .on("project")
808
712
  .priority(10)
809
- .describe("Owners can still delete/archive their projects")
810
713
  .build();
811
714
  ```
812
715
 
813
- ### IP-Based Restriction via Async Condition
716
+ ### IP-Based Restriction
814
717
 
815
718
  ```typescript
816
719
  allow()
817
- .id("admin-from-office")
818
720
  .roles("admin")
819
721
  .actions("settings:update")
820
722
  .on("settings")
@@ -823,15 +725,13 @@ allow()
823
725
  const geo = await geoService.lookup(ip);
824
726
  return geo.isOfficeNetwork;
825
727
  })
826
- .describe("Admin settings changes only from office network")
827
728
  .build();
828
729
  ```
829
730
 
830
- ---
831
-
832
- ## Testing Your Policies
731
+ </details>
833
732
 
834
- Authorization policies are security-critical code — they should be tested like any other business logic. The `explain()` method is purpose-built for this:
733
+ <details>
734
+ <summary><strong>Testing your policies</strong></summary>
835
735
 
836
736
  ```typescript
837
737
  import { describe, it, expect } from "vitest";
@@ -860,7 +760,6 @@ describe("invoice policies", () => {
860
760
  "acme",
861
761
  );
862
762
 
863
- // Find the ownership rule and verify the condition failed
864
763
  const ownershipRule = result.evaluatedRules.find(
865
764
  e => e.rule.id === "member-own-invoices",
866
765
  );
@@ -868,7 +767,6 @@ describe("invoice policies", () => {
868
767
  });
869
768
 
870
769
  it("prevents cross-tenant access", () => {
871
- // User is admin in acme, viewer in globex
872
770
  const resultAcme = engine.evaluate(user, "invoice:approve", "invoice", {}, "acme");
873
771
  const resultGlobex = engine.evaluate(user, "invoice:approve", "invoice", {}, "globex");
874
772
 
@@ -878,11 +776,12 @@ describe("invoice policies", () => {
878
776
  });
879
777
  ```
880
778
 
881
- `explain()` returns per-rule evaluation details — which rules matched on role, action, and resource, and which conditions passed or failed. This makes your tests self-documenting: when a test fails, the explain trace tells you exactly *why* the decision changed.
779
+ `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*.
882
780
 
883
- ---
781
+ </details>
884
782
 
885
- ## Migration Guide
783
+ <details>
784
+ <summary><strong>Migration guides</strong></summary>
886
785
 
887
786
  ### Coming from CASL
888
787
 
@@ -890,12 +789,12 @@ describe("invoice policies", () => {
890
789
  |---|---|
891
790
  | `defineAbility(can => { can('read', 'Article') })` | `allow().actions("article:read").on("article").build()` |
892
791
  | `ability.can('read', 'Article')` | `engine.evaluate(user, "article:read", "article")` |
893
- | `subject('Article', article)` | Actions use `resource:verb` format natively — no wrapper needed |
792
+ | `subject('Article', article)` | Actions use `resource:verb` format natively |
894
793
  | `conditions: { authorId: user.id }` | `.when(ctx => ctx.subject.id === ctx.resourceContext.authorId)` |
895
794
  | No multi-tenancy | Built-in: `{ role: "admin", tenantId: "acme" }` |
896
795
  | No explain/debug | `engine.explain()` gives per-rule trace |
897
796
 
898
- **Key difference:** CASL uses MongoDB-style conditions (declarative objects). Sentinel uses functions, which gives you full TypeScript expressiveness — async calls, date math, external lookups — with compile-time type safety on the context.
797
+ **Key difference:** CASL uses MongoDB-style conditions (declarative objects). Sentinel uses functions — async calls, date math, external lookups — with compile-time type safety.
899
798
 
900
799
  ### Coming from Casbin
901
800
 
@@ -907,54 +806,23 @@ describe("invoice policies", () => {
907
806
  | Custom matchers for ABAC | `.when()` conditions with full TypeScript |
908
807
  | Role manager | `RoleHierarchy` with cycle detection |
909
808
 
910
- **Key difference:** Casbin requires learning its own DSL for model definitions. Sentinel is pure TypeScript — your IDE autocompletes everything, and your policy definitions are type-checked at compile time.
809
+ **Key difference:** Casbin requires its own DSL. Sentinel is pure TypeScript — your IDE autocompletes everything.
911
810
 
912
811
  ### Coming from accesscontrol
913
812
 
914
813
  | accesscontrol | Sentinel |
915
814
  |---|---|
916
815
  | `ac.grant('admin').createAny('video')` | `allow().roles("admin").actions("video:create").on("video").build()` |
917
- | CRUD only: create, read, update, delete | Domain verbs: `invoice:approve`, `order:ship` |
918
- | `ac.can('admin').createAny('video')` | `engine.evaluate(user, "video:create", "video")` |
816
+ | CRUD only | Domain verbs: `invoice:approve`, `order:ship` |
919
817
  | No conditions/ABAC | Full ABAC with `.when()` conditions |
920
818
  | No multi-tenancy | Built-in per-tenant role assignments |
921
819
 
922
- **Key difference:** accesscontrol is locked into CRUD semantics. If your app has domain-specific actions (approve, archive, impersonate, ship), you'll fight the library. Sentinel treats domain verbs as first-class.
923
-
924
- ---
925
-
926
- ## When NOT to Use This
927
-
928
- Being honest about boundaries:
820
+ **Key difference:** accesscontrol is locked into CRUD semantics. Sentinel treats domain verbs as first-class.
929
821
 
930
- - **You need a full policy language.** If you want Rego (OPA), Cedar (AWS), or a declarative DSL, this isn't that. Sentinel policies are TypeScript code, not a separate language.
931
- - **You need a Zanzibar-style relationship graph.** If your authorization model is "who can access this Google Doc?" with deeply nested sharing relationships, use [SpiceDB](https://authzed.com/) or [OpenFGA](https://openfga.dev/).
932
- - **You need a hosted authorization service.** If you want a managed SaaS solution rather than an embedded library, look at [Permit.io](https://permit.io/) or [Oso Cloud](https://www.osohq.com/).
933
- - **Your model is truly just CRUD on REST resources.** If `create`, `read`, `update`, `delete` is all you need and you don't have tenants, simpler libraries like [accesscontrol](https://www.npmjs.com/package/accesscontrol) may be sufficient.
934
-
935
- Sentinel is built for TypeScript-first SaaS applications with domain-specific actions, multi-tenant requirements, and a need for observable, testable authorization logic.
936
-
937
- ---
938
-
939
- ## Security
940
-
941
- ### Design Principles
942
-
943
- - **Deny by default.** If no rule matches, the answer is no.
944
- - **Fail closed.** If a condition throws, it evaluates to `false`. No silent privilege escalation.
945
- - **Frozen rules.** Rules are `Object.freeze`'d on add. Mutation after insertion is impossible.
946
- - **Cache safety.** Only unconditional rule evaluations are cached. Conditional results (which depend on `resourceContext`) are never cached, preventing stale cache entries from granting access.
947
- - **Strict tenancy.** Optional mode that throws if `tenantId` is omitted for subjects with tenant-scoped roles, preventing accidental cross-tenant privilege escalation.
948
- - **Import validation.** `importRulesFromJson()` validates the `effect` field and rejects invalid or missing values.
949
- - **Server hardening.** `createAuthServer` supports an `authenticate` callback (rejects with 401 on failure) and configurable `maxBodyBytes` (default 1 MB) to prevent DoS via oversized request bodies.
950
-
951
- ### Reporting Vulnerabilities
952
-
953
- See [SECURITY.md](./SECURITY.md) for responsible disclosure instructions.
954
-
955
- ---
822
+ </details>
956
823
 
957
- ## API Reference
824
+ <details>
825
+ <summary><strong>API reference</strong></summary>
958
826
 
959
827
  ### AccessEngine\<S\>
960
828
 
@@ -985,7 +853,6 @@ See [SECURITY.md](./SECURITY.md) for responsible disclosure instructions.
985
853
  | `defaultEffect` | `"deny"` (default) or `"allow"` |
986
854
  | `onDecision` | Listener called on every evaluation |
987
855
  | `onConditionError` | Called when a condition throws (fail-closed) |
988
- | `asyncConditions` | *(deprecated)* When true, sync methods throw immediately. Will be removed in v2. Async conditions are now detected automatically. |
989
856
  | `strictTenancy` | Throw if tenantId is omitted for tenant-scoped subjects |
990
857
  | `roleHierarchy` | A `RoleHierarchy` instance |
991
858
  | `cacheSize` | LRU cache capacity (0 = disabled) |
@@ -1051,19 +918,35 @@ Returned by `engine.explain()`:
1051
918
  - `allowed`, `effect`, `reason`, `durationMs`
1052
919
  - `evaluatedRules` — array of `RuleEvaluation<S>` with per-rule and per-condition details
1053
920
 
921
+ </details>
922
+
1054
923
  ---
1055
924
 
1056
- ## Philosophy
925
+ ## When NOT to use this
1057
926
 
1058
- 1. **Policies belong in one place.** Not scattered across middleware, handlers, and services.
1059
- 2. **Authorization is not authentication.** This library does not care how you identify users. It cares what they're allowed to do.
1060
- 3. **Types are documentation.** If your IDE can't autocomplete it, the API is wrong.
1061
- 4. **Every decision is observable.** If you can't audit it, you can't trust it.
1062
- 5. **Deny by default.** If no rule matches, the answer is no.
1063
- 6. **Fail closed.** If a condition throws, the answer is no.
1064
- 7. **Zero dependencies.** The core engine, server, and middleware use nothing outside Node.js built-ins.
927
+ - **You need a full policy language.** If you want Rego (OPA) or Cedar (AWS), this isn't that. Sentinel policies are TypeScript code.
928
+ - **You need Zanzibar-style relationship graphs.** For "who can access this Google Doc?" with nested sharing, use [SpiceDB](https://authzed.com/) or [OpenFGA](https://openfga.dev/).
929
+ - **You need a hosted authorization service.** Look at [Permit.io](https://permit.io/) or [Oso Cloud](https://www.osohq.com/).
930
+ - **Your model is truly just CRUD.** If `create`/`read`/`update`/`delete` is all you need with no tenants, simpler libraries may suffice.
1065
931
 
1066
- ---
932
+ ## Security
933
+
934
+ - **Deny by default.** No rule match = no access.
935
+ - **Fail closed.** Condition throws = `false`. No silent privilege escalation.
936
+ - **Frozen rules.** Rules are `Object.freeze`'d on add. Mutation after insertion is impossible.
937
+ - **Cache safety.** Only unconditional evaluations are cached. Conditional results are never cached.
938
+ - **Strict tenancy.** Throws if `tenantId` is omitted for tenant-scoped subjects.
939
+ - **Import validation.** `importRulesFromJson()` validates the `effect` field and rejects invalid values.
940
+ - **Server hardening.** `createAuthServer` supports `authenticate` callback and configurable `maxBodyBytes`.
941
+
942
+ See [SECURITY.md](./SECURITY.md) for responsible disclosure.
943
+
944
+ ## What's New in 0.4.1
945
+
946
+ - **Async conditions without opt-in** — No more `asyncConditions: true` flag. Use `evaluateAsync()`, `explainAsync()`, or `permittedAsync()` when you have async conditions; the engine detects them automatically.
947
+ - `asyncConditions` option deprecated (will be removed in v2)
948
+
949
+ See the full [CHANGELOG](./CHANGELOG.md).
1067
950
 
1068
951
  ## Contributing
1069
952
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siremzam/sentinel",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "TypeScript-first, domain-driven authorization engine for modern SaaS apps",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",