@xeplr/auth 1.0.0 → 1.0.2

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 (59) hide show
  1. package/bin/migrate.js +58 -25
  2. package/bin/server.js +12 -30
  3. package/index.js +188 -48
  4. package/lib/adminRouter.js +20 -92
  5. package/lib/authHelper.js +26 -3
  6. package/lib/authMiddleware.js +39 -13
  7. package/lib/authRouter.js +144 -12
  8. package/lib/authService.js +448 -16
  9. package/lib/hooks.js +50 -0
  10. package/lib/mtMembershipMiddleware.js +61 -0
  11. package/lib/seed.js +74 -0
  12. package/lib/sessionService.js +54 -6
  13. package/lib/ticketService.js +88 -0
  14. package/lib/tokenDecision.js +45 -0
  15. package/migrations/0001_extensions.sql +9 -0
  16. package/migrations/0002_users.sql +31 -0
  17. package/migrations/0003_catalog_tables.sql +82 -0
  18. package/migrations/0004_role_mappings.sql +78 -0
  19. package/migrations/0005_user_tenants_mapping.sql +39 -0
  20. package/migrations/0006_seed_catalog.sql +120 -0
  21. package/models/Api.js +3 -0
  22. package/models/ApisRolesMapping.js +3 -0
  23. package/models/Menu.js +3 -0
  24. package/models/MenuRolesMapping.js +3 -0
  25. package/models/Role.js +8 -0
  26. package/models/UiElement.js +3 -0
  27. package/models/UiElementsRolesMapping.js +3 -0
  28. package/models/UiPage.js +3 -0
  29. package/models/UiPagesRolesMapping.js +3 -0
  30. package/models/User.js +3 -13
  31. package/models/UserRolesMapping.js +3 -0
  32. package/models/UserTenantsMapping.js +20 -2
  33. package/models/index.js +0 -2
  34. package/package.json +26 -5
  35. package/migrations/0001_users.js +0 -22
  36. package/migrations/0002_menus.js +0 -16
  37. package/migrations/0003_apis.js +0 -16
  38. package/migrations/0004_uiPages.js +0 -16
  39. package/migrations/0005_uiElements.js +0 -16
  40. package/migrations/0006_roles.js +0 -15
  41. package/migrations/0007_apisRolesMapping.js +0 -16
  42. package/migrations/0008_uiPagesRolesMapping.js +0 -16
  43. package/migrations/0009_uiElementsRolesMapping.js +0 -16
  44. package/migrations/0010_menuRolesMapping.js +0 -16
  45. package/migrations/0011_userRolesMapping.js +0 -16
  46. package/migrations/0012_users_add_reset_token.js +0 -13
  47. package/migrations/0013_add_isPublic.js +0 -27
  48. package/migrations/0014_users_add_activation_token.js +0 -13
  49. package/migrations/0015_add_mt_columns.js +0 -41
  50. package/migrations/0016_tenants.js +0 -23
  51. package/migrations/0017_userTenantsMapping.js +0 -20
  52. package/models/Tenant.js +0 -63
  53. package/seeds/001_roles.js +0 -26
  54. package/seeds/002_apis.js +0 -70
  55. package/seeds/003_pages.js +0 -41
  56. package/seeds/004_elements.js +0 -46
  57. package/seeds/005_menus.js +0 -35
  58. package/seeds/006_default_tenant.js +0 -50
  59. package/seeds/zzz_admin_access.js +0 -49
@@ -1,6 +1,8 @@
1
1
  var express = require('express');
2
2
  var { generateId } = require('./authHelper');
3
3
  var { clearUserAccess, clearAccessRules } = require('./accessService');
4
+ var authMiddleware = require('./authMiddleware');
5
+ var hooks = require('./hooks');
4
6
  var User = require('../models/User');
5
7
  var Role = require('../models/Role');
6
8
  var Api = require('../models/Api');
@@ -12,8 +14,6 @@ var ApisRolesMapping = require('../models/ApisRolesMapping');
12
14
  var UiPagesRolesMapping = require('../models/UiPagesRolesMapping');
