@technomoron/api-server-base 1.1.2 → 1.1.4

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.txt CHANGED
@@ -127,7 +127,9 @@ Request Lifecycle
127
127
 
128
128
  Client IP Helpers
129
129
  -----------------
130
- Use getClientIp(req) to obtain the most likely client address, skipping loopback entries collected from proxy headers. Call getClientIpChain(req) when you need the de-duplicated sequence gathered from the standard Forwarded/X-Forwarded-For/X-Real-IP headers as well as Express' req.ip/req.ips and the underlying socket.
130
+ Call `apiReq.getClientInfo()` when you need the entire client fingerprint captured during request hydration. It returns the raw user-agent string plus derived browser/OS/device labels along with the computed `ip` and `ipchain`.
131
+
132
+ Call `apiReq.getClientIp()` to obtain the most likely client address, skipping loopback entries collected from proxy headers. Use `apiReq.getClientIpChain()` when you need the de-duplicated sequence gathered from the standard Forwarded/X-Forwarded-For/X-Real-IP headers as well as Express' `req.ip`/`req.ips` and the underlying socket. Both helpers reuse the cached payload returned by `apiReq.getClientInfo()`.
131
133
 
132
134
  Extending the Base Classes
133
135
  --------------------------
@@ -110,6 +110,111 @@ function extractForwardedHeader(header) {
110
110
  }
111
111
  return ips;
112
112
  }
