@diister/quick-permission 0.9.0-beta.5 → 0.9.0-beta.7

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.
package/README.md CHANGED
@@ -1,747 +1,290 @@
1
- # Quick Permission - Library 2
1
+ # @diister/quick-permission
2
2
 
3
- A TypeScript permission management library that is **type-safe**, **flexible**, and **composable** with support for hierarchical permissions, field-level filtering, and metadata accumulation.
3
+ Declarative, type-safe permissions that answer three questions at once: **may
4
+ this subject act?**, **on which rows?**, and **which fields come back?**
4
5
 
5
- ## 🎯 Key Features
6
+ A check returns not just a yes or no, but the MongoDB constraints — and, when a
7
+ rule reaches across collections, the aggregation pipeline — needed to enforce
8
+ the same decision on a list query.
6
9
 
7
- ### ✨ **Extreme Type Safety**
8
- ```typescript
9
- // ✅ TypeScript enforces the correct argument type
10
- await permSystem.can(user, "article.read", articleId); // Accepts ArticleId
11
- await permSystem.can(user, "article.create"); // Does not accept argument
10
+ [![npm](https://img.shields.io/npm/v/@diister/quick-permission)](https://www.npmjs.com/package/@diister/quick-permission)
11
+ [![JSR](https://jsr.io/badges/@diister/quick-permission)](https://jsr.io/@diister/quick-permission)
12
+ [![License](https://img.shields.io/github/license/diister-dev/quick-permission)](LICENSE)
12
13
 
13
- // ✅ Output type is inferred automatically
14
- const result = await permSystem.can(user, "article.read", "article:1");
15
- result.output?.filter // Type: Record<string, boolean> | undefined
16
- result.output?.data // Type: any
17
- ```
18
-
19
- ### 🔥 **Field-Level Permissions with Accumulation**
20
- ```typescript
21
- // Multiple permissions can contribute
22
- // Owner permission : { filter: { _id, title, owner, salary } }
23
- // Public permission : { filter: { _id, title, body } }
24
- // Contributor permission : { filter: { views } }
14
+ Runs on Bun, Node.js 20.11+ and Deno. No runtime dependencies.
25
15
 
26
- const result = await permSystem.can(ownerUser, "article.read", "article:1");
27
- // result.output.data = { _id, title, body, owner, salary, views }
28
- // ↑ Union of all filters!
29
- ```
16
+ ## Installation
30
17
 
31
- ### 🎪 **Recursive Intermediate Permissions**
32
- ```typescript
33
- "article.manage": intermediate((ctx) => [
34
- { ...ctx, key: "article.read" },
35
- { ...ctx, key: "article.update" },
36
- { ...ctx, key: "article.comment.manage", target: [ctx.target, "*"] }
37
- ])
18
+ ```bash
19
+ # Bun / npm / pnpm / yarn
20
+ bun add @diister/quick-permission
38
21
 
