@xeplr/base-apis 1.0.0 → 1.0.1
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 +35 -0
- package/bin/check-env.js +28 -0
- package/express.js +17 -0
- package/http.js +15 -0
- package/index.js +4 -1
- package/lib/checkEnv.js +36 -0
- package/lib/genericController.js +95 -16
- package/lib/sse.js +290 -0
- package/lib/uploadRoute.js +56 -0
- package/package.json +35 -7
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @xeplr/base-apis
|
|
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`.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
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, AUTH_EXT_MIGRATIONS_DIR, AUTH_PORT.
|
|
13
|
+
4. The AUTH_EXT_MIGRATIONS_DIR is y
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
var { resolveConfig, getConnection, bindModels } = require('@xeplr/db');
|
|
19
|
+
|
|
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
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = setup();
|
|
29
|
+
```
|
|
30
|
+
|
|
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.
|
package/bin/check-env.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// xeplr-check-env — build-time env validation. Reads the app's required list
|
|
4
|
+
// and checks it against the environment. Same check createApp runs at startup.
|
|
5
|
+
//
|
|
6
|
+
// xeplr-check-env # reads ./env.required.js
|
|
7
|
+
// xeplr-check-env --file ./env.list.js # custom list module (exports string[])
|
|
8
|
+
//
|
|
9
|
+
// Reads process.env ONLY. The consuming app loads its .env (e.g. via dotenv-cli
|
|
10
|
+
// in the npm script) — @xeplr/* packages never read .env files.
|
|
11
|
+
|
|
12
|
+
var path = require('path');
|
|
13
|
+
|
|
14
|
+
function argOf(name, fallback) {
|
|
15
|
+
var i = process.argv.indexOf('--' + name);
|
|
16
|
+
return (i !== -1 && process.argv[i + 1]) ? process.argv[i + 1] : fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
var file = argOf('file', './env.required.js');
|
|
20
|
+
var required;
|
|
21
|
+
try {
|
|
22
|
+
required = require(path.resolve(process.cwd(), file));
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error('xeplr-check-env: could not load required list "' + file + '": ' + err.message);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
require('../lib/checkEnv')(required, { appName: argOf('name', 'check-env') });
|
package/express.js
CHANGED
|
@@ -3,6 +3,7 @@ var cors = require('cors');
|
|
|
3
3
|
var cookieParser = require('cookie-parser');
|
|
4
4
|
var path = require('path');
|
|
5
5
|
var { createServer } = require('./lib/server');
|
|
6
|
+
var checkEnv = require('./lib/checkEnv');
|
|
6
7
|
var logs = require('@xeplr/logs');
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -12,6 +13,7 @@ var logs = require('@xeplr/logs');
|
|
|
12
13
|
* @param {number|string} port - Port or named pipe to listen on
|
|
13
14
|
* @param {string} appName - Service name (used for debug namespace and console logs)
|
|
14
15
|
* @param {object} [options] - Configuration options
|
|
16
|
+
* @param {string[]} [options.requiredEnv] - Mandatory env var names; missing ones abort startup
|
|
15
17
|
* @param {object} [options.routes] - Route map: { '/path': router }
|
|
16
18
|
* @param {Function[]} [options.middleware] - Middleware to apply before routes (e.g. authMiddleware)
|
|
17
19
|
* @param {object} [options.views] - { engine: 'pug', dir: '/abs/path' }
|
|
@@ -36,6 +38,11 @@ var logs = require('@xeplr/logs');
|
|
|
36
38
|
*/
|
|
37
39
|
function createApp(port, appName, options) {
|
|
38
40
|
options = options || {};
|
|
41
|
+
|
|
42
|
+
// Fail fast if the app's mandatory env vars are missing (same check the
|
|
43
|
+
// xeplr-check-env build step runs — one implementation).
|
|
44
|
+
if (options.requiredEnv) checkEnv(options.requiredEnv, { appName: appName });
|
|
45
|
+
|
|
39
46
|
var app = express();
|
|
40
47
|
|
|
41
48
|
// ── Logger ──
|
|
@@ -114,6 +121,16 @@ function createApp(port, appName, options) {
|
|
|
114
121
|
});
|
|
115
122
|
}
|
|
116
123
|
|
|
124
|
+
// ── SSE endpoint (server→client push) ──
|
|
125
|
+
// Enable with `sse: true` for the default '/events' path, or
|
|
126
|
+
// `sse: { path: '/api/events' }` for a custom one. Mounts AFTER the
|
|
127
|
+
// middleware chain, so any auth middleware you passed in still applies.
|
|
128
|
+
if (options.sse) {
|
|
129
|
+
var sseCfg = options.sse === true ? {} : options.sse;
|
|
130
|
+
var ssePath = sseCfg.path || '/events';
|
|
131
|
+
app.get(ssePath, require('./lib/sse').handler);
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
// ── Routes ──
|
|
118
135
|
if (options.routes) {
|
|
119
136
|
Object.keys(options.routes).forEach(function(routePath) {
|
package/http.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const { createServer } = require('./lib/server');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a plain Node HTTP server (no Express).
|
|
5
|
+
*
|
|
6
|
+
* @param {number|string} port - Port or named pipe to listen on
|
|
7
|
+
* @param {string} appName - Service name (debug namespace + console logs)
|
|
8
|
+
* @param {Function} handler - Request handler: function(req, res)
|
|
9
|
+
* @returns {http.Server}
|
|
10
|
+
*/
|
|
11
|
+
function init(port, appName, handler) {
|
|
12
|
+
return createServer(handler, port, appName);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { init };
|
package/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
module.exports = {
|
|
2
2
|
createApp: require('./express'),
|
|
3
3
|
createHttpServer: require('./http'),
|
|
4
|
+
checkEnv: require('./lib/checkEnv'),
|
|
4
5
|
genericRoute: require('./lib/genericRoute'),
|
|
5
6
|
genericController: require('./lib/genericController'),
|
|
7
|
+
uploadRoute: require('./lib/uploadRoute'),
|
|
6
8
|
runCluster: require('./lib/cluster').runCluster,
|
|
7
9
|
broadcast: require('./lib/cluster').broadcast,
|
|
8
10
|
isPrimary: require('./lib/cluster').isPrimary,
|
|
9
|
-
isWorker: require('./lib/cluster').isWorker
|
|
11
|
+
isWorker: require('./lib/cluster').isWorker,
|
|
12
|
+
sse: require('./lib/sse')
|
|
10
13
|
};
|
package/lib/checkEnv.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Validate that a required set of env vars is present. ONE implementation,
|
|
2
|
+
// used two ways:
|
|
3
|
+
// • at startup — createApp({ requiredEnv: [...] }) runs it before booting
|
|
4
|
+
// • at build — the `xeplr-check-env` CLI runs it against the same list
|
|
5
|
+
// The app supplies the list; the framework owns the check.
|
|
6
|
+
//
|
|
7
|
+
// checkEnv(['JWT_SECRET', 'XEPLR_BI_CONNECTION'], { appName: 'bi' })
|
|
8
|
+
//
|
|
9
|
+
// Options:
|
|
10
|
+
// appName label in the message (default 'app')
|
|
11
|
+
// exit process.exit(1) on failure (default true) — set false to just return
|
|
12
|
+
// throw throw instead of logging/exiting (default false)
|
|
13
|
+
// silent suppress the success line (default false)
|
|
14
|
+
|
|
15
|
+
function checkEnv(required, options) {
|
|
16
|
+
options = options || {};
|
|
17
|
+
required = required || [];
|
|
18
|
+
var appName = options.appName || 'app';
|
|
19
|
+
|
|
20
|
+
var missing = required.filter(function (v) { return !process.env[v]; });
|
|
21
|
+
|
|
22
|
+
if (missing.length > 0) {
|
|
23
|
+
var msg = '[' + appName + '] Missing required env vars:\n' +
|
|
24
|
+
missing.map(function (v) { return ' - ' + v; }).join('\n') +
|
|
25
|
+
'\n\n' + missing.length + ' env var(s) missing. Add them to your .env or CI secrets.';
|
|
26
|
+
if (options.throw) throw new Error(msg);
|
|
27
|
+
console.error('\n' + msg + '\n');
|
|
28
|
+
if (options.exit !== false) process.exit(1);
|
|
29
|
+
return { ok: false, missing: missing };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!options.silent) console.log('[' + appName + '] All required env vars present.');
|
|
33
|
+
return { ok: true, missing: [] };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = checkEnv;
|
package/lib/genericController.js
CHANGED
|
@@ -2,6 +2,50 @@ var { respond } = require('@xeplr/utils/lib/response');
|
|
|
2
2
|
var { HTTP, STATUS } = require('@xeplr/utils/isomorphic');
|
|
3
3
|
var { generateId } = require('@xeplr/utils/lib/helpers');
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Map an Objection / DB error to a clean, client-friendly HTTP response instead
|
|
7
|
+
* of a blanket 500. Matched by `err.name` (not instanceof) so it survives two
|
|
8
|
+
* copies of objection in a linked monorepo. Unknown errors still fall to 500.
|
|
9
|
+
*
|
|
10
|
+
* ValidationError → 422 { fields } (jsonSchema: required/format/…)
|
|
11
|
+
* UniqueViolationError → 409 already exists
|
|
12
|
+
* NotNullViolationError → 400 <column> is required
|
|
13
|
+
* ForeignKeyViolationError → 409 referenced record missing / in use
|
|
14
|
+
* Check/DataError → 400 invalid data
|
|
15
|
+
* NotFoundError → 404
|
|
16
|
+
*/
|
|
17
|
+
function sendError(req, res, err) {
|
|
18
|
+
if (req && req.log && req.log.error) req.log.error((err && err.message) || 'error', { stack: err && err.stack });
|
|
19
|
+
|
|
20
|
+
switch (err && err.name) {
|
|
21
|
+
case 'ValidationError':
|
|
22
|
+
return respond(res, HTTP.VALIDATION_ERROR, STATUS.VALIDATION_ERROR, 'validation_error', {
|
|
23
|
+
message: 'Validation failed', fields: err.data || {}
|
|
24
|
+
});
|
|
25
|
+
case 'UniqueViolationError':
|
|
26
|
+
return respond(res, HTTP.CONFLICT, STATUS.CONFLICT, 'conflict', {
|
|
27
|
+
message: 'A record with these values already exists', columns: err.columns
|
|
28
|
+
});
|
|
29
|
+
case 'NotNullViolationError':
|
|
30
|
+
return respond(res, HTTP.BAD_REQUEST, STATUS.BAD_REQUEST, 'bad_request', {
|
|
31
|
+
message: (err.column || 'A required field') + ' is required', column: err.column
|
|
32
|
+
});
|
|
33
|
+
case 'ForeignKeyViolationError':
|
|
34
|
+
return respond(res, HTTP.CONFLICT, STATUS.CONFLICT, 'conflict', {
|
|
35
|
+
message: 'A referenced record is missing or still in use'
|
|
36
|
+
});
|
|
37
|
+
case 'CheckViolationError':
|
|
38
|
+
case 'DataError':
|
|
39
|
+
return respond(res, HTTP.BAD_REQUEST, STATUS.BAD_REQUEST, 'bad_request', {
|
|
40
|
+
message: 'Invalid data for one or more fields'
|
|
41
|
+
});
|
|
42
|
+
case 'NotFoundError':
|
|
43
|
+
return respond(res, HTTP.NOT_FOUND, STATUS.NOT_FOUND, 'not_found');
|
|
44
|
+
default:
|
|
45
|
+
return respond(res, HTTP.SERVER_ERROR, STATUS.SERVER_ERROR, 'server_error', { error: err });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
5
49
|
/**
|
|
6
50
|
* Generic CRUD controller.
|
|
7
51
|
* Processes the changeset from xeplr-ui-table in a single transaction.
|
|
@@ -35,17 +79,45 @@ async function save(req, res, hierarchy) {
|
|
|
35
79
|
|
|
36
80
|
var trx = await knex.transaction();
|
|
37
81
|
|
|
82
|
+
// Threaded onto every query in this save via .context(...) — lets a
|
|
83
|
+
// model's own Objection lifecycle hooks (e.g. $afterInsert(queryContext))
|
|
84
|
+
// know who made the request, without genericController needing to know
|
|
85
|
+
// anything about what a specific model does with that. { user: req.user }
|
|
86
|
+
// only (not the whole req) — keep this small and serializable-shaped.
|
|
87
|
+
//
|
|
88
|
+
// afterCommit: a hook that needs a side effect on ANOTHER connection (e.g.
|
|
89
|
+
// a cross-DB write — see xeplr-bi's Company.$afterInsert granting the
|
|
90
|
+
// creator access in the auth DB) must not run it inline: this trx hasn't
|
|
91
|
+
// committed yet, so a later entry in the same changeset failing would roll
|
|
92
|
+
// this row back while the other connection's write already landed —
|
|
93
|
+
// orphaned data pointing at a record that no longer exists. Instead the
|
|
94
|
+
// hook pushes a callback here; genericController drains it only once
|
|
95
|
+
// trx.commit() has actually succeeded. genericController itself stays
|
|
96
|
+
// generic — it never knows what a callback does, only that it's safe to
|
|
97
|
+
// run now.
|
|
98
|
+
var queryContext = req.user ? { user: req.user, afterCommit: [] } : { afterCommit: [] };
|
|
99
|
+
|
|
38
100
|
try {
|
|
39
101
|
for (var i = 0; i < changeSet.length; i++) {
|
|
40
|
-
var ids = await processEntry(changeSet[i], rootDef, trx);
|
|
102
|
+
var ids = await processEntry(changeSet[i], rootDef, trx, null, queryContext);
|
|
41
103
|
updatedIds = updatedIds.concat(ids);
|
|
42
104
|
}
|
|
43
105
|
|
|
44
106
|
await trx.commit();
|
|
107
|
+
|
|
108
|
+
for (var c = 0; c < queryContext.afterCommit.length; c++) {
|
|
109
|
+
try {
|
|
110
|
+
await queryContext.afterCommit[c]();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (req && req.log && req.log.error) req.log.error('afterCommit callback failed', { message: err.message, stack: err.stack });
|
|
113
|
+
else console.error('[genericController] afterCommit callback failed:', err.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
45
117
|
respond(res, HTTP.OK, STATUS.UPDATED, 'updated', { updatedIds: updatedIds });
|
|
46
118
|
} catch (err) {
|
|
47
119
|
await trx.rollback();
|
|
48
|
-
|
|
120
|
+
sendError(req, res, err);
|
|
49
121
|
}
|
|
50
122
|
}
|
|
51
123
|
|
|
@@ -55,15 +127,17 @@ async function save(req, res, hierarchy) {
|
|
|
55
127
|
* @param {object} def - Hierarchy definition { key, model, children, foreignKey }
|
|
56
128
|
* @param {object} trx - Knex transaction
|
|
57
129
|
* @param {string} [parentId] - Parent record ID (set automatically for children)
|
|
130
|
+
* @param {object} [queryContext] - Passed to .context(...) on every query — see save()
|
|
58
131
|
*/
|
|
59
|
-
async function processEntry(entry, def, trx, parentId) {
|
|
132
|
+
async function processEntry(entry, def, trx, parentId, queryContext) {
|
|
60
133
|
var Model = def.model;
|
|
61
134
|
var children = def.children || [];
|
|
62
135
|
var ids = [];
|
|
136
|
+
queryContext = queryContext || {};
|
|
63
137
|
|
|
64
138
|
// ── SOFT DELETE ──
|
|
65
139
|
if (entry.deleted === true) {
|
|
66
|
-
await Model.query(trx).findById(entry.id).patch({ isActive: false });
|
|
140
|
+
await Model.query(trx).context(queryContext).findById(entry.id).patch({ isActive: false });
|
|
67
141
|
ids.push(entry.id);
|
|
68
142
|
return ids;
|
|
69
143
|
}
|
|
@@ -92,19 +166,24 @@ async function processEntry(entry, def, trx, parentId) {
|
|
|
92
166
|
|
|
93
167
|
var recordId = ownFields.id;
|
|
94
168
|
|
|
95
|
-
// ── INSERT ──
|
|
169
|
+
// ── INSERT (no id provided) ──
|
|
96
170
|
if (!recordId) {
|
|
97
171
|
recordId = generateId();
|
|
98
172
|
ownFields.id = recordId;
|
|
99
|
-
await Model.query(trx).insert(ownFields);
|
|
173
|
+
await Model.query(trx).context(queryContext).insert(ownFields);
|
|
100
174
|
ids.push(recordId);
|
|
101
175
|
} else {
|
|
102
|
-
// ──
|
|
103
|
-
var
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
176
|
+
// ── UPSERT (id provided): patch if exists, else insert with given id ──
|
|
177
|
+
var existing = await Model.query(trx).findById(recordId);
|
|
178
|
+
if (existing) {
|
|
179
|
+
var patchFields = Object.assign({}, ownFields);
|
|
180
|
+
delete patchFields.id;
|
|
181
|
+
if (Object.keys(patchFields).length > 0) {
|
|
182
|
+
await Model.query(trx).context(queryContext).findById(recordId).patch(patchFields);
|
|
183
|
+
}
|
|
184
|
+
ids.push(recordId);
|
|
185
|
+
} else {
|
|
186
|
+
await Model.query(trx).context(queryContext).insert(ownFields);
|
|
108
187
|
ids.push(recordId);
|
|
109
188
|
}
|
|
110
189
|
}
|
|
@@ -116,7 +195,7 @@ async function processEntry(entry, def, trx, parentId) {
|
|
|
116
195
|
if (!childEntries || !Array.isArray(childEntries)) continue;
|
|
117
196
|
|
|
118
197
|
for (var j = 0; j < childEntries.length; j++) {
|
|
119
|
-
var childIds = await processEntry(childEntries[j], cDef, trx, recordId);
|
|
198
|
+
var childIds = await processEntry(childEntries[j], cDef, trx, recordId, queryContext);
|
|
120
199
|
ids = ids.concat(childIds);
|
|
121
200
|
}
|
|
122
201
|
}
|
|
@@ -147,7 +226,7 @@ async function deleteByIds(req, res, Model) {
|
|
|
147
226
|
await Model.query().whereIn('id', ids).patch({ isActive: false });
|
|
148
227
|
respond(res, HTTP.OK, STATUS.DELETED, 'deleted', { updatedIds: ids });
|
|
149
228
|
} catch (err) {
|
|
150
|
-
|
|
229
|
+
sendError(req, res, err);
|
|
151
230
|
}
|
|
152
231
|
}
|
|
153
232
|
|
|
@@ -168,7 +247,7 @@ async function getById(req, res, Model, graph) {
|
|
|
168
247
|
|
|
169
248
|
respond(res, HTTP.OK, STATUS.SUCCESS, 'success', { dataArray: [record] });
|
|
170
249
|
} catch (err) {
|
|
171
|
-
|
|
250
|
+
sendError(req, res, err);
|
|
172
251
|
}
|
|
173
252
|
}
|
|
174
253
|
|
|
@@ -211,7 +290,7 @@ async function list(req, res, Model, graph) {
|
|
|
211
290
|
respond(res, HTTP.OK, STATUS.SUCCESS, 'success', { dataArray: records });
|
|
212
291
|
}
|
|
213
292
|
} catch (err) {
|
|
214
|
-
|
|
293
|
+
sendError(req, res, err);
|
|
215
294
|
}
|
|
216
295
|
}
|
|
217
296
|
|
package/lib/sse.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Server-Sent Events (SSE) — one-way server → UI push over HTTP.
|
|
2
|
+
// Cluster-aware from day one: publish() fans out locally AND across all
|
|
3
|
+
// workers via the existing `broadcast()` primitive, so a completion event
|
|
4
|
+
// fired on worker N reaches subscribers connected to worker M.
|
|
5
|
+
//
|
|
6
|
+
// Two handler variants:
|
|
7
|
+
//
|
|
8
|
+
// sse.handler — legacy, unauthenticated.
|
|
9
|
+
// Kept for dev, tests, private networks.
|
|
10
|
+
// Do NOT expose on a public endpoint.
|
|
11
|
+
//
|
|
12
|
+
// sse.createHandler({ — production. Requires a ticket.
|
|
13
|
+
// validateTicket, Owns auth-on-connect + per-topic auth.
|
|
14
|
+
// authorizeTopics?
|
|
15
|
+
// })
|
|
16
|
+
//
|
|
17
|
+
// PRODUCTION FLOW (with ticket auth):
|
|
18
|
+
// 1. Client is authenticated by your normal auth layer (session cookie /
|
|
19
|
+
// JWT / whatever). Client POSTs to a ticket endpoint provided by
|
|
20
|
+
// xeplr-auth (or any provider) → gets a short-lived, single-use ticket.
|
|
21
|
+
// 2. Client opens EventSource('/events?ticket=<tk>&topics=user:me:jobs').
|
|
22
|
+
// 3. sse.createHandler calls validateTicket(tk):
|
|
23
|
+
// · returns { userId, scopes } on success
|
|
24
|
+
// · returns null on invalid / expired / already-consumed
|
|
25
|
+
// 4. authorizeTopics(payload, requestedTopics) filters the requested
|
|
26
|
+
// topics against the ticket's scopes. Rewrites `user:me:*` →
|
|
27
|
+
// `user:<userId>:*`. Rejects out-of-scope.
|
|
28
|
+
// 5. Only if any topics survive does the SSE connection open.
|
|
29
|
+
//
|
|
30
|
+
// AUTHORIZATION is where teams cut corners — the auth check on connect is
|
|
31
|
+
// easy; the per-topic check is where users end up subscribing to
|
|
32
|
+
// `user:other_user:jobs` and leaking data. Do NOT trust client-supplied
|
|
33
|
+
// topics verbatim. The default authorizeTopics enforces sane rules; supply
|
|
34
|
+
// your own only if you know why you're overriding.
|
|
35
|
+
//
|
|
36
|
+
// PUBLISH:
|
|
37
|
+
// sse.publish('user:42:jobs', { jobId, status });
|
|
38
|
+
// Cluster-aware: fires on this worker + relays to all others.
|
|
39
|
+
//
|
|
40
|
+
// CLIENT:
|
|
41
|
+
// var events = new EventSource('/events?ticket=<tk>&topics=user:me:jobs');
|
|
42
|
+
// events.onmessage = function(e) {
|
|
43
|
+
// var { topic, data } = JSON.parse(e.data);
|
|
44
|
+
// // ...
|
|
45
|
+
// };
|
|
46
|
+
|
|
47
|
+
var cluster = require('cluster');
|
|
48
|
+
var { broadcast } = require('./cluster');
|
|
49
|
+
|
|
50
|
+
// topic → Set<res> — local subscribers on THIS worker/process
|
|
51
|
+
var _subs = new Map();
|
|
52
|
+
|
|
53
|
+
// How often to send a keep-alive comment (proxies close idle conns silently)
|
|
54
|
+
var HEARTBEAT_MS = 25000;
|
|
55
|
+
|
|
56
|
+
// Cluster message discriminator so we don't confuse other broadcasts
|
|
57
|
+
var TAG = '__xeplrSse';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Attach a Response to an authorized set of topics. This is the shared
|
|
61
|
+
* inner core — both the legacy `handler` and the auth'd `createHandler`
|
|
62
|
+
* call this after they've decided which topics the client gets.
|
|
63
|
+
*
|
|
64
|
+
* On disconnect, all subscriptions are cleaned up automatically.
|
|
65
|
+
*/
|
|
66
|
+
function attachStream(req, res, topics) {
|
|
67
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
68
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
69
|
+
res.setHeader('Connection', 'keep-alive');
|
|
70
|
+
res.setHeader('X-Accel-Buffering', 'no'); // nginx: don't buffer
|
|
71
|
+
res.flushHeaders();
|
|
72
|
+
|
|
73
|
+
topics.forEach(function(t) {
|
|
74
|
+
if (!_subs.has(t)) _subs.set(t, new Set());
|
|
75
|
+
_subs.get(t).add(res);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Initial comment lets the client know the stream is open
|
|
79
|
+
res.write(': connected ' + Date.now() + '\n\n');
|
|
80
|
+
|
|
81
|
+
// Heartbeat — SSE comment lines are silently ignored by EventSource
|
|
82
|
+
var heartbeat = setInterval(function() {
|
|
83
|
+
try { res.write(': keepalive\n\n'); }
|
|
84
|
+
catch (_) { /* stream closed */ }
|
|
85
|
+
}, HEARTBEAT_MS);
|
|
86
|
+
if (heartbeat && heartbeat.unref) heartbeat.unref();
|
|
87
|
+
|
|
88
|
+
var cleanedUp = false;
|
|
89
|
+
function cleanup() {
|
|
90
|
+
if (cleanedUp) return;
|
|
91
|
+
cleanedUp = true;
|
|
92
|
+
clearInterval(heartbeat);
|
|
93
|
+
topics.forEach(function(t) {
|
|
94
|
+
var set = _subs.get(t);
|
|
95
|
+
if (!set) return;
|
|
96
|
+
set.delete(res);
|
|
97
|
+
if (set.size === 0) _subs.delete(t);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
req.on('close', cleanup);
|
|
101
|
+
res.on('close', cleanup);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseTopics(req) {
|
|
105
|
+
var topicsParam = (req.query && req.query.topics) || '';
|
|
106
|
+
return String(topicsParam).split(',').map(function(t) { return t.trim(); }).filter(Boolean);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* LEGACY handler — no auth. Kept for tests + private networks.
|
|
111
|
+
* DO NOT expose on a public endpoint. Use createHandler() there.
|
|
112
|
+
*/
|
|
113
|
+
function handler(req, res) {
|
|
114
|
+
var topics = parseTopics(req);
|
|
115
|
+
if (topics.length === 0) {
|
|
116
|
+
return res.status(400).json({ error: 'topics query parameter required (e.g. ?topics=job:1,movement:2)' });
|
|
117
|
+
}
|
|
118
|
+
return attachStream(req, res, topics);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* PRODUCTION handler factory. Requires a ticket. Returns an Express
|
|
123
|
+
* handler that:
|
|
124
|
+
* 1. Reads ?ticket=... from query
|
|
125
|
+
* 2. Calls opts.validateTicket(ticket) → { userId, scopes } | null
|
|
126
|
+
* 3. Calls opts.authorizeTopics(payload, requestedTopics) → allowed[]
|
|
127
|
+
* (default policy: rewrite user:me:* → user:<uid>:*, enforce scopes)
|
|
128
|
+
* 4. Attaches the stream ONLY to the intersection
|
|
129
|
+
*
|
|
130
|
+
* @param {object} opts
|
|
131
|
+
* @param {Function} opts.validateTicket — async (ticket) => payload | null
|
|
132
|
+
* @param {Function} [opts.authorizeTopics] — (payload, requested[]) => allowed[]
|
|
133
|
+
* @returns {Function} Express handler
|
|
134
|
+
*/
|
|
135
|
+
function createHandler(opts) {
|
|
136
|
+
opts = opts || {};
|
|
137
|
+
if (typeof opts.validateTicket !== 'function') {
|
|
138
|
+
throw new Error('sse.createHandler: opts.validateTicket is required (pass ticketService.consumeTicket from xeplr-auth, or your own).');
|
|
139
|
+
}
|
|
140
|
+
var validateTicket = opts.validateTicket;
|
|
141
|
+
var authorizeTopics = opts.authorizeTopics || defaultAuthorizeTopics;
|
|
142
|
+
|
|
143
|
+
return async function(req, res) {
|
|
144
|
+
var ticket = req.query && req.query.ticket;
|
|
145
|
+
if (!ticket) {
|
|
146
|
+
return res.status(401).json({ error: 'ticket query parameter required' });
|
|
147
|
+
}
|
|
148
|
+
var requested = parseTopics(req);
|
|
149
|
+
if (requested.length === 0) {
|
|
150
|
+
return res.status(400).json({ error: 'topics query parameter required (e.g. ?topics=user:me:jobs)' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
var payload;
|
|
154
|
+
try { payload = await validateTicket(ticket); }
|
|
155
|
+
catch (_) { return res.status(500).json({ error: 'ticket validation failed' }); }
|
|
156
|
+
if (!payload) return res.status(401).json({ error: 'invalid or expired ticket' });
|
|
157
|
+
|
|
158
|
+
var allowed;
|
|
159
|
+
try { allowed = authorizeTopics(payload, requested) || []; }
|
|
160
|
+
catch (_) { return res.status(500).json({ error: 'topic authorization failed' }); }
|
|
161
|
+
if (!allowed.length) {
|
|
162
|
+
return res.status(403).json({ error: 'no requested topics are authorized' });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return attachStream(req, res, allowed);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── default authorization policy ─────────────────────────────────────────
|
|
170
|
+
//
|
|
171
|
+
// Consumers pass a `scopes` array in the ticket payload. Scopes are glob
|
|
172
|
+
// patterns; `user:me:*` is a well-known shorthand for "the authenticated
|
|
173
|
+
// user's own namespace" and is rewritten to `user:<userId>:*`.
|
|
174
|
+
//
|
|
175
|
+
// Every requested topic must:
|
|
176
|
+
// · match at least ONE scope pattern (after rewrite), AND
|
|
177
|
+
// · if the topic starts with `user:`, the userId segment must match
|
|
178
|
+
// the authenticated user (defense in depth against a scope that
|
|
179
|
+
// accidentally allows `user:*:jobs`)
|
|
180
|
+
//
|
|
181
|
+
// Returns the allowed subset. Topics that fail either check are dropped
|
|
182
|
+
// silently — the handler decides how to respond when the result is empty.
|
|
183
|
+
function defaultAuthorizeTopics(payload, requested) {
|
|
184
|
+
var uid = String(payload && payload.userId);
|
|
185
|
+
if (!uid) return [];
|
|
186
|
+
var scopes = (payload && Array.isArray(payload.scopes) && payload.scopes.length)
|
|
187
|
+
? payload.scopes
|
|
188
|
+
: ['user:me:*'];
|
|
189
|
+
|
|
190
|
+
var rewrittenScopes = scopes.map(function(s) {
|
|
191
|
+
return s.replace(/user:me:/g, 'user:' + uid + ':');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return requested
|
|
195
|
+
.map(function(t) { return t.replace(/^user:me:/, 'user:' + uid + ':'); })
|
|
196
|
+
.filter(function(t) {
|
|
197
|
+
// Never allow accessing another user's namespace, regardless of scope.
|
|
198
|
+
var m = t.match(/^user:([^:]+):/);
|
|
199
|
+
if (m && m[1] !== uid) return false;
|
|
200
|
+
return rewrittenScopes.some(function(scope) { return globMatch(scope, t); });
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function globMatch(pattern, topic) {
|
|
205
|
+
var parts = pattern.split('*').map(escapeRegExp);
|
|
206
|
+
return new RegExp('^' + parts.join('.*') + '$').test(topic);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function escapeRegExp(s) { return s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); }
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Push an event to every subscriber of `topic`, on this worker AND across
|
|
213
|
+
* the cluster. Same API whether you're in a single-process app, a worker,
|
|
214
|
+
* or the primary.
|
|
215
|
+
*/
|
|
216
|
+
function publish(topic, data) {
|
|
217
|
+
publishLocal(topic, data);
|
|
218
|
+
|
|
219
|
+
if (cluster.isPrimary) {
|
|
220
|
+
// If workers exist, hand the event to each one. If they don't (single
|
|
221
|
+
// process), this loop is a no-op.
|
|
222
|
+
var workers = cluster.workers || {};
|
|
223
|
+
Object.keys(workers).forEach(function(id) {
|
|
224
|
+
var w = workers[id];
|
|
225
|
+
if (w && !w.isDead()) {
|
|
226
|
+
try { w.send(taggedMessage(topic, data)); } catch (_) {}
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
} else if (process.send) {
|
|
230
|
+
// In a worker: relay through the primary to all OTHER workers.
|
|
231
|
+
// (This process's local subscribers were already written above.)
|
|
232
|
+
broadcast(taggedMessage(topic, data));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Fan out to LOCAL subscribers only. Used by both publish() and the
|
|
237
|
+
// cluster-message receiver below.
|
|
238
|
+
function publishLocal(topic, data) {
|
|
239
|
+
var set = _subs.get(topic);
|
|
240
|
+
if (!set || set.size === 0) return;
|
|
241
|
+
var payload = 'data: ' + JSON.stringify({ topic: topic, data: data }) + '\n\n';
|
|
242
|
+
set.forEach(function(res) {
|
|
243
|
+
try { res.write(payload); }
|
|
244
|
+
catch (_) { /* write failed — cleanup runs on close */ }
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function taggedMessage(topic, data) {
|
|
249
|
+
var msg = { topic: topic, data: data };
|
|
250
|
+
msg[TAG] = true;
|
|
251
|
+
return msg;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Install the cluster message receiver once at module load. Idempotent.
|
|
255
|
+
// Runs in every worker (and harmlessly in single-process mode where
|
|
256
|
+
// process.on('message') never fires without a parent).
|
|
257
|
+
var _installed = false;
|
|
258
|
+
function installReceiver() {
|
|
259
|
+
if (_installed) return;
|
|
260
|
+
_installed = true;
|
|
261
|
+
process.on('message', function(msg) {
|
|
262
|
+
if (msg && msg[TAG]) publishLocal(msg.topic, msg.data);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
installReceiver();
|
|
266
|
+
|
|
267
|
+
// ── test-only helpers ────────────────────────────────────────────────────
|
|
268
|
+
function _reset() {
|
|
269
|
+
_subs.forEach(function(set) {
|
|
270
|
+
set.forEach(function(res) { try { res.end(); } catch (_) {} });
|
|
271
|
+
});
|
|
272
|
+
_subs.clear();
|
|
273
|
+
}
|
|
274
|
+
function _stats() {
|
|
275
|
+
var out = { topics: {}, totalConnections: 0 };
|
|
276
|
+
_subs.forEach(function(set, topic) {
|
|
277
|
+
out.topics[topic] = set.size;
|
|
278
|
+
out.totalConnections += set.size;
|
|
279
|
+
});
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = {
|
|
284
|
+
handler: handler, // legacy — unauthenticated
|
|
285
|
+
createHandler: createHandler, // production — ticket auth
|
|
286
|
+
defaultAuthorizeTopics: defaultAuthorizeTopics, // exported for reuse in custom policies
|
|
287
|
+
publish: publish,
|
|
288
|
+
_reset: _reset,
|
|
289
|
+
_stats: _stats
|
|
290
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
var express = require('express');
|
|
2
|
+
var FileUploader = require('@xeplr/utils/lib/fileUploader');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generic "receive a file, hand back its path" route — product/app-agnostic.
|
|
6
|
+
* Every app that needs file upload (avatars, data imports, attachments, ...)
|
|
7
|
+
* mounts this once instead of hand-rolling multer wiring; whatever the app
|
|
8
|
+
* actually DOES with the file (parse it, load it into a DB, attach it to a
|
|
9
|
+
* record) is a separate, app-specific request made AFTER this one, using the
|
|
10
|
+
* returned filePath.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* var { uploadRoute } = require('@xeplr/base-apis');
|
|
14
|
+
* app.use('/uploads', uploadRoute({ destination: './uploads', auth: authMiddleware }));
|
|
15
|
+
*
|
|
16
|
+
* Generated route:
|
|
17
|
+
* POST / (multipart, field name options.fieldName, default "file")
|
|
18
|
+
* → { filePath, fileName, originalName, size, mimetype }
|
|
19
|
+
*
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @param {string} [options.destination='./uploads'] - disk directory to store uploaded files
|
|
22
|
+
* @param {number} [options.maxSize=5MB]
|
|
23
|
+
* @param {string[]} [options.allowedTypes=['*']]
|
|
24
|
+
* @param {string} [options.fieldName='file']
|
|
25
|
+
* @param {Function|Function[]} [options.auth] - middleware(s) to run before the upload itself
|
|
26
|
+
* @returns {Router}
|
|
27
|
+
*/
|
|
28
|
+
function uploadRoute(options) {
|
|
29
|
+
options = options || {};
|
|
30
|
+
var router = express.Router();
|
|
31
|
+
var fieldName = options.fieldName || 'file';
|
|
32
|
+
|
|
33
|
+
var uploader = new FileUploader({
|
|
34
|
+
destination: options.destination,
|
|
35
|
+
maxSize: options.maxSize,
|
|
36
|
+
allowedTypes: options.allowedTypes
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
var auth = options.auth ? (Array.isArray(options.auth) ? options.auth : [options.auth]) : [];
|
|
40
|
+
var multerChain = uploader.single(fieldName); // [authGuard(noop unless FileUploader's own auth set), multer, errorHandler]
|
|
41
|
+
|
|
42
|
+
router.post.apply(router, ['/'].concat(auth, multerChain, [function(req, res) {
|
|
43
|
+
if (!req.file) return res.status(400).json({ error: fieldName + ' is required' });
|
|
44
|
+
res.json({
|
|
45
|
+
filePath: req.file.path,
|
|
46
|
+
fileName: req.file.filename,
|
|
47
|
+
originalName: req.file.originalname,
|
|
48
|
+
size: req.file.size,
|
|
49
|
+
mimetype: req.file.mimetype
|
|
50
|
+
});
|
|
51
|
+
}]));
|
|
52
|
+
|
|
53
|
+
return router;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = uploadRoute;
|
package/package.json
CHANGED
|
@@ -1,22 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xeplr/base-apis",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Base API bootstrapper — Express and plain HTTP server factories with middleware injection",
|
|
5
5
|
"main": "index.js",
|
|
6
|
-
"
|
|
7
|
-
|
|
6
|
+
"bin": {
|
|
7
|
+
"xeplr-check-env": "./bin/check-env.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"lib/",
|
|
12
|
+
"express.js",
|
|
13
|
+
"http.js",
|
|
14
|
+
"bin/"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"express",
|
|
18
|
+
"http",
|
|
19
|
+
"api",
|
|
20
|
+
"server",
|
|
21
|
+
"bootstrap",
|
|
22
|
+
"crud"
|
|
23
|
+
],
|
|
8
24
|
"author": "xeplr",
|
|
9
25
|
"license": "MIT",
|
|
10
|
-
"repository": {
|
|
11
|
-
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/Xeplr/xeplr-base-apis"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
12
33
|
"dependencies": {
|
|
13
34
|
"cookie-parser": "^1.4.7",
|
|
14
35
|
"cors": "^2.8.6",
|
|
15
36
|
"debug": "^4.4.3",
|
|
16
37
|
"http-errors": "^2.0.1",
|
|
17
|
-
"@xeplr/logs": "^1.0.0"
|
|
38
|
+
"@xeplr/logs": "^1.0.0",
|
|
39
|
+
"@xeplr/utils": "^1.0.0"
|
|
18
40
|
},
|
|
19
41
|
"peerDependencies": {
|
|
20
|
-
"express": "^4.0.0 || ^5.0.0"
|
|
42
|
+
"express": "^4.0.0 || ^5.0.0",
|
|
43
|
+
"multer": "^1.4.5-lts.1"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"multer": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
21
49
|
}
|
|
22
50
|
}
|