@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.
Files changed (112) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +485 -0
  3. package/dist/acl/role-assessor/all.d.ts +7 -0
  4. package/dist/acl/role-assessor/all.d.ts.map +1 -0
  5. package/dist/acl/role-assessor/all.js +18 -0
  6. package/dist/acl/role-assessor/all.js.map +1 -0
  7. package/dist/acl/role-assessor/index.d.ts +4 -0
  8. package/dist/acl/role-assessor/index.d.ts.map +1 -0
  9. package/dist/acl/role-assessor/index.js +7 -0
  10. package/dist/acl/role-assessor/index.js.map +1 -0
  11. package/dist/acl/role-assessor.d.ts +13 -0
  12. package/dist/acl/role-assessor.d.ts.map +1 -0
  13. package/dist/acl/role-assessor.js +20 -0
  14. package/dist/acl/role-assessor.js.map +1 -0
  15. package/dist/acl.d.ts +41 -0
  16. package/dist/acl.d.ts.map +1 -0
  17. package/dist/acl.js +116 -0
  18. package/dist/acl.js.map +1 -0
  19. package/dist/api-config.d.ts +92 -0
  20. package/dist/api-config.d.ts.map +1 -0
  21. package/dist/api-config.js +3 -0
  22. package/dist/api-config.js.map +1 -0
  23. package/dist/error.d.ts +30 -0
  24. package/dist/error.d.ts.map +1 -0
  25. package/dist/error.js +72 -0
  26. package/dist/error.js.map +1 -0
  27. package/dist/index.d.ts +9 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +28 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/remote-object/body-parser-configurer.d.ts +28 -0
  32. package/dist/remote-object/body-parser-configurer.d.ts.map +1 -0
  33. package/dist/remote-object/body-parser-configurer.js +103 -0
  34. package/dist/remote-object/body-parser-configurer.js.map +1 -0
  35. package/dist/remote-object/endpoint-resolver.d.ts +12 -0
  36. package/dist/remote-object/endpoint-resolver.d.ts.map +1 -0
  37. package/dist/remote-object/endpoint-resolver.js +58 -0
  38. package/dist/remote-object/endpoint-resolver.js.map +1 -0
  39. package/dist/remote-object/error-handler.d.ts +16 -0
  40. package/dist/remote-object/error-handler.d.ts.map +1 -0
  41. package/dist/remote-object/error-handler.js +149 -0
  42. package/dist/remote-object/error-handler.js.map +1 -0
  43. package/dist/remote-object/index.d.ts +8 -0
  44. package/dist/remote-object/index.d.ts.map +1 -0
  45. package/dist/remote-object/index.js +24 -0
  46. package/dist/remote-object/index.js.map +1 -0
  47. package/dist/remote-object/interfaces.d.ts +42 -0
  48. package/dist/remote-object/interfaces.d.ts.map +1 -0
  49. package/dist/remote-object/interfaces.js +3 -0
  50. package/dist/remote-object/interfaces.js.map +1 -0
  51. package/dist/remote-object/middleware-factory.d.ts +14 -0
  52. package/dist/remote-object/middleware-factory.d.ts.map +1 -0
  53. package/dist/remote-object/middleware-factory.js +66 -0
  54. package/dist/remote-object/middleware-factory.js.map +1 -0
  55. package/dist/remote-object/request-data-parser.d.ts +18 -0
  56. package/dist/remote-object/request-data-parser.d.ts.map +1 -0
  57. package/dist/remote-object/request-data-parser.js +75 -0
  58. package/dist/remote-object/request-data-parser.js.map +1 -0
  59. package/dist/remote-object/response-handler.d.ts +6 -0
  60. package/dist/remote-object/response-handler.d.ts.map +1 -0
  61. package/dist/remote-object/response-handler.js +20 -0
  62. package/dist/remote-object/response-handler.js.map +1 -0
  63. package/dist/remote-object.d.ts +50 -0
  64. package/dist/remote-object.d.ts.map +1 -0
  65. package/dist/remote-object.js +102 -0
  66. package/dist/remote-object.js.map +1 -0
  67. package/dist/server/acl-registry.d.ts +12 -0
  68. package/dist/server/acl-registry.d.ts.map +1 -0
  69. package/dist/server/acl-registry.js +21 -0
  70. package/dist/server/acl-registry.js.map +1 -0
  71. package/dist/server/api-config-registry.d.ts +10 -0
  72. package/dist/server/api-config-registry.d.ts.map +1 -0
  73. package/dist/server/api-config-registry.js +19 -0
  74. package/dist/server/api-config-registry.js.map +1 -0
  75. package/dist/server/configuration-manager.d.ts +12 -0
  76. package/dist/server/configuration-manager.d.ts.map +1 -0
  77. package/dist/server/configuration-manager.js +62 -0
  78. package/dist/server/configuration-manager.js.map +1 -0
  79. package/dist/server/endpoint-registrar.d.ts +13 -0
  80. package/dist/server/endpoint-registrar.d.ts.map +1 -0
  81. package/dist/server/endpoint-registrar.js +92 -0
  82. package/dist/server/endpoint-registrar.js.map +1 -0
  83. package/dist/server/express-app-manager.d.ts +14 -0
  84. package/dist/server/express-app-manager.d.ts.map +1 -0
  85. package/dist/server/express-app-manager.js +33 -0
  86. package/dist/server/express-app-manager.js.map +1 -0
  87. package/dist/server/http-server-manager.d.ts +16 -0
  88. package/dist/server/http-server-manager.d.ts.map +1 -0
  89. package/dist/server/http-server-manager.js +40 -0
  90. package/dist/server/http-server-manager.js.map +1 -0
  91. package/dist/server/index.d.ts +9 -0
  92. package/dist/server/index.d.ts.map +1 -0
  93. package/dist/server/index.js +33 -0
  94. package/dist/server/index.js.map +1 -0
  95. package/dist/server/interfaces.d.ts +70 -0
  96. package/dist/server/interfaces.d.ts.map +1 -0
  97. package/dist/server/interfaces.js +3 -0
  98. package/dist/server/interfaces.js.map +1 -0
  99. package/dist/server/lifecycle-manager.d.ts +19 -0
  100. package/dist/server/lifecycle-manager.d.ts.map +1 -0
  101. package/dist/server/lifecycle-manager.js +60 -0
  102. package/dist/server/lifecycle-manager.js.map +1 -0
  103. package/dist/server-config.d.ts +10 -0
  104. package/dist/server-config.d.ts.map +1 -0
  105. package/dist/server-config.js +3 -0
  106. package/dist/server-config.js.map +1 -0
  107. package/dist/server.d.ts +53 -0
  108. package/dist/server.d.ts.map +1 -0
  109. package/dist/server.js +115 -0
  110. package/dist/server.js.map +1 -0
  111. package/package.json +66 -0
  112. 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,4 @@
1
+ import { ServerAclRoleAssessorAll } from './all';
2
+ export declare const roleAssessors: ServerAclRoleAssessorAll[];
3
+ export default roleAssessors;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -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"}