39
- // Recursive resolution with configurable max depth
22
+ # Deno
23
+ deno add jsr:@diister/quick-permission
40
24
  ```
41
25
 
42
- ### 🧩 **Providers + Rules Architecture**
43
- ```typescript
44
- const permSystem = createPermissionSystem({
45
- schemas: permissionsSchemas,
46
- sources: [
47
- directProvider(staticPerms),
48
- ownerProvider(["article.read"], "article:*"),
49
- customDatabaseProvider(),
50
- ],
51
- rules: [TimeRule(), IpRule(), WithRule()],
52
- });
53
- ```
54
-
55
- ---
26
+ ## Quick start
56
27
 
57
- ## 📚 Usage Guide
28
+ A **resource** says how to load a document. A **permission** says what target it
29
+ takes and which rules must hold. A **provider** says which grants a subject
30
+ carries.
58
31
 
59
- ### Installation
60
-
61
- ```typescript
32
+ ```ts
62
33
  import {
63
- createPermissionSystem,
34
+ createSystem,
64
35
  permission,
65
- intermediate,
66
- FilterRule,
67
- directProvider,
68
- ownerProvider,
69
- } from "./library_2/mod.ts";
70
- ```
71
-
72
- ### 1. Define Permissions
73
-
74
- ```typescript
75
- import { permission, intermediate, FilterRule } from "./library_2/mod.ts";
76
-
77
- // Resource fetchers
78
- async function getArticle(id: string) {
79
- return db.articles.findById(id);
80
- }
81
-
82
- async function getComment(ids: [string, string]) {
83
- const [articleId, commentId] = ids;
84
- return db.comments.findById(commentId, { articleId });
85
- }
86
-
87
- // Permission schemas
88
- const permissionsSchemas = {
89
- // Simple permission
90
- "article.create": permission(),
91
-
92
- // Permission with context + field filtering
93
- "article.read": permission(getArticle, [FilterRule()] as const),
94
-
95
- // Permission with context but without rules
96
- "article.update": permission(getArticle),
97
-
98
- // Intermediate permission (expand to children)
99
- "article.manage": intermediate(
100
- (ctx) => [
101
- { subject: ctx.subject, key: "article.read", target: ctx.target },
102
- { subject: ctx.subject, key: "article.update", target: ctx.target },
103
- { subject: ctx.subject, key: "article.delete", target: ctx.target },
104
- ],
105
- getArticle
106
- ),
107
-
108
- // Permission with nested context
109
- "article.comment.delete": permission(getComment),
110
- };
111
- ```
112
-
113
- ### 2. Create Providers
114
-
115
- **Providers** are sources of permissions. They return the permissions for a subject.
116
-
117
- ```typescript
118
- import { directProvider, ownerProvider } from "./library_2/mod.ts";
119
- import type { PermissionProvider } from "./library_2/mod.ts";
120
-
121
- // Provider 1: Static permissions
122
- const staticPerms = directProvider([
123
- {
124
- subject: adminUser,
125
- key: "article.create",
126
- },
127
- {
128
- subject: moderatorUser,
129
- key: "article.update",
130
- target: "article:*",
131
- },
132
- ]);
133
-
134
- // Provider 2: Ownership-based permissions
135
- const ownerPerms = ownerProvider(
136
- ["article.read", "article.update", "article.delete"],
137
- "article:*",
138
- {
139
- filter: { _id: true, title: true, body: true, owner: true }
140
- }
141
- );
142
-
143
- // Provider 3: Custom provider (e.g., from database)
144
- function databaseProvider(): PermissionProvider {
145
- return {
146
- provide: async (subject, _key, _target) => {
147
- const userPerms = await db.permissions.find({ userId: subject.id });
148
- return userPerms.map(p => ({
149
- subject,
150
- key: p.permissionKey,
151
- target: p.target,
152
- filter: p.filter,
153
- }));
154
- }
155
- };
156
- }
157
-
158
- // Provider 4: Conditional provider
159
- function publicArticleProvider(): PermissionProvider {
160
- return {
161
- provide: (subject, _key, _target) => {
162
- return Promise.resolve([
163
- {
164
- key: "article.read",
165
- subject,
166
- target: "*",
167
- with: { public: true }, // Condition: article must be public
168
- filter: { _id: true, title: true, body: true },
169
- },
170
- ]);
171
- }
172
- };
173
- }
174
- ```
175
-
176
- ### 3. Create the Permission System
177
-
178
- ```typescript
179
- import { createPermissionSystem, TimeRule, IpRule, WithRule } from "./library_2/mod.ts";
180
-
181
- const permSystem = createPermissionSystem({
182
- schemas: permissionsSchemas,
183
-
184
- sources: [
185
- staticPerms,
186
- ownerPerms,
187
- publicArticleProvider(),
188
- databaseProvider(),
189
- ],
190
-
191
- // Global validation rules (applied to ALL permissions)
192
- rules: [
193
- TimeRule(), // Validate time constraints (startDate, endDate)
194
- IpRule(), // Validate IP restrictions
195
- WithRule(), // Validate resource constraints
196
- ] as const,
36
+ resource,
37
+ target,
38
+ } from "@diister/quick-permission";
197
39
 
198
- // Max depth for recursive intermediate resolution
199
- maxIntermediateDepth: 10,
40
+ const userOf = resource({
41
+ id: "user",
42
+ fetch: ({ target }) => db.users.findOne({ _id: target[0] }),
200
43
  });
201
- ```
202
-
203
- ### 4. Check Permissions
204
-
205
- ```typescript
206
- const user = { id: "user:123" };
207
-
208
- // Permission without context
209
- const canCreate = await permSystem.can(user, "article.create");
210
- if (canCreate.ok) {
211
- // User can create articles
212
- }
213
-
214
- // Permission with context (target)
215
- const canRead = await permSystem.can(user, "article.read", "article:1");
216
- if (canRead.ok) {
217
- // User can read article:1
218
- console.log("Allowed fields:", canRead.output?.filter);
219
- console.log("Filtered data:", canRead.output?.data);
220
- }
221
-
222
- // Create a checker with shared context + cache
223
- const checker = permSystem.context({
224
- subject: user,
225
- checkDate: new Date("2024-01-01"),
226
- ips: ["192.168.1.1"],
227
- });
228
-
229
- // All calls to checker.can() share the same cache!
230
- const r1 = await checker.can("article.read", "article:1"); // FETCH
231
- const r2 = await checker.can("article.update", "article:1"); // CACHE HIT
232
- const r3 = await checker.can("article.delete", "article:1"); // CACHE HIT
233
- ```
234
-
235
- ---
236
-
237
- ## 🧪 Complete Examples
238
-
239
- ### Example 1: Blog with Field Filtering
240
-
241
- ```typescript
242
- import { createPermissionSystem, permission, FilterRule, ownerProvider } from "./library_2/mod.ts";
243
-
244
- type Article = {
245
- _id: string;
246
- title: string;
247
- body: string;
248
- owner: string;
249
- salary?: number;
250
- secret?: string;
251
- };
252
-
253
- async function getArticle(id: string): Promise<Article | undefined> {
254
- return db.articles.findById(id);
255
- }
256
44
 
257
- const schemas = {
258
- "article.read": permission(getArticle, [FilterRule()] as const),
259
- };
260
-
261
- const permSystem = createPermissionSystem({
262
- schemas,
263
- sources: [
264
- // Public users see basic fields
265
- publicProvider({
266
- filter: { _id: true, title: true, body: true }
267
- }),
268
-
269
- // Owners see more fields
270
- ownerProvider(["article.read"], "article:*", {
271
- filter: { _id: true, title: true, body: true, owner: true, salary: true }
272
- }),
273
-
274
- // Admins see everything
275
- adminProvider({
276
- filter: { _id: true, title: true, body: true, owner: true, salary: true, secret: true }
277
- }),
45
+ const sys = createSystem({
46
+ schema: {
47
+ "users.read": permission({ target: target.required("user") })
48
+ .rules([userOf.match()]),
49
+ },
50
+ providers: [
51
+ (subject) => grantsFor(subject), // [{ key: "users.read", target: ["user:abc"] }]
278
52
  ],
279
53
  });
280
54
 
281
- // Usage
282
- const result = await permSystem.can(publicUser, "article.read", "article:1");
283
- // result.output.data = { _id, title, body } ← filtered automatically
55
+ const result = await sys
56
+ .context({ subject: { id: "user:caller" } })
57
+ .can("users.read", ["user:abc"]);
284
58
 
285
- const result2 = await permSystem.can(ownerUser, "article.read", "article:1");
286
- // result2.output.data = { _id, title, body, owner, salary } ← more fields
59
+ result.ok; // true
287
60
  ```
