@venizia/ignis-docs 0.0.6-2 → 0.0.7-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -388
- package/dist/mcp-server/common/config.d.ts +0 -21
- package/dist/mcp-server/common/config.d.ts.map +1 -1
- package/dist/mcp-server/common/config.js +1 -36
- package/dist/mcp-server/common/config.js.map +1 -1
- package/dist/mcp-server/helpers/docs.helper.d.ts +0 -24
- package/dist/mcp-server/helpers/docs.helper.d.ts.map +1 -1
- package/dist/mcp-server/helpers/docs.helper.js +0 -25
- package/dist/mcp-server/helpers/docs.helper.js.map +1 -1
- package/dist/mcp-server/helpers/github.helper.d.ts +0 -13
- package/dist/mcp-server/helpers/github.helper.d.ts.map +1 -1
- package/dist/mcp-server/helpers/github.helper.js +3 -20
- package/dist/mcp-server/helpers/github.helper.js.map +1 -1
- package/dist/mcp-server/index.js +1 -20
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/mcp-server/tools/base.tool.d.ts +4 -85
- package/dist/mcp-server/tools/base.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/base.tool.js +1 -38
- package/dist/mcp-server/tools/base.tool.js.map +1 -1
- package/dist/mcp-server/tools/docs/get-document-content.tool.d.ts +8 -2
- package/dist/mcp-server/tools/docs/get-document-content.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/docs/get-document-content.tool.js +1 -10
- package/dist/mcp-server/tools/docs/get-document-content.tool.js.map +1 -1
- package/dist/mcp-server/tools/docs/get-document-metadata.tool.d.ts +13 -2
- package/dist/mcp-server/tools/docs/get-document-metadata.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/docs/get-document-metadata.tool.js +1 -10
- package/dist/mcp-server/tools/docs/get-document-metadata.tool.js.map +1 -1
- package/dist/mcp-server/tools/docs/get-package-overview.tool.d.ts +16 -8
- package/dist/mcp-server/tools/docs/get-package-overview.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/docs/get-package-overview.tool.js +2 -25
- package/dist/mcp-server/tools/docs/get-package-overview.tool.js.map +1 -1
- package/dist/mcp-server/tools/docs/list-categories.tool.d.ts +5 -2
- package/dist/mcp-server/tools/docs/list-categories.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/docs/list-categories.tool.js +1 -10
- package/dist/mcp-server/tools/docs/list-categories.tool.js.map +1 -1
- package/dist/mcp-server/tools/docs/list-documents.tool.d.ts +11 -2
- package/dist/mcp-server/tools/docs/list-documents.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/docs/list-documents.tool.js +1 -10
- package/dist/mcp-server/tools/docs/list-documents.tool.js.map +1 -1
- package/dist/mcp-server/tools/docs/search-documents.tool.d.ts +13 -2
- package/dist/mcp-server/tools/docs/search-documents.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/docs/search-documents.tool.js +1 -10
- 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 +9 -2
- package/dist/mcp-server/tools/github/list-project-files.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/github/list-project-files.tool.js +1 -10
- 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 +16 -2
- package/dist/mcp-server/tools/github/search-code.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/github/search-code.tool.js +2 -14
- package/dist/mcp-server/tools/github/search-code.tool.js.map +1 -1
- package/dist/mcp-server/tools/github/verify-dependencies.tool.d.ts +19 -6
- package/dist/mcp-server/tools/github/verify-dependencies.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/github/verify-dependencies.tool.js +2 -19
- package/dist/mcp-server/tools/github/verify-dependencies.tool.js.map +1 -1
- package/dist/mcp-server/tools/github/view-source-file.tool.d.ts +8 -2
- package/dist/mcp-server/tools/github/view-source-file.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/github/view-source-file.tool.js +1 -10
- package/dist/mcp-server/tools/github/view-source-file.tool.js.map +1 -1
- package/dist/mcp-server/tools/index.d.ts.map +1 -1
- package/dist/mcp-server/tools/index.js +0 -2
- package/dist/mcp-server/tools/index.js.map +1 -1
- package/package.json +68 -54
- package/wiki/best-practices/api-usage-examples.md +7 -5
- package/wiki/best-practices/code-style-standards/advanced-patterns.md +1 -1
- package/wiki/best-practices/code-style-standards/constants-configuration.md +1 -1
- package/wiki/best-practices/code-style-standards/control-flow.md +1 -1
- package/wiki/best-practices/code-style-standards/function-patterns.md +1 -1
- package/wiki/best-practices/common-pitfalls.md +1 -1
- package/wiki/best-practices/data-modeling.md +33 -1
- package/wiki/best-practices/error-handling.md +7 -4
- package/wiki/best-practices/performance-optimization.md +1 -1
- package/wiki/best-practices/security-guidelines.md +5 -4
- package/wiki/guides/core-concepts/components-guide.md +1 -1
- package/wiki/guides/core-concepts/controllers.md +14 -8
- package/wiki/guides/core-concepts/persistent/models.md +32 -0
- package/wiki/guides/core-concepts/services.md +2 -1
- package/wiki/guides/get-started/5-minute-quickstart.md +1 -1
- package/wiki/guides/reference/mcp-docs-server.md +0 -134
- package/wiki/guides/tutorials/building-a-crud-api.md +2 -1
- package/wiki/guides/tutorials/complete-installation.md +2 -2
- package/wiki/guides/tutorials/ecommerce-api.md +3 -3
- package/wiki/guides/tutorials/realtime-chat.md +7 -6
- package/wiki/index.md +2 -1
- package/wiki/references/base/components.md +2 -1
- package/wiki/references/base/controllers.md +19 -12
- package/wiki/references/base/middlewares.md +2 -1
- package/wiki/references/base/models.md +11 -2
- package/wiki/references/base/services.md +2 -1
- package/wiki/references/components/authentication/api.md +525 -205
- package/wiki/references/components/authentication/errors.md +502 -105
- package/wiki/references/components/authentication/index.md +388 -75
- package/wiki/references/components/authentication/usage.md +428 -266
- package/wiki/references/components/authorization/api.md +1213 -0
- package/wiki/references/components/authorization/errors.md +387 -0
- package/wiki/references/components/authorization/index.md +712 -0
- package/wiki/references/components/authorization/usage.md +758 -0
- package/wiki/references/components/health-check.md +2 -1
- package/wiki/references/components/index.md +2 -0
- package/wiki/references/components/socket-io/index.md +9 -4
- package/wiki/references/components/socket-io/usage.md +1 -1
- package/wiki/references/components/static-asset/index.md +3 -5
- package/wiki/references/components/swagger.md +2 -1
- package/wiki/references/configuration/environment-variables.md +2 -1
- package/wiki/references/configuration/index.md +2 -1
- package/wiki/references/helpers/error/index.md +1 -1
- package/wiki/references/helpers/index.md +1 -0
- package/wiki/references/helpers/inversion/index.md +1 -1
- package/wiki/references/helpers/kafka/index.md +305 -0
- package/wiki/references/helpers/redis/index.md +2 -9
- package/wiki/references/quick-reference.md +3 -5
- package/wiki/references/utilities/crypto.md +2 -2
- package/wiki/references/utilities/date.md +5 -5
- package/wiki/references/utilities/index.md +3 -11
- package/wiki/references/utilities/jsx.md +4 -2
- package/wiki/references/utilities/module.md +1 -1
- package/wiki/references/utilities/parse.md +4 -4
- package/wiki/references/utilities/performance.md +2 -2
- package/wiki/references/utilities/promise.md +4 -4
- package/wiki/references/utilities/request.md +2 -2
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
# Authorization -- Usage & Examples
|
|
2
|
+
|
|
3
|
+
> Securing routes, voters, CRUD factory integration, custom enforcers, and comparable actions/resources. See [Setup & Configuration](./) for initial setup.
|
|
4
|
+
|
|
5
|
+
## Securing Routes
|
|
6
|
+
|
|
7
|
+
### Imperative Route (defineRoute)
|
|
8
|
+
|
|
9
|
+
Use the `authorize` field in route configs to declare authorization requirements:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import {
|
|
13
|
+
BaseController,
|
|
14
|
+
Authentication,
|
|
15
|
+
AuthorizationActions,
|
|
16
|
+
} from '@venizia/ignis';
|
|
17
|
+
|
|
18
|
+
class ArticleController extends BaseController {
|
|
19
|
+
binding() {
|
|
20
|
+
// Read requires 'read' action on 'Article' resource
|
|
21
|
+
this.defineRoute({
|
|
22
|
+
configs: {
|
|
23
|
+
path: '/',
|
|
24
|
+
method: 'get',
|
|
25
|
+
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
26
|
+
authorize: {
|
|
27
|
+
action: AuthorizationActions.READ,
|
|
28
|
+
resource: 'Article',
|
|
29
|
+
},
|
|
30
|
+
responses: jsonResponse({
|
|
31
|
+
description: 'List of articles',
|
|
32
|
+
schema: z.array(ArticleSchema),
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
handler: async (context) => {
|
|
36
|
+
const articles = await this.articleService.findAll();
|
|
37
|
+
return context.json(articles);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Delete requires 'delete' action with conditions
|
|
42
|
+
this.defineRoute({
|
|
43
|
+
configs: {
|
|
44
|
+
path: '/{id}',
|
|
45
|
+
method: 'delete',
|
|
46
|
+
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
47
|
+
authorize: {
|
|
48
|
+
action: AuthorizationActions.DELETE,
|
|
49
|
+
resource: 'Article',
|
|
50
|
+
conditions: { ownerId: 'currentUser' },
|
|
51
|
+
},
|
|
52
|
+
responses: jsonResponse({
|
|
53
|
+
description: 'Deleted article',
|
|
54
|
+
schema: ArticleSchema,
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
handler: async (context) => {
|
|
58
|
+
const { id } = context.req.valid('param');
|
|
59
|
+
const result = await this.articleService.deleteById({ id });
|
|
60
|
+
return context.json(result);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Multiple Authorization Specs
|
|
68
|
+
|
|
69
|
+
Pass an array of `IAuthorizationSpec` to require **all** specs to pass. Each spec creates a separate middleware -- all must succeed for the handler to execute:
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
import { Authentication, AuthorizationActions } from '@venizia/ignis';
|
|
73
|
+
|
|
74
|
+
this.defineRoute({
|
|
75
|
+
configs: {
|
|
76
|
+
path: '/admin/users/{id}',
|
|
77
|
+
method: 'patch',
|
|
78
|
+
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
79
|
+
authorize: [
|
|
80
|
+
{ action: AuthorizationActions.UPDATE, resource: 'User' },
|
|
81
|
+
{ action: AuthorizationActions.UPDATE, resource: 'Admin' },
|
|
82
|
+
],
|
|
83
|
+
responses: jsonResponse({
|
|
84
|
+
description: 'Updated user',
|
|
85
|
+
schema: UserSchema,
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
handler: async (context) => {
|
|
89
|
+
// Both 'update:User' AND 'update:Admin' must pass
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
> [!NOTE]
|
|
95
|
+
> When multiple specs are evaluated on the same route, rules are built once and cached on the context (`Authorization.RULES`). The second spec reuses the cached rules without rebuilding.
|
|
96
|
+
|
|
97
|
+
### Decorator-Based Route
|
|
98
|
+
|
|
99
|
+
Use the `authorize` field alongside `authenticate` in decorator configs:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
import { controller, get, post, AuthorizationActions, AuthorizationRoles } from '@venizia/ignis';
|
|
103
|
+
|
|
104
|
+
@controller({ path: '/articles' })
|
|
105
|
+
class ArticleController extends BaseController {
|
|
106
|
+
@get({
|
|
107
|
+
configs: {
|
|
108
|
+
path: '/',
|
|
109
|
+
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
110
|
+
authorize: { action: AuthorizationActions.READ, resource: 'Article' },
|
|
111
|
+
responses: jsonResponse({ description: 'Articles', schema: z.array(ArticleSchema) }),
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
async findAll(opts: { context: TRouteContext }) {
|
|
115
|
+
// Handler runs only if authorized
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@post({
|
|
119
|
+
configs: {
|
|
120
|
+
path: '/',
|
|
121
|
+
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
122
|
+
authorize: {
|
|
123
|
+
action: AuthorizationActions.CREATE,
|
|
124
|
+
resource: 'Article',
|
|
125
|
+
allowedRoles: ['editor', AuthorizationRoles.ADMIN.identifier],
|
|
126
|
+
},
|
|
127
|
+
request: { body: jsonContent({ schema: CreateArticleSchema }) },
|
|
128
|
+
responses: jsonResponse({ description: 'Created article', schema: ArticleSchema }),
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
async create(opts: { context: TRouteContext }) {
|
|
132
|
+
// Handler runs if user has 'create:Article' permission OR 'editor'/'900_admin' role
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Using the `authorize()` Standalone Function
|
|
138
|
+
|
|
139
|
+
The `authorize()` function is a convenience wrapper around `AuthorizationProvider`. It returns a Hono `MiddlewareHandler`:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
import { authorize, authenticate, Authentication, AuthorizationActions } from '@venizia/ignis';
|
|
143
|
+
|
|
144
|
+
// Use as Hono middleware directly
|
|
145
|
+
app.delete(
|
|
146
|
+
'/articles/:id',
|
|
147
|
+
authenticate({ strategies: [Authentication.STRATEGY_JWT] }),
|
|
148
|
+
authorize({ spec: { action: AuthorizationActions.DELETE, resource: 'Article' } }),
|
|
149
|
+
(c) => {
|
|
150
|
+
const user = c.get(Authentication.CURRENT_USER);
|
|
151
|
+
return c.json({ deleted: true });
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### With Specific Enforcer
|
|
157
|
+
|
|
158
|
+
If multiple enforcers are registered, specify which one to use:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
authorize({
|
|
162
|
+
spec: { action: AuthorizationActions.READ, resource: 'Report' },
|
|
163
|
+
enforcerName: 'my-custom', // defaults to first registered if omitted
|
|
164
|
+
});
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Voters
|
|
168
|
+
|
|
169
|
+
Voters provide custom authorization logic that runs **before** the enforcer (step 4 in the pipeline).
|
|
170
|
+
|
|
171
|
+
```mermaid
|
|
172
|
+
flowchart TD
|
|
173
|
+
Start([Step 4: Voters]) --> HasVoters{Has voters?}
|
|
174
|
+
HasVoters -->|No| Enforcer([Continue to enforcer])
|
|
175
|
+
HasVoters -->|Yes| V1["Call voter 1"]
|
|
176
|
+
V1 --> D1{Decision?}
|
|
177
|
+
D1 -->|DENY| E403[/403 Forbidden/]
|
|
178
|
+
D1 -->|ALLOW| Next([next - authorized])
|
|
179
|
+
D1 -->|ABSTAIN| V2["Call voter 2"]
|
|
180
|
+
V2 --> D2{Decision?}
|
|
181
|
+
D2 -->|DENY| E403
|
|
182
|
+
D2 -->|ALLOW| Next
|
|
183
|
+
D2 -->|ABSTAIN| VN["... voter N"]
|
|
184
|
+
VN -->|All ABSTAIN| Enforcer
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Each voter returns one of three decisions:
|
|
188
|
+
|
|
189
|
+
| Decision | Effect |
|
|
190
|
+
|----------|--------|
|
|
191
|
+
| `AuthorizationDecisions.ALLOW` | Immediately grants access (skips remaining voters and enforcer) |
|
|
192
|
+
| `AuthorizationDecisions.DENY` | Immediately denies access (throws 403) |
|
|
193
|
+
| `AuthorizationDecisions.ABSTAIN` | No opinion -- continues to next voter or enforcer |
|
|
194
|
+
|
|
195
|
+
### Basic Voter Example
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
import {
|
|
199
|
+
AuthorizationActions,
|
|
200
|
+
AuthorizationDecisions,
|
|
201
|
+
TAuthorizationVoter,
|
|
202
|
+
} from '@venizia/ignis';
|
|
203
|
+
|
|
204
|
+
const ownerVoter: TAuthorizationVoter = async ({ user, action, resource, context }) => {
|
|
205
|
+
if (action !== AuthorizationActions.UPDATE && action !== AuthorizationActions.DELETE) {
|
|
206
|
+
return AuthorizationDecisions.ABSTAIN;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const articleId = context.req.param('id');
|
|
210
|
+
const article = await articleService.findById({ id: articleId });
|
|
211
|
+
|
|
212
|
+
if (!article) {
|
|
213
|
+
return AuthorizationDecisions.ABSTAIN;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (article.authorId === user.userId) {
|
|
217
|
+
return AuthorizationDecisions.ALLOW;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return AuthorizationDecisions.ABSTAIN; // Let enforcer decide
|
|
221
|
+
};
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Using Voters in Routes
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
this.defineRoute({
|
|
228
|
+
configs: {
|
|
229
|
+
path: '/{id}',
|
|
230
|
+
method: 'patch',
|
|
231
|
+
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
232
|
+
authorize: {
|
|
233
|
+
action: AuthorizationActions.UPDATE,
|
|
234
|
+
resource: 'Article',
|
|
235
|
+
voters: [ownerVoter],
|
|
236
|
+
},
|
|
237
|
+
// ...
|
|
238
|
+
},
|
|
239
|
+
handler: async (context) => {
|
|
240
|
+
// Runs if: owner (voter ALLOW) OR enforcer permits
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Multiple Voters
|
|
246
|
+
|
|
247
|
+
Voters are evaluated sequentially. The first non-ABSTAIN decision wins:
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
authorize: {
|
|
251
|
+
action: AuthorizationActions.UPDATE,
|
|
252
|
+
resource: 'Article',
|
|
253
|
+
voters: [ownerVoter, adminOverrideVoter, timeWindowVoter],
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
**Evaluation flow:**
|
|
258
|
+
1. `ownerVoter` returns `ABSTAIN` -- continue
|
|
259
|
+
2. `adminOverrideVoter` returns `ALLOW` -- **access granted** (skips remaining voters and enforcer)
|
|
260
|
+
|
|
261
|
+
> [!TIP]
|
|
262
|
+
> Use `ABSTAIN` as the default return when a voter doesn't have a strong opinion. Only return `DENY` when you're certain the request should be blocked regardless of other checks.
|
|
263
|
+
|
|
264
|
+
## Role-Based Shortcuts
|
|
265
|
+
|
|
266
|
+
### Global `alwaysAllowRoles`
|
|
267
|
+
|
|
268
|
+
Roles listed in `alwaysAllowRoles` bypass **all** authorization checks globally (step 3 in the pipeline):
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
import { AuthorizationRoles } from '@venizia/ignis';
|
|
272
|
+
|
|
273
|
+
this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
|
|
274
|
+
defaultDecision: 'deny',
|
|
275
|
+
alwaysAllowRoles: [AuthorizationRoles.SUPER_ADMIN.identifier, 'system'],
|
|
276
|
+
});
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Per-Route `allowedRoles`
|
|
280
|
+
|
|
281
|
+
Roles listed in `allowedRoles` on a specific `IAuthorizationSpec` bypass the enforcer for that route only (still evaluated at step 3):
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
authorize: {
|
|
285
|
+
action: AuthorizationActions.DELETE,
|
|
286
|
+
resource: 'Article',
|
|
287
|
+
allowedRoles: [AuthorizationRoles.ADMIN.identifier, 'moderator'],
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Role Extraction
|
|
292
|
+
|
|
293
|
+
The authorization middleware extracts roles from the authenticated user's `roles` field via the `extractUserRoles()` method:
|
|
294
|
+
|
|
295
|
+
```mermaid
|
|
296
|
+
flowchart TD
|
|
297
|
+
Input["user.roles"] --> IsArray{Array?}
|
|
298
|
+
IsArray -->|No| Empty(["return []"])
|
|
299
|
+
IsArray -->|Yes| Map["Map each role"]
|
|
300
|
+
Map --> Type{Type?}
|
|
301
|
+
Type -->|string| AsIs["Use as-is"]
|
|
302
|
+
Type -->|object| Prio["r.identifier"]
|
|
303
|
+
Prio -->|undefined| Name["r.name"]
|
|
304
|
+
Name -->|undefined| Id["String(r.id)"]
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
It supports multiple formats:
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
// String array
|
|
311
|
+
roles: ['admin', 'user']
|
|
312
|
+
|
|
313
|
+
// Object array with identifier (preferred — matches AuthorizationRole.identifier)
|
|
314
|
+
roles: [{ id: 1, identifier: '900_admin', priority: 900 }]
|
|
315
|
+
|
|
316
|
+
// Object array with name fallback
|
|
317
|
+
roles: [{ id: 1, name: 'admin' }]
|
|
318
|
+
|
|
319
|
+
// Object array with id-only fallback
|
|
320
|
+
roles: [{ id: 1 }]
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Extraction priority: `identifier` > `name` > `String(id)`
|
|
324
|
+
|
|
325
|
+
## CRUD Factory Integration
|
|
326
|
+
|
|
327
|
+
### Controller-Level Authorization
|
|
328
|
+
|
|
329
|
+
Apply authorization to all CRUD routes:
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
import { AuthorizationActions } from '@venizia/ignis';
|
|
333
|
+
|
|
334
|
+
ControllerFactory.defineCrudController({
|
|
335
|
+
entity: Article,
|
|
336
|
+
repository: { name: 'ArticleRepository' },
|
|
337
|
+
controller: { name: 'ArticleController', basePath: '/articles' },
|
|
338
|
+
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
339
|
+
authorize: { action: AuthorizationActions.READ, resource: 'Article' },
|
|
340
|
+
});
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Per-Route Overrides
|
|
344
|
+
|
|
345
|
+
Override authorization per CRUD endpoint:
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
import { AuthorizationActions, AuthorizationRoles } from '@venizia/ignis';
|
|
349
|
+
|
|
350
|
+
ControllerFactory.defineCrudController({
|
|
351
|
+
entity: Article,
|
|
352
|
+
repository: { name: 'ArticleRepository' },
|
|
353
|
+
controller: { name: 'ArticleController', basePath: '/articles' },
|
|
354
|
+
authenticate: { strategies: [Authentication.STRATEGY_JWT] },
|
|
355
|
+
authorize: { action: AuthorizationActions.READ, resource: 'Article' },
|
|
356
|
+
routes: {
|
|
357
|
+
// Public read -- skip both auth
|
|
358
|
+
find: { authenticate: { skip: true } },
|
|
359
|
+
count: { authenticate: { skip: true } },
|
|
360
|
+
|
|
361
|
+
// Custom authorization for write operations
|
|
362
|
+
create: {
|
|
363
|
+
authorize: { action: AuthorizationActions.CREATE, resource: 'Article' },
|
|
364
|
+
},
|
|
365
|
+
updateById: {
|
|
366
|
+
authorize: { action: AuthorizationActions.UPDATE, resource: 'Article' },
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
// Skip only authorization (still requires auth)
|
|
370
|
+
findOne: { authorize: { skip: true } },
|
|
371
|
+
|
|
372
|
+
// Strict delete with custom roles
|
|
373
|
+
deleteById: {
|
|
374
|
+
authorize: {
|
|
375
|
+
action: AuthorizationActions.DELETE,
|
|
376
|
+
resource: 'Article',
|
|
377
|
+
allowedRoles: [AuthorizationRoles.ADMIN.identifier],
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Priority Resolution (Factory Routes)
|
|
385
|
+
|
|
386
|
+
The `defineControllerRouteConfigs` function resolves authorization with this priority:
|
|
387
|
+
|
|
388
|
+
```mermaid
|
|
389
|
+
flowchart TD
|
|
390
|
+
Route([Route config]) --> AuthSkip{"authenticate:<br/>{ skip: true }?"}
|
|
391
|
+
AuthSkip -->|Yes| NoAuth([Skip BOTH<br/>auth + authz])
|
|
392
|
+
AuthSkip -->|No| AuthzSkip{"authorize:<br/>{ skip: true }?"}
|
|
393
|
+
AuthzSkip -->|Yes| NoAuthz([Skip authz only])
|
|
394
|
+
AuthzSkip -->|No| PerRoute{"Per-route<br/>authorize spec?"}
|
|
395
|
+
PerRoute -->|Yes| UseRoute([Use per-route spec])
|
|
396
|
+
PerRoute -->|No| Controller{"Controller-level<br/>authorize?"}
|
|
397
|
+
Controller -->|Yes| UseCtrl([Use controller spec])
|
|
398
|
+
Controller -->|No| NoAuthz2([No authorization])
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
1. **`authenticate: { skip: true }`** -- skips both authentication and authorization
|
|
402
|
+
2. **`authorize: { skip: true }`** -- skips authorization only
|
|
403
|
+
3. **Per-route `authorize` spec** -- overrides controller-level
|
|
404
|
+
4. **Controller-level `authorize`** -- default for all routes
|
|
405
|
+
|
|
406
|
+
## Dynamic Skip Authorization
|
|
407
|
+
|
|
408
|
+
Use `Authorization.SKIP_AUTHORIZATION` to dynamically bypass authorization in middleware (step 1 in the pipeline):
|
|
409
|
+
|
|
410
|
+
```typescript
|
|
411
|
+
import { Authorization } from '@venizia/ignis';
|
|
412
|
+
import { createMiddleware } from 'hono/factory';
|
|
413
|
+
|
|
414
|
+
const conditionalAuthzMiddleware = createMiddleware(async (c, next) => {
|
|
415
|
+
// Skip authorization for internal service-to-service calls
|
|
416
|
+
if (c.req.header('X-Internal-Service') === 'trusted-key') {
|
|
417
|
+
c.set(Authorization.SKIP_AUTHORIZATION, true);
|
|
418
|
+
}
|
|
419
|
+
return next();
|
|
420
|
+
});
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
## Rules Caching
|
|
424
|
+
|
|
425
|
+
The authorization middleware caches rules on the Hono context to avoid rebuilding them on every authorization spec evaluation. This is especially useful when multiple authorization specs are applied to the same route:
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
// First spec triggers buildRules() → result cached on context
|
|
429
|
+
authorize: [
|
|
430
|
+
{ action: AuthorizationActions.READ, resource: 'Article' },
|
|
431
|
+
{ action: AuthorizationActions.READ, resource: 'Comment' },
|
|
432
|
+
]
|
|
433
|
+
// Second spec reuses cached rules → no rebuild
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
> [!TIP]
|
|
437
|
+
> Rules caching happens per-request. Each new HTTP request starts with an empty cache. If you need to invalidate cached rules mid-request (e.g., after role change), set `context.set(Authorization.RULES, null)`.
|
|
438
|
+
|
|
439
|
+
## Accessing Context Variables
|
|
440
|
+
|
|
441
|
+
The authorization module provides type-safe access to auth data on the Hono context:
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
import { Authorization, Authentication } from '@venizia/ignis';
|
|
445
|
+
|
|
446
|
+
// In a route handler or middleware
|
|
447
|
+
const user = c.get(Authentication.CURRENT_USER); // IAuthUser
|
|
448
|
+
const rules = c.get(Authorization.RULES); // unknown (type depends on enforcer)
|
|
449
|
+
const isSkipped = c.get(Authorization.SKIP_AUTHORIZATION); // boolean
|
|
450
|
+
|
|
451
|
+
// Set skip dynamically
|
|
452
|
+
c.set(Authorization.SKIP_AUTHORIZATION, true);
|
|
453
|
+
|
|
454
|
+
// Invalidate cached rules
|
|
455
|
+
c.set(Authorization.RULES, null);
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
## Using IAuthorizationComparable
|
|
459
|
+
|
|
460
|
+
For custom action/resource comparison logic beyond plain string equality, implement `IAuthorizationComparable`.
|
|
461
|
+
|
|
462
|
+
### StringAuthorizationAction with Wildcard
|
|
463
|
+
|
|
464
|
+
The built-in `StringAuthorizationAction` supports a wildcard (`*`) that matches any action:
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
import { StringAuthorizationAction } from '@venizia/ignis';
|
|
468
|
+
|
|
469
|
+
const wildcard = StringAuthorizationAction.build({ value: '*' });
|
|
470
|
+
wildcard.isEqual('read'); // true — wildcard matches all
|
|
471
|
+
wildcard.isEqual('delete'); // true — wildcard matches all
|
|
472
|
+
wildcard.isEqual('create'); // true — wildcard matches all
|
|
473
|
+
|
|
474
|
+
const readOnly = StringAuthorizationAction.build({ value: 'read' });
|
|
475
|
+
readOnly.isEqual('read'); // true
|
|
476
|
+
readOnly.isEqual('update'); // false
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
### StringAuthorizationResource
|
|
480
|
+
|
|
481
|
+
Standard string comparison for resources (no wildcard):
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
import { StringAuthorizationResource } from '@venizia/ignis';
|
|
485
|
+
|
|
486
|
+
const article = StringAuthorizationResource.build({ value: 'Article' });
|
|
487
|
+
article.isEqual('Article'); // true
|
|
488
|
+
article.isEqual('User'); // false
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
### Custom Comparable Implementation
|
|
492
|
+
|
|
493
|
+
Create your own comparable type for advanced matching:
|
|
494
|
+
|
|
495
|
+
```typescript
|
|
496
|
+
import type { IAuthorizationComparable } from '@venizia/ignis';
|
|
497
|
+
|
|
498
|
+
class HierarchicalResource implements IAuthorizationComparable<string> {
|
|
499
|
+
readonly value: string;
|
|
500
|
+
|
|
501
|
+
constructor(opts: { value: string }) {
|
|
502
|
+
this.value = opts.value;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
compare(other: string): number {
|
|
506
|
+
// Match if the other resource starts with this resource's value
|
|
507
|
+
// e.g., 'articles' matches 'articles.comments'
|
|
508
|
+
if (other.startsWith(this.value)) return 0;
|
|
509
|
+
return this.value.localeCompare(other);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
isEqual(other: string): boolean {
|
|
513
|
+
return this.compare(other) === 0;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
## Custom Enforcer
|
|
519
|
+
|
|
520
|
+
Create a custom enforcer by implementing `IAuthorizationEnforcer`:
|
|
521
|
+
|
|
522
|
+
```typescript
|
|
523
|
+
import {
|
|
524
|
+
IAuthorizationEnforcer,
|
|
525
|
+
IAuthorizationRequest,
|
|
526
|
+
IAuthUser,
|
|
527
|
+
TAuthorizationDecision,
|
|
528
|
+
AuthorizationDecisions,
|
|
529
|
+
TContext,
|
|
530
|
+
} from '@venizia/ignis';
|
|
531
|
+
import { BaseHelper, ValueOrPromise } from '@venizia/ignis-helpers';
|
|
532
|
+
import { Env } from 'hono';
|
|
533
|
+
|
|
534
|
+
type MyRules = Map<string, Set<string>>;
|
|
535
|
+
|
|
536
|
+
class MyCustomEnforcer
|
|
537
|
+
extends BaseHelper
|
|
538
|
+
implements IAuthorizationEnforcer<Env, string, string, MyRules>
|
|
539
|
+
{
|
|
540
|
+
name = 'my-custom';
|
|
541
|
+
|
|
542
|
+
constructor() {
|
|
543
|
+
super({ scope: MyCustomEnforcer.name });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async configure(): Promise<void> {
|
|
547
|
+
// One-time initialization (called by registry on first use)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async buildRules(opts: {
|
|
551
|
+
user: { principalType: string } & IAuthUser;
|
|
552
|
+
context: TContext;
|
|
553
|
+
}): Promise<MyRules> {
|
|
554
|
+
const rules = new Map<string, Set<string>>();
|
|
555
|
+
// Build your rules map from DB, config, etc.
|
|
556
|
+
return rules;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async evaluate(opts: {
|
|
560
|
+
rules: MyRules;
|
|
561
|
+
request: IAuthorizationRequest;
|
|
562
|
+
context: TContext;
|
|
563
|
+
}): Promise<TAuthorizationDecision> {
|
|
564
|
+
const { rules, request } = opts;
|
|
565
|
+
const resourceActions = rules.get(request.resource);
|
|
566
|
+
if (resourceActions?.has(request.action)) {
|
|
567
|
+
return AuthorizationDecisions.ALLOW;
|
|
568
|
+
}
|
|
569
|
+
return AuthorizationDecisions.DENY;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
Then register it via the registry:
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
import {
|
|
578
|
+
AuthorizationEnforcerRegistry,
|
|
579
|
+
AuthorizationEnforcerTypes,
|
|
580
|
+
AuthorizeBindingKeys,
|
|
581
|
+
AuthorizeComponent,
|
|
582
|
+
IAuthorizeOptions,
|
|
583
|
+
} from '@venizia/ignis';
|
|
584
|
+
|
|
585
|
+
// Step 1: Global options
|
|
586
|
+
this.bind<IAuthorizeOptions>({ key: AuthorizeBindingKeys.OPTIONS }).toValue({
|
|
587
|
+
defaultDecision: 'deny',
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// Step 2: Component
|
|
591
|
+
this.component(AuthorizeComponent);
|
|
592
|
+
|
|
593
|
+
// Step 3: Register custom enforcer
|
|
594
|
+
AuthorizationEnforcerRegistry.getInstance().register({
|
|
595
|
+
container: this,
|
|
596
|
+
enforcers: [{
|
|
597
|
+
enforcer: MyCustomEnforcer,
|
|
598
|
+
name: 'my-custom',
|
|
599
|
+
type: AuthorizationEnforcerTypes.CUSTOM,
|
|
600
|
+
options: { /* your enforcer-specific options if needed */ },
|
|
601
|
+
}],
|
|
602
|
+
});
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
> [!NOTE]
|
|
606
|
+
> Custom enforcers can inject their options via `@inject({ key: AuthorizeBindingKeys.enforcerOptions('my-custom') })` in the constructor, just like `CasbinAuthorizationEnforcer` does.
|
|
607
|
+
|
|
608
|
+
## Custom Filtered Adapter
|
|
609
|
+
|
|
610
|
+
Create a custom adapter by extending `BaseFilteredAdapter`:
|
|
611
|
+
|
|
612
|
+
```typescript
|
|
613
|
+
import {
|
|
614
|
+
BaseFilteredAdapter,
|
|
615
|
+
IBaseFilteredAdapterEntities,
|
|
616
|
+
ICasbinPolicyFilter,
|
|
617
|
+
TBasePolicyRow,
|
|
618
|
+
} from '@venizia/ignis';
|
|
619
|
+
|
|
620
|
+
interface MyEntities extends IBaseFilteredAdapterEntities {
|
|
621
|
+
permission: { tableName: string; principalType: string };
|
|
622
|
+
role: { tableName: string; principalType: string };
|
|
623
|
+
policyDefinition: { tableName: string; principalType: string };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
class MyCustomAdapter extends BaseFilteredAdapter<MyEntities> {
|
|
627
|
+
constructor(opts: { entities: MyEntities; /* your dependencies */ }) {
|
|
628
|
+
super({ scope: MyCustomAdapter.name, entities: opts.entities });
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
protected async buildDirectPolicies(opts: {
|
|
632
|
+
filter: ICasbinPolicyFilter;
|
|
633
|
+
rolePrincipal: string;
|
|
634
|
+
}): Promise<string[]> {
|
|
635
|
+
// Query direct permission policies for the user
|
|
636
|
+
// Return casbin `p` lines using this.toPolicyLine()
|
|
637
|
+
const rows = await this.queryDirectPolicies(opts.filter);
|
|
638
|
+
return rows.map(row => this.toPolicyLine({ row })).filter(Boolean) as string[];
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
protected async buildGroupPolicies(opts: {
|
|
642
|
+
filter: ICasbinPolicyFilter;
|
|
643
|
+
}): Promise<{ lines: string[]; roleIds: (string | number)[] }> {
|
|
644
|
+
// Query role assignments for the user
|
|
645
|
+
// Return casbin `g` lines using this.toGroupLine() + role IDs
|
|
646
|
+
return { lines: [...], roleIds: [...] };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
protected async buildRolePolicies(opts: {
|
|
650
|
+
roleIds: (string | number)[];
|
|
651
|
+
rolePrincipal: string;
|
|
652
|
+
}): Promise<string[]> {
|
|
653
|
+
// Query permission policies inherited through roles
|
|
654
|
+
// Return casbin `p` lines using this.toPolicyLine()
|
|
655
|
+
return [...];
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
The base class provides shared formatters:
|
|
661
|
+
- `this.formatDomain(domain)` -- adds entity prefix to domain values
|
|
662
|
+
- `this.toGroupLine({ subject, role, domain })` -- formats `g` lines
|
|
663
|
+
- `this.toPolicyLine({ row })` -- formats `p` lines
|
|
664
|
+
|
|
665
|
+
## AuthorizationRole Comparison
|
|
666
|
+
|
|
667
|
+
Use `AuthorizationRole` for priority-based role comparison:
|
|
668
|
+
|
|
669
|
+
```typescript
|
|
670
|
+
import { AuthorizationRole, AuthorizationRoles } from '@venizia/ignis';
|
|
671
|
+
|
|
672
|
+
// Built-in roles
|
|
673
|
+
AuthorizationRoles.SUPER_ADMIN.identifier; // '999_super-admin'
|
|
674
|
+
AuthorizationRoles.ADMIN.identifier; // '900_admin'
|
|
675
|
+
AuthorizationRoles.USER.identifier; // '010_user'
|
|
676
|
+
|
|
677
|
+
// Comparison
|
|
678
|
+
AuthorizationRoles.SUPER_ADMIN.isHigherThan({ target: AuthorizationRoles.ADMIN }); // true
|
|
679
|
+
AuthorizationRoles.GUEST.isLowerThan({ target: AuthorizationRoles.USER }); // true
|
|
680
|
+
|
|
681
|
+
// Custom roles
|
|
682
|
+
const moderator = AuthorizationRole.build({ name: 'moderator', priority: 500 });
|
|
683
|
+
moderator.identifier; // '500_moderator'
|
|
684
|
+
moderator.isHigherThan({ target: AuthorizationRoles.USER }); // true (500 > 10)
|
|
685
|
+
moderator.isLowerThan({ target: AuthorizationRoles.ADMIN }); // true (500 < 900)
|
|
686
|
+
|
|
687
|
+
// Custom delimiter
|
|
688
|
+
const customRole = AuthorizationRole.build({ name: 'editor', priority: 100, delimiter: '-' });
|
|
689
|
+
customRole.identifier; // '100-editor'
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
## Model-Based Resource References
|
|
693
|
+
|
|
694
|
+
Instead of hardcoding resource strings, use `AUTHORIZATION_SUBJECT` from your model classes. When a model declares `authorize.principal` in `@model` settings, the decorator auto-populates `AUTHORIZATION_SUBJECT`:
|
|
695
|
+
|
|
696
|
+
```typescript
|
|
697
|
+
import { BaseEntity, model, generateIdColumnDefs } from '@venizia/ignis';
|
|
698
|
+
import { pgTable, text } from 'drizzle-orm/pg-core';
|
|
699
|
+
|
|
700
|
+
@model({
|
|
701
|
+
type: 'entity',
|
|
702
|
+
settings: {
|
|
703
|
+
authorize: { principal: 'article' },
|
|
704
|
+
},
|
|
705
|
+
})
|
|
706
|
+
export class Article extends BaseEntity<typeof Article.schema> {
|
|
707
|
+
static override schema = pgTable('Article', {
|
|
708
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
709
|
+
title: text('title').notNull(),
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Article.AUTHORIZATION_SUBJECT === 'article'
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
Use it in route configs for type-safe, refactor-friendly resource references:
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
import { AuthorizationActions } from '@venizia/ignis';
|
|
720
|
+
import { Article } from '../models/entities/article.model';
|
|
721
|
+
|
|
722
|
+
// Instead of: resource: 'article'
|
|
723
|
+
authorize: {
|
|
724
|
+
action: AuthorizationActions.READ,
|
|
725
|
+
resource: Article.AUTHORIZATION_SUBJECT,
|
|
726
|
+
}
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
### Querying All Principals
|
|
730
|
+
|
|
731
|
+
Use `MetadataRegistry` to retrieve all registered authorization principals at runtime:
|
|
732
|
+
|
|
733
|
+
```typescript
|
|
734
|
+
import { MetadataRegistry } from '@venizia/ignis';
|
|
735
|
+
|
|
736
|
+
const registry = MetadataRegistry.getInstance();
|
|
737
|
+
|
|
738
|
+
// Flat array of principal names — ideal for Casbin policy setup
|
|
739
|
+
const principals = registry.getAuthorizeModelPrincipals({ format: 'array' });
|
|
740
|
+
// ['article', 'user', 'configuration']
|
|
741
|
+
|
|
742
|
+
// Record of model name → principal
|
|
743
|
+
const principalMap = registry.getAuthorizeModelPrincipals({ format: 'record' });
|
|
744
|
+
// { Article: 'article', User: 'user', Configuration: 'configuration' }
|
|
745
|
+
|
|
746
|
+
// Full settings with model registry entries (framework-level)
|
|
747
|
+
const settings = registry.getAuthorizeModelSettings({ format: 'array' });
|
|
748
|
+
// [{ name: 'Article', authorize: { principal: 'article' }, entry: IModelRegistryEntry }]
|
|
749
|
+
```
|
|
750
|
+
|
|
751
|
+
> [!TIP]
|
|
752
|
+
> 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.
|
|
753
|
+
|
|
754
|
+
## See Also
|
|
755
|
+
|
|
756
|
+
- [Setup & Configuration](./) -- Binding keys, options interfaces, and initial setup
|
|
757
|
+
- [API Reference](./api) -- Architecture, enforcer internals, provider, registry, and adapters
|
|
758
|
+
- [Error Reference](./errors) -- Error messages and troubleshooting
|