113
+ function detectBrowser(userAgent) {
114
+ const browserMatchers = [
115
+ { label: 'Edge', pattern: /(Edg|Edge|EdgiOS|EdgA)\/([\d.]+)/i, versionGroup: 2 },
116
+ { label: 'Chrome', pattern: /(Chrome|CriOS)\/([\d.]+)/i, versionGroup: 2 },
117
+ { label: 'Firefox', pattern: /(Firefox|FxiOS)\/([\d.]+)/i, versionGroup: 2 },
118
+ { label: 'Safari', pattern: /Version\/([\d.]+).*Safari/i, versionGroup: 1 },
119
+ { label: 'Opera', pattern: /(OPR|Opera)\/([\d.]+)/i, versionGroup: 2 },
120
+ { label: 'Brave', pattern: /Brave\/([\d.]+)/i, versionGroup: 1 },
121
+ { label: 'Vivaldi', pattern: /Vivaldi\/([\d.]+)/i, versionGroup: 1 },
122
+ { label: 'Electron', pattern: /Electron\/([\d.]+)/i, versionGroup: 1 },
123
+ { label: 'Node', pattern: /Node\.js\/([\d.]+)/i, versionGroup: 1 },
124
+ { label: 'IE', pattern: /MSIE ([\d.]+)/i, versionGroup: 1 },
125
+ { label: 'IE', pattern: /Trident\/.*rv:([\d.]+)/i, versionGroup: 1 }
126
+ ];
127
+ for (const matcher of browserMatchers) {
128
+ const m = userAgent.match(matcher.pattern);
129
+ if (m) {
130
+ const version = matcher.versionGroup ? m[matcher.versionGroup] : '';
131
+ return version ? `${matcher.label} ${version}` : matcher.label;
132
+ }
133
+ }
134
+ return '';
135
+ }
136
+ function detectOs(userAgent) {
137
+ const osMatchers = [
138
+ {
139
+ label: 'Windows',
140
+ pattern: /Windows NT ([\d.]+)/i,
141
+ transform: (match) => `Windows ${match[1]}`
142
+ },
143
+ {
144
+ label: 'iOS',
145
+ pattern: /iPhone OS ([\d_]+)/i,
146
+ transform: (match) => `iOS ${match[1].replace(/_/g, '.')}`
147
+ },
148
+ {
149
+ label: 'iPadOS',
150
+ pattern: /iPad; CPU OS ([\d_]+)/i,
151
+ transform: (match) => `iPadOS ${match[1].replace(/_/g, '.')}`
152
+ },
153
+ {
154
+ label: 'macOS',
155
+ pattern: /Mac OS X ([\d_]+)/i,
156
+ transform: (match) => `macOS ${match[1].replace(/_/g, '.')}`
157
+ },
158
+ {
159
+ label: 'Android',
160
+ pattern: /Android ([\d.]+)/i,
161
+ transform: (match) => `Android ${match[1]}`
162
+ },
163
+ {
164
+ label: 'ChromeOS',
165
+ pattern: /CrOS [^ ]+ ([\d.]+)/i,
166
+ transform: (match) => `ChromeOS ${match[1]}`
167
+ },
168
+ { label: 'Linux', pattern: /Linux/i },
169
+ { label: 'Unix', pattern: /X11/i }
170
+ ];
171
+ for (const matcher of osMatchers) {
172
+ const m = userAgent.match(matcher.pattern);
173
+ if (m) {
174
+ return matcher.transform ? matcher.transform(m) : matcher.label;
175
+ }
176
+ }
177
+ return '';
178
+ }
179
+ function detectDevice(userAgent, osLabel) {
180
+ if (/iPhone/i.test(userAgent)) {
181
+ return 'iPhone';
182
+ }
183
+ if (/iPad/i.test(userAgent)) {
184
+ return 'iPad';
185
+ }
186
+ if (/iPod/i.test(userAgent)) {
187
+ return 'iPod';
188
+ }
189
+ if (/Android/i.test(userAgent)) {
190
+ const match = userAgent.match(/;\s*([^;]+)\s+Build/i);
191
+ if (match) {
192
+ return match[1];
193
+ }
194
+ return 'Android Device';
195
+ }
196
+ if (/Macintosh/i.test(userAgent)) {
197
+ return 'Mac';
198
+ }
199
+ if (/Windows/i.test(userAgent)) {
200
+ return 'PC';
201
+ }
202
+ if (/CrOS/i.test(userAgent)) {
203
+ return 'Chromebook';
204
+ }
205
+ return osLabel;
206
+ }
207
+ function parseClientAgent(userAgentHeader) {
208
+ const raw = Array.isArray(userAgentHeader) ? userAgentHeader[0] : userAgentHeader;
209
+ const ua = typeof raw === 'string' ? raw.trim() : '';
210
+ if (!ua) {
211
+ return { ua: '', browser: '', os: '', device: '' };
212
+ }
213
+ const os = detectOs(ua);
214
+ const browser = detectBrowser(ua);
215
+ const device = detectDevice(ua, os);
216
+ return { ua, browser, os, device };
217
+ }
113
218
  function isLoopbackAddress(ip) {
114
219
  if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
115
220
  return true;
@@ -122,6 +227,71 @@ function isLoopbackAddress(ip) {
122
227
  }
123
228
  return false;
124
229
  }