288
61
 
289
- ### Example 2: Hierarchical Permissions
290
-
291
- ```typescript
292
- const schemas = {
293
- "article.read": permission(getArticle),
294
- "article.update": permission(getArticle),
295
- "article.delete": permission(getArticle),
296
-
297
- // Intermediate permission
298
- "article.manage": intermediate(
299
- (ctx) => [
300
- { ...ctx, key: "article.read" },
301
- { ...ctx, key: "article.update" },
302
- { ...ctx, key: "article.delete" },
303
- ],
304
- getArticle
305
- ),
306
- };
307
-
308
- const permSystem = createPermissionSystem({
309
- schemas,
310
- sources: [
311
- directProvider([
312
- { subject: admin, key: "article.manage", target: "*" }
313
- ])
314
- ],
315
- });
62
+ ## Core concepts
316
63
 
317
- // Admin has article.manage, which expands to read + update + delete
318
- const canRead = await permSystem.can(admin, "article.read", "article:1");
319
- // ✅ true (via article.manage)
64
+ ### Targets
320
65
 
321
- const canUpdate = await permSystem.can(admin, "article.update", "article:1");
322
- // ✅ true (via article.manage)
323
- ```
66
+ A permission declares the shape of what it acts on. Targets are segment tuples,
67
+ so `["user:abc"]` and `["org:1", "project:2"]` are both valid — for their
68
+ respective declarations.
324
69
 
