@venizia/ignis-docs 0.0.8-0 → 0.0.8-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 (39) hide show
  1. package/dist/mcp-server/helpers/docs.helper.d.ts.map +1 -1
  2. package/dist/mcp-server/helpers/docs.helper.js +1 -1
  3. package/dist/mcp-server/helpers/docs.helper.js.map +1 -1
  4. package/dist/mcp-server/tools/base.tool.d.ts +1 -1
  5. package/dist/mcp-server/tools/docs/search-documents.tool.d.ts +1 -1
  6. package/dist/mcp-server/tools/docs/search-documents.tool.js +1 -1
  7. package/dist/mcp-server/tools/docs/search-documents.tool.js.map +1 -1
  8. package/dist/mcp-server/tools/github/list-project-files.tool.d.ts +1 -1
  9. package/dist/mcp-server/tools/github/list-project-files.tool.js +1 -1
  10. package/dist/mcp-server/tools/github/list-project-files.tool.js.map +1 -1
  11. package/dist/mcp-server/tools/github/search-code.tool.d.ts +1 -1
  12. package/dist/mcp-server/tools/github/search-code.tool.js +1 -1
  13. package/dist/mcp-server/tools/github/search-code.tool.js.map +1 -1
  14. package/package.json +14 -14
  15. package/wiki/extensions/components/authorization/api.md +239 -376
  16. package/wiki/extensions/components/authorization/errors.md +52 -43
  17. package/wiki/extensions/components/authorization/index.md +127 -65
  18. package/wiki/extensions/components/authorization/usage.md +198 -98
  19. package/wiki/extensions/helpers/kafka/consumer.md +6 -5
  20. package/wiki/extensions/helpers/kafka/examples.md +1 -1
  21. package/wiki/extensions/helpers/kafka/index.md +16 -12
  22. package/wiki/extensions/helpers/kafka/producer.md +4 -3
  23. package/wiki/guides/core-concepts/persistent/datasources.md +10 -11
  24. package/wiki/guides/core-concepts/persistent/index.md +6 -6
  25. package/wiki/guides/core-concepts/persistent/models.md +7 -5
  26. package/wiki/guides/core-concepts/persistent/repositories.md +11 -3
  27. package/wiki/guides/core-concepts/persistent/transactions.md +2 -1
  28. package/wiki/guides/core-concepts/rest-controllers.md +2 -2
  29. package/wiki/guides/core-concepts/services.md +0 -1
  30. package/wiki/guides/get-started/5-minute-quickstart.md +11 -10
  31. package/wiki/guides/migrations/scoped-rbac-migration.md +300 -0
  32. package/wiki/guides/tutorials/building-a-crud-api.md +43 -37
  33. package/wiki/guides/tutorials/complete-installation.md +64 -44
  34. package/wiki/guides/tutorials/ecommerce-api.md +21 -12
  35. package/wiki/guides/tutorials/realtime-chat.md +4 -5
  36. package/wiki/references/base/filter-system/default-filter.md +6 -3
  37. package/wiki/references/base/filter-system/fields-order-pagination.md +26 -0
  38. package/wiki/references/base/models.md +6 -3
  39. package/wiki/references/base/repositories/advanced.md +111 -0
@@ -107,31 +107,33 @@ classDiagram
107
107
  }
108
108
 
109
109
  class CasbinAuthorizationEnforcer {
110
- -enforcer: CasbinEnforcer
111
- -inMemoryInvalidationTimer
110
+ -pool: BasePoolHelper~Enforcer~
111
+ -pendingLineFetches: Map
112
112
  +configure() void
113
113
  +destroy() void
114
- +buildRules(opts) IAuthUser
114
+ +buildRules(opts) ICasbinRules
115
115
  +evaluate(opts) TAuthorizationDecision
116
+ +invalidateUserCache(opts)?
117
+ +rebuildUserCache(opts)?
116
118
  }
117
119
 
