@technomoron/api-server-base 2.0.0-beta.21 → 2.0.0-beta.23

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 (177) hide show
  1. package/dist/cjs/common/types.cjs +10 -0
  2. package/dist/cjs/common/types.d.ts +137 -0
  3. package/dist/cjs/{api-module.cjs → server/src/api-module.cjs} +8 -0
  4. package/dist/{esm → cjs/server/src}/api-module.d.ts +15 -0
  5. package/dist/cjs/{api-server-base.cjs → server/src/api-server-base.cjs} +669 -627
  6. package/dist/{esm → cjs/server/src}/api-server-base.d.ts +105 -78
  7. package/dist/cjs/{auth-api/auth-module.js → server/src/auth-api/auth-module.cjs} +96 -76
  8. package/dist/cjs/{auth-api → server/src/auth-api}/auth-module.d.ts +1 -1
  9. package/dist/cjs/{auth-api/compat-auth-storage.js → server/src/auth-api/compat-auth-storage.cjs} +4 -4
  10. package/dist/cjs/{auth-api/mem-auth-store.js → server/src/auth-api/mem-auth-store.cjs} +7 -7
  11. package/dist/cjs/{auth-api/module.js → server/src/auth-api/module.cjs} +1 -1
  12. package/dist/cjs/server/src/auth-api/schemas.cjs +171 -0
  13. package/dist/cjs/server/src/auth-api/schemas.d.ts +21 -0
  14. package/dist/cjs/{auth-api/sql-auth-store.js → server/src/auth-api/sql-auth-store.cjs} +8 -8
  15. package/dist/cjs/{auth-api/user-id.js → server/src/auth-api/user-id.cjs} +12 -3
  16. package/dist/{esm → cjs/server/src}/auth-cookie-options.d.ts +5 -3
  17. package/dist/cjs/server/src/base/client-info.cjs +285 -0
  18. package/dist/cjs/server/src/base/client-info.d.ts +27 -0
  19. package/dist/cjs/server/src/base/error-utils.cjs +50 -0
  20. package/dist/cjs/server/src/base/error-utils.d.ts +16 -0
  21. package/dist/cjs/server/src/base/request-utils.cjs +27 -0
  22. package/dist/cjs/server/src/base/request-utils.d.ts +8 -0
  23. package/dist/cjs/{index.cjs → server/src/index.cjs} +24 -15
  24. package/dist/{esm → cjs/server/src}/index.d.ts +7 -0
  25. package/dist/cjs/server/src/limiter/auth-rate-limiter.cjs +35 -0
  26. package/dist/cjs/server/src/limiter/auth-rate-limiter.d.ts +12 -0
  27. package/dist/cjs/server/src/limiter/fixed-window.cjs +41 -0
  28. package/dist/cjs/server/src/limiter/fixed-window.d.ts +11 -0
  29. package/dist/cjs/{oauth/base.js → server/src/oauth/base.cjs} +1 -0
  30. package/dist/cjs/{oauth → server/src/oauth}/base.d.ts +8 -1
  31. package/dist/cjs/{oauth/memory.js → server/src/oauth/memory.cjs} +7 -4
  32. package/dist/{esm → cjs/server/src}/oauth/memory.d.ts +1 -1
  33. package/dist/cjs/{oauth/models.js → server/src/oauth/models.cjs} +2 -2
  34. package/dist/cjs/{oauth/sequelize.js → server/src/oauth/sequelize.cjs} +11 -7
  35. package/dist/{esm → cjs/server/src}/oauth/sequelize.d.ts +1 -1
  36. package/dist/cjs/{passkey/base.js → server/src/passkey/base.cjs} +1 -0
  37. package/dist/{esm → cjs/server/src}/passkey/base.d.ts +11 -0
  38. package/dist/cjs/{passkey/memory.js → server/src/passkey/memory.cjs} +2 -2
  39. package/dist/cjs/{passkey/models.js → server/src/passkey/models.cjs} +1 -1
  40. package/dist/cjs/{passkey/sequelize.js → server/src/passkey/sequelize.cjs} +3 -3
  41. package/dist/cjs/{passkey/service.js → server/src/passkey/service.cjs} +17 -3
  42. package/dist/{esm → cjs/server/src}/passkey/service.d.ts +1 -1
  43. package/dist/cjs/{sequelize-utils.js → server/src/sequelize-utils.cjs} +4 -5
  44. package/dist/cjs/{token/base.js → server/src/token/base.cjs} +4 -0
  45. package/dist/{esm → cjs/server/src}/token/base.d.ts +7 -0
  46. package/dist/cjs/{token/memory.js → server/src/token/memory.cjs} +15 -20
  47. package/dist/cjs/{token/sequelize.js → server/src/token/sequelize.cjs} +25 -11
  48. package/dist/cjs/server/src/upload/memory.cjs +92 -0
  49. package/dist/cjs/server/src/upload/memory.d.ts +17 -0
  50. package/dist/cjs/server/src/upload/tus-module.cjs +270 -0
  51. package/dist/cjs/server/src/upload/tus-module.d.ts +38 -0
  52. package/dist/cjs/server/src/upload/types.d.ts +8 -0
  53. package/dist/cjs/{user/base.js → server/src/user/base.cjs} +1 -0
  54. package/dist/cjs/{user → server/src/user}/base.d.ts +9 -0
  55. package/dist/cjs/{user/memory.js → server/src/user/memory.cjs} +29 -7
  56. package/dist/cjs/{user/sequelize.js → server/src/user/sequelize.cjs} +33 -8
  57. package/dist/cjs/server/src/user/types.cjs +2 -0
  58. package/dist/esm/common/types.d.ts +137 -0
  59. package/dist/esm/common/types.js +9 -0
  60. package/dist/{cjs → esm/server/src}/api-module.d.ts +15 -0
  61. package/dist/esm/{api-module.js → server/src/api-module.js} +8 -0
  62. package/dist/{cjs → esm/server/src}/api-server-base.d.ts +105 -78
  63. package/dist/esm/{api-server-base.js → server/src/api-server-base.js} +658 -616
  64. package/dist/esm/{auth-api → server/src/auth-api}/auth-module.d.ts +1 -1
  65. package/dist/esm/{auth-api → server/src/auth-api}/auth-module.js +92 -72
  66. package/dist/esm/{auth-api → server/src/auth-api}/compat-auth-storage.js +3 -3
  67. package/dist/esm/server/src/auth-api/schemas.d.ts +21 -0
  68. package/dist/esm/server/src/auth-api/schemas.js +168 -0
  69. package/dist/esm/{auth-api → server/src/auth-api}/user-id.js +12 -3
  70. package/dist/{cjs → esm/server/src}/auth-cookie-options.d.ts +5 -3
  71. package/dist/esm/server/src/base/client-info.d.ts +27 -0
  72. package/dist/esm/server/src/base/client-info.js +282 -0
  73. package/dist/esm/server/src/base/error-utils.d.ts +16 -0
  74. package/dist/esm/server/src/base/error-utils.js +44 -0
  75. package/dist/esm/server/src/base/request-utils.d.ts +8 -0
  76. package/dist/esm/server/src/base/request-utils.js +23 -0
  77. package/dist/{cjs → esm/server/src}/index.d.ts +7 -0
  78. package/dist/esm/{index.js → server/src/index.js} +4 -0
  79. package/dist/esm/server/src/limiter/auth-rate-limiter.d.ts +12 -0
  80. package/dist/esm/server/src/limiter/auth-rate-limiter.js +32 -0
  81. package/dist/esm/server/src/limiter/fixed-window.d.ts +11 -0
  82. package/dist/esm/server/src/limiter/fixed-window.js +37 -0
  83. package/dist/esm/{oauth → server/src/oauth}/base.d.ts +8 -1
  84. package/dist/esm/server/src/oauth/base.js +3 -0
  85. package/dist/{cjs → esm/server/src}/oauth/memory.d.ts +1 -1
  86. package/dist/esm/{oauth → server/src/oauth}/memory.js +5 -2
  87. package/dist/{cjs → esm/server/src}/oauth/sequelize.d.ts +1 -1
  88. package/dist/esm/{oauth → server/src/oauth}/sequelize.js +6 -2
  89. package/dist/{cjs → esm/server/src}/passkey/base.d.ts +11 -0
  90. package/dist/esm/server/src/passkey/base.js +3 -0
  91. package/dist/{cjs → esm/server/src}/passkey/service.d.ts +1 -1
  92. package/dist/esm/{passkey → server/src/passkey}/service.js +17 -3
  93. package/dist/esm/{sequelize-utils.js → server/src/sequelize-utils.js} +4 -5
  94. package/dist/{cjs → esm/server/src}/token/base.d.ts +7 -0
  95. package/dist/esm/{token → server/src/token}/base.js +4 -0
  96. package/dist/esm/{token → server/src/token}/memory.js +14 -19
  97. package/dist/esm/{token → server/src/token}/sequelize.js +22 -8
  98. package/dist/esm/server/src/upload/memory.d.ts +17 -0
  99. package/dist/esm/server/src/upload/memory.js +86 -0
  100. package/dist/esm/server/src/upload/tus-module.d.ts +38 -0
  101. package/dist/esm/server/src/upload/tus-module.js +266 -0
  102. package/dist/esm/server/src/upload/types.d.ts +8 -0
  103. package/dist/esm/{user → server/src/user}/base.d.ts +9 -0
  104. package/dist/esm/{user → server/src/user}/base.js +1 -0
  105. package/dist/esm/{user → server/src/user}/memory.js +27 -5
  106. package/dist/esm/{user → server/src/user}/sequelize.js +30 -5
  107. package/dist/esm/server/src/user/types.js +1 -0
  108. package/docs/swagger/openapi.json +411 -125
  109. package/package.json +129 -134
  110. package/README.txt +0 -213
  111. package/dist/esm/oauth/base.js +0 -2
  112. package/dist/esm/passkey/base.js +0 -2
  113. /package/dist/cjs/{auth-api → server/src/auth-api}/compat-auth-storage.d.ts +0 -0
  114. /package/dist/cjs/{auth-api → server/src/auth-api}/mem-auth-store.d.ts +0 -0
  115. /package/dist/cjs/{auth-api → server/src/auth-api}/module.d.ts +0 -0
  116. /package/dist/cjs/{auth-api → server/src/auth-api}/sql-auth-store.d.ts +0 -0
  117. /package/dist/cjs/{auth-api/storage.js → server/src/auth-api/storage.cjs} +0 -0
  118. /package/dist/cjs/{auth-api → server/src/auth-api}/storage.d.ts +0 -0
  119. /package/dist/cjs/{auth-api/types.js → server/src/auth-api/types.cjs} +0 -0
  120. /package/dist/cjs/{auth-api → server/src/auth-api}/types.d.ts +0 -0
  121. /package/dist/cjs/{auth-api → server/src/auth-api}/user-id.d.ts +0 -0
  122. /package/dist/cjs/{auth-cookie-options.js → server/src/auth-cookie-options.cjs} +0 -0
  123. /package/dist/cjs/{oauth → server/src/oauth}/models.d.ts +0 -0
  124. /package/dist/cjs/{oauth/types.js → server/src/oauth/types.cjs} +0 -0
  125. /package/dist/cjs/{oauth → server/src/oauth}/types.d.ts +0 -0
  126. /package/dist/cjs/{passkey/config.js → server/src/passkey/config.cjs} +0 -0
  127. /package/dist/cjs/{passkey → server/src/passkey}/config.d.ts +0 -0
  128. /package/dist/cjs/{passkey → server/src/passkey}/memory.d.ts +0 -0
  129. /package/dist/cjs/{passkey → server/src/passkey}/models.d.ts +0 -0
  130. /package/dist/cjs/{passkey → server/src/passkey}/sequelize.d.ts +0 -0
  131. /package/dist/cjs/{passkey/types.js → server/src/passkey/types.cjs} +0 -0
  132. /package/dist/cjs/{passkey → server/src/passkey}/types.d.ts +0 -0
  133. /package/dist/cjs/{sequelize-utils.d.ts → server/src/sequelize-utils.d.ts} +0 -0
  134. /package/dist/cjs/{token → server/src/token}/memory.d.ts +0 -0
  135. /package/dist/cjs/{token → server/src/token}/sequelize.d.ts +0 -0
  136. /package/dist/cjs/{token/types.js → server/src/token/types.cjs} +0 -0
  137. /package/dist/cjs/{token → server/src/token}/types.d.ts +0 -0
  138. /package/dist/cjs/{user/types.js → server/src/upload/types.cjs} +0 -0
  139. /package/dist/cjs/{user → server/src/user}/memory.d.ts +0 -0
  140. /package/dist/cjs/{user → server/src/user}/sequelize.d.ts +0 -0
  141. /package/dist/cjs/{user → server/src/user}/types.d.ts +0 -0
  142. /package/dist/esm/{auth-api → server/src/auth-api}/compat-auth-storage.d.ts +0 -0
  143. /package/dist/esm/{auth-api → server/src/auth-api}/mem-auth-store.d.ts +0 -0
  144. /package/dist/esm/{auth-api → server/src/auth-api}/mem-auth-store.js +0 -0
  145. /package/dist/esm/{auth-api → server/src/auth-api}/module.d.ts +0 -0
  146. /package/dist/esm/{auth-api → server/src/auth-api}/module.js +0 -0
  147. /package/dist/esm/{auth-api → server/src/auth-api}/sql-auth-store.d.ts +0 -0
  148. /package/dist/esm/{auth-api → server/src/auth-api}/sql-auth-store.js +0 -0
  149. /package/dist/esm/{auth-api → server/src/auth-api}/storage.d.ts +0 -0
  150. /package/dist/esm/{auth-api → server/src/auth-api}/storage.js +0 -0
  151. /package/dist/esm/{auth-api → server/src/auth-api}/types.d.ts +0 -0
  152. /package/dist/esm/{auth-api → server/src/auth-api}/types.js +0 -0
  153. /package/dist/esm/{auth-api → server/src/auth-api}/user-id.d.ts +0 -0
  154. /package/dist/esm/{auth-cookie-options.js → server/src/auth-cookie-options.js} +0 -0
  155. /package/dist/esm/{oauth → server/src/oauth}/models.d.ts +0 -0
  156. /package/dist/esm/{oauth → server/src/oauth}/models.js +0 -0
  157. /package/dist/esm/{oauth → server/src/oauth}/types.d.ts +0 -0
  158. /package/dist/esm/{oauth → server/src/oauth}/types.js +0 -0
  159. /package/dist/esm/{passkey → server/src/passkey}/config.d.ts +0 -0
  160. /package/dist/esm/{passkey → server/src/passkey}/config.js +0 -0
  161. /package/dist/esm/{passkey → server/src/passkey}/memory.d.ts +0 -0
  162. /package/dist/esm/{passkey → server/src/passkey}/memory.js +0 -0
  163. /package/dist/esm/{passkey → server/src/passkey}/models.d.ts +0 -0
  164. /package/dist/esm/{passkey → server/src/passkey}/models.js +0 -0
  165. /package/dist/esm/{passkey → server/src/passkey}/sequelize.d.ts +0 -0
  166. /package/dist/esm/{passkey → server/src/passkey}/sequelize.js +0 -0
  167. /package/dist/esm/{passkey → server/src/passkey}/types.d.ts +0 -0
  168. /package/dist/esm/{passkey → server/src/passkey}/types.js +0 -0
  169. /package/dist/esm/{sequelize-utils.d.ts → server/src/sequelize-utils.d.ts} +0 -0
  170. /package/dist/esm/{token → server/src/token}/memory.d.ts +0 -0
  171. /package/dist/esm/{token → server/src/token}/sequelize.d.ts +0 -0
  172. /package/dist/esm/{token → server/src/token}/types.d.ts +0 -0
  173. /package/dist/esm/{token → server/src/token}/types.js +0 -0
  174. /package/dist/esm/{user → server/src/upload}/types.js +0 -0
  175. /package/dist/esm/{user → server/src/user}/memory.d.ts +0 -0
  176. /package/dist/esm/{user → server/src/user}/sequelize.d.ts +0 -0
  177. /package/dist/esm/{user → server/src/user}/types.d.ts +0 -0
