@datacapy/server 0.1.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/LICENSE +29 -0
- package/README.md +485 -0
- package/dist/acl/role-assessor/all.d.ts +7 -0
- package/dist/acl/role-assessor/all.d.ts.map +1 -0
- package/dist/acl/role-assessor/all.js +18 -0
- package/dist/acl/role-assessor/all.js.map +1 -0
- package/dist/acl/role-assessor/index.d.ts +4 -0
- package/dist/acl/role-assessor/index.d.ts.map +1 -0
- package/dist/acl/role-assessor/index.js +7 -0
- package/dist/acl/role-assessor/index.js.map +1 -0
- package/dist/acl/role-assessor.d.ts +13 -0
- package/dist/acl/role-assessor.d.ts.map +1 -0
- package/dist/acl/role-assessor.js +20 -0
- package/dist/acl/role-assessor.js.map +1 -0
- package/dist/acl.d.ts +41 -0
- package/dist/acl.d.ts.map +1 -0
- package/dist/acl.js +116 -0
- package/dist/acl.js.map +1 -0
- package/dist/api-config.d.ts +92 -0
- package/dist/api-config.d.ts.map +1 -0
- package/dist/api-config.js +3 -0
- package/dist/api-config.js.map +1 -0
- package/dist/error.d.ts +30 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +72 -0
- package/dist/error.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/remote-object/body-parser-configurer.d.ts +28 -0
- package/dist/remote-object/body-parser-configurer.d.ts.map +1 -0
- package/dist/remote-object/body-parser-configurer.js +103 -0
- package/dist/remote-object/body-parser-configurer.js.map +1 -0
- package/dist/remote-object/endpoint-resolver.d.ts +12 -0
- package/dist/remote-object/endpoint-resolver.d.ts.map +1 -0
- package/dist/remote-object/endpoint-resolver.js +58 -0
- package/dist/remote-object/endpoint-resolver.js.map +1 -0
- package/dist/remote-object/error-handler.d.ts +16 -0
- package/dist/remote-object/error-handler.d.ts.map +1 -0
- package/dist/remote-object/error-handler.js +149 -0
- package/dist/remote-object/error-handler.js.map +1 -0
- package/dist/remote-object/index.d.ts +8 -0
- package/dist/remote-object/index.d.ts.map +1 -0
- package/dist/remote-object/index.js +24 -0
- package/dist/remote-object/index.js.map +1 -0
- package/dist/remote-object/interfaces.d.ts +42 -0
- package/dist/remote-object/interfaces.d.ts.map +1 -0
- package/dist/remote-object/interfaces.js +3 -0
- package/dist/remote-object/interfaces.js.map +1 -0
- package/dist/remote-object/middleware-factory.d.ts +14 -0
- package/dist/remote-object/middleware-factory.d.ts.map +1 -0
- package/dist/remote-object/middleware-factory.js +66 -0
- package/dist/remote-object/middleware-factory.js.map +1 -0
- package/dist/remote-object/request-data-parser.d.ts +18 -0
- package/dist/remote-object/request-data-parser.d.ts.map +1 -0
- package/dist/remote-object/request-data-parser.js +75 -0
- package/dist/remote-object/request-data-parser.js.map +1 -0
- package/dist/remote-object/response-handler.d.ts +6 -0
- package/dist/remote-object/response-handler.d.ts.map +1 -0
- package/dist/remote-object/response-handler.js +20 -0
- package/dist/remote-object/response-handler.js.map +1 -0
- package/dist/remote-object.d.ts +50 -0
- package/dist/remote-object.d.ts.map +1 -0
- package/dist/remote-object.js +102 -0
- package/dist/remote-object.js.map +1 -0
- package/dist/server/acl-registry.d.ts +12 -0
- package/dist/server/acl-registry.d.ts.map +1 -0
- package/dist/server/acl-registry.js +21 -0
- package/dist/server/acl-registry.js.map +1 -0
- package/dist/server/api-config-registry.d.ts +10 -0
- package/dist/server/api-config-registry.d.ts.map +1 -0
- package/dist/server/api-config-registry.js +19 -0
- package/dist/server/api-config-registry.js.map +1 -0
- package/dist/server/configuration-manager.d.ts +12 -0
- package/dist/server/configuration-manager.d.ts.map +1 -0
- package/dist/server/configuration-manager.js +62 -0
- package/dist/server/configuration-manager.js.map +1 -0
- package/dist/server/endpoint-registrar.d.ts +13 -0
- package/dist/server/endpoint-registrar.d.ts.map +1 -0
- package/dist/server/endpoint-registrar.js +92 -0
- package/dist/server/endpoint-registrar.js.map +1 -0
- package/dist/server/express-app-manager.d.ts +14 -0
- package/dist/server/express-app-manager.d.ts.map +1 -0
- package/dist/server/express-app-manager.js +33 -0
- package/dist/server/express-app-manager.js.map +1 -0
- package/dist/server/http-server-manager.d.ts +16 -0
- package/dist/server/http-server-manager.d.ts.map +1 -0
- package/dist/server/http-server-manager.js +40 -0
- package/dist/server/http-server-manager.js.map +1 -0
- package/dist/server/index.d.ts +9 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +33 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/interfaces.d.ts +70 -0
- package/dist/server/interfaces.d.ts.map +1 -0
- package/dist/server/interfaces.js +3 -0
- package/dist/server/interfaces.js.map +1 -0
- package/dist/server/lifecycle-manager.d.ts +19 -0
- package/dist/server/lifecycle-manager.d.ts.map +1 -0
- package/dist/server/lifecycle-manager.js +60 -0
- package/dist/server/lifecycle-manager.js.map +1 -0
- package/dist/server-config.d.ts +10 -0
- package/dist/server-config.d.ts.map +1 -0
- package/dist/server-config.js +3 -0
- package/dist/server-config.js.map +1 -0
- package/dist/server.d.ts +53 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +115 -0
- package/dist/server.js.map +1 -0
- package/package.json +66 -0
- package/tsconfig.json +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016, Kevin Foster
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
* Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
* Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
<!-- cspell:ignore noteget -->
|
|
2
|
+
|
|
3
|
+
# @datacapy/server
|
|
4
|
+
|
|
5
|
+
An HTTP API layer for [Datacapy](../../README.md) applications. You describe
|
|
6
|
+
each endpoint in a plain config object; @datacapy/server maps it to an
|
|
7
|
+
[Express](https://expressjs.com) route, validates and casts the request data,
|
|
8
|
+
checks access rules, calls a method on one of your services or repositories, and
|
|
9
|
+
turns the result or the error into an HTTP response.
|
|
10
|
+
|
|
11
|
+
- **Config-driven endpoints**: path, verbs, target method, request data,
|
|
12
|
+
response codes and access rules live in one object per service or repository
|
|
13
|
+
- **Explicit exposure**: nothing is reachable until you declare an endpoint for
|
|
14
|
+
it
|
|
15
|
+
- **Request validation and type-casting** using
|
|
16
|
+
[@datacapy/schema](../schema/README.md)
|
|
17
|
+
- **Access control** with ordered allow and deny rules and pluggable role
|
|
18
|
+
assessors that decide, per request, whether a caller holds a role
|
|
19
|
+
- **Error mapping**: throw a typed error in your service and get the right HTTP
|
|
20
|
+
status
|
|
21
|
+
- **Lifecycle hooks** for start-up and shutdown work
|
|
22
|
+
- **Plain Express underneath**: use any Express middleware
|
|
23
|
+
|
|
24
|
+
@datacapy/server does not authenticate anyone. It gives you the place to do it:
|
|
25
|
+
a role assessor reads whatever credential your application uses and decides
|
|
26
|
+
which roles the request has. See [Access control](#access-control).
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install @datacapy/server
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
It depends on `@datacapy/om` (models, services, repositories) and `express`.
|
|
35
|
+
Everything `@datacapy/om` exports, including `Service`, `Repo`, `ModelManager`
|
|
36
|
+
and `Schema`, is re-exported from `@datacapy/server`.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
A service with two methods, one role, and the endpoints that expose them.
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import Server, {
|
|
44
|
+
Service,
|
|
45
|
+
ServerAclRoleAssessor,
|
|
46
|
+
ServerErrorNotFound,
|
|
47
|
+
} from '@datacapy/server'
|
|
48
|
+
|
|
49
|
+
class NoteService extends Service {
|
|
50
|
+
private notes = [{ id: 1, text: 'Hello', owner: 'kevin' }]
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
super({ name: 'note' })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Every endpoint method receives one argument: an object holding the
|
|
57
|
+
// request data you declared for the endpoint, plus aclContext and
|
|
58
|
+
// aclConditions.
|
|
59
|
+
async get({ id }: { id: number }) {
|
|
60
|
+
const note = this.notes.find((n) => n.id === id)
|
|
61
|
+
if (!note) throw new ServerErrorNotFound({ message: 'No such note' })
|
|
62
|
+
return note
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async add({
|
|
66
|
+
text,
|
|
67
|
+
aclContext,
|
|
68
|
+
}: {
|
|
69
|
+
text: string
|
|
70
|
+
aclContext: { user: string }
|
|
71
|
+
}) {
|
|
72
|
+
const note = { id: this.notes.length + 1, text, owner: aclContext.user }
|
|
73
|
+
this.notes.push(note)
|
|
74
|
+
return note
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Demo only: trusts a header. A real role assessor verifies a token or session.
|
|
79
|
+
class Authed extends ServerAclRoleAssessor {
|
|
80
|
+
constructor() {
|
|
81
|
+
super('authed')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async initContext(request, context) {
|
|
85
|
+
context.user = request.get('X-User') ?? null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async hasRole(context) {
|
|
89
|
+
return !!context.user
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const server = new Server({ port: 3838, path: '/api' })
|
|
94
|
+
|
|
95
|
+
server.modelManager.addService(new NoteService())
|
|
96
|
+
server.addRoleAssessor(new Authed())
|
|
97
|
+
|
|
98
|
+
server.addApiConfig({
|
|
99
|
+
service: 'note',
|
|
100
|
+
acl: { rules: [{ allow: true, role: 'authed' }] },
|
|
101
|
+
endpoints: {
|
|
102
|
+
getOne: {
|
|
103
|
+
path: '/:id',
|
|
104
|
+
method: 'get',
|
|
105
|
+
verbs: ['get'],
|
|
106
|
+
data: { id: { src: 'param', type: Number, required: true } },
|
|
107
|
+
},
|
|
108
|
+
add: {
|
|
109
|
+
path: '/',
|
|
110
|
+
method: 'add',
|
|
111
|
+
verbs: ['post'],
|
|
112
|
+
data: {
|
|
113
|
+
text: { src: 'body', type: String, required: true, notEmpty: true },
|
|
114
|
+
},
|
|
115
|
+
response: { success: { http: { code: 201 } } },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await server.init()
|
|
121
|
+
await server.start()
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
GET /api/note/1 (no X-User) 401 {"name":"ServerErrorUnauthorized"}
|
|
126
|
+
GET /api/note/1 (X-User: sam) 200 {"id":1,"text":"Hello","owner":"kevin"}
|
|
127
|
+
GET /api/note/99 (X-User: sam) 404 {"message":"No such note","name":"ServerErrorNotFound"}
|
|
128
|
+
GET /api/note/abc (X-User: sam) 403 {"validationErrors":{"id":["'abc' of type String cannot be cast to type Number"]}}
|
|
129
|
+
POST /api/note {"text":""} (X-User: sam) 403 {"validationErrors":{"text":["text cannot be empty"]}}
|
|
130
|
+
POST /api/note {"text":"hi"} (X-User: sam) 201 {"id":2,"text":"hi","owner":"sam"}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The URL is `server path + service path + endpoint path`: `/api` + `/note` +
|
|
134
|
+
`/:id`. The service path defaults to the kebab-case service name (`noteBook`
|
|
135
|
+
becomes `/note-book`).
|
|
136
|
+
|
|
137
|
+
## How it fits together
|
|
138
|
+
|
|
139
|
+
| Piece | Role |
|
|
140
|
+
| ------------- | -------------------------------------------------------------------------------------- |
|
|
141
|
+
| `Server` | Owns the Express app, the `ModelManager`, the ACL role assessors and the lifecycle |
|
|
142
|
+
| Remote object | The thing an endpoint calls: a `Service` (usual), a `Repo`, or any object with methods |
|
|
143
|
+
| API config | One object per remote object: its path, default ACL rules and its `endpoints` |
|
|
144
|
+
| Endpoint | One method on the remote object, mapped to a path and one or more HTTP verbs |
|
|
145
|
+
| Role assessor | Decides whether a request has a named role, used by ACL rules |
|
|
146
|
+
|
|
147
|
+
**Only declared endpoints exist.** Adding a repository to the model does not
|
|
148
|
+
expose it. Put business logic in a service, and expose the service methods you
|
|
149
|
+
want.
|
|
150
|
+
|
|
151
|
+
## API configs
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
server.addApiConfig(config)
|
|
155
|
+
server.addApiConfigs([configA, configB])
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
| Option | Meaning |
|
|
159
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
160
|
+
| `service` / `repo` / `object` | The remote object: a service name, a repository name (both looked up in the `ModelManager`), or an object instance |
|
|
161
|
+
| `path` | Root path for this config. Defaults to `/` plus the kebab-case service or repo name. `''` mounts at the server root |
|
|
162
|
+
| `acl.rules` | Rules applied to every endpoint in this config |
|
|
163
|
+
| `endpoints` | Map of endpoint name to endpoint config |
|
|
164
|
+
| `enable` | `false` skips the whole config |
|
|
165
|
+
| `disable` | `{ endpointName: true }` removes named endpoints |
|
|
166
|
+
| `disableGroup` | `{ groupName: true }` removes endpoints listing that group in `groups` |
|
|
167
|
+
|
|
168
|
+
### Endpoints
|
|
169
|
+
|
|
170
|
+
| Option | Meaning |
|
|
171
|
+
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
172
|
+
| `method` | Name of the method to call on the remote object. The server throws at request time if it does not exist |
|
|
173
|
+
| `path` | Path under the config's root. **Start it with `/`**: a missing slash is concatenated as-is (`/note` + `get` gives `/noteget`) |
|
|
174
|
+
| `verbs` | HTTP verbs, for example `['get']` or `['get', 'head']`. Default `['get']` |
|
|
175
|
+
| `data` | Request data to extract and validate. See [Request data](#request-data) |
|
|
176
|
+
| `acl.rules` | Rules added after the config's rules. See [Access control](#access-control) |
|
|
177
|
+
| `response` | Success status and content type, and error mapping. See [Responses and errors](#responses-and-errors) |
|
|
178
|
+
| `service` / `repo` / `object` | Override the remote object for this endpoint only |
|
|
179
|
+
| `groups` | Labels for `disableGroup` |
|
|
180
|
+
| `enable` | `false` removes this endpoint |
|
|
181
|
+
| `priority` | Higher priority routes register first. Default `0` |
|
|
182
|
+
| `skipResponse` | The method sends the response itself. See [Sending your own response](#sending-your-own-response) |
|
|
183
|
+
| `bodyParser` | Per-endpoint body parsing. See [Body parsing](#body-parsing) |
|
|
184
|
+
|
|
185
|
+
**Route order matters.** Express matches in registration order, so a fixed path
|
|
186
|
+
must come before a parameter that would swallow it. Give `/stats` a higher
|
|
187
|
+
`priority` than `/:id`.
|
|
188
|
+
|
|
189
|
+
## Request data
|
|
190
|
+
|
|
191
|
+
`data` maps each argument name to where it comes from and how to check it. The
|
|
192
|
+
values arrive on the single object passed to your method.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
data: {
|
|
196
|
+
id: { src: 'param', type: Number, required: true },
|
|
197
|
+
page: { src: 'query', type: Number, defaultValue: 1 },
|
|
198
|
+
token: { src: 'header', srcPath: 'X-Token' },
|
|
199
|
+
city: { src: 'body', srcPath: 'address.city' },
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
| Option | Meaning |
|
|
204
|
+
| --------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
205
|
+
| `src` | `query` (default), `param`, `body`, `header`, `aclContext`, `aclConditions`, or `container` |
|
|
206
|
+
| `srcPath` | Name or dotted path to read. Defaults to the argument name. For `header` it is the header name |
|
|
207
|
+
| `type` | Type to cast to (`String`, `Number`, `Boolean`, `Date`, ...) |
|
|
208
|
+
| `required`, `notNull`, `notEmpty` | Validation rules |
|
|
209
|
+
| `defaultValue` | Used when the value is absent |
|
|
210
|
+
|
|
211
|
+
`container` reads from the whole request context (`request`, `response`,
|
|
212
|
+
`config`, `param`, `query`, `body` and the ACL values), so
|
|
213
|
+
`{ src: 'container', srcPath: 'response' }` gives your method the Express
|
|
214
|
+
response object.
|
|
215
|
+
|
|
216
|
+
Validation uses [@datacapy/schema](../schema/README.md). A failure returns
|
|
217
|
+
**403** with `{ "validationErrors": { field: [messages] } }`. This check runs
|
|
218
|
+
before the access rules, so an unauthorised caller who sends invalid data sees
|
|
219
|
+
the validation error, not a 401.
|
|
220
|
+
|
|
221
|
+
## Responses and errors
|
|
222
|
+
|
|
223
|
+
A method's return value is sent as JSON with status 200. Override that per
|
|
224
|
+
endpoint:
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
response: { success: { http: { code: 201 } } }
|
|
228
|
+
response: { success: { http: { code: 200, contentType: 'text/csv' } } }
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Any `contentType` other than `json` sets the `Content-Type` header and sends the
|
|
232
|
+
value as-is.
|
|
233
|
+
|
|
234
|
+
Throw one of the built-in errors to choose the status. Extra properties you pass
|
|
235
|
+
are included in the response body.
|
|
236
|
+
|
|
237
|
+
| Error | Status |
|
|
238
|
+
| ----------------------------- | ------ |
|
|
239
|
+
| `ServerErrorBadRequest` | 400 |
|
|
240
|
+
| `ServerErrorUnauthorized` | 401 |
|
|
241
|
+
| `ServerErrorForbidden` | 403 |
|
|
242
|
+
| `ServerErrorNotFound` | 404 |
|
|
243
|
+
| `ServerErrorMethodNotAllowed` | 405 |
|
|
244
|
+
| `ServerErrorTooManyRequests` | 429 |
|
|
245
|
+
| `ServerErrorInternal` | 500 |
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
throw new ServerErrorForbidden({ message: 'Not your note', reason: 'owner' })
|
|
249
|
+
// 403 {"message":"Not your note","reason":"owner","name":"ServerErrorForbidden"}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
To map your own error classes, list them by class name in the endpoint's
|
|
253
|
+
`response.error`. The first matching entry wins. A `schema` narrows the match to
|
|
254
|
+
errors whose properties validate.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
class OutOfStockError extends ServerError {}
|
|
258
|
+
|
|
259
|
+
response: {
|
|
260
|
+
error: {
|
|
261
|
+
OutOfStockError: { http: { code: 409 } },
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The response body is the error serialised as JSON, so only its own enumerable
|
|
267
|
+
properties appear. `ServerError` subclasses include `message`
|
|
268
|
+
(`new OutOfStockError('none left')` sends
|
|
269
|
+
`{"message":"none left","name":"ServerError"}`). A plain `Error` subclass sends
|
|
270
|
+
only the properties you assign to it, and no message.
|
|
271
|
+
|
|
272
|
+
An error with no matching entry becomes a 500 with
|
|
273
|
+
`{ error: "InternalServerError", message, endpoint, method }`.
|
|
274
|
+
|
|
275
|
+
To rewrite errors globally, for example to translate messages into the caller's
|
|
276
|
+
language, set a translator. It runs on every thrown error before matching and
|
|
277
|
+
can be async.
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
server.setErrorTranslator(async (err, req) => translate(err, req.aclContext))
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Sending your own response
|
|
284
|
+
|
|
285
|
+
For downloads, streams or anything that is not a return value, set
|
|
286
|
+
`skipResponse: true` and take the response object as request data.
|
|
287
|
+
|
|
288
|
+
```ts
|
|
289
|
+
files: {
|
|
290
|
+
path: '/:name',
|
|
291
|
+
method: 'download',
|
|
292
|
+
skipResponse: true,
|
|
293
|
+
data: {
|
|
294
|
+
name: { src: 'param' },
|
|
295
|
+
res: { src: 'container', srcPath: 'response' },
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Access control
|
|
301
|
+
|
|
302
|
+
An endpoint is denied unless a rule allows it. Each rule names a role and says
|
|
303
|
+
whether that role is `allow: true` or `allow: false`.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
acl: {
|
|
307
|
+
rules: [
|
|
308
|
+
{ allow: true, role: 'authed' },
|
|
309
|
+
{ allow: false, role: 'suspended' },
|
|
310
|
+
],
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
**How rules combine.** The config's rules are followed by the endpoint's rules,
|
|
315
|
+
and they are evaluated in order. A rule only counts if the caller holds its
|
|
316
|
+
role. When it does, it sets the outcome to its `allow` value, so **later rules
|
|
317
|
+
override earlier ones**. The built-in `all` role is held by every request.
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
// Anyone authed may call, unless they are suspended, but admins always may
|
|
321
|
+
rules: [
|
|
322
|
+
{ allow: true, role: 'authed' },
|
|
323
|
+
{ allow: false, role: 'suspended' },
|
|
324
|
+
{ allow: true, role: 'admin' },
|
|
325
|
+
]
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
A denied request throws `ServerErrorUnauthorized` (401).
|
|
329
|
+
|
|
330
|
+
### Role assessors
|
|
331
|
+
|
|
332
|
+
A role is defined by a class extending `ServerAclRoleAssessor`. Register it with
|
|
333
|
+
`server.addRoleAssessor()` or `addRoleAssessors([...])`.
|
|
334
|
+
|
|
335
|
+
| Member | Purpose |
|
|
336
|
+
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
337
|
+
| `super(name)` | The role name used in rules |
|
|
338
|
+
| `priority` | Assessors with a higher priority run `initContext` first. Same-priority assessors run together. Default `0` |
|
|
339
|
+
| `initContext(request, context, remoteObject)` | Runs once per request before any rule. Read the credential and store what `hasRole` needs on `context` |
|
|
340
|
+
| `hasRole(context)` | Return `true`, `false`, or an object of conditions |
|
|
341
|
+
| `this.repos` | The model's repositories, for lookups |
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
class ProjectAdmin extends ServerAclRoleAssessor {
|
|
345
|
+
constructor() {
|
|
346
|
+
super('projectAdmin')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async hasRole({ jwt, projectId }) {
|
|
350
|
+
const ids: string[] = jwt?.adminOf ?? []
|
|
351
|
+
return ids.includes(projectId)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
An assessor that reads context filled in by another (a decoded token, say) needs
|
|
357
|
+
a lower `priority` than the assessor that fills it in. Give the
|
|
358
|
+
credential-reading assessor the higher number.
|
|
359
|
+
|
|
360
|
+
### Conditions
|
|
361
|
+
|
|
362
|
+
`hasRole` may return an object instead of `true`. The request is allowed, and
|
|
363
|
+
the object is passed to your method as `aclConditions`, so the method can limit
|
|
364
|
+
what it returns, for example to one team's rows.
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
async hasRole({ jwt }) {
|
|
368
|
+
return jwt?.teamId ? { teamId: jwt.teamId } : false
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
A role that returns conditions is always treated as a grant, whatever its rule's
|
|
373
|
+
`allow` says. Once one has granted access, later deny rules do not revoke it.
|
|
374
|
+
Use conditions only for roles you allow, and put them last.
|
|
375
|
+
|
|
376
|
+
### Security notes
|
|
377
|
+
|
|
378
|
+
- `context` starts as a copy of the request data you declared, so a caller
|
|
379
|
+
controls those keys. Always overwrite the keys your assessor uses in
|
|
380
|
+
`initContext`, and never declare request data whose name matches an ACL
|
|
381
|
+
context key.
|
|
382
|
+
- Scope by role, not by a check inside the method. For an endpoint that acts on
|
|
383
|
+
one project, use a role that checks the caller's rights on the `projectId`
|
|
384
|
+
argument, rather than a generic role plus a lookup.
|
|
385
|
+
|
|
386
|
+
## Body parsing
|
|
387
|
+
|
|
388
|
+
JSON bodies are parsed by default, limited to `100kb`. Enable other parsers or
|
|
389
|
+
change the limit per endpoint:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
bodyParser: {
|
|
393
|
+
json: { limit: '2mb' },
|
|
394
|
+
urlencoded: { enable: true },
|
|
395
|
+
text: { enable: true, type: 'text/csv' },
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
## Enabling and disabling
|
|
400
|
+
|
|
401
|
+
```ts
|
|
402
|
+
{ service: 'note', enable: false, endpoints: { ... } } // whole config
|
|
403
|
+
{ service: 'note', disable: { getOne: true }, endpoints: { ... } } // by name
|
|
404
|
+
{ service: 'note', disableGroup: { write: true }, endpoints: {
|
|
405
|
+
add: { groups: ['write'], ... },
|
|
406
|
+
} } // by group
|
|
407
|
+
{ getOne: { enable: false, ... } } // one endpoint
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Use this to switch endpoints off per environment without editing the config.
|
|
411
|
+
|
|
412
|
+
## Server configuration
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
new Server({
|
|
416
|
+
port: 3838,
|
|
417
|
+
path: '/api',
|
|
418
|
+
model: {
|
|
419
|
+
/* ModelManager config */
|
|
420
|
+
},
|
|
421
|
+
})
|
|
422
|
+
new Server(config, existingModelManager)
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
| Option | Default | Meaning |
|
|
426
|
+
| -------- | ------- | ------------------------------------------------------------------------------ |
|
|
427
|
+
| `path` | `/api` | Prefix for every route |
|
|
428
|
+
| `port` | `3838` | Port for `start()` |
|
|
429
|
+
| `appDir` | | Resolved to an absolute path and available to your code as `config.appDir` |
|
|
430
|
+
| `model` | | [ModelManager](../om/README.md) config: data sources, repos, services, schemas |
|
|
431
|
+
|
|
432
|
+
Any other keys are kept on `server.config`, and are available to endpoints as
|
|
433
|
+
`{ src: 'container', srcPath: 'config.yourKey' }`. Pass an existing
|
|
434
|
+
`ModelManager` as the second argument to share one across servers or tests.
|
|
435
|
+
|
|
436
|
+
`setLogger(logger)` replaces the console logger. A logger needs `error()`, and
|
|
437
|
+
optionally `warn()`, `info()` and `debug()`.
|
|
438
|
+
|
|
439
|
+
## Lifecycle
|
|
440
|
+
|
|
441
|
+
`await server.init()` runs the stages below in order, then
|
|
442
|
+
`await server.start()` listens. Add work to a stage with
|
|
443
|
+
`addInitialiser(fn, stage)`. The function receives the server. If it returns a
|
|
444
|
+
function, that function runs at shutdown.
|
|
445
|
+
|
|
446
|
+
| Stage | When |
|
|
447
|
+
| ------------------------- | ------------------------------------------ |
|
|
448
|
+
| (none) and `00-init` | Before the model starts |
|
|
449
|
+
| `01-model-initialised` | After `ModelManager.init()` |
|
|
450
|
+
| `02-resources-loaded` | Before endpoints are registered |
|
|
451
|
+
| `03-endpoints-registered` | Routes exist, router not yet mounted |
|
|
452
|
+
| `04-router-mounted` | Router mounted under `path` |
|
|
453
|
+
| `99-final` | Last, after the error handler is installed |
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
server.addInitialiser(async (s) => {
|
|
457
|
+
const queue = await connectQueue()
|
|
458
|
+
return () => queue.close() // runs on shutdown
|
|
459
|
+
}, '01-model-initialised')
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
`start()` also listens for `SIGINT` and `SIGTERM`. On either, or on
|
|
463
|
+
`await server.shutdown()`, shutdown handlers run in reverse stage order, then
|
|
464
|
+
the model shuts down and the HTTP server closes. Add handlers directly with
|
|
465
|
+
`addShutdownHandler(fn, stage)`.
|
|
466
|
+
|
|
467
|
+
## Express
|
|
468
|
+
|
|
469
|
+
`server.app` is the Express application and `server.router` the router your
|
|
470
|
+
endpoints are registered on. Middleware added to `server.app` before `init()`
|
|
471
|
+
runs before every endpoint, which suits CORS, security headers and request
|
|
472
|
+
logging.
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
server.app.use(helmet())
|
|
476
|
+
server.app.use(cors({ origin: 'https://app.example.com' }))
|
|
477
|
+
await server.init()
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Errors thrown outside an endpoint method, in your own middleware for example,
|
|
481
|
+
are caught by a final handler that logs them and returns a 500.
|
|
482
|
+
|
|
483
|
+
## Licence
|
|
484
|
+
|
|
485
|
+
[BSD 3-Clause](LICENSE)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import ServerAclRoleAssessor from '../role-assessor';
|
|
2
|
+
export declare class ServerAclRoleAssessorAll extends ServerAclRoleAssessor {
|
|
3
|
+
constructor();
|
|
4
|
+
hasRole(_user: any, _context?: any): Promise<boolean>;
|
|
5
|
+
}
|
|
6
|
+
export default ServerAclRoleAssessorAll;
|
|
7
|
+
//# sourceMappingURL=all.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"all.d.ts","sourceRoot":"","sources":["../../../src/acl/role-assessor/all.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,MAAM,kBAAkB,CAAA;AAEpD,qBAAa,wBAAyB,SAAQ,qBAAqB;;IAK3D,OAAO,CAAC,KAAK,KAAA,EAAE,QAAQ,CAAC,KAAA;CAG/B;AAED,eAAe,wBAAwB,CAAA"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ServerAclRoleAssessorAll = void 0;
|
|
7
|
+
const role_assessor_1 = __importDefault(require("../role-assessor"));
|
|
8
|
+
class ServerAclRoleAssessorAll extends role_assessor_1.default {
|
|
9
|
+
constructor() {
|
|
10
|
+
super('all');
|
|
11
|
+
}
|
|
12
|
+
async hasRole(_user, _context) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
exports.ServerAclRoleAssessorAll = ServerAclRoleAssessorAll;
|
|
17
|
+
exports.default = ServerAclRoleAssessorAll;
|
|
18
|
+
//# sourceMappingURL=all.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"all.js","sourceRoot":"","sources":["../../../src/acl/role-assessor/all.ts"],"names":[],"mappings":";;;;;;AAAA,qEAAoD;AAEpD,MAAa,wBAAyB,SAAQ,uBAAqB;IACjE;QACE,KAAK,CAAC,KAAK,CAAC,CAAA;IACd,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,QAAS;QAC5B,OAAO,IAAI,CAAA;IACb,CAAC;CACF;AARD,4DAQC;AAED,kBAAe,wBAAwB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/acl/role-assessor/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,OAAO,CAAA;AAEhD,eAAO,MAAM,aAAa,4BAAmC,CAAA;AAE7D,eAAe,aAAa,CAAA"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.roleAssessors = void 0;
|
|
4
|
+
const all_1 = require("./all");
|
|
5
|
+
exports.roleAssessors = [new all_1.ServerAclRoleAssessorAll()];
|
|
6
|
+
exports.default = exports.roleAssessors;
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/acl/role-assessor/index.ts"],"names":[],"mappings":";;;AAAA,+BAAgD;AAEnC,QAAA,aAAa,GAAG,CAAC,IAAI,8BAAwB,EAAE,CAAC,CAAA;AAE7D,kBAAe,qBAAa,CAAA"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare class ServerAclRoleAssessor {
|
|
2
|
+
role: string;
|
|
3
|
+
repos: {
|
|
4
|
+
[key: string]: any;
|
|
5
|
+
};
|
|
6
|
+
priority: number;
|
|
7
|
+
constructor(role?: any);
|
|
8
|
+
initContext(_request: any, _context?: any, _remoteObject?: any): Promise<void>;
|
|
9
|
+
hasRole(_context: any): Promise<any | boolean>;
|
|
10
|
+
setRepos(repos: any): void;
|
|
11
|
+
}
|
|
12
|
+
export default ServerAclRoleAssessor;
|
|
13
|
+
//# sourceMappingURL=role-assessor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-assessor.d.ts","sourceRoot":"","sources":["../../src/acl/role-assessor.ts"],"names":[],"mappings":"AAAA,qBAAa,qBAAqB;IAChC,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAA;IAC7B,QAAQ,EAAE,MAAM,CAAA;gBAEJ,IAAI,CAAC,KAAA;IAMX,WAAW,CAAC,QAAQ,KAAA,EAAE,QAAQ,CAAC,KAAA,EAAE,aAAa,CAAC,KAAA,GAAG,OAAO,CAAC,IAAI,CAAC;IAE/D,OAAO,CAAC,QAAQ,KAAA,GAAG,OAAO,CAAC,GAAG,GAAG,OAAO,CAAC;IAI/C,QAAQ,CAAC,KAAK,KAAA;CAGf;AAED,eAAe,qBAAqB,CAAA"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ServerAclRoleAssessor = void 0;
|
|
4
|
+
class ServerAclRoleAssessor {
|
|
5
|
+
constructor(role) {
|
|
6
|
+
this.role = role ? role : '';
|
|
7
|
+
this.repos = {};
|
|
8
|
+
this.priority = 0;
|
|
9
|
+
}
|
|
10
|
+
async initContext(_request, _context, _remoteObject) { }
|
|
11
|
+
async hasRole(_context) {
|
|
12
|
+
return Promise.resolve(true);
|
|
13
|
+
}
|
|
14
|
+
setRepos(repos) {
|
|
15
|
+
this.repos = repos;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.ServerAclRoleAssessor = ServerAclRoleAssessor;
|
|
19
|
+
exports.default = ServerAclRoleAssessor;
|
|
20
|
+
//# sourceMappingURL=role-assessor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"role-assessor.js","sourceRoot":"","sources":["../../src/acl/role-assessor.ts"],"names":[],"mappings":";;;AAAA,MAAa,qBAAqB;IAKhC,YAAY,IAAK;QACf,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAA;QAC5B,IAAI,CAAC,KAAK,GAAG,EAAE,CAAA;QACf,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAA;IACnB,CAAC;IAED,KAAK,CAAC,WAAW,CAAC,QAAQ,EAAE,QAAS,EAAE,aAAc,IAAkB,CAAC;IAExE,KAAK,CAAC,OAAO,CAAC,QAAQ;QACpB,OAAO,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9B,CAAC;IAED,QAAQ,CAAC,KAAK;QACZ,IAAI,CAAC,KAAK,GAAG,KAAK,CAAA;IACpB,CAAC;CACF;AApBD,sDAoBC;AAED,kBAAe,qBAAqB,CAAA"}
|
package/dist/acl.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import ServerAclRoleAssessor from './acl/role-assessor';
|
|
2
|
+
import { ServerApiConfigEndpoint } from './api-config';
|
|
3
|
+
export interface ServerAclConfigRule {
|
|
4
|
+
allow: boolean;
|
|
5
|
+
role: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ServerAclConfig {
|
|
8
|
+
rules?: Array<ServerAclConfigRule>;
|
|
9
|
+
endpoints?: {
|
|
10
|
+
[key: string]: ServerApiConfigEndpoint;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface isPermittedResult {
|
|
14
|
+
[key: string]: string;
|
|
15
|
+
}
|
|
16
|
+
type PermittedResultType = {
|
|
17
|
+
[key: string]: any;
|
|
18
|
+
} | boolean;
|
|
19
|
+
export declare class ServerAcl {
|
|
20
|
+
config: ServerAclConfig;
|
|
21
|
+
roleAssessor: {
|
|
22
|
+
[key: string]: ServerAclRoleAssessor;
|
|
23
|
+
};
|
|
24
|
+
initContextGroups: {
|
|
25
|
+
[key: number]: [ServerAclRoleAssessor];
|
|
26
|
+
};
|
|
27
|
+
repos: {
|
|
28
|
+
[key: string]: any;
|
|
29
|
+
};
|
|
30
|
+
constructor(options?: ServerAclConfig);
|
|
31
|
+
loadDefaultRoleAssessors(): void;
|
|
32
|
+
populateContext(request: any, context?: any, remoteObject?: any): Promise<boolean>;
|
|
33
|
+
isPermitted(endpointName: any, context?: any): Promise<PermittedResultType>;
|
|
34
|
+
hasRole(role: string, context?: any): Promise<any | boolean>;
|
|
35
|
+
addRule(rule: any): void;
|
|
36
|
+
getRules(endpointName: any): ServerAclConfigRule[];
|
|
37
|
+
setRepos(repos: any): void;
|
|
38
|
+
addRoleAssessor(assessor: any): void;
|
|
39
|
+
}
|
|
40
|
+
export default ServerAcl;
|
|
41
|
+
//# sourceMappingURL=acl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"acl.d.ts","sourceRoot":"","sources":["../src/acl.ts"],"names":[],"mappings":"AAAA,OAAO,qBAAqB,MAAM,qBAAqB,CAAA;AAEvD,OAAO,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAA;AAEtD,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,OAAO,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,KAAK,CAAC,mBAAmB,CAAC,CAAA;IAClC,SAAS,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,uBAAuB,CAAA;KAAE,CAAA;CACvD;AAED,MAAM,WAAW,iBAAiB;IAChC,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;CACtB;AAED,KAAK,mBAAmB,GACpB;IACE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB,GACD,OAAO,CAAA;AAEX,qBAAa,SAAS;IACpB,MAAM,EAAE,eAAe,CAAA;IACvB,YAAY,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,qBAAqB,CAAA;KAAE,CAAA;IACtD,iBAAiB,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,CAAC,qBAAqB,CAAC,CAAA;KAAE,CAAA;IAC7D,KAAK,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KAAE,CAAA;gBAEjB,OAAO,CAAC,EAAE,eAAe;IAWrC,wBAAwB;IAIlB,eAAe,CAAC,OAAO,KAAA,EAAE,OAAO,CAAC,KAAA,EAAE,YAAY,CAAC,KAAA;IA+BtD,WAAW,CAAC,YAAY,KAAA,EAAE,OAAO,CAAC,KAAA,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAwDjE,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,KAAA,GAAG,OAAO,CAAC,GAAG,GAAG,OAAO,CAAC;IAUvD,OAAO,CAAC,IAAI,KAAA;IAQZ,QAAQ,CAAC,YAAY,KAAA;IAiBrB,QAAQ,CAAC,KAAK,KAAA;IAOd,eAAe,CAAC,QAAQ,KAAA;CAIzB;AAsCD,eAAe,SAAS,CAAA"}
|