@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 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.
@@ -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
  };
@@ -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;
@@ -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
- respond(res, HTTP.SERVER_ERROR, STATUS.SERVER_ERROR, 'server_error', { error: err });
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
- // ── PATCH ──
103
- var patchFields = Object.assign({}, ownFields);
104
- delete patchFields.id;
105
-
106
- if (Object.keys(patchFields).length > 0) {
107
- await Model.query(trx).findById(recordId).patch(patchFields);
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
- respond(res, HTTP.SERVER_ERROR, STATUS.SERVER_ERROR, 'server_error', { error: err });
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
- respond(res, HTTP.SERVER_ERROR, STATUS.SERVER_ERROR, 'server_error', { error: err });
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
- respond(res, HTTP.SERVER_ERROR, STATUS.SERVER_ERROR, 'server_error', { error: err });
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.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
- "files": ["index.js", "lib/", "express.js"],
7
- "keywords": ["express", "http", "api", "server", "bootstrap", "crud"],
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": { "type": "git", "url": "https://github.com/Xeplr/xeplr-base-apis" },
11
- "publishConfig": { "access": "public" },
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
  }