230
+ function collectClientIpChain(req) {
231
+ const seen = new Set();
232
+ const result = [];
233
+ const pushNormalized = (ip) => {
234
+ if (!ip || seen.has(ip)) {
235
+ return;
236
+ }
237
+ seen.add(ip);
238
+ result.push(ip);
239
+ };
240
+ for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
241
+ pushNormalized(ip);
242
+ }
243
+ for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
244
+ pushNormalized(ip);
245
+ }
246
+ const realIp = req.headers['x-real-ip'];
247
+ if (Array.isArray(realIp)) {
248
+ realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
249
+ }
250
+ else if (typeof realIp === 'string') {
251
+ pushNormalized(normalizeIpAddress(realIp));
252
+ }
253
+ if (Array.isArray(req.ips)) {
254
+ for (const ip of req.ips) {
255
+ pushNormalized(normalizeIpAddress(ip));
256
+ }
257
+ }
258
+ if (typeof req.ip === 'string') {
259
+ pushNormalized(normalizeIpAddress(req.ip));
260
+ }
261
+ const socketAddress = req.socket?.remoteAddress;
262
+ if (typeof socketAddress === 'string') {
263
+ pushNormalized(normalizeIpAddress(socketAddress));
264
+ }
265
+ const connectionAddress = req.connection?.remoteAddress;
266
+ if (typeof connectionAddress === 'string') {
267
+ pushNormalized(normalizeIpAddress(connectionAddress));
268
+ }
269
+ return result;
270
+ }
271
+ function selectClientIp(chain) {
272
+ for (const ip of chain) {
273
+ if (!isLoopbackAddress(ip)) {
274
+ return ip;
275
+ }
276
+ }
277
+ return chain[0] ?? null;
278
+ }
279
+ function buildClientInfo(req) {
280
+ const agent = parseClientAgent(req.headers['user-agent']);
281
+ const ipchain = collectClientIpChain(req);
282
+ const ip = selectClientIp(ipchain);
283
+ return {
284
+ ...agent,
285
+ ip,
286
+ ipchain
287
+ };
288
+ }
289
+ function ensureClientInfo(apiReq) {
290
+ if (!apiReq.clientInfo) {
291
+ apiReq.clientInfo = buildClientInfo(apiReq.req);
292
+ }
293
+ return apiReq.clientInfo;
294
+ }
125
295
  class ApiError extends Error {
126
296
  constructor({ code, message, data, errors }) {
127
297
  const msg = guess_exception_text(message, '[Unknown error (null/undefined)]');
@@ -133,6 +303,13 @@ class ApiError extends Error {
133
303
  }
134
304
  }
135
305
  exports.ApiError = ApiError;
306
+ function isApiErrorLike(candidate) {
307
+ if (!candidate || typeof candidate !== 'object') {
308
+ return false;
309
+ }
310
+ const maybeError = candidate;
311
+ return typeof maybeError.code === 'number' && typeof maybeError.message === 'string';
312
+ }
136
313
  function fillConfig(config) {
137
314
  return {
138
315
  apiPort: config.apiPort ?? 3101,
@@ -413,13 +590,17 @@ class ApiServer {
413
590
  return async (req, res, next) => {
414
591
  void next;
415
592
  try {
416
- const apiReq = (this.currReq = {
593
+ const apiReq = {
417
594
  server: this,
418
595
  req,
419
596
  res,
420
597
  token: '',
421
- tokenData: null
422
- });
598
+ tokenData: null,
599
+ getClientInfo: () => ensureClientInfo(apiReq),
600
+ getClientIp: () => ensureClientInfo(apiReq).ip,
601
+ getClientIpChain: () => ensureClientInfo(apiReq).ipchain
602
+ };
603
+ this.currReq = apiReq;
423
604
  if (this.config.hydrateGetBody) {
424
605
  hydrateGetBody(apiReq.req);
425
606
  }
@@ -428,16 +609,31 @@ class ApiServer {
428
609
  }
429
610
  apiReq.tokenData = await this.authenticate(apiReq, auth.type);
430
611
  await this.authorize(apiReq, auth.req);
431
- const [code, data = null, message = 'Success'] = await handler(apiReq);
612
+ const handlerResult = await handler(apiReq);
613
+ if (!Array.isArray(handlerResult)) {
614
+ throw new ApiError({
615
+ code: 500,
616
+ message: 'Handler result must be an array: [status, data?, message?]'
617
+ });
618
+ }
619
+ const [code, data = null, rawMessage = 'Success'] = handlerResult;
620
+ if (typeof code !== 'number' || Number.isNaN(code)) {
621
+ throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
622
+ }
623
+ const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
432
624
  res.status(code).json({ code, message, data });
433
625
  }
434
626
  catch (error) {
435
- if (error instanceof ApiError) {
436
- res.status(error.code).json({
437
- code: error.code,
438
- message: error.message,
439
- data: error.data || null,
440
- errors: error.errors || []
627
+ if (error instanceof ApiError || isApiErrorLike(error)) {
628
+ const apiError = error;
629
+ const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
630
+ ? apiError.errors
631
+ : {};
632
+ res.status(apiError.code).json({
633
+ code: apiError.code,
634
+ message: apiError.message,
635
+ data: apiError.data ?? null,
636
+ errors: normalizedErrors
441
637
  });
442
638
  }
443
639
  else {
@@ -445,62 +641,12 @@ class ApiServer {
445
641
  code: 500,
446
642
  message: this.guessExceptionText(error),
447
643
  data: null,
448
- errors: []
644
+ errors: {}
449
645
  });
450
646
  }
451
647
  }
452
648
  };
453
649
  }
454
- getClientIp(req) {
455
- const chain = this.getClientIpChain(req);
456
- for (const ip of chain) {
457
- if (!isLoopbackAddress(ip)) {
458
- return ip;
459
- }
460
- }
461
- return chain[0] ?? null;
462
- }
463
- getClientIpChain(req) {
464
- const seen = new Set();
465
- const result = [];
466
- const pushNormalized = (ip) => {
467
- if (!ip || seen.has(ip)) {
468
- return;
469
- }
470
- seen.add(ip);
471
- result.push(ip);
472
- };
473
- for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
474
- pushNormalized(ip);
475
- }
476
- for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
477
- pushNormalized(ip);
478
- }
479
- const realIp = req.headers['x-real-ip'];
480
- if (Array.isArray(realIp)) {
481
- realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
482
- }
483
- else if (typeof realIp === 'string') {
484
- pushNormalized(normalizeIpAddress(realIp));
485
- }
486
- if (Array.isArray(req.ips)) {
487
- for (const ip of req.ips) {
488
- pushNormalized(normalizeIpAddress(ip));
489
- }
490
- }
491
- if (typeof req.ip === 'string') {
492
- pushNormalized(normalizeIpAddress(req.ip));
493
- }
494
- const socketAddress = req.socket?.remoteAddress;
495
- if (typeof socketAddress === 'string') {
496
- pushNormalized(normalizeIpAddress(socketAddress));
497
- }
498
- const connectionAddress = req.connection?.remoteAddress;
499
- if (typeof connectionAddress === 'string') {
500
- pushNormalized(normalizeIpAddress(connectionAddress));
501
- }
502
- return result;
503
- }
504
650
  api(module) {
505
651
  const router = express_1.default.Router();
506
652
  module.server = this;
@@ -46,6 +46,20 @@ export interface ApiRequest {
46
46
  res: Response;
47
47
  tokenData?: ApiTokenData | null;
48
48
  token?: string;
49
+ clientInfo?: ClientInfo;
50
+ getClientInfo: () => ClientInfo;
51
+ getClientIp: () => string | null;
52
+ getClientIpChain: () => string[];
53
+ }
54
+ export interface ClientAgentProfile {
55
+ ua: string;
56
+ browser: string;
57
+ os: string;
58
+ device: string;
59
+ }
60
+ export interface ClientInfo extends ClientAgentProfile {
61
+ ip: string | null;
62
+ ipchain: string[];
49
63
  }
50
64
  export { ApiModule } from './api-module.js';
51
65
  export type { ApiHandler, ApiAuthType, ApiAuthClass, ApiRoute, ApiKey } from './api-module.js';
@@ -121,8 +135,6 @@ export declare class ApiServer {
121
135
  private verifyJWT;
122
136
  private authenticate;
123
137
  private handle_request;
124
- getClientIp(req: RequestWithStuff): string | null;
125
- getClientIpChain(req: RequestWithStuff): string[];
126
138
  api<T extends ApiModule<any>>(module: T): this;
127
139
  dumpRequest(apiReq: ApiRequest): void;
128
140
  }
@@ -4,12 +4,18 @@ export interface AuthTokenMetadata {
4
4
  domain?: string;
5
5
  fingerprint?: string;
6
6
  label?: string;
7
+ browser?: string;
8
+ device?: string;
9
+ ip?: string;
10
+ os?: string;
7
11
  scope?: string | string[];
8
12
  revokeSessions?: 'device' | 'domain' | 'client' | 'user';
9
13
  }
10
14
  export interface AuthTokenData extends AuthTokenMetadata {
11
15
  access: string;
12
16
  expires?: Date;
17
+ issuedAt?: Date;
18
+ lastSeenAt?: Date;
13
19
  refresh: string;
14
20
  userId: AuthIdentifier;
15
21
  }
@@ -46,6 +46,20 @@ export interface ApiRequest {
46
46
  res: Response;
47
47
  tokenData?: ApiTokenData | null;
48
48
  token?: string;
49
+ clientInfo?: ClientInfo;
50
+ getClientInfo: () => ClientInfo;
51
+ getClientIp: () => string | null;
52
+ getClientIpChain: () => string[];
53
+ }
54
+ export interface ClientAgentProfile {
55
+ ua: string;
56
+ browser: string;
57
+ os: string;
58
+ device: string;
59
+ }
60
+ export interface ClientInfo extends ClientAgentProfile {
61
+ ip: string | null;
62
+ ipchain: string[];
49
63
  }
50
64
  export { ApiModule } from './api-module.js';
51
65
  export type { ApiHandler, ApiAuthType, ApiAuthClass, ApiRoute, ApiKey } from './api-module.js';
@@ -121,8 +135,6 @@ export declare class ApiServer {
121
135
  private verifyJWT;
122
136
  private authenticate;
123
137
  private handle_request;
124
- getClientIp(req: RequestWithStuff): string | null;
125
- getClientIpChain(req: RequestWithStuff): string[];
126
138
  api<T extends ApiModule<any>>(module: T): this;
127
139
  dumpRequest(apiReq: ApiRequest): void;
128
140
  }
@@ -103,6 +103,111 @@ function extractForwardedHeader(header) {
103
103
  }
104
104
  return ips;
105
105
  }
106
+ function detectBrowser(userAgent) {
107
+ const browserMatchers = [
108
+ { label: 'Edge', pattern: /(Edg|Edge|EdgiOS|EdgA)\/([\d.]+)/i, versionGroup: 2 },
109
+ { label: 'Chrome', pattern: /(Chrome|CriOS)\/([\d.]+)/i, versionGroup: 2 },
110
+ { label: 'Firefox', pattern: /(Firefox|FxiOS)\/([\d.]+)/i, versionGroup: 2 },
111
+ { label: 'Safari', pattern: /Version\/([\d.]+).*Safari/i, versionGroup: 1 },
112
+ { label: 'Opera', pattern: /(OPR|Opera)\/([\d.]+)/i, versionGroup: 2 },
113
+ { label: 'Brave', pattern: /Brave\/([\d.]+)/i, versionGroup: 1 },
114
+ { label: 'Vivaldi', pattern: /Vivaldi\/([\d.]+)/i, versionGroup: 1 },
115
+ { label: 'Electron', pattern: /Electron\/([\d.]+)/i, versionGroup: 1 },
116
+ { label: 'Node', pattern: /Node\.js\/([\d.]+)/i, versionGroup: 1 },
117
+ { label: 'IE', pattern: /MSIE ([\d.]+)/i, versionGroup: 1 },
118
+ { label: 'IE', pattern: /Trident\/.*rv:([\d.]+)/i, versionGroup: 1 }
119
+ ];
120
+ for (const matcher of browserMatchers) {
121
+ const m = userAgent.match(matcher.pattern);
122
+ if (m) {
123
+ const version = matcher.versionGroup ? m[matcher.versionGroup] : '';
124
+ return version ? `${matcher.label} ${version}` : matcher.label;
125
+ }
126
+ }
127
+ return '';
128
+ }
129
+ function detectOs(userAgent) {
130
+ const osMatchers = [
131
+ {
132
+ label: 'Windows',
133
+ pattern: /Windows NT ([\d.]+)/i,
134
+ transform: (match) => `Windows ${match[1]}`
135
+ },
136
+ {
137
+ label: 'iOS',
138
+ pattern: /iPhone OS ([\d_]+)/i,
139
+ transform: (match) => `iOS ${match[1].replace(/_/g, '.')}`
140
+ },
141
+ {
142
+ label: 'iPadOS',
143
+ pattern: /iPad; CPU OS ([\d_]+)/i,
144
+ transform: (match) => `iPadOS ${match[1].replace(/_/g, '.')}`
145
+ },
146
+ {
147
+ label: 'macOS',
148
+ pattern: /Mac OS X ([\d_]+)/i,
149
+ transform: (match) => `macOS ${match[1].replace(/_/g, '.')}`
150
+ },
151
+ {
152
+ label: 'Android',
153
+ pattern: /Android ([\d.]+)/i,
154
+ transform: (match) => `Android ${match[1]}`
155
+ },
156
+ {
157
+ label: 'ChromeOS',
158
+ pattern: /CrOS [^ ]+ ([\d.]+)/i,
159
+ transform: (match) => `ChromeOS ${match[1]}`
160
+ },
161
+ { label: 'Linux', pattern: /Linux/i },
162
+ { label: 'Unix', pattern: /X11/i }
163
+ ];
164
+ for (const matcher of osMatchers) {
165
+ const m = userAgent.match(matcher.pattern);
166
+ if (m) {
167
+ return matcher.transform ? matcher.transform(m) : matcher.label;
168
+ }
169
+ }
170
+ return '';
171
+ }
172
+ function detectDevice(userAgent, osLabel) {
173
+ if (/iPhone/i.test(userAgent)) {
174
+ return 'iPhone';
175
+ }
176
+ if (/iPad/i.test(userAgent)) {
177
+ return 'iPad';
178
+ }
179
+ if (/iPod/i.test(userAgent)) {
180
+ return 'iPod';
181
+ }
182
+ if (/Android/i.test(userAgent)) {
183
+ const match = userAgent.match(/;\s*([^;]+)\s+Build/i);
184
+ if (match) {
185
+ return match[1];
186
+ }
187
+ return 'Android Device';
188
+ }
189
+ if (/Macintosh/i.test(userAgent)) {
190
+ return 'Mac';
191
+ }
192
+ if (/Windows/i.test(userAgent)) {
193
+ return 'PC';
194
+ }
195
+ if (/CrOS/i.test(userAgent)) {
196
+ return 'Chromebook';
197
+ }
198
+ return osLabel;
199
+ }
200
+ function parseClientAgent(userAgentHeader) {
201
+ const raw = Array.isArray(userAgentHeader) ? userAgentHeader[0] : userAgentHeader;
202
+ const ua = typeof raw === 'string' ? raw.trim() : '';
203
+ if (!ua) {
204
+ return { ua: '', browser: '', os: '', device: '' };
205
+ }
206
+ const os = detectOs(ua);
207
+ const browser = detectBrowser(ua);
208
+ const device = detectDevice(ua, os);
209
+ return { ua, browser, os, device };
210
+ }
106
211
  function isLoopbackAddress(ip) {
107
212
  if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
108
213
  return true;
@@ -115,6 +220,71 @@ function isLoopbackAddress(ip) {
115
220
  }
116
221
  return false;
117
222
  }
223
+ function collectClientIpChain(req) {
224
+ const seen = new Set();
225
+ const result = [];
226
+ const pushNormalized = (ip) => {
227
+ if (!ip || seen.has(ip)) {
228
+ return;
229
+ }
230
+ seen.add(ip);
231
+ result.push(ip);
232
+ };
233
+ for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
234
+ pushNormalized(ip);
235
+ }
236
+ for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
237
+ pushNormalized(ip);
238
+ }
239
+ const realIp = req.headers['x-real-ip'];
240
+ if (Array.isArray(realIp)) {
241
+ realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
242
+ }
243
+ else if (typeof realIp === 'string') {
244
+ pushNormalized(normalizeIpAddress(realIp));
245
+ }
246
+ if (Array.isArray(req.ips)) {
247
+ for (const ip of req.ips) {
248
+ pushNormalized(normalizeIpAddress(ip));
249
+ }
250
+ }
251
+ if (typeof req.ip === 'string') {
252
+ pushNormalized(normalizeIpAddress(req.ip));
253
+ }
254
+ const socketAddress = req.socket?.remoteAddress;
255
+ if (typeof socketAddress === 'string') {
256
+ pushNormalized(normalizeIpAddress(socketAddress));
257
+ }
258
+ const connectionAddress = req.connection?.remoteAddress;
259
+ if (typeof connectionAddress === 'string') {
260
+ pushNormalized(normalizeIpAddress(connectionAddress));
261
+ }
262
+ return result;
263
+ }
264
+ function selectClientIp(chain) {
265
+ for (const ip of chain) {
266
+ if (!isLoopbackAddress(ip)) {
267
+ return ip;
268
+ }
269
+ }
270
+ return chain[0] ?? null;
271
+ }
272
+ function buildClientInfo(req) {
273
+ const agent = parseClientAgent(req.headers['user-agent']);
274
+ const ipchain = collectClientIpChain(req);
275
+ const ip = selectClientIp(ipchain);
276
+ return {
277
+ ...agent,
278
+ ip,
279
+ ipchain
280
+ };
281
+ }
282
+ function ensureClientInfo(apiReq) {
283
+ if (!apiReq.clientInfo) {
284
+ apiReq.clientInfo = buildClientInfo(apiReq.req);
285
+ }
286
+ return apiReq.clientInfo;
287
+ }
118
288
  export class ApiError extends Error {
119
289
  constructor({ code, message, data, errors }) {
120
290
  const msg = guess_exception_text(message, '[Unknown error (null/undefined)]');
@@ -125,6 +295,13 @@ export class ApiError extends Error {
125
295
  this.errors = errors !== undefined ? errors : {};
126
296
  }
127
297
  }
298
+ function isApiErrorLike(candidate) {
299
+ if (!candidate || typeof candidate !== 'object') {
300
+ return false;
301
+ }
302
+ const maybeError = candidate;
303
+ return typeof maybeError.code === 'number' && typeof maybeError.message === 'string';
304
+ }
128
305
  function fillConfig(config) {
129
306
  return {
130
307
  apiPort: config.apiPort ?? 3101,
@@ -405,13 +582,17 @@ export class ApiServer {
405
582
  return async (req, res, next) => {
406
583
  void next;
407
584
  try {
408
- const apiReq = (this.currReq = {
585
+ const apiReq = {
409
586
  server: this,
410
587
  req,
411
588
  res,
412
589
  token: '',
413
- tokenData: null
414
- });
590
+ tokenData: null,
591
+ getClientInfo: () => ensureClientInfo(apiReq),
592
+ getClientIp: () => ensureClientInfo(apiReq).ip,
593
+ getClientIpChain: () => ensureClientInfo(apiReq).ipchain
594
+ };
595
+ this.currReq = apiReq;
415
596
  if (this.config.hydrateGetBody) {
416
597
  hydrateGetBody(apiReq.req);
417
598
  }
@@ -420,16 +601,31 @@ export class ApiServer {
420
601
  }
421
602
  apiReq.tokenData = await this.authenticate(apiReq, auth.type);
422
603
  await this.authorize(apiReq, auth.req);
423
- const [code, data = null, message = 'Success'] = await handler(apiReq);
604
+ const handlerResult = await handler(apiReq);
605
+ if (!Array.isArray(handlerResult)) {
606
+ throw new ApiError({
607
+ code: 500,
608
+ message: 'Handler result must be an array: [status, data?, message?]'
609
+ });
610
+ }
611
+ const [code, data = null, rawMessage = 'Success'] = handlerResult;
612
+ if (typeof code !== 'number' || Number.isNaN(code)) {
613
+ throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
614
+ }
615
+ const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
424
616
  res.status(code).json({ code, message, data });
425
617
  }
426
618
  catch (error) {
427
- if (error instanceof ApiError) {
428
- res.status(error.code).json({
429
- code: error.code,
430
- message: error.message,
431
- data: error.data || null,
432
- errors: error.errors || []
619
+ if (error instanceof ApiError || isApiErrorLike(error)) {
620
+ const apiError = error;
621
+ const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
622
+ ? apiError.errors
623
+ : {};
624
+ res.status(apiError.code).json({
625
+ code: apiError.code,
626
+ message: apiError.message,
627
+ data: apiError.data ?? null,
628
+ errors: normalizedErrors
433
629
  });
434
630
  }
435
631
  else {
@@ -437,62 +633,12 @@ export class ApiServer {
437
633
  code: 500,
438
634
  message: this.guessExceptionText(error),
439
635
  data: null,
440
- errors: []
636
+ errors: {}
441
637
  });
442
638
  }
443
639
  }
444
640
  };
445
641
  }
446
- getClientIp(req) {
447
- const chain = this.getClientIpChain(req);
448
- for (const ip of chain) {
449
- if (!isLoopbackAddress(ip)) {
450
- return ip;
451
- }
452
- }
453
- return chain[0] ?? null;
454
- }
455
- getClientIpChain(req) {
456
- const seen = new Set();
457
- const result = [];
458
- const pushNormalized = (ip) => {
459
- if (!ip || seen.has(ip)) {
460
- return;
461
- }
462
- seen.add(ip);
463
- result.push(ip);
464
- };
465
- for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
466
- pushNormalized(ip);
467
- }
468
- for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
469
- pushNormalized(ip);
470
- }
471
- const realIp = req.headers['x-real-ip'];
472
- if (Array.isArray(realIp)) {
473
- realIp.forEach((value) => pushNormalized(normalizeIpAddress(value)));
474
- }
475
- else if (typeof realIp === 'string') {
476
- pushNormalized(normalizeIpAddress(realIp));
477
- }
478
- if (Array.isArray(req.ips)) {
479
- for (const ip of req.ips) {
480
- pushNormalized(normalizeIpAddress(ip));
481
- }
482
- }
483
- if (typeof req.ip === 'string') {
484
- pushNormalized(normalizeIpAddress(req.ip));
485
- }
486
- const socketAddress = req.socket?.remoteAddress;
487
- if (typeof socketAddress === 'string') {
488
- pushNormalized(normalizeIpAddress(socketAddress));
489
- }
490
- const connectionAddress = req.connection?.remoteAddress;
491
- if (typeof connectionAddress === 'string') {
492
- pushNormalized(normalizeIpAddress(connectionAddress));
493
- }
494
- return result;
495
- }
496
642
  api(module) {
497
643
  const router = express.Router();
498
644
  module.server = this;
@@ -4,12 +4,18 @@ export interface AuthTokenMetadata {
4
4
  domain?: string;
5
5
  fingerprint?: string;
6
6
  label?: string;
7
+ browser?: string;
8
+ device?: string;
9
+ ip?: string;
10
+ os?: string;
7
11
  scope?: string | string[];
8
12
  revokeSessions?: 'device' | 'domain' | 'client' | 'user';
9
13
  }
10
14
  export interface AuthTokenData extends AuthTokenMetadata {
11
15
  access: string;
12
16
  expires?: Date;
17
+ issuedAt?: Date;
18
+ lastSeenAt?: Date;
13
19
  refresh: string;
14
20
  userId: AuthIdentifier;
15
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@technomoron/api-server-base",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "Api Server Skeleton / Base Class",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.cjs",