118
- class BaseFilteredAdapter~TEntities~ {
120
+ class BaseFilteredAdapter~TFilter~ {
119
121
  <<abstract>>
120
- #entities: TEntities
121
- +loadFilteredPolicy(model, filter) void
122
- #buildDirectPolicies(opts)* string[]
123
- #buildGroupPolicies(opts)* lines + roleIds
124
- #buildRolePolicies(opts)* string[]
125
- #formatDomain(domain) string
126
- #toGroupLine(opts) string
127
- #toPolicyLine(opts) string
122
+ #dataSource: IDataSource
123
+ #connector: TAnyConnector
124
+ +loadFilteredPolicy(model, filter)* void
125
+ +isFiltered() boolean
126
+ #loadLines(opts) void
128
127
  }
129
128
 
130
- class DrizzleCasbinAdapter {
131
- -connector: TAnyConnector
132
- #buildDirectPolicies(opts) string[]
133
- #buildGroupPolicies(opts) lines + roleIds
134
- #buildRolePolicies(opts) string[]
129
+ class ScopedCasbinAdapter {
130
+ #entities: IScopedCasbinEntities
131
+ +loadFilteredPolicy(model, filter) void
132
+ #queryRoleAssignments(opts) lines + roleIds
133
+ #queryMemberships(opts) string[]
134
+ #queryGrants(opts) string[]
135
+ #loadStructuralTrees() string[]
136
+ #expandRoleClosure(opts) IdType[]
135
137
  }
136
138
 
137
139
  BaseHelper <|-- AbstractAuthRegistry
@@ -139,7 +141,7 @@ classDiagram
139
141
  IAuthorizationEnforcer <|.. CasbinAuthorizationEnforcer
140
142
  BaseHelper <|-- CasbinAuthorizationEnforcer
141
143
  BaseHelper <|-- BaseFilteredAdapter
142
- BaseFilteredAdapter <|-- DrizzleCasbinAdapter
144
+ BaseFilteredAdapter <|-- ScopedCasbinAdapter
143
145
  ```
144
146
 
145
147
  ### Module File Layout
@@ -147,13 +149,16 @@ classDiagram
147
149
  ```
148
150
  auth/authorize/
149
151
  ├── adapters/
150
- │ ├── base-filtered.ts # BaseFilteredAdapter (abstract template)
151
- └── drizzle-casbin.ts # DrizzleCasbinAdapter (concrete SQL)
152
+ │ ├── base-filtered.ts # BaseFilteredAdapter (thin abstract) + ICasbinPolicyFilter
153
+ ├── scoped-casbin.adapter.ts # ScopedCasbinAdapter (generic edge-table reader)
154
+ │ └── types.ts # IScopedCasbinEntities, IScopedCasbinTable
152
155
  ├── common/
153
156
  │ ├── constants.ts # Authorization, AuthorizationActions, AuthorizationDecisions,
157
+ │ │ # AuthorizationDomainScopes, AuthorizationPolicyVariants,
154
158
  │ │ # AuthorizationRoles, AuthorizationEnforcerTypes,
155
159
  │ │ # CasbinEnforcerCachedDrivers, CasbinEnforcerModelDrivers,
156
160
  │ │ # CasbinRuleVariants
161
+ │ ├── object-match.ts # objectMatch resource-hierarchy matcher
157
162
  │ ├── keys.ts # AuthorizeBindingKeys
158
163
  │ ├── types.ts # IAuthorizeOptions, IAuthorizationEnforcer,
159
164
  │ │ # IAuthorizationSpec, ICasbinEnforcerOptions, etc.
@@ -161,14 +166,13 @@ auth/authorize/
161
166
  ├── enforcers/
162
167
  │ ├── casbin.enforcer.ts # CasbinAuthorizationEnforcer
163
168
  │ ├── enforcer-registry.ts # AuthorizationEnforcerRegistry (singleton)
169
+ │ ├── models/
170
+ │ │ ├── rbac-domain.model.ts # CASBIN_RBAC_DOMAIN_SCOPED_MODEL (scoped model string)
171
+ │ │ └── index.ts
164
172
  │ └── index.ts # Barrel export
165
173
  ├── middlewares/
166
174
  │ └── authorize.middleware.ts # authorize() standalone function
167
175
  ├── models/
168
- │ ├── abilities/
169
- │ │ ├── string-action.model.ts # StringAuthorizationAction
170
- │ │ ├── string-resource.model.ts # StringAuthorizationResource
171
- │ │ └── index.ts
172
176
  │ ├── authorization-role.model.ts # AuthorizationRole
173
177
  │ └── index.ts
174
178
  ├── providers/
@@ -197,8 +201,7 @@ auth/authorize/
197
201
  | **Rules caching** | Built rules cached on Hono context per-request -- avoids rebuilding for multi-spec routes |
198
202
  | **Registry singleton** | Mirrors `AuthenticationStrategyRegistry` pattern -- consistent with the codebase |
199
203
  | **Abstract base** | `AbstractAuthRegistry<T>` shared between authentication and authorization registries |
200
- | **Filtered adapter pattern** | `BaseFilteredAdapter` template method pattern allows custom query backends while sharing formatting logic |
201
- | **IAuthorizationComparable** | Generic comparison interface for custom action/resource types beyond plain strings |
204
+ | **Filtered adapter pattern** | `BaseFilteredAdapter` is a thin read-only base; subclasses implement `loadFilteredPolicy` for custom query backends |
202
205
  | **No-enforcer fallback** | When no enforcers are registered, the middleware skips authorization and calls `next()` instead of throwing -- prevents hard failures during development or gradual rollout |
203
206
 
204
207
  ## Component Lifecycle
@@ -408,8 +411,8 @@ interface IAuthorizationEnforcer<
408
411
  | Parameter | Default | Description |
409
412
  |-----------|---------|-------------|
410
413
  | `E` | `Env` | Hono `Env` type for typed context access |
411
- | `TAction` | `string` | Action type. Can be `string` or `IAuthorizationComparable` for custom comparison |
412
- | `TResource` | `string` | Resource type. Can be `string` or `IAuthorizationComparable` for custom comparison |
414
+ | `TAction` | `string` | Action type (string) |
415
+ | `TResource` | `string` | Resource type (string) |
413
416
  | `TRules` | `unknown` | Rules type produced by `buildRules` and consumed by `evaluate` |
414
417
  | `TBuildRulesReturn` | `ValueOrPromise<TRules>` | Return type of `buildRules` |
415
418
  | `TEvaluateReturn` | `ValueOrPromise<TAuthorizationDecision>` | Return type of `evaluate` |
@@ -418,7 +421,7 @@ interface IAuthorizationEnforcer<
418
421
 
419
422
  | Enforcer | TRules | Description |
420
423
  |----------|--------|-------------|
421
- | `CasbinAuthorizationEnforcer` | `IAuthUser` | User object (Casbin evaluates internally from loaded model) |
424
+ | `CasbinAuthorizationEnforcer` | `ICasbinRules` | `{ user, lines }` — the user plus their resolved Casbin policy lines (loaded into a pooled enforcer at evaluate time) |
422
425
  | Custom | Any type | Your custom rules structure |
423
426
 
424
427
  ### Method Contracts
@@ -438,6 +441,8 @@ interface IAuthorizationRequest<TAction = string, TResource = string> {
438
441
  action: TAction;
439
442
  resource: TResource;
440
443
  conditions?: TAuthorizationConditions;
444
+ /** Resolved domain scope: `"<DomainType>_<id>"` (e.g. `"Merchant_7"`) or the `"SYSTEM_WIDE"` sentinel. */
445
+ domain?: string;
441
446
  }
442
447
  ```
443
448
 
@@ -447,87 +452,6 @@ interface IAuthorizationRequest<TAction = string, TResource = string> {
447
452
  | `resource` | `TResource` | Resource being accessed (e.g., `'Article'`) |
448
453
  | `conditions` | `TAuthorizationConditions` | Optional key-value conditions for ABAC |
449
454
 
450
- ## IAuthorizationComparable Interface
451
-
452
- Generic comparison interface for custom action and resource types. Allows enforcers to work with objects that define their own comparison logic rather than plain strings.
453
-
454
- ```typescript
455
- interface IAuthorizationComparable<TElement = string, TCompareResult = number> {
456
- value: TElement;
457
- compare(other: TElement): TCompareResult;
458
- isEqual(other: TElement): boolean;
459
- }
460
- ```
461
-
462
- | Member | Type | Description |
463
- |--------|------|-------------|
464
- | `value` | `TElement` | The underlying value |
465
- | `compare(other)` | `TCompareResult` | Compare with another value. Convention: `0` means equal. |
466
- | `isEqual(other)` | `boolean` | Convenience check -- typically `compare(other) === 0` |
467
-
468
- The `CasbinAuthorizationEnforcer` constrains its `TAction` and `TResource` generics to `string | IAuthorizationComparable`, allowing either plain strings or comparable objects.
469
-
470
- ## StringAuthorizationAction
471
-
472
- `IAuthorizationComparable` implementation for string-based actions with wildcard support.
473
-
474
- ```typescript
475
- class StringAuthorizationAction implements IAuthorizationComparable<string> {
476
- static readonly WILDCARD = '*';
477
-
478
- readonly value: string;
479
-
480
- static build(opts: { value: string }): StringAuthorizationAction;
481
- constructor(opts: { value: string });
482
-
483
- compare(other: string): number;
484
- isEqual(other: string): boolean;
485
- }
486
- ```
487
-
488
- ### Comparison Logic
489
-
490
- - If `this.value === '*'` (WILDCARD), `compare()` returns `0` (matches everything)
491
- - Otherwise, `this.value.localeCompare(other)` is used
492
-
493
- ```typescript
494
- import { StringAuthorizationAction } from '@venizia/ignis';
495
-
496
- const wildcard = StringAuthorizationAction.build({ value: '*' });
497
- wildcard.isEqual('read'); // true — wildcard matches all
498
- wildcard.isEqual('delete'); // true — wildcard matches all
499
-
500
- const read = StringAuthorizationAction.build({ value: 'read' });
501
- read.isEqual('read'); // true
502
- read.isEqual('create'); // false
503
- ```
504
-
505
- ## StringAuthorizationResource
506
-
507
- `IAuthorizationComparable` implementation for string-based resources using `localeCompare`.
508
-
509
- ```typescript
510
- class StringAuthorizationResource implements IAuthorizationComparable<string> {
511
- readonly value: string;
512
-
513
- static build(opts: { value: string }): StringAuthorizationResource;
514
- constructor(opts: { value: string });
515
-
516
- compare(other: string): number; // this.value.localeCompare(other)
517
- isEqual(other: string): boolean; // compare(other) === 0
518
- }
519
- ```
520
-
521
- Unlike `StringAuthorizationAction`, this class has no wildcard support -- comparison is always via `localeCompare`.
522
-
523
- ```typescript
524
- import { StringAuthorizationResource } from '@venizia/ignis';
525
-
526
- const article = StringAuthorizationResource.build({ value: 'Article' });
527
- article.isEqual('Article'); // true
528
- article.isEqual('User'); // false
529
- ```
530
-
531
455
  ## Casbin Enforcer
532
456
 
533
457
  `CasbinAuthorizationEnforcer` wraps the `casbin` library (optional peer dependency).
@@ -537,17 +461,19 @@ article.isEqual('User'); // false
537
461
  ```typescript
538
462
  class CasbinAuthorizationEnforcer<
539
463
  E extends Env = Env,
540
- TAction extends string | IAuthorizationComparable = string,
541
- TResource extends string | IAuthorizationComparable = string,
464
+ TAction extends string = string,
465
+ TResource extends string = string,
542
466
  >
543
467
  extends BaseHelper
544
- implements IAuthorizationEnforcer<E, TAction, TResource, IAuthUser>
468
+ implements IAuthorizationEnforcer<E, TAction, TResource, ICasbinRules>
545
469
  {
546
470
  name = 'CasbinAuthorizationEnforcer';
547
471
 
548
472
  private readonly MIN_EXPIRES_IN = 10_000;
549
- private enforcer: TNullable<CasbinEnforcerType | CasbinCachedEnforcerType>;
550
- private inMemoryInvalidationTimer: TNullable<NodeJS.Timeout>;
473
+ private pool: TNullable<BasePoolHelper<CasbinEnforcerType>>; // per-request enforcers
474
+ private helper: TNullable<typeof CasbinHelper>; // casbin.Helper (loadPolicyLine)
475
+ private readonly pendingLineFetches = new Map<string, Promise<string[]>>(); // single-flight
476
+ private resolvedPayloadFn: TNullable<TNormalizePayloadFn>; // memoized in configure()
551
477
 
552
478
  constructor(
553
479
  @inject({ key: AuthorizeBindingKeys.enforcerOptions('casbin') })
@@ -559,382 +485,312 @@ class CasbinAuthorizationEnforcer<
559
485
  destroy(): void;
560
486
 
561
487
  // IAuthorizationEnforcer
562
- async buildRules(opts: { user; context }): Promise<IAuthUser>;
488
+ async buildRules(opts: { user; context }): Promise<ICasbinRules>; // { user, lines }
563
489
  async evaluate(opts: { rules; request; context }): Promise<TAuthorizationDecision>;
564
490
 
491
+ // Optional cache management (Redis only)
492
+ async invalidateUserCache(opts: { user }): Promise<{ invalidatedKeys: number }>;
493
+ async rebuildUserCache(opts: { user }): Promise<{ cacheKey: string; lineCount: number }>;
494
+
565
495
  // Protected internals
566
- protected async resolveCasbinEnforcer(opts): Promise<CasbinEnforcerType | CasbinCachedEnforcerType>;
496
+ protected async registerMatchers(opts: { enforcer; casbin }): Promise<void>;
497
+ protected assertMatcherCompilesSync(opts: { enforcer }): void;
567
498
  protected resolveModel(opts): Model;
568
499
  protected validateExpiresIn(opts: { expiresIn: number }): void;
569
- protected async loadPoliciesFromAdapter(opts): Promise<void>;
570
- protected async loadPoliciesWithRedisCache(opts): Promise<void>;
571
- protected async extractPolicyLines(): Promise<string[]>;
572
- protected async loadPolicyLinesIntoModel(opts: { lines: string[] }): Promise<void>;
500
+ protected async fetchLinesWithRedisCache(opts: { user; cached }): Promise<string[]>;
501
+ protected async extractUserLines(opts: { user }): Promise<string[]>; // throwaway enforcer + adapter
502
+ protected async extractLinesFrom(enforcer): Promise<string[]>;
503
+ protected async loadPolicyLinesIntoModel(opts: { enforcer; lines }): Promise<void>;
504
+ protected enforceWithExplain(opts: { enforcer; vals: string[] }): boolean;
573
505
  }
574
506
  ```
575
507
 
508
+ > **Architecture in one line:** the adapter (DB load) runs only on a *throwaway* enforcer to build a
509
+ > user's lines (cached in Redis); every request then enforces on a *pooled* enforcer freshly loaded
510
+ > with those lines. This isolates concurrency and keeps the DB out of the hot path.
511
+
576
512
  ### Constructor
577
513
 
578
- Injects `ICasbinEnforcerOptions` from the DI container using the binding key `AuthorizeBindingKeys.enforcerOptions('casbin')` (which resolves to `@app/authorize/enforcers/casbin/options`).
514
+ Injects `ICasbinEnforcerOptions` from the DI container using the binding key `AuthorizeBindingKeys.enforcerOptions('casbin')`.
579
515
 
580
516
  ### configure()
581
517
 
582
518
  Called once by the registry on first use. Performs:
583
519
 
584
- 1. Dynamically imports `casbin` -- throws `"casbin" is not installed` if missing
585
- 2. Validates `options.model` -- throws `options.model is required.` if missing
586
- 3. Resolves model via driver:
587
- - `'file'` -> `casbin.newModelFromFile(definition)`
588
- - `'text'` -> `casbin.newModelFromString(definition)`
589
- 4. Creates enforcer based on cache config:
590
- - `cached.use: false` -> `casbin.newEnforcer(model, adapter)`
591
- - `cached.driver: 'in-memory'` -> `casbin.newCachedEnforcer(model, adapter)` + periodic invalidation timer (`setInterval`)
592
- - `cached.driver: 'redis'` -> `casbin.newEnforcer(model, adapter)` (Redis handles caching externally)
593
- 5. Validates `expiresIn >= MIN_EXPIRES_IN` (10,000 ms) for both in-memory and redis cache drivers
520
+ 1. Dynamically imports `casbin` throws `"casbin" is not installed` if missing.
521
+ 2. Validates `options.model` throws `options.model is required.` if missing.
522
+ 3. Memoizes the payload normalizer (`options.normalizePayloadFn ?? defaultScopedPayloadFn()`).
523
+ 4. If `cached.use`, validates `expiresIn >= MIN_EXPIRES_IN` (10,000 ms).
524
+ 5. Builds a **`BasePoolHelper<Enforcer>`** (`size = poolSize ?? 16`, `acquireTimeoutMs = poolAcquireTimeoutMs ?? 5000`). Each pooled enforcer is created **without an adapter** (`newEnforcer(model)` — no DB load at warmup), then `registerMatchers()` and `assertMatcherCompilesSync()` run on it.
525
+ 6. `await pool.warmup()` pre-creates the enforcers.
594
526
 
595
- ### destroy()
527
+ `registerMatchers()` — when `isScoped`, registers `keyMatch` as the domain matching func on `g`, adds `objectMatch` as a function, and registers it as the matching func on the resource relation (`g4`). When `domainMatching` is set (non-scoped), registers the chosen `Util.*Func` on the named role definition. Always finishes with `buildRoleLinks()`.
596
528
 
597
- Cleans up the in-memory invalidation timer:
529
+ `assertMatcherCompilesSync()` a boot-time smoke test: forces casbin's lazy matcher compile by running one dummy `enforceSync` (4 args when scoped/`normalizePayloadFn`, else 3), so a malformed matcher, an unregistered function, or an arity mismatch fails at warmup instead of on the first real request.
598
530
 
599
- ```typescript
600
- destroy() {
601
- if (!this.inMemoryInvalidationTimer) {
602
- return;
603
- }
604
- clearInterval(this.inMemoryInvalidationTimer);
605
- this.inMemoryInvalidationTimer = null;
606
- }
607
- ```
531
+ ### destroy()
608
532
 
609
- Call this when shutting down the application to prevent timer leaks. Only relevant when using the `'in-memory'` cache driver.
533
+ `this.pool?.destroy()` drains and disposes the pooled enforcers.
610
534
 
611
535
  ### buildRules()
612
536
 
613
- Loads policies into the casbin enforcer model. Always uses `loadFilteredPolicy` -- the adapter must implement the `FilteredAdapter` interface.
537
+ Returns `ICasbinRules` = `{ user, lines }`. The `lines` are the user's complete Casbin policy lines.
614
538
 
615
539
  ```mermaid
616
540
  flowchart TD
617
541
  Start([buildRules]) --> Check{cached.use?}
618
- Check -->|false| Direct["loadPoliciesFromAdapter(user)"]
619
- Check -->|true| Driver{cached.driver?}
620
- Driver -->|in-memory| InMem["loadPoliciesFromAdapter(user)<br/>(CachedEnforcer handles invalidation)"]
621
- Driver -->|redis| Redis["loadPoliciesWithRedisCache(user, cached)"]
622
- Driver -->|other| Error[/500 Invalid cached.driver/]
623
- Direct --> Return([return user])
624
- InMem --> Return
625
- Redis --> Return
542
+ Check -->|false| Extract["extractUserLines(user)"]
543
+ Check -->|true| Redis["fetchLinesWithRedisCache(user, cached)"]
544
+ Redis --> Hit{Redis hit?}
545
+ Hit -->|Yes| Lines([lines])
546
+ Hit -->|No| SF["single-flight extractUserLines + SET PX"]
547
+ SF --> Lines
548
+ Extract --> Lines
549
+ Lines --> Return(["return { user, lines }"])
626
550
  ```
627
551
 
628
- | Cache Driver | Behavior |
629
- |-------------|----------|
630
- | `use: false` | Load policies from adapter directly via `loadPoliciesFromAdapter()` |
631
- | `'in-memory'` | Load policies from adapter (periodic invalidation handles cache refresh) |
632
- | `'redis'` | Check Redis cache -> hit: load lines into model via `loadPolicyLinesIntoModel()`; miss: load from adapter, extract lines via `extractPolicyLines()`, cache in Redis with TTL |
633
-
634
- Returns the `IAuthUser` directly (Casbin evaluates policies from its internal model, not from the returned value).
552
+ - **`extractUserLines(user)`** builds a fresh, **isolated** enforcer *with the adapter*, calls
553
+ `adapter.loadFilteredPolicy({ principal: { type, id } })`, then `extractLinesFrom()` serializes every
554
+ p-type and g-type rule back into lines. This throwaway enforcer never serves a request — that is the
555
+ anti-poisoning guarantee.
556
+ - **`fetchLinesWithRedisCache`** returns cached lines on hit (Redis owns expiry via `PX`). On miss it
557
+ dedups concurrent misses through `pendingLineFetches` (single-flight), extracts once, and writes the
558
+ lines back to Redis. A corrupt entry is logged and discarded (refetch), never a 500.
635
559
 
636
560
  ### evaluate()
637
561
 
638
- Delegates to Casbin's synchronous `enforceSync()`:
562
+ Borrows an enforcer from the pool and evaluates **atomically** inside `pool.use`:
639
563
 
640
- ```typescript
641
- // Without normalizePayloadFn:
642
- // subject = `${user.principalType}_${user.userId}`
643
- // enforceSync(subject, resource, action)
644
-
645
- // With normalizePayloadFn:
646
- // const { subject, domain, resource, action } = normalizePayloadFn({ user, ... })
647
- // Domain-aware: enforceSync(subject, domain, resource, action)
648
- // No domain: enforceSync(subject, resource, action)
564
+ ```mermaid
565
+ flowchart TD
566
+ Start([evaluate]) --> Use["pool.use(enforcer =>"]
567
+ Use --> Load["loadPolicyLinesIntoModel(enforcer, rules.lines)<br/>clearPolicy + loadPolicyLine* + buildRoleLinks"]
568
+ Load --> Norm["normalizePayloadFn(user, action, resource, context)"]
569
+ Norm --> Dom["domain = normalized.domain ?? request.domain ?? (isScoped ? SYSTEM_WIDE : undefined)"]
570
+ Dom --> Enf["enforceWithExplain(vals)"]
571
+ Enf --> Dec{allowed?}
572
+ Dec -->|Yes| Allow([ALLOW])
573
+ Dec -->|No| Deny([DENY])
649
574
  ```
650
575
 
651
- Returns `AuthorizationDecisions.ALLOW` or `AuthorizationDecisions.DENY`.
576
+ - `vals` is `[subject, domain, resource, action]` when a domain is present (scoped), else `[subject, resource, action]`.
577
+ - On any error inside `pool.use`, the pool **destroys** the borrowed enforcer (fail-closed); a fresh one is created on demand.
578
+ - `enforceWithExplain` uses `enforceExSync` to also log the deciding policy on a DENY.
652
579
 
653
- ### Protected Methods
580
+ ### invalidateUserCache() / rebuildUserCache()
654
581
 
655
- | Method | Input | Output | Description |
656
- |--------|-------|--------|-------------|
657
- | `resolveCasbinEnforcer` | `{ casbin, model, adapter, cached }` | `Enforcer \| CachedEnforcer` | Creates the casbin enforcer instance based on cache config |
658
- | `resolveModel` | `{ casbin, model }` | `Model` | Resolves casbin model from file or text via driver discriminant |
659
- | `validateExpiresIn` | `{ expiresIn }` | `void` | Throws if `expiresIn < MIN_EXPIRES_IN` |
660
- | `loadPoliciesFromAdapter` | `{ user }` | `void` | Calls `enforcer.loadFilteredPolicy({ principalType, principalValue })` |
661
- | `loadPoliciesWithRedisCache` | `{ user, cached }` | `void` | Redis cache flow: check cache -> hit: load lines; miss: load from adapter + cache |
662
- | `extractPolicyLines` | -- | `string[]` | Extracts `p` and `g` lines from enforcer model via `getPolicy()` and `getGroupingPolicy()` |
663
- | `loadPolicyLinesIntoModel` | `{ lines }` | `void` | Clears model, loads lines via `Helper.loadPolicyLine()`, rebuilds role links |
582
+ Redis-only (throw if caching is disabled). `invalidateUserCache` deletes the user's shared Redis key
583
+ (next request rebuilds lazily). `rebuildUserCache` deletes then immediately re-extracts (on a throwaway
584
+ enforcer) and re-caches. Because the key is shared in Redis, a single call is correct across instances.
664
585
 
665
- ### Policy Loading Internals
586
+ ### Protected Methods
666
587
 
667
- #### Redis Cache Flow
588
+ | Method | Output | Description |
589
+ |--------|--------|-------------|
590
+ | `registerMatchers` | `void` | Registers domain/resource matching funcs (+ `buildRoleLinks`); scoped vs `domainMatching` |
591
+ | `assertMatcherCompilesSync` | `void` | Boot-time matcher smoke test (forces lazy compile) |
592
+ | `resolveModel` | `Model` | Resolves casbin model from `file` or `text` driver |
593
+ | `validateExpiresIn` | `void` | Throws if `expiresIn < MIN_EXPIRES_IN` |
594
+ | `fetchLinesWithRedisCache` | `string[]` | Redis read → single-flight extract+write on miss |
595
+ | `extractUserLines` | `string[]` | Throwaway enforcer + adapter `loadFilteredPolicy` → `extractLinesFrom` |
596
+ | `extractLinesFrom` | `string[]` | Serializes every p-type and g-type rule into lines |
597
+ | `loadPolicyLinesIntoModel` | `void` | `clearPolicy` + `loadPolicyLine` per line + `buildRoleLinks` |
598
+ | `enforceWithExplain` | `boolean` | `enforceExSync`; logs the deciding rule on DENY |
668
599
 
669
- ```mermaid
670
- flowchart TD
671
- Start([buildRules with Redis]) --> Key["keyFn({ user }) → cacheKey"]
672
- Key --> Valid{cacheKey truthy?}
673
- Valid -->|No| E400[/400 Invalid cachedKey/]
674
- Valid -->|Yes| Get["Redis GET cacheKey"]
675
- Get --> Hit{Cache hit?}
676
- Hit -->|Yes| Parse["JSON.parse(cachedData)"]
677
- Parse --> LoadModel["loadPolicyLinesIntoModel(lines)"]
678
- LoadModel --> LogHit["Log: Loaded CACHED Policies"]
679
- Hit -->|No| Adapter["loadPoliciesFromAdapter(user)"]
680
- Adapter --> Extract["extractPolicyLines()"]
681
- Extract --> Set["Redis SET cacheKey, lines, PX expiresIn"]
682
- Set --> LogMiss["Log: Loaded ADAPTER + CACHED Policies"]
683
- LogHit --> Return([return user])
684
- LogMiss --> Return
685
- ```
686
-
687
- #### extractPolicyLines()
600
+ #### extractLinesFrom()
688
601
 
689
- Extracts all loaded policies from the enforcer model as casbin-format strings:
602
+ Serializes **all** policy + grouping rule types (not just `p`/`g`) so the cached payload is complete
603
+ for the scoped model (`g2`…`g5`):
690
604
 
691
605
  ```typescript
692
- // Policy rules: ["p, user_123, Article, read, allow", ...]
693
- const pRules = await this.enforcer.getPolicy();
694
- const ps = pRules.map(r => [CasbinRuleVariants.P, ...r].join(', '));
695
-
696
- // Group rules: ["g, user_123, role_admin, org_1", ...]
697
- const gRules = await this.enforcer.getGroupingPolicy();
698
- const gs = gRules.map(r => [CasbinRuleVariants.G, ...r].join(', '));
606
+ const model = enforcer.getModel();
607
+ const lines: string[] = [];
699
608
 
700
- return [...ps, ...gs];
609
+ for (const ptype of model.model.get(CasbinRuleVariants.P)?.keys() ?? []) {
610
+ for (const rule of await enforcer.getNamedPolicy(ptype)) lines.push([ptype, ...rule].join(', '));
611
+ }
612
+ for (const gtype of model.model.get(CasbinRuleVariants.G)?.keys() ?? []) {
613
+ for (const rule of await enforcer.getNamedGroupingPolicy(gtype)) lines.push([gtype, ...rule].join(', '));
614
+ }
615
+ return lines;
701
616
  ```
702
617
 
703
618
  #### loadPolicyLinesIntoModel()
704
619
 
705
- Clears the model and reloads from cached string lines:
620
+ Atomically resets a borrowed enforcer's model to exactly `lines`:
706
621
 
707
622
  ```typescript
708
- const { Helper } = await import('casbin');
709
- const model = this.enforcer.getModel();
623
+ const model = opts.enforcer.getModel();
710
624
  model.clearPolicy();
711
-
712
625
  for (const line of opts.lines) {
713
- Helper.loadPolicyLine(line, model);
626
+ this.helper.loadPolicyLine(line, model);
714
627
  }
715
-
716
- await this.enforcer.buildRoleLinks();
628
+ await opts.enforcer.buildRoleLinks();
717
629
  ```
718
630
 
719
631
  ## BaseFilteredAdapter
720
632
 
721
- Abstract read-only casbin `FilteredAdapter` using a template method pattern. Subclasses provide query hooks; the base orchestrates loading and provides shared formatters.
633
+ Thin read-only base for casbin `FilteredAdapter`s backed by a datasource. It owns the boilerplate
634
+ every filtered adapter repeats — datasource/connector plumbing, the `isFiltered() === true` flag, the
635
+ no-op write methods, and a `loadLines` helper. A subclass implements only `loadFilteredPolicy`: query
636
+ the store for ONE principal's policies and turn them into casbin lines.
722
637
 
723
638
  ### Class
724
639
 
725
640
  ```typescript
726
- abstract class BaseFilteredAdapter<
727
- TEntities extends IBaseFilteredAdapterEntities = IBaseFilteredAdapterEntities,
728
- TFilter = ICasbinPolicyFilter,
729
- TPolicyRow extends TBasePolicyRow = TBasePolicyRow,
730
- >
641
+ abstract class BaseFilteredAdapter<TFilter = ICasbinPolicyFilter>
731
642
  extends BaseHelper
732
643
  implements FilteredAdapter
733
644
  {
734
- protected readonly entities: TEntities;
645
+ protected readonly dataSource: IDataSource;
646
+ protected get connector(): TAnyConnector;
647
+
648
+ constructor(opts: { scope: string; dataSource: IDataSource });
735
649
 
736
- constructor(opts: { scope: string; entities: TEntities });
650
+ // Subclasses implement ONLY this:
651
+ abstract loadFilteredPolicy(model: Model, filter: TFilter): Promise<void>;
737
652
 
738
- // FilteredAdapter — public API
739
- async loadPolicy(): Promise<void>; // no-op
740
- async loadFilteredPolicy(model: Model, filter: TFilter): Promise<void>;
741
653
  isFiltered(): boolean; // always true
742
654
 
743
655
  // No-op write methods (read-only adapter)
744
- async savePolicy(): Promise<boolean>; // returns true
745
- async addPolicy(): Promise<void>; // no-op
746
- async removePolicy(): Promise<void>; // no-op
747
- async removeFilteredPolicy(): Promise<void>; // no-op
748
-
749
- // Abstract hooks — subclasses provide the data queries
750
- protected abstract buildDirectPolicies(opts): ValueOrPromise<string[]>;
751
- protected abstract buildGroupPolicies(opts): ValueOrPromise<{ lines: string[]; roleIds }>;
752
- protected abstract buildRolePolicies(opts): ValueOrPromise<string[]>;
753
-
754
- // Shared formatters
755
- protected formatDomain(domain: string | null): string | null;
756
- protected toGroupLine(opts): string;
757
- protected toPolicyLine(opts): string | null;
656
+ async loadPolicy(): Promise<void>;
657
+ async savePolicy(): Promise<boolean>; // returns true
658
+ async addPolicy(): Promise<void>;
659
+ async removePolicy(): Promise<void>;
660
+ async removeFilteredPolicy(): Promise<void>;
661
+
662
+ // Helper: parse + load casbin lines into a model.
663
+ protected async loadLines(opts: { model: Model; lines: string[] }): Promise<void>;
758
664
  }
759
665
  ```
760
666
 
761
667
  ### Generic Parameters
762
668
 
763
- | Parameter | Extends | Default | Description |
764
- |-----------|---------|---------|-------------|
765
- | `TEntities` | `IBaseFilteredAdapterEntities` | `IBaseFilteredAdapterEntities` | Entity configuration (subclass adds fields like `tableName`) |
766
- | `TFilter` | -- | `ICasbinPolicyFilter` | Filter shape passed to `loadFilteredPolicy` |
767
- | `TPolicyRow` | `TBasePolicyRow` | `TBasePolicyRow` | Policy row shape consumed by `toPolicyLine` |
768
-
769
- ### IBaseFilteredAdapterEntities
770
-
771
- ```typescript
772
- interface IBaseFilteredAdapterEntities {
773
- role: { principalType: string };
774
- domain?: { principalType: string };
775
- }
776
- ```
669
+ | Parameter | Default | Description |
670
+ |-----------|---------|-------------|
671
+ | `TFilter` | `ICasbinPolicyFilter` | Filter shape passed to `loadFilteredPolicy`. Subclasses may narrow it (e.g. `IScopedCasbinPolicyFilter`) |
777
672
 
778
673
  ### ICasbinPolicyFilter
779
674
 
675
+ The default filter: which principal's policies to load. Subclasses may narrow it.
676
+
780
677
  ```typescript
781
678
  interface ICasbinPolicyFilter {
782
- principalType: string;
783
- principalValue: string | number;
679
+ principal: { type: string; id: IdType };
784
680
  }
785
681
  ```
786
682
 
787
- ### TBasePolicyRow
683
+ ### loadLines()
788
684
 
789
- Declared as `type` (not `interface`) for Drizzle compatibility -- carries an implicit index signature required by `connector.execute<T>()`.
685
+ The base's only orchestration helper subclasses call it from `loadFilteredPolicy` after assembling
686
+ their casbin lines:
790
687
 
791
688
  ```typescript
792
- type TBasePolicyRow = {
793
- variant: string; // 'policy' or 'group'
794
- code: string; // permission/resource code
795
- action: string | null;
796
- subjectType: string;
797
- subjectId: string | number;
798
- effect: string | null;
799
- domain: string | null;
800
- };
801
- ```
802
-
803
- ### loadFilteredPolicy()
804
-
805
- Orchestrates three query phases using `casbin.Helper.loadPolicyLine()`:
806
-
807
- ```mermaid
808
- flowchart LR
809
- Start([loadFilteredPolicy]) --> D[buildDirectPolicies]
810
- D -->|p lines| Load1[Load into model]
811
- Load1 --> G[buildGroupPolicies]
812
- G -->|g lines + roleIds| Load2[Load into model]
813
- Load2 --> Check{roleIds empty?}
814
- Check -->|No| R[buildRolePolicies]
815
- R -->|p lines| Load3[Load into model]
816
- Check -->|Yes| Done([Done])
817
- Load3 --> Done
818
- ```
819
-
820
- ```
821
- 1. buildDirectPolicies({ filter, rolePrincipal })
822
- → Direct permissions assigned to the principal → casbin `p` lines
823
-
824
- 2. buildGroupPolicies({ filter })
825
- → Role assignments → casbin `g` lines + roleIds
826
-
827
- 3. buildRolePolicies({ roleIds, rolePrincipal })
828
- → Permissions inherited through roles → casbin `p` lines
829
- (only if roleIds is non-empty)
689
+ protected async loadLines(opts: { model: Model; lines: string[] }): Promise<void> {
690
+ const { Helper } = await import('casbin');
691
+ for (const line of opts.lines) {
692
+ Helper.loadPolicyLine(line, opts.model);
693
+ }
694
+ }
830
695
  ```
831
696
 
832
- ### Abstract Query Hooks
833
-
834
- | Hook | Input | Output | Description |
835
- |------|-------|--------|-------------|
836
- | `buildDirectPolicies` | `{ filter: TFilter, rolePrincipal: string }` | `string[]` | Direct permission `p` lines for the user |
837
- | `buildGroupPolicies` | `{ filter: TFilter }` | `{ lines: string[], roleIds: (string \| number)[] }` | Role assignment `g` lines + role IDs |
838
- | `buildRolePolicies` | `{ roleIds: (string \| number)[], rolePrincipal: string }` | `string[]` | Inherited permission `p` lines via roles |
697
+ There are no template-method query hooks or shared line formatters on the base — a subclass owns its
698
+ own queries and line construction (see `ScopedCasbinAdapter` below for the reference implementation).
839
699
 
840
- ### Shared Formatters
700
+ ## ScopedCasbinAdapter
841
701
 
842
- | Method | Input | Output | Description |
843
- |--------|-------|--------|-------------|
844
- | `formatDomain(domain)` | `string \| null` | `string \| null` | Prepends `entities.domain.principalType` prefix if configured (e.g., `"Organization_<uuid>"`). Returns `null` if input is null. |
845
- | `toGroupLine(opts)` | `{ subject, role, domain }` | `string` | Formats: <code v-pre>g, &lt;subject&gt;, &lt;role&gt;[, &lt;domain&gt;]</code> |
846
- | `toPolicyLine(opts)` | `{ row: TPolicyRow }` | `string \| null` | Formats: <code v-pre>p, &lt;subject&gt;, [&lt;domain&gt;,] &lt;resource&gt;, &lt;action&gt;, &lt;effect&gt;</code>. Returns `null` if row has no action. Effect defaults to `'allow'`. |
847
-
848
- ## DrizzleCasbinAdapter
849
-
850
- Concrete read-only `FilteredAdapter` using raw SQL queries via Drizzle's `connector.execute()`.
702
+ The generic, read-only `FilteredAdapter` for the scoped RBAC model. It reads **one principal's edges**
703
+ plus the **shared structural hierarchy** from a single `PolicyDefinition` edge table (joined to
704
+ `Permission` for codes) and emits casbin lines. No subclassing configure it with `IScopedCasbinEntities`.
851
705
 
852
706
  ### Class
853
707
 
854
708
  ```typescript
855
- class DrizzleCasbinAdapter extends BaseFilteredAdapter<IDrizzleCasbinEntities> {
856
- private connector: TAnyConnector;
857
-
858
- constructor(opts: IDrizzleCasbinAdapterOptions);
859
-
860
- protected async buildDirectPolicies(opts): Promise<string[]>;
861
- protected async buildGroupPolicies(opts): Promise<{ lines; roleIds }>;
862
- protected async buildRolePolicies(opts): Promise<string[]>;
709
+ class ScopedCasbinAdapter extends BaseFilteredAdapter<IScopedCasbinPolicyFilter> {
710
+ protected readonly entities: IScopedCasbinEntities;
711
+
712
+ constructor(opts: { dataSource: IDataSource; entities: IScopedCasbinEntities });
713
+
714
+ async loadFilteredPolicy(model: Model, filter: IScopedCasbinPolicyFilter): Promise<void>;
715
+
716
+ // Per-principal queries
717
+ protected queryRoleAssignments(opts): Promise<{ lines: string[]; roleIds: IdType[] }>; // → g
718
+ protected queryMemberships(opts): Promise<string[]>; // → g2
719
+ protected queryGrants(opts): Promise<string[]>; // → p
720
+ // Shared hierarchy
721
+ protected loadStructuralTrees(): Promise<string[]>; // role(g)/domain(g3)/resource(g4)/action(g5)
722
+ protected queryRoleInherits(): Promise<string[]>; // → g
723
+ protected queryDomainInherits(): Promise<string[]>; // → g3
724
+ protected queryResourceInherits(): Promise<string[]>; // → g4
725
+ protected queryActionInherits(): Promise<string[]>; // → g5
726
+ // Role closure (BFS over role_inherits)
727
+ protected expandRoleClosure(opts: { role: { ids: IdType[]; edges: string[] } }): IdType[];
863
728
  }
864
729
  ```
865
730
 
866
- ### IDrizzleCasbinEntities
731
+ ### IScopedCasbinEntities
867
732
 
868
733
  ```typescript
869
- interface IDrizzleCasbinEntities extends IBaseFilteredAdapterEntities {
870
- permission: { tableName: string; principalType: string };
871
- role: { tableName: string; principalType: string };
872
- policyDefinition: { tableName: string; principalType: string };
873
- domain?: { principalType: string };
734
+ interface IScopedCasbinTable { tableName: string; schemaName?: string; }
735
+
736
+ interface IScopedCasbinEntities {
737
+ policyDefinition: IScopedCasbinTable; // the single edge table
738
+ permission: IScopedCasbinTable; // permission catalog (id, code, ...)
739
+ principals: { user: string; role: string }; // casbin name prefixes
740
+ domainTypes: string[]; // e.g. ['Merchant', 'Organizer']
741
+ softDelete?: { use: false } | { use: true; columnName: string };
874
742
  }
875
743
  ```
876
744
 
877
- ### IDrizzleCasbinAdapterOptions
745
+ ### IScopedCasbinPolicyFilter
878
746
 
879
747
  ```typescript
880
- interface IDrizzleCasbinAdapterOptions {
881
- dataSource: IDataSource;
882
- entities: IDrizzleCasbinEntities;
748
+ interface IScopedCasbinPolicyFilter {
749
+ principal: { type: string; id: IdType };
883
750
  }
884
751
  ```
885
752
 
886
- ### SQL Queries
887
-
888
- All queries use the `sql` template tag from `drizzle-orm` and filter by `variant` using `CasbinRuleVariants.POLICY` or `CasbinRuleVariants.GROUP` constants.
889
-
890
- **buildDirectPolicies** -- direct permissions assigned to the user:
891
- ```sql
892
- SELECT pd.variant, p.code, pd.action,
893
- pd.subject_type AS "subjectType", pd.subject_id AS "subjectId",
894
- pd.effect, pd.domain
895
- FROM {policyDefinition.tableName} pd
896
- INNER JOIN {permission.tableName} p ON pd.target_id = p.id
897
- WHERE pd.variant = 'policy'
898
- AND pd.subject_type = :principalType
899
- AND pd.subject_id = :principalValue
900
- AND pd.target_type = :permission.principalType
901
- ```
753
+ ### loadFilteredPolicy() — two waves
902
754
 
903
- **buildGroupPolicies** -- role assignments for the user:
904
- ```sql
905
- SELECT pd.target_id AS "targetId", pd.domain
906
- FROM {policyDefinition.tableName} pd
907
- WHERE pd.variant = 'group'
908
- AND pd.subject_type = :principalType
909
- AND pd.subject_id = :principalValue
910
- AND pd.target_type = :role.principalType
755
+ ```mermaid
756
+ flowchart TD
757
+ Start([loadFilteredPolicy]) --> W1["Wave 1 (parallel): queryRoleAssignments (g) ·
758
+ queryMemberships (g2) · queryGrants[user] (p) · loadStructuralTrees (g/g3/g4/g5)"]
759
+ W1 --> Closure["expandRoleClosure(assigned roleIds, role_inherits edges)"]
760
+ Closure --> W2["Wave 2: queryGrants[roleClosure] (p)"]
761
+ W2 --> Load["loadLines(model, all lines)"]
911
762
  ```
912
763
 
913
- **buildRolePolicies** -- permissions inherited through assigned roles:
914
- ```sql
915
- SELECT pd.variant, p.code, pd.action,
916
- pd.subject_type AS "subjectType", pd.subject_id AS "subjectId",
917
- pd.effect, pd.domain
918
- FROM {policyDefinition.tableName} pd
919
- INNER JOIN {permission.tableName} p ON pd.target_id = p.id
920
- WHERE pd.variant = 'policy'
921
- AND pd.subject_type = :role.principalType
922
- AND pd.subject_id IN (:roleIds)
923
- AND pd.target_type = :permission.principalType
924
- ```
764
+ 1. **Wave 1 (parallel):** the principal's own edges — role assignments (`g`), domain memberships
765
+ (`g2`), direct grants (`p`) — plus the shared structural trees (`role_inherits` → `g`,
766
+ `domain_inherits` `g3`, `resource_inherits` → `g4`, `action_inherits` → `g5`).
767
+ 2. **Role closure:** `expandRoleClosure` does a cycle-safe BFS over the `role_inherits` (`g`) edges to
768
+ collect the assigned roles + all transitive parents.
769
+ 3. **Wave 2:** fetch the grants (`p`) of every role in the closure, so a user inherits the permissions
770
+ of parent roles.
771
+ 4. All lines are loaded via `loadLines`.
772
+
773
+ ### SQL notes
774
+
775
+ All queries use the `sql` template tag from `drizzle-orm`. Tables are schema-qualified via
776
+ `sql.identifier` (injection-safe); interpolated values (the `variant` discriminator from
777
+ `AuthorizationPolicyVariants.*.action`, ids, types) are bound parameters. The soft-delete clause
778
+ (`AND <alias>.<col> IS NULL`) is appended when `entities.softDelete.use` is true. `queryGrants`
779
+ short-circuits to `[]` when given no subject ids (no DB round-trip).
925
780
 
926
781
  ### Usage Example
927
782
 
928
783
  ```typescript
929
- import { DrizzleCasbinAdapter } from '@venizia/ignis';
784
+ import { ScopedCasbinAdapter } from '@venizia/ignis';
930
785
 
931
- const adapter = new DrizzleCasbinAdapter({
786
+ const adapter = new ScopedCasbinAdapter({
932
787
  dataSource: myPostgresDataSource,
933
788
  entities: {
934
- permission: { tableName: 'Permission', principalType: 'Permission' },
935
- role: { tableName: 'Role', principalType: 'Role' },
936
- policyDefinition: { tableName: 'PolicyDefinition', principalType: 'PolicyDefinition' },
937
- domain: { principalType: 'Organization' },
789
+ policyDefinition: { tableName: 'PolicyDefinition', schemaName: 'identity' },
790
+ permission: { tableName: 'Permission', schemaName: 'identity' },
791
+ principals: { user: 'User', role: 'Role' },
792
+ domainTypes: ['Merchant', 'Organizer'],
793
+ softDelete: { use: true, columnName: 'deleted_at' },
938
794
  },
939
795
  });
940
796
  ```
@@ -993,6 +849,12 @@ if (!registry.hasEnforcers()) → next() // skip if no enforcers registered
993
849
  const resolvedName = enforcerName ?? registry.getDefaultEnforcerName();
994
850
  const enforcer = await registry.resolveEnforcer({ name: resolvedName });
995
851
 
852
+ // Step 5b: Resolve request domain scope (only when domain scoping is in play)
853
+ if (spec.domain || options?.domainResolver) {
854
+ const domainScope = await resolveRequestDomain({ spec, context, options }); // "<Type>_<id>" | SYSTEM_WIDE
855
+ context.set(Authorization.DOMAIN, domainScope); // the enforcer reads this for request.domain
856
+ }
857
+
996
858
  // Step 6: Build/cache rules
997
859
  let rules = context.get(Authorization.RULES);
998
860
  if (!rules) {
@@ -1249,6 +1111,7 @@ declare module 'hono' {
1249
1111
  // Authorization
1250
1112
  [Authorization.RULES]: unknown; // 'authorization.rules'
1251
1113
  [Authorization.SKIP_AUTHORIZATION]: boolean; // 'authorization.skip'
1114
+ [Authorization.DOMAIN]: string; // 'authorization.domain'
1252
1115
  }
1253
1116
  }
1254
1117
  ```