@@ -4,19 +4,53 @@
4
4
  * This source code is licensed under the MIT license found in the
5
5
  * LICENSE file in the root directory of this source tree.
6
6
  */
7
- import { randomUUID } from 'node:crypto';
8
7
  import { access, readFile } from 'node:fs/promises';
9
8
  import { createRequire } from 'node:module';
10
9
  import path from 'node:path';
11
- import cookieParser from 'cookie-parser';
12
- import cors from 'cors';
13
- import express from 'express';
14
- import multer from 'multer';
10
+ import cookie from '@fastify/cookie';
11
+ import cors from '@fastify/cors';
12
+ import multipart from '@fastify/multipart';
13
+ import fastifyStatic from '@fastify/static';
14
+ import fastify from 'fastify';
15
15
  import { nullAuthModule } from './auth-api/module.js';
16
16
  import { nullAuthAdapter } from './auth-api/storage.js';
17
17
  import { toOptionalStringId } from './auth-api/user-id.js';
18
18
  import { buildAuthCookieOptions } from './auth-cookie-options.js';
19
+ import { ensureClientInfo } from './base/client-info.js';
20
+ import { asHttpStatus, guessExceptionText, isApiErrorLike } from './base/error-utils.js';
21
+ import { hydrateGetBody, isPlainObject } from './base/request-utils.js';
19
22
  import { TokenStore } from './token/base.js';
