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

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 +170 -668
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,747 +1,249 @@
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
40
- ```
41
-
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
- });
22
+ # Deno
23
+ deno add jsr:@diister/quick-permission
53
24
  ```
54
25
 
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,
200
- });
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"],
40
+ const userOf = resource({
41
+ id: "user",
42
+ fetch: ({ target }) => db.users.findOne({ _id: target[0] }),
227
43
  });
228
44
 
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
-
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
- });
316
-
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)
62
+ ## Core concepts
320
63
 
321
- const canUpdate = await permSystem.can(admin, "article.update", "article:1");
322
- // ✅ true (via article.manage)
323
- ```
64
+ ### Targets
324
65
 
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
- });
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.
359
69
 
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
92
  ```
400
93
 
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)
94
+ ### Rules
406
95
 
407
- ---
96
+ Rules are what a permission checks. Every resource carries sugar methods for the
97
+ common ones:
408
98
 
409
- ## 🏗️ Architecture
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 |
410
108
 
411
- ### Validation Flow
109
+ For anything else, `defineRule` is the primitive they are all built on:
412
110
 
413
- ```
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
-
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
- ```
111
+ ```ts
112
+ import { defineRule } from "@diister/quick-permission";
474
113
 
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,
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
533
-
534
- ### Built-in Optimizations
124
+ `check` returns a boolean for a plain verdict, or `{ ok: false, reason }` when
125
+ the caller deserves to know why.
535
126
 
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
540
-
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
- ]
127
+ ### Providers
550
128
 
551
- // ❌ BAD: Everyone has access, providers are useless
552
- sources: [
553
- alwaysAllowProvider(), // ← Useless if you have this
554
- ownerProvider(...), // ← Never used
555
- ]
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.
556
131
 
557
- // ✅ GOOD: Specific provider
558
- sources: [
559
- roleBasedProvider(), // Return quickly if wrong role
560
- ownerProvider(...),
132
+ ```ts
133
+ providers: [
134
+ (subject) => db.grants.find({ userId: subject.id }).toArray(),
135
+ (subject) => (subject.isAdmin ? [{ key: "*", target: ["*"] }] : []),
561
136
  ]
562
137
  ```
563
138
 
564
- ---
565
-
566
- ## 🔧 API Reference
139
+ ## Filtering lists, not just single documents
567
140
 
568
- ### Core Functions
141
+ This is what the library is really for. `can()` hands back the query fragments
142
+ needed to apply the same decision to a list.
569
143
 
570
- #### `createPermissionSystem<PS, TRules>(config)`
144
+ ```ts
145
+ const result = await sys.context({ subject }).can("posts.list");
146
+ if (!result.ok) return [];
571
147
 
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
- });
148
+ const rows = result.stages
149
+ ? await db.posts.aggregate([...result.stages]).toArray()
150
+ : await db.posts.find(result.constraints ?? {}).toArray();
581
151
  ```
582
152
 
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)
153
+ `constraints` is a plain MongoDB filter. `stages` appears instead when a rule
154
+ reaches through an **indirect resource** — a join the engine has to express as
155
+ an aggregation, because the deciding data lives in another collection.
593
156
 
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
- )
610
- ```
157
+ ```ts
158
+ import { indirectResource } from "@diister/quick-permission";
611
159
 
612
- ### Permission System Methods
613
-
614
- #### `permSystem.can<K>(subject, key, ...target?)`
615
-
616
- Checks a permission for a given subject.
617
-
618
- ```typescript
619
- // Without target
620
- await permSystem.can(user, "article.create")
621
-
622
- // With target
623
- await permSystem.can(user, "article.read", "article:1")
624
-
625
- // Return
626
- type PermissionResult<TOutput> = {
627
- ok: boolean;
628
- output?: TOutput; // Type inferred from rules
629
- }
630
- ```
631
-
632
- #### `permSystem.context(ctx)`
633
-
634
- Creates a checker with shared context and cache.
635
-
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
160
+ const membershipsOf = indirectResource({
161
+ id: "memberships_of_participant",
162
+ from: participantOf,
163
+ on: { localField: "_id", foreignField: "participantId" },
164
+ to: { _type: "org_membership" },
165
+ cardinality: "many",
642
166
  });
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
167
  ```
648
168
 
649
- **Return**: `{ can<K>(key, ...target?) => Promise<PermissionResult<...>> }`
169
+ Field-level filtering works the same way: `filter()` rules contribute a
170
+ `FilterSpec`, and `applyFilter` applies it to a document.
650
171
 
651
- ### Built-in Providers
172
+ ```ts
173
+ import { applyFilter } from "@diister/quick-permission";
652
174
 
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
- ])
175
+ const visible = applyFilter(post, result.data as FilterSpec);
662
176
  ```
663
177
 
