@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.
- package/dist/mcp-server/helpers/docs.helper.d.ts.map +1 -1
- package/dist/mcp-server/helpers/docs.helper.js +1 -1
- package/dist/mcp-server/helpers/docs.helper.js.map +1 -1
- package/dist/mcp-server/tools/base.tool.d.ts +1 -1
- package/dist/mcp-server/tools/docs/search-documents.tool.d.ts +1 -1
- package/dist/mcp-server/tools/docs/search-documents.tool.js +1 -1
- package/dist/mcp-server/tools/docs/search-documents.tool.js.map +1 -1
- package/dist/mcp-server/tools/github/list-project-files.tool.d.ts +1 -1
- package/dist/mcp-server/tools/github/list-project-files.tool.js +1 -1
- package/dist/mcp-server/tools/github/list-project-files.tool.js.map +1 -1
- package/dist/mcp-server/tools/github/search-code.tool.d.ts +1 -1
- package/dist/mcp-server/tools/github/search-code.tool.js +1 -1
- package/dist/mcp-server/tools/github/search-code.tool.js.map +1 -1
- package/package.json +14 -14
- package/wiki/extensions/components/authorization/api.md +239 -376
- package/wiki/extensions/components/authorization/errors.md +52 -43
- package/wiki/extensions/components/authorization/index.md +127 -65
- package/wiki/extensions/components/authorization/usage.md +198 -98
- package/wiki/extensions/helpers/kafka/consumer.md +6 -5
- package/wiki/extensions/helpers/kafka/examples.md +1 -1
- package/wiki/extensions/helpers/kafka/index.md +16 -12
- package/wiki/extensions/helpers/kafka/producer.md +4 -3
- package/wiki/guides/core-concepts/persistent/datasources.md +10 -11
- package/wiki/guides/core-concepts/persistent/index.md +6 -6
- package/wiki/guides/core-concepts/persistent/models.md +7 -5
- package/wiki/guides/core-concepts/persistent/repositories.md +11 -3
- package/wiki/guides/core-concepts/persistent/transactions.md +2 -1
- package/wiki/guides/core-concepts/rest-controllers.md +2 -2
- package/wiki/guides/core-concepts/services.md +0 -1
- package/wiki/guides/get-started/5-minute-quickstart.md +11 -10
- package/wiki/guides/migrations/scoped-rbac-migration.md +300 -0
- package/wiki/guides/tutorials/building-a-crud-api.md +43 -37
- package/wiki/guides/tutorials/complete-installation.md +64 -44
- package/wiki/guides/tutorials/ecommerce-api.md +21 -12
- package/wiki/guides/tutorials/realtime-chat.md +4 -5
- package/wiki/references/base/filter-system/default-filter.md +6 -3
- package/wiki/references/base/filter-system/fields-order-pagination.md +26 -0
- package/wiki/references/base/models.md +6 -3
- package/wiki/references/base/repositories/advanced.md +111 -0
|
@@ -485,6 +485,7 @@ import { Authorization, Authentication } from '@venizia/ignis';
|
|
|
485
485
|
const user = c.get(Authentication.CURRENT_USER); // IAuthUser
|
|
486
486
|
const rules = c.get(Authorization.RULES); // unknown (type depends on enforcer)
|
|
487
487
|
const isSkipped = c.get(Authorization.SKIP_AUTHORIZATION); // boolean
|
|
488
|
+
const domain = c.get(Authorization.DOMAIN); // string ("<Type>_<id>" | "SYSTEM_WIDE"), set when domain scoping is in play
|
|
488
489
|
|
|
489
490
|
// Set skip dynamically
|
|
490
491
|
c.set(Authorization.SKIP_AUTHORIZATION, true);
|
|
@@ -493,66 +494,6 @@ c.set(Authorization.SKIP_AUTHORIZATION, true);
|
|
|
493
494
|
c.set(Authorization.RULES, null);
|
|
494
495
|
```
|
|
495
496
|
|
|
496
|
-
## Using IAuthorizationComparable
|
|
497
|
-
|
|
498
|
-
For custom action/resource comparison logic beyond plain string equality, implement `IAuthorizationComparable`.
|
|
499
|
-
|
|
500
|
-
### StringAuthorizationAction with Wildcard
|
|
501
|
-
|
|
502
|
-
The built-in `StringAuthorizationAction` supports a wildcard (`*`) that matches any action:
|
|
503
|
-
|
|
504
|
-
```typescript
|
|
505
|
-
import { StringAuthorizationAction } from '@venizia/ignis';
|
|
506
|
-
|
|
507
|
-
const wildcard = StringAuthorizationAction.build({ value: '*' });
|
|
508
|
-
wildcard.isEqual('read'); // true — wildcard matches all
|
|
509
|
-
wildcard.isEqual('delete'); // true — wildcard matches all
|
|
510
|
-
wildcard.isEqual('create'); // true — wildcard matches all
|
|
511
|
-
|
|
512
|
-
const readOnly = StringAuthorizationAction.build({ value: 'read' });
|
|
513
|
-
readOnly.isEqual('read'); // true
|
|
514
|
-
readOnly.isEqual('update'); // false
|
|
515
|
-
```
|
|
516
|
-
|
|
517
|
-
### StringAuthorizationResource
|
|
518
|
-
|
|
519
|
-
Standard string comparison for resources (no wildcard):
|
|
520
|
-
|
|
521
|
-
```typescript
|
|
522
|
-
import { StringAuthorizationResource } from '@venizia/ignis';
|
|
523
|
-
|
|
524
|
-
const article = StringAuthorizationResource.build({ value: 'Article' });
|
|
525
|
-
article.isEqual('Article'); // true
|
|
526
|
-
article.isEqual('User'); // false
|
|
527
|
-
```
|
|
528
|
-
|
|
529
|
-
### Custom Comparable Implementation
|
|
530
|
-
|
|
531
|
-
Create your own comparable type for advanced matching:
|
|
532
|
-
|
|
533
|
-
```typescript
|
|
534
|
-
import type { IAuthorizationComparable } from '@venizia/ignis';
|
|
535
|
-
|
|
536
|
-
class HierarchicalResource implements IAuthorizationComparable<string> {
|
|
537
|
-
readonly value: string;
|
|
538
|
-
|
|
539
|
-
constructor(opts: { value: string }) {
|
|
540
|
-
this.value = opts.value;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
compare(other: string): number {
|
|
544
|
-
// Match if the other resource starts with this resource's value
|
|
545
|
-
// e.g., 'articles' matches 'articles.comments'
|
|
546
|
-
if (other.startsWith(this.value)) return 0;
|
|
547
|
-
return this.value.localeCompare(other);
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
isEqual(other: string): boolean {
|
|
551
|
-
return this.compare(other) === 0;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
```
|
|
555
|
-
|
|
556
497
|
## Custom Enforcer
|
|
557
498
|
|
|
558
499
|
Create a custom enforcer by implementing `IAuthorizationEnforcer`:
|
|
@@ -645,60 +586,50 @@ AuthorizationEnforcerRegistry.getInstance().register({
|
|
|
645
586
|
|
|
646
587
|
## Custom Filtered Adapter
|
|
647
588
|
|
|
648
|
-
|
|
589
|
+
For most apps, use the ready-made [`ScopedCasbinAdapter`](./api#scopedcasbinadapter) — it reads a single
|
|
590
|
+
edge table and needs no subclassing. Write a custom adapter only when your storage model differs.
|
|
591
|
+
|
|
592
|
+
`BaseFilteredAdapter` is now thin: it provides the datasource/connector plumbing, the `isFiltered()`
|
|
593
|
+
flag, no-op write methods, and a `loadLines` helper. A subclass implements **only** `loadFilteredPolicy`
|
|
594
|
+
— query your store for ONE principal's policies and turn them into casbin lines.
|
|
649
595
|
|
|
650
596
|
```typescript
|
|
651
597
|
import {
|
|
652
598
|
BaseFilteredAdapter,
|
|
653
|
-
IBaseFilteredAdapterEntities,
|
|
654
599
|
ICasbinPolicyFilter,
|
|
655
|
-
|
|
600
|
+
type IDataSource,
|
|
656
601
|
} from '@venizia/ignis';
|
|
602
|
+
import type { Model } from 'casbin';
|
|
657
603
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
class MyCustomAdapter extends BaseFilteredAdapter<MyEntities> {
|
|
665
|
-
constructor(opts: { entities: MyEntities; /* your dependencies */ }) {
|
|
666
|
-
super({ scope: MyCustomAdapter.name, entities: opts.entities });
|
|
604
|
+
// Narrow the filter if you like, or use the default ICasbinPolicyFilter ({ principal: { type, id } }).
|
|
605
|
+
class MyCustomAdapter extends BaseFilteredAdapter<ICasbinPolicyFilter> {
|
|
606
|
+
constructor(opts: { dataSource: IDataSource }) {
|
|
607
|
+
super({ scope: MyCustomAdapter.name, dataSource: opts.dataSource });
|
|
667
608
|
}
|
|
668
609
|
|
|
669
|
-
|
|
670
|
-
filter
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
}
|
|
610
|
+
async loadFilteredPolicy(model: Model, filter: ICasbinPolicyFilter): Promise<void> {
|
|
611
|
+
const { type, id } = filter.principal;
|
|
612
|
+
|
|
613
|
+
// 1. Query your store for THIS principal's policies (this.connector is provided by the base).
|
|
614
|
+
// 2. Build casbin lines as plain strings, e.g.:
|
|
615
|
+
// `p, User_${id}, Order, read, allow`
|
|
616
|
+
// `g, User_${id}, Role_42, *`
|
|
617
|
+
const lines: string[] = await this.buildLinesFor({ type, id });
|
|
678
618
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
}): Promise<{ lines: string[]; roleIds: (string | number)[] }> {
|
|
682
|
-
// Query role assignments for the user
|
|
683
|
-
// Return casbin `g` lines using this.toGroupLine() + role IDs
|
|
684
|
-
return { lines: [...], roleIds: [...] };
|
|
619
|
+
// 3. Load them into the model with the base helper.
|
|
620
|
+
await this.loadLines({ model, lines });
|
|
685
621
|
}
|
|
686
622
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
}): Promise<string[]> {
|
|
691
|
-
// Query permission policies inherited through roles
|
|
692
|
-
// Return casbin `p` lines using this.toPolicyLine()
|
|
693
|
-
return [...];
|
|
623
|
+
private async buildLinesFor(principal: { type: string; id: unknown }): Promise<string[]> {
|
|
624
|
+
// ...your queries via this.connector...
|
|
625
|
+
return [];
|
|
694
626
|
}
|
|
695
627
|
}
|
|
696
628
|
```
|
|
697
629
|
|
|
698
|
-
The base
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
- `this.toPolicyLine({ row })` -- formats `p` lines
|
|
630
|
+
The base no longer ships template-method hooks (`buildDirectPolicies`/`buildGroupPolicies`/…) or line
|
|
631
|
+
formatters — you own line construction. See `ScopedCasbinAdapter` for a full reference implementation
|
|
632
|
+
(role closure, structural trees, soft-delete, schema-qualified SQL).
|
|
702
633
|
|
|
703
634
|
## AuthorizationRole Comparison
|
|
704
635
|
|
|
@@ -789,6 +720,175 @@ const settings = registry.getAuthorizeModelSettings({ format: 'array' });
|
|
|
789
720
|
> [!TIP]
|
|
790
721
|
> Defining `authorize.principal` on the model makes the model the single source of truth for its authorization subject. This eliminates string duplication across route configs and policy setup.
|
|
791
722
|
|
|
723
|
+
## RBAC with Domains (Multi-Tenant)
|
|
724
|
+
|
|
725
|
+
For multi-tenant apps where a user holds roles **scoped to specific tenants** (and sometimes globally), use Casbin's [RBAC with domains](https://casbin.apache.org/docs/rbac-with-domains/) model and register a **domain matching function** via `domainMatching`. This lets the domain slot of a grouping (`g`) policy use a wildcard, so a global role is a single line (`g, user, role, *`) and role permissions stay domain-agnostic (`p, role, *, …`).
|
|
726
|
+
|
|
727
|
+
> [!TIP]
|
|
728
|
+
> Why this matters: putting the tenant on the membership (`g`) and keeping permissions wildcard (`p.dom = "*"`) keeps a user's materialized policy count **linear** (`memberships + permissions`) instead of the `permissions × tenants` cross-product. For a user with 30 tenants and 700 permissions that is ~730 lines instead of ~21,000.
|
|
729
|
+
|
|
730
|
+
There are two ways to do domain scoping:
|
|
731
|
+
|
|
732
|
+
- **Scoped model (recommended)** — set `isScoped: true` + use `ScopedCasbinAdapter` + the built-in
|
|
733
|
+
`CASBIN_RBAC_DOMAIN_SCOPED_MODEL`, and supply the request domain **per route** via `spec.domain` (or a
|
|
734
|
+
global `domainResolver`). The enforcer registers the matchers for you; you do not write `domainMatching`
|
|
735
|
+
or `normalizePayloadFn`.
|
|
736
|
+
- **Manual flat model (lower-level)** — keep a flat `g + p` model and register `domainMatching` +
|
|
737
|
+
`normalizePayloadFn` yourself. Documented below under [The model](#the-model).
|
|
738
|
+
|
|
739
|
+
### Scoped model + per-route domain (recommended)
|
|
740
|
+
|
|
741
|
+
Register the scoped enforcer (see [Setup](./#step-3-register-enforcers-via-registry)) with `isScoped: true`,
|
|
742
|
+
then tell each route where to read its domain from. `IAuthorizationSpec.domain` accepts either a
|
|
743
|
+
**declarative source** or a **resolver function**:
|
|
744
|
+
|
|
745
|
+
```typescript
|
|
746
|
+
import type { IAuthorizationDomainSource, TAuthorizationDomainResolver } from '@venizia/ignis';
|
|
747
|
+
|
|
748
|
+
// (a) Declarative — read the domain id from a request param/header/query/context var:
|
|
749
|
+
authorize({
|
|
750
|
+
spec: {
|
|
751
|
+
action: 'read',
|
|
752
|
+
resource: 'Order',
|
|
753
|
+
domain: { from: 'param', key: 'merchantId', type: 'Merchant' }, // → "Merchant_<param>"
|
|
754
|
+
},
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
// (b) Resolver — compute { type, id } yourself (return null → SYSTEM_WIDE):
|
|
758
|
+
authorize({
|
|
759
|
+
spec: {
|
|
760
|
+
action: 'read',
|
|
761
|
+
resource: 'Order',
|
|
762
|
+
domain: ({ context }) => {
|
|
763
|
+
const merchantId = resolveActiveMerchant({ context });
|
|
764
|
+
return merchantId ? { type: 'Merchant', id: merchantId } : null;
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
Precedence (see `resolveRequestDomain`): `spec.domain` (resolver → declarative) → the global
|
|
771
|
+
`IAuthorizeOptions.domainResolver` → `SYSTEM_WIDE`. The resolved value is stashed on
|
|
772
|
+
`Authorization.DOMAIN` and passed to the enforcer as `request.domain`. A route with no domain at all
|
|
773
|
+
enforces `SYSTEM_WIDE` (super-admin scope) in scoped mode.
|
|
774
|
+
|
|
775
|
+
For a **global fallback** (apply the same resolver to every route that doesn't set `spec.domain`):
|
|
776
|
+
|
|
777
|
+
```typescript
|
|
778
|
+
this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
|
|
779
|
+
defaultDecision: 'deny',
|
|
780
|
+
domainResolver: ({ context }) => {
|
|
781
|
+
const id = resolveActiveMerchant({ context });
|
|
782
|
+
return id ? { type: 'Merchant', id } : null;
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
### The model
|
|
788
|
+
|
|
789
|
+
```ini
|
|
790
|
+
[request_definition]
|
|
791
|
+
r = sub, dom, obj, act
|
|
792
|
+
|
|
793
|
+
[policy_definition]
|
|
794
|
+
p = sub, dom, obj, act, eft
|
|
795
|
+
|
|
796
|
+
[role_definition]
|
|
797
|
+
g = _, _, _
|
|
798
|
+
|
|
799
|
+
[policy_effect]
|
|
800
|
+
e = some(where (p.eft == allow)) && !some(where (p.eft == deny))
|
|
801
|
+
|
|
802
|
+
[matchers]
|
|
803
|
+
m = g(r.sub, p.sub, r.dom) && keyMatch(r.dom, p.dom) && r.obj == p.obj && r.act == p.act
|
|
804
|
+
```
|
|
805
|
+
|
|
806
|
+
- `g = _, _, _` — the membership relation is **domain-aware** (subject, role, domain).
|
|
807
|
+
- `g(r.sub, p.sub, r.dom)` — the registered domain matching function decides whether the request domain matches the stored membership domain (this is what makes `*` a wildcard).
|
|
808
|
+
- `keyMatch(r.dom, p.dom)` — `keyMatch` is a **built-in matcher function** (no registration needed); it lets a permission with `p.dom = "*"` match any request domain.
|
|
809
|
+
|
|
810
|
+
### Registering the domain matching function
|
|
811
|
+
|
|
812
|
+
Pass `domainMatching` in the enforcer options. It is registered once during `configure()`:
|
|
813
|
+
|
|
814
|
+
```typescript
|
|
815
|
+
import {
|
|
816
|
+
AuthorizationEnforcerRegistry,
|
|
817
|
+
AuthorizationEnforcerTypes,
|
|
818
|
+
CasbinAuthorizationEnforcer,
|
|
819
|
+
CasbinDomainMatchingFunctions,
|
|
820
|
+
CasbinEnforcerModelDrivers,
|
|
821
|
+
type ICasbinEnforcerOptions,
|
|
822
|
+
} from '@venizia/ignis';
|
|
823
|
+
|
|
824
|
+
AuthorizationEnforcerRegistry.getInstance().register({
|
|
825
|
+
container: this,
|
|
826
|
+
enforcers: [
|
|
827
|
+
{
|
|
828
|
+
enforcer: CasbinAuthorizationEnforcer,
|
|
829
|
+
name: 'casbin',
|
|
830
|
+
type: AuthorizationEnforcerTypes.CASBIN,
|
|
831
|
+
options: {
|
|
832
|
+
model: { driver: CasbinEnforcerModelDrivers.TEXT, definition: CASBIN_RBAC_MODEL },
|
|
833
|
+
adapter,
|
|
834
|
+
cached,
|
|
835
|
+
// For a domain model, normalizePayloadFn MUST always return a `domain`.
|
|
836
|
+
normalizePayloadFn: ({ user, action, resource, context }) => ({
|
|
837
|
+
subject: `User_${user.userId}`,
|
|
838
|
+
domain: `Merchant_${resolveActiveMerchant({ context })}`,
|
|
839
|
+
resource,
|
|
840
|
+
action,
|
|
841
|
+
}),
|
|
842
|
+
// Register keyMatch on the `g` role definition so wildcard domains work:
|
|
843
|
+
domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH },
|
|
844
|
+
} satisfies ICasbinEnforcerOptions,
|
|
845
|
+
},
|
|
846
|
+
],
|
|
847
|
+
});
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
> [!NOTE]
|
|
851
|
+
> `domainMatching` is opt-in. When omitted, domains are compared as exact strings and behavior is unchanged. The enforcer calls Casbin's `addNamedDomainMatchingFunc(roleDefinition, Util.keyMatchFunc)` internally — you never call it directly.
|
|
852
|
+
|
|
853
|
+
### Choosing the matching function
|
|
854
|
+
|
|
855
|
+
`keyMatch` is the safe default for opaque domain identifiers like `Merchant_<uuid>`: it treats only `*` as special and never splits on `/` or `:`, so it cannot accidentally match one tenant against another. Use the others only if your domains are structured paths.
|
|
856
|
+
|
|
857
|
+
```typescript
|
|
858
|
+
domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH }; // * wildcard (recommended)
|
|
859
|
+
domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH_2 }; // /tenants/:id
|
|
860
|
+
domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.KEY_MATCH_3 }; // /tenants/{id}
|
|
861
|
+
domainMatching: { roleDefinition: 'g', fn: CasbinDomainMatchingFunctions.REGEX_MATCH }; // ^Merchant_.*$
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
### All cases — policy lines and outcomes
|
|
865
|
+
|
|
866
|
+
Given the model above with `keyMatch` registered on `g`, the request is `enforceSync(subject, domain, resource, action)`:
|
|
867
|
+
|
|
868
|
+
| Case | Policy lines | Request | Outcome |
|
|
869
|
+
|------|--------------|---------|---------|
|
|
870
|
+
| **Scoped role** (owner/employee in a tenant) | `g, User_u, Role_owner, Merchant_A`<br/>`p, Role_owner, *, Material.find, read, allow` | `(User_u, Merchant_A, Material.find, read)` | ✅ allow |
|
|
871
|
+
| **Scoped role — isolation** | (same as above) | `(User_u, Merchant_B, Material.find, read)` | ❌ deny (`g` domain doesn't match) |
|
|
872
|
+
| **Multi-tenant role** | `g, User_u, Role_owner, Merchant_A`<br/>`g, User_u, Role_owner, Merchant_B`<br/>`p, Role_owner, *, Material.find, read, allow` | `(User_u, Merchant_B, Material.find, read)` | ✅ allow (one `g` line per owned tenant; **single** `p` line) |
|
|
873
|
+
| **Global role** (e.g. guest/onboarding) | `g, User_u, Role_guest, *`<br/>`p, Role_guest, *, Organizer.onBoarding, create, allow` | `(User_u, Merchant_anything, Organizer.onBoarding, create)` | ✅ allow (wildcard `g` domain) |
|
|
874
|
+
| **Direct user permission (scoped)** | `p, User_u, Merchant_A, Report.read, read, allow` | `(User_u, Merchant_A, Report.read, read)` | ✅ allow (reflexive `g(u,u,dom)` + `keyMatch`) |
|
|
875
|
+
| **Direct user permission — isolation** | (same as above) | `(User_u, Merchant_B, Report.read, read)` | ❌ deny |
|
|
876
|
+
| **Deny override** | `p, Role_x, *, Secret.read, read, deny`<br/>`p, Role_y, *, Secret.read, read, allow` | any domain where the user has both roles | ❌ deny (`!some(p.eft == deny)`) |
|
|
877
|
+
|
|
878
|
+
> [!IMPORTANT]
|
|
879
|
+
> The function is applied as `fn(requestDomain, policyDomain)` — the wildcard belongs on the **stored** side. Store only `*` or exact domain values (never `Merchant_*`) to keep isolation guaranteed.
|
|
880
|
+
|
|
881
|
+
### Misconfiguration is caught early
|
|
882
|
+
|
|
883
|
+
If `roleDefinition` is not declared under `[role_definition]` in the model, `configure()` throws — Casbin would otherwise register the function as a silent no-op, leaving wildcard domains permanently unmatched (global roles silently denied):
|
|
884
|
+
|
|
885
|
+
```typescript
|
|
886
|
+
// model declares `g` only
|
|
887
|
+
domainMatching: { roleDefinition: 'g2', fn: CasbinDomainMatchingFunctions.KEY_MATCH };
|
|
888
|
+
// => throws: Role definition "g2" is not declared in the Casbin model. Declare it under
|
|
889
|
+
// [role_definition] (e.g. `g = _, _, _`) before enabling domainMatching.
|
|
890
|
+
```
|
|
891
|
+
|
|
792
892
|
## See Also
|
|
793
893
|
|
|
794
894
|
- [Setup & Configuration](./) -- Binding keys, options interfaces, and initial setup
|
|
@@ -21,9 +21,10 @@ class KafkaConsumerHelper<
|
|
|
21
21
|
| `start(opts)` | `(opts: IKafkaConsumeStartOptions): Promise<void>` | Start consuming (creates stream, wires callbacks) |
|
|
22
22
|
| `startLagMonitoring(opts)` | `(opts: { topics: string[]; interval?: number }): void` | Start periodic lag monitoring |
|
|
23
23
|
| `stopLagMonitoring()` | `(): void` | Stop lag monitoring |
|
|
24
|
-
| `isHealthy()` | `(): boolean` | `true` when broker connected |
|
|
24
|
+
| `isHealthy()` | `(): boolean` | `true` when at least one broker connected |
|
|
25
25
|
| `isReady()` | `(): boolean` | `isHealthy()` **and** `consumer.isActive()` |
|
|
26
26
|
| `getHealthStatus()` | `(): TKafkaHealthStatus` | `'connected'` \| `'disconnected'` \| `'unknown'` |
|
|
27
|
+
| `getConnectedBrokerCount()` | `(): number` | Number of currently connected brokers |
|
|
27
28
|
| `close(opts?)` | `(opts?: { isForce?: boolean }): Promise<void>` | Stop lag, close stream, close consumer |
|
|
28
29
|
|
|
29
30
|
## IKafkaConsumerOptions
|
|
@@ -41,8 +42,8 @@ interface IKafkaConsumerOptions<KeyType, ValueType, HeaderKeyType, HeaderValueTy
|
|
|
41
42
|
| `identifier` | `string` | `'kafka-consumer'` | Scoped logging identifier |
|
|
42
43
|
| `deserializers` | `Partial<Deserializers<K,V,HK,HV>>` | -- | Key/value/header deserializers |
|
|
43
44
|
| `autocommit` | `boolean \| number` | `false` | Auto-commit offsets. `true` = default interval, `number` = custom ms |
|
|
44
|
-
| `sessionTimeout` | `number` | `
|
|
45
|
-
| `heartbeatInterval` | `number` | `
|
|
45
|
+
| `sessionTimeout` | `number` | `60000` | Session timeout -- consumer removed from group if no heartbeat |
|
|
46
|
+
| `heartbeatInterval` | `number` | `10000` | Heartbeat interval -- must be less than `sessionTimeout` |
|
|
46
47
|
| `rebalanceTimeout` | `number` | `sessionTimeout` | Max time for rebalance. Defaults to the value of `sessionTimeout` |
|
|
47
48
|
| `highWaterMark` | `number` | `1024` | Stream buffer size (messages) |
|
|
48
49
|
| `minBytes` | `number` | `1` | Min bytes per fetch response |
|
|
@@ -141,8 +142,8 @@ await helper.start({ topics: ['orders'] });
|
|
|
141
142
|
helper.startLagMonitoring({ topics: ['orders'], interval: 10_000 });
|
|
142
143
|
|
|
143
144
|
// Health check
|
|
144
|
-
helper.isHealthy(); // true when broker connected
|
|
145
|
-
helper.isReady(); // true when broker connected AND consumer is active
|
|
145
|
+
helper.isHealthy(); // true when at least one broker connected
|
|
146
|
+
helper.isReady(); // true when at least one broker connected AND consumer is active
|
|
146
147
|
|
|
147
148
|
// Shutdown
|
|
148
149
|
await helper.close();
|
|
@@ -318,7 +318,7 @@ export class OrderEventService {
|
|
|
318
318
|
| `Failed to deserialize a message` | Mismatch between serializer and deserializer | Ensure matching serde. For old data, use a new consumer group or recreate topic |
|
|
319
319
|
| `JSON.stringify cannot serialize BigInt` | `message.offset` and `message.timestamp` are `bigint` | Use custom replacer: `(_k, v) => typeof v === 'bigint' ? v.toString() : v` |
|
|
320
320
|
| Consumer idle (no messages) | More consumers than partitions | Ensure `numPartitions >= numConsumers` |
|
|
321
|
-
| `isHealthy()` returns `false` |
|
|
321
|
+
| `isHealthy()` returns `false` | All brokers disconnected (a single idle disconnect won't trigger this) | Check broker addresses, SASL config, network connectivity. Use `getConnectedBrokerCount()` for details |
|
|
322
322
|
| `isReady()` returns `false` (consumer) | Consumer not active -- `start()` not called or stream closed | Call `await helper.start({ topics })` before checking readiness |
|
|
323
323
|
| Graceful shutdown timeout | In-flight requests taking too long | Increase `shutdownTimeout` or use `close({ isForce: true })` |
|
|
324
324
|
|
|
@@ -16,9 +16,9 @@ The Kafka module provides four helper classes built on a shared `BaseKafkaHelper
|
|
|
16
16
|
All helpers (except schema registry) extend `BaseKafkaHelper` which provides:
|
|
17
17
|
|
|
18
18
|
- **Scoped logging** via `BaseHelper` (Winston with daily rotation)
|
|
19
|
-
- **Health tracking** -- `isHealthy()`, `isReady()`, `getHealthStatus()`
|
|
19
|
+
- **Health tracking** -- per-broker connection tracking via `isHealthy()`, `isReady()`, `getHealthStatus()`, `getConnectedBrokerCount()`
|
|
20
20
|
- **Broker event callbacks** -- `onBrokerConnect`, `onBrokerDisconnect`
|
|
21
|
-
- **Broker failure tracking** -- automatic `configureBrokerFailed()` sets status to `'disconnected'`
|
|
21
|
+
- **Broker failure tracking** -- automatic `configureBrokerFailed()` sets status to `'disconnected'` only when all brokers are gone
|
|
22
22
|
- **Graceful shutdown** -- timeout-based with force fallback
|
|
23
23
|
- **Sensible defaults** via `KafkaDefaults` constants
|
|
24
24
|
- **Factory pattern** via `newInstance()` static method
|
|
@@ -115,21 +115,25 @@ All Kafka helpers (except schema registry) extend `BaseKafkaHelper<TClient>`, wh
|
|
|
115
115
|
```typescript
|
|
116
116
|
abstract class BaseKafkaHelper<TClient extends Base<BaseOptions>> extends BaseHelper {
|
|
117
117
|
// Health
|
|
118
|
-
isHealthy(): boolean;
|
|
119
|
-
isReady(): boolean;
|
|
120
|
-
getHealthStatus(): TKafkaHealthStatus;
|
|
118
|
+
isHealthy(): boolean; // true when at least one broker is connected
|
|
119
|
+
isReady(): boolean; // healthStatus === 'connected' (consumer overrides: + isActive())
|
|
120
|
+
getHealthStatus(): TKafkaHealthStatus; // 'connected' | 'disconnected' | 'unknown'
|
|
121
|
+
getConnectedBrokerCount(): number; // number of currently connected brokers
|
|
121
122
|
|
|
122
123
|
// Shutdown (used by subclasses)
|
|
123
124
|
protected closeClient(): Promise<void>;
|
|
124
125
|
protected gracefulCloseClient(): Promise<void>; // races closeClient vs shutdownTimeout
|
|
126
|
+
protected resetHealthState(): void; // clears broker tracking + sets 'disconnected'
|
|
125
127
|
}
|
|
126
128
|
```
|
|
127
129
|
|
|
130
|
+
Health tracking uses a **per-broker connection set** (`host:port` keys). A single idle broker disconnect does not make the client unhealthy -- only when **all** brokers are disconnected does `isHealthy()` return `false`.
|
|
131
|
+
|
|
128
132
|
Health status transitions automatically via broker events:
|
|
129
|
-
- `client:broker:connect` -> `'connected'`
|
|
130
|
-
- `client:broker:disconnect` -> `'disconnected'`
|
|
131
|
-
- `client:broker:failed` -> `'disconnected'`
|
|
132
|
-
- `close()` -> `'disconnected'`
|
|
133
|
+
- `client:broker:connect` -> adds broker, sets `healthStatus` to `'connected'`
|
|
134
|
+
- `client:broker:disconnect` -> removes broker, sets `healthStatus` to `'disconnected'` only when all brokers are gone
|
|
135
|
+
- `client:broker:failed` -> removes broker, sets `healthStatus` to `'disconnected'` only when all brokers are gone
|
|
136
|
+
- `close()` -> clears all brokers, sets `healthStatus` to `'disconnected'`
|
|
133
137
|
|
|
134
138
|
## Connection Options
|
|
135
139
|
|
|
@@ -400,8 +404,8 @@ import { KafkaDefaults } from '@venizia/ignis-helpers/kafka';
|
|
|
400
404
|
| `STRICT` | `true` | Producer | Fail on unknown topics |
|
|
401
405
|
| `AUTOCREATE_TOPICS` | `false` | Producer | Auto-create topics on produce |
|
|
402
406
|
| `AUTOCOMMIT` | `false` | Consumer | Auto-commit offsets |
|
|
403
|
-
| `SESSION_TIMEOUT` | `
|
|
404
|
-
| `HEARTBEAT_INTERVAL` | `
|
|
407
|
+
| `SESSION_TIMEOUT` | `60000` | Consumer | Session timeout in ms |
|
|
408
|
+
| `HEARTBEAT_INTERVAL` | `10000` | Consumer | Heartbeat interval in ms |
|
|
405
409
|
| `HIGH_WATER_MARK` | `1024` | Consumer | Stream buffer size (messages) |
|
|
406
410
|
| `MIN_BYTES` | `1` | Consumer | Min bytes per fetch |
|
|
407
411
|
| `METADATA_MAX_AGE` | `300000` | Consumer | Metadata cache TTL in ms |
|
|
@@ -570,7 +574,7 @@ const consumer = KafkaConsumerHelper.newInstance({
|
|
|
570
574
|
|
|
571
575
|
```typescript
|
|
572
576
|
// All three -- identical API
|
|
573
|
-
helper.isHealthy(); // true when broker connected
|
|
577
|
+
helper.isHealthy(); // true when at least one broker connected
|
|
574
578
|
helper.isReady(); // Admin/Producer: same as isHealthy()
|
|
575
579
|
// Consumer: isHealthy() + consumer.isActive()
|
|
576
580
|
helper.getHealthStatus(); // 'connected' | 'disconnected' | 'unknown'
|
|
@@ -18,9 +18,10 @@ class KafkaProducerHelper<
|
|
|
18
18
|
| `newInstance(opts)` | `static newInstance<K,V,HK,HV>(opts): KafkaProducerHelper<K,V,HK,HV>` | Factory method |
|
|
19
19
|
| `getProducer()` | `(): Producer<K,V,HK,HV>` | Access the underlying `Producer` |
|
|
20
20
|
| `runInTransaction(cb)` | `<R>(cb: TKafkaTransactionCallback<R,K,V,HK,HV>): Promise<R>` | Execute callback within a Kafka transaction |
|
|
21
|
-
| `isHealthy()` | `(): boolean` | `true` when broker connected |
|
|
21
|
+
| `isHealthy()` | `(): boolean` | `true` when at least one broker connected |
|
|
22
22
|
| `isReady()` | `(): boolean` | Same as `isHealthy()` |
|
|
23
23
|
| `getHealthStatus()` | `(): TKafkaHealthStatus` | `'connected'` \| `'disconnected'` \| `'unknown'` |
|
|
24
|
+
| `getConnectedBrokerCount()` | `(): number` | Number of currently connected brokers |
|
|
24
25
|
| `close(opts?)` | `(opts?: { isForce?: boolean }): Promise<void>` | Close the producer (default: graceful) |
|
|
25
26
|
|
|
26
27
|
## IKafkaProducerOptions
|
|
@@ -64,7 +65,7 @@ const helper = KafkaProducerHelper.newInstance({
|
|
|
64
65
|
});
|
|
65
66
|
|
|
66
67
|
// Health check
|
|
67
|
-
helper.isHealthy(); // true when connected
|
|
68
|
+
helper.isHealthy(); // true when at least one broker connected
|
|
68
69
|
helper.getHealthStatus(); // 'connected' | 'disconnected' | 'unknown'
|
|
69
70
|
|
|
70
71
|
// Send messages via the underlying producer
|
|
@@ -153,7 +154,7 @@ If the callback throws, the transaction is automatically aborted and the error r
|
|
|
153
154
|
2. **Force fallback**: If graceful times out, automatically force-closes
|
|
154
155
|
3. **Force** (`{ isForce: true }`): Immediately calls `close(true)` without timeout protection
|
|
155
156
|
|
|
156
|
-
After `close()`, `healthStatus` is set to `'disconnected'`.
|
|
157
|
+
After `close()`, all broker tracking is cleared and `healthStatus` is set to `'disconnected'`.
|
|
157
158
|
|
|
158
159
|
```typescript
|
|
159
160
|
// Graceful (recommended)
|
|
@@ -12,7 +12,6 @@ A DataSource manages database connections and supports **schema auto-discovery**
|
|
|
12
12
|
import {
|
|
13
13
|
BaseDataSource,
|
|
14
14
|
datasource,
|
|
15
|
-
TNodePostgresConnector,
|
|
16
15
|
ValueOrPromise,
|
|
17
16
|
} from '@venizia/ignis';
|
|
18
17
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
@@ -32,11 +31,11 @@ export class PostgresDataSource extends BaseDataSource<IDSConfigs> {
|
|
|
32
31
|
super({
|
|
33
32
|
name: PostgresDataSource.name,
|
|
34
33
|
config: {
|
|
35
|
-
host: process.env.
|
|
36
|
-
port: +(process.env.
|
|
37
|
-
database: process.env.
|
|
38
|
-
user: process.env.
|
|
39
|
-
password: process.env.
|
|
34
|
+
host: process.env.APP_ENV_POSTGRES_HOST ?? 'localhost',
|
|
35
|
+
port: +(process.env.APP_ENV_POSTGRES_PORT ?? 5432),
|
|
36
|
+
database: process.env.APP_ENV_POSTGRES_DATABASE ?? 'mydb',
|
|
37
|
+
user: process.env.APP_ENV_POSTGRES_USERNAME ?? 'postgres',
|
|
38
|
+
password: process.env.APP_ENV_POSTGRES_PASSWORD ?? '',
|
|
40
39
|
},
|
|
41
40
|
// No schema needed - auto-discovered from @repository bindings!
|
|
42
41
|
});
|
|
@@ -148,11 +147,11 @@ export class PostgresDataSource extends BaseDataSource<IDSConfigs> {
|
|
|
148
147
|
super({
|
|
149
148
|
name: PostgresDataSource.name,
|
|
150
149
|
config: {
|
|
151
|
-
host: process.env.
|
|
152
|
-
port: +(process.env.
|
|
153
|
-
database: process.env.
|
|
154
|
-
user: process.env.
|
|
155
|
-
password: process.env.
|
|
150
|
+
host: process.env.APP_ENV_POSTGRES_HOST ?? 'localhost',
|
|
151
|
+
port: +(process.env.APP_ENV_POSTGRES_PORT ?? 5432),
|
|
152
|
+
database: process.env.APP_ENV_POSTGRES_DATABASE ?? 'mydb',
|
|
153
|
+
user: process.env.APP_ENV_POSTGRES_USERNAME ?? 'postgres',
|
|
154
|
+
password: process.env.APP_ENV_POSTGRES_PASSWORD ?? '',
|
|
156
155
|
},
|
|
157
156
|
});
|
|
158
157
|
}
|
|
@@ -46,16 +46,16 @@ export class User extends BaseEntity<typeof User.schema> {
|
|
|
46
46
|
|
|
47
47
|
// 2. Create a DataSource
|
|
48
48
|
@datasource({ driver: 'node-postgres' })
|
|
49
|
-
export class PostgresDataSource extends BaseDataSource<
|
|
49
|
+
export class PostgresDataSource extends BaseDataSource<IDSConfigs> {
|
|
50
50
|
constructor() {
|
|
51
51
|
super({
|
|
52
52
|
name: PostgresDataSource.name,
|
|
53
53
|
config: {
|
|
54
|
-
host: process.env.
|
|
55
|
-
port: +(process.env.
|
|
56
|
-
database: process.env.
|
|
57
|
-
user: process.env.
|
|
58
|
-
password: process.env.
|
|
54
|
+
host: process.env.APP_ENV_POSTGRES_HOST ?? 'localhost',
|
|
55
|
+
port: +(process.env.APP_ENV_POSTGRES_PORT ?? 5432),
|
|
56
|
+
database: process.env.APP_ENV_POSTGRES_DATABASE ?? 'mydb',
|
|
57
|
+
user: process.env.APP_ENV_POSTGRES_USERNAME ?? 'postgres',
|
|
58
|
+
password: process.env.APP_ENV_POSTGRES_PASSWORD ?? '',
|
|
59
59
|
},
|
|
60
60
|
});
|
|
61
61
|
}
|
|
@@ -6,15 +6,17 @@ Models define your data structure using Drizzle ORM schemas. A model is a single
|
|
|
6
6
|
|
|
7
7
|
```typescript
|
|
8
8
|
// src/models/entities/user.model.ts
|
|
9
|
-
import { BaseEntity,
|
|
10
|
-
import { pgTable } from 'drizzle-orm/pg-core';
|
|
9
|
+
import { BaseEntity, generateIdColumnDefs, generateTzColumnDefs, model } from '@venizia/ignis';
|
|
10
|
+
import { pgTable, text } from 'drizzle-orm/pg-core';
|
|
11
11
|
|
|
12
12
|
@model({ type: 'entity' })
|
|
13
13
|
export class User extends BaseEntity<typeof User.schema> {
|
|
14
14
|
// Define schema as static property
|
|
15
15
|
static override schema = pgTable('User', {
|
|
16
16
|
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
17
|
-
...
|
|
17
|
+
...generateTzColumnDefs(),
|
|
18
|
+
name: text('name').notNull(),
|
|
19
|
+
email: text('email').notNull(),
|
|
18
20
|
});
|
|
19
21
|
|
|
20
22
|
// Relations (empty array if none)
|
|
@@ -126,7 +128,8 @@ static override schema = pgTable('User', {
|
|
|
126
128
|
```typescript
|
|
127
129
|
static override schema = pgTable('User', {
|
|
128
130
|
...generateIdColumnDefs({ id: { dataType: 'string' } }), // id (text with UUID default)
|
|
129
|
-
...
|
|
131
|
+
...generateTzColumnDefs(), // createdAt, modifiedAt
|
|
132
|
+
...generateUserAuditColumnDefs(), // createdBy, modifiedBy
|
|
130
133
|
// ... your fields
|
|
131
134
|
});
|
|
132
135
|
```
|
|
@@ -139,7 +142,6 @@ static override schema = pgTable('User', {
|
|
|
139
142
|
| `generateTzColumnDefs()` | `createdAt`, `modifiedAt` | Track timestamps |
|
|
140
143
|
| `generateUserAuditColumnDefs()` | `createdBy`, `modifiedBy` | Track who created/updated |
|
|
141
144
|
| `generateDataTypeColumnDefs()` | `dataType`, `tValue`, `nValue`, etc. | Configuration tables |
|
|
142
|
-
| `extraUserColumns()` | Combines audit + status + type | Full-featured entities |
|
|
143
145
|
|
|
144
146
|
:::note User Audit Options
|
|
145
147
|
The `generateUserAuditColumnDefs` enricher supports an `allowAnonymous` option (default: `true`). Set to `false` to require authenticated user context and throw errors for anonymous operations:
|
|
@@ -159,10 +159,18 @@ All repository operations accept an `options` parameter with these fields:
|
|
|
159
159
|
| `shouldSkipDefaultFilter` | `boolean` | Bypass the model's default filter (e.g., soft delete) |
|
|
160
160
|
|
|
161
161
|
```typescript
|
|
162
|
-
// Create without returning data (faster
|
|
162
|
+
// Create without returning data (faster)
|
|
163
163
|
await repo.create({
|
|
164
|
-
data:
|
|
165
|
-
options: { shouldReturn: false }
|
|
164
|
+
data: { code: 'SETTING', group: 'SYSTEM' },
|
|
165
|
+
options: { shouldReturn: false },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Bulk create multiple records
|
|
169
|
+
await repo.createAll({
|
|
170
|
+
data: [
|
|
171
|
+
{ code: 'SETTING_A', group: 'SYSTEM' },
|
|
172
|
+
{ code: 'SETTING_B', group: 'SYSTEM' },
|
|
173
|
+
],
|
|
166
174
|
});
|
|
167
175
|
|
|
168
176
|
// Query with pagination range
|
|
@@ -62,7 +62,8 @@ Ignis supports standard PostgreSQL isolation levels:
|
|
|
62
62
|
| `REPEATABLE READ` | Queries see a snapshot as of the start of the transaction. | Reports, consistent reads across multiple queries. |
|
|
63
63
|
| `SERIALIZABLE` | Strictest level. Emulates serial execution. | Financial transactions, critical data integrity. |
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
> [!NOTE]
|
|
66
|
+
> Ignis only supports these three levels. `READ UNCOMMITTED` is **not** accepted — PostgreSQL treats it as `READ COMMITTED` anyway, so Ignis omits it to avoid confusion.
|
|
66
67
|
|
|
67
68
|
## Best Practices
|
|
68
69
|
|
|
@@ -218,7 +218,7 @@ this.bindRoute({
|
|
|
218
218
|
For standard CRUD (Create, Read, Update, Delete) operations, `Ignis` provides a `ControllerFactory` that can generate a full-featured controller for any given entity. This significantly reduces boilerplate code.
|
|
219
219
|
|
|
220
220
|
```typescript
|
|
221
|
-
// src/controllers/configuration.controller.ts (Example from @examples/vert)
|
|
221
|
+
// src/controllers/configuration/configuration.controller.ts (Example from @examples/vert)
|
|
222
222
|
import { Configuration } from '@/models';
|
|
223
223
|
import { ConfigurationRepository } from '@/repositories';
|
|
224
224
|
import {
|
|
@@ -236,7 +236,7 @@ const _Controller = ControllerFactory.defineCrudController({
|
|
|
236
236
|
controller: {
|
|
237
237
|
name: 'ConfigurationController',
|
|
238
238
|
basePath: BASE_PATH,
|
|
239
|
-
isStrict: true,
|
|
239
|
+
isStrict: { path: true, requestSchema: true },
|
|
240
240
|
},
|
|
241
241
|
entity: () => Configuration, // Provide a resolver for your entity class
|
|
242
242
|
});
|
|
@@ -19,7 +19,6 @@ To create a service, extend the `BaseService` class and inject the repositories
|
|
|
19
19
|
|
|
20
20
|
```typescript
|
|
21
21
|
import { BaseService, inject } from '@venizia/ignis';
|
|
22
|
-
import { getError } from '@venizia/ignis-helpers';
|
|
23
22
|
import { ConfigurationRepository } from '../repositories';
|
|
24
23
|
import { UserRepository } from '../repositories';
|
|
25
24
|
import { LoggingService } from './logging.service'; // Example of another service
|