23
+ class FastifyResponseAdapter {
24
+ constructor(reply) {
25
+ this.reply = reply;
26
+ this.locals = {};
27
+ this.statusCode = 200;
28
+ }
29
+ get headersSent() {
30
+ return this.reply.sent;
31
+ }
32
+ status(code) {
33
+ this.statusCode = code;
34
+ this.reply.code(code);
35
+ return this;
36
+ }
37
+ json(payload) {
38
+ if (!this.reply.sent) {
39
+ this.reply.code(this.statusCode).send(payload);
40
+ }
41
+ }
42
+ send(payload) {
43
+ if (!this.reply.sent) {
44
+ this.reply.code(this.statusCode).send(payload);
45
+ }
46
+ }
47
+ cookie(name, value, options = {}) {
48
+ this.reply.setCookie(name, value, options);
49
+ }
50
+ clearCookie(name, options = {}) {
51
+ this.reply.clearCookie(name, options);
52
+ }
53
+ }
20
54
  class JwtHelperStore extends TokenStore {
21
55
  async save() {
22
56
  throw new Error('Token store is not configured');
@@ -38,288 +72,9 @@ class JwtHelperStore extends TokenStore {
38
72
  }
39
73
  }
40
74
  export { ApiModule } from './api-module.js';
41
- function guess_exception_text(error, defMsg = 'Unknown Error') {
42
- const msg = [];
43
- if (typeof error === 'string' && error.trim() !== '') {
44
- msg.push(error);
45
- }
46
- else if (error && typeof error === 'object') {
47
- const errorDetails = error;
48
- if (typeof errorDetails.message === 'string' && errorDetails.message.trim() !== '') {
49
- msg.push(errorDetails.message);
50
- }
51
- if (errorDetails.parent &&
52
- typeof errorDetails.parent.message === 'string' &&
53
- errorDetails.parent.message.trim() !== '') {
54
- msg.push(errorDetails.parent.message);
55
- }
56
- }
57
- return msg.length > 0 ? msg.join(' / ') : defMsg;
58
- }
59
- function isPlainObject(value) {
60
- return !!value && typeof value === 'object' && !Array.isArray(value);
61
- }
62
- function hydrateGetBody(req) {
63
- if ((req.method ?? '').toUpperCase() !== 'GET') {
64
- return;
65
- }
66
- const query = isPlainObject(req.query) ? req.query : null;
67
- if (!query || Object.keys(query).length === 0) {
68
- return;
69
- }
70
- const body = isPlainObject(req.body) ? req.body : null;
71
- if (!body || Object.keys(body).length === 0) {
72
- req.body = { ...query };
73
- return;
74
- }
75
- // Keep explicit body fields authoritative when both query and body provide the same key.
76
- req.body = { ...query, ...body };
77
- }
78
- function normalizeIpAddress(candidate) {
79
- let value = candidate.trim();
80
- if (!value) {
81
- return null;
82
- }
83
- value = value.replace(/^"+|"+$/g, '').replace(/^'+|'+$/g, '');
84
- if (value.startsWith('::ffff:')) {
85
- value = value.slice(7);
86
- }
87
- if (value.startsWith('[') && value.endsWith(']')) {
88
- value = value.slice(1, -1);
89
- }
90
- const firstColon = value.indexOf(':');
91
- const lastColon = value.lastIndexOf(':');
92
- if (firstColon !== -1 && firstColon === lastColon) {
93
- const maybePort = value.slice(lastColon + 1);
94
- if (/^\d+$/.test(maybePort)) {
95
- value = value.slice(0, lastColon);
96
- }
97
- }
98
- value = value.trim();
99
- return value || null;
100
- }
101
- function extractForwardedFor(header) {
102
- if (!header) {
103
- return [];
104
- }
105
- const values = Array.isArray(header) ? header : [header];
106
- const ips = [];
107
- for (const entry of values) {
108
- for (const part of entry.split(',')) {
109
- const normalized = normalizeIpAddress(part);
110
- if (normalized) {
111
- ips.push(normalized);
112
- }
113
- }
114
- }
115
- return ips;
116
- }
117
- function extractForwardedHeader(header) {
118
- if (!header) {
119
- return [];
120
- }
121
- const values = Array.isArray(header) ? header : [header];
122
- const ips = [];
123
- for (const entry of values) {
124
- for (const part of entry.split(',')) {
125
- const match = part.match(/for=([^;]+)/i);
126
- if (match) {
127
- const normalized = normalizeIpAddress(match[1]);
128
- if (normalized) {
129
- ips.push(normalized);
130
- }
131
- }
132
- }
133
- }
134
- return ips;
135
- }
136
- function detectBrowser(userAgent) {
137
- const browserMatchers = [
138
- { label: 'Edge', pattern: /(Edg|Edge|EdgiOS|EdgA)\/([\d.]+)/i, versionGroup: 2 },
139
- { label: 'Chrome', pattern: /(Chrome|CriOS)\/([\d.]+)/i, versionGroup: 2 },
140
- { label: 'Firefox', pattern: /(Firefox|FxiOS)\/([\d.]+)/i, versionGroup: 2 },
141
- { label: 'Safari', pattern: /Version\/([\d.]+).*Safari/i, versionGroup: 1 },
142
- { label: 'Opera', pattern: /(OPR|Opera)\/([\d.]+)/i, versionGroup: 2 },
143
- { label: 'Brave', pattern: /Brave\/([\d.]+)/i, versionGroup: 1 },
144
- { label: 'Vivaldi', pattern: /Vivaldi\/([\d.]+)/i, versionGroup: 1 },
145
- { label: 'Electron', pattern: /Electron\/([\d.]+)/i, versionGroup: 1 },
146
- { label: 'Node', pattern: /Node\.js\/([\d.]+)/i, versionGroup: 1 },
147
- { label: 'IE', pattern: /MSIE ([\d.]+)/i, versionGroup: 1 },
148
- { label: 'IE', pattern: /Trident\/.*rv:([\d.]+)/i, versionGroup: 1 }
149
- ];
150
- for (const matcher of browserMatchers) {
151
- const m = userAgent.match(matcher.pattern);
152
- if (m) {
153
- const version = matcher.versionGroup ? m[matcher.versionGroup] : '';
154
- return version ? `${matcher.label} ${version}` : matcher.label;
155
- }
156
- }
157
- return '';
158
- }
159
- function detectOs(userAgent) {
160
- const osMatchers = [
161
- {
162
- label: 'Windows',
163
- pattern: /Windows NT ([\d.]+)/i,
164
- transform: (match) => `Windows ${match[1]}`
165
- },
166
- {
167
- label: 'iOS',
168
- pattern: /iPhone OS ([\d_]+)/i,
169
- transform: (match) => `iOS ${match[1].replace(/_/g, '.')}`
170
- },
171
- {
172
- label: 'iPadOS',
173
- pattern: /iPad; CPU OS ([\d_]+)/i,
174
- transform: (match) => `iPadOS ${match[1].replace(/_/g, '.')}`
175
- },
176
- {
177
- label: 'macOS',
178
- pattern: /Mac OS X ([\d_]+)/i,
179
- transform: (match) => `macOS ${match[1].replace(/_/g, '.')}`
180
- },
181
- {
182
- label: 'Android',
183
- pattern: /Android ([\d.]+)/i,
184
- transform: (match) => `Android ${match[1]}`
185
- },
186
- {
187
- label: 'ChromeOS',
188
- pattern: /CrOS [^ ]+ ([\d.]+)/i,
189
- transform: (match) => `ChromeOS ${match[1]}`
190
- },
191
- { label: 'Linux', pattern: /Linux/i },
192
- { label: 'Unix', pattern: /X11/i }
193
- ];
194
- for (const matcher of osMatchers) {
195
- const m = userAgent.match(matcher.pattern);
196
- if (m) {
197
- return matcher.transform ? matcher.transform(m) : matcher.label;
198
- }
199
- }
200
- return '';
201
- }
202
- function detectDevice(userAgent, osLabel) {
203
- if (/iPhone/i.test(userAgent)) {
204
- return 'iPhone';
205
- }
206
- if (/iPad/i.test(userAgent)) {
207
- return 'iPad';
208
- }
209
- if (/iPod/i.test(userAgent)) {
210
- return 'iPod';
211
- }
212
- if (/Android/i.test(userAgent)) {
213
- const match = userAgent.match(/;\s*([^;]+)\s+Build/i);
214
- if (match) {
215
- return match[1];
216
- }
217
- return 'Android Device';
218
- }
219
- if (/Macintosh/i.test(userAgent)) {
220
- return 'Mac';
221
- }
222
- if (/Windows/i.test(userAgent)) {
223
- return 'PC';
224
- }
225
- if (/CrOS/i.test(userAgent)) {
226
- return 'Chromebook';
227
- }
228
- return osLabel;
229
- }
230
- function parseClientAgent(userAgentHeader) {
231
- const raw = Array.isArray(userAgentHeader) ? userAgentHeader[0] : userAgentHeader;
232
- const ua = typeof raw === 'string' ? raw.trim() : '';
233
- if (!ua) {
234
- return { ua: '', browser: '', os: '', device: '' };
235
- }
236
- const os = detectOs(ua);
237
- const browser = detectBrowser(ua);
238
- const device = detectDevice(ua, os);
239
- return { ua, browser, os, device };
240
- }
241
- function isLoopbackAddress(ip) {
242
- if (ip === '::1' || ip === '0:0:0:0:0:0:0:1') {
243
- return true;
244
- }
245
- if (ip === '0.0.0.0' || ip === '127.0.0.1') {
246
- return true;
247
- }
248
- if (ip.startsWith('127.')) {
249
- return true;
250
- }
251
- return false;
252
- }
253
- function collectClientIpChain(req) {
254
- const seen = new Set();
255
- const result = [];
256
- const pushNormalized = (ip) => {
257
- if (!ip || seen.has(ip)) {
258
- return;
259
- }
260
- seen.add(ip);
261
- result.push(ip);
262
- };
263
- for (const ip of extractForwardedFor(req.headers['x-forwarded-for'])) {
264
- pushNormalized(ip);
265
- }
266
- for (const ip of extractForwardedHeader(req.headers['forwarded'])) {
267
- pushNormalized(ip);
268
- }
269
- const realIp = req.headers['x-real-ip'];
270
- if (Array.isArray(realIp)) {
271
- for (const value of realIp) {
272
- pushNormalized(normalizeIpAddress(value));
273
- }
274
- }
275
- else if (typeof realIp === 'string') {
276
- pushNormalized(normalizeIpAddress(realIp));
277
- }
278
- if (Array.isArray(req.ips)) {
279
- for (const ip of req.ips) {
280
- pushNormalized(normalizeIpAddress(ip));
281
- }
282
- }
283
- if (typeof req.ip === 'string') {
284
- pushNormalized(normalizeIpAddress(req.ip));
285
- }
286
- const socketAddress = req.socket?.remoteAddress;
287
- if (typeof socketAddress === 'string') {
288
- pushNormalized(normalizeIpAddress(socketAddress));
289
- }
290
- const connectionAddress = req.connection?.remoteAddress;
291
- if (typeof connectionAddress === 'string') {
292
- pushNormalized(normalizeIpAddress(connectionAddress));
293
- }
294
- return result;
295
- }
296
- function selectClientIp(chain) {
297
- for (const ip of chain) {
298
- if (!isLoopbackAddress(ip)) {
299
- return ip;
300
- }
301
- }
302
- return chain[0] ?? null;
303
- }
304
- function buildClientInfo(req) {
305
- const agent = parseClientAgent(req.headers['user-agent']);
306
- const ipchain = collectClientIpChain(req);
307
- const ip = selectClientIp(ipchain);
308
- return {
309
- ...agent,
310
- ip,
311
- ipchain
312
- };
313
- }
314
- function ensureClientInfo(apiReq) {
315
- if (!apiReq.clientInfo) {
316
- apiReq.clientInfo = buildClientInfo(apiReq.req);
317
- }
318
- return apiReq.clientInfo;
319
- }
320
75
  export class ApiError extends Error {
321
76
  constructor({ code, message, data, errors }) {
322
- const msg = guess_exception_text(message, '[Unknown error (null/undefined)]');
77
+ const msg = guessExceptionText(message, '[Unknown error (null/undefined)]');
323
78
  super(msg);
324
79
  this.message = msg;
325
80
  this.code = typeof code === 'number' ? code : 500;
@@ -327,24 +82,6 @@ export class ApiError extends Error {
327
82
  this.errors = errors !== undefined ? errors : {};
328
83
  }
329
84
  }
330
- function isApiErrorLike(candidate) {
331
- if (!candidate || typeof candidate !== 'object') {
332
- return false;
333
- }
334
- const maybeError = candidate;
335
- return typeof maybeError.code === 'number' && typeof maybeError.message === 'string';
336
- }
337
- function asHttpStatus(error) {
338
- if (!error || typeof error !== 'object') {
339
- return null;
340
- }
341
- const maybe = error;
342
- const status = typeof maybe.status === 'number' ? maybe.status : maybe.statusCode;
343
- if (typeof status === 'number' && status >= 400 && status <= 599) {
344
- return status;
345
- }
346
- return null;
347
- }
348
85
  function fillConfig(config) {
349
86
  return {
350
87
  apiPort: config.apiPort ?? 3101,
@@ -364,6 +101,10 @@ function fillConfig(config) {
364
101
  cookieSameSite: config.cookieSameSite ?? 'lax',
365
102
  cookieSecure: config.cookieSecure ?? 'auto',
366
103
  cookieHttpOnly: config.cookieHttpOnly ?? true,
104
+ apiKeyPrefix: config.apiKeyPrefix ?? '',
105
+ apiKeyEnabled: config.apiKeyEnabled ?? false,
106
+ tokenParam: config.tokenParam ?? '',
107
+ tokenParamLocation: config.tokenParamLocation ?? 'body',
367
108
  accessCookie: config.accessCookie ?? 'dat',
368
109
  refreshCookie: config.refreshCookie ?? 'drt',
369
110
  accessExpiry: config.accessExpiry ?? 60 * 15,
@@ -377,36 +118,27 @@ function fillConfig(config) {
377
118
  apiVersion: config.apiVersion ?? '',
378
119
  minClientVersion: config.minClientVersion ?? '',
379
120
  tokenStore: config.tokenStore,
380
- authStores: config.authStores,
381
- onStartError: config.onStartError
121
+ onStartError: config.onStartError,
122
+ trustProxy: config.trustProxy ?? true
382
123
  };
383
124
  }
125
+ /** Core Fastify-based API server with module mounting and auth integration hooks. */
384
126
  export class ApiServer {
385
- /**
386
- * @deprecated ApiServer does not track a global "current request". This value is always null.
387
- * Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
388
- * when mounting raw Express endpoints.
389
- */
390
127
  get currReq() {
391
128
  return null;
392
129
  }
393
130
  set currReq(_value) {
394
131
  if (this.config.devMode && !this.currReqDeprecationWarned) {
395
132
  this.currReqDeprecationWarned = true;
396
- console.warn('[api-server-base] ApiServer.currReq is deprecated and always null. Use req.apiReq or res.locals.apiReq.');
133
+ console.warn('[api-server-base] ApiServer.currReq is deprecated and always null. Use per-request ApiRequest in handlers.');
397
134
  }
398
135
  void _value;
399
136
  }
400
137
  constructor(config = {}) {
401
138
  this.finalized = false;
402
- this.serverAuthAdapter = null;
403
- this.apiNotFoundHandler = null;
404
139
  this.apiErrorHandlerInstalled = false;
405
140
  this.tokenStoreAdapter = null;
406
- this.userStoreAdapter = null;
407
- this.passkeyServiceAdapter = null;
408
- this.oauthStoreAdapter = null;
409
- this.canImpersonateAdapter = null;
141
+ this.compatGlobalErrorHandler = null;
410
142
  this.currReqDeprecationWarned = false;
411
143
  this.config = fillConfig(config);
412
144
  this.apiBasePath = this.normalizeApiBasePath(this.config.apiBasePath);
@@ -415,100 +147,153 @@ export class ApiServer {
415
147
  this.moduleAdapter = nullAuthModule;
416
148
  this.jwtHelper = new JwtHelperStore();
417
149
  this.tokenStoreAdapter = this.config.tokenStore ?? null;
418
- if (this.config.authStores) {
419
- const { userStore, tokenStore, passkeyService, oauthStore, canImpersonate } = this.config.authStores;
420
- this.userStoreAdapter = userStore;
421
- this.tokenStoreAdapter = tokenStore;
422
- this.passkeyServiceAdapter = passkeyService ?? null;
423
- this.oauthStoreAdapter = oauthStore ?? null;
424
- this.canImpersonateAdapter = canImpersonate ?? null;
425
- this.storageAdapter = this.getServerAuthAdapter();
426
- }
427
- if ((this.config.authApi || this.config.authStores) &&
428
- (!this.config.accessSecret || !this.config.refreshSecret)) {
150
+ if (this.config.authApi && (!this.config.accessSecret || !this.config.refreshSecret)) {
429
151
  console.warn('[api-server-base] Auth is enabled but accessSecret and/or refreshSecret are empty. JWT signing will fail at runtime.');
430
152
  }
431
- this.app = express();
432
- // Mount API modules and any custom endpoints under `apiBasePath` on this router so we can keep
433
- // the API 404 handler ordered last without relying on Express internals.
434
- this.apiRouter = express.Router();
435
- if (config.uploadPath) {
436
- const upload = multer({ dest: config.uploadPath, limits: { fileSize: this.config.uploadMax } });
437
- this.app.use(upload.any());
438
- // Multer errors happen before ApiModule route wrappers; format the common "too large" failure.
439
- this.app.use((err, _req, res, next) => {
440
- const code = err && typeof err === 'object' ? err.code : undefined;
441
- if (code === 'LIMIT_FILE_SIZE') {
442
- res.status(413).json({
443
- success: false,
444
- code: 413,
445
- message: `Upload exceeds maximum size of ${this.config.uploadMax} bytes`,
446
- data: null,
447
- errors: {}
448
- });
449
- return;
153
+ this.fastify = fastify({ logger: false, ajv: { customOptions: { allErrors: true, allowUnionTypes: true } } });
154
+ this.setupRuntime();
155
+ this.readyPromise = Promise.resolve(this.fastify.ready()).then(() => undefined);
156
+ const appHandler = (req, res) => {
157
+ void this.readyPromise
158
+ .then(() => {
159
+ this.fastify.routing(req, res);
160
+ })
161
+ .catch((error) => {
162
+ const message = this.internalServerErrorMessage(error);
163
+ res.statusCode = 500;
164
+ res.setHeader('content-type', 'application/json');
165
+ res.end(JSON.stringify({ success: false, code: 500, message, data: null, errors: {} }));
166
+ });
167
+ };
168
+ appHandler.listen = (...args) => {
169
+ const server = this.fastify.server;
170
+ void this.readyPromise.then(() => {
171
+ if (!server.listening) {
172
+ server.listen(...args);
450
173
  }
451
- next(err);
452
174
  });
453
- }
454
- this.middlewares();
175
+ return server;
176
+ };
177
+ this.app = appHandler;
178
+ }
179
+ setupRuntime() {
180
+ this.fastify.register(cookie);
181
+ this.fastify.register(cors, {
182
+ origin: (origin, callback) => {
183
+ // No Origin header means a non-browser client (curl, server-to-server).
184
+ // CORS is a browser security feature; non-browser clients can bypass it
185
+ // by simply omitting the header, so blocking them here adds no security.
186
+ if (!origin) {
187
+ callback(null, true);
188
+ return;
189
+ }
190
+ // NOTE: An empty origins list means "allow all origins" (open/dev mode).
191
+ // Configure a non-empty origins list in production to restrict access.
192
+ if (this.config.origins.length > 0 && !this.config.origins.includes(origin)) {
193
+ callback(new Error('Not allowed by CORS'), false);
194
+ return;
195
+ }
196
+ callback(null, true);
197
+ },
198
+ credentials: true
199
+ });
200
+ this.fastify.register(multipart, {
201
+ limits: { fileSize: this.config.uploadMax }
202
+ });
203
+ this.fastify.addHook('preValidation', async (request) => {
204
+ if (request.method === 'GET' &&
205
+ request.body === undefined &&
206
+ typeof request.headers['content-length'] === 'string' &&
207
+ Number.parseInt(request.headers['content-length'], 10) > 0) {
208
+ const rawBody = await this.readRawBody(request.raw);
209
+ if (rawBody) {
210
+ const contentType = (request.headers['content-type'] ?? '').toString().toLowerCase();
211
+ request.body = this.parseRawBody(rawBody, contentType);
212
+ }
213
+ }
214
+ if (!request.isMultipart()) {
215
+ return;
216
+ }
217
+ const files = [];
218
+ const body = {};
219
+ for await (const part of request.parts()) {
220
+ if (part.type === 'file') {
221
+ const buffer = await part.toBuffer();
222
+ files.push({
223
+ fieldname: part.fieldname,
224
+ originalname: part.filename,
225
+ encoding: part.encoding,
226
+ mimetype: part.mimetype,
227
+ size: buffer.length,
228
+ buffer
229
+ });
230
+ }
231
+ else {
232
+ body[part.fieldname] = part.value;
233
+ }
234
+ }
235
+ request.__apiParsedBody = body;
236
+ request.__apiParsedFiles = files;
237
+ });
455
238
  this.installStaticDirs();
456
239
  this.installPingHandler();
457
240
  this.installSwaggerHandler();
458
- this.app.use(this.apiBasePath, this.apiRouter);
459
- // addSwaggerUi(this.app);
460
241
  this.installApiNotFoundHandler();
461
242
  this.installApiErrorHandler();
462
243
  }
463
- assertNotFinalized(action) {
464
- if (this.finalized) {
465
- throw new Error(`Cannot call ApiServer.${action}() after finalize()/start()`);
244
+ async readRawBody(req, maxBytes = 1048576) {
245
+ const chunks = [];
246
+ let total = 0;
247
+ for await (const chunk of req) {
248
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
249
+ total += buf.length;
250
+ if (total > maxBytes) {
251
+ throw new ApiError({ code: 413, message: `Request body exceeds maximum size of ${maxBytes} bytes` });
252
+ }
253
+ chunks.push(buf);
466
254
  }
255
+ return Buffer.concat(chunks).toString('utf8');
467
256
  }
468
- toApiRouterPath(candidate) {
469
- if (typeof candidate !== 'string') {
470
- return null;
471
- }
472
- const trimmed = candidate.trim();
473
- if (!trimmed) {
474
- return null;
257
+ parseRawBody(raw, contentType) {
258
+ if (!raw) {
259
+ return {};
475
260
  }
476
- const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
477
- const base = this.apiBasePath;
478
- if (base === '/') {
479
- return normalized;
261
+ if (contentType.includes('application/json')) {
262
+ try {
263
+ return JSON.parse(raw);
264
+ }
265
+ catch {
266
+ return {};
267
+ }
480
268
  }
481
- if (normalized === base) {
482
- return '/';
269
+ if (contentType.includes('application/x-www-form-urlencoded')) {
270
+ const params = new URLSearchParams(raw);
271
+ return Object.fromEntries(params.entries());
483
272
  }
484
- if (normalized.startsWith(`${base}/`)) {
485
- return normalized.slice(base.length) || '/';
273
+ return raw;
274
+ }
275
+ assertNotFinalized(action) {
276
+ if (this.finalized) {
277
+ throw new Error(`Cannot call ApiServer.${action}() after finalize()/start()`);
486
278
  }
487
- return null;
488
279
  }
489
280
  finalize() {
490
- this.installApiNotFoundHandler();
491
- this.installApiErrorHandler();
492
281
  this.finalized = true;
493
282
  return this;
494
283
  }
495
284
  authStorage(storage) {
285
+ this.assertNotFinalized('authStorage');
496
286
  this.storageAdapter = storage;
497
287
  return this;
498
288
  }
499
- /**
500
- * @deprecated Use {@link ApiServer.authStorage} instead.
501
- */
502
289
  useAuthStorage(storage) {
503
290
  return this.authStorage(storage);
504
291
  }
505
292
  authModule(module) {
293
+ this.assertNotFinalized('authModule');
506
294
  this.moduleAdapter = module;
507
295
  return this;
508
296
  }
509
- /**
510
- * @deprecated Use {@link ApiServer.authModule} instead.
511
- */
512
297
  useAuthModule(module) {
513
298
  return this.authModule(module);
514
299
  }
@@ -519,88 +304,39 @@ export class ApiServer {
519
304
  return this.moduleAdapter;
520
305
  }
521
306
  setTokenStore(store) {
307
+ this.assertNotFinalized('setTokenStore');
522
308
  this.tokenStoreAdapter = store;
523
- // If using direct stores, expose the server-backed auth adapter.
524
- if (this.userStoreAdapter) {
525
- this.storageAdapter = this.getServerAuthAdapter();
526
- }
527
309
  return this;
528
310
  }
529
311
  getTokenStore() {
530
312
  return this.tokenStoreAdapter;
531
313
  }
532
- ensureUserStore() {
533
- if (!this.userStoreAdapter) {
534
- throw new Error('User store is not configured');
535
- }
536
- return this.userStoreAdapter;
537
- }
538
- ensureTokenStore() {
539
- if (!this.tokenStoreAdapter) {
540
- throw new Error('Token store is not configured');
541
- }
542
- return this.tokenStoreAdapter;
543
- }
544
- ensurePasskeyService() {
545
- if (!this.passkeyServiceAdapter) {
314
+ async listUserCredentials(userId) {
315
+ if (typeof this.storageAdapter.listUserCredentials !== 'function') {
546
316
  throw new Error('Passkey service is not configured');
547
317
  }
548
- return this.passkeyServiceAdapter;
549
- }
550
- async listUserCredentials(userId) {
551
- return this.ensurePasskeyService().listUserCredentials(userId);
318
+ return this.storageAdapter.listUserCredentials(userId);
552
319
  }
553
320
  async deletePasskeyCredential(credentialId) {
554
- return this.ensurePasskeyService().deleteCredential(credentialId);
555
- }
556
- ensureOAuthStore() {
557
- if (!this.oauthStoreAdapter) {
558
- throw new Error('OAuth store is not configured');
321
+ if (typeof this.storageAdapter.deletePasskeyCredential !== 'function') {
322
+ throw new Error('Passkey service is not configured');
559
323
  }
560
- return this.oauthStoreAdapter;
561
- }
562
- getServerAuthAdapter() {
563
- if (this.serverAuthAdapter) {
564
- return this.serverAuthAdapter;
565
- }
566
- const server = this;
567
- this.serverAuthAdapter = {
568
- getUser: (identifier) => server.getUser(identifier),
569
- getUserPasswordHash: (user) => server.getUserPasswordHash(user),
570
- getUserId: (user) => server.getUserId(user),
571
- filterUser: (user) => server.filterUser(user),
572
- verifyPassword: (password, hash) => server.verifyPassword(password, hash),
573
- storeToken: (data) => server.storeToken(data),
574
- getToken: (query, opts) => server.getToken(query, opts),
575
- deleteToken: (query) => server.deleteToken(query),
576
- updateToken: (updates) => server.updateToken(updates),
577
- createPasskeyChallenge: (params) => server.createPasskeyChallenge(params),
578
- verifyPasskeyResponse: (params) => server.verifyPasskeyResponse(params),
579
- listUserCredentials: (userId) => server.listUserCredentials(userId),
580
- deletePasskeyCredential: (credentialId) => server.deletePasskeyCredential(credentialId),
581
- getClient: (clientId) => server.getClient(clientId),
582
- verifyClientSecret: (client, clientSecret) => server.verifyClientSecret(client, clientSecret),
583
- createAuthCode: (request) => server.createAuthCode(request),
584
- consumeAuthCode: (code, clientId) => server.consumeAuthCode(code, clientId),
585
- canImpersonate: (params) => server.canImpersonate(params)
586
- };
587
- return this.serverAuthAdapter;
324
+ return this.storageAdapter.deletePasskeyCredential(credentialId);
588
325
  }
589
- // AuthAdapter-compatible helpers (used by AuthModule)
590
326
  async getUser(identifier) {
591
- return this.userStoreAdapter ? this.userStoreAdapter.findUser(identifier) : null;
327
+ return this.storageAdapter.getUser(identifier);
592
328
  }
593
329
  getUserPasswordHash(user) {
594
- return this.ensureUserStore().getPasswordHash(user) ?? '';
330
+ return this.storageAdapter.getUserPasswordHash(user);
595
331
  }
596
332
  getUserId(user) {
597
- return this.ensureUserStore().getUserId(user);
333
+ return this.storageAdapter.getUserId(user);
598
334
  }
599
335
  filterUser(user) {
600
- return this.ensureUserStore().toPublic(user);
336
+ return this.storageAdapter.filterUser(user);
601
337
  }
602
338
  async verifyPassword(password, hash) {
603
- return this.ensureUserStore().verifyPassword(password, hash);
339
+ return this.storageAdapter.verifyPassword(password, hash);
604
340
  }
605
341
  async storeToken(data) {
606
342
  if (this.tokenStoreAdapter) {
@@ -643,47 +379,55 @@ export class ApiServer {
643
379
  return 0;
644
380
  }
645
381
  async createPasskeyChallenge(params) {
646
- return this.ensurePasskeyService().createChallenge(params);
382
+ if (typeof this.storageAdapter.createPasskeyChallenge !== 'function') {
383
+ throw new Error('Passkey service is not configured');
384
+ }
385
+ return this.storageAdapter.createPasskeyChallenge(params);
647
386
  }
648
387
  async verifyPasskeyResponse(params) {
649
- return this.ensurePasskeyService().verifyResponse(params);
388
+ if (typeof this.storageAdapter.verifyPasskeyResponse !== 'function') {
389
+ throw new Error('Passkey service is not configured');
390
+ }
391
+ return this.storageAdapter.verifyPasskeyResponse(params);
650
392
  }
651
393
  async getClient(clientId) {
652
- return this.oauthStoreAdapter ? this.oauthStoreAdapter.getClient(clientId) : null;
394
+ if (typeof this.storageAdapter.getClient !== 'function') {
395
+ return null;
396
+ }
397
+ return this.storageAdapter.getClient(clientId);
653
398
  }
654
399
  async verifyClientSecret(client, clientSecret) {
655
- return this.ensureOAuthStore().verifyClientSecret(client.clientId, clientSecret);
400
+ if (typeof this.storageAdapter.verifyClientSecret !== 'function') {
401
+ throw new Error('OAuth store is not configured');
402
+ }
403
+ return this.storageAdapter.verifyClientSecret(client, clientSecret);
656
404
  }
657
405
  async createAuthCode(request) {
658
- const expiresAt = new Date(Date.now() + (request.expiresInSeconds ?? 300) * 1000);
659
- const code = request.code ?? randomUUID();
660
- await this.ensureOAuthStore().createAuthCode({ ...request, code, expiresAt });
661
- return {
662
- code,
663
- clientId: request.clientId,
664
- userId: request.userId,
665
- redirectUri: request.redirectUri,
666
- scope: request.scope ?? [],
667
- codeChallenge: request.codeChallenge,
668
- codeChallengeMethod: request.codeChallengeMethod,
669
- expiresAt,
670
- metadata: request.metadata
671
- };
406
+ if (typeof this.storageAdapter.createAuthCode !== 'function') {
407
+ throw new Error('OAuth store is not configured');
408
+ }
409
+ return this.storageAdapter.createAuthCode(request);
672
410
  }
673
411
  async consumeAuthCode(code, clientId) {
674
- const consumed = await this.ensureOAuthStore().consumeAuthCode(code);
412
+ if (typeof this.storageAdapter.consumeAuthCode !== 'function') {
413
+ return null;
414
+ }
415
+ const consumed = await this.storageAdapter.consumeAuthCode(code, clientId);
675
416
  if (!consumed || consumed.clientId !== clientId) {
676
417
  return null;
677
418
  }
678
419
  return consumed;
679
420
  }
680
421
  async canImpersonate(params) {
681
- if (this.canImpersonateAdapter) {
682
- return !!(await this.canImpersonateAdapter(params));
422
+ if (typeof this.storageAdapter.canImpersonate === 'function') {
423
+ return !!(await this.storageAdapter.canImpersonate(params));
683
424
  }
684
425
  return params.realUserId === params.effectiveUserId;
685
426
  }
686
427
  jwtSign(payload, secret, expiresInSeconds, options) {
428
+ if (!secret) {
429
+ return { success: false, error: 'JWT secret is not configured' };
430
+ }
687
431
  return (this.tokenStoreAdapter ?? this.jwtHelper).jwtSign(payload, secret, expiresInSeconds, options);
688
432
  }
689
433
  jwtVerify(token, secret, options) {
@@ -694,6 +438,8 @@ export class ApiServer {
694
438
  }
695
439
  async getApiKey(token) {
696
440
  void token;
441
+ console.warn('[api-server-base] getApiKey() is not implemented. ' +
442
+ 'Override getApiKey() in your ApiServer subclass to support API key authentication.');
697
443
  return null;
698
444
  }
699
445
  async authenticateUser(params) {
@@ -718,51 +464,30 @@ export class ApiServer {
718
464
  return false;
719
465
  }
720
466
  guessExceptionText(error, defMsg = 'Unknown Error') {
721
- return guess_exception_text(error, defMsg);
467
+ return guessExceptionText(error, defMsg);
722
468
  }
723
469
  async authorize(apiReq, requiredClass) {
724
470
  void apiReq;
725
- void requiredClass;
471
+ if (requiredClass && requiredClass !== 'any') {
472
+ console.warn(`[api-server-base] Route requires auth class "${requiredClass}" but the base authorize() is not overridden. ` +
473
+ 'Override authorize() in your ApiServer subclass to enforce role/class requirements.');
474
+ }
726
475
  }
727
- middlewares() {
728
- this.app.use(express.json());
729
- this.app.use(cookieParser());
730
- const corsOptions = {
731
- origin: (origin, callback) => {
732
- if (!origin) {
733
- return callback(null, true);
734
- }
735
- if (this.config.origins && this.config.origins.length > 0) {
736
- if (this.config.origins.includes(origin)) {
737
- return callback(null, true);
738
- }
739
- return callback(new Error(`${origin} Not allowed by CORS`));
740
- }
741
- return callback(null, true);
742
- },
743
- credentials: true
744
- };
745
- this.app.use(cors(corsOptions));
746
- // Provide a consistent and non-500 response when requests are blocked by the CORS allowlist.
747
- this.app.use((err, req, res, next) => {
748
- const message = err instanceof Error ? err.message : '';
749
- if (message.includes('Not allowed by CORS')) {
750
- const isApiRequest = Boolean(req.originalUrl?.startsWith(this.apiBasePath));
751
- if (isApiRequest) {
752
- res.status(403).json({
753
- success: false,
754
- code: 403,
755
- message: 'Origin not allowed by CORS',
756
- data: null,
757
- errors: {}
758
- });
759
- return;
760
- }
761
- res.status(403).send('Origin not allowed by CORS');
762
- return;
763
- }
764
- next(err);
765
- });
476
+ /**
477
+ * Authenticate and authorise an incoming Fastify request outside of the
478
+ * standard `defineRoutes` pipeline (e.g. TUS upload routes that need full
479
+ * control over their own response format).
480
+ *
481
+ * Throws `ApiError` on auth failure — callers should catch it and respond
482
+ * with the appropriate HTTP status code.
483
+ */
484
+ async resolveRequest(request, reply, auth) {
485
+ const req = this.toExtendedReq(request);
486
+ const res = new FastifyResponseAdapter(reply);
487
+ const apiReq = this.createApiRequest(req, res);
488
+ apiReq.tokenData = await this.authenticate(apiReq, auth.type);
489
+ await this.authorize(apiReq, auth.req ?? 'any');
490
+ return apiReq;
766
491
  }
767
492
  installStaticDirs() {
768
493
  const staticDirs = this.config.staticDirs;
@@ -776,12 +501,15 @@ export class ApiServer {
776
501
  continue;
777
502
  }
778
503
  const resolvedMount = mount.startsWith('/') ? mount : `/${mount}`;
779
- this.app.use(resolvedMount, express.static(dir));
504
+ void this.fastify.register(fastifyStatic, {
505
+ root: dir,
506
+ prefix: resolvedMount.endsWith('/') ? resolvedMount : `${resolvedMount}/`,
507
+ decorateReply: false
508
+ });
780
509
  }
781
510
  }
782
511
  installPingHandler() {
783
- const path = `${this.apiBasePath}/v1/ping`;
784
- this.app.get(path, (_req, res) => {
512
+ this.fastify.get(`${this.apiBasePath}/v1/ping`, async () => {
785
513
  const payload = {
786
514
  success: true,
787
515
  status: 'ok',
@@ -791,7 +519,7 @@ export class ApiServer {
791
519
  startedAt: this.startedAt,
792
520
  timestamp: new Date().toISOString()
793
521
  };
794
- res.status(200).json({ success: true, code: 200, message: 'Success', data: payload, errors: {} });
522
+ return { success: true, code: 200, message: 'Success', data: payload, errors: {} };
795
523
  });
796
524
  }
797
525
  async loadSwaggerSpec() {
@@ -799,10 +527,11 @@ export class ApiServer {
799
527
  if (typeof __dirname === 'string') {
800
528
  candidates.push(path.resolve(__dirname, '../../docs/swagger/openapi.json'));
801
529
  }
530
+ let packageRoot;
802
531
  try {
803
532
  const require = createRequire(path.join(process.cwd(), 'package.json'));
804
533
  const entry = require.resolve('@technomoron/api-server-base');
805
- const packageRoot = path.resolve(path.dirname(entry), '..', '..');
534
+ packageRoot = path.resolve(path.dirname(entry), '..', '..');
806
535
  candidates.push(path.join(packageRoot, 'docs/swagger/openapi.json'));
807
536
  }
808
537
  catch {
@@ -817,7 +546,13 @@ export class ApiServer {
817
546
  }
818
547
  try {
819
548
  const raw = await readFile(candidate, 'utf8');
820
- return JSON.parse(raw);
549
+ const spec = JSON.parse(raw);
550
+ // Inject version from package.json at runtime
551
+ const version = await this.readPackageVersion(path.dirname(candidate));
552
+ if (version && spec.info && typeof spec.info === 'object') {
553
+ spec.info.version = version;
554
+ }
555
+ return spec;
821
556
  }
822
557
  catch {
823
558
  return null;
@@ -825,6 +560,27 @@ export class ApiServer {
825
560
  }
826
561
  return null;
827
562
  }
563
+ async readPackageVersion(specDir) {
564
+ // Walk up from the spec directory looking for package.json
565
+ const candidates = [
566
+ path.resolve(specDir, '../../package.json'),
567
+ path.resolve(specDir, '../package.json'),
568
+ path.resolve(process.cwd(), 'package.json')
569
+ ];
570
+ for (const candidate of candidates) {
571
+ try {
572
+ const raw = await readFile(candidate, 'utf8');
573
+ const pkg = JSON.parse(raw);
574
+ if (typeof pkg.version === 'string') {
575
+ return pkg.version;
576
+ }
577
+ }
578
+ catch {
579
+ continue;
580
+ }
581
+ }
582
+ return null;
583
+ }
828
584
  installSwaggerHandler() {
829
585
  const rawPath = typeof this.config.swaggerPath === 'string' ? this.config.swaggerPath.trim() : '';
830
586
  const enabled = Boolean(this.config.swaggerEnabled) || rawPath.length > 0;
@@ -833,31 +589,35 @@ export class ApiServer {
833
589
  }
834
590
  const base = this.apiBasePath === '/' ? '' : this.apiBasePath;
835
591
  const resolved = rawPath.length > 0 ? rawPath : `${base}/swagger`;
836
- const path = resolved.startsWith('/') ? resolved : `/${resolved}`;
592
+ const routePath = resolved.startsWith('/') ? resolved : `/${resolved}`;
837
593
  let specPromise;
838
- this.app.get(path, async (_req, res) => {
594
+ this.fastify.get(routePath, async (_request, reply) => {
839
595
  if (!specPromise) {
840
596
  specPromise = this.loadSwaggerSpec();
841
597
  }
842
598
  const spec = await specPromise;
843
599
  if (!spec) {
844
- res.status(500).json({
600
+ // Clear cached failure so next request retries
601
+ specPromise = undefined;
602
+ }
603
+ if (!spec) {
604
+ reply.code(500);
605
+ return {
845
606
  success: false,
846
607
  code: 500,
847
608
  message: 'Swagger spec is unavailable',
848
609
  data: null,
849
610
  errors: {}
850
- });
851
- return;
611
+ };
852
612
  }
853
- res.status(200).json(spec);
613
+ return spec;
854
614
  });
855
615
  }
856
- normalizeApiBasePath(path) {
857
- if (!path || typeof path !== 'string') {
616
+ normalizeApiBasePath(routePath) {
617
+ if (!routePath || typeof routePath !== 'string') {
858
618
  return '/api';
859
619
  }
860
- const trimmed = path.trim();
620
+ const trimmed = routePath.trim();
861
621
  if (!trimmed) {
862
622
  return '/api';
863
623
  }
@@ -868,47 +628,121 @@ export class ApiServer {
868
628
  return withLeadingSlash.replace(/\/+$/, '') || '/api';
869
629
  }
870
630
  installApiNotFoundHandler() {
871
- if (this.apiNotFoundHandler) {
872
- return;
873
- }
874
- this.apiNotFoundHandler = (req, res) => {
875
- const payload = {
631
+ this.fastify.setNotFoundHandler((request, reply) => {
632
+ const target = request.raw.url ?? request.url;
633
+ if (!target.startsWith(this.apiBasePath)) {
634
+ reply.code(404).send('Not Found');
635
+ return;
636
+ }
637
+ const method = request.method.toUpperCase();
638
+ reply.code(404).send({
876
639
  success: false,
877
640
  code: 404,
878
- message: this.describeMissingEndpoint(req),
641
+ message: `No such endpoint: ${method} ${target}`,
879
642
  data: null,
880
643
  errors: {}
881
- };
882
- res.status(404).json(payload);
883
- };
884
- this.app.use(this.apiBasePath, this.apiNotFoundHandler);
644
+ });
645
+ });
885
646
  }
886
647
  installApiErrorHandler() {
887
648
  if (this.apiErrorHandlerInstalled) {
888
649
  return;
889
650
  }
890
651
  this.apiErrorHandlerInstalled = true;
891
- this.app.use(this.apiBasePath, this.expressErrorHandler());
892
- }
893
- describeMissingEndpoint(req) {
894
- const method = typeof req.method === 'string' ? req.method.toUpperCase() : 'GET';
895
- const target = req.originalUrl || req.url || this.apiBasePath;
896
- return `No such endpoint: ${method} ${target}`;
652
+ this.fastify.setErrorHandler((error, request, reply) => {
653
+ const message = error instanceof Error ? error.message : '';
654
+ if (message.includes('Not allowed by CORS')) {
655
+ const target = request.raw.url ?? request.url;
656
+ if (target.startsWith(this.apiBasePath)) {
657
+ reply.code(403).send({
658
+ success: false,
659
+ code: 403,
660
+ message: 'Origin not allowed by CORS',
661
+ data: null,
662
+ errors: {}
663
+ });
664
+ return;
665
+ }
666
+ reply.code(403).send('Origin not allowed by CORS');
667
+ return;
668
+ }
669
+ const validation = error.validation;
670
+ if (Array.isArray(validation)) {
671
+ const fieldErrors = {};
672
+ for (const v of validation) {
673
+ const field = v.params?.missingProperty ?? (v.instancePath?.replace(/^\//, '') || 'body');
674
+ fieldErrors[field] = v.message ?? 'Invalid value';
675
+ }
676
+ reply.code(400).send({
677
+ success: false,
678
+ code: 400,
679
+ message: 'Validation failed',
680
+ data: null,
681
+ errors: fieldErrors
682
+ });
683
+ return;
684
+ }
685
+ if (error instanceof ApiError || isApiErrorLike(error)) {
686
+ const apiError = error;
687
+ reply.code(apiError.code).send({
688
+ success: false,
689
+ code: apiError.code,
690
+ message: apiError.message,
691
+ data: apiError.data ?? null,
692
+ errors: apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
693
+ ? apiError.errors
694
+ : {}
695
+ });
696
+ return;
697
+ }
698
+ const status = asHttpStatus(error);
699
+ if (status) {
700
+ if (status >= 500) {
701
+ this.logUnhandledError(`Unhandled Fastify error mapped to HTTP ${status}`, error);
702
+ }
703
+ if (status === 413) {
704
+ reply.code(413).send({
705
+ success: false,
706
+ code: 413,
707
+ message: `Upload exceeds maximum size of ${this.config.uploadMax} bytes`,
708
+ data: null,
709
+ errors: {}
710
+ });
711
+ return;
712
+ }
713
+ reply.code(status).send({
714
+ success: false,
715
+ code: status,
716
+ message: status === 400 ? 'Invalid request payload' : this.internalServerErrorMessage(error),
717
+ data: null,
718
+ errors: {}
719
+ });
720
+ return;
721
+ }
722
+ this.logUnhandledError('Unhandled Fastify error fallback to HTTP 500', error);
723
+ reply.code(500).send({
724
+ success: false,
725
+ code: 500,
726
+ message: this.internalServerErrorMessage(error),
727
+ data: null,
728
+ errors: {}
729
+ });
730
+ });
897
731
  }
898
732
  start() {
899
733
  if (!this.finalized) {
900
734
  console.warn('[api-server-base] ApiServer.start() called without finalize(); auto-finalizing. Call server.finalize() before start() to silence this warning.');
901
735
  this.finalize();
902
736
  }
903
- this.app
737
+ void this.fastify
904
738
  .listen({
905
739
  port: this.config.apiPort,
906
740
  host: this.config.apiHost
907
741
  })
908
- .on('listening', () => {
742
+ .then(() => {
909
743
  console.log(`Server is running on http://${this.config.apiHost}:${this.config.apiPort}`);
910
744
  })
911
- .on('error', (error) => {
745
+ .catch((error) => {
912
746
  let message;
913
747
  if (error.code === 'EADDRINUSE') {
914
748
  message = `Port ${this.config.apiPort} is already in use.`;
@@ -928,13 +762,17 @@ export class ApiServer {
928
762
  this.config.onStartError(err);
929
763
  return;
930
764
  }
931
- throw err;
765
+ this.logUnhandledError('Server startup failed', err);
766
+ process.exitCode = 1;
932
767
  });
933
768
  return this;
934
769
  }
935
770
  internalServerErrorMessage(error) {
936
771
  return this.config.debug ? this.guessExceptionText(error) : 'Internal server error';
937
772
  }
773
+ logUnhandledError(context, error) {
774
+ console.error(`[ApiServer] ${context}`, error);
775
+ }
938
776
  async verifyJWT(token) {
939
777
  if (!this.config.accessSecret) {
940
778
  return { tokenData: undefined, error: 'JWT authentication disabled; no jwtSecret set', expired: false };
@@ -943,7 +781,7 @@ export class ApiServer {
943
781
  if (!result.success) {
944
782
  return { tokenData: undefined, error: result.error, expired: result.expired };
945
783
  }
946
- if (!result.data.uid) {
784
+ if (result.data.uid == null) {
947
785
  return { tokenData: undefined, error: 'Missing/bad userid in token', expired: false };
948
786
  }
949
787
  return { tokenData: result.data, error: undefined, expired: false };
@@ -986,8 +824,9 @@ export class ApiServer {
986
824
  return null;
987
825
  }
988
826
  const storedUid = String(stored.userId);
989
- const verifyUid = verify.data.uid === undefined || verify.data.uid === null ? null : String(verify.data.uid);
990
- if (verifyUid && verifyUid !== storedUid) {
827
+ const verifyUid = verify.data.uid != null ? String(verify.data.uid) : null;
828
+ // A missing uid claim is treated as a binding failure, not a skip.
829
+ if (!verifyUid || verifyUid !== storedUid) {
991
830
  return null;
992
831
  }
993
832
  const claims = verify.data;
@@ -995,7 +834,6 @@ export class ApiServer {
995
834
  void _exp;
996
835
  void _iat;
997
836
  void _nbf;
998
- // Ensure we never embed token secrets into refreshed access tokens.
999
837
  delete payload.accessToken;
1000
838
  delete payload.refreshToken;
1001
839
  delete payload.userId;
@@ -1034,19 +872,29 @@ export class ApiServer {
1034
872
  }
1035
873
  let token = null;
1036
874
  const authHeader = apiReq.req.headers.authorization;
875
+ const headerValue = Array.isArray(authHeader) ? authHeader[0] : authHeader;
876
+ const bearerToken = headerValue?.startsWith('Bearer ') ? headerValue.slice(7).trim() || null : null;
877
+ const paramToken = bearerToken ? null : this.resolveTokenFromRequest(apiReq.req);
1037
878
  const requiresAuthToken = this.requiresAuthToken(authType);
1038
879
  const allowRefresh = requiresAuthToken || (authType === 'maybe' && this.config.refreshMaybe);
1039
- const apiKeyAuth = await this.tryAuthenticateApiKey(apiReq, authType, authHeader);
1040
- if (apiKeyAuth) {
1041
- return apiKeyAuth;
880
+ if (this.config.apiKeyEnabled || authType === 'apikey') {
881
+ const apiKeyAuth = await this.tryAuthenticateApiKey(apiReq, authType, bearerToken ?? paramToken);
882
+ if (apiKeyAuth) {
883
+ return apiKeyAuth;
884
+ }
885
+ }
886
+ token = bearerToken ?? paramToken;
887
+ if (bearerToken) {
888
+ apiReq.authMethod = 'bearer';
1042
889
  }
1043
- if (authHeader?.startsWith('Bearer ')) {
1044
- token = authHeader.slice(7).trim();
890
+ else if (paramToken) {
891
+ apiReq.authMethod = 'param';
1045
892
  }
1046
893
  if (!token) {
1047
894
  const access = apiReq.req.cookies?.[this.config.accessCookie];
1048
895
  if (access) {
1049
896
  token = access;
897
+ apiReq.authMethod = 'cookie';
1050
898
  }
1051
899
  }
1052
900
  let tokenData;
@@ -1126,40 +974,82 @@ export class ApiServer {
1126
974
  apiReq.token = token;
1127
975
  return tokenData;
1128
976
  }
1129
- async tryAuthenticateApiKey(apiReq, authType, authHeader) {
1130
- if (!authHeader?.startsWith('Bearer ')) {
977
+ async tryAuthenticateApiKey(apiReq, authType, tokenCandidate) {
978
+ if (!tokenCandidate) {
1131
979
  if (authType === 'apikey') {
1132
980
  throw new ApiError({ code: 401, message: 'Authorization header is missing or invalid' });
1133
981
  }
1134
982
  return null;
1135
983
  }
1136
- const keyToken = authHeader.slice(7).trim();
1137
- if (!keyToken.startsWith('apikey-')) {
984
+ const keyToken = tokenCandidate;
985
+ const prefix = this.config.apiKeyPrefix;
986
+ const secret = prefix === '' ? keyToken : keyToken.startsWith(prefix) ? keyToken.slice(prefix.length) : null;
987
+ if (!secret) {
1138
988
  if (authType === 'apikey') {
1139
989
  throw new ApiError({ code: 401, message: 'Invalid API Key' });
1140
990
  }
1141
991
  return null;
1142
992
  }
1143
- const secret = keyToken.replace(/^apikey-/, '');
1144
993
  const key = await this.getApiKey(secret);
1145
994
  if (!key) {
1146
- throw new ApiError({ code: 401, message: 'Invalid API Key' });
995
+ if (authType === 'apikey') {
996
+ throw new ApiError({ code: 401, message: 'Invalid API Key' });
997
+ }
998
+ return null;
1147
999
  }
1148
1000
  apiReq.token = secret;
1149
1001
  apiReq.apiKey = key;
1150
- // Treat API keys as authenticated identities, consistent with JWT-based flows.
1151
- const resolvedUid = this.normalizeAuthIdentifier(key.uid);
1152
- if (resolvedUid !== null) {
1153
- apiReq.realUid = resolvedUid;
1002
+ apiReq.authMethod = 'apikey';
1003
+ const resolvedRuid = this.normalizeAuthIdentifier(key.uid);
1004
+ const resolvedEuid = key.euid !== undefined ? this.normalizeAuthIdentifier(key.euid) : null;
1005
+ const effectiveUid = resolvedEuid ?? resolvedRuid;
1006
+ if (resolvedRuid !== null) {
1007
+ apiReq.realUid = resolvedRuid;
1154
1008
  }
1155
1009
  return {
1156
- uid: key.uid,
1010
+ uid: effectiveUid,
1011
+ ...(resolvedEuid !== null ? { ruid: resolvedRuid !== null ? String(resolvedRuid) : undefined } : {}),
1157
1012
  domain: '',
1158
1013
  fingerprint: '',
1159
1014
  iat: 0,
1160
1015
  exp: 0
1161
1016
  };
1162
1017
  }
1018
+ resolveTokenFromRequest(req) {
1019
+ const paramName = this.config.tokenParam.trim();
1020
+ if (!paramName) {
1021
+ return null;
1022
+ }
1023
+ const location = this.config.tokenParamLocation;
1024
+ if (location === 'body') {
1025
+ return this.readNamedValue(req.body, paramName);
1026
+ }
1027
+ if (location === 'query') {
1028
+ return this.readNamedValue(req.query, paramName);
1029
+ }
1030
+ return this.readNamedValue(req.body, paramName) ?? this.readNamedValue(req.query, paramName);
1031
+ }
1032
+ readNamedValue(container, key) {
1033
+ if (!container || typeof container !== 'object' || Array.isArray(container)) {
1034
+ return null;
1035
+ }
1036
+ const raw = container[key];
1037
+ if (typeof raw === 'string') {
1038
+ const token = raw.trim();
1039
+ return token.length > 0 ? token : null;
1040
+ }
1041
+ if (Array.isArray(raw)) {
1042
+ for (const entry of raw) {
1043
+ if (typeof entry === 'string') {
1044
+ const token = entry.trim();
1045
+ if (token.length > 0) {
1046
+ return token;
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+ return null;
1052
+ }
1163
1053
  requiresAuthToken(authType) {
1164
1054
  return authType === 'yes' || authType === 'strict';
1165
1055
  }
@@ -1207,21 +1097,25 @@ export class ApiServer {
1207
1097
  }
1208
1098
  return rawReal;
1209
1099
  }
1210
- useExpress(pathOrHandler, ...handlers) {
1211
- this.assertNotFinalized('useExpress');
1212
- if (typeof pathOrHandler === 'string') {
1213
- const apiPath = this.toApiRouterPath(pathOrHandler);
1214
- if (apiPath) {
1215
- this.apiRouter.use(apiPath, ...handlers);
1216
- }
1217
- else {
1218
- this.app.use(pathOrHandler, ...handlers);
1219
- }
1220
- }
1221
- else {
1222
- this.app.use(pathOrHandler, ...handlers);
1223
- }
1224
- return this;
1100
+ toExtendedReq(request) {
1101
+ const parsedBody = request.__apiParsedBody;
1102
+ const parsedFiles = request.__apiParsedFiles;
1103
+ const headers = request.headers;
1104
+ return {
1105
+ method: request.method,
1106
+ url: request.url,
1107
+ originalUrl: request.raw.url,
1108
+ headers,
1109
+ query: (request.query ?? {}),
1110
+ body: parsedBody ?? request.body,
1111
+ params: (request.params ?? {}),
1112
+ cookies: (request.cookies ?? {}),
1113
+ ip: request.ip,
1114
+ ips: request.ips,
1115
+ socket: request.socket,
1116
+ protocol: request.protocol,
1117
+ files: parsedFiles
1118
+ };
1225
1119
  }
1226
1120
  createApiRequest(req, res) {
1227
1121
  const apiReq = {
@@ -1230,10 +1124,11 @@ export class ApiServer {
1230
1124
  res,
1231
1125
  token: '',
1232
1126
  tokenData: null,
1127
+ authMethod: null,
1233
1128
  realUid: null,
1234
- getClientInfo: () => ensureClientInfo(apiReq),
1235
- getClientIp: () => ensureClientInfo(apiReq).ip,
1236
- getClientIpChain: () => ensureClientInfo(apiReq).ipchain,
1129
+ getClientInfo: () => ensureClientInfo(apiReq, this.config.trustProxy),
1130
+ getClientIp: () => ensureClientInfo(apiReq, this.config.trustProxy).ip,
1131
+ getClientIpChain: () => ensureClientInfo(apiReq, this.config.trustProxy).ipchain,
1237
1132
  getRealUid: () => apiReq.realUid ?? null,
1238
1133
  isImpersonating: () => {
1239
1134
  const realUid = apiReq.realUid;
@@ -1244,11 +1139,101 @@ export class ApiServer {
1244
1139
  if (tokenUid === null || tokenUid === undefined) {
1245
1140
  return false;
1246
1141
  }
1247
- return realUid !== tokenUid;
1142
+ // Normalise both sides to strings before comparing so that
1143
+ // numeric 42 and string "42" are treated as the same identity.
1144
+ return String(realUid) !== String(tokenUid);
1248
1145
  }
1249
1146
  };
1250
1147
  return apiReq;
1251
1148
  }
1149
+ useExpress(pathOrHandler, ...handlers) {
1150
+ this.assertNotFinalized('useExpress');
1151
+ if (typeof pathOrHandler !== 'string') {
1152
+ if (pathOrHandler.length === 4) {
1153
+ this.compatGlobalErrorHandler = pathOrHandler;
1154
+ }
1155
+ return this;
1156
+ }
1157
+ const path = pathOrHandler;
1158
+ const stack = handlers;
1159
+ this.fastify.all(path, async (request, reply) => {
1160
+ const req = this.toExtendedReq(request);
1161
+ const res = new FastifyResponseAdapter(reply);
1162
+ try {
1163
+ await this.runCompatHandlers(stack, req, res);
1164
+ }
1165
+ catch (error) {
1166
+ await this.runCompatErrorHandlers(error, stack, req, res);
1167
+ }
1168
+ });
1169
+ return this;
1170
+ }
1171
+ async runCompatHandlers(stack, req, res) {
1172
+ const handlers = stack.filter((fn) => fn.length !== 4);
1173
+ for (const fn of handlers) {
1174
+ await new Promise((resolve, reject) => {
1175
+ let nextCalled = false;
1176
+ const next = (error) => {
1177
+ nextCalled = true;
1178
+ if (error) {
1179
+ reject(error);
1180
+ return;
1181
+ }
1182
+ resolve();
1183
+ };
1184
+ try {
1185
+ const out = fn(req, res, next);
1186
+ Promise.resolve(out)
1187
+ .then(() => {
1188
+ if (!nextCalled) {
1189
+ resolve();
1190
+ }
1191
+ })
1192
+ .catch(reject);
1193
+ }
1194
+ catch (error) {
1195
+ reject(error);
1196
+ }
1197
+ });
1198
+ if (res.headersSent) {
1199
+ return;
1200
+ }
1201
+ }
1202
+ }
1203
+ async runCompatErrorHandlers(error, stack, req, res) {
1204
+ const errorHandlers = stack.filter((fn) => fn.length === 4);
1205
+ if (this.compatGlobalErrorHandler) {
1206
+ errorHandlers.push(this.compatGlobalErrorHandler);
1207
+ }
1208
+ if (errorHandlers.length === 0) {
1209
+ throw error;
1210
+ }
1211
+ let currentError = error;
1212
+ for (const handler of errorHandlers) {
1213
+ await new Promise((resolve, reject) => {
1214
+ const next = (nextError) => {
1215
+ if (nextError) {
1216
+ currentError = nextError;
1217
+ }
1218
+ resolve();
1219
+ };
1220
+ try {
1221
+ Promise.resolve(handler(currentError, req, res, next))
1222
+ .then(() => resolve())
1223
+ .catch(reject);
1224
+ }
1225
+ catch (caught) {
1226
+ reject(caught);
1227
+ }
1228
+ });
1229
+ if (res.headersSent) {
1230
+ return;
1231
+ }
1232
+ }
1233
+ if (!res.headersSent) {
1234
+ throw currentError;
1235
+ }
1236
+ }
1252
1237
  expressAuth(auth) {
1253
1238
  return async (req, res, next) => {
1254
1239
  const apiReq = this.createApiRequest(req, res);
@@ -1294,6 +1279,9 @@ export class ApiServer {
1294
1279
  }
1295
1280
  const status = asHttpStatus(error);
1296
1281
  if (status) {
1282
+ if (status >= 500) {
1283
+ this.logUnhandledError(`Unhandled compat middleware error mapped to HTTP ${status}`, error);
1284
+ }
1297
1285
  res.status(status).json({
1298
1286
  success: false,
1299
1287
  code: status,
@@ -1303,19 +1291,20 @@ export class ApiServer {
1303
1291
  });
1304
1292
  return;
1305
1293
  }
1306
- const errorPayload = {
1294
+ this.logUnhandledError('Unhandled compat middleware error fallback to HTTP 500', error);
1295
+ res.status(500).json({
1307
1296
  success: false,
1308
1297
  code: 500,
1309
1298
  message: this.internalServerErrorMessage(error),
1310
1299
  data: null,
1311
1300
  errors: {}
1312
- };
1313
- res.status(500).json(errorPayload);
1301
+ });
1314
1302
  };
1315
1303
  }
1316
- handle_request(handler, auth) {
1317
- return async (req, res, next) => {
1318
- void next;
1304
+ handleRequest(handler, auth) {
1305
+ return async (request, reply) => {
1306
+ const req = this.toExtendedReq(request);
1307
+ const res = new FastifyResponseAdapter(reply);
1319
1308
  const apiReq = this.createApiRequest(req, res);
1320
1309
  try {
1321
1310
  if (this.config.hydrateGetBody) {
@@ -1338,11 +1327,11 @@ export class ApiServer {
1338
1327
  throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
1339
1328
  }
1340
1329
  const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
1341
- const responsePayload = { success: true, code, message, data, errors: {} };
1330
+ const responsePayload = { success: code < 400, code, message, data, errors: {} };
1342
1331
  if (this.config.debug) {
1343
1332
  this.dumpResponse(apiReq, responsePayload, code);
1344
1333
  }
1345
- res.status(code).json(responsePayload);
1334
+ reply.code(code).send(responsePayload);
1346
1335
  }
1347
1336
  catch (error) {
1348
1337
  if (error instanceof ApiError || isApiErrorLike(error)) {
@@ -1360,27 +1349,53 @@ export class ApiServer {
1360
1349
  if (this.config.debug) {
1361
1350
  this.dumpResponse(apiReq, errorPayload, apiError.code);
1362
1351
  }
1363
- res.status(apiError.code).json(errorPayload);
1352
+ reply.code(apiError.code).send(errorPayload);
1364
1353
  }
1365
1354
  else {
1355
+ const status = asHttpStatus(error) ?? 500;
1356
+ if (status >= 500) {
1357
+ this.logUnhandledError('Unhandled API route error fallback to HTTP 500', error);
1358
+ }
1359
+ const uploadTooLarge = error &&
1360
+ typeof error === 'object' &&
1361
+ ('code' in error ? error.code === 'FST_REQ_FILE_TOO_LARGE' : false);
1366
1362
  const errorPayload = {
1367
1363
  success: false,
1368
- code: 500,
1369
- message: this.internalServerErrorMessage(error),
1364
+ code: uploadTooLarge ? 413 : status,
1365
+ message: uploadTooLarge
1366
+ ? `Upload exceeds maximum size of ${this.config.uploadMax} bytes`
1367
+ : this.internalServerErrorMessage(error),
1370
1368
  data: null,
1371
1369
  errors: {}
1372
1370
  };
1373
1371
  if (this.config.debug) {
1374
- this.dumpResponse(apiReq, errorPayload, 500);
1372
+ this.dumpResponse(apiReq, errorPayload, uploadTooLarge ? 413 : status);
1375
1373
  }
1376
- res.status(500).json(errorPayload);
1374
+ reply.code(uploadTooLarge ? 413 : status).send(errorPayload);
1375
+ }
1376
+ }
1377
+ };
1378
+ }
1379
+ // Test/compat shim for direct unit-level request wrapping without Fastify runtime.
1380
+ handle_request(handler, auth) {
1381
+ return async (req, res, next) => {
1382
+ const apiReq = this.createApiRequest(req, res);
1383
+ try {
1384
+ if (this.config.hydrateGetBody) {
1385
+ hydrateGetBody(apiReq.req);
1377
1386
  }
1387
+ apiReq.tokenData = await this.authenticate(apiReq, auth.type);
1388
+ await this.authorize(apiReq, auth.req);
1389
+ await handler(apiReq);
1390
+ next();
1391
+ }
1392
+ catch (error) {
1393
+ next(error);
1378
1394
  }
1379
1395
  };
1380
1396
  }
1381
1397
  api(module) {
1382
1398
  this.assertNotFinalized('api');
1383
- const router = express.Router();
1384
1399
  module.server = this;
1385
1400
  const moduleType = module.moduleType;
1386
1401
  if (moduleType === 'auth') {
@@ -1391,44 +1406,51 @@ export class ApiServer {
1391
1406
  const name = module.constructor?.name ? String(module.constructor.name) : 'ApiModule';
1392
1407
  throw new Error(`${name}.checkConfig() returned false`);
1393
1408
  }
1394
- const base = this.apiBasePath;
1409
+ module.onMount();
1395
1410
  const ns = module.namespace;
1396
- const mountPath = `${base}${ns}`;
1411
+ const mountPath = `${this.apiBasePath}${ns}`;
1397
1412
  module.mountpath = mountPath;
1398
- module.defineRoutes().forEach((r) => {
1399
- const handler = this.handle_request(r.handler, r.auth);
1400
- switch (r.method) {
1401
- case 'get':
1402
- router.get(r.path, handler);
1403
- break;
1404
- case 'post':
1405
- router.post(r.path, handler);
1406
- break;
1407
- case 'put':
1408
- router.put(r.path, handler);
1409
- break;
1410
- case 'patch':
1411
- router.patch(r.path, handler);
1412
- break;
1413
- case 'delete':
1414
- router.delete(r.path, handler);
1415
- break;
1416
- }
1413
+ for (const route of module.defineRoutes()) {
1414
+ const handler = this.handleRequest(route.handler, route.auth);
1415
+ const method = route.method.toUpperCase();
1416
+ const fullPath = this.joinRoutePath(this.apiBasePath, ns, route.path);
1417
+ this.fastify.route({ method, url: fullPath, handler, ...(route.schema ? { schema: route.schema } : {}) });
1417
1418
  if (this.config.debug) {
1418
- console.log(`Adding ${mountPath}${r.path} (${r.method.toUpperCase()})`);
1419
+ console.log(`Adding ${fullPath} (${method})`);
1419
1420
  }
1420
- });
1421
- this.apiRouter.use(ns, router);
1421
+ }
1422
1422
  return this;
1423
1423
  }
1424
+ joinRoutePath(base, namespace, routePath) {
1425
+ const parts = [base, namespace, routePath]
1426
+ .filter((value) => typeof value === 'string' && value.length > 0)
1427
+ .map((value, index) => {
1428
+ if (index === 0) {
1429
+ return value.startsWith('/') ? value : `/${value}`;
1430
+ }
1431
+ return value.replace(/^\/+/, '').replace(/\/+$/, '');
1432
+ });
1433
+ const joined = parts.join('/').replace(/\/+/g, '/');
1434
+ return joined.startsWith('/') ? joined : `/${joined}`;
1435
+ }
1424
1436
  dumpRequest(apiReq) {
1425
1437
  const req = apiReq.req;
1438
+ const tokenParam = this.config.tokenParam.trim();
1426
1439
  console.log('--- Incoming Request! ---');
1427
1440
  const url = req.originalUrl || req.url;
1428
1441
  console.log('URL:', url);
1429
1442
  console.log('Method:', req.method);
1430
- console.log('Query Params:', req.query || {});
1443
+ if (tokenParam && req.query && tokenParam in req.query) {
1444
+ const maskedQuery = { ...req.query, [tokenParam]: '[REDACTED]' };
1445
+ console.log('Query Params:', maskedQuery);
1446
+ }
1447
+ else {
1448
+ console.log('Query Params:', req.query || {});
1449
+ }
1431
1450
  const sensitiveBodyKeys = ['password', 'client_secret', 'clientSecret', 'secret'];
1451
+ if (tokenParam) {
1452
+ sensitiveBodyKeys.push(tokenParam);
1453
+ }
1432
1454
  const body = req.body && typeof req.body === 'object' ? { ...req.body } : req.body;
1433
1455
  if (body && typeof body === 'object') {
1434
1456
  for (const key of sensitiveBodyKeys) {
@@ -1450,6 +1472,12 @@ export class ApiServer {
1450
1472
  if (headers.authorization) {
1451
1473
  headers.authorization = '[REDACTED]';
1452
1474
  }
1475
+ if (headers['x-api-key']) {
1476
+ headers['x-api-key'] = '[REDACTED]';
1477
+ }
1478
+ if (headers.cookie) {
1479
+ headers.cookie = '[REDACTED]';
1480
+ }
1453
1481
  console.log('Headers:', headers);
1454
1482
  console.log('------------------------');
1455
1483
  }
@@ -1499,7 +1527,21 @@ export class ApiServer {
1499
1527
  return value;
1500
1528
  }
1501
1529
  dumpResponse(apiReq, payload, status) {
1502
- const url = apiReq.req.originalUrl || apiReq.req.url;
1530
+ const rawUrl = apiReq.req.originalUrl || apiReq.req.url;
1531
+ const tokenParam = this.config.tokenParam.trim();
1532
+ let url = rawUrl;
1533
+ if (tokenParam && rawUrl) {
1534
+ try {
1535
+ const parsed = new URL(rawUrl, 'http://x');
1536
+ if (parsed.searchParams.has(tokenParam)) {
1537
+ parsed.searchParams.set(tokenParam, '[REDACTED]');
1538
+ url = parsed.pathname + '?' + parsed.searchParams.toString();
1539
+ }
1540
+ }
1541
+ catch {
1542
+ // Leave url unmodified if parsing fails
1543
+ }
1544
+ }
1503
1545
  console.log('--- Outgoing Response! ---');
1504
1546
  console.log('URL:', url);
1505
1547
  console.log('Status:', status);