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