13
15
  var UiElementsRolesMapping = require('../models/UiElementsRolesMapping');
14
16
  var MenuRolesMapping = require('../models/MenuRolesMapping');
15
- var Tenant = require('../models/Tenant');
16
- var UserTenantsMapping = require('../models/UserTenantsMapping');
17
17
 
18
18
  // Group field names per type
19
19
  var GROUP_FIELDS = {
@@ -33,12 +33,15 @@ function isModuleGroup(group) {
33
33
  function createAdminRouter() {
34
34
  var router = express.Router();
35
35
 
36
+ // Every admin route requires a valid JWT — populates req.user (with roles).
37
+ router.use(authMiddleware);
38
+
36
39
  // ─── Users with their roles ───
37
40
  router.get('/users', async function(req, res) {
38
41
  try {
39
42
  var users = await User.query()
40
43
  .select('id', 'email', 'name', 'isActive', 'isActivated')
41
- .withGraphFetched('[roles, tenants]');
44
+ .withGraphFetched('roles');
42
45
  res.json(users);
43
46
  } catch (err) {
44
47
  res.status(500).json({ error: 'Something went wrong' });
@@ -97,6 +100,7 @@ function createAdminRouter() {
97
100
  }
98
101
 
99
102
  await clearUserAccess(userId);
103
+ await hooks.fire('userRolesMapping', assign ? 'create' : 'delete', userId);
100
104
  res.json({ ok: true });
101
105
  } catch (err) {
102
106
  res.status(500).json({ error: 'Something went wrong' });
@@ -143,6 +147,7 @@ function createAdminRouter() {
143
147
  }
144
148
 
145
149
  await clearAccessRules();
150
+ await hooks.fire(type + 'RolesMapping', assign ? 'create' : 'delete', itemId);
146
151
  res.json({ ok: true });
147
152
  } catch (err) {
148
153
  res.status(500).json({ error: 'Something went wrong' });
@@ -162,10 +167,10 @@ function createAdminRouter() {
162
167
  var createdBy = req.user ? req.user.id : null;
163
168
 
164
169
  var types = [
165
- { Model: Api, MappingModel: ApisRolesMapping, fk: 'apiId', groupField: 'apiGroup' },
166
- { Model: UiPage, MappingModel: UiPagesRolesMapping, fk: 'uiPageId', groupField: 'uiPagesGroup' },
167
- { Model: UiElement, MappingModel: UiElementsRolesMapping, fk: 'uiElementId', groupField: 'uiElementsGroup' },
168
- { Model: Menu, MappingModel: MenuRolesMapping, fk: 'menuId', groupField: 'menuGroup' }
170
+ { type: 'apis', Model: Api, MappingModel: ApisRolesMapping, fk: 'apiId', groupField: 'apiGroup' },
171
+ { type: 'pages', Model: UiPage, MappingModel: UiPagesRolesMapping, fk: 'uiPageId', groupField: 'uiPagesGroup' },
172
+ { type: 'elements', Model: UiElement, MappingModel: UiElementsRolesMapping, fk: 'uiElementId', groupField: 'uiElementsGroup' },
173
+ { type: 'menus', Model: Menu, MappingModel: MenuRolesMapping, fk: 'menuId', groupField: 'menuGroup' }
169
174
  ];
170
175
 
171
176
  var promises = types.map(async function(cfg) {
@@ -187,6 +192,8 @@ function createAdminRouter() {
187
192
  } else {
188
193
  await cfg.MappingModel.query().delete().where(itemWhere);
189
194
  }
195
+
196
+ await hooks.fire(cfg.type + 'RolesMapping', assign ? 'create' : 'delete', items[i].id);
190
197
  }
191
198
  });
192
199
 
@@ -215,10 +222,12 @@ function createAdminRouter() {
215
222
 
216
223
  if (id) {
217
224
  await Role.query().findById(id).patch({ name: name });
225
+ await hooks.fire('roles', 'update', id);
218
226
  res.json({ id: id });
219
227
  } else {
220
228
  var newId = generateId();
221
229
  await Role.query().insert({ id: newId, name: name });
230
+ await hooks.fire('roles', 'create', newId);
222
231
  res.json({ id: newId });
223
232
  }
224
233
  } catch (err) {
@@ -232,6 +241,7 @@ function createAdminRouter() {
232
241
  if (!id) return res.status(400).json({ error: 'id is required' });
233
242
  await Role.query().deleteById(id);
234
243
  await clearAccessRules();
244
+ await hooks.fire('roles', 'delete', id);
235
245
  res.json({ ok: true });
236
246
  } catch (err) {
237
247
  res.status(500).json({ error: 'Something went wrong' });
@@ -271,11 +281,13 @@ function createAdminRouter() {
271
281
 
272
282
  if (id) {
273
283
  await cfg.Model.query().findById(id).patch(data);
284
+ await hooks.fire(typeKey, 'update', id);
274
285
  res.json({ id: id });
275
286
  } else {
276
287
  var newId = generateId();
277
288
  data.id = newId;
278
289
  await cfg.Model.query().insert(data);
290
+ await hooks.fire(typeKey, 'create', newId);
279
291
  res.json({ id: newId });
280
292
  }
281
293
  } catch (err) {
@@ -289,6 +301,7 @@ function createAdminRouter() {
289
301
  if (!id) return res.status(400).json({ error: 'id is required' });
290
302
  await cfg.Model.query().deleteById(id);
291
303
  await clearAccessRules();
304
+ await hooks.fire(typeKey, 'delete', id);
292
305
  res.json({ ok: true });
293
306
  } catch (err) {
294
307
  res.status(500).json({ error: 'Something went wrong' });
@@ -296,91 +309,6 @@ function createAdminRouter() {
296
309
  });
297
310
  });
298
311
 
299
- // ─── Tenants (Super Admin only) ───
300
-
301
- function requireSuperAdmin(req, res, next) {
302
- if (!req.user || !req.user.roles || req.user.roles.indexOf('Super Admin') === -1) {
303
- return res.status(403).json({ error: 'Super Admin access required' });
304
- }
305
- next();
306
- }
307
-
308
- router.get('/tenants', requireSuperAdmin, async function(req, res) {
309
- try {
310
- var level = req.query.level ? parseInt(req.query.level) : null;
311
- var query = Tenant.query().select('id', 'name', 'code', 'level', 'parentId', 'description');
312
- if (level) query = query.where('level', level);
313
- var tenants = await query;
314
- res.json(tenants);
315
- } catch (err) {
316
- res.status(500).json({ error: 'Something went wrong' });
317
- }
318
- });
319
-
320
- router.post('/tenants', requireSuperAdmin, async function(req, res) {
321
- try {
322
- var { id, name, code, level, parentId, description } = req.body;
323
- if (!name) return res.status(400).json({ error: 'name is required' });
324
-
325
- var data = { name: name };
326
- if (code !== undefined) data.code = code;
327
- if (level !== undefined) data.level = level;
328
- if (parentId !== undefined) data.parentId = parentId;
329
- if (description !== undefined) data.description = description;
330
-
331
- if (id) {
332
- await Tenant.query().findById(id).patch(data);
333
- res.json({ id: id });
334
- } else {
335
- var newId = generateId();
336
- data.id = newId;
337
- await Tenant.query().insert(data);
338
- res.json({ id: newId });
339
- }
340
- } catch (err) {
341
- res.status(500).json({ error: 'Something went wrong' });
342
- }
343
- });
344
-
345
- router.post('/tenants/delete', requireSuperAdmin, async function(req, res) {
346
- try {
347
- var { id } = req.body;
348
- if (!id) return res.status(400).json({ error: 'id is required' });
349
- await Tenant.query().findById(id).patch({ isActive: false });
350
- res.json({ ok: true });
351
- } catch (err) {
352
- res.status(500).json({ error: 'Something went wrong' });
353
- }
354
- });
355
-
356
- // Assign/unassign tenant to a user (via mapping table)
357
- // Access controlled via Access Matrix (tenants:edit group), not hardcoded to Super Admin
358
- router.post('/tenants/assign-user', async function(req, res) {
359
- try {
360
- var { userId, tenantId, assign } = req.body;
361
- if (!userId || !tenantId) return res.status(400).json({ error: 'userId and tenantId are required' });
362
-
363
- if (assign === false) {
364
- await UserTenantsMapping.query().delete().where({ userId: userId, tenantId: tenantId });
365
- } else {
366
- var existing = await UserTenantsMapping.query().findOne({ userId: userId, tenantId: tenantId });
367
- if (!existing) {
368
- await UserTenantsMapping.query().insert({
369
- id: generateId(),
370
- userId: userId,
371
- tenantId: tenantId,
372
- recordCreatedBy: req.user ? req.user.id : null
373
- });
374
- }
375
- }
376
-
377
- await clearUserAccess(userId);
378
- res.json({ ok: true });
379
- } catch (err) {
380
- res.status(500).json({ error: 'Something went wrong' });
381
- }
382
- });
383
-
384
312
  return router;
385
313
  }
386
314
 
package/lib/authHelper.js CHANGED
@@ -16,7 +16,7 @@ function configure(config) {
16
16
  }
17
17
 
18
18
  function getSecret() {
19
- return _config.jwtSecret || process.env.JWT_SECRET || 'change_me_in_production';
19
+ return _config.jwtSecret || process.env.AUTH_JWT_SECRET || 'change_me_in_production';
20
20
  }
21
21
 
22
22
  function generateId() {
@@ -35,10 +35,13 @@ async function comparePassword(password, hash) {
35
35
  }
36
36
 
37
37
  function generateAccessToken(user) {
38
+ // Caller is responsible for providing `roles` as an array of role names
39
+ // (see authService.login → withGraphFetched('roles'))
38
40
  const payload = {
39
41
  id: user.id,
40
42
  email: user.email,
41
- name: user.name
43
+ name: user.name,
44
+ roles: Array.isArray(user.roles) ? user.roles : []
42
45
  };
43
46
  const expiresIn = _config.accessTokenExpiresIn || '15m';
44
47
  return jwt.sign(payload, getSecret(), { expiresIn });
@@ -52,6 +55,24 @@ function verifyToken(token) {
52
55
  return jwt.verify(token, getSecret());
53
56
  }
54
57
 
58
+ // Verify the SIGNATURE but ignore expiry — the middleware decides on expiry
59
+ // itself (so it can apply the tolerance window). Returns { decoded, signatureValid }.
60
+ function decodeVerified(token) {
61
+ try {
62
+ return { decoded: jwt.verify(token, getSecret(), { ignoreExpiration: true }), signatureValid: true };
63
+ } catch (err) {
64
+ return { decoded: null, signatureValid: false };
65
+ }
66
+ }
67
+
68
+ // Grace window (seconds) during which an expired access token is still honored
69
+ // and slid to a fresh one. 0 = strict (reject on expiry) = default / back-compat.
70
+ function getToleranceSeconds() {
71
+ var t = _config.accessTokenToleranceSeconds;
72
+ if (t === undefined || t === null) t = parseInt(process.env.AUTH_ACCESS_TOKEN_TOLERANCE_SECONDS || '0', 10);
73
+ return (Number.isFinite(t) && t > 0) ? t : 0;
74
+ }
75
+
55
76
  module.exports = {
56
77
  configure,
57
78
  generateId,
@@ -59,5 +80,7 @@ module.exports = {
59
80
  comparePassword,
60
81
  generateAccessToken,
61
82
  generateRefreshToken,
62
- verifyToken
83
+ verifyToken,
84
+ decodeVerified,
85
+ getToleranceSeconds
63
86
  };
@@ -1,27 +1,53 @@
1
- const { verifyToken } = require('./authHelper');
2
- const { validateAccessToken } = require('./sessionService');
1
+ const { decodeVerified, getToleranceSeconds } = require('./authHelper');
2
+ const { validateAccessToken, slideAccessToken } = require('./sessionService');
3
+ const { decide } = require('./tokenDecision');
3
4
 
5
+ // The ONE place token logic lives. Order (from tokenDecision): signature →
6
+ // revocation → expiry. Tolerance (ACCESS_TOKEN_TOLERANCE_SECONDS, default 0)
7
+ // turns on the sliding refresh: an expired-but-recent token is served AND a
8
+ // fresh token is attached to the response (X-New-Token), set BEFORE the handler
9
+ // runs so it rides every response path. With tolerance 0 this is exactly the
10
+ // old behavior (reject on expiry).
4
11
  async function authMiddleware(req, res, next) {
5
12
  const authHeader = req.headers.authorization;
6
-
7
13
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
8
14
  return res.status(401).json({ error: 'No token provided' });
9
15
  }
10
-
11
16
  const token = authHeader.split(' ')[1];
12
17
 
13
- // Step 1: Verify JWT signature + expiry
14
- let decoded;
15
- try {
16
- decoded = verifyToken(token);
17
- } catch (err) {
18
+ // Verify signature, ignore expiry (we decide on expiry ourselves).
19
+ const { decoded, signatureValid } = decodeVerified(token);
20
+
21
+ // Session liveness = the whitelist entry still exists (a logout deletes it).
22
+ // The entry outlives the token by `tolerance`, so it's present during grace.
23
+ const session = signatureValid ? await validateAccessToken(token) : null;
24
+
25
+ const decision = decide({
26
+ exp: decoded && decoded.exp,
27
+ now: Math.floor(Date.now() / 1000),
28
+ toleranceSeconds: getToleranceSeconds(),
29
+ sessionLive: !!session,
30
+ signatureValid: signatureValid
31
+ });
32
+
33
+ if (decision.action === 'reject') {
34
+ if (req.log) req.log.debug('auth reject: ' + decision.reason);
18
35
  return res.status(401).json({ error: 'Invalid or expired token' });
19
36
  }
20
37
 
21
- // Step 2: Check Redis whitelist (token not revoked)
22
- const session = await validateAccessToken(token);
23
- if (!session) {
24
- return res.status(401).json({ error: 'Session expired or revoked' });
38
+ if (decision.action === 'slide') {
39
+ // Serve the request but hand back a fresh token. Header set BEFORE next()
40
+ // present on every response. A slide failure must not block a live
41
+ // session fall through and serve with the (still-tolerated) old token.
42
+ try {
43
+ const newToken = await slideAccessToken(token, decoded);
44
+ if (newToken) {
45
+ res.setHeader('X-New-Token', newToken);
46
+ res.setHeader('Access-Control-Expose-Headers', 'X-New-Token');
47
+ }
48
+ } catch (err) {
49
+ if (req.log) req.log.error('slide failed: ' + err.message);
50
+ }
25
51
  }
26
52
 
27
53
  req.user = decoded;
package/lib/authRouter.js CHANGED
@@ -1,10 +1,19 @@
1
1
  const express = require('express');
2
- const { register, activate, login, forgotPassword, resetPassword } = require('./authService');
2
+ const FileUploader = require('@xeplr/utils/lib/fileUploader');
3
+ const { register, activate, previewInvite, acceptInvite, login, forgotPassword, resetPassword, requestPhoneOtp, verifyPhoneOtp, getProfile, updateProfile, setProfilePic, changePassword } = require('./authService');
3
4
  const { getUserAccess } = require('./accessService');
4
5
  const { getSession, rotateSession, destroySession } = require('./sessionService');
6
+ const { issueTicket } = require('./ticketService');
5
7
  const authMiddleware = require('./authMiddleware');
6
8
  const User = require('../models/User');
7
9
 
10
+ // Avatar uploads — local disk by default (FileUploader's own default:
11
+ // process.env.UPLOAD_DIR or ./uploads). Auth only persists the resulting
12
+ // path on the user; serving it back over HTTP is the consuming app's concern
13
+ // (same as any other uploaded asset), so wire a static route to the same
14
+ // directory wherever it makes sense for your deployment.
15
+ const avatarUploader = new FileUploader({ allowedTypes: ['image/*'], maxSize: 2 * 1024 * 1024 });
16
+
8
17
  function logError(req, err) {
9
18
  if (req.log) req.log.error(err.message, { stack: err.stack });
10
19
  }
@@ -39,7 +48,10 @@ function createAuthRouter(options = {}) {
39
48
  return res.status(400).json({ error: 'Activation token is required' });
40
49
  }
41
50
 
42
- var result = await activate(token);
51
+ // Carried in the activation LINK and handed straight back — see
52
+ // authService.register. If a workflow is waiting on this person, this is
53
+ // the key that releases it.
54
+ var result = await activate(token, { workflowKey: req.query.workflowKey });
43
55
  res.json({ message: 'Account activated successfully', user: result });
44
56
  } catch (err) {
45
57
  logError(req, err);
@@ -47,6 +59,32 @@ function createAuthRouter(options = {}) {
47
59
  }
48
60
  });
49
61
 
62
+ router.get('/invite-preview', async function(req, res) {
63
+ try {
64
+ var token = req.query.token;
65
+ if (!token) return res.status(400).json({ error: 'Token is required' });
66
+ var info = await previewInvite(token);
67
+ res.json(info);
68
+ } catch (err) {
69
+ logError(req, err);
70
+ res.status(400).json({ error: err.message });
71
+ }
72
+ });
73
+
74
+ router.post('/invite-accept', async function(req, res) {
75
+ try {
76
+ const { token, name, password } = req.body;
77
+ if (!token || !password) {
78
+ return res.status(400).json({ error: 'Token and password are required' });
79
+ }
80
+ const result = await acceptInvite({ token, name, password });
81
+ res.json({ message: 'Invite accepted', user: result });
82
+ } catch (err) {
83
+ logError(req, err);
84
+ res.status(400).json({ error: err.message });
85
+ }
86
+ });
87
+
50
88
  router.post('/login', async function(req, res) {
51
89
  try {
52
90
  const { email, password } = req.body;
@@ -115,12 +153,20 @@ function createAuthRouter(options = {}) {
115
153
  return res.status(401).json({ error: 'Invalid or expired refresh token' });
116
154
  }
117
155
 
118
- const user = await User.query().findById(session.userId);
156
+ const user = await User.query().findById(session.userId).withGraphFetched('roles');
119
157
  if (!user || !user.isActive) {
120
158
  return res.status(401).json({ error: 'Account not found or deactivated' });
121
159
  }
122
160
 
123
- const result = await rotateSession(refreshToken, user);
161
+ // Pack role names so refreshed tokens carry the same role claims as login.
162
+ const tokenUser = {
163
+ id: user.id,
164
+ email: user.email,
165
+ name: user.name,
166
+ roles: (user.roles || []).map(function(r) { return r.name; })
167
+ };
168
+
169
+ const result = await rotateSession(refreshToken, tokenUser);
124
170
  if (!result) {
125
171
  return res.status(401).json({ error: 'Invalid or expired refresh token' });
126
172
  }
@@ -172,17 +218,103 @@ function createAuthRouter(options = {}) {
172
218
  }
173
219
  });
174
220
 
175
- // Get tenants for the current logged-in user
176
- router.get('/my-tenants', authMiddleware, async function(req, res) {
221
+ router.get('/profile', authMiddleware, async function(req, res) {
177
222
  try {
178
- var user = await User.query().findById(req.user.id).withGraphFetched('tenants');
179
- var tenants = (user && user.tenants) ? user.tenants : [];
180
- res.json(tenants.map(function(t) {
181
- return { id: t.id, name: t.name, code: t.code, description: t.description };
182
- }));
223
+ const profile = await getProfile(req.user.id);
224
+ res.json(profile);
183
225
  } catch (err) {
184
226
  logError(req, err);
185
- res.status(500).json({ error: 'Something went wrong' });
227
+ res.status(400).json({ error: err.message });
228
+ }
229
+ });
230
+
231
+ router.put('/profile', authMiddleware, async function(req, res) {
232
+ try {
233
+ const profile = await updateProfile(req.user.id, req.body);
234
+ res.json(profile);
235
+ } catch (err) {
236
+ logError(req, err);
237
+ res.status(400).json({ error: err.message });
238
+ }
239
+ });
240
+
241
+ router.post('/profile/avatar', authMiddleware, ...avatarUploader.single('avatar'), async function(req, res) {
242
+ try {
243
+ if (!req.file) return res.status(400).json({ error: 'avatar file is required' });
244
+ // Store just the generated filename — never the server's filesystem
245
+ // path (leaks server layout, and isn't meaningful once you swap disk
246
+ // storage for S3/etc). The app knows its own UPLOAD_DIR and however
247
+ // it serves it; it builds the actual URL from this filename.
248
+ const profile = await setProfilePic(req.user.id, req.file.filename);
249
+ res.json(profile);
250
+ } catch (err) {
251
+ logError(req, err);
252
+ res.status(400).json({ error: err.message });
253
+ }
254
+ });
255
+
256
+ router.post('/change-password', authMiddleware, async function(req, res) {
257
+ try {
258
+ const { oldPassword, newPassword } = req.body;
259
+ if (!oldPassword || !newPassword) {
260
+ return res.status(400).json({ error: 'oldPassword and newPassword are required' });
261
+ }
262
+ await changePassword(req.user.id, { oldPassword, newPassword });
263
+ res.json({ message: 'Password changed. Please log in again.' });
264
+ } catch (err) {
265
+ logError(req, err);
266
+ res.status(400).json({ error: err.message });
267
+ }
268
+ });
269
+
270
+ // Additive phone verification — like email activation, but for a phone
271
+ // number attached to an already-authenticated account. Body: { phoneNumber }
272
+ // optional (omit to re-send to the number already on file).
273
+ router.post('/request-phone-otp', authMiddleware, async function(req, res) {
274
+ try {
275
+ const result = await requestPhoneOtp(req.user.id, req.body.phoneNumber);
276
+ res.json(result);
277
+ } catch (err) {
278
+ logError(req, err);
279
+ res.status(400).json({ error: err.message });
280
+ }
281
+ });
282
+
283
+ router.post('/verify-phone-otp', authMiddleware, async function(req, res) {
284
+ try {
285
+ if (!req.body.code) return res.status(400).json({ error: 'code is required' });
286
+ const result = await verifyPhoneOtp(req.user.id, req.body.code);
287
+ res.json(result);
288
+ } catch (err) {
289
+ logError(req, err);
290
+ res.status(400).json({ error: err.message });
291
+ }
292
+ });
293
+
294
+ // ── SSE ticket ────────────────────────────────────────────────────────
295
+ //
296
+ // Mints a short-lived, single-use ticket so a browser can open an
297
+ // EventSource without leaking the primary session token in the URL.
298
+ //
299
+ // Body (optional):
300
+ // { scopes: [...] } — client-requested scopes. If omitted, defaults
301
+ // to the user's own namespace (['user:me:*']).
302
+ // Whatever is minted here is what the SSE server
303
+ // will enforce at subscribe time.
304
+ //
305
+ // NOTE the scopes are advisory intent — the SSE handler owns the final
306
+ // authorization check (see xeplr-base-apis/lib/sse.js#createHandler).
307
+ router.post('/sse-ticket', authMiddleware, async function(req, res) {
308
+ try {
309
+ var requested = (req.body && Array.isArray(req.body.scopes)) ? req.body.scopes : ['user:me:*'];
310
+ var { ticket, expiresIn } = await issueTicket({
311
+ userId: req.user.id,
312
+ scopes: requested
313
+ });
314
+ res.json({ ticket: ticket, expiresIn: expiresIn });
315
+ } catch (err) {
316
+ logError(req, err);
317
+ res.status(500).json({ error: 'Failed to mint SSE ticket' });
186
318
  }
187
319
  });
188
320