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