325
- ### Example 3: Conditional Permissions
326
-
327
- ```typescript
328
- import { WithRule } from "./library_2/mod.ts";
329
-
330
- const schemas = {
331
- "article.read": permission(getArticle),
332
- };
333
-
334
- const permSystem = createPermissionSystem({
335
- schemas,
336
- sources: [
337
- // Only on public articles
338
- {
339
- provide: (subject) => Promise.resolve([{
340
- subject,
341
- key: "article.read",
342
- target: "*",
343
- with: { public: true }, // ← Condition
344
- }])
345
- },
346
-
347
- // Only if user is author
348
- {
349
- provide: (subject) => Promise.resolve([{
350
- subject,
351
- key: "article.read",
352
- target: "*",
353
- with: { author: subject.id }, // ← Condition
354
- }])
355
- },
356
- ],
357
- rules: [WithRule()] as const,
358
- });
359
-
360
- // Only works if article.public === true OR article.author === user.id
70
+ ```ts
71
+ target.required("user") // exactly one user segment
72
+ target.optional("user") // the segment may be omitted
73
+ target.none() // the permission acts on nothing in particular
74
+ target.path("org", "project") // a hierarchy
361
75
  ```
362
76
 
363
- ### Example 4: Resource Caching with `context()`
77
+ Grants may use `*` as a segment wildcard: a grant on `["user:*"]` matches any
78
+ user.
364
79
 
365
- ```typescript
366
- import { createPermissionSystem, permission, FilterRule } from "./library_2/mod.ts";
80
+ ### Resources
367
81
 
368
- const schemas = {
369
- "article.read": permission(getArticle, [FilterRule()] as const),
370
- "article.update": permission(getArticle),
371
- "article.delete": permission(getArticle),
372
- };
82
+ A resource is a named loader. The engine calls `fetch` at most once per distinct
83
+ target within a check, and `dedupKey` lets you widen or narrow that sharing.
373
84
 
