@xeplr/base-apis 2.0.6 → 3.0.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 CHANGED
@@ -67,7 +67,8 @@ createApp(3000, 'myapp', {
67
67
  | `remoteAuth(config)` | the default gate, as middleware |
68
68
  | `gateRequiredEnv` | `['AUTH_URL']` |
69
69
  | `checkEnv(names, options)` | missing-variable check |
70
- | `uploadRoute(options)` | a standalone "receive a file, return its path" router |
70
+ | `uploadRoute(options)` | a standalone "receive a file, return its path" router — into the requester's own `uploads/` unless told otherwise |
71
+ | `sendTenantFile(res, name, { download? })` | serve a stored file back, only from the requesting tenant's own folder (404 another company's name, 400 a path out, 400 no tenant) |
71
72
  | `sse` | `{ handler, createHandler, publish, defaultAuthorizeTopics }` |
72
73
  | `runCluster(options)`, `broadcast(msg)`, `isPrimary()`, `isWorker()` | Node cluster helpers |
73
74
  | `createHttpServer` | `{ init(port, appName, handler) }` — a plain `http.Server` |
@@ -116,24 +117,27 @@ Why on by default: a service that forgot to configure auth used to boot clean, p
116
117
 
117
118
  `remoteAuth` sends the caller's `Authorization: Bearer …` **and the request it is serving** (`x-verify-method`, `x-verify-path`) to `url + endpoint` (`/auth/api/verify`). Auth answers who the caller is and whether they may call that API, so **every request is checked for access, not only for a signed-in token**; the answer's `user` and `access` go on `req.user` / `req.access`. See `@xeplr/auth`'s "Every request is checked".
118
119
 
120
+ **And whether the caller belongs to the company and workspace the request names.** When the app registered tenancy (`@xeplr/db`'s `registerMTs`) and the request carries those headers, the gate sends them as `x-verify-tenant` (`{"l1":"<company>","l2":"<workspace>"}`), and auth refuses a caller without a membership for each (`403 NOT_A_MEMBER`, Super Admin excepted) — before the app sees the request. So `mtMiddleware`'s tenant, which every query and every file path (`@xeplr/db`'s `dataDir`) reads, can be trusted on every request of every app, and no route has to add `mtMembershipMiddleware` itself. An auth that does not answer `tenant: 'checked'` (older than `@xeplr/auth` 1.4) is refused (503), never taken as a member.
121
+
119
122
  | situation | response |
120
123
  |---|---|
121
124
  | path starts with a `publicPaths` entry | skipped |
122
125
  | no `Bearer` token | 401 `No token provided` |
123
- | auth answers `403` with `code` `DISABLED`, `HIDDEN` or `UNLICENSED` | 403 with that `code`: signed in, but not allowed this API (`UNLICENSED`: its module is not in the caller's licence) |
126
+ | auth answers `403` with `code` `DISABLED`, `HIDDEN`, `UNLICENSED` or `NOT_A_MEMBER` | 403 with that `code`: signed in, but not allowed this API (`UNLICENSED`: its module is not in the caller's licence; `NOT_A_MEMBER`: not in the company or workspace the request names) |
127
+ | the request names a company, and auth answers without `tenant: 'checked'` | **503**: an auth that ignored the question is not an answer |
124
128
  | auth answers 401 / other 403 | same status, `Invalid or expired token` |
125
129
  | auth answers 200 **without** `state: 'enabled'` (an older auth, or an `endpoint` such as `/me` that only says who the caller is) | **503**: "did not say no" is not "said yes" |
126
130
  | auth unreachable, times out (`timeoutMs`, 5000), answers anything else, or answers non-JSON | **503 `Authentication service unavailable` — never let through** |
127
131
 
128
132
  - **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.
129
133
  - **A burst makes one call.** Requests with the same token, method and path that arrive while a check is on its way share its answer: ten at once, one call to auth. This holds with `cacheSeconds: 0` too.
130
- - **`cacheSeconds` (default 5)** caches each answer (allowed or refused) per token, method and path, per process; a revoked token or a changed mapping takes that long to reach here. `0` checks every request.
134
+ - **`cacheSeconds` (default 5)** caches each answer (allowed or refused) per token, method, path **and company named**, per process — another company with the same token is asked about afresh; a revoked token or a changed mapping takes that long to reach here. `0` checks every request.
131
135
  - **`X-New-Token` is forwarded** (with `Access-Control-Expose-Headers`) when the auth service slides a token, to every request in the burst that shared the check, so none is rejected because the token changed. 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.
132
136
 
133
137
  ### Uploads
134
138
 
135
139
  ```js
136
- createApp(port, 'myapp', { upload: { destination: './uploads', allowedTypes: ['image/*'], maxSize: 2e6 } })
140
+ createApp(port, 'myapp', { upload: { allowedTypes: ['image/*'], maxSize: 2e6 } })
137
141
  ```
138
142
 
139
143
  | option | default |
@@ -141,15 +145,19 @@ createApp(port, 'myapp', { upload: { destination: './uploads', allowedTypes: ['i
141
145
  | `route` | `/internal/upload` — `POST`, multipart, behind the gate |
142
146
  | `fieldName` | `file` |
143
147
  | `maxFiles` | `10` |
144
- | `destination` | `UPLOAD_DIR`, else `./uploads` |
148
+ | `destination` | **the requester's own `uploads/`** under `XEPLR_DATA_DIR` (`@xeplr/db`'s `dataDir`), asked per upload — never one folder for every company. A request with no tenant is refused. A folder named here is the app's own, and the app serves it |
145
149
  | `allowedTypes` | `['*']` |
146
150
  | `maxSize` | `5242880` (5 MB) |
147
- | `serveRoute` | `/uploads` — static, **before the gate** |
151
+ | `serveRoute` | `/uploads` — `GET /uploads/:name`, **behind the gate**, from the requester's own `uploads/` (`sendTenantFile`). It used to be `express.static` over the whole folder before the gate: every company's files to anyone with a name. Only with the default `destination` |
148
152
  | `auth` | a function passed to `@xeplr/utils`' `FileUploader` |
149
153
 
150
154
  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.
151
155
 
152
- `uploadRoute({ destination, maxSize, allowedTypes, fieldName, auth })` is the same as a router of its own: `POST /` (single file) → `{ filePath, fileName, originalName, size, mimetype }`.
156
+ `uploadRoute({ destination, maxSize, allowedTypes, fieldName, auth })` is the same as a router of its own: `POST /` (single file) → `{ filePath, fileName, originalName, size, mimetype }`. With no `destination`, into the requester's own `uploads/`.
157
+
158
+ **Serving a stored file back:** `sendTenantFile(res, 'uploads/' + name)` — the one way. It reads only the requesting tenant's own folder: another company's name is 404, a path out (`../`, a link, a full path elsewhere) is 400, a request with no tenant is 400. Never `express.static` over a shared folder.
159
+
160
+ **From 2.x (3.0.0):** an upload with no folder named goes into the requester's own folder (it was `UPLOAD_DIR` / `./uploads`, shared), and `createApp`'s uploads are no longer served before the gate. Set `XEPLR_DATA_DIR` (`@xeplr/db`'s `dataRequiredEnv`) and register the application (`registerApplication`).
153
161
 
154
162
  ## genericRoute
155
163
 
@@ -266,7 +274,7 @@ broadcast({ type: 'cache-clear' }) // from a worker: relayed to every other wo
266
274
  | variable | |
267
275
  |---|---|
268
276
  | `AUTH_URL` | the auth service base URL — **required** for the default gate |
269
- | `UPLOAD_DIR` | upload destination when `upload.destination` is not given |
277
+ | `XEPLR_DATA_DIR` | the root of every tenant's files: an upload with no folder named goes in the requester's `uploads/` under it (`@xeplr/db` `dataRequiredEnv`) |
270
278
  | `NODE_ENV` | anything but `production` puts the logger in dev mode |
271
279
 
272
280
  ## Tests
@@ -275,7 +283,7 @@ broadcast({ type: 'cache-clear' }) // from a worker: relayed to every other wo
275
283
  npm test
276
284
  ```
277
285
 
278
- 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.
286
+ 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. `tenant-gate.test.js`: the gate naming the company and workspace, refusing a non-member before the app sees the request, refusing an auth that did not check, caching per company; a real multipart upload landing in the company's own `uploads/` and served back only to it; `createApp`'s uploads no longer public; `sendTenantFile` refusing another company's name, a path out and no tenant.
279
287
 
280
288
  ## License
281
289
 
package/express.js CHANGED
@@ -37,12 +37,12 @@ var logs = require('@xeplr/logs');
37
37
  * @param {object} [options.log.emailService] - Email service instance
38
38
  * @param {object} [options.upload] - File upload config
39
39
  * @param {string} [options.upload.route='/internal/upload'] - Upload endpoint path
40
- * @param {string} [options.upload.destination='./uploads'] - Storage directory
40
+ * @param {string} [options.upload.destination] - A folder of the app's own; default: the requester's own uploads/ under XEPLR_DATA_DIR
41
41
  * @param {string[]} [options.upload.allowedTypes=['*']] - MIME types
42
42
  * @param {number} [options.upload.maxSize=5242880] - Max file size in bytes
43
43
  * @param {string} [options.upload.fieldName='file'] - Form field name
44
44
  * @param {number} [options.upload.maxFiles=10] - Max files per request
45
- * @param {string} [options.upload.serveRoute='/uploads'] - Static serve path for uploaded files
45
+ * @param {string} [options.upload.serveRoute='/uploads'] - Where an upload is fetched back, signed in, from the requester's own uploads/ (default destination only)
46
46
  * @returns {{ app: Express, server: http.Server }}
47
47
  */
48
48
  function createApp(port, appName, options) {
@@ -86,13 +86,6 @@ function createApp(port, appName, options) {
86
86
  app.use(express.static(options.staticDir));
87
87
  }
88
88
 
89
- // ── File upload (static serve before auth, upload route after auth) ──
90
- if (options.upload) {
91
- var serveRoute = options.upload.serveRoute || '/uploads';
92
- var destination = path.resolve(options.upload.destination || process.env.UPLOAD_DIR || './uploads');
93
- app.use(serveRoute, express.static(destination));
94
- }
95
-
96
89
  // ── Auth gate ──
97
90
  //
98
91
  // ON BY DEFAULT. A service that forgets to configure auth must not answer
@@ -146,7 +139,10 @@ function createApp(port, appName, options) {
146
139
  if (options.upload) {
147
140
  var FileUploader = require('@xeplr/utils').FileUploader;
148
141
  var uploadCfg = options.upload;
149
- var destination = path.resolve(uploadCfg.destination || process.env.UPLOAD_DIR || './uploads');
142
+ // Named: that folder. Not named: the requesting tenant's own uploads/
143
+ // under XEPLR_DATA_DIR, asked per upload — never one folder for every
144
+ // company (lib/uploadRoute.js tenantUploads).
145
+ var destination = uploadCfg.destination ? path.resolve(uploadCfg.destination) : require('./lib/uploadRoute').tenantUploads;
150
146
  var uploader = new FileUploader({
151
147
  auth: uploadCfg.auth || null,
152
148
  allowedTypes: uploadCfg.allowedTypes || ['*'],
@@ -158,6 +154,17 @@ function createApp(port, appName, options) {
158
154
  var fieldName = uploadCfg.fieldName || 'file';
159
155
  var maxFiles = uploadCfg.maxFiles || 10;
160
156
 
157
+ // SERVED BACK SIGNED IN, from the requester's own uploads/ only. This was
158
+ // express.static over the whole folder, BEFORE the gate: every company's
159
+ // files to anyone who had a name. Now behind the gate (which checks the
160
+ // caller belongs to the company named) and read in that company's folder.
161
+ // With a folder of the app's own (`destination`), the app serves it.
162
+ if (!uploadCfg.destination) {
163
+ var serveRoute = uploadCfg.serveRoute || '/uploads';
164
+ var sendTenantFile = require('./lib/sendTenantFile');
165
+ app.get(serveRoute + '/:name', function(req, res) { sendTenantFile(res, 'uploads/' + req.params.name); });
166
+ }
167
+
161
168
  app.post(uploadRoute, uploader.array(fieldName, maxFiles), function(req, res) {
162
169
  if (!req.files || req.files.length === 0) {
163
170
  return res.status(400).json({ error: 'No files uploaded' });
package/index.js CHANGED
@@ -15,6 +15,8 @@ module.exports = {
15
15
  remoteAuth: require('./lib/remoteAuth'),
16
16
  genericController: require('./lib/genericController'),
17
17
  uploadRoute: require('./lib/uploadRoute'),
18
+ // Serve a stored file back, only from the requesting tenant's own folder.
19
+ sendTenantFile: require('./lib/sendTenantFile'),
18
20
  runCluster: require('./lib/cluster').runCluster,
19
21
  broadcast: require('./lib/cluster').broadcast,
20
22
  isPrimary: require('./lib/cluster').isPrimary,
package/lib/remoteAuth.js CHANGED
@@ -37,7 +37,33 @@ var DEFAULT_TIMEOUT_MS = 5000;
37
37
  // The 403 codes auth gives for a signed-in caller this API is refused to. Any
38
38
  // other 403 is a token problem. UNLICENSED: the API's module is not in the
39
39
  // caller's licence (@xeplr/auth's licence.use).
40
- var REFUSALS = ['DISABLED', 'HIDDEN', 'UNLICENSED'];
40
+ // NOT_A_MEMBER: the request names a company or workspace the caller does not
41
+ // belong to (see tenantOf).
42
+ var REFUSALS = ['DISABLED', 'HIDDEN', 'UNLICENSED', 'NOT_A_MEMBER'];
43
+ var LEVELS = ['l1', 'l2', 'l3', 'l4'];
44
+
45
+ /**
46
+ * THE COMPANY AND WORKSPACE THIS REQUEST NAMES, for auth to check membership
47
+ * of — in this gate, so it is checked for every request of every app built on
48
+ * createApp, and no route can forget it (@xeplr/db's mtMiddleware would
49
+ * otherwise trust whatever the header says). By level, as auth keeps
50
+ * memberships: '{"l1":"<company>","l2":"<workspace>"}', or null when the app
51
+ * registered no tenancy or the request names none. Read from the app's own
52
+ * registerMTs(), per request.
53
+ */
54
+ function tenantOf(req) {
55
+ var mt;
56
+ try { mt = require('@xeplr/db').getMtConfig(); } catch (e) { return null; }
57
+ if (!mt || !mt.enabled) return null;
58
+ var named = {};
59
+ var any = false;
60
+ LEVELS.forEach(function (level) {
61
+ var slot = mt.slots && mt.slots[level];
62
+ var value = slot && req.headers[slot.header];
63
+ if (value) { named[level] = String(value); any = true; }
64
+ });
65
+ return any ? JSON.stringify(named) : null;
66
+ }
41
67
 
42
68
  /**
43
69
  * @param {object} config
@@ -99,11 +125,13 @@ function remoteAuth(config) {
99
125
  }
100
126
 
101
127
  // One call to auth → an outcome every waiter can apply.
102
- async function ask(header, method, path) {
128
+ async function ask(header, method, path, tenant) {
103
129
  var response;
130
+ var headers = { authorization: header, 'x-verify-method': method, 'x-verify-path': path };
131
+ if (tenant) headers['x-verify-tenant'] = tenant;
104
132
  try {
105
133
  response = await fetch(url + endpoint, {
106
- headers: { authorization: header, 'x-verify-method': method, 'x-verify-path': path },
134
+ headers: headers,
107
135
  signal: AbortSignal.timeout(timeoutMs)
108
136
  });
109
137
  } catch (err) {
@@ -153,6 +181,12 @@ function remoteAuth(config) {
153
181
  if (!body || body.state !== 'enabled') {
154
182
  return { kind: 'unavailable', why: 'auth answered without an access state; update @xeplr/auth' };
155
183
  }
184
+ // Asked about a company and not told it was checked: an older auth that
185
+ // ignored the question. Refused for the same reason — "did not say no" is
186
+ // not "is a member".
187
+ if (tenant && body.tenant !== 'checked') {
188
+ return { kind: 'unavailable', why: 'auth did not check membership of the company named; update @xeplr/auth to 1.4 or later' };
189
+ }
156
190
 
157
191
  return { kind: 'ok', newToken: newToken, user: body.user, access: body.access };
158
192
  }
@@ -188,7 +222,10 @@ function remoteAuth(config) {
188
222
  var token = header.slice('Bearer '.length);
189
223
  var method = req.method;
190
224
  var path = (req.baseUrl || '') + req.path;
191
- var key = token + '\n' + method + ' ' + path;
225
+ var tenant = tenantOf(req);
226
+ // The answer is about this token, this request AND this company: another
227
+ // company named with the same token is asked about afresh.
228
+ var key = token + '\n' + method + ' ' + path + '\n' + (tenant || '');
192
229
 
193
230
  if (cacheSeconds > 0) {
194
231
  var hit = cache.get(key);
@@ -197,7 +234,7 @@ function remoteAuth(config) {
197
234
 
198
235
  var inFlight = pending.get(key);
199
236
  if (!inFlight) {
200
- inFlight = ask(header, method, path);
237
+ inFlight = ask(header, method, path, tenant);
201
238
  pending.set(key, inFlight);
202
239
  inFlight.then(function (outcome) {
203
240
  pending.delete(key);
@@ -220,3 +257,4 @@ function remoteAuth(config) {
220
257
  }
221
258
 
222
259
  module.exports = remoteAuth;
260
+ module.exports.tenantOf = tenantOf;
@@ -0,0 +1,35 @@
1
+ // SEND A FILE BACK — only from the requesting tenant's own folder.
2
+ //
3
+ // The one way an app serves a stored file: the name is resolved inside the
4
+ // tenant's folder under XEPLR_DATA_DIR (@xeplr/db's dataDir), so another
5
+ // company's name finds nothing here, and a path that climbs out (../, a link,
6
+ // an absolute path elsewhere) is refused before anything is opened. The
7
+ // tenant is the request's, checked by the gate (createApp asks auth whether
8
+ // the caller belongs to it) — never what the client names.
9
+ //
10
+ // router.get('/files/:name', function(req, res) { sendTenantFile(res, 'uploads/' + req.params.name); });
11
+ //
12
+ // 404 when there is nothing there; 400 when the name is not one in the
13
+ // tenant's folder. Never express.static over a shared folder.
14
+ var fs = require('fs');
15
+
16
+ /**
17
+ * @param res the Express response
18
+ * @param name a name in the tenant's folder, e.g. 'report-assets/logo.png'
19
+ * @param options { download: true | '<file name>' } to send as an attachment
20
+ */
21
+ function sendTenantFile(res, name, options) {
22
+ options = options || {};
23
+ var file;
24
+ try {
25
+ file = require('@xeplr/db').dataDir.resolve(String(name || ''), 'The file');
26
+ } catch (err) {
27
+ var status = err.code === 'DATA_DIR_UNSET' ? 500 : 400;
28
+ return res.status(status).json({ error: status === 500 ? 'Files are not set up on this server' : err.message, code: err.code });
29
+ }
30
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return res.status(404).json({ error: 'No such file' });
31
+ if (options.download) return res.download(file, options.download === true ? undefined : options.download);
32
+ return res.sendFile(file);
33
+ }
34
+
35
+ module.exports = sendTenantFile;
@@ -18,7 +18,10 @@ var FileUploader = require('@xeplr/utils/lib/fileUploader');
18
18
  * → { filePath, fileName, originalName, size, mimetype }
19
19
  *
20
20
  * @param {object} [options]
21
- * @param {string} [options.destination='./uploads'] - disk directory to store uploaded files
21
+ * @param {string|Function} [options.destination] - where to keep the file: a
22
+ * folder, or (req) => folder. DEFAULT: the requesting tenant's own
23
+ * `uploads/` folder under XEPLR_DATA_DIR (@xeplr/db's dataDir) — never one
24
+ * folder for every company. A request with no tenant is refused.
22
25
  * @param {number} [options.maxSize=5MB]
23
26
  * @param {string[]} [options.allowedTypes=['*']]
24
27
  * @param {string} [options.fieldName='file']
@@ -31,7 +34,7 @@ function uploadRoute(options) {
31
34
  var fieldName = options.fieldName || 'file';
32
35
 
33
36
  var uploader = new FileUploader({
34
- destination: options.destination,
37
+ destination: options.destination || tenantUploads,
35
38
  maxSize: options.maxSize,
36
39
  allowedTypes: options.allowedTypes
37
40
  });
@@ -53,4 +56,10 @@ function uploadRoute(options) {
53
56
  return router;
54
57
  }
55
58
 
59
+ /** The requesting tenant's uploads/ — asked per upload, inside its context. */
60
+ function tenantUploads() {
61
+ return require('@xeplr/db').dataDir.resolve('uploads', 'The uploads folder');
62
+ }
63
+
56
64
  module.exports = uploadRoute;
65
+ module.exports.tenantUploads = tenantUploads;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xeplr/base-apis",
3
- "version": "2.0.6",
3
+ "version": "3.0.0",
4
4
  "description": "Base API bootstrapper — Express and plain HTTP server factories with middleware injection",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -31,23 +31,31 @@
31
31
  "access": "public"
32
32
  },
33
33
  "dependencies": {
34
+ "@xeplr/logs": "^1.0.0",
35
+ "@xeplr/utils": "^1.1.0",
34
36
  "cookie-parser": "^1.4.7",
35
37
  "cors": "^2.8.6",
36
38
  "debug": "^4.4.3",
37
- "http-errors": "^2.0.1",
38
- "@xeplr/logs": "^1.0.0",
39
- "@xeplr/utils": "^1.0.0"
39
+ "http-errors": "^2.0.1"
40
40
  },
41
41
  "peerDependencies": {
42
+ "@xeplr/db": "^1.1.0",
42
43
  "express": "^4.0.0 || ^5.0.0",
43
44
  "multer": "^2.3.0"
44
45
  },
45
46
  "peerDependenciesMeta": {
46
47
  "multer": {
47
48
  "optional": true
49
+ },
50
+ "@xeplr/db": {
51
+ "optional": true
48
52
  }
49
53
  },
50
54
  "scripts": {
51
55
  "test": "node --test test/*.test.js"
56
+ },
57
+ "devDependencies": {
58
+ "@xeplr/db": "^1.1.0",
59
+ "multer": "^2.5.0"
52
60
  }
53
61
  }