664
- #### `ownerProvider(keys, targetPattern, metadata?)`
178
+ ## API reference
665
179
 
666
- Ownership-based provider.
180
+ ### `createSystem({ schema, providers })`
667
181
 
668
- ```typescript
669
- ownerProvider(
670
- ["article.read", "article.update"],
671
- "article:*",
672
- { filter: { _id: true, title: true } }
673
- )
674
- ```
182
+ Builds the engine. `schema` maps permission keys to `permission(...)`
183
+ declarations; `providers` lists the grant sources. Returns a `System`:
675
184
 
676
- ### Built-in Rules
185
+ | Member | Purpose |
186
+ | --- | --- |
187
+ | `context({ subject })` | bind a subject; returns the surface below |
188
+ | `can(key, target?)` | one-shot check without binding a context |
189
+ | `list()` | every permission key with its metadata |
190
+ | `tree()` | the same, as a hierarchy |
191
+ | `schema` | the declaration you passed in |
192
+ | `indirectResources()` | every indirect resource reachable from the schema |
193
+ | `indirectsUsedBy(key)` | those a given permission depends on |
677
194
 
678
- #### Global Rules
195
+ ### `context({ subject })`
679
196
 
680
- - `TimeRule()`: Validates time constraints (`startDate`, `endDate`)
681
- - `IpRule()`: Validates IP restrictions (`allowedIps`)
682
- - `WithRule()`: Validates resource constraints (`with`)
197
+ | Member | Purpose |
198
+ | --- | --- |
199
+ | `can(key, target?)` | run a check; resource fetches are deduplicated across it |
200
+ | `preseed(resourceOrId, target, value)` | supply a document you already hold |
201
+ | `getFetchCounters()` | how many times each resource was fetched |
202
+ | `clearCounters()` | reset those counters |
203
+ | `dumpGrants()` | the grants the providers returned, for debugging |
683
204
 
684
- #### Output Rules
205
+ `preseed` is worth knowing: when the caller already has the document in hand,
206
+ seeding it skips the fetch entirely.
685
207
 
686
- - `FilterRule()`: Applies field-level filtering
208
+ ### The result of `can()`
687
209
 
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
210
+ ```ts
211
+ type CanResult =
212
+ | {
213
+ ok: true;
214
+ data?: unknown; // filter spec, when filter() rules ran
215
+ constraints?: Record<string, unknown>; // MongoDB filter for list queries
216
+ stages?: readonly Record<string, unknown>[]; // aggregation, for indirect rules
217
+ matchedGrants?: readonly string[];
218
+ }
219
+ | { ok: false /* … */ };
697
220
  ```
698
221
 
699
- #### `applyFilter(obj, filterSpec)`
700
-
701
- Applies a filter to an object.
702
-
703
- ```typescript
704
- applyFilter(
705
- { _id: 1, name: "John", password: "secret" },
706
- { _id: true, name: true }
707
- )
708
- // → { _id: 1, name: "John" }
709
- ```
222
+ ### Other exports
710
223
 
711
- #### `mergeFilters(filter1, filter2)`
224
+ `permission`, `resource`, `defineRule`, `indirectResource`, `target`, `seg`,
225
+ `applyFilter`, `inputMatch`, `intermediate`, `matchPath`, `pickFields`,
226
+ `requireSelf`, and the types they use.
712
227
 
713
- Merges two filters (union).
228
+ ## Development
714
229
 
715
- ```typescript
716
- mergeFilters(
717
- { _id: true, name: true },
718
- { name: true, email: true }
719
- )
720
- // → { _id: true, name: true, email: true }
230
+ ```bash
231
+ bun install
232
+ bun test # 184 tests
233
+ bun run check # tsc
234
+ bun run lint # biome
235
+ bun run build # dist/ for npm
721
236
  ```
722
237
 
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
238
+ The suite targets `node:test`, so it runs unchanged under `bun test`,
239
+ `node --test` and `deno test` — worth it, because Bun runs JavaScriptCore while
240
+ Node and Deno run V8. Five cases need a MongoDB on `localhost:27017`; set
241
+ `MONGO_URL` to point elsewhere.
742
242
 
743
- ---
243
+ `sift/` is vendored from [sift.js](https://github.com/crcn/sift.js) (MIT) with
244
+ the `$where` operator removed, and is excluded from lint and formatting so
245
+ re-vendoring stays a clean diff.
744
246
 
745
- ## 🤝 Contributing
247
+ ## License
746
248
 
747
- Contributions welcome! See examples in `/playground` for usage patterns.
249
+ MIT — see [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diister/quick-permission",
3
- "version": "0.9.0-beta.5",
3
+ "version": "0.9.0-beta.6",
4
4
  "description": "Declarative, type-safe permission rules with MongoDB query generation",
5
5
  "license": "MIT",
6
6
  "type": "module",