374
- const permSystem = createPermissionSystem({
375
- schemas,
376
- sources: [ownerProvider(["article.read", "article.update", "article.delete"], "article:*")],
85
+ ```ts
86
+ const postOf = resource({
87
+ id: "post",
88
+ fetch: ({ subject, target, grant }) => db.posts.findOne({ _id: target[0] }),
89
+ activeWhen: (grant) => grant.with?.post !== undefined, // skip when irrelevant
90
+ dedupKey: ({ target }) => String(target[0]),
377
91
  });
378
-
379
- // WITHOUT context(): each permission triggers a fetch
380
- await permSystem.can(user, "article.read", "article:1"); // FETCH #1
381
- await permSystem.can(user, "article.update", "article:1"); // FETCH #2
382
- await permSystem.can(user, "article.delete", "article:1"); // FETCH #3
383
-
384
- // WITH context(): cache is shared between all can()
385
- const checker = permSystem.context({ subject: user });
386
- await checker.can("article.read", "article:1"); // FETCH #1
387
- await checker.can("article.update", "article:1"); // CACHE HIT ✨
388
- await checker.can("article.delete", "article:1"); // CACHE HIT ✨
389
-
390
- // Cache also works in nested functions
391
- async function checkAllPermissions(checker, articleId: string) {
392
- const canRead = await checker.can("article.read", articleId); // CACHE HIT
393
- const canUpdate = await checker.can("article.update", articleId); // CACHE HIT
394
- return { canRead: canRead.ok, canUpdate: canUpdate.ok };
395
- }
396
-
397
- const perms = await checkAllPermissions(checker, "article:1");
398
- // ↑ No new fetch! Everything is cached
399
- ```
400
-
401
- **Advantages of `context()`:**
402
- - ✅ Cache shared between all `can()` of the same checker
403
- - ✅ Custom context (dates, IPs, metadata) in all calls
404
- - ✅ Elegant API: `checker.can(key, target)` instead of `can(subject, key, target)`
405
- - ✅ No need for callbacks/closures (unlike AsyncContext)
406
-
407
- ---
408
-
409
- ## 🏗️ Architecture
410
-
411
- ### Validation Flow
412
-
413
92
  ```
414
- 1. permSystem.can(subject, key, target) or checker.can(key, target)
415
- ↓
416
- 2. Call all providers → [permissions]
417
- ↓
418
- 3. Resolve intermediates (recursive, max depth: 10) → [resolved permissions]
419
- ↓
420
- 4. Filter by key + target matching → [matched permissions]
421
- ↓
422
- 5. Apply global rules (Time, IP, With) → [valid permissions]
423
- ↓
424
- 6. Fetch resource (with cache if context()) ✨
425
- ↓
426
- 7. Apply output rules (FilterRule) → outputs
427
- ↓
428
- 8. Merge outputs (union strategy) → final output
429
- ↓
430
- 9. Return { ok: true, output }
431
- ```
432
-
433
- **Note on caching**: Without `context()`, each `can()` creates its own local cache. With `context()`, all `can()` of the same checker share the same cache Map.
434
-
435
- ### Components
436
-
437
- | Component | Role | Example |
438
- |-----------|------|---------|
439
- | **Permission** | Defines a permission with optional context | `permission(getArticle)` |
440
- | **Intermediate** | Permission that resolves to other permissions | `intermediate((ctx) => [...])` |
441
- | **Provider** | Source of permissions for a subject | `ownerProvider(...)` |
442
- | **Global Rule** | Global validation (applied to all permissions) | `TimeRule()`, `IpRule()` |
443
- | **Output Rule** | Generates metadata/output | `FilterRule()` |
444
-
445
- ---
446
-
447
- ## 🎨 Advanced Patterns
448
-
449
- ### Pattern 1: Multi-Tenant with Organizations
450
-
451
- ```typescript
452
- const schemas = {
453
- "org.member.read": permission(getOrgMember, [FilterRule()] as const),
454
- };
455
-
456
- function orgMemberProvider(): PermissionProvider {
457
- return {
458
- provide: async (subject, _key, _target) => {
459
- // Get user's organizations
460
- const orgs = await db.orgMembers.find({ userId: subject.id });
461
93
 
462
- return orgs.map(org => ({
463
- subject,
464
- key: "org.member.read",
465
- target: `org:${org.orgId}:member:*`,
466
- filter: org.role === 'admin'
467
- ? { _id: true, name: true, email: true, salary: true }
468
- : { _id: true, name: true, email: true },
469
- }));
470
- }
471
- };
472
- }
473
- ```
474
-
475
- ### Pattern 2: Rate Limiting
476
-
477
- ```typescript
478
- function RateLimitRule(): OutputRule<
479
- { rateLimit?: { max: number, window: string } }
480
- > {
481
- return {
482
- name: "rateLimit",
483
- output: ({ state, currentOutput }) => {
484
- const current = currentOutput?.rateLimit?.max || 0;
485
- const incoming = state.rateLimit?.max || 0;
486
-
487
- return {
488
- rateLimit: {
489
- max: Math.max(current, incoming), // Most permissive
490
- window: state.rateLimit?.window || "1h"
491
- }
492
- };
493
- }
494
- };
495
- }
496
-
497
- // Usage
498
- const schemas = {
499
- "api.call": permission(undefined, [RateLimitRule()] as const),
500
- };
501
-
502
- const result = await permSystem.can(user, "api.call");
503
- // result.output.rateLimit = { max: 1000, window: "1h" }
504
- ```
505
-
506
- ### Pattern 3: Temporary Permissions
507
-
508
- ```typescript
509
- import { TimeRule } from "./library_2/mod.ts";
510
-
511
- const permSystem = createPermissionSystem({
512
- schemas,
513
- sources: [
514
- {
515
- provide: (subject) => Promise.resolve([{
516
- subject,
517
- key: "article.read",
518
- target: "article:1",
519
- startDate: new Date("2024-01-01"),
520
- endDate: new Date("2024-12-31"),
521
- }])
522
- }
523
- ],
524
- rules: [TimeRule()] as const,
94
+ ### Rules
95
+
96
+ Rules are what a permission checks. Every resource carries sugar methods for the
97
+ common ones:
98
+
99
+ | Method | Holds when |
100
+ | --- | --- |
101
+ | `match(extract?)` | the fetched value equals the grant's constraint |
102
+ | `filter(extract?)` | contributes a field filter rather than a verdict |
103
+ | `includes(field, extract)` | the grant's `field` is in the extracted list |
104
+ | `requireTruthy()` | the document exists and is truthy |
105
+ | `requireOwner(get, { flag })` | the subject owns the document |
106
+ | `requireMembership(get, { flag })` | the subject is in the extracted list |
107
+ | `requireCustom(predicate, opts)` | your own predicate holds |
108
+
109
+ For anything else, `defineRule` is the primitive they are all built on:
110
+
111
+ ```ts
112
+ import { defineRule } from "@diister/quick-permission";
113
+
114
+ const isPublished = defineRule({
115
+ kind: "post.published",
116
+ needs: [postOf],
117
+ check: ([post]) =>
118
+ post?.status === "published"
119
+ ? true
120
+ : { ok: false, reason: "post is not published" },
525
121
  });
526
-
527
- // Only works between 2024-01-01 and 2024-12-31
528
122
  ```
529
123
 
530
- ---
531
-
532
- ## 🚀 Performance
124
+ `check` returns a boolean for a plain verdict, or `{ ok: false, reason }` when
125
+ the caller deserves to know why.
533
126
 
534
- ### Built-in Optimizations
127
+ ### Providers
535
128
 
536
- 1. **Resource Caching**: With `context()`, cache is shared between all `can()` calls
537
- 2. **Resource Fetching**: Resource is fetched **only once** per permission check
538
- 3. **Short-Circuit**: Global rules stop validation as soon as one fails
539
- 4. **Lazy Evaluation**: Intermediates are resolved only if necessary
129
+ A provider turns a subject into grants. Several may be combined, and a subject
130
+ is allowed when any single grant satisfies the permission's rules. A grant names
131
+ one permission key; there is no wildcard key, so an administrator is granted the
132
+ intermediate keys that expand to everything they hold.
540
133
 
541
- ### Tips for Best Performance
542
-
543
- ```typescript
544
- // ✅ GOOD: Put most restrictive rules first
545
- rules: [
546
- IpRule(), // Fast, filters many
547
- WithRule(), // May fetch resource
548
- TimeRule(), // Fast
549
- ]
550
-
551
- // ❌ BAD: Everyone has access, providers are useless
552
- sources: [
553
- alwaysAllowProvider(), // ← Useless if you have this
554
- ownerProvider(...), // ← Never used
555
- ]
556
-
557
- // ✅ GOOD: Specific provider
558
- sources: [
559
- roleBasedProvider(), // Return quickly if wrong role
560
- ownerProvider(...),
134
+ ```ts
135
+ providers: [
136
+ (subject) => db.grants.find({ userId: subject.id }).toArray(),
137
+ (subject) =>
138
+ subject.isAdmin ? [{ key: "posts.manage", target: ["post:*"] }] : [],
561
139
  ]
562
140
  ```
563
141
 
564
- ---
565
-
566
- ## 🔧 API Reference
567
-
568
- ### Core Functions
569
-
570
- #### `createPermissionSystem<PS, TRules>(config)`
571
-
572
- Creates a permission system.
573
-
574
- ```typescript
575
- const permSystem = createPermissionSystem({
576
- schemas: PermissionSchemas,
577
- sources: PermissionProvider[],
578
- rules?: readonly PermissionRule[],
579
- maxIntermediateDepth?: number,
580
- });
581
- ```
142
+ A provider can also be an object, which lets the engine skip it and cache it:
582
143
 
583
- #### `permission<C, TRules>(fetchTarget?, rules?)`
584
-
585
- Creates a permission.
586
-
587
- ```typescript
588
- // No context, no rules
589
- permission()
590
-
591
- // With context
592
- permission<ArticleId>(getArticle)
593
-
594
- // With context + rules
595
- permission(getArticle, [FilterRule()] as const)
596
- ```
597
-
598
- #### `intermediate<C, TRules>(provide, fetchTarget?, rules?)`
599
-
600
- Creates an intermediate permission.
601
-
602
- ```typescript
603
- intermediate(
604
- (ctx) => [
605
- { ...ctx, key: "article.read" },
606
- { ...ctx, key: "article.update" },
607
- ],
608
- getArticle
609
- )
144
+ ```ts
145
+ {
146
+ name: "memberships",
147
+ keys: ["posts.manage"],
148
+ targetType: "post",
149
+ cacheKey: (subject) => subject.id,
150
+ fetch: (subject, key, target) => membershipGrants(subject.id, target?.[0]),
151
+ }
610
152
  ```
611
153
 
612
- ### Permission System Methods
154
+ | Field | Effect |
155
+ | --- | --- |
156
+ | `name` | how the provider is named in hooks and in the reasons of a deny |
157
+ | `keys`, `matches` | the keys the provider emits; it is consulted for those and for every key they expand to |
158
+ | `targetType` | consulted only when `target[0]` is one concrete id of that type (`"post:1"`, never `"post:*"`) |
159
+ | `cacheKey` | grants are reused within a `context()` for the same key; return `undefined` to skip the cache |
160
+ | `fetch` | returns the grants |
613
161
 
614
- #### `permSystem.can<K>(subject, key, ...target?)`
162
+ A provider that throws, or whose `cacheKey` throws, contributes no grant. The
163
+ check goes on with the others, so a failure can only narrow what a subject may
164
+ do. The failure reaches `hooks.onError` and, when the check is denied, its
165
+ `reasons`. A resource or rule that throws rejects only the grant that needed it,
166
+ the same way.
615
167
 
616
- Checks a permission for a given subject.
168
+ ## Filtering lists, not just single documents
617
169
 
618
- ```typescript
619
- // Without target
620
- await permSystem.can(user, "article.create")
170
+ This is what the library is really for. `can()` hands back the query fragments
171
+ needed to apply the same decision to a list.
621
172
 
622
- // With target
623
- await permSystem.can(user, "article.read", "article:1")
173
+ ```ts
174
+ const result = await sys.context({ subject }).can("posts.list");
175
+ if (!result.ok) return [];
624
176
 
625
- // Return
626
- type PermissionResult<TOutput> = {
627
- ok: boolean;
628
- output?: TOutput; // Type inferred from rules
629
- }
177
+ const rows = result.stages
178
+ ? await db.posts.aggregate([...result.stages]).toArray()
179
+ : await db.posts.find(result.constraints ?? {}).toArray();
630
180
  ```
631
181
 
632
- #### `permSystem.context(ctx)`
182
+ `constraints` is a plain MongoDB filter. `stages` appears instead when a rule
183
+ reaches through an **indirect resource** — a join the engine has to express as
184
+ an aggregation, because the deciding data lives in another collection.
633
185
 
634
- Creates a checker with shared context and cache.
186
+ ```ts
187
+ import { indirectResource } from "@diister/quick-permission";
635
188
 
636
- ```typescript
637
- const checker = permSystem.context({
638
- subject: user, // Required
639
- checkDate: new Date(), // Optional (for TimeRule)
640
- ips: ["192.168.1.1"], // Optional (for IpRule)
641
- // ... other custom properties
189
+ const membershipsOf = indirectResource({
190
+ id: "memberships_of_participant",
191
+ from: participantOf,
192
+ on: { localField: "_id", foreignField: "participantId" },
193
+ to: { _type: "org_membership" },
194
+ cardinality: "many",
642
195
  });
643
-
644
- // All can() of the checker share the same cache
645
- await checker.can("article.read", "article:1"); // FETCH
646
- await checker.can("article.update", "article:1"); // CACHE HIT
647
- ```
648
-
649
- **Return**: `{ can<K>(key, ...target?) => Promise<PermissionResult<...>> }`
650
-
651
- ### Built-in Providers
652
-
653
- #### `directProvider(permissions)`
654
-
655
- Provider with static permissions.
656
-
657
- ```typescript
658
- directProvider([
659
- { subject: user1, key: "article.create" },
660
- { subject: user2, key: "article.read", target: "article:*" },
661
- ])
662
196
  ```
663
197
 
664
- #### `ownerProvider(keys, targetPattern, metadata?)`
198
+ Field-level filtering works the same way: `filter()` rules contribute a
199
+ `FilterSpec`, and `applyFilter` applies it to a document.
665
200
 
666
- Ownership-based provider.
201
+ ```ts
202
+ import { applyFilter } from "@diister/quick-permission";
667
203
 
668
- ```typescript
669
- ownerProvider(
670
- ["article.read", "article.update"],
671
- "article:*",
672
- { filter: { _id: true, title: true } }
673
- )
204
+ const visible = applyFilter(post, result.data as FilterSpec);
674
205
  ```
675
206
 
676
- ### Built-in Rules
677
-
678
- #### Global Rules
679
-
680
- - `TimeRule()`: Validates time constraints (`startDate`, `endDate`)
681
- - `IpRule()`: Validates IP restrictions (`allowedIps`)
682
- - `WithRule()`: Validates resource constraints (`with`)
683
-
684
- #### Output Rules
685
-
686
- - `FilterRule()`: Applies field-level filtering
687
-
688
- ### Utilities
689
-
690
- #### `matchPath(requested, pattern)`
691
-
692
- Matches a path with wildcards.
693
-
694
- ```typescript
695
- matchPath("article:123", "article:*") // true
696
- matchPath(["article:1", "comment:2"], ["article:*", "*"]) // true
207
+ ## API reference
208
+
209
+ ### `createSystem({ schema, providers, hooks })`
210
+
211
+ Builds the engine. `schema` maps permission keys to `permission(...)`
212
+ declarations; `providers` lists the grant sources; `hooks` observes them:
213
+
214
+ | Hook | Called with |
215
+ | --- | --- |
216
+ | `onProviderFetch` | provider name, key, target, `durationMs`, `grantCount`, for every fetch that was not served from the cache |
217
+ | `onError` | `{ source: "provider", provider, error, … }` or `{ source: "rule", grantId, error, … }` |
218
+
219
+ A hook that throws never changes a decision. `createSystem` returns a `System`:
220
+
221
+ | Member | Purpose |
222
+ | --- | --- |
223
+ | `context({ subject })` | bind a subject; returns the surface below |
224
+ | `can(key, target?)` | one-shot check without binding a context |
225
+ | `list()` | every permission key with its metadata |
226
+ | `tree()` | the same, as a hierarchy |
227
+ | `schema` | the declaration you passed in |
228
+ | `indirectResources()` | every indirect resource reachable from the schema |
229
+ | `indirectsUsedBy(key)` | those a given permission depends on |
230
+
231
+ ### `context({ subject })`
232
+
233
+ | Member | Purpose |
234
+ | --- | --- |
235
+ | `can(key, target?)` | run a check; resource fetches are deduplicated across it |
236
+ | `preseed(resourceOrId, target, value)` | supply a document you already hold |
237
+ | `getFetchCounters()` | how many times each resource was fetched |
238
+ | `clearCounters()` | reset those counters |
239
+ | `dumpGrants()` | the grants the providers returned, for debugging |
240
+
241
+ `preseed` is worth knowing: when the caller already has the document in hand,
242
+ seeding it skips the fetch entirely.
243
+
244
+ ### The result of `can()`
245
+
246
+ ```ts
247
+ type CanResult =
248
+ | {
249
+ ok: true;
250
+ data?: unknown; // filter spec, when filter() rules ran
251
+ constraints?: Record<string, unknown>; // MongoDB filter for list queries
252
+ stages?: readonly Record<string, unknown>[]; // aggregation, for indirect rules
253
+ matchedGrants?: readonly string[];
254
+ }
255
+ | { ok: false /* … */ };
697
256
  ```
698
257
 
699
- #### `applyFilter(obj, filterSpec)`
700
-
701
- Applies a filter to an object.
258
+ ### Other exports
702
259
 
703
- ```typescript
704
- applyFilter(
705
- { _id: 1, name: "John", password: "secret" },
706
- { _id: true, name: true }
707
- )
708
- // → { _id: 1, name: "John" }
709
- ```
260
+ `permission`, `resource`, `defineRule`, `indirectResource`, `target`, `seg`,
261
+ `applyFilter`, `inputMatch`, `intermediate`, `matchPath`, `pickFields`,
262
+ `requireSelf`, and the types they use.
710
263
 
711
- #### `mergeFilters(filter1, filter2)`
264
+ `isCapabilityQuery(target)`, `isWildcardSegment(segment)` and
265
+ `refType(segment)` expose how the engine reads a target: a query with any
266
+ wildcard segment (`"*"` or `"post:*"`) is a capability query, and `refType`
267
+ returns the type of a `"type:id"` segment.
712
268
 
713
- Merges two filters (union).
269
+ ## Development
714
270
 
715
- ```typescript
716
- mergeFilters(
717
- { _id: true, name: true },
718
- { name: true, email: true }
719
- )
720
- // → { _id: true, name: true, email: true }
271
+ ```bash
272
+ bun install
273
+ bun test # 198 tests
274
+ bun run check # tsc
275
+ bun run lint # biome
276
+ bun run build # dist/ for npm
721
277
  ```
722
278
 
723
- ---
724
-
725
- ## 🆚 Comparison with Other Libraries
726
-
727
- | Feature | CASL | Casbin | **Library_2** |
728
- |---------|------|--------|---------------|
729
- | Type Safety | ⭐⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ |
730
- | Field-level permissions | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
731
- | Auto-filtering | ❌ | ❌ | ✅ |
732
- | Multi-permission accumulation | ❌ | ❌ | ✅ |
733
- | Output metadata | ⭐⭐ | ⭐ | ⭐⭐⭐⭐⭐ |
734
- | Hierarchical permissions | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
735
- | Flexible providers | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
736
-
737
- ---
738
-
739
- ## 📝 License
740
-
741
- MIT
279
+ The suite targets `node:test`, so it runs unchanged under `bun test`,
280
+ `node --test` and `deno test` — worth it, because Bun runs JavaScriptCore while
281
+ Node and Deno run V8. Five cases need a MongoDB on `localhost:27017`; set
282
+ `MONGO_URL` to point elsewhere.
742
283
 
743
- ---
284
+ `sift/` is vendored from [sift.js](https://github.com/crcn/sift.js) (MIT) with
285
+ the `$where` operator removed, and is excluded from lint and formatting so
286
+ re-vendoring stays a clean diff.
744
287
 
745
- ## 🤝 Contributing
288
+ ## License
746
289
 
747
- Contributions welcome! See examples in `/playground` for usage patterns.
290
+ MIT — see [LICENSE](LICENSE).