@xeplr/base-apis 2.0.1 → 2.0.3

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 (3) hide show
  1. package/README.md +269 -25
  2. package/index.js +9 -0
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,35 +1,279 @@
1
1
  # @xeplr/base-apis
2
2
 
3
- The service layer for a xeplr app: `createApp` wires an Express service, and
4
- `genericRoute` gives any model a full CRUD API. Composes with `@xeplr/db` and
5
- `@xeplr/auth`.
3
+ **The service layer for a xeplr app.** `createApp` builds an Express service that is **authenticated by default**, `genericRoute` gives a model hierarchy a full CRUD API, and the rest — env checks, uploads, server-sent events, clustering — is what every service otherwise hand-rolls.
6
4
 
7
- ## Setup
5
+ ```js
6
+ var { createApp, genericRoute } = require('@xeplr/base-apis')
8
7
 
9
- 1. Install `@xeplr/base-apis` (add `@xeplr/db`, `@xeplr/auth`, `dotenv`).
10
- 2. Create a file env.required.js
11
- 3. For anything
12
- 3. Each API is by default guarded through Auth System. But you will need following variables in your env file for AUTH: AUTH_DB_NAME, AUTH_DB_CONNECTION_INFO_ENCRYPTED, XEPLR_AUTH_MIGRATIONS, AUTH_PORT.
13
- 4. The XEPLR_AUTH_MIGRATIONS is y
8
+ createApp(process.env.PORT, 'myapp', {
9
+ requiredEnv: require('./env.required'),
10
+ routes: { '/api/departments': genericRoute({ key: 'department', model: Department }) }
11
+ })
12
+ ```
13
+
14
+ ## How the pieces fit
15
+
16
+ | what | where |
17
+ |---|---|
18
+ | An Express service: logging, CORS, body parsing, auth gate, routes, 404, errors | `createApp(port, appName, options)` |
19
+ | Who the caller is | the **auth gate** — asks `@xeplr/auth` at `AUTH_URL`; sets `req.user` and `req.access` |
20
+ | List / get / save / delete for a model and its children | `genericRoute(hierarchy)` |
21
+ | Refusing to start without the settings a service needs | `checkEnv` / `requiredEnv` / `xeplr-check-env` |
22
+ | Server → browser push | `sse` |
23
+ | Several processes | `runCluster`, `broadcast` |
24
+ | A server without Express | `createHttpServer.init(port, name, handler)` |
25
+
26
+ Models, tenancy and connections come from [`@xeplr/db`](https://www.npmjs.com/package/@xeplr/db).
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ npm i @xeplr/base-apis express
32
+ ```
33
+
34
+ Peers: `express` (4 or 5), `multer` (optional — only for uploads). Depends on `@xeplr/logs` and `@xeplr/utils`.
35
+
36
+ ## Quick start
37
+
38
+ ```js
39
+ // env.required.js
40
+ module.exports = ['XEPLR_DB_CONNECTION', 'ENCRYPTION_KEY', 'DB_API'].concat(require('@xeplr/base-apis').gateRequiredEnv)
41
+ ```
42
+
43
+ ```js
44
+ var { createApp, genericRoute } = require('@xeplr/base-apis')
45
+ var { getConnection, registerMTs, mtMiddleware, BaseModel } = require('@xeplr/db')
46
+
47
+ registerMTs({ l1: { name: 'companyId', header: 'x-company-id' } })
48
+ await getConnection(process.env.DB_API, process.env.XEPLR_DB_CONNECTION)
49
+
50
+ class Department extends BaseModel { static get tableName() { return 'departments' } }
51
+
52
+ createApp(3000, 'myapp', {
53
+ requiredEnv: require('./env.required'),
54
+ auth: { publicPaths: ['/public/'] }, // omitted = every route gated
55
+ middleware: [mtMiddleware()], // runs after the gate, so req.user is set
56
+ routes: { '/api/departments': genericRoute({ key: 'department', model: Department }) }
57
+ })
58
+ ```
59
+
60
+ ## Exports
61
+
62
+ | export | |
63
+ |---|---|
64
+ | `createApp(port, appName, options)` | Express service → `{ app, server }` |
65
+ | `genericRoute(hierarchy, { auth })` | CRUD router |
66
+ | `genericController` | `{ list, getById, save, deleteByIds }` — the handlers behind `genericRoute` |
67
+ | `remoteAuth(config)` | the default gate, as middleware |
68
+ | `gateRequiredEnv` | `['AUTH_URL']` |
69
+ | `checkEnv(names, options)` | missing-variable check |
70
+ | `uploadRoute(options)` | a standalone "receive a file, return its path" router |
71
+ | `sse` | `{ handler, createHandler, publish, defaultAuthorizeTopics }` |
72
+ | `runCluster(options)`, `broadcast(msg)`, `isPrimary()`, `isWorker()` | Node cluster helpers |
73
+ | `createHttpServer` | `{ init(port, appName, handler) }` — a plain `http.Server` |
14
74
 
15
- Example:
75
+ ## createApp
16
76
 
17
- ```bash
18
- var { resolveConfig, getConnection, bindModels } = require('@xeplr/db');
77
+ `createApp(port, appName, options)` listens on `port` (a number or a named pipe) and returns `{ app, server }`. `EADDRINUSE` / `EACCES` print a message and exit.
19
78
 
20
- async function setup() {
21
- var dbName = process.env.DB_API || 'xeplr_bi';
22
- await resolveConfig('api', process.env.BI_CONNECTION); // one shared secret
23
- var connection = getConnection(dbName, { connectionName: 'api' });
24
- bindModels(connection);
25
- return connection;
26
- }
79
+ | option | |
80
+ |---|---|
81
+ | `requiredEnv` | variable names; any missing → message and `process.exit(1)` before anything starts |
82
+ | `auth` | the gate — see below. **On when omitted** |
83
+ | `middleware` | array, mounted after the gate |
84
+ | `routes` | `{ '/path': router }` |
85
+ | `log` | `@xeplr/logs` settings (`logDir` `'./logs'`, `fatalThreshold`, `emailThreshold`, `emailTo`, `emailService`), or `false` |
86
+ | `corsOptions` | passed to `cors` (default: allow all) |
87
+ | `staticDir` | `express.static` directory |
88
+ | `views` | `{ engine: 'pug', dir }` — also switches 404 and errors to rendered pages |
89
+ | `upload` | file upload — see below |
90
+ | `sse` | `true` for `GET /events`, or `{ path }` |
91
+ | `errorHandler` | replaces the default `{ error: message }` JSON handler |
27
92
 
28
- module.exports = setup();
93
+ ### Order of the middleware
94
+
95
+ 1. request logger (`@xeplr/logs` — sets `req.log`), unless `log: false`
96
+ 2. view engine, `cors`, `express.json`, `express.urlencoded`, `cookie-parser`
97
+ 3. `staticDir`
98
+ 4. uploaded files served at `upload.serveRoute` — **before the gate, so public**
99
+ 5. **the auth gate**
100
+ 6. `options.middleware`
101
+ 7. `POST upload.route`
102
+ 8. SSE endpoint
103
+ 9. `options.routes`
104
+ 10. 404, then the error handler
105
+
106
+ ### The auth gate
107
+
108
+ | `auth` | result |
109
+ |---|---|
110
+ | omitted | gated through `remoteAuth` with `AUTH_URL`. **No `AUTH_URL` → `createApp` throws** |
111
+ | `{ url, publicPaths, cacheSeconds, endpoint, timeoutMs }` | gated, configured here |
112
+ | `{ middleware: fn }` | your gate instead — how `@xeplr/auth`'s own server gates itself (it cannot ask itself over HTTP) |
113
+ | `false` | public, on purpose and visible in the diff |
114
+
115
+ Why on by default: a service that forgot to configure auth used to boot clean, pass every health check, and serve every route to anybody — nothing distinguished it from a working one.
116
+
117
+ `remoteAuth` sends the caller's `Authorization: Bearer …` to `url + endpoint` (`/auth/api/me`) and puts the answer's `user` and `access` on `req.user` / `req.access`.
118
+
119
+ | situation | response |
120
+ |---|---|
121
+ | path starts with a `publicPaths` entry | skipped |
122
+ | no `Bearer` token | 401 `No token provided` |
123
+ | auth answers 401 / 403 | same status, `Invalid or expired token` |
124
+ | auth unreachable, times out (`timeoutMs`, 5000), answers anything else, or answers non-JSON | **503 `Authentication service unavailable` — never let through** |
125
+
126
+ - **Validated over HTTP, not in-process** — so a service needs no `@xeplr/auth` dependency (it would be a cycle), no JWT secret and no Redis. The cost is a hop per request.
127
+ - **`cacheSeconds` (default 5)** caches a validated token per process; a revoked token keeps working that long here. `0` checks every request.
128
+ - **`X-New-Token` is forwarded** (with `Access-Control-Expose-Headers`) when the auth service slides a token, and that answer is **not cached** — otherwise the next requests would be answered from memory without telling the client to switch, and it would be logged out when the window closed.
129
+
130
+ ### Uploads
131
+
132
+ ```js
133
+ createApp(port, 'myapp', { upload: { destination: './uploads', allowedTypes: ['image/*'], maxSize: 2e6 } })
29
134
  ```
30
135
 
31
- 4. Define models (extend `BaseModel`).
32
- 5. Expose CRUD routes with `genericRoute`, one per resource.
33
- 6. Create the service with `createApp` — mount routes, add auth middleware.
34
- 7. Write migrations (schema + data) and run them.
35
- 8. Start the app.
136
+ | option | default |
137
+ |---|---|
138
+ | `route` | `/internal/upload` — `POST`, multipart, behind the gate |
139
+ | `fieldName` | `file` |
140
+ | `maxFiles` | `10` |
141
+ | `destination` | `UPLOAD_DIR`, else `./uploads` |
142
+ | `allowedTypes` | `['*']` |
143
+ | `maxSize` | `5242880` (5 MB) |
144
+ | `serveRoute` | `/uploads` — static, **before the gate** |
145
+ | `auth` | a function passed to `@xeplr/utils`' `FileUploader` |
146
+
147
+ Returns `{ filename, originalName, size, mimetype }`, or an array of them for several files; 400 when none arrived. Files are stored under a generated id plus the original extension.
148
+
149
+ `uploadRoute({ destination, maxSize, allowedTypes, fieldName, auth })` is the same as a router of its own: `POST /` (single file) → `{ filePath, fileName, originalName, size, mimetype }`.
150
+
151
+ ## genericRoute
152
+
153
+ ```js
154
+ genericRoute({
155
+ key: 'department',
156
+ model: Department,
157
+ children: [
158
+ { key: 'employees', model: Employee, foreignKey: 'departmentId',
159
+ children: [{ key: 'monthlySalaries', model: MonthlySalary, foreignKey: 'employeeId' }] }
160
+ ]
161
+ }, { auth: { list: gate, getById: gate, save: [gate, adminOnly], delete: [gate, adminOnly] } })
162
+ ```
163
+
164
+ `children[].key` is the Objection relation name; it becomes the eager graph (`employees.monthlySalaries`). `auth` is a middleware, an array, or per operation; omitted means no extra middleware (the `createApp` gate still applies).
165
+
166
+ | route | does |
167
+ |---|---|
168
+ | `GET /` | list with children; `?page=1&limit=50` → `pagination: { page, limit, total, totalPages }`; `?limit=0` returns everything |
169
+ | `GET /:id` | one record with children; 404 if none |
170
+ | `POST /save` | a changeset array, **in one transaction** → `updatedIds` |
171
+ | `POST /delete` | `{ ids }` → `isActive = false` |
172
+
173
+ Queries go through `Model.query()`, so a `BaseModel` gets its active, tenant and application filters.
174
+
175
+ ### The changeset
176
+
177
+ ```json
178
+ [
179
+ { "id": "a1b2", "name": "Engineering", "employees": [
180
+ { "name": "New Hire" },
181
+ { "id": "d4e5", "role": "Senior" },
182
+ { "id": "x1y2", "deleted": true } ] },
183
+ { "name": "New Department" }
184
+ ]
185
+ ```
186
+
187
+ | entry | action |
188
+ |---|---|
189
+ | no `id` | insert with a generated id |
190
+ | `id` that exists | patch the fields given |
191
+ | `id` that does not exist | insert with that id |
192
+ | `deleted: true` | `isActive = false` |
193
+ | a key matching a child `key` | processed recursively; the child's `foreignKey` is set to the parent id |
194
+
195
+ Every query carries `.context({ user: req.user, afterCommit: [] })`. A model hook that must write to **another connection** pushes a function onto `afterCommit` instead of writing inline: the callbacks run only after the transaction commits, so a later failure in the same changeset cannot leave an orphan behind. A failing callback is logged, not returned.
196
+
197
+ ### Responses and errors
198
+
199
+ Responses use `@xeplr/utils`' shape: `{ code, message, error, dataArray, updatedIds, sessionId }` (+ `pagination`). Database errors are mapped by `err.name` (so two copies of objection still match):
200
+
201
+ | error | status |
202
+ |---|---|
203
+ | `ValidationError` | 422 |
204
+ | `UniqueViolationError`, `ForeignKeyViolationError` | 409 |
205
+ | `NotNullViolationError` (`<column> is required`), `CheckViolationError`, `DataError` | 400 |
206
+ | `NotFoundError` | 404 |
207
+ | anything else | 500 |
208
+
209
+ ## checkEnv
210
+
211
+ ```js
212
+ checkEnv(['XEPLR_DB_CONNECTION', 'AUTH_URL'], { appName: 'myapp' })
213
+ ```
214
+
215
+ Options: `appName`, `exit` (default `true` — `process.exit(1)` on failure), `throw` (throw instead), `silent` (no success line). Returns `{ ok, missing }`.
216
+
217
+ ```sh
218
+ npx xeplr-check-env # checks ./env.required.js
219
+ npx xeplr-check-env --file ./env.list.js --name myapp
220
+ ```
221
+
222
+ The same check at build time. It reads `process.env` only; load the `.env` first (e.g. `dotenv-cli`).
223
+
224
+ `gateRequiredEnv` is separate from any plain `requiredEnv` on purpose: only an app using the default gate needs `AUTH_URL`; one with `auth: false` or its own `middleware` would have to invent a value.
225
+
226
+ ## Server-sent events
227
+
228
+ ```js
229
+ var { sse } = require('@xeplr/base-apis')
230
+
231
+ app.get('/events', sse.createHandler({ validateTicket: ticketService.consumeTicket }))
232
+ sse.publish('user:42:jobs', { jobId, status })
233
+ ```
234
+
235
+ ```js
236
+ new EventSource('/events?ticket=<tk>&topics=user:me:jobs').onmessage = (e) => { const { topic, data } = JSON.parse(e.data) }
237
+ ```
238
+
239
+ | | |
240
+ |---|---|
241
+ | `createHandler({ validateTicket, authorizeTopics? })` | production handler. `?ticket` required (401), `?topics` required (400). `validateTicket(ticket)` → `{ userId, scopes }` or `null` (401); throwing → 500. No authorised topic → 403 |
242
+ | `defaultAuthorizeTopics(payload, requested)` | rewrites `user:me:` to `user:<userId>:`, keeps topics matching a scope glob (default scope `user:me:*`), and **always drops another user's `user:<id>:` topic**, whatever the scopes say |
243
+ | `handler` | **no ticket, no topic check** — what `createApp({ sse: true })` mounts. For development and private networks |
244
+ | `publish(topic, data)` | writes to local subscribers and relays to every cluster worker |
245
+
246
+ A comment line is sent every 25 s so proxies do not drop idle streams; subscriptions are removed when the connection closes. The per-topic check exists because checking the ticket on connect is easy, while trusting client-supplied topics is how one user ends up subscribed to another's.
247
+
248
+ ## Cluster
249
+
250
+ ```js
251
+ runCluster({
252
+ workers: 4, // default: CPU count
253
+ respawn: true, // default
254
+ start: function () { createApp(3000, 'myapp', { … }) },
255
+ onMessage: function (msg) { if (msg.type === 'cache-clear') clearCache() }
256
+ })
257
+
258
+ broadcast({ type: 'cache-clear' }) // from a worker: relayed to every other worker
259
+ ```
260
+
261
+ ## Environment
262
+
263
+ | variable | |
264
+ |---|---|
265
+ | `AUTH_URL` | the auth service base URL — **required** for the default gate |
266
+ | `UPLOAD_DIR` | upload destination when `upload.destination` is not given |
267
+ | `NODE_ENV` | anything but `production` puts the logger in dev mode |
268
+
269
+ ## Tests
270
+
271
+ ```sh
272
+ npm test
273
+ ```
274
+
275
+ The auth gate against a stand-in auth service (refusal to boot, 401 / 503, public paths, caching, `X-New-Token`), SSE publish and subscribe, and ticket-authorised SSE.
276
+
277
+ ## License
278
+
279
+ MIT
package/index.js CHANGED
@@ -1,4 +1,13 @@
1
+ // Only for apps that use createApp's DEFAULT gate. Deliberately NOT a plain
2
+ // `requiredEnv`: an app that answers the auth question another way — auth:false
3
+ // for the auth service itself, or auth:{middleware} for its own check — never
4
+ // reaches AUTH_URL, and forcing it to set one would be a variable it must
5
+ // invent a value for. Same split as @xeplr/email's requiredEnv vs
6
+ // templatesRequiredEnv: one list per capability, spread only if you use it.
7
+ var gateRequiredEnv = ['AUTH_URL'];
8
+
1
9
  module.exports = {
10
+ gateRequiredEnv: gateRequiredEnv,
2
11
  createApp: require('./express'),
3
12
  createHttpServer: require('./http'),
4
13
  checkEnv: require('./lib/checkEnv'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/base-apis",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Base API bootstrapper — Express and plain HTTP server factories with middleware injection",
5
5
  "main": "index.js",
6
6
  "bin": {