@technomoron/api-server-base 2.0.0-beta.22 → 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 +12 -0
  5. package/dist/cjs/{api-server-base.cjs → server/src/api-server-base.cjs} +573 -615
  6. package/dist/{esm → cjs/server/src}/api-server-base.d.ts +97 -87
  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/{esm → cjs/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/{esm → cjs/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 +12 -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 +97 -87
  63. package/dist/esm/{api-server-base.js → server/src/api-server-base.js} +562 -604
  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/{cjs → esm/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/{cjs → esm/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 +1 -1
  109. package/package.json +18 -17
  110. package/README.txt +0 -216
  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,
@@ -381,36 +118,27 @@ function fillConfig(config) {
381
118
  apiVersion: config.apiVersion ?? '',
382
119
  minClientVersion: config.minClientVersion ?? '',
383
120
  tokenStore: config.tokenStore,
384
- authStores: config.authStores,
385
- onStartError: config.onStartError
121
+ onStartError: config.onStartError,
122
+ trustProxy: config.trustProxy ?? true
386
123
  };
387
124
  }
125
+ /** Core Fastify-based API server with module mounting and auth integration hooks. */
388
126
  export class ApiServer {
389
- /**
390
- * @deprecated ApiServer does not track a global "current request". This value is always null.
391
- * Use the per-request ApiRequest passed to handlers, or `req.apiReq` / `res.locals.apiReq`
392
- * when mounting raw Express endpoints.
393
- */
394
127
  get currReq() {
395
128
  return null;
396
129
  }
397
130
  set currReq(_value) {
398
131
  if (this.config.devMode && !this.currReqDeprecationWarned) {
399
132
  this.currReqDeprecationWarned = true;
400
- 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.');
401
134
  }
402
135
  void _value;
403
136
  }
404
137
  constructor(config = {}) {
405
138
  this.finalized = false;
406
- this.serverAuthAdapter = null;
407
- this.apiNotFoundHandler = null;
408
139
  this.apiErrorHandlerInstalled = false;
409
140
  this.tokenStoreAdapter = null;
410
- this.userStoreAdapter = null;
411
- this.passkeyServiceAdapter = null;
412
- this.oauthStoreAdapter = null;
413
- this.canImpersonateAdapter = null;
141
+ this.compatGlobalErrorHandler = null;
414
142
  this.currReqDeprecationWarned = false;
415
143
  this.config = fillConfig(config);
416
144
  this.apiBasePath = this.normalizeApiBasePath(this.config.apiBasePath);
@@ -419,100 +147,153 @@ export class ApiServer {
419
147
  this.moduleAdapter = nullAuthModule;
420
148
  this.jwtHelper = new JwtHelperStore();
421
149
  this.tokenStoreAdapter = this.config.tokenStore ?? null;
422
- if (this.config.authStores) {
423
- const { userStore, tokenStore, passkeyService, oauthStore, canImpersonate } = this.config.authStores;
424
- this.userStoreAdapter = userStore;
425
- this.tokenStoreAdapter = tokenStore;
426
- this.passkeyServiceAdapter = passkeyService ?? null;
427
- this.oauthStoreAdapter = oauthStore ?? null;
428
- this.canImpersonateAdapter = canImpersonate ?? null;
429
- this.storageAdapter = this.getServerAuthAdapter();
430
- }
431
- if ((this.config.authApi || this.config.authStores) &&
432
- (!this.config.accessSecret || !this.config.refreshSecret)) {
150
+ if (this.config.authApi && (!this.config.accessSecret || !this.config.refreshSecret)) {
433
151
  console.warn('[api-server-base] Auth is enabled but accessSecret and/or refreshSecret are empty. JWT signing will fail at runtime.');
434
152
  }
435
- this.app = express();
436
- // Mount API modules and any custom endpoints under `apiBasePath` on this router so we can keep
437
- // the API 404 handler ordered last without relying on Express internals.
438
- this.apiRouter = express.Router();
439
- if (config.uploadPath) {
440
- const upload = multer({ dest: config.uploadPath, limits: { fileSize: this.config.uploadMax } });
441
- this.app.use(upload.any());
442
- // Multer errors happen before ApiModule route wrappers; format the common "too large" failure.
443
- this.app.use((err, _req, res, next) => {
444
- const code = err && typeof err === 'object' ? err.code : undefined;
445
- if (code === 'LIMIT_FILE_SIZE') {
446
- res.status(413).json({
447
- success: false,
448
- code: 413,
449
- message: `Upload exceeds maximum size of ${this.config.uploadMax} bytes`,
450
- data: null,
451
- errors: {}
452
- });
453
- 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);
454
173
  }
455
- next(err);
456
174
  });
457
- }
458
- 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
+ });
459
238
  this.installStaticDirs();
460
239
  this.installPingHandler();
461
240
  this.installSwaggerHandler();
462
- this.app.use(this.apiBasePath, this.apiRouter);
463
- // addSwaggerUi(this.app);
464
241
  this.installApiNotFoundHandler();
465
242
  this.installApiErrorHandler();
466
243
  }
467
- assertNotFinalized(action) {
468
- if (this.finalized) {
469
- 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);
470
254
  }
255
+ return Buffer.concat(chunks).toString('utf8');
471
256
  }
472
- toApiRouterPath(candidate) {
473
- if (typeof candidate !== 'string') {
474
- return null;
475
- }
476
- const trimmed = candidate.trim();
477
- if (!trimmed) {
478
- return null;
257
+ parseRawBody(raw, contentType) {
258
+ if (!raw) {
259
+ return {};
479
260
  }
480
- const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
481
- const base = this.apiBasePath;
482
- if (base === '/') {
483
- return normalized;
261
+ if (contentType.includes('application/json')) {
262
+ try {
263
+ return JSON.parse(raw);
264
+ }
265
+ catch {
266
+ return {};
267
+ }
484
268
  }
485
- if (normalized === base) {
486
- return '/';
269
+ if (contentType.includes('application/x-www-form-urlencoded')) {
270
+ const params = new URLSearchParams(raw);
271
+ return Object.fromEntries(params.entries());
487
272
  }
488
- if (normalized.startsWith(`${base}/`)) {
489
- 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()`);
490
278
  }
491
- return null;
492
279
  }
493
280
  finalize() {
494
- this.installApiNotFoundHandler();
495
- this.installApiErrorHandler();
496
281
  this.finalized = true;
497
282
  return this;
498
283
  }
499
284
  authStorage(storage) {
285
+ this.assertNotFinalized('authStorage');
500
286
  this.storageAdapter = storage;
501
287
  return this;
502
288
  }
503
- /**
504
- * @deprecated Use {@link ApiServer.authStorage} instead.
505
- */
506
289
  useAuthStorage(storage) {
507
290
  return this.authStorage(storage);
508
291
  }
509
292
  authModule(module) {
293
+ this.assertNotFinalized('authModule');
510
294
  this.moduleAdapter = module;
511
295
  return this;
512
296
  }
513
- /**
514
- * @deprecated Use {@link ApiServer.authModule} instead.
515
- */
516
297
  useAuthModule(module) {
517
298
  return this.authModule(module);
518
299
  }
@@ -523,88 +304,39 @@ export class ApiServer {
523
304
  return this.moduleAdapter;
524
305
  }
525
306
  setTokenStore(store) {
307
+ this.assertNotFinalized('setTokenStore');
526
308
  this.tokenStoreAdapter = store;
527
- // If using direct stores, expose the server-backed auth adapter.
528
- if (this.userStoreAdapter) {
529
- this.storageAdapter = this.getServerAuthAdapter();
530
- }
531
309
  return this;
532
310
  }
533
311
  getTokenStore() {
534
312
  return this.tokenStoreAdapter;
535
313
  }
536
- ensureUserStore() {
537
- if (!this.userStoreAdapter) {
538
- throw new Error('User store is not configured');
539
- }
540
- return this.userStoreAdapter;
541
- }
542
- ensureTokenStore() {
543
- if (!this.tokenStoreAdapter) {
544
- throw new Error('Token store is not configured');
545
- }
546
- return this.tokenStoreAdapter;
547
- }
548
- ensurePasskeyService() {
549
- if (!this.passkeyServiceAdapter) {
314
+ async listUserCredentials(userId) {
315
+ if (typeof this.storageAdapter.listUserCredentials !== 'function') {
550
316
  throw new Error('Passkey service is not configured');
551
317
  }
552
- return this.passkeyServiceAdapter;
553
- }
554
- async listUserCredentials(userId) {
555
- return this.ensurePasskeyService().listUserCredentials(userId);
318
+ return this.storageAdapter.listUserCredentials(userId);
556
319
  }
557
320
  async deletePasskeyCredential(credentialId) {
558
- return this.ensurePasskeyService().deleteCredential(credentialId);
559
- }
560
- ensureOAuthStore() {
561
- if (!this.oauthStoreAdapter) {
562
- throw new Error('OAuth store is not configured');
321
+ if (typeof this.storageAdapter.deletePasskeyCredential !== 'function') {
322
+ throw new Error('Passkey service is not configured');
563
323
  }
564
- return this.oauthStoreAdapter;
565
- }
566
- getServerAuthAdapter() {
567
- if (this.serverAuthAdapter) {
568
- return this.serverAuthAdapter;
569
- }
570
- const server = this;
571
- this.serverAuthAdapter = {
572
- getUser: (identifier) => server.getUser(identifier),
573
- getUserPasswordHash: (user) => server.getUserPasswordHash(user),
574
- getUserId: (user) => server.getUserId(user),
575
- filterUser: (user) => server.filterUser(user),
576
- verifyPassword: (password, hash) => server.verifyPassword(password, hash),
577
- storeToken: (data) => server.storeToken(data),
578
- getToken: (query, opts) => server.getToken(query, opts),
579
- deleteToken: (query) => server.deleteToken(query),
580
- updateToken: (updates) => server.updateToken(updates),
581
- createPasskeyChallenge: (params) => server.createPasskeyChallenge(params),
582
- verifyPasskeyResponse: (params) => server.verifyPasskeyResponse(params),
583
- listUserCredentials: (userId) => server.listUserCredentials(userId),
584
- deletePasskeyCredential: (credentialId) => server.deletePasskeyCredential(credentialId),
585
- getClient: (clientId) => server.getClient(clientId),
586
- verifyClientSecret: (client, clientSecret) => server.verifyClientSecret(client, clientSecret),
587
- createAuthCode: (request) => server.createAuthCode(request),
588
- consumeAuthCode: (code, clientId) => server.consumeAuthCode(code, clientId),
589
- canImpersonate: (params) => server.canImpersonate(params)
590
- };
591
- return this.serverAuthAdapter;
324
+ return this.storageAdapter.deletePasskeyCredential(credentialId);
592
325
  }
593
- // AuthAdapter-compatible helpers (used by AuthModule)
594
326
  async getUser(identifier) {
595
- return this.userStoreAdapter ? this.userStoreAdapter.findUser(identifier) : null;
327
+ return this.storageAdapter.getUser(identifier);
596
328
  }
597
329
  getUserPasswordHash(user) {
598
- return this.ensureUserStore().getPasswordHash(user) ?? '';
330
+ return this.storageAdapter.getUserPasswordHash(user);
599
331
  }
600
332
  getUserId(user) {
601
- return this.ensureUserStore().getUserId(user);
333
+ return this.storageAdapter.getUserId(user);
602
334
  }
603
335
  filterUser(user) {
604
- return this.ensureUserStore().toPublic(user);
336
+ return this.storageAdapter.filterUser(user);
605
337
  }
606
338
  async verifyPassword(password, hash) {
607
- return this.ensureUserStore().verifyPassword(password, hash);
339
+ return this.storageAdapter.verifyPassword(password, hash);
608
340
  }
609
341
  async storeToken(data) {
610
342
  if (this.tokenStoreAdapter) {
@@ -647,47 +379,55 @@ export class ApiServer {
647
379
  return 0;
648
380
  }
649
381
  async createPasskeyChallenge(params) {
650
- 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);
651
386
  }
652
387
  async verifyPasskeyResponse(params) {
653
- 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);
654
392
  }
655
393
  async getClient(clientId) {
656
- 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);
657
398
  }
658
399
  async verifyClientSecret(client, clientSecret) {
659
- 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);
660
404
  }
661
405
  async createAuthCode(request) {
662
- const expiresAt = new Date(Date.now() + (request.expiresInSeconds ?? 300) * 1000);
663
- const code = request.code ?? randomUUID();
664
- await this.ensureOAuthStore().createAuthCode({ ...request, code, expiresAt });
665
- return {
666
- code,
667
- clientId: request.clientId,
668
- userId: request.userId,
669
- redirectUri: request.redirectUri,
670
- scope: request.scope ?? [],
671
- codeChallenge: request.codeChallenge,
672
- codeChallengeMethod: request.codeChallengeMethod,
673
- expiresAt,
674
- metadata: request.metadata
675
- };
406
+ if (typeof this.storageAdapter.createAuthCode !== 'function') {
407
+ throw new Error('OAuth store is not configured');
408
+ }
409
+ return this.storageAdapter.createAuthCode(request);
676
410
  }
677
411
  async consumeAuthCode(code, clientId) {
678
- 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);
679
416
  if (!consumed || consumed.clientId !== clientId) {
680
417
  return null;
681
418
  }
682
419
  return consumed;
683
420
  }
684
421
  async canImpersonate(params) {
685
- if (this.canImpersonateAdapter) {
686
- return !!(await this.canImpersonateAdapter(params));
422
+ if (typeof this.storageAdapter.canImpersonate === 'function') {
423
+ return !!(await this.storageAdapter.canImpersonate(params));
687
424
  }
688
425
  return params.realUserId === params.effectiveUserId;
689
426
  }
690
427
  jwtSign(payload, secret, expiresInSeconds, options) {
428
+ if (!secret) {
429
+ return { success: false, error: 'JWT secret is not configured' };
430
+ }
691
431
  return (this.tokenStoreAdapter ?? this.jwtHelper).jwtSign(payload, secret, expiresInSeconds, options);
692
432
  }
693
433
  jwtVerify(token, secret, options) {
@@ -698,6 +438,8 @@ export class ApiServer {
698
438
  }
699
439
  async getApiKey(token) {
700
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.');
701
443
  return null;
702
444
  }
703
445
  async authenticateUser(params) {
@@ -722,51 +464,30 @@ export class ApiServer {
722
464
  return false;
723
465
  }
724
466
  guessExceptionText(error, defMsg = 'Unknown Error') {
725
- return guess_exception_text(error, defMsg);
467
+ return guessExceptionText(error, defMsg);
726
468
  }
727
469
  async authorize(apiReq, requiredClass) {
728
470
  void apiReq;
729
- 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
+ }
730
475
  }
731
- middlewares() {
732
- this.app.use(express.json());
733
- this.app.use(cookieParser());
734
- const corsOptions = {
735
- origin: (origin, callback) => {
736
- if (!origin) {
737
- return callback(null, true);
738
- }
739
- if (this.config.origins && this.config.origins.length > 0) {
740
- if (this.config.origins.includes(origin)) {
741
- return callback(null, true);
742
- }
743
- return callback(new Error(`${origin} Not allowed by CORS`));
744
- }
745
- return callback(null, true);
746
- },
747
- credentials: true
748
- };
749
- this.app.use(cors(corsOptions));
750
- // Provide a consistent and non-500 response when requests are blocked by the CORS allowlist.
751
- this.app.use((err, req, res, next) => {
752
- const message = err instanceof Error ? err.message : '';
753
- if (message.includes('Not allowed by CORS')) {
754
- const isApiRequest = Boolean(req.originalUrl?.startsWith(this.apiBasePath));
755
- if (isApiRequest) {
756
- res.status(403).json({
757
- success: false,
758
- code: 403,
759
- message: 'Origin not allowed by CORS',
760
- data: null,
761
- errors: {}
762
- });
763
- return;
764
- }
765
- res.status(403).send('Origin not allowed by CORS');
766
- return;
767
- }
768
- next(err);
769
- });
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;
770
491
  }
771
492
  installStaticDirs() {
772
493
  const staticDirs = this.config.staticDirs;
@@ -780,12 +501,15 @@ export class ApiServer {
780
501
  continue;
781
502
  }
782
503
  const resolvedMount = mount.startsWith('/') ? mount : `/${mount}`;
783
- 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
+ });
784
509
  }
785
510
  }
786
511
  installPingHandler() {
787
- const path = `${this.apiBasePath}/v1/ping`;
788
- this.app.get(path, (_req, res) => {
512
+ this.fastify.get(`${this.apiBasePath}/v1/ping`, async () => {
789
513
  const payload = {
790
514
  success: true,
791
515
  status: 'ok',
@@ -795,7 +519,7 @@ export class ApiServer {
795
519
  startedAt: this.startedAt,
796
520
  timestamp: new Date().toISOString()
797
521
  };
798
- res.status(200).json({ success: true, code: 200, message: 'Success', data: payload, errors: {} });
522
+ return { success: true, code: 200, message: 'Success', data: payload, errors: {} };
799
523
  });
800
524
  }
801
525
  async loadSwaggerSpec() {
@@ -803,10 +527,11 @@ export class ApiServer {
803
527
  if (typeof __dirname === 'string') {
804
528
  candidates.push(path.resolve(__dirname, '../../docs/swagger/openapi.json'));
805
529
  }
530
+ let packageRoot;
806
531
  try {
807
532
  const require = createRequire(path.join(process.cwd(), 'package.json'));
808
533
  const entry = require.resolve('@technomoron/api-server-base');
809
- const packageRoot = path.resolve(path.dirname(entry), '..', '..');
534
+ packageRoot = path.resolve(path.dirname(entry), '..', '..');
810
535
  candidates.push(path.join(packageRoot, 'docs/swagger/openapi.json'));
811
536
  }
812
537
  catch {
@@ -821,7 +546,13 @@ export class ApiServer {
821
546
  }
822
547
  try {
823
548
  const raw = await readFile(candidate, 'utf8');
824
- 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;
825
556
  }
826
557
  catch {
827
558
  return null;
@@ -829,6 +560,27 @@ export class ApiServer {
829
560
  }
830
561
  return null;
831
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
+ }
832
584
  installSwaggerHandler() {
833
585
  const rawPath = typeof this.config.swaggerPath === 'string' ? this.config.swaggerPath.trim() : '';
834
586
  const enabled = Boolean(this.config.swaggerEnabled) || rawPath.length > 0;
@@ -837,31 +589,35 @@ export class ApiServer {
837
589
  }
838
590
  const base = this.apiBasePath === '/' ? '' : this.apiBasePath;
839
591
  const resolved = rawPath.length > 0 ? rawPath : `${base}/swagger`;
840
- const path = resolved.startsWith('/') ? resolved : `/${resolved}`;
592
+ const routePath = resolved.startsWith('/') ? resolved : `/${resolved}`;
841
593
  let specPromise;
842
- this.app.get(path, async (_req, res) => {
594
+ this.fastify.get(routePath, async (_request, reply) => {
843
595
  if (!specPromise) {
844
596
  specPromise = this.loadSwaggerSpec();
845
597
  }
846
598
  const spec = await specPromise;
847
599
  if (!spec) {
848
- 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 {
849
606
  success: false,
850
607
  code: 500,
851
608
  message: 'Swagger spec is unavailable',
852
609
  data: null,
853
610
  errors: {}
854
- });
855
- return;
611
+ };
856
612
  }
857
- res.status(200).json(spec);
613
+ return spec;
858
614
  });
859
615
  }
860
- normalizeApiBasePath(path) {
861
- if (!path || typeof path !== 'string') {
616
+ normalizeApiBasePath(routePath) {
617
+ if (!routePath || typeof routePath !== 'string') {
862
618
  return '/api';
863
619
  }
864
- const trimmed = path.trim();
620
+ const trimmed = routePath.trim();
865
621
  if (!trimmed) {
866
622
  return '/api';
867
623
  }
@@ -872,47 +628,121 @@ export class ApiServer {
872
628
  return withLeadingSlash.replace(/\/+$/, '') || '/api';
873
629
  }
874
630
  installApiNotFoundHandler() {
875
- if (this.apiNotFoundHandler) {
876
- return;
877
- }
878
- this.apiNotFoundHandler = (req, res) => {
879
- 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({
880
639
  success: false,
881
640
  code: 404,
882
- message: this.describeMissingEndpoint(req),
641
+ message: `No such endpoint: ${method} ${target}`,
883
642
  data: null,
884
643
  errors: {}
885
- };
886
- res.status(404).json(payload);
887
- };
888
- this.app.use(this.apiBasePath, this.apiNotFoundHandler);
644
+ });
645
+ });
889
646
  }
890
647
  installApiErrorHandler() {
891
648
  if (this.apiErrorHandlerInstalled) {
892
649
  return;
893
650
  }
894
651
  this.apiErrorHandlerInstalled = true;
895
- this.app.use(this.apiBasePath, this.expressErrorHandler());
896
- }
897
- describeMissingEndpoint(req) {
898
- const method = typeof req.method === 'string' ? req.method.toUpperCase() : 'GET';
899
- const target = req.originalUrl || req.url || this.apiBasePath;
900
- 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
+ });
901
731
  }
902
732
  start() {
903
733
  if (!this.finalized) {
904
734
  console.warn('[api-server-base] ApiServer.start() called without finalize(); auto-finalizing. Call server.finalize() before start() to silence this warning.');
905
735
  this.finalize();
906
736
  }
907
- this.app
737
+ void this.fastify
908
738
  .listen({
909
739
  port: this.config.apiPort,
910
740
  host: this.config.apiHost
911
741
  })
912
- .on('listening', () => {
742
+ .then(() => {
913
743
  console.log(`Server is running on http://${this.config.apiHost}:${this.config.apiPort}`);
914
744
  })
915
- .on('error', (error) => {
745
+ .catch((error) => {
916
746
  let message;
917
747
  if (error.code === 'EADDRINUSE') {
918
748
  message = `Port ${this.config.apiPort} is already in use.`;
@@ -932,13 +762,17 @@ export class ApiServer {
932
762
  this.config.onStartError(err);
933
763
  return;
934
764
  }
935
- throw err;
765
+ this.logUnhandledError('Server startup failed', err);
766
+ process.exitCode = 1;
936
767
  });
937
768
  return this;
938
769
  }
939
770
  internalServerErrorMessage(error) {
940
771
  return this.config.debug ? this.guessExceptionText(error) : 'Internal server error';
941
772
  }
773
+ logUnhandledError(context, error) {
774
+ console.error(`[ApiServer] ${context}`, error);
775
+ }
942
776
  async verifyJWT(token) {
943
777
  if (!this.config.accessSecret) {
944
778
  return { tokenData: undefined, error: 'JWT authentication disabled; no jwtSecret set', expired: false };
@@ -947,7 +781,7 @@ export class ApiServer {
947
781
  if (!result.success) {
948
782
  return { tokenData: undefined, error: result.error, expired: result.expired };
949
783
  }
950
- if (!result.data.uid) {
784
+ if (result.data.uid == null) {
951
785
  return { tokenData: undefined, error: 'Missing/bad userid in token', expired: false };
952
786
  }
953
787
  return { tokenData: result.data, error: undefined, expired: false };
@@ -990,8 +824,9 @@ export class ApiServer {
990
824
  return null;
991
825
  }
992
826
  const storedUid = String(stored.userId);
993
- const verifyUid = verify.data.uid === undefined || verify.data.uid === null ? null : String(verify.data.uid);
994
- 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) {
995
830
  return null;
996
831
  }
997
832
  const claims = verify.data;
@@ -999,7 +834,6 @@ export class ApiServer {
999
834
  void _exp;
1000
835
  void _iat;
1001
836
  void _nbf;
1002
- // Ensure we never embed token secrets into refreshed access tokens.
1003
837
  delete payload.accessToken;
1004
838
  delete payload.refreshToken;
1005
839
  delete payload.userId;
@@ -1038,7 +872,8 @@ export class ApiServer {
1038
872
  }
1039
873
  let token = null;
1040
874
  const authHeader = apiReq.req.headers.authorization;
1041
- const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7).trim() || null : null;
875
+ const headerValue = Array.isArray(authHeader) ? authHeader[0] : authHeader;
876
+ const bearerToken = headerValue?.startsWith('Bearer ') ? headerValue.slice(7).trim() || null : null;
1042
877
  const paramToken = bearerToken ? null : this.resolveTokenFromRequest(apiReq.req);
1043
878
  const requiresAuthToken = this.requiresAuthToken(authType);
1044
879
  const allowRefresh = requiresAuthToken || (authType === 'maybe' && this.config.refreshMaybe);
@@ -1165,7 +1000,6 @@ export class ApiServer {
1165
1000
  apiReq.token = secret;
1166
1001
  apiReq.apiKey = key;
1167
1002
  apiReq.authMethod = 'apikey';
1168
- // uid is the real identity; euid (if set) is the effective/impersonated identity.
1169
1003
  const resolvedRuid = this.normalizeAuthIdentifier(key.uid);
1170
1004
  const resolvedEuid = key.euid !== undefined ? this.normalizeAuthIdentifier(key.euid) : null;
1171
1005
  const effectiveUid = resolvedEuid ?? resolvedRuid;
@@ -1263,21 +1097,25 @@ export class ApiServer {
1263
1097
  }
1264
1098
  return rawReal;
1265
1099
  }
1266
- useExpress(pathOrHandler, ...handlers) {
1267
- this.assertNotFinalized('useExpress');
1268
- if (typeof pathOrHandler === 'string') {
1269
- const apiPath = this.toApiRouterPath(pathOrHandler);
1270
- if (apiPath) {
1271
- this.apiRouter.use(apiPath, ...handlers);
1272
- }
1273
- else {
1274
- this.app.use(pathOrHandler, ...handlers);
1275
- }
1276
- }
1277
- else {
1278
- this.app.use(pathOrHandler, ...handlers);
1279
- }
1280
- 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
+ };
1281
1119
  }
1282
1120
  createApiRequest(req, res) {
1283
1121
  const apiReq = {
@@ -1288,9 +1126,9 @@ export class ApiServer {
1288
1126
  tokenData: null,
1289
1127
  authMethod: null,
1290
1128
  realUid: null,
1291
- getClientInfo: () => ensureClientInfo(apiReq),
1292
- getClientIp: () => ensureClientInfo(apiReq).ip,
1293
- 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,
1294
1132
  getRealUid: () => apiReq.realUid ?? null,
1295
1133
  isImpersonating: () => {
1296
1134
  const realUid = apiReq.realUid;
@@ -1301,11 +1139,101 @@ export class ApiServer {
1301
1139
  if (tokenUid === null || tokenUid === undefined) {
1302
1140
  return false;
1303
1141
  }
1304
- 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);
1305
1145
  }
1306
1146
  };
1307
1147
  return apiReq;
1308
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
+ }
1309
1237
  expressAuth(auth) {
1310
1238
  return async (req, res, next) => {
1311
1239
  const apiReq = this.createApiRequest(req, res);
@@ -1351,6 +1279,9 @@ export class ApiServer {
1351
1279
  }
1352
1280
  const status = asHttpStatus(error);
1353
1281
  if (status) {
1282
+ if (status >= 500) {
1283
+ this.logUnhandledError(`Unhandled compat middleware error mapped to HTTP ${status}`, error);
1284
+ }
1354
1285
  res.status(status).json({
1355
1286
  success: false,
1356
1287
  code: status,
@@ -1360,19 +1291,20 @@ export class ApiServer {
1360
1291
  });
1361
1292
  return;
1362
1293
  }
1363
- const errorPayload = {
1294
+ this.logUnhandledError('Unhandled compat middleware error fallback to HTTP 500', error);
1295
+ res.status(500).json({
1364
1296
  success: false,
1365
1297
  code: 500,
1366
1298
  message: this.internalServerErrorMessage(error),
1367
1299
  data: null,
1368
1300
  errors: {}
1369
- };
1370
- res.status(500).json(errorPayload);
1301
+ });
1371
1302
  };
1372
1303
  }
1373
- handle_request(handler, auth) {
1374
- return async (req, res, next) => {
1375
- void next;
1304
+ handleRequest(handler, auth) {
1305
+ return async (request, reply) => {
1306
+ const req = this.toExtendedReq(request);
1307
+ const res = new FastifyResponseAdapter(reply);
1376
1308
  const apiReq = this.createApiRequest(req, res);
1377
1309
  try {
1378
1310
  if (this.config.hydrateGetBody) {
@@ -1395,11 +1327,11 @@ export class ApiServer {
1395
1327
  throw new ApiError({ code: 500, message: 'Handler result must start with a numeric status code' });
1396
1328
  }
1397
1329
  const message = typeof rawMessage === 'string' ? rawMessage : 'Success';
1398
- const responsePayload = { success: true, code, message, data, errors: {} };
1330
+ const responsePayload = { success: code < 400, code, message, data, errors: {} };
1399
1331
  if (this.config.debug) {
1400
1332
  this.dumpResponse(apiReq, responsePayload, code);
1401
1333
  }
1402
- res.status(code).json(responsePayload);
1334
+ reply.code(code).send(responsePayload);
1403
1335
  }
1404
1336
  catch (error) {
1405
1337
  if (error instanceof ApiError || isApiErrorLike(error)) {
@@ -1417,27 +1349,53 @@ export class ApiServer {
1417
1349
  if (this.config.debug) {
1418
1350
  this.dumpResponse(apiReq, errorPayload, apiError.code);
1419
1351
  }
1420
- res.status(apiError.code).json(errorPayload);
1352
+ reply.code(apiError.code).send(errorPayload);
1421
1353
  }
1422
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);
1423
1362
  const errorPayload = {
1424
1363
  success: false,
1425
- code: 500,
1426
- 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),
1427
1368
  data: null,
1428
1369
  errors: {}
1429
1370
  };
1430
1371
  if (this.config.debug) {
1431
- this.dumpResponse(apiReq, errorPayload, 500);
1372
+ this.dumpResponse(apiReq, errorPayload, uploadTooLarge ? 413 : status);
1432
1373
  }
1433
- 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);
1434
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);
1435
1394
  }
1436
1395
  };
1437
1396
  }
1438
1397
  api(module) {
1439
1398
  this.assertNotFinalized('api');
1440
- const router = express.Router();
1441
1399
  module.server = this;
1442
1400
  const moduleType = module.moduleType;
1443
1401
  if (moduleType === 'auth') {
@@ -1448,36 +1406,33 @@ export class ApiServer {
1448
1406
  const name = module.constructor?.name ? String(module.constructor.name) : 'ApiModule';
1449
1407
  throw new Error(`${name}.checkConfig() returned false`);
1450
1408
  }
1451
- const base = this.apiBasePath;
1409
+ module.onMount();
1452
1410
  const ns = module.namespace;
1453
- const mountPath = `${base}${ns}`;
1411
+ const mountPath = `${this.apiBasePath}${ns}`;
1454
1412
  module.mountpath = mountPath;
1455
- module.defineRoutes().forEach((r) => {
1456
- const handler = this.handle_request(r.handler, r.auth);
1457
- switch (r.method) {
1458
- case 'get':
1459
- router.get(r.path, handler);
1460
- break;
1461
- case 'post':
1462
- router.post(r.path, handler);
1463
- break;
1464
- case 'put':
1465
- router.put(r.path, handler);
1466
- break;
1467
- case 'patch':
1468
- router.patch(r.path, handler);
1469
- break;
1470
- case 'delete':
1471
- router.delete(r.path, handler);
1472
- break;
1473
- }
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 } : {}) });
1474
1418
  if (this.config.debug) {
1475
- console.log(`Adding ${mountPath}${r.path} (${r.method.toUpperCase()})`);
1419
+ console.log(`Adding ${fullPath} (${method})`);
1476
1420
  }
1477
- });
1478
- this.apiRouter.use(ns, router);
1421
+ }
1479
1422
  return this;
1480
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
+ }
1481
1436
  dumpRequest(apiReq) {
1482
1437
  const req = apiReq.req;
1483
1438
  const tokenParam = this.config.tokenParam.trim();
@@ -1485,10 +1440,7 @@ export class ApiServer {
1485
1440
  const url = req.originalUrl || req.url;
1486
1441
  console.log('URL:', url);
1487
1442
  console.log('Method:', req.method);
1488
- if (tokenParam &&
1489
- req.query &&
1490
- typeof req.query === 'object' &&
1491
- tokenParam in req.query) {
1443
+ if (tokenParam && req.query && tokenParam in req.query) {
1492
1444
  const maskedQuery = { ...req.query, [tokenParam]: '[REDACTED]' };
1493
1445
  console.log('Query Params:', maskedQuery);
1494
1446
  }
@@ -1520,6 +1472,12 @@ export class ApiServer {
1520
1472
  if (headers.authorization) {
1521
1473
  headers.authorization = '[REDACTED]';
1522
1474
  }
1475
+ if (headers['x-api-key']) {
1476
+ headers['x-api-key'] = '[REDACTED]';
1477
+ }
1478
+ if (headers.cookie) {
1479
+ headers.cookie = '[REDACTED]';
1480
+ }
1523
1481
  console.log('Headers:', headers);
1524
1482
  console.log('------------------------');
1525
1483
  }