@technomoron/api-server-base 2.0.0-beta.22 → 2.0.0-beta.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/api-module.cjs +8 -0
- package/dist/cjs/api-module.d.ts +12 -0
- package/dist/cjs/api-server-base.cjs +573 -615
- package/dist/cjs/api-server-base.d.ts +97 -87
- package/dist/cjs/auth-api/{auth-module.js → auth-module.cjs} +96 -76
- package/dist/cjs/auth-api/auth-module.d.ts +1 -1
- package/dist/cjs/auth-api/{compat-auth-storage.js → compat-auth-storage.cjs} +4 -4
- package/dist/cjs/auth-api/{mem-auth-store.js → mem-auth-store.cjs} +7 -7
- package/dist/cjs/auth-api/{module.js → module.cjs} +1 -1
- package/dist/cjs/auth-api/schemas.cjs +171 -0
- package/dist/cjs/auth-api/schemas.d.ts +21 -0
- package/dist/cjs/auth-api/{sql-auth-store.js → sql-auth-store.cjs} +8 -8
- package/dist/cjs/auth-api/{user-id.js → user-id.cjs} +12 -3
- package/dist/cjs/auth-cookie-options.d.ts +5 -3
- package/dist/cjs/base/client-info.cjs +285 -0
- package/dist/cjs/base/client-info.d.ts +27 -0
- package/dist/cjs/base/error-utils.cjs +50 -0
- package/dist/cjs/base/error-utils.d.ts +16 -0
- package/dist/cjs/base/request-utils.cjs +27 -0
- package/dist/cjs/base/request-utils.d.ts +8 -0
- package/dist/cjs/index.cjs +24 -15
- package/dist/cjs/index.d.ts +7 -0
- package/dist/cjs/limiter/auth-rate-limiter.cjs +35 -0
- package/dist/cjs/limiter/auth-rate-limiter.d.ts +12 -0
- package/dist/cjs/limiter/fixed-window.cjs +41 -0
- package/dist/cjs/limiter/fixed-window.d.ts +11 -0
- package/dist/cjs/oauth/{base.js → base.cjs} +1 -0
- package/dist/cjs/oauth/base.d.ts +8 -1
- package/dist/cjs/oauth/{memory.js → memory.cjs} +7 -4
- package/dist/cjs/oauth/memory.d.ts +1 -1
- package/dist/cjs/oauth/{models.js → models.cjs} +2 -2
- package/dist/cjs/oauth/{sequelize.js → sequelize.cjs} +11 -7
- package/dist/cjs/oauth/sequelize.d.ts +1 -1
- package/dist/cjs/passkey/{base.js → base.cjs} +1 -0
- package/dist/cjs/passkey/base.d.ts +11 -0
- package/dist/cjs/passkey/{memory.js → memory.cjs} +2 -2
- package/dist/cjs/passkey/{models.js → models.cjs} +1 -1
- package/dist/cjs/passkey/{sequelize.js → sequelize.cjs} +3 -3
- package/dist/cjs/passkey/{service.js → service.cjs} +17 -3
- package/dist/cjs/passkey/service.d.ts +1 -1
- package/dist/cjs/{sequelize-utils.js → sequelize-utils.cjs} +4 -5
- package/dist/cjs/token/{base.js → base.cjs} +4 -0
- package/dist/cjs/token/base.d.ts +7 -0
- package/dist/cjs/token/{memory.js → memory.cjs} +15 -20
- package/dist/cjs/token/{sequelize.js → sequelize.cjs} +25 -11
- package/dist/cjs/upload/memory.cjs +92 -0
- package/dist/cjs/upload/memory.d.ts +17 -0
- package/dist/cjs/upload/tus-module.cjs +270 -0
- package/dist/cjs/upload/tus-module.d.ts +38 -0
- package/dist/cjs/upload/types.d.ts +28 -0
- package/dist/cjs/user/{base.js → base.cjs} +1 -0
- package/dist/cjs/user/base.d.ts +9 -0
- package/dist/cjs/user/{memory.js → memory.cjs} +29 -7
- package/dist/cjs/user/{sequelize.js → sequelize.cjs} +33 -8
- package/dist/cjs/user/types.cjs +2 -0
- package/dist/esm/api-module.d.ts +12 -0
- package/dist/esm/api-module.js +8 -0
- package/dist/esm/api-server-base.d.ts +97 -87
- package/dist/esm/api-server-base.js +562 -604
- package/dist/esm/auth-api/auth-module.d.ts +1 -1
- package/dist/esm/auth-api/auth-module.js +92 -72
- package/dist/esm/auth-api/compat-auth-storage.js +3 -3
- package/dist/esm/auth-api/schemas.d.ts +21 -0
- package/dist/esm/auth-api/schemas.js +168 -0
- package/dist/esm/auth-api/user-id.js +12 -3
- package/dist/esm/auth-cookie-options.d.ts +5 -3
- package/dist/esm/base/client-info.d.ts +27 -0
- package/dist/esm/base/client-info.js +282 -0
- package/dist/esm/base/error-utils.d.ts +16 -0
- package/dist/esm/base/error-utils.js +44 -0
- package/dist/esm/base/request-utils.d.ts +8 -0
- package/dist/esm/base/request-utils.js +23 -0
- package/dist/esm/index.d.ts +7 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/limiter/auth-rate-limiter.d.ts +12 -0
- package/dist/esm/limiter/auth-rate-limiter.js +32 -0
- package/dist/esm/limiter/fixed-window.d.ts +11 -0
- package/dist/esm/limiter/fixed-window.js +37 -0
- package/dist/esm/oauth/base.d.ts +8 -1
- package/dist/esm/oauth/base.js +1 -0
- package/dist/esm/oauth/memory.d.ts +1 -1
- package/dist/esm/oauth/memory.js +5 -2
- package/dist/esm/oauth/sequelize.d.ts +1 -1
- package/dist/esm/oauth/sequelize.js +6 -2
- package/dist/esm/passkey/base.d.ts +11 -0
- package/dist/esm/passkey/base.js +1 -0
- package/dist/esm/passkey/service.d.ts +1 -1
- package/dist/esm/passkey/service.js +17 -3
- package/dist/esm/sequelize-utils.js +4 -5
- package/dist/esm/token/base.d.ts +7 -0
- package/dist/esm/token/base.js +4 -0
- package/dist/esm/token/memory.js +14 -19
- package/dist/esm/token/sequelize.js +22 -8
- package/dist/esm/upload/memory.d.ts +17 -0
- package/dist/esm/upload/memory.js +86 -0
- package/dist/esm/upload/tus-module.d.ts +38 -0
- package/dist/esm/upload/tus-module.js +266 -0
- package/dist/esm/upload/types.d.ts +28 -0
- package/dist/esm/upload/types.js +1 -0
- package/dist/esm/user/base.d.ts +9 -0
- package/dist/esm/user/base.js +1 -0
- package/dist/esm/user/memory.js +27 -5
- package/dist/esm/user/sequelize.js +30 -5
- package/docs/swagger/openapi.json +1 -1
- package/package.json +18 -17
- package/README.txt +0 -216
- /package/dist/cjs/auth-api/{storage.js → storage.cjs} +0 -0
- /package/dist/cjs/auth-api/{types.js → types.cjs} +0 -0
- /package/dist/cjs/{auth-cookie-options.js → auth-cookie-options.cjs} +0 -0
- /package/dist/cjs/oauth/{types.js → types.cjs} +0 -0
- /package/dist/cjs/passkey/{config.js → config.cjs} +0 -0
- /package/dist/cjs/passkey/{types.js → types.cjs} +0 -0
- /package/dist/cjs/token/{types.js → types.cjs} +0 -0
- /package/dist/cjs/{user/types.js → upload/types.cjs} +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
|
|
12
|
-
import cors from 'cors';
|
|
13
|
-
import
|
|
14
|
-
import
|
|
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 =
|
|
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
|
-
|
|
385
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
473
|
-
if (
|
|
474
|
-
return
|
|
475
|
-
}
|
|
476
|
-
const trimmed = candidate.trim();
|
|
477
|
-
if (!trimmed) {
|
|
478
|
-
return null;
|
|
257
|
+
parseRawBody(raw, contentType) {
|
|
258
|
+
if (!raw) {
|
|
259
|
+
return {};
|
|
479
260
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
261
|
+
if (contentType.includes('application/json')) {
|
|
262
|
+
try {
|
|
263
|
+
return JSON.parse(raw);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return {};
|
|
267
|
+
}
|
|
484
268
|
}
|
|
485
|
-
if (
|
|
486
|
-
|
|
269
|
+
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
270
|
+
const params = new URLSearchParams(raw);
|
|
271
|
+
return Object.fromEntries(params.entries());
|
|
487
272
|
}
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
537
|
-
if (
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
327
|
+
return this.storageAdapter.getUser(identifier);
|
|
596
328
|
}
|
|
597
329
|
getUserPasswordHash(user) {
|
|
598
|
-
return this.
|
|
330
|
+
return this.storageAdapter.getUserPasswordHash(user);
|
|
599
331
|
}
|
|
600
332
|
getUserId(user) {
|
|
601
|
-
return this.
|
|
333
|
+
return this.storageAdapter.getUserId(user);
|
|
602
334
|
}
|
|
603
335
|
filterUser(user) {
|
|
604
|
-
return this.
|
|
336
|
+
return this.storageAdapter.filterUser(user);
|
|
605
337
|
}
|
|
606
338
|
async verifyPassword(password, hash) {
|
|
607
|
-
return this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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.
|
|
686
|
-
return !!(await this.
|
|
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
|
|
467
|
+
return guessExceptionText(error, defMsg);
|
|
726
468
|
}
|
|
727
469
|
async authorize(apiReq, requiredClass) {
|
|
728
470
|
void apiReq;
|
|
729
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
592
|
+
const routePath = resolved.startsWith('/') ? resolved : `/${resolved}`;
|
|
841
593
|
let specPromise;
|
|
842
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
613
|
+
return spec;
|
|
858
614
|
});
|
|
859
615
|
}
|
|
860
|
-
normalizeApiBasePath(
|
|
861
|
-
if (!
|
|
616
|
+
normalizeApiBasePath(routePath) {
|
|
617
|
+
if (!routePath || typeof routePath !== 'string') {
|
|
862
618
|
return '/api';
|
|
863
619
|
}
|
|
864
|
-
const trimmed =
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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:
|
|
641
|
+
message: `No such endpoint: ${method} ${target}`,
|
|
883
642
|
data: null,
|
|
884
643
|
errors: {}
|
|
885
|
-
};
|
|
886
|
-
|
|
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.
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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.
|
|
737
|
+
void this.fastify
|
|
908
738
|
.listen({
|
|
909
739
|
port: this.config.apiPort,
|
|
910
740
|
host: this.config.apiHost
|
|
911
741
|
})
|
|
912
|
-
.
|
|
742
|
+
.then(() => {
|
|
913
743
|
console.log(`Server is running on http://${this.config.apiHost}:${this.config.apiPort}`);
|
|
914
744
|
})
|
|
915
|
-
.
|
|
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
|
-
|
|
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 (
|
|
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
|
|
994
|
-
|
|
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
|
|
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
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1374
|
-
return async (
|
|
1375
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1426
|
-
message:
|
|
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,
|
|
1372
|
+
this.dumpResponse(apiReq, errorPayload, uploadTooLarge ? 413 : status);
|
|
1432
1373
|
}
|
|
1433
|
-
|
|
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
|
-
|
|
1409
|
+
module.onMount();
|
|
1452
1410
|
const ns = module.namespace;
|
|
1453
|
-
const mountPath = `${
|
|
1411
|
+
const mountPath = `${this.apiBasePath}${ns}`;
|
|
1454
1412
|
module.mountpath = mountPath;
|
|
1455
|
-
module.defineRoutes()
|
|
1456
|
-
const handler = this.
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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 ${
|
|
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
|
}
|